HTTP Request Smuggling について (h11 を用いた例証)
HTTP Request Smuggling についての覚え書きと, h11 で過去に見つかった脆弱性 CVE-2025-43859 についての検証
検証環境
- Windows 11 Home
- Ubuntu 24.04 (WSL)
HTTP Request Smuggling の基礎
- HTTP Request Smuggling はフロントエンドとバックエンドの HTTP リクエストのパーサーの解釈差によって生じる脆弱性である (PortSwigger の Web Security Academy の該当ページ)
- 例として, body の大きさを表す
Content-Lengthヘッダーと body がチャンク化されて順番に送られてくることを示すTransfer-Encodingヘッダーのchunkedについて, 本来チャンクが利用される場合は body の大きさが定まらないため二つのヘッダーは共存しないはずだが, 共存するリクエストを送った場合にパーサーの解釈差で予期せぬ挙動をすることがある Transfer-Encoding: chunkedでは 0 が出てくるところがデータの終了であるのに対してContent-Lengthでは指定されたバイト数でデータが終了するため, フロントエンドで優先される方のヘッダーにおけるデータの終了地点より後に仕込んだデータはフロントエンドでは読まれずバックエンドでは解釈される, というような状況が生じる- 他にも, これらのヘッダーについてフロントエンドとバックエンドで "どこまでがボディか?" という部分の解釈差が生じると, 例として攻撃者と被害者のリクエストが順番に送られてきた際に, フロントエンド側 (プロキシなど) では "二つのリクエスト" として認識されてバックエンドに送られ, バックエンド側では一つのリクエストとして解釈してしまい結果攻撃者に被害者のリクエスト (Cookie など重要な情報を含む可能性のあるもの) を含めたもののレスポンスが返ってしまう, などのシナリオがありうる (h11 accepts some malformed Chunked-Encoding bodies)
HTTP/2 について
- HTTP/2 以降ではチャンクより効率的な転送方法が適用されており,
Transfer-Encodingヘッダーの使用が禁止されている - MDN の Transfer-Encoding のページ
CVE-2025-43859 について
h11という python の HTTP/1.1 の通信の処理 (ヘッダ・ボディのパースなど) を行うライブラリにおける, HTTP Request Smuggling につながる脆弱性
具体的な問題点
Transfer-Encoding: chunkedではチャンクごとに以下のように, 送るデータ長の数字 ->\r\n-> データ ->\r\nのようにデータを送っていき, 最後に 0 を送ってデータの終了を通知する (以下コードは MDN のドキュメント より引用)
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked
7\r\n
Welcome\r\n
1c\r\n
to Mozilla Developer Network\r\n
0\r\n
\r\n
- しかし, h11 ではチャンクデータの後に来るデータが
\r\nであるかどうかを確認せず, 2 バイト分直接捨てる設計となっていたため,\r\nを終端とするようなソフトとと組み合わせた際にパーサーの解釈差で HTTP Request Smuggling が生じる可能性があった (現在は修正済み) - 例として以下のようにチャンク化した HTTP リクエストを作成して, チャンクデータ "hello" の後に
\r\nを入れる場合と他の 2 バイトを入れる場合でエラーが生じるかどうかを調べた
import h11
def parse_chunked_request(footer: bytes) -> tuple[bool, list[str]]:
request = (
b"POST / HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Transfer-Encoding: chunked\r\n"
b"\r\n"
b"5\r\n"
b"hello"
+ footer
+ b"0\r\n"
+ b"\r\n"
)
connection = h11.Connection(h11.SERVER)
connection.receive_data(request)
events = []
try:
while True:
event = connection.next_event()
if event is h11.NEED_DATA:
return False, events
events.append(type(event).__name__)
if isinstance(event, h11.EndOfMessage):
return True, events
except h11.ProtocolError as error:
events.append(f"{type(error).__name__}: {error}")
return False, events
for footer in (b"\r\n", b"XX", b"\n\n"):
accepted, events = parse_chunked_request(footer)
result = "ACCEPTED" if accepted else "REJECTED"
print(f"footer={footer!r}: {result} ({', '.join(events)})")
- 結果は以下のようになり, 本来
\r\nが来る必要がある場所に他の 2 バイトが来てもエラーが生じない結果となった
footer=b'\r\n': ACCEPTED (Request, Data, EndOfMessage)
footer=b'XX': ACCEPTED (Request, Data, EndOfMessage)
footer=b'\n\n': ACCEPTED (Request, Data, EndOfMessage)
- 該当バージョンの処理のコードでは以下のように, チャンク内の読み込んでないデータがなくなった場合に
_bytes_to_discardというメンバーに無視するバイト数を指定して読み飛ばすようになっていた
if self._bytes_in_chunk == 0:
self._bytes_to_discard = 2
chunk_end = True
まとめ
- パーサーの解釈差は脆弱性につながるので特定のプロトコルについて複数のライブラリで扱う場合は規約に従いライブラリごとの挙動の差を意識する必要がある (CTF の Misc, Web 問題では特に)