PWN(0)

24 March 2026
8 mins

好久没有学网安了,偶尔也要学一学,现在基本功也还算扎实。打算开始好好学一下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可以探测以下几种保护机制:

保护机制开启时的状态关闭时的状态作用
RELROFull RELRONo RELRO决定能否修改 GOT 表
CanaryCanary foundNo canary found决定能否直接覆盖返回地址
NXNX enabledNX disabled决定能否执行栈上 Shellcode
PIEPIE enabledNo PIE决定地址是否固定

接下来就简单的介绍一下这几个保护机制的作用和大概的绕过思路:

RELRO(Relocation Read-Only)

用于保护 GOT(全局偏移表)不被篡改。GOT 表存储了动态链接函数的实际地址,攻击者常通过覆写 GOT 表项来劫持程序流程。

通常会看到三种级别的保护:

对于NO RELRO和Partial RELRO,意味着可以直接覆写.got.plt表来劫持实际的执行流程(换成想要执行的函数)。如果是FULL RELRO就无法使用这个方法。

Stack Canary

编译时,在栈帧中(局部变量和返回地址之间)插入一个随机值,函数返回前检查该值是否被篡改。如果被修改,程序调用 __stack_chk_fail 终止。用于判断栈溢出。

低地址
+-----------------+
|    局部变量      |  ← 溢出从这里开始
+-----------------+
|     canary      |  ← 随机值(通常以 \x00 结尾)
+-----------------+
|    saved ebp    |
+-----------------+
|    返回地址      |  ← 想要覆盖的目标
+-----------------+
高地址

通常有以下方法绕过:

这个还是比较常见的,之后会经常遇到。

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的作用,则是修改程序加载时的基址。

我们可以通过一个真实的函数位置和相对偏移求出随机基址,然后在此基础之上,推导这个程序段的绝对地址。

工具准备

准备以下常用工具:


← 一生一芯F3(1)

code a TCP/IP(1) →