文章发表于:
https://www.ch35tnut.site/zh-cn/vulnerability/cve-2023-44487-http2-rapid-reset-ddos-attack/
基本信息
利用 HTTP/2 的多路复用流功能,恶意攻击者可通过快速创建请求并立即重置请求,绕过最大并发流限制,导致服务器资源的过度消耗。
影响范围
Go < 1.21.3
Go < 1.20.10
11.0.0-M1 <= Apache Tomcat <= 11.0.0-M11
10.1.0-M1 <= Apache Tomcat <= 10.1.13
9.0.0-M1 <= Apache Tomcat <= 9.0.80
8.5.0 <= Apache Tomcat <= 8.5.9
grpc-go < 1.58.3
grpc-go < 1.57.1
grpc-go < 1.56.3
环境搭建
使用go起一个http2 server。
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Println(r.Proto, r.URL)
fmt.Fprint(w, "Hello World!")
})
http.ListenAndServeTLS(":443", "certs/cert.pem", "certs/key.pem", nil)
}
使用curl测试是否成功
curl https://localhost -i -k --http2 -vvv
返回
➜ http curl https://localhost -i -k --http2 -vvv
* Trying 127.0.0.1:443...
* Connected to localhost (127.0.0.1) port 443 (#0)
* ALPN: offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN: server accepted h2
* Server certificate:
* subject: C=XX; ST=StateName; L=CityName; O=CompanyName; OU=CompanySectionName; CN=CommonNameOrHostname
* start date: Oct 12 08:46:51 2023 GMT
* expire date: Oct 9 08:46:51 2033 GMT
* issuer: C=XX; ST=StateName; L=CityName; O=CompanyName; OU=CompanySectionName; CN=CommonNameOrHostname
* SSL certificate verify result: self-signed certificate (18), continuing anyway.
* using HTTP/2
* h2h3 [:method: GET]
* h2h3 [:path: /]
* h2h3 [:scheme: https]
* h2h3 [:authority: localhost]
* h2h3 [user-agent: curl/7.88.1]
* h2h3 [accept: */*]
* Using Stream ID: 1 (easy handle 0x55566530a9e0)
> GET / HTTP/2
> Host: localhost
> user-agent: curl/7.88.1
> accept: */*
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/2 200
HTTP/2 200
< content-type: text/plain; charset=utf-8
content-type: text/plain; charset=utf-8
< content-length: 12
content-length: 12
< date: Fri, 13 Oct 2023 06:52:06 GMT
date: Fri, 13 Oct 2023 06:52:06 GMT
<
* Connection #0 to host localhost left intact
Hello World!#
也可以使用nginx搭建一个http2服务器,配置很多博客都有就不写了。
技术分析
其实这个在我看来某些方面看也不算漏洞,根据RFC9113规定,在HTTP2 setting阶段服务器可以声明支持的最大并发流,同时规定了客户端或服务端可以随时发送RST_STREAM以取消流,收到RST_STREAM接收方不能再发送其他数据,除了优先级,协议并未规定客户端发送RST_STREAM的阈值,那么各大语言和软件实现的时候可能限制也有可能不限制RST_STREAM的阈值。这属于是各自实现方式的问题。从协议层面讲,客户端直接RST_STREAM没有问题,典型的场景是用户在浏览器访问网站期间因为某些原因直接关闭了页面,此时浏览器需要向服务端发送RST_STREAM帧来取消流,可以帮助服务器节省资源。
HTTP的几种DDOS
-
HTTP 1.1
在HTTP 1.1中使用单个TCP连接顺序发送请求和响应,在前面的请求被响应之后才可以发送后续的请求,不能被多路复用,此时如果要对其进行DOS的话需要大量机器打开TCP连接顺序发送请求,消耗资源。
-
HTTP 2
HTTP2在单个TCP连接中实现了多路复用和并发,可以异步请求,客户端和服务端通过流ID(stream id)来识别数据属于哪一个请求,这和HTTP1.1相比,客户端可以启用大量并行请求,造成服务器负载上升。
所以为了防护这种情况,在HTTP2初始化的时候,
SETTINGS_MAX_CONCURRENT_STREAMS
允许服务器向客户端通告最大允许的并发流,超过这个限制的流,服务器会返回RST_STREAM来拒绝这个流。HTTP2各个状态可以使用状态机表示,当服务器收到客户端发送的HEADERS帧时,会将流状态从空闲转换为打开,而后转为半关闭状态,只有流处于打开状态或半关闭状态才会被计入打开的流数量。
来源:https://blog.cloudflare.com/technical-breakdown-http2-rapid-reset-ddos-attack/
上面提到,只有打开和半关闭的流才会计入流数量,影响并发限制,当客户端发送RST_STREAM时,流状态会从半关闭状态转入关闭状态,即释放了一个流,此时客户端可以立即发起一个新请求占用释放的流,这就是CVE-2023-44487的关键。恶意客户端可以在打开大量流之后立即发送RST_STREAM帧,这样在请求到达服务器,而服务器暂未准备好响应时,这个请求的RST_STREAM帧随机到达服务器,服务器取消这个流并释放一个并发流。
图片来源:https://cloud.google.com/blog/products/identity-security/how-it-works-the-novel-http2-rapid-reset-ddos-attack
在标准HTTP2 DDOS的时候,恶意客户端可以打开服务器允许的最大限制的流数量而后发送请求,服务器依次响应,循环这个过程,消耗服务器资源。而变种HTTP2 DDOS中,攻击者可以利用CVE-2023-44487 绕过这个限制,滥用HTTP 2的取消请求,快速重置无限数量的流,根据RFC 服务器收到RST_STREAM帧之后不需要返回数据,在现实实现时,服务器收到了客户端的HRADERS请求,在收到RST_STREAM之前,需要解析客户端请求的资源,在收到RST_STREAM之后需要释放资源,所以在客户端只需要付出带宽的代价下,服务器会付出比这个高得多的代价,导致高效率的DDOS。
补丁分析
在go中支持HTTP2协议的解析,所以go也受这个漏洞影响,下面是go修复漏洞的补丁diff。对比go修复这个漏洞的补丁,主要修复逻辑在http2#scheduleHandler,其中advMaxStreams默认为250,当在处理的流超过了250,则比较未开始处理的流数量是否大于1000,大于则报错。可以看出来补丁主要是限制了同时并发流的数量。该方法在processHeaders中调用,可以看出来,在原先逻辑中,会直接处理客户端请求,而在补丁中会判断当前流的数量,在范围内才会调用go sc.runHandler(rw, req, handler)
处理请求
type unstartedHandler struct {
streamID uint32
rw *responseWriter
req *http.Request
handler func(http.ResponseWriter, *http.Request)
}
// scheduleHandler starts a handler goroutine,
// or schedules one to start as soon as an existing handler finishes.
func (sc *serverConn) scheduleHandler(streamID uint32, rw *responseWriter, req *http.Request, handler func(http.ResponseWriter, *http.Request)) error {
sc.serveG.check()
maxHandlers := sc.advMaxStreams
if sc.curHandlers < maxHandlers {
sc.curHandlers++
go sc.runHandler(rw, req, handler)
return nil
}
if len(sc.unstartedHandlers) > int(4*sc.advMaxStreams) {
return sc.countError("too_many_early_resets", ConnectionError(ErrCodeEnhanceYourCalm))
}
sc.unstartedHandlers = append(sc.unstartedHandlers, unstartedHandler{
streamID: streamID,
rw: rw,
req: req,
handler: handler,
})
return nil
}
func (sc *serverConn) handlerDone() {
sc.serveG.check()
sc.curHandlers--
i := 0
maxHandlers := sc.advMaxStreams
for ; i < len(sc.unstartedHandlers); i++ {
u := sc.unstartedHandlers[i]
if sc.streams[u.streamID] == nil {
// This stream was reset before its goroutine had a chance to start.
continue
}
if sc.curHandlers >= maxHandlers {
break
}
sc.curHandlers++
go sc.runHandler(u.rw, u.req, u.handler)
sc.unstartedHandlers[i] = unstartedHandler{} // don't retain references
}
sc.unstartedHandlers = sc.unstartedHandlers[i:]
if len(sc.unstartedHandlers) == 0 {
sc.unstartedHandlers = nil
}
}
nginx针对这个漏洞也出了一份官方通告(https://www.nginx.com/blog/http-2-rapid-reset-attack-impacting-f5-nginx-products/)指出默认配置不受此漏洞影响,即 keepalive_requests 1000;http2_max_concurrent_streams 128;这个配置也可以对应上go补丁中的250最大stream和1000个队列。
虽然默认配置不受该漏洞影响,但nginx也针对这个漏洞进行了修复,commit为6ceef192e7af1c507826ac38a2d43f08bf265fb9,在该commit中也是统计并限制了并发流数量,超过某个阈值则返回错误。
开发PoC
在GitHub的PoC经过实际测试,没有达到谷歌和CF所说的在发HTTP2请求之后立马重置,也就是无效PoC。根据代码逻辑,在发送HTTP2 header之后PoC接着尝试接收服务器返回的数据,如果服务端返回了StreamReset则打印已成功取消,如果是RequestReceived则调用conn.reset_stream,但实际利用应该是发送HTTP2 header之后立马发送RST_Stream,而后打开一个新流重复如上过程。实际测试发现,conn.reset_stream(event.stream_id, error_code=ErrorCodes.CANCEL)永远不会被调用到,也就是该PoC会执行完整的HTTP2请求,完事之后服务端返回RST_STREAM,打印已取消,这明显是错误的。
stream_id = conn.get_next_available_stream_id()
conn.send_headers(
stream_id,
[(':method', 'GET'), (':authority', url), (':path', '/'), (':scheme', 'https')],
)
sock.sendall(conn.data_to_send())
# Read some data
while True:
data = sock.recv(65535)
if not data:
break
events = conn.receive_data(data)
for event in events:
if isinstance(event, RequestReceived):
# Cancel the stream with error code for CANCEL
conn.reset_stream(event.stream_id, error_code=ErrorCodes.CANCEL)
elif isinstance(event, StreamReset):
print(f"Stream {event.stream_id} cancelled.")
所以我们只需要将接收数据的逻辑去掉,改为发送headers之后直接发送reset即可,PoC晚些时候会上传到GitHub(https://github.com/Chestnuts4/)上。利用效果如下,单个进程单个线程可以使服务CPU占用20%
当然这个漏洞PoC也适合使用go写,我这边go写的并发有点问题,不如python版本稳定。
抓包分析
运行PoC抓包,解密,wireshark摘要如下:
在本次环境中,服务器通告的最大并发流为250。在下面的数据包中,客户端先发送HEADERS请求,而后发送RST_STREAM请求,循环往复。
通过这种办法客户端不用等待服务器响应,发送速率只受自己带宽限制,从而并非服务器在HTTP2协议初始化时声明的最大并发流限制。
小结
HTTP2 DOS原理较为简单,利用RFC所规定的协议特性,其本质上属于滥用,而CloudFlare对此的应对策略是当客户端重置次数超过某个阈值则认为是恶意客户端,关闭该连接。
由于nginx默认配置不受影响,所以受此漏洞影响的大部分是go 或者java启动的HTTP服务,同时如果使用nginx反代后端服务,即使后端服务支持HTTP2,nginx也会将请求降级到HTTP 1.1。
可预见的将来,此次这种漏洞的出现不会是最后一次,hope the internet will become more and more secure.
参考链接
https://datatracker.ietf.org/doc/html/rfc9113#name-stream-identifiers
https://cloud.google.com/blog/products/identity-security/how-it-works-the-novel-http2-rapid-reset-ddos-attack
https://blog.cloudflare.com/technical-breakdown-http2-rapid-reset-ddos-attack/
https://www.nginx.com/blog/http-2-rapid-reset-attack-impacting-f5-nginx-products/
原文始发于微信公众号(闲聊趣说):谈一下最新的CVE-2023-44487 HTTP2 快速重置攻击
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论