好久没有学网安了,偶尔也要学一学,现在基本功也还算扎实。打算开始好好学一下pwn,和一些常见的漏洞利用,精进一下技术。顺便就当是给AI agents整理文档了。
在PWN之前
以下内容均基于x86,32bit和64bit会标注出来
函数调用过程的ABI
ABI 是 应用程序二进制接口(Application Binary Interface)的缩写。简单来说,它是一套二进制层面的规则,定义了不同程序模块之间如何交互。
要学习栈溢出首先应该认识栈的在系统层面的作用,即函数调用的过程:
程序的执行过程可看作连续的函数调用。当一个函数执行完毕时,程序要回到调用指令的下一条指令(紧接call指令)处继续执行。函数调用过程通常使用堆栈实现,每个用户态进程对应一个调用栈结构(call stack)。编译器使用堆栈传递函数参数、保存返回地址、临时保存寄存器原有值(即函数调用的上下文)以备恢复以及存储本地局部变量。
所以要认识栈溢出,我们需要了解寄存器和调用栈是怎么协同完成函数调用的过程。(这一部分参考官方文档)
i386(32bit)
实际上我们需要知道的只有三点:
- 函数调用的过程中,参数是如何传递的?
- 函数调用的过程中,返回值怎么被传递?
- 函数调用的栈帧是什么样的?
先从寄存器说起,在i386中提供了8个通用寄存器,每个寄存器有不同的作用:
| 寄存器 | 用途 | 保存者 | 说明 |
|---|---|---|---|
| %eax | 返回值、累加器 | 调用者保存 | 函数返回值存放在此;也用于部分运算 |
| %ebx | 基址寄存器 | 被调用者保存 | 需保留原值 |
| %ecx | 计数器、第4个参数 | 调用者保存 | 循环计数、参数传递 |
| %edx | 数据寄存器、第3个参数 | 调用者保存 | 除法/乘法运算、参数传递 |
| %esi | 源索引、第2个参数 | 被调用者保存 | 字符串操作、参数传递 |
| %edi | 目标索引、第1个参数 | 被调用者保存 | 字符串操作、参数传递 |
| %ebp | 帧指针 | 被调用者保存 | 指向当前栈帧基址 |
| %esp | 栈指针 | 被调用者保存 | 始终指向栈顶 |
在i386中函数调用的参数通过栈进行传递。只有在指定的优化情况下才会使用寄存器来存放参数。
接下来演示函数调用过程中栈帧的变化,每个函数的栈帧由以下的固定部分构成:
高地址
+-------------------------+
| 调用者栈帧的其他部分 | ← 调用者的局部变量、临时空间等
+-------------------------+
| 参数区域 | ← 被调用函数的参数(从右向左压栈)
| (argument area) |
+-------------------------+
| 返回地址 | ← call 指令压入
| (return address) |
+-------------------------+
| 保存的帧指针 | ← push %ebp(可选,优化编译可能省略)
| (saved %ebp) |
+-------------------------+ ← %ebp(帧指针)
| 局部变量区域 | ← 被调用函数的局部变量
| (local variables) |
+-------------------------+
| 临时/溢出区域 | ← 编译器分配的临时空间、对齐填充
| (temporary area) |
+-------------------------+ ← %esp(栈指针)
低地址
一般分析的时候是以帧指针 %ebp 为基准,各区域的固定偏移如下(实际偏移值会有变动):
偏移量(字节) 内容
+∞
↑
│
+12 → 参数 2(若存在) *栈对齐要求为4字节*
+8 → 参数 1
+4 → 返回地址
0 → 保存的 %ebp ← 当前 %ebp 指向此处
-4 → 局部变量 1
-8 → 局部变量 2
↓
-∞
栈帧结构的变化是通过函数序言和尾部来驱动的:
push arg(n) ; 参数从右往左压入
push arg(n-1)
; ...
call <func> ; 将下一条指令的地址压入栈中
;... 指令 ...
func:
push %ebp ; 保存调用者的帧指针
mov %esp, %ebp ; 设置当前函数的帧指针
sub $N, %esp ; 分配局部变量空间 (N字节)
; ... 函数体 ...
leave ; mov %esp, %ebp + pop %ebp
ret ; 弹出返回地址并跳转
以下是每个环节中%ebp和%esp中要发生的变化
| 阶段 | 操作 | 栈变化 | %ebp 变化 | %esp 变化 |
|---|---|---|---|---|
| 调用前 | main 序言 | 分配局部变量空间 | 指向 main 帧基址 | 指向栈顶 |
| 参数传递 | push 参数 | 压入参数值 | 不变 | 减 4×参数数 |
| 调用 | call | 压入返回地址 | 不变 | 减 4 |
| 被调用者序言 | push %ebp | 压入旧 %ebp | 不变 | 减 4 |
| mov %esp, %ebp | 设置新帧指针 | 指向当前帧底 | 不变 | |
| sub $N, %esp | 分配局部变量 | 不变 | 减 N | |
| 返回 | leave | 恢复栈和帧指针 | 恢复调用者值 | 指向返回地址 |
| ret | 弹出返回地址 | 不变 | 加 4 | |
| 调用后 | add $M, %esp | 清理参数 | 不变 | 加 M |
x86-64(64bit)
x86-64在结构上的和i386上差不多,但是在一些具体的做法上还是有些许差异。先从寄存器说起:
| 寄存器 | 用途 | 保存者 | 说明 |
|---|---|---|---|
| %rax | 返回值、累加器 | 调用者保存 | 函数返回值存放在此 |
| %rbx | 基址寄存器 | 被调用者保存 | 需保留原值 |
| %rcx | 计数器、第4个参数 | 调用者保存 | 循环计数、参数传递 |
| %rdx | 数据寄存器、第3个参数 | 调用者保存 | 除法/乘法运算、参数传递 |
| %rsi | 源索引、第2个参数 | 调用者保存 | 字符串操作、参数传递 |
| %rdi | 目标索引、第1个参数 | 调用者保存 | 字符串操作、参数传递 |
| %rbp | 帧指针 | 被调用者保存 | 指向当前栈帧基址(可选) |
| %rsp | 栈指针 | 被调用者保存 | 始终指向栈顶 |
| %r8 | 第5个参数 | 调用者保存 | 通用寄存器 |
| %r9 | 第6个参数 | 调用者保存 | 通用寄存器 |
| %r10 | 临时寄存器 | 调用者保存 | 用于动态链接等 |
| %r11 | 临时寄存器 | 调用者保存 | 用于动态链接等 |
| %r12 | 通用 | 被调用者保存 | 需保留原值 |
| %r13 | 通用 | 被调用者保存 | 需保留原值 |
| %r14 | 通用 | 被调用者保存 | 需保留原值 |
| %r15 | 通用 | 被调用者保存 | 需保留原值 |
这里需要注意:x86-64 中前 6 个整数/指针参数通过寄存器传递,第 7 个及之后才使用栈。这与 i386 完全不同。
栈帧结构则基本相同,多出来的红区是用于程序性能优化的,我们可以忽略:
高地址
+-------------------------+
| 调用者栈帧的其他部分 | ← 调用者的局部变量、临时空间等
+-------------------------+
| 参数区域 | ← 第7个及之后的参数(从右向左压栈)
| (argument area) |
+-------------------------+
| 返回地址 | ← call 指令压入
| (return address) |
+-------------------------+
| 保存的帧指针 | ← push %rbp(可选,优化编译可能省略)
| (saved %rbp) |
+-------------------------+ ← %rbp(帧指针,如果使用)
| 局部变量区域 | ← 被调用函数的局部变量
| (local variables) |
+-------------------------+
| 临时/溢出区域 | ← 编译器分配的临时空间、对齐填充
| (temporary area) |
+-------------------------+
| 红区 (Red Zone) | ← 128 字节保留区域,叶子函数可直接使用
| (128 bytes) |
+-------------------------+ ← %rsp(栈指针)
低地址
当使用帧指针时,各区域的固定偏移如下,注意在x86-64中的栈对齐要求是16字节:
偏移量(字节) 内容
+∞
↑
│
+24 → 参数 8(若存在,第8个参数)
+16 → 参数 7(若存在,第7个参数)
+8 → 返回地址
+0 → 保存的 %rbp ← 当前 %rbp 指向此处
-8 → 局部变量 1
-16 → 局部变量 2
↓
-∞
函数的序言和尾部则基本相同,只不过参数的传递方式会略有不同。
而栈指针的变化也基本相同,只是需要注意在x86-64下寄存器是8字节的。
保护机制
这一部分初学者可以暂时跳过
在实际攻击的过程中,我们面对的程序可能会开启不同的保护机制。这些保护机制会限制我们的攻击角度,不同的保护机制组合,意味着我们需要通过不同的技术来实现绕过。不过在此之前我们需要认识这些保护机制并学会探测。
我们可以通过pwntools中的工具来探测一个程序的保护机制pwn checksec <file>,会得到以下结构:
[*] '/home/Ylin/programs/C/build/native.elf'
Arch: amd64-64-little # 机器的架构
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No # 未剥离符号表
Debuginfo: Yes # 保留了调试信息
中间的则是程序开启的保护机制,在一般情况下checksec可以探测以下几种保护机制:
| 保护机制 | 开启时的状态 | 关闭时的状态 | 作用 |
|---|---|---|---|
| RELRO | Full RELRO | No RELRO | 决定能否修改 GOT 表 |
| Canary | Canary found | No canary found | 决定能否直接覆盖返回地址 |
| NX | NX enabled | NX disabled | 决定能否执行栈上 Shellcode |
| PIE | PIE enabled | No PIE | 决定地址是否固定 |
接下来就简单的介绍一下这几个保护机制的作用和大概的绕过思路:
RELRO(Relocation Read-Only)
用于保护 GOT(全局偏移表)不被篡改。GOT 表存储了动态链接函数的实际地址,攻击者常通过覆写 GOT 表项来劫持程序流程。
通常会看到三种级别的保护:
No RELRO:无保护,GOT完全可写,延迟绑定Partial RELRO:部分保护,.got只读,.got.plt可写Full RELRO:完全保护,GOT只读,立即绑定。
对于NO RELRO和Partial RELRO,意味着可以直接覆写.got.plt表来劫持实际的执行流程(换成想要执行的函数)。如果是FULL RELRO就无法使用这个方法。
Stack Canary
编译时,在栈帧中(局部变量和返回地址之间)插入一个随机值,函数返回前检查该值是否被篡改。如果被修改,程序调用 __stack_chk_fail 终止。用于判断栈溢出。
低地址
+-----------------+
| 局部变量 | ← 溢出从这里开始
+-----------------+
| canary | ← 随机值(通常以 \x00 结尾)
+-----------------+
| saved ebp |
+-----------------+
| 返回地址 | ← 想要覆盖的目标
+-----------------+
高地址
通常有以下方法绕过:
- 格式化字符串漏洞泄露canary
- 在32位环境中爆破,根据
fork()继承canary的原理 - 劫持检查函数
__stack_chk_fail
这个还是比较常见的,之后会经常遇到。
NX (No-eXecute)
CPU 的页表机制,将内存页标记为不可执行。栈、堆、数据段只有读写权限,没有执行权限。
当NX开启时,栈上只有读写的权限,关闭后具有读写和执行的权限。对于NX没有开启的情况,我们可以在栈上布置好shellcode,然后通过控制返回地址的方式跳过去执行。
这种保护机制的利用属于比较常见和简单的。
PIE (Position Independent Executable)
程序代码段可以加载到随机基址,内部使用相对寻址。配合 ASLR 使代码段地址随机化。
如果开启了PIE,程序在加载时的代码段地址就是随机;否则则是代码段固定从0x400000地址开始。可以从下面的例子中看出:
# no-pie
0000000000401126 <main>:
401126: 55 push %rbp
401127: 48 89 e5 mov %rsp,%rbp
40112a: 48 8d 05 d3 0e 00 00 lea 0xed3(%rip),%rax # 402004 <_IO_stdin_used+0x4>
401131: 48 89 c7 mov %rax,%rdi
401134: e8 f7 fe ff ff call 401030 <puts@plt>
401139: b8 00 00 00 00 mov $0x0,%eax
40113e: 5d pop %rbp
40113f: c3 ret
# pie
0000000000001139 <main>:
1139: 55 push %rbp
113a: 48 89 e5 mov %rsp,%rbp
113d: 48 8d 05 c0 0e 00 00 lea 0xec0(%rip),%rax # 2004 <_IO_stdin_used+0x4>
1144: 48 89 c7 mov %rax,%rdi
1147: e8 e4 fe ff ff call 1030 <puts@plt>
114c: b8 00 00 00 00 mov $0x0,%eax
1151: 5d pop %rbp
1152: c3 ret
通常,一个真实的内存地址可以看作两部分构成:地址 = 随机基址 + 页内偏移,而PIE的作用,则是修改程序加载时的基址。
我们可以通过一个真实的函数位置和相对偏移求出随机基址,然后在此基础之上,推导这个程序段的绝对地址。
工具准备
准备以下常用工具:
- Linux环境(一般的pwn环境是在linux的用户态下)
- python + pwntools
- ida pro / Ghidra
- gdb / pwndbg