栈溢出(stack overflow)是最常见的二进制漏洞,在介绍栈溢出之前,我们首先需要了解函数调用栈。
函数调用栈是一块连续的用来保存函数运行状态的内存区域,调用函数(caller)和被调用函数(callee)根据调用关系堆叠起来。
栈在内存区域中从高地址向低地址生长。
每个函数在栈上都有自己的栈帧,用来存放局部变量、函数参数等信息。当caller调用callee时,callee对应的栈帧就会被开辟,当调用结束返回caller时,callee对应的栈帧就会被销毁。
下图展示了栈帧的结构。在32位程序中,寄存器ebp指向栈帧的底部,用来存储当前栈帧的基址,在函数运行过程中不变,可以用来索引函数参数和局部变量的位置。寄存器esp指向栈帧的顶部,当栈生长时,esp的值减少(向低地址生长)。寄存器eip用于存储下一条指令的地址。在64位程序中,三个寄存器分别为rbp、rsp和rip。
当函数调用发生时,首先需要保存caller的状态,以便函数调用结束后进行恢复,然后创建callee的状态。具体来说:
如果是32位程序,将传给callee的参数按照逆序依次压入caller的栈帧中;如果是64位程序,前6个参数分别通过rdi、rsi、rdx、rcx、r8、r9进行传递,剩余参数从后向前压栈。如果callee不需要参数,则这一步骤省略。
对于64位程序,如果只有2个参数:
mov rsi, arg2 mov rdi, arg1
对于64位程序,如果有8个参数:
push arg8 push arg7 mov r9, arg6 mov r8, arg5 mov rcx, arg4 mov rdx, arg3 mov
rsi, arg2 mov rdi, arg1
*
将caller调用callee后的下一条指令的地址压入栈中,作为callee的返回地址,这样,当函数返回后可以正常执行接下来的指令。
*
将当前ebp寄存器的值压入栈中,这是caller栈帧的基址,将ebp更新为当前的esp。
*
将callee的局部变量压入栈中。
*
函数调用结束后,就是上面过程的逆过程,callee栈帧中数据会出栈,恢复到caller栈帧状态。
上面的第1步由caller完成,第2步在caller执行call指令时完成,第3、4步由callee完成。
下面看一个具体的例子,callerStack.c代码如下:
// callerStack.c // C语言函数调用栈 # include <stdio.h> int func(int arg1, int arg2,
int arg3, int arg4, int arg5, int arg6, int arg7, int arg8) { int loc1 = arg1 +
1; int loc2 = arg8 + 8; return loc1 + loc2; } int main(void) { int ret =
func(1, 2, 3, 4, 5, 6, 7, 8); return 0; }
用命令gcc -m32 callerStack.c -o callerStack32生成32位程序,用gdb反汇编,得到的结果如下:
(这里额外说一下,如果是在64位机器上执行上述命令可能会报错: fatal error: bits/libc-header-start.h: No such
file or directory #include <bits/libc-header-start.h>,需要安装multilib库:sudo apt
install gcc-multilib)
0x565561dd <main> endbr32 0x565561e1 <main+4> push ebp ;
将ebp入栈,保存caller的基址,esp -= 4 0x565561e2 <main+5> mov ebp, esp ; 将ebp更新为当前的esp
0x565561e4 <main+7> sub esp, 0x10 ; esp -= 0x10 0x565561e7 <main+10> call
__x86.get_pc_thunk.ax <__x86.get_pc_thunk.ax> ; 没看懂 0x565561ec <main+15> add
eax, 0x2df0 ; 没看懂 0x565561f1 <main+20> push 8 ; 参数入栈,esp -= 4 0x565561f3
<main+22> push 7 0x565561f5 <main+24> push 6 0x565561f7 <main+26> push 5
0x565561f9 <main+28> push 4 0x565561fb <main+30> push 3 0x565561fd <main+32>
push 2 0x565561ff <main+34> push 1 0x56556201 <main+36> call func <func> ;
调用func,返回地址入栈 0x56556206 <main+41> add esp, 0x20 ; 恢复栈顶 0x56556209 <main+44>
mov dword ptr [ebp - 4], eax ; eax存放func的返回值 0x5655620c <main+47> mov eax, 0
0x56556211 <main+52> leave 0x56556212 <main+53> ret 0x565561ad <func> endbr32
0x565561b1 <func+4> push ebp ; 将ebp入栈,保存caller的基址,esp -= 4 0x565561b2 <func+5>
mov ebp, esp ; ebp更新为当前的esp 0x565561b4 <func+7> sub esp, 0x10 ; esp -= 0x10
0x565561b7 <func+10> call __x86.get_pc_thunk.ax <__x86.get_pc_thunk.ax> ; 没看懂
0x565561bc <func+15> add eax, 0x2e20 <func+15> ; 没看懂 0x565561c1 <func+20> mov
eax, dword ptr [ebp + 8] ; 取出arg1(值为1),放入eax中 0x565561c4 <func+23> add eax, 1 ;
arg1 + 1 0x565561c7 <func+26> mov dword ptr [ebp - 8], eax ; 计算结果(局部变量loc1)放入栈中
0x565561ca <func+29> mov eax, dword ptr [ebp + 0x24] ; 取出arg8(值为8),放入eax中
0x565561cd <func+32> add eax, 8 ; arg8 + 8 0x565561d0 <func+35> mov dword ptr
[ebp - 4], eax ; 计算结果(局部变量loc8)放入栈中 0x565561d3 <func+38> mov edx, dword ptr
[ebp - 8] 0x565561d6 <func+41> mov eax, dword ptr [ebp - 4] 0x565561d9
<func+44> add eax, edx ; eax = eax (loc8) + edx (loc1),函数返回值存放在eax中 0x565561db
<func+46> leave ; mov esp, ebp pop ebp 0x565561dc <func+47> ret ; pop eip
以上就是C语言函数的调用过程以及栈的情况,但是我还有几点疑问大家可以记录一下:
*
为什么在函数刚开始的地方sub esp, 0x10
,从后面的代码来看,开辟的空间用于存放局部变量,那为什么不是在局部变量定义的时候将局部变量的值入栈,再移动esp呢?而是一次性先esp -= 0x10
,这样不会带来空间的浪费吗?
*
call __x86.get_pc_thunk.ax是什么意思?
*
add eax, 0x2e20有什么作用?