如何触发Chrome的ERR_HTTP2_FRAME_SIZE_ERROR
本文的最终目的是构造一个支持HTTP2的webserver, 当使用Chrome访问的时候触发ERR_HTTP2_FRAME_SIZE_ERROR,这比单纯写“对”代码更复杂。
日常使用的工具基本都是高层的语言、库,目的是为了简化开发者的使用体验,这些语言、库经过长时间验证,会自动处理各种异常,尽量避免让开发者出错。但这可能会让我们天然的对某些知识了解不够深入,例如写一个RST攻击Demo所需要的网络知识,就比单纯的能写基于socket的C/S通信要更深入、能写一个让JVM崩溃的Java代码就比单纯会写Java要了解的东西更多等等。
ERR_HTTP2_FRAME_SIZE_ERROR
这个错误可以从Chrome的dev tools中的Network或者Console中看到,从名字上可以看出是和HTTP2协议有关的错误,Frame是HTTP2中专属的概念,是Http2通信中的最小单位。
Chrome有一个记录更详细日志的机制:chrome://net-export/,记录下来的JSON文件可以通过这里查看
HTTP2
继续深入这个错误就不得不了解一些HTTP2的基础知识。推荐阅读以下两篇文章了解基础概念:
https://imququ.com/post/http2-and-wpo-2.html
https://developers.google.com/web/fundamentals/performance/http2
如果需要了解更多细节,请直接阅读RFC7540和RFC7541分别了解HTTP2和HPACK:
https://httpwg.org/specs/rfc7540.html
https://httpwg.org/specs/rfc7541.html
最基本的概念如下:
- 帧(Frame):HTTP/2 数据通信的最小单位。帧用来承载特定类型的数据,如 HTTP 首部、负荷;或者用来实现特定功能,例如打开、关闭流。每个帧都包含帧首部,其中会标识出当前帧所属的流;
- 消息(Message):指 HTTP/2 中逻辑上的 HTTP 消息。例如请求和响应等,消息由一个或多个帧组成;
- 流(Stream):存在于连接中的一个虚拟通道。流可以承载双向消息,每个流都有一个唯一的整数 ID;
- 连接(Connection):与 HTTP/1 相同,都是指对应的 TCP 连接;

通过查看Chrome netlog可以看到在某个时间点,同时有多个Request出现了ERR_HTTP2_FRAME_SIZE_ERROR,关于Frame size, 在RFC7540中看到这样的描述:https://httpwg.org/specs/rfc7540.html#FrameSize
The size of a frame payload is limited by the maximum size that a receiver advertises in the SETTINGS_MAX_FRAME_SIZE setting. This setting can have any value between 1<<14 (16,384) and 1<<24 - 1 (16,777,215) octets, inclusive....
An endpoint MUST send an error code of FRAME_SIZE_ERROR if a frame exceeds the size defined in SETTINGS_MAX_FRAME_SIZE, exceeds any limit defined for the frame type, or is too small to contain mandatory frame data. A frame size error in a frame that could alter the state of the entire connection MUST be treated as a connection error (Section 5.4.1); this includes any frame carrying a header block (Section 4.3) (that is, HEADERS, PUSH_PROMISE, and CONTINUATION), SETTINGS, and any frame with a stream identifier of 0.
首先,Frame size是有限制的,是一个范围,最小2^14,最大2^24 - 1,为什么是这个范围?其实是和Frame格式有关,HTTP2是一个二进制协议,这是和HTTP1最大的不一样,你无法直接看到HTTP2协议的内容,因为全是字节,需要decode后才能看到格式化后的内容,所有这种二进制协议都需要有办法能让工具decode,常见的做法可能是通过特定的字节序列作为分隔符、固定长度、从数据本身提取出长度等等。
+-----------------------------------------------+
| Length (24) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+-------------------------------+
|R| Stream Identifier (31) |
+=+=============================================================+
| Frame Payload (0...) ...
+---------------------------------------------------------------+
HTTP2的Frame是由固定9 octets(字节)长度的header, 以及变长的payload组成。header中前三个字节表示payload长度,接下来一个字节是type, 一个字节是flag, 最后四个字节中有1位保留,31位表示stream id. 所以Frame size受三个字节的限制,只能表示那么大。
RFC中提到如果Frame size超过了SETTINGS_MAX_FRAME_SIZE,收到这个Frame的一方需要报告FRAME_SIZE_ERROR,并且会导致Connection error, 会使得在这个connection中的request都失败,这和从netlog观察到的状况一致。
SETTINGS_MAX_FRAME_SIZE是为数不多的Settings中的一个,有一种type是setting的frame专门设置这些参数,一般是在刚刚建立起来连接之后,客户端会通知服务端一些setting, 而服务端可以再通知客户端一些配置,最终双方对setting的设置是一致的。
对于max frame size设置,如果双方都没明确通知的话,会使用1^14作为初始值。
最后,我从Chromium的代码中看到对RFC准确的实现,发现错误就通知对方:https://source.chromium.org/chromium/chromium/src/+/main:net/third_party/quiche/src/spdy/core/http2_frame_decoder_adapter.cc;l=777;drc=6efee78914660d0249e01aa403579476d8dc6a20,我对这段代码有个疑惑,780行应该在776行的位置,还是说要按照RFC实现,发现size不对就上报?
实现1
了解了上面的知识后,对于触发错误的原理我们已经很清楚了——只需要让frame header中记录payload的size大于max frame size setting就可以了,但最麻烦的地方在于如让某个frame的size超过最大值?
我一开始选择了nghttp2
这个库帮助实现,通过它可以实现HTTP2客户端和服务端,客户端是一个命令行版的调试HTTP2的工具,可以输入详细的通信日志,但和Chrome netlog相比还差一些,大约是这样:
[ 0.286] Connected
The negotiated protocol: h2
[ 0.486] recv SETTINGS frame <length=18, flags=0x00, stream_id=0>
(niv=3)
[SETTINGS_MAX_CONCURRENT_STREAMS(0x03):128]
[SETTINGS_INITIAL_WINDOW_SIZE(0x04):65536]
[SETTINGS_MAX_FRAME_SIZE(0x05):16777215]
[ 0.486] recv WINDOW_UPDATE frame <length=4, flags=0x00, stream_id=0>
(window_size_increment=2147418112)
[ 0.486] send SETTINGS frame <length=12, flags=0x00, stream_id=0>
(niv=2)
[SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
[SETTINGS_INITIAL_WINDOW_SIZE(0x04):65535]
[ 0.486] send SETTINGS frame <length=0, flags=0x01, stream_id=0>
; ACK
(niv=0)
[ 0.486] send PRIORITY frame <length=5, flags=0x00, stream_id=3>
(dep_stream_id=0, weight=201, exclusive=0)
[ 0.486] send PRIORITY frame <length=5, flags=0x00, stream_id=5>
(dep_stream_id=0, weight=101, exclusive=0)
[ 0.486] send PRIORITY frame <length=5, flags=0x00, stream_id=7>
(dep_stream_id=0, weight=1, exclusive=0)
[ 0.486] send PRIORITY frame <length=5, flags=0x00, stream_id=9>
(dep_stream_id=7, weight=1, exclusive=0)
[ 0.486] send PRIORITY frame <length=5, flags=0x00, stream_id=11>
(dep_stream_id=3, weight=1, exclusive=0)
[ 0.486] send HEADERS frame <length=39, flags=0x25, stream_id=13>
; END_STREAM | END_HEADERS | PRIORITY
(padlen=0, dep_stream_id=11, weight=16, exclusive=0)
; Open new stream
:method: GET
:path: /
:scheme: https
:authority: alibabacloud.com
accept: */*
accept-encoding: gzip, deflate
user-agent: nghttp2/1.43.0
[ 0.585] recv SETTINGS frame <length=0, flags=0x01, stream_id=0>
; ACK
(niv=0)
[ 0.585] recv (stream_id=13) :status: 301
[ 0.585] recv (stream_id=13) date: Thu, 17 Jun 2021 00:38:36 GMT
[ 0.585] recv (stream_id=13) content-type: text/html
[ 0.585] recv (stream_id=13) content-length: 278
[ 0.586] recv (stream_id=13) location: https://www.alibabacloud.com/
[ 0.586] recv (stream_id=13) server: Tengine
[ 0.586] recv (stream_id=13) eagleeye-traceid: 0a98a6d616238903160871274e55cb
[ 0.586] recv (stream_id=13) strict-transport-security: max-age=31536000
[ 0.586] recv (stream_id=13) timing-allow-origin: *
[ 0.586] recv HEADERS frame <length=164, flags=0x04, stream_id=13>
; END_HEADERS
(padlen=0)
; First response header
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html>
<head><title>301 Moved Permanently</title></head>
<body bgcolor="white">
...
可以看到alibabacloud在刚刚建立起连接后,就通知了客户端,把max frame size设置到最大(2^24 -1),其实没必要设置那么大,这个大小和传输的数据量有关系,如果值太小而数据量很大,那么会造成分成多个frame传输,而如果值很大,而传输的值并不大,可能会造成服务端内存占用。所以原则就是,不管这个值是多少,双方通信时候的frame payload都不应该超过这么大。例如我们双方约定了frame size最大值是100, 那么如果客户端构造了一个frame:
- size写的是101, 那服务端收到就会报告对方错误,
- 如果size是99,没问题,服务端会按照99长度截断字节
- 如果size是100, 但实际payload是101, 那服务端还是按照100截断,这样就会造成数据损坏,因为101长度的才是完整数据,就可能破坏后续的frame size读取,所以才需要让整个connection出现error?
所以frame size虽然我们双方都知道了上限,但还是推荐把实际的payload大小作为frame size的值,只不过在构造后需要检查一下这个值是否超出了上限,如果超了,应该把frame分割后传输。
我一开始用nghttp2
的python api来实现HTTP2 webserver,然而这个api并不能让我控制底层发送的数据,我只能调用api发送我想发送的数据,不管数据有多大都可以,底层库帮我自动分割好了。。。
实现2
我需要找一个能让我控制底层发送数据的HTTP2库,或者实在找不到就只能从socket开始写HTTP2实现了,这个就很麻烦了,因为在发送请求前,双方还要沟通一些事情,如果再实现TLS的话,可能我自己引入的bug就解决不过来了。。。不过我找到了一个纯粹的HTTP2实现,而且这个实现不处理IO, 也就是说这个库不负责数据如何发送/接收,只负责构造好数据,这简直太适合这个场景了。这个库叫hyper h2.
这是我基于hyper h2的demo:https://github.com/dawncold/badhttp2
if data_to_send:
print('original: ', data_to_send.hex(':'))
new_length = bytes.fromhex('004001')
type_flag_bytes = bytes.fromhex('0401')
stream_id_bytes = bytes.fromhex('00000000')
new_payload_bytes = bytes.fromhex('00' * ((1 << 14) + 1))
data_to_send = new_length + type_flag_bytes + stream_id_bytes + new_payload_bytes + data_to_send[9:]
print('new: ', data_to_send.hex(':'))
_sock.sendall(data_to_send)
data_to_send是将要丢到socket上的数据,具体的是response的内容,从wireshark看到这段内容包含了一个setting frame, 一个response header frame, 一个data frame(第一行和可见的倒数第二行):

setting frame就是专门通知setting用的,另外两个很常用的frame type是headers和data, 对应HTTP/1.1的header部分和body部分。
我直接修改了第一个frame:
- 前三个字节是payload的大小,我直接改成了default size + 1
- type和flag保持不变
- stream id保持不变
- payload原本没有,因为这是个setting ack,没有payload,但我已经改了length,也把payload改掉吧,就构造了default size + 1那么大的payload添加上去了
- 因为第一个frame原本没有payload,只有header占了9字节,我就跳过了前9个字节,拼上我的超大frame
浏览器访问一下果然出现了同样的错误,通过netlog也看到了一样的内容。另外,浏览器发现错误后,会使用goaway这种类型的frame上报给服务端,内容是:
Framer error: 16 (OVERSIZED_PAYLOAD)
这很符合RFC,其他的客户端软件,比如nghttp
也会上报,但内容不是这样写的,也类似吧:
too large frame size
后续
使用h2spec对WAF进行测试,发现WAF在收到超过max frame size的FRAME后仍然继续处理而不是按照协议返回错误、断开连接;同时,与客户一起收集了一次包含raw data的Chrome netlog数据,看到最后一次收到的FRAME确实是超过了max frame size,超了差不多1000字节。基本实锤WAF的锅了。
Solution
一个良好实现HTTP2协议的软件应该能处理好frame size问题,当然临时解决办法是从WAF那边配置提高max frame size,因为在浏览器这边很难主动提高max frame size。