背景:
在积累了一些场景之后,Web Fuzzer HTTP2 的测试需求逐渐应该被提上日程;很多时候,在测试的时候遇到 H2 的网站,用户还是不得不去切换成 Burp 以继续测试,被迫忍受交互和扩展性都有蛮大问题的 Repeater / Intruder。但是实际上,HTTP2 的支持经过一些简单的探索之后,也并不是特别复杂,完全可以以一些 “无感” 的底层基础设置支持。
HTTP2 为协议测试设下的障碍
HTTP/2 与 HTTP/1.1 差别巨大,最直观的感受是,HTTP2 默认强制执行 TLS,H2C(HTTP 2 Cleartext) 变成了“不受推荐的协议”;事实确实如此。这给了我们研究 “HTTP2”非常大的不便:我们默认居然无法通过 wireshark 来分析 H2 的数据包?
当然,配合一些其他手段,我们可以看到 H2 的具体数据包,这不是我们这篇文章的重点,所以就不介绍这部分内容了,之后有机会另外写内容为大家讲如何使用 H2C
RFC7541 HPACK 的引入
hpack 主要是针对 http header 的压缩算法,经过这种算法压缩之后,header 的体积将会明确减小,同时也很直观的 “我们没有办法直接观察到协议描述的 Header” 了。
我们以一个简单的例子来讲,HPACK 压缩以前后,数据长啥样子:
// 最简单的格式
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
// 原本的数据头 HEXDUMP 的格式
([]uint8) (len=129 cap=243) {
00000000 55 73 65 72 2d 41 67 65 6e 74 3a 20 4d 6f 7a 69 |User-Agent: Mozi|
00000010 6c 6c 61 2f 35 2e 30 20 28 57 69 6e 64 6f 77 73 |lla/5.0 (Windows|
00000020 20 4e 54 20 31 30 2e 30 3b 20 57 69 6e 36 34 3b | NT 10.0; Win64;|
00000030 20 78 36 34 29 20 41 70 70 6c 65 57 65 62 4b 69 | x64) AppleWebKi|
00000040 74 2f 35 33 37 2e 33 36 20 28 4b 48 54 4d 4c 2c |t/537.36 (KHTML,|
00000050 20 6c 69 6b 65 20 47 65 63 6b 6f 29 20 43 68 72 | like Gecko) Chr|
00000060 6f 6d 65 2f 38 33 2e 30 2e 34 31 30 33 2e 31 31 |ome/83.0.4103.11|
00000070 36 20 53 61 66 61 72 69 2f 35 33 37 2e 33 36 0d |6 Safari/537.36.|
00000080 0a |.|
}
// 原本的数据头 HPACK 的格式
([]uint8) (len=99 cap=99) {
00000000 40 88 e0 82 d8 b4 33 16 a4 ff d8 d0 7f 66 a2 81 |@.....3......f..|
00000010 b0 da e0 53 fa e4 6a a4 3f 84 29 a7 7a 81 02 e0 |...S..j.?.).z...|
00000020 fb 53 91 aa 71 af b5 3c b8 d7 f6 a4 35 d7 41 79 |.S..q..<....5.Ay|
00000030 16 3c c6 4b 0d b2 ea ec b8 a7 f5 9b 1e fd 19 fe |.<.K............|
00000040 94 a0 dd 4a a6 22 93 a9 ff b5 2f 4f 61 e9 2b 0f |...J."..../Oa.+.|
00000050 32 b8 17 68 20 65 70 85 c5 37 0e 51 d8 66 1b 65 |2..h ep..7.Q.f.e|
00000060 d5 d9 73 |..s|
}
H2 Frame:多种帧
除了最基础的 TLS 要求变化之外,H2 基于 “帧” 的通信,也给研究和一些内容造成了一系列的困难。
这个帧同样,我们以大家最能理解的方式来描述变化:
- SETTING 帧可设置协商 H2 连接中的各种参数
- UpdateWindows 帧:可以设置通信帧的窗口
- Header 帧,通过 hpack 压缩的 header 数据,不光可以传输部分数据,也可以标志流的结束。
- Data 帧,类似于 chunk 数据也可以分为多个帧,可以 END_STREAM 标志流结束
- 控制帧:PING / SETTING_ACK / GOAWAY / CONTINUATION 等等各种帧可以极大增强 H2 的表现力
这里大家有个印象即可,我们其实本文并不会去着重探索各种数据帧的内容。
H2 全双工通信
我们知晓数据帧的概念之后,将会很快意识到,数据包传输的过程中,我们可以同时一边传数据,一边传控制帧。没错,这个就是全双工的最简单的表现形式。
实际在 Golang 标准库实现过程中,这个实现更加明确(伪代码表示):
go readLoop();
...
...
fn readLoop() {
for {
frame, err = framer.ReadFrame();
if err != nil { break }
switch frame.type {
case http2.MetaFrameHeader:
...
case http2.DataFrame:
...
case http2.SettingFrame:
...
case http2.UpdateWindow:
...
case http2......
}
}
}
如何构建 HTTP2 连接?
一般来说,构建 HTTP2 的连接,我们需要先构建 TLS,为了大家能快速理解这个过程,我把 Yaklang 中实现 HTTP2 连接的步骤总结成一个很容易理解的时序图:
当我们大致有概念之后,基本可以理解,HTTP2 在建立完通信之后,才会进行大家熟悉的 HTTP 通信。
当然,因为主要讨论一次通信的过程,我有意在上述内容中做了一些屏蔽,比如 STREAM_ID,以及 SETTING 中常见的具体配置内容。
那么我们实际前面的握手的过程,我们可以把它看作一个固定的 “仪式” 的时候,可能将会更关注于 “HTTP/1.1” 的数据包如何转成 “HTTP/2.0”,所以我们将会讲解一下这个数据包的转化过程
HTTP/1.1 数据包如何转成 HTTP/2.0?
这个过程其实一点都不高深
HTTP1.1 Header 转 HTTP2 HeaderFrame
注:以下内容来源是 Golang HTTP2 标准库
GET /path HTTP/1.1
Connection: close
User-Agent: Demo-User-Agent
Host: Example Domain
上述数据包是一个非常简单的案例,我们看一眼就知道
- Method 为 GET
- RequestURI 为 /path
- 有三个 Header
然后经过思考之后,HTTP2 的协议好像并没有规定 Method RequestURI 这类字段有类似 HTTP1.1 的位置,那么就一定有别的机制额外存储这些重要的内容
HTTP1 关键字段转 HTTP2 表
类型 | HTTP/1.1 字段 | HTTP/2.0 字段 | 备注 |
---|---|---|---|
HTTP 请求 | Method | :method | |
ReqestURI | :path | CONNECT 方法下不存在 | |
Scheme | :scheme | CONNECT 方法下不存在 | |
Host | :authority | ||
HTTP 响应 | StatusCode | :status |
我们发现,HTTP1 中的字段通过上述表的内容转 HTTP2 的时候,会给关键字段加上 ":"。
这非常关键:在 Golang 标准库的 hpack 实现中,我们通过 IsPseudo 来区分这种情况,我们可以理解为 Pseudo 为这些关键字头部内容的描述,这是兼容 HTTP/1.1 的关键。
不应该再出现的 Header
当然,众所周知,HTTP/1.1 中有很多 HEADER 其实并不适合 H2 的情况,比如说 Connection,Proxy-Connection 等,这些头其实在 Golang http2 标准库中默认将会被移除,那具体会移除哪些内容呢?
我们进行以下总结:
- Connection
- Proxy-Connection
- Transfer-Encoding
- Upgrade
- Keep-Alive
Cookie 的兼容与建议
在标准库中,Cookie 处理遵循性能优化
Per 8.1.2.5 To allow for better compression efficiency, the
Cookie header field MAY be split into separate header fields,
each with one or more cookie-pairs.
实际上根据描述的内容,这只是一个建议内容而已,如果为了提高性能,可以这么做,实际测试过程中,也并没有因为不这么做受到惩罚,我们为了在 Cookie 中保留更多更复杂和多样的内容,可以选择 Cookie 和原内容完全一致。
HTTP1.1 转 HTTP2.0 数据帧
这个内容其实相比 header 之外,其实没有什么其他的处理障碍,可以直接使用 http2.Framer.WriteData(streamId, endStream, bodyBytes) 即可实现。
攻击边界思考
在我们理解了 HTTP2 的基础问题之后,我们将会需要思考一下,HTTP2 的引入,将可能带来哪些潜在可测试的面?
HTTP/2.0 降级 HTTP/1.1
众所周知,HTTP2 如果失败,将会自动降级为 HTTP1,一般来说这个过程在大部分的客户端,尤其是浏览器中,他是无感的;服务端为了兼容,也比较少会出现 “只允许 HTTP2” 的情况发生。这个过程,我们不必过多描述;但是一个容易让人忽略的点是 “HTTP/1.1 如何升级为 HTTP2”
HTTP/1.1 升级为 HTTP/2.0
这个升级的过程,其实大多数人并不知道,我们可以简单描述一下这个升级的方法,其实非常类似 websocket,具体方法是说在原来的 1.1 数据包加三个额外的头来升级 Connection
Connection: Upgrade, HTTP2-Settings
Upgrade: h2
HTTP2-Settings: AAMAAABkAAQCAAAAAAIAAAAA
通过这种方法,可以绕过 Golang 标准库不支持 H2C 的问题。
那么,这种方式也很容易成本,发现一个 Web 服务器到底支持不支持 HTTP2 的依据。
HTTP/2.0 数据帧的分片
众所周之,在 HTTP/1.1 中,chunk 可以直接分片 body,但是在 HTTP/2.0 的协议下,framer.WriteData 可以设置 END_STREAM,于是只要不设置 END_STREAM 就可以进行任意 “分片”。
我们以一段简单的代码来解释这个过程:
func TestFramer(t *testing.T) {
var buf bytes.Buffer
framer := http2.NewFramer(&buf, &buf)
for _, block := range funk.Chunk([]byte(`hello world
hello world
hello worldhello worldhello worldhello worldasdfasdfh
`), 10).([][]byte) {
framer.WriteData(1, false, block)
}
framer.WriteData(1, true, nil)
spew.Dump(buf.Bytes())
}
我们执行上述代码之后,执行结果为:
([]uint8) (len=159 cap=311) {
00000000 00 00 0a 00 00 00 00 00 01 68 65 6c 6c 6f 20 77 |.........hello w|
00000010 6f 72 6c 00 00 0a 00 00 00 00 00 01 64 0a 68 65 |orl.........d.he|
00000020 6c 6c 6f 20 77 6f 00 00 0a 00 00 00 00 00 01 72 |llo wo.........r|
00000030 6c 64 0a 68 65 6c 6c 6f 20 00 00 0a 00 00 00 00 |ld.hello .......|
00000040 00 01 77 6f 72 6c 64 68 65 6c 6c 6f 00 00 0a 00 |..worldhello....|
00000050 00 00 00 00 01 20 77 6f 72 6c 64 68 65 6c 6c 00 |..... worldhell.|
00000060 00 0a 00 00 00 00 00 01 6f 20 77 6f 72 6c 64 68 |........o worldh|
00000070 65 6c 00 00 0a 00 00 00 00 00 01 6c 6f 20 77 6f |el.........lo wo|
00000080 72 6c 64 61 73 00 00 08 00 00 00 00 00 01 64 66 |rldas.........df|
00000090 61 73 64 66 68 0a 00 00 00 00 01 00 00 00 01 |asdfh..........|
}
肉眼可见的,我们使用 HTTP2.0 的 Framer 把传输的数据进行了分片,实现了类似 chunk 的效果。
注意
实际使用中,大多数 HTTP2 客户端都会主动移除 Transfer-Encoding,使用 HTTP2 的 DataFrame 的 END_STREAM 标识位来进行分片。但是,这是一个君子协定,实现不实现其实由具体服务器和中间件来实现。
HTTP/2.0 头部帧的分片写入
当然,受上述案例的启发,HTTP2 的头部帧自然也可以分片写入,更细节地来说,HTTP2 头部帧写入的时候,我们可以通过控制各种各样的参数来控制内容:
http2.HeadersFrameParam{
// 这个帧数据具体哪个数据流?
StreamID: 1,
// 具体传输的数据
BlockFragment: hpackHeaderBytes,
// 判断这个数据流是否结束,一般来说,确定没有 Body 要传输,可以设置为 True
EndStream: false,
// 确定 Headers 传输结束了
EndHeaders: false,
// 控制 Padding 和优先级的,暂不讨论
PadLength: 0,
Priority: http2.PriorityParam{},
}
为此我们就不多介绍了,但是值得注意的是:EndHeaders 这个字段可以传输多次完整 hpack 编码的 Header,这是非常有意思的设定。
HTTP2 Frame-Stream 构造“时空交错”
一般来说,Framer 为传输介质,介质中传输的一个请求或者响应,我们称之为一个 Stream;
因此在 http2 标准库的一般客户端实现中,每次新建一个 Stream 为 n+2;以为 Request 为 N,Response 将为 N+1,那么下一个 Request 就是 N+2;
那么思路打开!我们可以通过 STREAM ID 来单独控制一个数据帧到底是为哪个 Stream 服务的,也就是说,我可以同时传输:
- Stream2-Header[1]
- Stream1-Data[1]
- Stream2-Data[1]
- Stream1-Data[end]
这样,Stream2 作为干扰数据,可以穿插在 Stream1 的 Data 中,在卸载 HTTPS 后,可以快速致盲无法还原 HTTP2 数据流的网络设备,还可以干扰正常 IDS/IPS 的各种 “静态” 规则。
总结
上述描述的内容,绝大部分都来源于 Golang http2 的标准库,在我们深入理解上述描述的过程以及关键点之后,我们可以自己实现 HTTP2 的客户端。
实际实现过程中,Yaklang 引擎的 HTTP 部分移除了标准库中各种 “限制”,直接使用 http2 的 Framer 和 hpack 来构建 HTTP2 的客户端,可以实现控制通信中的任何一个步骤来实现 HTTP2 的通信。
Web Fuzzer 的 HTTP2.0 支持
通过我们上述的描述,我们实现了 HTTP/1.1 风格数据包到 HTTP2.0 数据帧的转换,因此
在数据包的 Proto 部分是 HTTP/2.0,且 HTTPS 打开的时候,Web Fuzzer 将会自动切换为 HTTP2 的形式。
Happy Game!
Yak官方资源
Yak 语言官方教程:
https://yaklang.com/docs/intro/
Yakit 视频教程:
https://space.bilibili.com/437503777
Github下载地址:
https://github.com/yaklang/yakit
Yakit官网下载地址:
https://yaklang.com/
Yakit安装文档:
https://yaklang.com/products/download_and_install
Yakit使用文档:
https://yaklang.com/products/intro/
常见问题速查:
https://yaklang.com/products/FAQ