2026-03-24

好久没有学网安了,偶尔也要学一学,现在基本功也还算扎实。打算开始好好学一下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 RELROPartial 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