Day11 的第一個 Crackme 中,Password 最後其實還是會直接出現在程式裡。
例如:
strcmp(input, "r3v_1s_fun")
這種題目只要找到 strcmp,通常很快就可以得到答案。
但真實的 Reverse Challenge 當然不一定會這麼友善。
所以今天來做一個稍微進階一點的 Crackme:
Stripped Binary
+
Password 不會直接出現在 strings
+
XOR 驗證
這次的重點不是學新的工具。
而是把前面學到的東西真的串起來。
假設今天拿到:
chall
先直接執行:
./chall
輸入:
Password: test
Wrong!
目前能知道的資訊只有:
程式會要求 Password
輸入錯誤:
Wrong!
輸入正確:
應該會出現 Correct!
我們的目標就是:
找出正確 Password
拿到 Binary 還是一樣,先從最便宜的方法開始。
file chall
可能看到:
chall: ELF 64-bit LSB pie executable, x86-64, dynamically linked, stripped
其中有一個很重要的資訊:
stripped
代表 Symbol 已經被移除了。
如果使用:
nm chall
可能會看到:
nm: chall: no symbols
所以這次不會直接看到:
main
check_password
verify
這些漂亮的 Function Name。
接著:
strings chall
可能找到:
Password:
Correct!
Wrong!
但是卻找不到:
r3v_x0r!
也就是說:
Password 並沒有直接以 Plaintext String 的形式存在 Binary 裡。
所以這次沒有辦法像之前一樣:
strings
↓
看到 Password
↓
結束
但 strings 還是非常有用。
因為:
Correct!
Wrong!
本身就是很好用的線索。
把 Binary 丟進 IDA 或 Ghidra。
因為 Binary 被 Strip 過,所以可能會看到很多:
sub_1169
sub_11A2
sub_1201
這種完全看不懂的 Function Name。
這時候不要從第一個 Function 開始慢慢看。
直接找:
Correct!
接著查看:
Xref
也就是:
誰使用了這個 String?
找到 Reference 後,可能會跳到某個 Function:
sub_1201
Decompiler 大概會看到:
if (sub_1169(input))
puts("Correct!");
else
puts("Wrong!");
雖然我們不知道:
sub_1169
叫什麼。
但從行為就可以猜到:
sub_1169(input)
很可能就是:
Password Validation
所以可以先 Rename:
sub_1169
↓
check_password
這就是前面 Stripped Binary 提過的概念:
Function Name 消失,不代表 Program Logic 消失。
接著進入:
check_password
可能看到類似:
int check_password(char *input)
{
int i;
unsigned char enc[8] = {
0x51,
0x10,
0x55,
0x7c,
0x5b,
0x13,
0x51,
0x02
};
if (strlen(input) != 8)
return 0;
for (i = 0; i < 8; i++)
{
if ((input[i] ^ 0x23) != enc[i])
return 0;
}
return 1;
}
這次沒有:
strcmp(input, password)
而是逐個 Character 驗證。
核心是這一行:
(input[i] ^ 0x23) != enc[i]
換句話說:
User Input
↓
XOR 0x23
↓
跟 enc[i] 比較
這裡有一個很重要的特性。
假設:
A XOR B = C
那麼:
C XOR B = A
因為:
X XOR K XOR K = X
同一個值 XOR 兩次會回到原本的值。
所以現在我們知道:
input[i] XOR 0x23 = enc[i]
那就可以直接反過來:
input[i] = enc[i] XOR 0x23
因此只需要把:
51 10 55 7c 5b 13 51 02
全部 XOR:
0x23
就可以還原 Password。
這種時候其實不需要手算。
直接寫一小段 Python:
enc = [
0x51,
0x10,
0x55,
0x7c,
0x5b,
0x13,
0x51,
0x02
]
password = ''.join(chr(x ^ 0x23) for x in enc)
print(password)
執行:
python3 solve.py
得到:
r3v_x0r!
所以我們目前推測:
Password = r3v_x0r!
但跟 Day11 一樣。
先不要因為 Script 跑出答案就直接宣布結束。
我們還可以:
驗證自己的分析
開啟:
gdb ./chall
因為這次是 Stripped Binary,Function Name 可能不好找。
所以可以先從 IDA 找到:
check_password
對應的 Address。
假設是:
0x1169
如果 Binary 是 PIE,實際 Address 會因為 ASLR 改變,因此可以先:
start
再根據 Binary Base Address 計算實際位置。
接著在 XOR 附近下 Breakpoint。
執行後觀察 Register 或 Memory。
可能會看到類似:
movzx eax, byte ptr [rbp+rax-0x20]
xor eax, 0x23
cmp al, byte ptr [rbp+rdx-0x18]
jne wrong
把它翻回比較容易理解的形式:
tmp = input[i];
tmp ^= 0x23;
if (tmp != enc[i])
goto wrong;
所以我們在 Decompiler 裡看到的邏輯確實沒有錯。
最後直接執行:
./chall
輸入:
Password: r3v_x0r!
得到:
Correct!
成功。
今天這題如果整理成完整流程,大概就是:
chall
↓
file
↓
發現 Stripped Binary
↓
strings
↓
找到 Correct! / Wrong!
↓
IDA / Ghidra
↓
String Xref
↓
找到驗證附近的 Function
↓
追蹤 Caller / Callee
↓
找到 Password Validation
↓
分析 Control Flow
↓
發現 XOR 0x23
↓
取得 Encoded Bytes
↓
Python 還原
↓
GDB 驗證
↓
取得 Password
這其實已經非常接近平常解 Reverse Challenge 時會使用的思路了。
做到 Day15,可以稍微整理一下 Reverse Engineering 的核心。
Reverse 並不是:
看到 Binary
↓
把所有 Assembly 從頭看到尾
更多時候其實是:
Information Gathering
↓
找到有價值的線索
↓
提出 Hypothesis
↓
追蹤 Data / Control Flow
↓
理解 Validation Logic
↓
寫 Script 還原資料
↓
Dynamic Analysis 驗證
例如今天:
Correct!
只是一個 String。
但透過:
String
↓
Xref
↓
Function
↓
Validation Logic
↓
Encoded Data
最後就可以一路追到真正的 Password。
這也是為什麼前面一直在介紹:
Strings
Xref
Control Flow
Decompiler
GDB
因為這些東西並不是獨立存在的工具或知識。
真正解題時,它們通常會全部串在一起。
到今天為止,前半段的 Reverse Engineering 基礎差不多告一段落。
我們從:
C Code
一路看到:
Binary
ELF
Assembly
Register
Stack
Function Call
Static Analysis
IDA / Ghidra
GDB
Control Flow
Xref
Stripped Binary
Crackme
當然 Reverse Engineering 還有非常多東西可以繼續深入,例如:
Anti-Debug
Packing
Obfuscation
VM
Malware Analysis
C++ Reverse
Firmware Reverse
但如果目標是開始解 Binary CTF,現在已經有足夠的基礎可以繼續往下走了。
而接下來,系列會開始進入另一個 Binary CTF 的大主題:
Pwn
前面 Reverse 的目標通常是:
理解程式在做什麼
接下來我們要開始問的是:
程式哪裡做錯了?
以及:
我們能不能利用這個錯誤控制程式?
下一篇就正式開始進入 Pwn 的世界