资讯详情

资讯详情

Frida+Unicorn:移动端动态分析与指令模拟执行实战

做移动端分析的朋友应该也有这种感觉从 2024 年下半年开始手头要看的 App 越来越“硬”了。壳是一层叠一层关键函数藏在 vmp 里反调试能检测到 parent pid连 ptrace 都有人跟你玩“自相残杀”。以前抓个包、挂个 Agent 就能搞定的日子明显不在了。这时候还能站在第一线的基本是两类人一类把静态分析玩到极致另一类就是动态插桩的高手——而这里绕不开的就是 Frida 和 Unicorn 这对组合。这篇文章我不会写“三分钟脱壳”之类的废话而是把我实际用 Frida 动态 hook、再用 Unicorn 把抽取出来的指令段原地模拟执行起来的完整过程按我自己的操作顺序拆给你看。文里的所有实验对象都是一个叫 DemoApp 的测试应用完全本地运行没有任何线上服务主要目的是把思路和步骤讲清楚。不管你是在做移动端安全分析、自动化数据采集链路的技术预研还是单纯想搞懂 VMP 这类代码还原到底怎么落地这篇都值得你花十几分钟细看。原理部分我会尽量用大白话讲代码和命令也会给全照着抄、改个函数名就能跑。1. Frida Unicorn 为什么能站上 2025 的动态分析C位1.1 先说 Frida它不是“抓包工具”是一把可编程手术刀很多人对 Frida 的理解停留在“hook 一下看返回值”这其实浪费了它一大半能力。Frida 的核心是一个注入式的动态插桩引擎进程启动后你把一个 JS 运行时灌进去然后在几乎任意函数边界上挂回调读取参数、改返回值、调用内部方法、遍历堆栈都能用几行脚本完成。我自己的体会是它的价值不在“能不能 hook”而在“你 hook 完能拿到什么样的事实”。比如你要还原一个 App 的登录协议如果只看 TCP 流量看到的是加密后的一坨 hex但如果你在 Java 层把加密前的明文参数打出来在 Native 层把加密函数的输入输出打出来问题就直接被切成了两半。这个“分层观测”的思路是静态分析很难做到的——因为很多类的加载时机、字段赋值路径是动态的看反编译代码往往只是猜。2025 年的现状是越来越多的关键算法不再待在 Java/Kotlin 层而是下沉到 so 文件里甚至配合 dex 抽取、资源混淆、调用堆栈校验来阻止你舒服地下断点。Frida 的 Native hook 能力就是为这种场景准备的通过Interceptor.attach对导出函数或者任意地址做指令级别的 hook不需要知道函数原型能直接看寄存器和内存快照。1.2 Unicorn 补的是“代码从哪来”这一环但 Frida 也有一个软肋它必须在目标进程活着的时候工作。当遇到抽取执行、运行时还原指令这类固定套路时攻击者通常的做法是——启动时先只加载空壳关键方法第一次调用才把真正的字节码或指令片段从加密资源里解出来、放到可执行内存里。这会导致什么呢你 Frida 挂上去的一瞬间函数地址是有了但你看不到它对应的原始指令因为它是动态生成的。Unicorn 就是来填这个坑的。它是一套纯 CPU 模拟器基于 QEMU 的底层能力但做了很大的裁剪和对外接口封装。你用 Python 或 C 去调用它把一段二进制指令喂进去指定架构ARM、ARM64、x86 都能支持设置好寄存器初始值和内存布局它就能像个迷你 CPU 一样把这串指令跑完而且每一步都允许你 hook。用生活化类比来说Frida 像个“病房内窥镜”你得在病人活着的时候伸进去看Unicorn 更像“器官体外循环机”你把关键器官切下来在体外搭一个环境它照样能干活而且你可以仔细观察每一滴血流到哪里。所以这两个工具的组合逻辑就清晰了Frida 负责找到代码、抽取出关键指令段Unicorn 负责在进程之外把这代码重新跑起来、分析清楚它在干什么。1.3 这套组合最典型的落地场景抽取壳样本的还原运行时拿到解密后的 so轻松 dump 出来但脱离进程之后没法直接反汇编验证逻辑Unicorn 可以帮你模拟执行关键片段。协议算法还原不用一行一行读汇编直接在模拟环境里跑目标函数输入输出多次对比反推算法结构。自研 SDK 的安全自检很多做风控、加密 SDK 的团队会特意模拟恶意调用环境验证自己的防护逻辑有没有被绕过。CTF 和样本分析训练大多数逆向 challenge 的题眼就是“一段防静态分析的指令”用 Unicorn 模拟执行是标准解法。说句实在话市面上很多所谓“2025 最新脱壳机”“一键去混淆工具”底层思想就是这两者的组合。理解了这个底层逻辑你才不会被某个工具绑架遇到新问题也能自己拼方案。2. 从零搭起实验环境frida-server 和 Unicorn 一键跑通工欲善其事必先利其器。这个组合的环境搭建其实不复杂但版本匹配的坑特别多我直接把自己的步骤和踩坑之后的稳定配置写在这里。2.1 需要准备的工具清单组件建议版本说明Frida CLI / frida-tools最新稳定版 16.x提供frida-ps、frida-trace等命令行工具frida-server跟 Frida CLI 主版本一致丢到手机或模拟器的/data/local/tmpPython3.10Unicorn 绑定和自动化脚本都在 Python 侧Unicorn Engine2.x通过 pip 安装自带 Capstone 可配合反汇编adb任意负责跟目标设备通信模拟器或真机Android 9~14 均可真机更贴近真实环境模拟器方便但更容易被反调试检测2.2 安装 Frida 和部署 frida-server先把电脑侧的 Frida 装好pip install frida-tools frida frida --version然后在 GitHub 的官方 Release 页下载对应架构的frida-server。一定要注意Linux 版和 Android 版是两个文件命名通常长这样frida-server-16.x.x-android-arm64.xz。我一开始下错过一次下成 Linux 版丢到手机里一执行就段错误排查了半天。把 server 推到设备上并启动adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server 这时候验证电脑是否连上adb forward tcp:27042 tcp:27042 frida-ps -U如果能看到设备上运行的进程列表Frida 环境就算通了。这里有个细节模拟器上的 ARM 翻译执行架构可能导致 frida-server 对不上 CPU 架构表现为启动后设备上进程直接消失。解决办法是优先用真机或选择模拟器镜像的 ABI 和 frida-server 完全一致。2.3 安装 Unicorn 和最小验证用例Unicorn 的 Python 绑定安装非常省事pip install unicorn python -c import unicorn; print(unicorn.__version__)装完之后我建议先跑一个最小模拟用例验证 ARM 指令能被正确执行。下面这段代码执行mov r0, #0x42; add r0, r0, #1最后我们期望r0变成 0x43from unicorn import Uc, UC_ARCH_ARM, UC_MODE_ARM from unicorn.arm_const import UC_ARM_REG_R0, UC_ARM_REG_PC, UC_ARM_REG_SP BASE 0x10000 code bytes([ 0x40, 0x00, 0xa0, 0xe3, # mov r0, #0x42 0x01, 0x00, 0x80, 0xe2, # add r0, r0, #1 ]) mu Uc(UC_ARCH_ARM, UC_MODE_ARM) mu.mem_map(BASE, 0x1000) mu.mem_write(BASE, code) mu.reg_write(UC_ARM_REG_SP, 0x11000) mu.reg_write(UC_ARM_REG_PC, BASE) mu.emu_start(BASE, BASE len(code)) r0 mu.reg_read(UC_ARM_REG_R0) print(R0 , hex(r0)) assert r0 0x43如果最后打印出R0 0x43说明 Unicorn 的正常运行链路没问题。别小看这个最简验证我后面用 Unicorn 调试抽取指令时靠的基本就是这套“初始化寄存器 执行 读寄存器”的循环。2.4 一个性能可以忽略的补充CapstoneUnicorn 只管执行不管反汇编。如果你想在模拟执行前后看指令长什么样强烈建议把 Capstone 一起装上pip install capstone配合使用的时候可以对emu_start前后的 PC 地址对应的二进制做反汇编便于确认喂进模拟器的指令是否真是你要分析的那一段。这个组合在实际分析里基本是标配。3. 先拿自研 Demo 练手Java 层到 Native 层 Hook 实战环境通了之后别急着上真实样本先用自己写的 DemoApp 把 Frida 的 hook 流程跑顺。这个 DemoApp 里我故意写了一个 Java 方法calc(int, int)和一个 native 方法nativeCalc(int, int)前者内部调用了后者。3.1 Java 层方法监听打印参数和返回值Frida 挂到进程上之后Java 层 hook 的标准开局是这样的Java.perform(function () { var MainActivity Java.use(com.demo.analytics.MainActivity); MainActivity.calc.implementation function (x, y) { console.log([calc] x x , y y); var ret this.calc(x, y); console.log([calc] ret ret); return ret; }; });用frida -U -f com.demo.analytics -l hook.js启动-f会以 spawn 模式冷启动 App适合需要 App 刚创建进程就 hook 的场景如果 App 已经在运行可以用frida -U 包名 -l hook.js对应的是 attach 模式。spawn 和 attach 的区别是新手最容易忽略的attach 模式会遇到“你要 hook 的类还没被加载”的情况导致Java.use抛异常。spawn 模式能捕获进程启动阶段的行为但需要自己处理resumesetTimeout(function () { Java.perform(function () { // hook 逻辑 }); }, 0);原理上别依赖 setTimeout 太小的时间如果在早期初始化里要 hook 的对象可能需要观察时机适当延迟到类加载完再注入。实操完之后你会发现 java 层 hook 几乎是零门槛核心价值在于“观察面”的选择同一个函数你 hook 在参数入口、返回值出口看到的协议信息完全不同。推荐把入口和出口分开打日志用[IN][OUT]标记方便后面处理大量样本时做对比。3.2 Native 层符号 Hook不再需要反编译猜函数Java 层揭露的往往是业务逻辑真正加密和防篡改的判断一般都在 so 里。对 native 函数的 hook我常用的套路是这样var lib Process.findModuleByName(libdemo.so); if (!lib) { console.log([!] libdemo.so not loaded); return; } var nativeCalc lib.findExportByName(nativeCalc); if (nativeCalc) { Interceptor.attach(nativeCalc, { onEnter: function (args) { console.log([nativeCalc] arg0 args[0].toInt32()); console.log([nativeCalc] arg1 args[1].toInt32()); }, onLeave: function (retval) { console.log([nativeCalc] ret retval.toInt32()); } }); }Process.findModuleByName和Module.findExportByName是老搭档它们解决的是“怎么把符号名变成真实地址”的问题。在 2025 年很多 so 做了动态注册导出表里不一定能看到nativeCalc这个名字这时候有两条路用Java.use找到nativeCalc对应的jmethodID再通过 Frida 的API或者 hookRegisterNatives来拿到真实函数地址。先跑一遍 App用frida-trace -U -i native*观察所有 native 调用快速定位入口。第一种方式用得最多推荐学习阶段把“动态注册函数如何拿到地址”吃透因为你以后遇到的样本大概率都走这条路。3.3 hook 时机、线程问题与常见误区Native hook 和 Java hook 都有三个高频问题我一个个说时机问题Process.findModuleByName如果返回 null说明 so 还没加载。解决办法是 spawn 模式启动并且订阅模块加载事件Process.enumerateModules(); // 或者用 var pending {};我一般在业务代码里这样处理先enumerateModules()检查是否存在不存在就 sleep 500ms 再重试超过 10 次才报错。写死等待时间虽然不优雅但实际稳定。线程问题Interceptor.attach的回调是在触发线程执行的所以回调里不能随便调用Java.use或者执行长时间操作。如果需要跨线程通信用一个全局日志队列在另一个线程统一输出。寄存器与返回值args[0]对应第一个参数但如果是 JNI 函数第一个参数其实是 JNIEnv*第二个才是业务入参。这个顺序记错后面分析全乱我就犯过这个低级错误排查半天才意识到多了一个隐藏参数。4. 把抽取的指令喂给 Unicorn模拟执行的最小落地流程这一节是重头戏。Frida 主要负责“找代码”Unicorn 负责“跑代码”。我以一个假想的指令段为例演示从 Frida 拿到二进制、到 Unicorn 完整体执行的最小流程。4.1 什么时候必须用 Unicorn假设你拿到一个 DemoApp 的 so 文件反汇编打开一看函数体全是空的真正逻辑都在运行时对某段密文做解密、然后跳过去执行。这就是典型的“指令抽取”。我不想教太多对抗细节但从原理上讲运行时解出来的指令段是躺在进程内存里的Frida 能把它 dump 出来但要分析这段指令你不能直接在进程里单步——很多环境检测代码会通过计时、系统调用、甚至内存断点来发现自己被调试。Unicorn 的价值在于把这段指令从进程里完整抠出来搬到模拟环境里执行。在这个模拟环境里一切都是你能控制的自定义系统调用、自定义内存映射、随时读取每个寄存器的值。4.2 从 dump 到模拟执行的四步流程第一步Frida dump 内存段在目标 so 运行起来后用 Frida 把解密后的代码段或者函数地址附近的原始指令抓下来var lib Process.findModuleByName(libdemo.so); var base lib.base; var size lib.size; var bytes Memory.readByteArray(base, size);用 Node 或 Python 脚本接收保存为 bin 文件。这一步相当于把“现场证据”固定下来。第二步切出关键指令段你 dump 出来的往往是一个很大的 so并不是所有代码都需要模拟。用 Capstone 反汇编找到目标指令的起始地址和结束地址掐头去尾。比如目标函数地址是0x1234长度 0x200那么你只需要喂这一段。第三步初始化 Unicorn 环境这一步最关键也是最容易崩的地方。下面是我常用的最小模板from unicorn import Uc, UC_ARCH_ARM64, UC_MODE_ARM from unicorn.arm64_const import * BASE 0x100000 STACK 0x200000 CODE_SIZE 0x10000 STACK_SIZE 0x10000 entry BASE 0x1234 end BASE 0x1434 mu Uc(UC_ARCH_ARM64, UC_MODE_ARM) # 映射代码段 栈空间权限全开 mu.mem_map(BASE, CODE_SIZE, UC_PROT_ALL) mu.mem_map(STACK, STACK_SIZE, UC_PROT_ALL) # 写入目标代码 with open(dump.bin, rb) as f: code f.read() mu.mem_write(BASE, code) # 设置栈指针栈方向是向低地址增长 mu.reg_write(UC_ARM64_REG_SP, STACK STACK_SIZE - 0x10) # 设置 PC 到函数入口 mu.reg_write(UC_ARM64_REG_PC, entry)第四步执行并挂钩观察点# 读取 PC 和指令数 mu.emu_start(entry, end, timeout0, count0) pc mu.reg_read(UC_ARM64_REG_PC) x0 mu.reg_read(UC_ARM64_REG_X0) print(PC , hex(pc)) print(X0 , hex(x0))执行完成之后PC 应该在 end 位置X0 或者其他寄存器里应该有返回值。你可以在emu_start之前注册内存读写 hookdef hook_mem_read(uc, access, address, size, value, user_data): print([MEM] read addr0x%x size%d % (address, size)) mu.hook_add(UC_HOOK_MEM_READ, hook_mem_read)这样就能看到算法运行到一半时访问了哪些内存、哪些地址没有被映射——后者常常是崩溃的原因需要回到代码里补内存映射区域。4.3 一套稳定的映射规则避免瞎试刚上手 Unicorn 时我很不理解为什么动不动就 Segmentation fault。后来总结出来大部分崩溃都源于三个点内存访问越界代码里可能引用了全局变量、字符串表的地址而这些地址不在你映射的范围内。解决办法是先跑一遍看第一个 mem hook 报的地址然后按需补映射。权限不对有些代码会对内存做写操作如果只映射了UC_PROT_READ就崩。最省事的方式是初学阶段全开UC_PROT_ALL调试顺畅后再收紧权限尽量模拟真实环境。栈指针问题ARM64 的栈必须 16 字节对齐栈大小至少留 64KB 以上。实际分析里很多函数一次就压入大量局部变量栈给太小会静默写穿。实际操作中我习惯把所有可能被访问到的段全部用一个连续内存块提前映射再通过 mem hook 检查实际访问 range。宁可“富余”一点也不要崩溃后反复猜。4.4 有惊无险的边界情况处理还有一类很常见的样本行为是 hook 代码、自修改代码。当一段代码执行到一半把自己后面的指令改掉了你 dump 出来的还是旧内容执行结果就对不上。这个场景里需要在 Unicorn 里同时开UC_HOOK_CODE在每条指令执行前检查当前地址的内容必要时动态修改内存。这属于进阶玩法但理解了“模拟器内可以改代码”这个点就有了还原自修改代码的基本能力。5. 那些 Frida 连接不上、Unicorn 崩溃的坑我替你踩过了写到这我猜你也发现了环境本身搭建容易但真正耗时间的全是环境问题和边界条件。这节按“现象 / 原因 / 解决方案”的表格来整理全是实操下来的干货。现象常见原因解决办法frida-ps -U卡死或连接失败电脑和设备的 frida 版本不一致统一电脑端 frida 和设备端 frida-server 的主版本号frida-server 启动后进程消失架构不匹配或 SELinux 拦截检查/proc/cpuinfo确认 ABI用真机时执行setenforce 0或使用 magisk 模块方式启动hook Java 方法时提示 ClassNotFound类还没加载或启用了压缩/抽取改用 spawn 模式或先 hookClassLoader.loadClass的动态加载点Native 函数地址是 0so 还没被 load用Process.enumerateModules()确认模块是否出现等它出现后再 attachUnicornUC_ERR_READ_UNMAPPED代码访问了未映射内存按报错地址增加mem_map常见于全局变量、栈、poolUnicorn 执行结果跟进程内不一致被自修改代码或环境检测干扰使用UC_HOOK_CODE看当前指令必要时在模拟器内手动 patch 数据对比进程内 dump 的内存快照指令数据全是 00从错误的基址 dump确认 so 的基址和偏移量计算基址需使用模块基址函数偏移日志中文乱码编码问题Frida 脚本里console.log前统一转成 base64 或 hex在 Python 侧解码我印象很深的一次费了两个小时排查 frida-server 启动失败最后发现是 Android 版本限制对exec权限的控制Chrome 更新后系统分区变化普通/data/local/tmp路径不能执行。后来换到/data/local/tmp/frida目录或者用magisk --denylist加入白名单才解决。这一类的环境问题很吃经验梯队文章很少讲这表格建议收藏。还有一个很容易被忽略的细节Frida 的attach模式对部分加固应用会被直接检测到。很多加固会读/proc/self/status看 TracerPid一旦发现有调试器挂载就自杀或跳到虚假逻辑。这种情况下 spawn 模式并不能根治因为 TracerPid 依然可被检测。更稳妥的做法是配合 Frida 的gadget方式注入或者使用frida-server的--no-pause参数减少启动窗口但无论哪种都要有“这门手艺是持有在正确使用前提下的”的意识避免用于非法场景。6. 关于合规边界和学习路径说说我的经验我见过不少朋友一上来就找镜像、下脱机教程结果折腾一星期还在环境里打转。我的建议相反只在你自己的 DemoApp、开源样本、CTF 赛题上练习避开有商业利益和隐私风险的真实目标。Frida 和 Unicorn 是安全研究里非常成熟的开源工具围绕它们的文档、示例、比赛题目多到数不清。先在自己写的 App 上把 Java hook、Native hook 跑通再自己生成一个带有指令抽取逻辑的 so用 Unicorn 模拟执行一段编译产物你会发现整个链路并不神秘。等这条路走顺了再去看别人分享的实战案例一眼就能判断他们方案的成色也不容易被网上那些“神乎其神”的标题唬住。学习路线我建议按这个顺序第一周熟悉 Frida 的 Java hook能把任意方法的参数和返回值打出来。第二周熟悉 Native hook理解 JNI 调用约定会处理RegisterNatives。第三周用 Capstone Unicorn 自己写一个能执行 arm64 单函数的模拟器脚本。第四周把前两周的能力串起来做一个“启动进程 - dump 内存段 - Unicorn 模拟执行 - 比对结果”的完整链路。到第四周结束你基本就有了独立分析一个带抽取壳的测试样本的能力。这个能力用在工作上是自动化安全测试、SDK 加固评估、算法协议调研的硬通货用在个人学习上是通往 VMP、OLLVM 等更深反向工程领域的地基。我个人踩过最大的坑是过度追求“完全还原整个 App 逻辑”恨不得把它所有算法都跑一遍。实际工作里没这个必要。90% 的场景你只需要还原那一段决定数据格式或协议字段的代码再配合运行时的输入输出做验证就够了。目标越小成功率越高。最后分享一个小技巧每次做动态分析先把 Frida 脚本、Unicorn 脚本、dump 的 bin 文件、运行日志分目录存好用时间戳命名。后面遇到同一个样本的变种你只需要对比 dump 出的指令段差别就知道它动了哪部分逻辑能省掉大量重复分析时间。这也是我从一个只看结果的临时操作成长为能沉淀成工具链的人最值得回头总结的一点。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →