HTTP/2 持续洪水:技术细节
HTTP/2 Continuation Flood: Technical Details

原始链接: https://nowotarski.info/http2-continuation-flood-technical-details/

本文讨论 HTTP/2 中名为快速重置攻击的安全漏洞。 作者于 2023 年 10 月了解到,最初对 HTTP/2 的了解有限。 他们决定从安全角度探索 HTTP/2。 HTTP/2 是一种二进制协议,客户端和服务器在其中交换帧而不是文本行。 与 HTTP/1.1 相比,一个关键的区别是使用了诸如 HEADERS 和 CONTINUATION 之类的框架。 HEADERS 帧允许发送请求和响应的 HTTP 标头。 这些标头存储在字段块片段中,并使用 HPACK 编码算法进行压缩。 当发送多个 HEader 帧时,第二个帧会发送 END_HEADERS 标志,除非前一帧已包含该标志。 当单个 HEADER 帧无法容纳所有标头时,这会产生潜在的漏洞。 相反,发送方使用一系列标头和延续帧。 然而,一些 HTTP/2 实现并没有有效解决 HTTP/1.1 中针对无限标头的保护机制。 如果对标头大小或超时没有足够的限制,攻击者可以发送标头达到容量,从而导致 CPU 使用率过高或内存不足崩溃。 尽管如此,即使在主要的 HTTP/2 服务器(例如 Apache httpd、Envoy 和众多 HTTP/2 软件包或编解码器)中,该漏洞也没有得到广泛解决。 因此,根据具体实施和条件,出现了几种结果。 其中包括:CPU 耗尽、使用多个连接的内存不足崩溃以及使用单个连接的内存不足崩溃。 此外,如果攻击者在没有设置 END_HEADERS 标志的情况下设法发送恶意标头,服务器将无法将这些请求保存到其访问日志中,从而给管理员尝试识别和解决问题带来困难。 现实世界的示例包括涉及 Golang 的 HPACK 解码器的 CPU 耗尽案例,尽管由于不正确的实现逻辑而达到了大小限制,但该解码器仍继续解码标头。 此外,当析构函数尝试在解析器接收 CONTINUATION 帧的同时删除 nghttp2 工件时,Node.js 中会出现可达断言崩溃。 以前的 HTTP/2 漏洞包括 CVE-2019-9516,称为“0 长度标头泄漏”,以及

本文以 Kubernetes 为例讨论了对现代 Web 技术复杂性的担忧,以及考虑典型场景之外的潜在威胁的重要性。 它认为,虽然像 HTTP/1.1 这样的旧协议可能看起来更简单,但它们实际上隐藏着复杂性和漏洞。 HTTP/2 引入了新功能,导致需要存储的有状态连接并增加了管理挑战。 由于这些新功能,不同类型连接的保护措施也有所不同。 然而,实施者必须确保在采用新协议之前彻底理解和测试。 HTTP/1.1 没有问题或者更容易实现的假设并不准确。 较新的 HTTP 规范提供了必要的指导,以避免常见错误。 由于不同的属性,请求或响应的不同部分具有不明确的大小,即使在看似简单的 HTTP 设置中,这些因素也可能导致请求走私风险。 最终,无论底层技术如何,可靠且经过广泛测试的解决方案对于确保安全高效的运营仍然至关重要。 虽然像 COBOL 这样的系统中的问题通常源于管理不善,而不是固有的技术限制,但对强大解决方案的需求是普遍存在的。 做得好,分析详细,解释清楚!
相关文章

原文

In October 2023 I learnt about HTTP/2 Rapid Reset attack, dubbed “the largest DDoS attack to date”. I didn’t have deep knowledge of HTTP/2 back then. I knew it’s basics like frames or HPACK but I was focusing more on HTTP/1.1 protocol and programming languages vulnerabilities. I decided to dedicate time to exploring HTTP/2 from a security analysis perspective after concluding my then-current research.

The main difference between HTTP/1.1 and HTTP/2 is that the later is a binary protocol and client and server exchange frames instead of text lines. There are many frame types, including some control frames that does not transmit data but rather allow configuration of an HTTP/2 session (like SETTINGS or WINDOW_UPDATE). To make this vulnerability easy to understand I need to present two frames: HEADERS frame and CONTINUATION frame. For those who would like to catch up, the best way to learn it is by reading RFC9204.

HEADERS frames allow sending HTTP headers of, both, request and response. The headers are stored in field block fragment and are encoded using HPACK, encoding algorithm that allows compression of header data. It is using static and dynamic tables of commonly used headers and Huffman encoding for the rest of headers. As other frames, this one can have some flags set, along them:

  • END_HEADERS: when set, tells the counterparty that this frame contains all the headers they wanted to send,
  • END_STREAM: when set, tells the counterparty that there will be no request/response body.

The frames also have a maximum size, configured at the beginning of communication. If a received frame exceeds the allowed size the connection is dropped with a protocol error. So what happens if a single HEADER frame is not enough to store all the headers? It sends the frame with END_HEADERS flag unset and continues the stream of headers using CONTINUATION frame.

CONTINUATION frame

CONTINUATION frames are very similar to HEADER frames but they have just one flag: END_HEADERS which has the same function: when set the counterparty knows that more headers are coming in the following CONTINUATION frames.

To sum it up, if headers exceed a single frame allowed size they are split in a frame stream:

  • HEADERS (no END_HEADERS flag),
  • CONTINUATION (no flags),
  • CONTINUATION (no flags),
  • CONTINUATION (END_HEADERS set),

After the last frame, either DATA frame is sent (contains request data) or HTTP/2 stream ends.

What if a client starts a new HTTP/2 stream and sends HEADERS and CONTINUATION frames but END_HEADERS flag is never set? This would create an infinite stream of headers that HTTP/2 server would need to parse and store in memory.

In HTTP/1.1 world, servers are protected from infinite headers by two mechanisms:

  • Header size limit: if headers list exceed the allowed size, the connection is dropped.
  • Request / headers timeouts: if the request/headers are not sent in timely manner, the connection is dropped.

In the last couple of months I checked dozens of implementations and, somehow, these protections were not implemented (or implemented incorrectly) even in major HTTP/2 servers, most notably: Apache httpd, Envoy and many HTTP/2 packages or codecs. I can divide outcomes of the bugs related to this vulnerability in the following categories:

  • CPU exhaustion. Reading the extra headers cause increased CPU usage, it results in slowness responding to other requests but in many cases it was just a matter of number of active HTTP/2 connections that are required to completely block the server from responding.
  • Out Of Memory crash using multiple connections. Headers from CONTINUATION frames are stored in memory but there is a headers list size limit. At the same time there is no headers timeout. This means that an attacker can send headers up to the limit and just wait. Each connection occupies memory indefinietly.
  • Out Of Memory crash using a single connection. Some implementations simply kept reading headers into memory until memory was full which forced the OS to kill the process.
  • Crash after a few frames sent. This is a special, most severe category. Just a few frames are required to crash the server because of implementation bugs when a connection is disconnected mid-CONTINUATION stream.

No END_HEADERS flag means that a request is not properly closed. Requests of malicious client would not be saved to access log making this attack hard to debug: in many cases analyzing raw traffic bytes would be necessary to understand the nature of this vulnerability.

In next sections I will demonstrate cases above using real vulnerabilities found in production code.

CPU exhaustion: Golang case

Golang was an example of the CPU exhaustion caused by CONTINUATION Flood. As many other implementations, Golang devs built an abstraction class called http2MetaHeadersFrame which encapsulates one HEADERS frame and zero or more CONTINUATION frames, and their HPACK decoder. Headers data is processed within a single call to readMetaFrame (h2_bundle.go from Go 1.21.3).

The HPACK decoder in Golang has multiple params and modes. One of them is SetEmitEnabled which enables or disables emitting of decoded headers. This is done to stop headers emission in case of errors or when the header size limit is reached.

2926func (fr *http2Framer) readMetaFrame(hf *http2HeadersFrame) (*http2MetaHeadersFrame, error) {
2927	if fr.AllowIllegalReads {
2928		return nil, errors.New("illegal use of AllowIllegalReads with ReadMetaHeaders")
2929	}
2930	mh := &http2MetaHeadersFrame{
2931		http2HeadersFrame: hf,
2932	}
2933	var remainSize = fr.maxHeaderListSize()
2934	var sawRegular bool
2935
2936	var invalid error // pseudo header field errors
2937	hdec := fr.ReadMetaHeaders
2938	hdec.SetEmitEnabled(true)
2939	hdec.SetMaxStringLength(fr.maxHeaderStringLen())

Indeed, in the SetEmitFunc callback function there’s a logic that checks if the allowed size (maxHeaderListSize) has been reached and in this case SetEmitEnabled(false) is called.

2940	hdec.SetEmitFunc(func(hf hpack.HeaderField) {
2941		if http2VerboseLogs && fr.logReads {
2942			fr.debugReadLoggerf("http2: decoded hpack field %+v", hf)
2943		}
2944		if !httpguts.ValidHeaderFieldValue(hf.Value) {
2945			// Don't include the value in the error, because it may be sensitive.
2946			invalid = http2headerFieldValueError(hf.Name)
2947		}
2948		isPseudo := strings.HasPrefix(hf.Name, ":")
2949		if isPseudo {
2950			if sawRegular {
2951				invalid = http2errPseudoAfterRegular
2952			}
2953		} else {
2954			sawRegular = true
2955			if !http2validWireHeaderFieldName(hf.Name) {
2956				invalid = http2headerFieldNameError(hf.Name)
2957			}
2958		}
2959
2960		if invalid != nil {
2961			hdec.SetEmitEnabled(false)
2962			return
2963		}
2964
2965		size := hf.Size()
2966		if size > remainSize {
2967			hdec.SetEmitEnabled(false)
2968			mh.Truncated = true
2969			return
2970		}
2971		remainSize -= size
2972
2973		mh.Fields = append(mh.Fields, hf)
2974	})
2975	// Lose reference to MetaHeadersFrame:
2976	defer hdec.SetEmitFunc(func(hf hpack.HeaderField) {})
2977     

The following part of this function is responsible for actually feeding the HPACK decoder and it does so until HeadersEnded() returns true which happens only when END_HEADERS flag is set.

2978	var hc http2headersOrContinuation = hf
2979	for {
2980		frag := hc.HeaderBlockFragment()
2981		if _, err := hdec.Write(frag); err != nil {
2982			return nil, http2ConnectionError(http2ErrCodeCompression)
2983		}
2984
2985		if hc.HeadersEnded() {
2986			break
2987		}
2988		if f, err := fr.ReadFrame(); err != nil {
2989			return nil, err
2990		} else {
2991			hc = f.(*http2ContinuationFrame) // guaranteed by checkFrameOrder
2992		}
2993	}
2994// <rest of this function irrelevant>

The vulnerability may not be clear at the first sight: the decoder is properly configured to stop emitting headers once limit is reached. However, it will still decode headers written to it, just without emiting them. Given that the feeding loop above can only be stopped by END_HEADERS flag or an error from ReadFrame an attacker fully controls how long the HPACK decoder will receive new bytes. With no END_HEADERS, this function will never return and HPACK will keep decoding headers as long as the attacker sends them.

Out of Memory

Out of Memory are probably the most boring yet severe cases. There is nothing special about it: no strange logic, no interesting race condition and so on. The implementations that allow OOM simply did not limit the size of headers list built using CONTINUATION frames. Implementations without header timeout required just a single HTTP/2 connection to crash the server.

In implementations with idle timeout it was often possible to send multiple HTTP/2 connections that occupied portions of RAM very close to the allowed per-connection limit and then sending the last CONTINUATION frame byte-by-byte every few seconds to keep the connection alive.

Reachable Assertion crash: Node.js (special) case

The last case I’d like to present is a Reachable Assertion connected to CONTINUATION frames in Node.js. While it properly handles the infinite stream of CONTINUATION frames there was a data race bug occuring when connection was disconnected during the headers stream.

When I was running the exploit code I noticed that sometimes Node.js crashed with the following stack:

  #  node[3253]: virtual node::http2::Http2Session::~Http2Session() at ../src/node_http2.cc:534
  #  Assertion failed: (current_nghttp2_memory_) == (0)

----- Native stack trace -----

 1: 0xca5430 node::Abort() [node]
 2: 0xca54b0 node::errors::SetPrepareStackTraceCallback(v8::FunctionCallbackInfo<v8::Value> const&) [node]
 3: 0xce7156 node::http2::Http2Session::~Http2Session() [node]
 4: 0xce7192 node::http2::Http2Session::~Http2Session() [node]
 5: 0x106f01d v8::internal::GlobalHandles::InvokeFirstPassWeakCallbacks() [node]
 6: 0x10f3215 v8::internal::Heap::PerformGarbageCollection(v8::internal::GarbageCollector, v8::internal::GarbageCollectionReason, char const*) [node]
 7: 0x10f3d7c v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [node]
 8: 0x10ca081 v8::internal::HeapAllocator::AllocateRawWithLightRetrySlowPath(int, v8::internal::AllocationType, v8::internal::AllocationOrigin, v8::internal::AllocationAlignment) [node]
 9: 0x10cb215 v8::internal::HeapAllocator::AllocateRawWithRetryOrFailSlowPath(int, v8::internal::AllocationType, v8::internal::AllocationOrigin, v8::internal::AllocationAlignment) [node]
10: 0x10a8866 v8::internal::Factory::NewFillerObject(int, v8::internal::AllocationAlignment, v8::internal::AllocationType, v8::internal::AllocationOrigin) [node]
11: 0x15035f6 v8::internal::Runtime_AllocateInYoungGeneration(int, unsigned long*, v8::internal::Isolate*) [node]
12: 0x7f41df699ef6 
Aborted (core dumped)

After several retries I correlated this crash to the exact moment when my HTTP/2 client disconnects from the Node.js server. This made sense because the assert() call was inside the Http2Session destructor. Let’s take a look at the code (Node.js v21.5.0):

528Http2Session::~Http2Session() {
529  CHECK(!is_in_scope());
530  Debug(this, "freeing nghttp2 session");
531  // Explicitly reset session_ so the subsequent
532  // current_nghttp2_memory_ check passes.
533  session_.reset();
534  CHECK_EQ(current_nghttp2_memory_, 0);
535}

Node.js is embedding nghttp2 library for HTTP/2 connections handling. current_nghttp2_memory_ is used to track memory allocated by nghttp2 internals and the assertion in the destructor simply ensures that all nghttp2 artifacts are properly removed from memory which happens in reset method (line 533). The value is updated: increased and decreased in several places in the code, often in a nghttp2 callback functions.

There was no other option than to check nghttp2 internals and see who is to blame: Node.js by incorrectly calculating the memory usage, or nghttp2 by giving invalid data used in calculations. After quite a long investigation I couldn’t find anything wrong with calculations which pointed to a possibility of a race condition: current_nghttp2_memory_ value was updated elsewhere, at the same time when ~Http2Session was being executed.

I found an instance of this case: reset() and nghttp2 callback when CONTINUATION frame is parsed are executed together. When CONTINUATION frame arrives the following chain on events occur when state machine is in NGHTTP2_IB_EXPECT_CONTINUATION state:

  • we go the happy path so state is changed to NGHTTP2_IB_READ_HEADER_BLOCK, from there it calls:
  • session_after_header_block_received which calls:
  • session_call_on_frame_received which calls:
  • on_frame_recv_callback.

The last one calls OnFrameReceive callback in Node.js and later: HandleHeadersFrame which does some memory counter update:

1454  DecrementCurrentSessionMemory(stream->current_headers_length_);
1455  stream->current_headers_length_ = 0;
746  void DecrementCurrentSessionMemory(uint64_t amount) {
747    DCHECK_LE(amount, current_session_memory_);
748    current_session_memory_ -= amount;
749  }

Now, when HandleHeadersFrame and Http2Session::~Http2Session() are executed at the same time it’s possible that they will update the current_session_memory_ variable at the same time:

Http2Session::~Http2Session()

529CHECK(!is_in_scope());
530Debug(this, "freeing nghttp2 session");
531// Explicitly reset session_ so the subsequent
532// current_nghttp2_memory_ check passes.
533session_.reset();
DecrementCurrentSessionMemory:
747DCHECK_LE(amount, current_session_memory_);
748current_session_memory_ -= amount;
Http2Session::~Http2Session()
534CHECK_EQ(current_nghttp2_memory_, 0);

This is why CHECK_EQ fails as current_nghttp2_memory_ value is negative.

There were a couple of HTTP/2 vulnerabilities in the past. In 2019, a batch of them was reported by Netflix and Google. They are listed in CERT/CC Vulnerability Note VU#605641 and the most similar are:

CVE-2019-9516, also known as 0-Length Headers Leak

The attacker sends a stream of headers with a 0-length header name and 0-length header value, optionally Huffman encoded into 1-byte or greater headers. Some implementations allocate memory for these headers and keep the allocation alive until the session dies. This can consume excess memory, potentially leading to a denial of service.

CONTINUATION Flood is different than CVE-2019-9516 because rather than sending empty headers, an attacker sends many random headers up to the frame size limit configured by the server.

CVE-2019-9518, also known as Empty Frame Flooding

The attacker sends a stream of frames with an empty payload and without the end-of-stream flag. These frames can be DATA, HEADERS, CONTINUATION and/or PUSH_PROMISE. The peer spends time processing each frame disproportionate to attack bandwidth. This can consume excess CPU, potentially leading to a denial of service.

CVE-2019-9518 is about sending empty frames. The CONTINUATION Flood consists of the largest possible frames that occupy memory and consume CPU cycles while being decoded.

In October 2023 the details of “Rapid Reset”, a zero day in HTTP/2 protocol, were published and the vulnerability was immedietly dubbed “the largest DDoS attack to date”. The details of this attack are explained in Cloudflare’s article, and while the severity of this vulnerability is different across many implementations, I think it’s important to list main points explaining why the new vulnerability seems to be more severe:

  • Rapid Reset used a combination of HEADERS (with END_STREAM and END_HEADERS flags set) and RST_STREAM frames which means that standard mitigations like rate limiting could at least limit the damage. Also, the server admin would see a lot of inbound server requests and be alerted. During CONTINUATION Flood attack not a single request is made (no END_HEADERS flag)! Admins do not see any requests in the logs!
  • In many implementations just one TCP connection was enough to crash the server (and in some cases with a very small amount of data sent) during the CONTINUATION Flood attack. On contrary, Rapid Reset was used in DDoS attacks (in most cases using a botnet was required for an attack to be successful).

According to Cloudflare Radar the HTTP/2 traffic accounts for around 60% of all human HTTP traffic (data excluding bots):

HTTP/2 distribution stats

Given that Cloudflare Radar estimates HTTP traffic data above 70% of all internet transfer and significance of affected projects I believe that we can assume that large part of internet was affected by an easy-to-exploit vulnerability: in many cases just a single TCP connection was enough to crash the server. Don’t forget that HTTP runs not only websites but significant portion of APIs (RESTful APIs). Availability issues with important business and government APIs and websites could incur losses of millions of dollar. Or cause chaos, for example: Poland, the main supplier of heavy weapons to Ukraine which also operates the most important military hub near Ukraine border, experiences increase in DDoS attacks originating from Russia.

HTTP/2 in total traffic

Had it been exploited in the wild, this would have been very hard to debug without proper HTTP/2 knowledge by the server administrators. This is due to the fact that none of malicious HTTP requests connected to this vulnerability is properly closed. The requests would not be visible in the server access logs and due to lack of advanced frame analytics in most of HTTP/2 servers this would have to be handled by manual, tedious raw connection data analysis.

This vulnerability class posed a significant risk to the internet safety! Because of this I am glad that CERT/CC decided to open a Vulnerability Coordination case to track this issue after I reported it in January 2024. Working on a responsible disclosure of this vulnerability with technology giants and open source projects was a great experience. It would be impossible to check so many implementations by a single resesearcher so Vulnerability Coordination is irreplaceable tool for handling issues that affect multiple vendors. Other than opening the case, CERT/CC decided to publish a Vulnerability Note about this issue, which is quite rare: only a few notes are published each year. Thank you to Christopher Cullen for handling the issue on CERT/CC side.

联系我们 contact @ memedata.com