win10 x86/32位栈溢出
上个月做了一个栈溢出执行cmd shell的例子(https://www.cnblogs.com/theseventhson/p/13933230.html),当时在代码中通过数组显式调用了shell函数(a[x] = (int)shell),即把shell函数的地址放在了数组中,只需要通过超(溢)长(出)数组把shell的地址写入函数返回地址(ebp+4)即可达到执行shell函数的目的。这次介绍一个更贴近实际业务的栈溢出例子,代码如下:
乍一看,就是一个普通的计数程序,正常情况下用户输入10个数字,然后输入0表示结尾,最后打印用户输入数字的总和;怎么才能让hackMe函数被执行了?
#include <iostream> #include <iomanip> void HackMe() { unsigned long long x = 0; for (int i = 0; true; i++) { if (i % 100000000 == 0) { system("cls"); std::cout << "\n■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■\n"; std::cout << "\n 你的电脑已经被拿下,所有文件都被加密! \n"; std::cout << "\n 请缴纳10个比特币作为赎金解密所有文件\n"; std::cout << "\n\\>正在传输硬盘数据....已经传输" << x++ << "个文件......\n\n"; std::cout << "\n\\> 数据传输完成后将电脑会自动关机!\n"; std::cout << "\n■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■\n"; } } } int GetNum() { int rt; std::cout << "请输入数字:"; std::cin >> rt; return rt; } int count() { int i=0; int total=0; int num[10]={0}; do { num[i] = GetNum(); total += num[i]; } while (num[i++]); return total; } int main() { std::cout << "\n HackMe地址:"<< HackMe <<std::endl; std::cout << "\n[说明:最多输入10个数字,当输入0时代表输入结束]\n\n"; std::cout << "\n总和为:" << count(); }
1、这里从main开始的函数调用链:main->count->GetNum->std::out和std::in;这个程序是x86、32位的,所以理论上每个调用都可以考虑搞得栈溢出,跳转到hackMe执行代码;但实际分析时发现:能让用户输入的只有GetNum函数,所以先从这个函数开始分析,用IDA查看汇编代码如下:
text:00401110 sub_401110 proc near ; CODE XREF: sub_401140:loc_401174↓p .text:00401110 .text:00401110 var_4 = dword ptr -4 .text:00401110 .text:00401110 push ebp .text:00401111 mov ebp, esp .text:00401113 push ecx .text:00401114 push offset unk_4032B4 .text:00401119 mov eax, ds:?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A ; std::basic_ostream<char,std::char_traits<char>> std::cout .text:0040111E push eax .text:0040111F call sub_4014A0 .text:00401124 add esp, 8 .text:00401127 lea ecx, [ebp+var_4] .text:0040112A push ecx .text:0040112B mov ecx, ds:?cin@std@@3V?$basic_istream@DU?$char_traits@D@std@@@1@A ; std::basic_istream<char,std::char_traits<char>> std::cin .text:00401131 call ds:??5?$basic_istream@DU?$char_traits@D@std@@@std@@QAEAAV01@AAH@Z ; std::basic_istream<char,std::char_traits<char>>::operator>>(int &) .text:00401137 mov eax, [ebp+var_4] .text:0040113A mov esp, ebp .text:0040113C pop ebp .text:0040113D retn .text:0040113D sub_401110 endp
用户的输入被放在ebp-4这里,然后再mov到eax作为函数的返回值;既然用输入放在epb-4,而ebp+4就是返回地址,是不是可以在这里输入一个超长的数字,比如12字节的数字,依次覆盖ebp-4、ebp、ebp+4了? 最终达到覆盖返回地址的效果?如下:
实际结果是不行的,马上让输入第二个数字了,原因是啥了?
理论上讲:用户输入保存在ebp-4,返回地址在ebp+4,那么需要构造12个字节的数据;这里hackMe的地址是E811000,那么输入的数据应该是0x0000000000000000E81000;如果输入是一个没有长度约束的字符串还好,用户可以为所欲为地输入;但这里的输入是std:cin>>rt,rt是int型,只有4字节,超过4字节的只取最低为的4字节,所以这里尽管输入了12字节,但只被读取了低位的4字节(感兴趣的小伙伴可以用OD、x32dbg调试看看:GetNum函数返回后,eax的值是多少);所以想通过这里的输入导致栈溢出是不太可能的,只能换个地方!
2、第一个GetNum函数排除,顺藤摸瓜看看第二个count函数,ida查看的汇编代码如下:
.text:00401140 ; Attributes: bp-based frame .text:00401140 .text:00401140 sub_401140 proc near ; CODE XREF: _main+44↓p .text:00401140 .text:00401140 var_34 = dword ptr -34h .text:00401140 var_30 = dword ptr -30h .text:00401140 var_2C = dword ptr -2Ch .text:00401140 var_28 = dword ptr -28h .text:00401140 var_24 = dword ptr -24h .text:00401140 var_20 = dword ptr -20h .text:00401140 var_1C = dword ptr -1Ch .text:00401140 var_18 = dword ptr -18h .text:00401140 var_14 = dword ptr -14h .text:00401140 var_10 = dword ptr -10h .text:00401140 var_C = dword ptr -0Ch .text:00401140 var_8 = dword ptr -8 .text:00401140 var_4 = dword ptr -4 .text:00401140 .text:00401140 push ebp .text:00401141 mov ebp, esp .text:00401143 sub esp, 34h .text:00401146 mov [ebp+var_4], 0 .text:0040114D mov [ebp+var_8], 0 .text:00401154 xor eax, eax .text:00401156 mov [ebp+var_34], eax .text:00401159 mov [ebp+var_30], eax .text:0040115C mov [ebp+var_2C], eax .text:0040115F mov [ebp+var_28], eax .text:00401162 mov [ebp+var_24], eax .text:00401165 mov [ebp+var_20], eax .text:00401168 mov [ebp+var_1C], eax .text:0040116B mov [ebp+var_18], eax .text:0040116E mov [ebp+var_14], eax .text:00401171 mov [ebp+var_10], eax .text:00401174 .text:00401174 loc_401174: ; CODE XREF: sub_401140+64↓j .text:00401174 call sub_401110 .text:00401179 mov ecx, [ebp+var_4] .text:0040117C mov [ebp+ecx*4+var_34], eax .text:00401180 mov edx, [ebp+var_4] .text:00401183 mov eax, [ebp+var_8] .text:00401186 add eax, [ebp+edx*4+var_34] .text:0040118A mov [ebp+var_8], eax .text:0040118D mov ecx, [ebp+var_4] .text:00401190 mov edx, [ebp+ecx*4+var_34] .text:00401194 mov [ebp+var_C], edx .text:00401197 mov eax, [ebp+var_4] .text:0040119A add eax, 1 .text:0040119D mov [ebp+var_4], eax .text:004011A0 cmp [ebp+var_C], 0 .text:004011A4 jnz short loc_401174 .text:004011A6 mov eax, [ebp+var_8] .text:004011A9 mov esp, ebp .text:004011AB pop ebp .text:004011AC retn .text:004011AC sub_401140 endp .text:004011AC
代码不多,分析的时候养成好习惯,画个栈图,一切都清晰明了了(顺便说一下自己的观点:数据是核心,所有的代码都是为数据服务的):刚开始esp-34h,相当于分配了13个字节的栈空间;从栈顶开始一次分配10个字节存储数组的10个元素;再接着是临时变量(也就是ebp-ch,用来临时存储输入的数据,后续用于和0比较,看看输入是否结束)、total(也就是ebp-8)、i(也就是ebp-4),然后是ebp本身,再往下就是ebp+4、也就是count函数调用前的返回地址了,整个栈空间如下:
用调试器能直观看到栈的变化情况:
这么来看就很清晰了:用户的输入从栈顶开始保存,第15个就是返回地址,那么一直输入直到第15的时候把hackMe函数的返回地址输入是不是就行了?刚开始想的就是这么简单,从1开始挨个输入,直到输入到14,寄存器和堆栈变成了这样:ebp+4被改成了E,而不是我们预想的hackMe返回地址,后面直接导致著名的access_violation异常.........
重新输入,每步都调试,终于发现问题所在:(1)第11个数字存放在epb-c(这里原本是临时存放输入的空间,下面用来和0比较的,看看是不是结束了),第12个数字放在ebp-8(这里原本存放total总和),第13个数字放在ebp-4(这里原本存放循环次数i),这3个数字会通过ebp互相使用(详情见汇编代码),不能乱填;(2)界面刚开始打印出来的hackMe地址是16进制的,但std::cin接受输入是10进制的,如果强行输入16进制的地址,遇到字母就会被截断,造成地址输入错误;所以需要先把16进制的地址换成10进制后再输入,最终的效果如下:
第14个数字输入hackMe的返回地址:
结果:hackMe函数已被执行:
最后总结一下注意事项:
1、编译的时候必须是32位的,因为64位汇编函数调用约定不同:32位所有参数都通过栈push传递,64位前4个参数通过rcx、rdx、r8、r9传递,第5个参数才通过栈push传递;
2、编译时禁止优化:
禁止安全检查:
3、std:cout打印的是变量的默认类型,比如hackMe地址是16进制的,打印也是16进制;
同理:std:cin输入也是按照变量类型读取的。这里输入类型是int,所以接受也按照int类型接受,上面的地址要先转成10进制再输入;
4、这个例子告诉我们:用户所有的输入都不能相信,接收到以后都必须过滤,检查有没有超长或非法输入。前段时间就又爆出了“bad neighbor”漏洞:https://www.cnblogs.com/theseventhson/p/14004712.html 也是驱动模块接受到数据后未检查长度和内容、导致内核代码被覆盖导致的;