picoCTF初参加
picoCTF2024に参加し、483位/4625pointsでした。
CTFを始めて約一年半となりますが、picoCTFに初参加してreversingを完走できたので、備忘録としてreversingだけでもwriteupを記しておきます。
初心者ゆえ泥臭い解き方ですがご容赦ください。
使用ツール:基本ghidra、あとexeはIDA free、elfはgdb
1.packer
ELFファイルが渡される。起動すると
Enter the password to unlock this file:
と表示される。
タイトルがもろpackerなのでとりあえずstringsするとUPXの文字あり。
UPX.exe -d out
で解凍。もう一回stringsすると
Enter the password to unlock this file:
You entered: %s
Password correct, please see flag: 7069636f4354467b5539585f556e5034636b314e365f42316e34526933535f39343130343638327d
の表記がありdecodeしてフラグゲット。
2.FactCheck
またELFファイル。ghidraで中身を見てみると、
tsIcESaIcEEC1EPKcRKS3_(auStack_248,"picoCTF{wELF_d0N3_mate_ ",&uStack_249);
と途中までフラグが書いてある。
その先はif文で細かく分岐させていて、条件に合致すればDAT部の中身をフラグに追記していく流れだと分かる。
分岐を計算して追うのがめんどいのでghidraのデバッガでgdbを動かす。
通ったところだけDAT部から値引っ張ってきて加えていったらフラグになった。
3.WinAntiDbg0x100
ヒントに確か「debuggerで開いてね!」みたいなことが書いてあったのでIDA freeで開いてみる。そのまま走らせるとOutputのウィンドウに
(_) / ____|__ __| ____|
_ __ _ ___ ___ | | | | | |__
| '_ \| |/ __/ _ \| | | | | __|
| |_) | | (_| (_) | |____ | | | |
| .__/|_|\___\___/ \_____| |_| |_|
| |
|_|
Welcome to the Anti-Debug challenge!
と出てきた。かわいい。続いて
### Oops! The debugger was detected. Try to bypass this check to get the flag!
と表示されて終わったので、この直前の分岐ポイントにbreakpointを張り、jzからjnzに書き換えて走らせるとフラグが表示された。
4.Classic Crackme 0x100
これだよ!これ!一番大変だったやつ!
まずghidraで逆コンパイルしてもらう。
undefined8 main(void)
{
int iVar1;
size_t sVar2;
char local_a8 [64];
undefined8 local_68;
undefined8 local_60;
undefined8 local_58;
undefined8 local_50;
undefined8 local_48;
undefined7 local_40;
undefined4 uStack_39;
uint local_2c;
uint local_28;
char local_21;
uint local_20;
uint local_1c;
uint local_18;
int local_14;
int local_10;
int local_c;
local_68 = 0x7570626770636871;
local_60 = 0x7570656767616277;
local_58 = 0x6f7762787473686c;
local_50 = 0x7a6b726766776177;
local_48 = 0x626e62636374736a;
local_40 = 0x6c67706b656873;
uStack_39 = 0x657a6c;
setvbuf(stdout,(char *)0x0,2,0);
printf("Enter the secret password: ");
__isoc99_scanf(&DAT_00402024,local_a8);
local_c = 0;
sVar2 = strlen((char *)&local_68);
local_14 = (int)sVar2;
local_18 = 0x55;
local_1c = 0x33;
local_20 = 0xf;
local_21 = 'a';
for (; local_c < 3; local_c = local_c + 1) {
for (local_10 = 0; local_10 < local_14; local_10 = local_10 + 1) {
local_28 = (local_10 % 0xff >> 1 & local_18) + (local_10 % 0xff & local_18);
local_2c = ((int)local_28 >> 2 & local_1c) + (local_1c & local_28);
iVar1 = ((int)local_2c >> 4 & local_20) +
((int)local_a8[local_10] - (int)local_21) + (local_20 & local_2c);
local_a8[local_10] = local_21 + (char)iVar1 + (char)(iVar1 / 0x1a) * -0x1a;
}
}
iVar1 = memcmp(local_a8,&local_68,(long)local_14);
if (iVar1 == 0) {
printf("SUCCESS! Here is your flag: %s\n","picoCTF{sample_flag}");
}
else {
puts("FAILED!");
}
return 0;
}
入力された値を計算して、演算結果をlocal_68と比べているっぽい。他はダミーか?
とりあえず最終的にlocal_68の値になればいいのか?と考えてpythonに書き直し、sympyでsolveしてみるがちゃんとした値が出ない。よく見ると計算の最後の行で+iVar1と-iVar1が行われていてこのままやっても定数になりそう。しかもcharを足している。分かんね~C言語(に似た疑似言語らしいけど)分かんね~
埒が明かないのでgdbで起動して変数の中身を確認することにする。
$ gdb crackme100
(gdb) b main
(gdb) run
(gdb) info local
これで
output = "@\000@\000\000\000\000\000<(\376\367\377\177\000\000\360\006\000\000\000\000\000\000\351\340\377\377\377\177\000\000\000\020\374\367\377\177\000\000\000\000\000\001\001\001\000\000\002\000"
input = '\000' <repeats 48 times>, "\001\000"
i = 0
len = 0
secret1 = 4096
secret2 = 0
secret3 = 100
fix = 0 '\000'
8個のlocal変数が確保されていることが分かった。最終的にmemcmpしているのはおそらくoutputとinputなので、これを
(gdb) watch output
(gdb) watch input
でwatchに追加してcontinueし続ける。gdbのwatch、値が変わったところで止めてくれるのでとても便利。途中でEnter the secret password:が表示されるので適当にaaaaaaaaとか入れてみる。
このままcontinueし続けるとinputの文字が処理されていって、最終的に比較のための値に置き換わる……と思ったが、なんか雲行きが怪しい。
(gdb) info local
i = 49
output = "qhcpgbpuwbaggepulhstxbwowawfgrkzjstccbnbshekpgllze"
input = "addgdggjQTTWTWWZQTTWTWWZTWWZWZZ]QTTWTWWZTWWZWZZ]UW"
i = 2
len = 50
secret1 = 85
secret2 = 51
secret3 = 15
fix = 97 'a'
両方50文字ある。なんで?
ここで思い違いに気が付く。outputの先に、上記のghidraの逆コンパイルのlocal_60以降の変数の中身が入っている。local_68からuStack_39まですべて比較用の値だったわけである。C言語分かんね~(2回目)
とはいえやることは変わらない。Enter the secret password:に今度はaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaと入力して処理結果を見てみる。
(gdb) info local
i = 49
output = "qhcpgbpuwbaggepulhstxbwowawfgrkzjstccbnbshekpgllze"
input = "addgdggjdggjgjjmdggjgjjmgjjmjmmpdggjgjjmgjjmjmmpgj"
i = 2
len = 50
secret1 = 85
secret2 = 51
secret3 = 15
fix = 97 'a'
ghidraの逆コンパイルを見てもaから一文字ずつindexに準拠して処理を行っており、中身から定数分ずらしたように見える。
よって、inputと同じだけoutputを戻していく。
cr_output = list("qhcpgbpuwbaggepulhstxbwowawfgrkzjstccbnbshekpgllze")
cr_input = list("addgdggjdggjgjjmdggjgjjmgjjmjmmpdggjgjjmgjjmjmmpgj")
difflist = []
passphrase = []
for s in cr_input:
diff = ord(s)-ord("a")
difflist.append(diff)
for i,s in zip(difflist,cr_output):
res = ord(s)-i
if res < 97:
res = res + 26
passphrase.append(chr(res))
print("".join(passphrase))
でこれで出てきた値をgdbでまた入力。処理してもらう。
(gdb) info local
i = 49
output = "qhcpgbpuwbaggepulhstxbwowawfgrkzjstccbnbshekpgllze"
input = "qhcpgbpuwbaggepulhstxbwowawfgrkzjstccbnbshekpgllze"
i = 2
len = 50
secret1 = 85
secret2 = 51
secret3 = 15
fix = 97 'a'
というわけで、改めてncで繋いでフラグがもらえた。
実はこれを解いたのが最終日の昼休みで、スクリプト書いてたら間に合わないと思って上司に白い目で見られながら50文字手で戻した思い出がある。スクリプトを書いてください。そっちのが全然早いから。
5.weirdSnake
pythonバイトコードが渡される。TCP1Pでも出たので二度目まして。こちらのページを参考にしました。
1 0 LOAD_CONST 0 (4)
2 LOAD_CONST 1 (54)
4 LOAD_CONST 2 (41)
6 LOAD_CONST 3 (0)
8 LOAD_CONST 4 (112)
10 LOAD_CONST 5 (32)
12 LOAD_CONST 6 (25)
14 LOAD_CONST 7 (49)
16 LOAD_CONST 8 (33)
18 LOAD_CONST 9 (3)
20 LOAD_CONST 3 (0)
22 LOAD_CONST 3 (0)
24 LOAD_CONST 10 (57)
26 LOAD_CONST 5 (32)
28 LOAD_CONST 11 (108)
30 LOAD_CONST 12 (23)
32 LOAD_CONST 13 (48)
34 LOAD_CONST 0 (4)
36 LOAD_CONST 14 (9)
38 LOAD_CONST 15 (70)
40 LOAD_CONST 16 (7)
42 LOAD_CONST 17 (110)
44 LOAD_CONST 18 (36)
46 LOAD_CONST 19 (8)
48 LOAD_CONST 11 (108)
50 LOAD_CONST 16 (7)
52 LOAD_CONST 7 (49)
54 LOAD_CONST 20 (10)
56 LOAD_CONST 0 (4)
58 LOAD_CONST 21 (86)
60 LOAD_CONST 22 (43)
62 LOAD_CONST 23 (104)
64 LOAD_CONST 24 (44)
66 LOAD_CONST 25 (91)
68 LOAD_CONST 16 (7)
70 LOAD_CONST 26 (18)
72 LOAD_CONST 27 (106)
74 LOAD_CONST 28 (124)
76 LOAD_CONST 29 (89)
78 LOAD_CONST 30 (78)
80 BUILD_LIST 40
82 STORE_NAME 0 (input_list)
ここまではlistに数列を入れているっぽいので、順番に加えてリストを作る。
input_list = [4, 54, 41, 0, 112, 32, 25, 49, 33, 3, 0, 0, 57, 32, 108, 23, 48, 4, 9, 70, 7, 110, 36, 8, 108, 7, 49, 10, 4, 86, 43, 104, 44, 91, 7, 18, 106, 124, 89, 78]
そのあと、
2 84 LOAD_CONST 31 ('J')
86 STORE_NAME 1 (key_str)
3 88 LOAD_CONST 32 ('_')
90 LOAD_NAME 1 (key_str)
92 BINARY_ADD
94 STORE_NAME 1 (key_str)
4 96 LOAD_NAME 1 (key_str)
98 LOAD_CONST 33 ('o')
100 BINARY_ADD
102 STORE_NAME 1 (key_str)
5 104 LOAD_NAME 1 (key_str)
106 LOAD_CONST 34 ('3')
108 BINARY_ADD
110 STORE_NAME 1 (key_str)
6 112 LOAD_CONST 35 ('t')
114 LOAD_NAME 1 (key_str)
116 BINARY_ADD
118 STORE_NAME 1 (key_str)
ではkey_strという変数に文字を一文字ずつ追加しているのが分かる。よって
key_str = 'J_o3t'
とする。(間違っているが後述)
9 120 LOAD_CONST 36 (<code object <listcomp> at 0x7ff3b9776d40, file "snake.py", line 9>)
122 LOAD_CONST 37 ('<listcomp>')
124 MAKE_FUNCTION 0
126 LOAD_NAME 1 (key_str)
128 GET_ITER
130 CALL_FUNCTION 1
132 STORE_NAME 2 (key_list)
"snake.py"のline 9を呼び出してkey_strからkey_listをつくるらしい。
at 0x7ff3b9776d40, file "snake.py", line 9>
9 0 BUILD_LIST 0
2 LOAD_FAST 0 (.0)
>> 4 FOR_ITER 12 (to 18)
6 STORE_FAST 1 (char)
8 LOAD_GLOBAL 0 (ord)
10 LOAD_FAST 1 (char)
12 CALL_FUNCTION 1
14 LIST_APPEND 2
16 JUMP_ABSOLUTE 4
>> 18 RETURN_VALUE
charをordにするだけか。
key_list = []
for key in list(key_str):
key_list.append(ord(key))
元に戻って
11 >> 134 LOAD_NAME 3 (len)
136 LOAD_NAME 2 (key_list)
138 CALL_FUNCTION 1
140 LOAD_NAME 3 (len)
142 LOAD_NAME 0 (input_list)
144 CALL_FUNCTION 1
146 COMPARE_OP 0 (<)
148 POP_JUMP_IF_FALSE 162
12 150 LOAD_NAME 2 (key_list)
152 LOAD_METHOD 4 (extend)
154 LOAD_NAME 2 (key_list)
156 CALL_METHOD 1
158 POP_TOP
160 JUMP_ABSOLUTE 134
key_listのlenとinput_listのlenを比べて、key_listの方が短ければkey_listをkey_listでextendしている。つまりkey_listをinput_listより長くなるまでループさせて伸ばす。
while True:
if len(key_list) < len(input_list):
key_list.extend(key_list)
else:
break
15 >> 162 LOAD_CONST 38 (<code object <listcomp> at 0x7ff3b9776df0, file "snake.py", line 15>)
164 LOAD_CONST 37 ('<listcomp>')
166 MAKE_FUNCTION 0
168 LOAD_NAME 5 (zip)
170 LOAD_NAME 0 (input_list)
172 LOAD_NAME 2 (key_list)
174 CALL_FUNCTION 2
176 GET_ITER
178 CALL_FUNCTION 1
180 STORE_NAME 6 (result)
input_listとkey_listをzipして"snake.py", line 15を読み込み、resultを作っている。
at 0x7ff3b9776df0, file "snake.py", line 15>
15 0 BUILD_LIST 0
2 LOAD_FAST 0 (.0)
>> 4 FOR_ITER 16 (to 22)
6 UNPACK_SEQUENCE 2
8 STORE_FAST 1 (a)
10 STORE_FAST 2 (b)
12 LOAD_FAST 1 (a)
14 LOAD_FAST 2 (b)
16 BINARY_XOR
18 LIST_APPEND 2
20 JUMP_ABSOLUTE 4
>> 22 RETURN_VALUE
listを用意。for文に引数(.0)を入れて、aとbという変数を用意し、これをxorしてlistにappendするもの。
result = []
for a,b in zip(input_list,key_list):
result.append(a^b)
最後に、
18 182 LOAD_CONST 39 ('')
184 LOAD_METHOD 7 (join)
186 LOAD_NAME 8 (map)
188 LOAD_NAME 9 (chr)
190 LOAD_NAME 6 (result)
192 CALL_FUNCTION 2
194 CALL_METHOD 1
196 STORE_NAME 10 (result_text)
198 LOAD_CONST 40 (None)
200 RETURN_VALUE
これを順番に処理して
result_text = ''.join(map(chr,result))
print(result_text)
こう。
> NiF3jF^wJ_V]ok:2M1K;Mne7"a7ChsX5j:
……?フラグにならないなあ。
"picoCTF{"で始まることが期待されるので、input_listとpicoCTF{でxorしてみる(ズル)
なんかkey_strの順番が違う。
最初にkey_strに文字を追加していったとき、「前に追加するか後ろに追加するか」を完全に無視していたことに気がつく。
というわけで改めて、
input_list = [4, 54, 41, 0, 112, 32, 25, 49, 33, 3, 0, 0, 57, 32, 108, 23, 48, 4, 9, 70, 7, 110, 36, 8, 108, 7, 49, 10, 4, 86, 43, 104, 44, 91, 7, 18, 106, 124, 89, 78]
key_str = 't_Jo3'
key_list = []
for key in list(key_str):
key_list.append(ord(key))
print(ord,input_list)
while True:
if len(key_list) < len(input_list):
key_list.extend(key_list)
else:
break
result = []
for a,b in zip(input_list,key_list):
result.append(a^b)
result_text = ''.join(map(chr,result))
print(result_text)
これでフラグゲット。
6.WinAntiDbg0x200
え!これで300点もらえるんですか!?みたいな問題。
0x100と同様にIDA freeで起動。今度は管理者権限つき。
グラフを見ていくと、「.text:009B1832 push offset aOopsTheDebugge ; "### Oops! The debugger was detected. Tr"」とdebugが失敗したことを表す文が挿入されているため、その手前のjnzにbreakpointを張ってjzに書き換え。
線が点滅していたのでprint screenのタイミングで消えました。
IDAのいいところは、分岐で止めた時に次にどっちに行くよーというのを視覚的に教えてくれることです。これによって、書き換えるべきかそのままにしておくべきかが一目で分かります。
今回は2段階あって、下のbreakpointを張ったjzもjnzに書き換える必要があります。
というわけで、このまま進めてフラグが出てきました。
7.WinAntiDbg0x300
ラスボスにふさわしい難問。
例によってIDA freeで見ていく。今までと違い、exeがGUIになっている。見ていると下の方に「UPX」の3文字を見つけたため、とりあえず解凍してもう一回IDA freeへ。関数をcallしているところが多すぎてページが飛び飛びでやりづらい。関数の中まで見ていくが、どこにもflagにつながる分岐が見当たらない。
仕方なくflagで検索したところ、StartAddress_0の中にflagの出力場所を発見。
この関数自体はStartAddressなるED123Fから始まる関数に呼び出されているが、今度はStartAddressがどこにも呼び出されていない。この関数のみで実行できると信じて適当な場所からStartAddressを呼び出すこととする。
起動してすぐのcall文が値が近かったから使った。適当だなあ。
というわけで適当に分岐をいじくりながら進めてみる。
ダメだった。
すぐ下に、
Debugged application message: ### (Note: The flag could become corrupted if the process state is tampered with in any way.)
と出てくる。適当にプロセスをいじくったせいでflagが壊れたらしい。
つまり適切なところで呼び出さないとflagにならない。
ここで、しばらく悩み、exeファイルと一緒にPDBファイルが渡されたことに気が付く。File>Load file>PDB file...で読み込んでみる。
情報量がめちゃめちゃ増えた。もう一度mainの画面をよく見ると、途中でStartAddressのアドレスが読み込まれており、その先に「__imp__free」という関数を発見。中まで追うが、何もなし。そのまま返ってくるだけ。怪しい。
というわけで、ここでアセンブラ職人してここの値を改ざん、StartAddressにつなげます。
本当は読み込んだアドレスを使うべきなんだろうけど、技術がないので直接StartAddressをcallしてみる。
変なダイアログも出てきたけど、もう一個のダイアログでフラグゲット。
writeupを書くのに再現してたらフラグが出なかったりIDAが落ちちゃったりしたこともあったので、想定通りの解ではなさそうです。というわけできれいな解がまだあるんでしょうが一応解けました。
終わり
こうやって書いているとすんなり解けているように見えますが、実際はbreakpointを張りまくってここかなーここかなーといろんなところを書き換えたりchatGPTにghidraの逆コンパイル結果を解釈してもらったりして頑張っています。デバッグ楽しい。