1. 为什么“简单ROP”不是入门捷径而是检验栈溢出理解的试金石很多人看到“栈溢出学习三之简单ROP”这个标题第一反应是“终于到实战了”、“可以绕过NX/DEP保护了”、“离pwn题拿分不远了”——这种期待很真实但恰恰埋下了第一个认知陷阱。我带过不少刚接触二进制安全的开发者他们花两周啃完ret2text、ret2libc信心满满去跑第一个ROP链结果卡在pop rdi; ret找不到、systemplt地址算错、或者payload一发就段错误反复调试三小时毫无头绪。问题不在于工具或环境而在于把“ROP”当成了一个独立技能点去记忆却忽略了它本质是对栈布局、调用约定、内存映射和指令执行流控制能力的综合投射。“简单ROP”之所以被冠以“简单”不是因为技术门槛低而是指它不依赖堆喷射、UAF、内核漏洞等高阶利用手段仅靠一次可控的栈溢出已知的二进制片段gadget就能达成目标。它的“简单”是相对的背后需要你清晰回答四个问题第一当前栈帧在触发溢出时的精确布局是什么返回地址覆盖后下一条指令从哪里取第二目标函数如system的调用约定如何满足x86-64下system(/bin/sh)要求rdi寄存器指向字符串地址这个值怎么塞进去第三程序加载基址是否随机化ASLR如果开启了你硬编码的gadget地址必然失效第四你找到的gadget是否真的“干净”比如一个pop rax; pop rdx; ret后面紧跟着mov rax, [rax]那它就不是纯跳转gadget而是会引发二次读取极易导致崩溃。这四个问题每一个都直指栈溢出的核心机制。比如栈布局它不是静态的内存图而是动态的执行快照main函数的栈帧里有局部变量、保存的寄存器、返回地址read系统调用写入数据时是从rbp-0x100这样的偏移开始填充而你覆盖的返回地址必须精准落在ret指令之后让CPU把你的伪造地址当作下一条指令去取。这不是靠“多试几次”能解决的而是要你能用gdb单步到ret指令执行前那一刻用x/20xg $rsp命令亲眼看到栈顶的8字节是什么——这才是“简单ROP”的起点。我见过太多人直接抄网上payloadpython3 -c print(A*120 \x7f\x12\x34...)一跑就崩原因就是没验证过自己程序里read的缓冲区到底有多大120这个数字是来自另一份CTF题目的答案而非本程序的实测。再比如调用约定这是最容易被忽略的底层逻辑。x86-64 ABI规定前六个整数参数依次通过rdi,rsi,rdx,r10,r8,r9传递。system只接受一个参数所以你必须确保rdi在call system之前已被正确赋值。而pop rdi; ret这个gadget的作用就是把栈顶的一个8字节值弹出来送给rdi然后跳转到下一个地址。这意味着你的payload结构必须是[padding][pop_rdi_addr][sh_str_addr][system_addr]。这里sh_str_addr不能是随便一个字符串地址它必须存在于程序的.data段或.rodata段且可读——因为system内部会去读取它。我曾经在一个实验中把/bin/sh硬编码在payload里然后用pop rdi; ret把它弹进rdi结果system一执行就报SIGSEGV。后来用readelf -S ./vuln才发现我写的字符串被放在了栈上而栈是不可执行的NX保护system尝试解析字符串时触发了页错误。真正的解法是要么找程序本身已有的字符串比如/bin/sh在libc.so.6的__libc_start_main附近就有要么用write系统调用把字符串先写到.bss段可读写再让rdi指向那里。所以“简单ROP”不是一道菜名而是一张能力检查表。当你能不假思索地画出溢出发生时的栈帧图能用ROPgadget --binary ./vuln --only pop|ret快速筛出可用gadget能用checksec ./vuln一眼看出ASLR和NX状态并据此决定是用ret2libc还是ret2csu那你才算真正跨过了那道门槛。否则所有“简单”的标签都只是给初学者的安慰剂。2. 从零构建第一个ROP链环境、工具与不可妥协的验证步骤搭建一个可复现、可调试的ROP学习环境远比写payload本身更重要。我见过太多人跳过这一步直接在CTF平台或远程靶机上折腾结果连最基础的栈偏移都测不准更别说构造链了。下面是我个人坚持了五年的标准流程它不追求“最快”但保证“最稳”每一步都有明确的验证目标。2.1 环境准备一个绝对可控的靶机我们不用任何现成的CTF题目而是亲手写一个极简的、漏洞明确的C程序。它必须满足三个条件无ASLR、无Stack Canary、NX开启即只禁用栈执行不禁止数据段。这样能隔离变量聚焦ROP核心逻辑。// vuln.c #include stdio.h #include unistd.h void vulnerable() { char buf[128]; // 栈上分配128字节缓冲区 read(0, buf, 256); // 故意读超造成溢出 printf(Done.\n); } int main() { vulnerable(); return 0; }编译命令必须显式指定所有保护开关gcc -m64 -no-pie -z execstack -fno-stack-protector -z norelro -o vuln vuln.c逐个解释这些flag的意义-m64强制64位避免32位和64位调用约定混淆-no-pie关闭位置无关可执行文件确保程序加载基址固定为0x400000这是ROP链地址计算的基石-z execstack关键这里不是为了绕过NX而是为了后续调试时能用gdb在栈上设置断点。实际ROP链中我们绝不会把shellcode放栈上所以这个flag仅用于学习环境不影响对NX的理解-fno-stack-protector关闭栈保护避免canary检测干扰溢出过程-z norelro关闭RELRO确保GOT表可写虽然本例不用但为后续ret2plt留接口。编译完成后立刻用checksec验证$ checksec ./vuln [*] ./vuln Arch: amd64-64-little RELRO: No RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000) RWX: Has RWX segments看到PIE: No PIE和NX: NX enabled同时存在说明环境达标。如果PIE是Partial或Full说明-no-pie没生效必须重编译。2.2 工具链不是越多越好而是每个都懂原理我只用三个核心工具且每个都要求能手写等效逻辑gdb-peda不是用它的pattern create一键生成偏移而是先用python3 -c print(A*100) | ./vuln触发崩溃再用gdb ./vulnrun crash.txtinfo registers看rip值最后用pattern offset $rip反查。这个过程强迫你理解pattern的本质是唯一字符串哈希。ROPgadget不用--ropchain自动生成它生成的链往往冗长且难调试而是用--only pop|ret筛选再用--string /bin/sh搜索字符串。重点是学会看输出0x0000000000401234 : pop rdi ; ret这个地址是相对于0x400000的偏移所以实际gadget地址就是0x401234。pwntools只用最基础的p64()和remote()/process()绝不碰ROP()类高级封装。因为p64(0x401234)就是把十六进制数转成小端序的8字节这是内存操作的物理事实而ROP()会隐藏字节序、对齐等细节让你失去对原始字节的掌控感。2.3 不可跳过的四步验证从崩溃到可控执行构造ROP链不是一蹴而就而是分四步渐进验证每步失败都必须定位到具体原因第一步确认精确栈偏移目标让rip精确覆盖为一个已知值如0xdeadbeef并验证gdb中rip确实变为该值。 操作用gdb运行程序在vulnerable函数ret指令处下断点b *vulnerable0x3a用objdump -d ./vuln | grep ret找偏移run后x/20xg $rsp看栈顶。此时栈顶8字节就是ret要弹出的返回地址。用python3 -c print(A*136 \xef\xbe\xad\xde\x00\x00\x00\x00)136128缓冲区8保存的rbp发送continue后info registers确认rip0xdeadbeef。如果rip是0x00000000deadbeef说明字节序正确如果是0xefbeadde00000000说明你用了大端序必须改用p64(0xdeadbeef)。第二步定位并验证第一个gadget目标用pop rdi; ret把rdi设为一个可控值如0x12345678并在gdb中验证rdi确实被修改。 操作先用ROPgadget --binary ./vuln --only pop rdi | head -n1找到gadget地址假设为0x401234。payload变为A*136 p64(0x401234) p64(0x12345678) p64(0x401000)最后一个地址随便填只要不崩溃。在gdb中b *0x401234run停在pop rdi处x/gx $rsp确认栈顶是0x12345678ni单步后info registers看rdi是否等于该值。这一步验证了gadget的“弹出”功能和栈地址的精确控制。第三步定位system地址并验证调用目标让rdi指向/bin/sh字符串rip跳转到system并在gdb中看到system函数被调用。 操作readelf -d ./vuln | grep NEEDED确认程序链接了libc然后用ldd ./vuln得到libc.so.6路径。用strings -a -t x /lib/x86_64-linux-gnu/libc.so.6 | grep /bin/sh找到/bin/sh在libc中的偏移假设为0x1b3e9a。再用objdump -d /lib/x86_64-linux-gnu/libc.so.6 | grep systemplt找到system的PLT地址假设为0x45390。注意PLT地址是固定的但libc基址是随机的所以这一步仅用于本地调试证明链逻辑正确。payload末尾改为p64(system_plt) p64(0x401000)0x401000是ret后的占位符防止system返回时崩溃。在gdb中b *systempltrun停住即证明调用成功。第四步整合完整链并获取shell目标rdi指向/bin/shrip跳转到system最终/bin/sh启动。 操作此时/bin/sh字符串不在libc里因为libc基址未知必须找程序自身的字符串。用strings -a -t x ./vuln | grep /bin/sh如果没找到就用printf /bin/sh\x00 sh.bin创建再用read系统调用把它读入.bss段需先找.bss地址readelf -S ./vuln | grep \.bss。但为求“简单”我们采用最直接的方案关闭ASLR临时调试。echo 0 | sudo tee /proc/sys/kernel/randomize_va_space然后重新运行./vuln此时libc基址固定system和/bin/sh地址均可硬编码。最终payloadfrom pwn import * p process(./vuln) pop_rdi 0x401234 system_libc 0x7ffff7a2d390 # 本地libc中system地址 sh_libc 0x7ffff7b9d89a # 本地libc中/bin/sh地址 payload bA * 136 payload p64(pop_rdi) payload p64(sh_libc) payload p64(system_libc) p.send(payload) p.interactive()运行后若出现$提示符说明成功。但请记住这只是学习闭环真实环境中必须用leak来绕过ASLR。提示每一步验证失败都不要急于改代码。先问gdb里rsp的值是多少栈顶8字节是什么rip停在哪个地址用x/10i $rip看那里的汇编。ROP的本质是内存地址的精确操控所有问题最终都会回归到这三个寄存器的值上。3. gadget挖掘的底层逻辑为什么pop rdi; ret是黄金标准而其他组合常踩坑在ROP学习中gadget不是“找得到就行”而是要理解它在CPU流水线中的真实行为。pop rdi; ret之所以被奉为黄金标准并非因为它多稀有而是因为它完美契合了x86-64调用约定中最关键的一环参数传递的原子性与确定性。而很多初学者热衷的pop rax; pop rdx; ret或mov rdi, rax; ret恰恰因为其“看似强大”而暗藏致命缺陷。下面我用三次真实的踩坑经历拆解gadget选择背后的硬件逻辑。3.1pop rdi; ret的不可替代性从寄存器生命周期说起pop rdi; ret的汇编指令只有两行401234: 5f pop rdi 401235: c3 ret它的执行流程是原子且可预测的pop指令从rsp指向的内存地址读取8字节写入rdi然后rsp自动加8ret指令从新的rsp地址读取8字节作为rip的新值。整个过程不依赖任何其他寄存器状态不修改内存不产生副作用。这就是它成为“黄金标准”的根本原因——它把栈上的一个8字节值无损、无歧义地转移到rdi然后无条件跳转。我第一次教某开发者写ROP时他坚持要用mov rdi, rax; ret理由是“更短少一个pop”。我让他用ROPgadget --binary ./vuln --only mov|ret搜果然找到了0x401100: mov rdi, rax; ret。他兴奋地把payload改成A*136 p64(0x401100)结果一运行就段错误。gdb调试发现rax寄存器的值是0x0因为vulnerable函数结束时rax未被初始化所以mov rdi, rax把rdi设为了0system(0)当然失败。问题根源在于mov指令依赖源寄存器rax的当前值而这个值是不可控的、易变的。rax可能在上一条指令中被清零也可能在库函数调用中被用作返回值它的状态完全取决于程序执行的历史路径无法在ROP链中被可靠设定。相比之下pop rdi的源是栈而栈是我们完全控制的。A*136 p64(pop_rdi) p64(sh_addr)中sh_addr是我们写死的pop rdi只是忠实地把它搬进rdi没有中间态没有历史依赖。这就是“确定性”的力量。3.2 多寄存器gadget的幻觉pop rax; pop rdx; ret为何常成陷阱pop rax; pop rdx; ret看起来很美一次弹两个值。但它的危险在于栈布局的脆弱性。这个gadget要求栈顶连续两个8字节分别对应rax和rdx。但在真实的ROP链中你很难保证这两个值恰好相邻且顺序正确。举个例子你想调用read(0, buf, 100)需要rdi0,rsibuf_addr,rdx100。你找到了pop rdi; pop rsi; ret那么payload是[padding][pop_rdi_rsi_ret][0][buf_addr][read_plt]。一切顺利。但如果你只有pop rax; pop rdx; ret你就得想办法把0放进rax100放进rdx而rsibuf_addr还得另外设。这通常需要多个gadget串联比如[padding] [pop_rax_ret] [0] # rax0 [pop_rdx_ret] [100] # rdx100 [pop_rsi_ret] [buf_addr] # rsibuf_addr [read_plt]这已经不是“简单ROP”了而是复杂链。更糟的是pop rax; pop rdx; ret本身可能不“干净”。我遇到过一个gadget0x401300: pop rax; pop rdx; ret但反汇编发现它后面紧跟着0x401302: mov rax, [rax]。这意味着pop rax后CPU会立即执行mov rax, [rax]试图把rax指向的内存内容读进来。如果rax被你设为0那么[rax]就是访问空指针直接SIGSEGV。而pop rdi; ret后面几乎总是ret或nop因为rdi很少被用作间接寻址的基址所以它天然更“干净”。3.3 “万能gadget”的真相ret2csu为何是进阶必修课当程序开启了Full RELROGOT表不可写ret2plt失效当libc基址随机化ret2libc需要先leak。这时ret2csu利用__libc_csu_init中的gadget就成了破局关键。它之所以强大不是因为指令多而是因为它提供了可控的寄存器赋值可控的跳转且不依赖外部地址。__libc_csu_init在libc中有一段固定模式的代码400740: 41 5c pop r12 400742: 41 5d pop r13 400744: 41 5e pop r14 400746: 41 5f pop r15 400748: c3 ret ... 400750: 4c 89 ea mov rdx,r13 400753: 4c 89 f6 mov rsi,r14 400756: 44 89 cf mov edi,r15d 400759: 41 ff 14 dc call QWORD PTR [r12rbp*8]这段代码的精妙之处在于前半段pop系列可以一次性把r12-r15设为我们想要的值后半段mov系列则把r13-r15的值分别传给rdx,rsi,edi然后call [r12rbp*8]。rbp是我们可控的通过栈溢出覆盖r12是我们可控的通过前面的pop所以[r12rbp*8]这个地址也是可控的。这意味着我们可以用它来调用任意地址的函数如readplt并同时设置好rdi,rsi,rdx三个参数。但这不是“简单ROP”而是需要深刻理解call指令的寻址模式和rbp在栈中的角色。我建议初学者先彻底掌握pop rdi; ret再挑战ret2csu。因为前者是基石后者是高楼。没有对单寄存器赋值的绝对掌控就无法理解多寄存器协同的精妙。注意所有gadget的可靠性最终都要回归到gdb中的x/10i $addr验证。不要相信ROPgadget的输出要亲眼看到那几条指令。我曾因一个gadget末尾是ret; nop; nop而浪费半天因为ret后nop不执行但gdb单步时会停在nop上让我误以为gadget有问题。4. ROP链的实战调试心法从gdb崩溃日志中读出十行代码外的信息调试ROP链90%的时间不是在写代码而是在读gdb的崩溃日志。一个Program received signal SIGSEGV, Segmentation fault.信息量巨大但多数人只看到“段错误”四个字就慌忙改payload。其实gdb的每一行输出都是CPU在向你发送精准的故障报告。下面是我总结的“崩溃日志解码七步法”它让我在三年内将ROP调试平均耗时从4小时缩短到20分钟。4.1 第一步锁定崩溃点区分“谁错了”gdb崩溃后第一眼要看的不是rip而是reason字段。执行info registers重点关注rip程序计数器指向即将执行的指令地址。rsp栈指针指向当前栈顶。r8-r15等通用寄存器它们的值是否符合你的预期但最关键的是reason。gdb的SIGSEGV有两种常见原因si_code: SEGV_MAPERR (1)地址未映射即你跳转到了一个不存在的内存页如0x00000000deadbeef。si_code: SEGV_ACCERR (2)地址存在但权限错误如向只读页写入或执行NX页。例如日志显示Program received signal SIGSEGV, Segmentation fault. 0x0000000000401234 in ?? ()rip0x401234这是一个合法的程序地址在0x400000附近si_code却是SEGV_MAPERR。这说明0x401234这个地址本身没问题但gdb无法解析它对应的符号in ??因为该地址可能是一个ret指令而ret之后的地址即rsp指向的地址才是问题所在。此时应立刻执行x/10xg $rsp看栈顶8字节是什么。如果它是0x0000000000000000说明你pop了一个空指针ret想跳到0x0自然失败。4.2 第二步逆向追踪栈重建执行流rsp是ROP链的命脉。gdb崩溃时rsp的值告诉你“CPU下一步想去哪”。执行x/20xg $rsp输出类似0x7fffffffe528: 0x0000000000401234 0x0000000000402000 0x7fffffffe538: 0x0000000000401000 0x0000000000000000 ...这表示栈顶是0x401234你的pop rdi地址第二个是0x402000你期望的/bin/sh地址第三个是0x401000你填的占位符。但如果崩溃发生在0x401234说明pop rdi执行时出了问题。此时用x/5i 0x401234看那里的指令0x401234: pop rdi 0x401235: ret没问题。那问题一定在pop rdi的源——即rsp指向的地址。执行x/gx $rsp如果输出是0x0000000000000000说明你payload里sh_addr写成了0而不是一个有效地址。这就是“栈逆向”的力量崩溃点是rip但根因在rsp。4.3 第三步指令级单步捕捉寄存器瞬态ninext instruction是ROP调试的灵魂。不要用ccontinue跳过任何ret。在pop rdi; ret处下断点ni单步执行pop rdi前x/gx $rsp→ 看rdi将被设为什么。执行pop rdi后info registers rdi→ 确认rdi是否等于预期。执行ret前x/gx $rsp→ 看rip将跳向何方。执行ret后info registers rip→ 确认跳转是否成功。我曾调试一个链rdi在pop后是正确的但ret后rip却变成了0x0。x/gx $rsp发现pop rdi后rsp已经加8指向了下一个8字节而那个8字节是我payload里一个未初始化的垃圾值。原来我在计算padding时少写了4个字节导致pop rdi后rsp没对齐到我预设的sh_addr而是对齐到了一个无效地址。ni单步暴露了这个字节级的偏差。4.4 第四步内存权限核查绕过NX的思维定式当si_code是SEGV_ACCERR且rip指向一个明显是代码段的地址如0x7ffff7a2d390libc中system的地址问题往往出在数据段权限。system(/bin/sh)会尝试读取/bin/sh字符串。如果这个字符串被你放在了栈上A*136 /bin/sh\x00而栈是NX的那么system内部的strlen或execve就会因尝试读取NX页而崩溃。解决方案不是关NX而是换地方放字符串。用readelf -S ./vuln找.bss段可读写Section Headers: [Nr] Name Type Address Offset [24] .bss NOBITS 0000000000404060 00003060.bss地址是0x404060大小通常足够。于是payload变成[A*136] [pop_rdi] [bss_addr] [pop_rsi] [0] [read_plt] [bss_addr] [pop_rdi] [bss_addr] [system_plt]前半段用read(0, bss_addr, 8)把/bin/sh\x00读进去后半段再调用system(bss_addr)。这比硬编码libc地址更鲁棒因为.bss地址是固定的-no-pie。4.5 第五步系统调用级验证超越system的视野system是方便但它是黑盒。当它失败时你不知道是fork失败、execve失败还是/bin/sh路径错误。更底层的调试方式是直接调用execve系统调用mov rax, 59 # sys_execve mov rdi, sh_addr # filename mov rsi, 0 # argv mov rdx, 0 # envp syscall这需要你找到pop rax; ret、pop rdi; ret等gadget并确保rsi和rdx为0。虽然麻烦但它绕过了libc的封装让你能精准控制每一个系统调用参数。我用此法解决过一个system因LD_PRELOAD环境变量污染而失败的案例——execve直接调用完全不受环境影响。提示gdb中用catch syscall execve可以捕获系统调用stepi单步进入内核前的最后一刻info registers能看到所有参数是否正确。这是ROP调试的“上帝视角”。5. 从实验室到真实世界ROP防御演进与学习者的务实路径“栈溢出学习三之简单ROP”这个标题像一个温柔的入口但它通向的是一条不断抬升的技术护城河。十年前ret2libc是主流五年前ret2csu和SROPSigreturn Oriented Programming开始普及今天Shadow Stack、CETControl-flow Enforcement Technology和MPKMemory Protection Keys正在从硬件层面重构游戏规则。作为学习者我们必须清醒学ROP不是为了在今天的CTF中拿分而是为了理解现代操作系统如何捍卫控制流以及攻击者如何持续寻找缝隙。下面是我为不同阶段的学习者规划的务实路径。5.1 初学者放弃“绕过所有保护”的幻想专注控制流建模如果你刚接触二进制安全首要任务不是攻克ASLRNXStack Canary全开的靶机而是建立一个精确的控制流心智模型。用我前面说的-no-pie -fno-stack-protector环境反复做三件事用gdb单步画出main→vulnerable→read→ret的完整栈帧图标出rbp、rsp、rip、局部变量buf的地址和大小。修改buf大小char buf[64]、buf[256]重新计算padding验证136这个数字如何随缓冲区变化。尝试不同的gadget组合pop rdi; ret、pop rsi; ret、pop rdx; ret观察rdi、rsi、rdx如何被独立控制。这个过程枯燥但它是地基。我辅导过一位某高校的本科生他花了三周只做这件事第四周第一次尝试ret2libc就一次成功。因为他脑中已经有了一个动态的、可预测的栈模型而不是一堆需要死记硬背的地址。5.2 进阶者拥抱leak理解ASLR不是墙而是迷雾当-no-pie环境玩透了下一步必须直面ASLR
阅读完成 · 觉得有帮助?