前言:
之前pwn写到别的地方没往github搬,后面慢慢搬吧
栈溢出基础知识:
栈溢出覆盖方向:
当程序使用 read、gets 或者你在打 payload 时,数据的写入方向永远是从低地址向高地址蔓延的。
假设栈上原本有一个 4 字节的整数 0x30405060(在这个数值里,0x60 是最低位字节,0x30 是最高位字节)。它在真实的物理内存里是这样排列的:
1 | 内存低地址 -----> 内存高地址 |
如果向栈内读入一个0x55;
1 | 内存低地址 -----> 内存高地址 |
数值就变成了:0x30405055
栈帧结构与工作原理
栈在调用call时,示意图
在call之前:
1 | push arg2 |
在call之后:
1 | func: |
call后栈存储:
1 | 高地址 |
32位系统和64位系统的区别
函数传参方式的根本差异
32位与64位系统在函数传参上有显著区别:
32位下: 函数参数是通过栈传递的。
64位下: 函数参数主要是通过寄存器传递的,前6个参数对应的寄存器依次为 rdi、rsi、rdx、rcx、r8、r9。
系统调用约定 (Syscall)
当栈不可执行且程序无 system 函数时,我们需要直接调用底层系统调用(如 execve)。
32 位 Linux: 触发方式:使用 int 0x80 或 sysenter。
参数寄存器:eax = 系统调用号,参数依次放在 ebx, ecx, edx, esi, edi, ebp 中。
函数返回值放在eax
64 位 Linux: 触发方式:使用 syscall 指令。
参数寄存器:rax = 系统调用号,参数依次放在 rdi, rsi, rdx, r10, r8, r9 中(注意第4个参数是 r10 而不是 rcx,因为 rcx 被用来保存返回地址,CPU 会将返回地址(即 syscall 指令的下一条指令的地址)自动保存到 rcx 寄存器。当系统调用结束返回时(sysret 指令),CPU 再从 rcx 中取出地址跳回去继续执行。)
函数返回值放在rax
int 0x80和syscall的作用时进入内核状态,执行我们现在设置好的内核函数
数据宽度与地址特性
32 位系统: 寄存器和栈上的每个“格子”是 4 个字节(32 bit)。写 Pwn 脚本时,地址和数据都要用 p32() 打包。
64 位系统: 寄存器和栈上的每个“格子”是 8 个字节(64 bit),写脚本用 p64() 打包。更重要的是,在 64 位程序里,指针地址通常只有低 6 个字节是有意义的,比如 0x00007ffff7a0b000,它的高位全都是 \x00。
_避坑点_:因为 64 位地址自带 \x00(字符串结束符),如果你溢出利用的是 strcpy 这类遇到 \x00 就停止拷贝的函数,你的 Payload 就会被硬生生截断!而 32 位的地址(如 0x08048460)通常没有这种烦恼。
常用系统调用号速查表 (Syscall Table)
在构造 ret2syscall 或手写 Shellcode 时,我们必须准确设置系统调用号。
请特别注意:Linux 下 32 位和 64 位的系统调用号是完全不同的! 以下是 Pwn 题中最常被用到的几个系统调用及其编号:
| 功能说明 | 对应 C 语言函数 | 32位调用号 (EAX) | 64位调用号 (RAX) | 常用 Pwn 场景 |
|---|---|---|---|---|
| 执行程序 | sys_execve |
11 (0xb) |
59 (0x3b) |
ret2syscall 直接拿 /bin/sh 的 Shell |
| 读文件/输入 | sys_read |
3 |
0 |
ORW 漏洞链读取 flag / 再次触发输入 |
| 写文件/输出 | sys_write |
4 |
1 |
ORW 漏洞链将 flag 打印到屏幕 |
| 打开文件 | sys_open |
5 |
2 |
ORW 漏洞链打开 flag 文件 |
| 退出程序 | sys_exit |
1 |
60 (0x3c) |
保持栈平衡优雅退出,防止程序崩溃引发报警 |
(注:32位下 execve 为 0xb,64位下为 59。64位下 read 的调用号为 0。另外,在 64 位下,由于很多现代 Linux 系统推荐使用 openat 替代 open,openat 的调用号是 257 (0x101),这在较新的沙盒题中经常遇到。)
实战查表小技巧
如果你在打比赛时忘了某个系统调用号,且不能联网,可以直接在 Linux 终端用自带的头文件搜索:
查 32 位: cat /usr/include/asm/unistd_32.h | grep "execve"
查 64 位: cat /usr/include/asm/unistd_64.h | grep "execve"
页对齐与随机化本质
无论是主程序(ELF 文件)还是动态库(libc 等),加载到内存时都必须页对齐。
页大小为 0x1000 = 4096B,所以加载基址的低12位(十六进制最后三位数)始终为 0。
低12位是页内偏移,永远是固定的,不会随着 ASLR 随机化。被 ASLR 随机化的是高位的页号(Page Number)。
栈对齐玄学理论概念
底层原因: 在较新的 Ubuntu 系统(glibc 2.27 及以上版本)中,调用 system、printf 等函数时,底层有一条叫做 movaps 的汇编指令。这条指令极其苛刻,它要求当前栈顶指针 rsp 的地址必须是 16 字节对齐(即 rsp 地址最后一位必须是 0)。
如果你在构造ROP 链时,溢出覆盖完后 rsp 刚好差了 8 个字节(没有对齐),哪怕你的参数全对,程序也会在进入 system 内部时直接崩溃(Segmentation Fault)。这就是为什么我们要在调用 system 前额外垫一个空指令 ret(它相当于让 rsp 再往下走 8 个字节),强行把栈给对齐
构造ROP链理论基础:
理解rop链底层原理只需要看下面这三个东西
rip(指令指针寄存器)rsp(栈顶指针寄存器)栈(Stack)
rop链本质是通过pop 和ret指令来给寄存器赋值,我们需要找到gadget来控制执行流
pop 寄存器(比如 pop rdi)
动作 A(取值):把当前 rsp 指向的地方(栈顶的内容,8个字节),原封不动地复制到 rdi 寄存器里。
动作 B(移位):把 rsp 往下(高地址方向)移动 8 个字节(rsp = rsp + 8)。
一句话总结:pop rdi 就是“吃掉”当前栈顶的 8 个字节给 rdi,然后把栈顶指针让给下一个数据。
ret(返回指令)
你以为它是“函数结束”,但在 CPU 底层,ret 等价于一条指令:pop rip。
动作 A(取值):把当前 rsp指向的地方(栈顶的 8 个字节),强行塞进 rip里
动作 B(移位):rsp 往下移动 8 个字节(rsp = rsp + 8)。
一句话总结:ret 就是把当前栈顶的数据当作“下一条要执行的指令地址”,让 CPU 飞过去,同时栈顶指针往下挪一格。
所以某些payload :
1 | payload = padding + p64(pop_rdi_ret的地址) + p64(data数据) + p64(system函数的地址) |
通过这样的方式就可以把data 赋值给rdi了
找gadget的方法:
1 | ROPgadget --binary ./pwn | grep "pop rdi" |
如果只想看pop和ret指令,可以配合--only参数使结果更精确:
1 | ROPgadget --binary [目标文件] --only "pop|ret" | grep "pop rdi" |
结果类似下面:
1 | 0x0000000000401234 : pop rdi ; ret |
前面就是gadget的地址
Ret2libc中plt表和got表的区别及其作用
| 对比维度 | PLT 表 (Procedure Linkage Table) | GOT 表 (Global Offset Table) |
|---|---|---|
| 中文名 | 过程链接表 | 全局偏移表 |
| 本质 | 代码段(一段段可执行的汇编指令) | 数据段(一个个存放地址的指针数组) |
| 内存权限 | 可读、可执行 (r-x) |
可读、可写 (rw-) _(注:开启 Full RELRO 后变为只读)_ |
| 存放位置 | .plt 段 |
.got.plt 段(通常简称为 GOT 表) |
| 作用 | 负责把程序的调用请求,转交给对应的 GOT 表项。 | 里面记录着真正函数的实际内存地址。 |
plt和got表运行过程:
当程序编译出来但是没运行时:
- PLT 地址: 是固定的。比如在 IDA 里看到
.plt段有个puts@plt,地址是0x400560。
plt的地址一般存的是一串代码
1 | ; 这里就是 puts@plt (0x400560) 内部真正的样子 |
- GOT 地址(虚拟地址): 也是固定的。比如
.got.plt段有个puts_got,地址是0x601018。注意:这个0x601018只是存放puts函数真实地址的地址,也就是说[0x601018]存的就是puts函数的地址。
程序运行时:
Linux 为了启动快,采用了延迟绑定 (Lazy Binding) 机制。也就是说,程序刚跑起来时,GOT 表里其实是没有真实地址的。
第一次调用
puts:程序跳到
puts@plt(0x400560)。PLT去看GOT表(0x601018)里存了啥。此时
GOT表里存的还是PLT的下一条指令地址(跳回去了)。接着触发动态链接器(
_dl_runtime_resolve),链接器去libc.so里找到puts的真实地址(比如0x7ffff7a94300)。链接器把这个真实地址写进
0x601018这个格子里,然后执行puts。
第二次调用
puts:程序跳到
puts@plt。PLT去看GOT表。这次
GOT表里已经是真实地址 (0x7ffff7a94300) 了。直接去执行
plt表和got表的使用:
在写 EXP 脚本时,我们到底什么时候用 .plt,什么时候用 .got?
elf.plt['xxx']—— 你的目的是“调用/执行”它
当你构造 ROP 链,想要让程序去执行某个函数时,用 PLT 地址。
- 底层含义: 你获取的是 IDA 中固定的那段执行代码的地址。因为 PLT 会通过 GOT 去动态解析 libc 里的真正地址。
- 打印:
1 | # 你想让程序打印东西,就直接跳到 puts 的 PLT 存根去执行 |
elf.got['xxx']—— 你的目的是“泄露”或“篡改”它(因为got表存的是数据,rdi到这里直接报错,除非栈可执行)
当你需要知道某个函数在 libc 中的真实内存地址,或者你想把某个函数劫持掉时,用 GOT 地址。
底层含义: 你获取的是那个存放了真实地址的“格子”的固定虚拟地址。给出的永远是程序的 GOT 表项地址,不是 libc 的真实函数地址。
泄露:
# 你的笔记中的经典用法:把 GOT 表项的地址传给 puts 去打印,以此泄露里面装的真实 libc 地址 adr_libc_main = elf.got['__libc_start_main'] payload = b'a'*18 + p64(adr_rdi) + p64(adr_libc_main) + p64(adr_puts) ...GOT 表劫持): 假设题目存在任意地址写漏洞(比如格式化字符串漏洞),你可以把
elf.got['printf']里的内容,改写成system的真实地址。这样程序下次再调用printf("/bin/sh")时,实际上执行的是system("/bin/sh")!
基础攻击方法分类
Ret2text
最基础的溢出利用,直接覆盖返回地址,跳转到程序自带的后门函数或目标地址。
1 | from pwn import * |
Ret2shellcode
当存在可执行的内存段(如未开启 NX 保护的栈,或可读写执行的 .bss 段)时,可以写入自定义的恶意机器码(shellcode)并跳转执行。
32位短字节 shellcode (21字节):
\x6a\x0b\x58\x99\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\xcd\x8064位较短 shellcode (23字节):
\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05
利用脚本示例(向 bss 段写 shellcode 并跳转):
1 | from pwn import * |
Ret2libc
适用场景: 程序开启了 NX 保护(栈不可执行),且为动态链接程序(运行时加载 libc.so)。
判断:
用 file 命令
file ./a.out
如果输出里有 “dynamically linked”,说明它运行时要加载 libc。
如果写着 “statically linked”,说明它已经把 libc 打包进 ELF,不需要外部 libc。
重要概念:
虚拟地址:ida pro存放printf函数的got.plt对应的地址,比如pwntools里写elf.got[‘printf’]拿到的就是printf的虚拟地址,虚拟地址里存放的值才是真实地址
真实地址:运行时printf函数的真实地址,也就是got表项虚拟地址里存放的值
libc基址:libc.so的基地址
libc偏移:libc.so中printf函数的真实地址与libc.so的基地址的差值
ret2libc分四种情况,每种情况又分32位和64位架构都有对应模板:
32位
有sys,有shell
最简单的情况,直接调用
1 | #!/usr/bin/env python |
有sys,无shell
注意,往函数中输入参数时,32位是通过栈传递的,64位是通过寄存器传递的,32位函数地址在前,64位寄存器在前,函数在后
1 |
|
无sys,有shell
这里没有sys,那就不能直接拿他的地址了
1 | adr_sys = elf.plt['system'] |
因为我们代码里没有system函数
PLT 表是按需生成的,它不是 libc 的完整目录。
在编译时,编译器会去检查你的 C 语言源码。
编译器只会为你源码里真正写出并调用过的外部函数,在
.plt段生成对应的链接存根(Stub)。也就是说,如果原程序的开发者在写代码时,只用了
puts、read和printf,根本没在代码里写过system(...),那么编译出来的可执行文件里就绝对没有system@plt。
如果你在 Pwntools 脚本里强行写 elf.plt['system'],Python 会当场给你报一个 KeyError: 'system' 的红字错误,因为它在 ELF 文件的符号表里根本找不到这个玩意儿。
步骤:
libc里啥都有,直接去libc里拿sys函数地址就行(参考无sys,无shell),然后参数传shell
寻找输出函数(如
puts或write)泄露某个 GOT 表项(比如__libc_start_main_got)。计算出 libc 基址,进而算出真实的
system函数地址。再次溢出时,直接把程序自带的
/bin/sh地址作为参数传给算出来的system函数。 _(注:这和“无sys无shell”的步骤几乎一样,只是省去了去 libc 里找/bin/sh的那一步,直接就地取材。)
无sys,无shell
步骤
泄露 __libc_start_main 地址
获取 libc 版本(如果题目把libc附件给你了,就用strings libc.so | grep -i “release”获取版本)
获取 system 地址与 /bin/sh 的地址
再次执行源程序
触发栈溢出执行 system(‘/bin/sh’)
1 | #!/usr/bin/env python |
64位
有sys,有shell
1 |
|
有sys,无shell
1 | # 有sys,无shell(64位) |
无sys,有shell
参考无sys,无shell
无sys,无shell
1 | from pwn import * |
Ret2syscall
这里面就没有plt表和got表了,不能借助libc的力量了
小tips:
没有完整的/bin/sh,但有’sh’,可以用sh,但别用’/sh’
调用
system("sh")时,系统会去你环境变量的PATH路径(比如/bin,/usr/bin等)里去搜索名字叫sh的程序。它能顺利找到/bin/sh并执行,所以你能拿到 Shell。用
'/sh'绝对不行 加上了前面的斜杠/,这就变成了一个绝对路径。当你调用system("/sh")时,系统会严格按照你的指示,去 Linux 的根目录(/)下面找一个名字叫sh的文件。很显然,根目录下没有这个文件,程序会直接报错 “No such file or directory”
使用 p32(adr_system) + p32(0) + p32(str_binsh) 来构造rop链执行
system("\bin\sh")需要加p32(0) 来伪造返回地址
是因为你直接跳到了 system 函数,它执行完后会 ret,所以你必须手动提供返回地址;(只有sys_plt和shell地址,没有调用call system)
使用 call system gadget 的话,它自动 push eip(返回地址)并跳转到 system,因此你不需要提供伪返回地址,参数直接跟上就行。
考点一:最理想状态:没sys有shell
特征: 静态编译程序,且没开沙盒。存在完整的
/bin/sh字符串,且各种pop reg; ret的 Gadget 随便挑。解法: 这是送分题。直接按照系统调用规矩:32 位给
eax,ebx,ecx,edx赋值并触发int 0x80;64 位给rax,rdi,rsi,rdx赋值并触发syscall。自动化杀器: 这种题甚至不需要手写代码,直接终端一把梭:
ROPgadget --binary ./pwn --ropchain。
普通exp:(无sys有/bin/sh)
1 | #!/usr/bin/env python |
自动化生成payload:
1 | from pwn import * |
考点二:无sys无shell
分两类:
不限制输入长度:
ROPgadget --binary ./pwn --ropchain。
自动化生成payload rop链
限制输入长度:
利用 read 系统调用写入: 构造一次 sys_read(0, bss_addr, length),让程序停下来等你输入,你顺势输入 /bin/sh\x00 到 bss 段。接着再构造一次 sys_execve(bss_addr, 0, 0)。
1 | from pwn import * |
考点三:极其缺 Gadget —— 凑不出系统调用号 rax
特征: 想执行 execve,64 位下 rax 必须是 59。但是你搜遍了程序,根本没有 pop rax; ret!
解法(三大骚操作):
算术运算硬凑: 找找有没有
xor rax, rax(清零),然后配合inc rax(加一) 或者add rax, 1。连续放 50 多次add rax, 1 ; ret,这就是为了硬生生把rax从 0 加到 59 以触发execve利用函数返回值 (Return Value): 在 Linux 系统调用中,
read和recv函数执行完后,会把实际读取到的字节数存放在rax寄存器里。如果你能控制程序执行一次read,你只需在键盘上精准输入 59 个字节的垃圾数据,此时rax就神奇地变成了 59!紧接着执行syscall,直接起飞!SROP (Sigreturn Oriented Programming): 如果连算术指令和返回值都借不到,但你能控制
rax = 15(sys_sigreturn的调用号),你就可以直接伪造整个 CPU 的寄存器上下文状态(极高级考点,后续可以单独开一篇讲)。
考点四:极其缺 Gadget —— 找不到控制 rdx 的指令
特征: 在 execve("/bin/sh", 0, 0) 中,第三个参数 envp 需要传给 rdx。但在 64 位程序中,pop rdx; ret 这个指令经常像人间蒸发一样找不到。
解法: 如果 rdx 实在找不到,也不一定是绝境。在多数 Linux 内核版本中,如果 rdx 里是些乱七八糟的非零值,execve 依然有可能执行成功。但如果不幸崩溃,你可以尝试利用 64 位 ELF 文件自带的万能函数 __libc_csu_init(也就是经典的 Ret2csu 技巧)。这个函数内部有一套极其稳定的寄存器赋值链,可以完美控制 rbx, rbp, r12, r13, r14, r15,并通过 mov rdx, r15 间接控制 rdx。
考点五:沙盒机制 (Seccomp / PRCTL)
特征: 题目的确是静态编译,你也算好了所有的 ret2syscall 链,但发过去直接显示 Bad system call (核心已转储)。
解法: 这是目前比赛中最常见的一堵墙。出题人用 seccomp 禁用了 execve (59 / 0xb) 这个系统调用。拿到 Shell 已经不可能了,你必须把 ret2syscall 的目标改成 ORW (Open - Read - Write):
Open: 构造
sys_open("/flag", 0),系统会返回一个文件描述符(通常是 3,因为 0,1,2 被标准输入输出占了)。Read: 构造
sys_read(3, bss_addr, 0x50),把 flag 从文件里读到内存的.bss段。Write: 构造
sys_write(1, bss_addr, 0x50),把.bss段里的 flag 打印到你的屏幕(标准输出 1)上。
- 本文链接: http://example.com/2026/03/12/PWN/pwn_attack/stack_overflow/栈溢出基础/
- 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
欢迎关注我的其它发布渠道