逆向圈子里但凡做过安卓样本分析的人,基本都绕不开一个词:脱壳。不管是分析恶意软件、做合规审计,还是研究自家App的加固强度,你在拿到一个加壳样本时,第一步想的往往就是怎么把真正的dex从内存里捞出来。Frida-unpack这个动态脱壳工具,就是专门干这事的——它依托Frida的插桩能力,在App运行时从内存中还原并转储dex文件,帮你绕开壳的静态保护,直接看到业务代码本身。
这篇内容会从原理讲到实操,再聊到常见坑,适合刚接触脱壳的新手,也适合已经用Frida但还没系统梳理过脱壳流程的进阶读者。我会把自己在实际使用中踩过的坑、试过的方案、验证过的参数一并写出来,你可以直接照着复现。
1. 脱壳这件事,到底在解什么
1.1 加壳的本质:让静态分析失效
先理清一个基础问题:壳做了什么。APK本质上是一个zip包,里面最核心的是classes.dex——Dalvik字节码文件,也就是App的业务逻辑所在。加固厂商做的事情,简单说就是把原本的classes.dex加密或隐藏起来,替换成一个壳的loader(通常是新的classes.dex或so库),真正的业务dex只在App运行时才被解密并加载进内存。
静态分析时,你解压APK看到的是壳的入口、壳的so库、加密后的文件,而不是真正的dex。这时候想要分析业务逻辑,要么静态逆壳(研究壳的加载流程,找到解密点),要么让App自己跑起来,在它解密dex加载进内存之后,直接从内存里把数据捞出来——这就是动态脱壳的基本思路。
动态脱壳的优势在于不依赖壳的具体加密算法。不管壳是几层、用什么加密方式,最终它都要把dex加载进ART虚拟机,而DexFile对象、ClassDef、方法区这些结构一定会出现在内存里。你要做的就是在合适的时机dump内存,再做修复。
1.2 Frida为什么适合干这件活
Frida是一个动态插桩框架,它能在App运行时注入JavaScript代码,hook住Java层、Native层的方法,读写内存、调用函数。它不是重打包、不是静态修改,而是运行时注入,所以不太受签名校验、完整性校验的干扰——只要App能正常跑起来,Frida就能介入。
脱壳工具用Frida来实现,核心就是把hook点集中在ART虚拟机的加载函数上,比如OpenMemory、DexFile::OpenFile、DefineClass这些。壳调用这些函数加载真正dex的时候,Frida的hook就能把dex数据截获下来,写入本地文件。相比自己写ptrace方案的脱壳机,用Frida开发快、迭代快、跨机型适配相对容易,这也是Frida-unpack这类工具能流行起来的原因。
注意:动态脱壳有两个前置条件——App能正常运行、设备能注入Frida。如果App有反调试、反Frida检测,你需要先处理好对抗问题,否则后面所有步骤都无从谈起。
2. 工具选型与核心设计拆解
2.1 常见动态脱壳方案对比
在聊Frida-unpack之前,先横向对比一下主流方案,方便你理解为什么Frida路线值得优先尝试。
| 方案类型 | 代表工具 | 优势 | 劣势 |
|---|---|---|---|
| Frida脚本脱壳 | Frida-unpack、各类开源dump脚本 | 部署快、上手门槛低、跨版本相对灵活 | 容易被检测、需要对抗反Frida |
| 定制ROM脱壳 | 刷入定制系统,改ART源码 | 稳定、不易被App感知 | 环境重、限设备、维护成本高 |
| 内核级脱壳 | 通过内核模块抓取文件读写 | 隐蔽性强 | 开发量大、兼容性差 |
| 脱壳机(基于定制虚拟机) | 各类商业脱壳机 | 功能全、自动化高 | 贵、闭源、样本数据易泄露 |
个人经验:如果你只是偶尔分析一两个样本,Frida脚本路线性价比最高;如果要做批量脱壳流水线,可以考虑定制ROM方案。Frida-unpack定位就是前者——一套脚本、一台root过的设备,就能完成大部分脱壳需求。
2.2 Frida-unpack的工作流程
我用的这个frida-unpack(GitHub上常见的那套,也有多个fork版本)核心流程如下:
- 注入Frida,hook住ART虚拟机中加载dex的入口函数。
- 当壳调用加载函数时,Frida在JavaScript层拿到dex文件的内存地址和大小。
- 读取内存数据,通过send或者rpc回调将数据传回主机端。
- 主机端Python脚本接收dex的二进制数据,按照dex魔数
dex\n验证后写入文件。 - 对所有dump出来的dex做批量修复(修正checksum、signature、data_offset等字段)。
关键设计点在于:hook哪个函数、在哪个时机dump。早期工具大都hookDexFile::OpenFile或DexFile::OpenMemory,这两个函数在壳load dex时一定会被调用。后来高版本安卓(Android 8.0以上,ART改动)有的需要hookDexFile::DexFile构造函数或OpenCommon,否则可能会漏dex。
2.3 为什么选择“内存Dump + 数据修复”组合
有些新手会问:内存dump出来直接就能用吗?不一定。壳在加载dex时,ART虚拟机为了优化性能,可能对dex做了一些调整,比如数据区偏移被修改、class_defs顺序被调整,更常见的是checksum和signature字段因为运行时被改写而失效。直接用十六进制工具拉出来的dex,拖进jadx或者GDA里往往会报错,提示“checkSum mismatch”之类的问题。
所以脱壳完整链路必须是:dump出来之后,再用脚本修正dex头部的校验字段。修复工具一般会按dex文件格式解析header,重新计算checksum(采用Adler32算法)和signature(采用SHA-1),并把file_size修正为实际读取长度。这一步是决定你dump出来能不能用的关键。
3. 完整实操:从环境搭建到拿到可用dex
3.1 环境准备与基础依赖
我使用的环境如下,你可以作为参考:
- 分析机:Ubuntu 20.04.x(Windows 也可,但Python和adb命令在Linux下更顺手)
- 目标设备:Pixel系列手机,系统Android 9~11,已root(Magisk)
- Frida工具链:frida-tools 15.x + frida-server 15.x(注意要和主机端版本一致)
- Python环境:Python 3.8及以上,需要安装frida、frida-tools、androguard(部分修复脚本会用到)
安装Frida命令如下:
pip install frida-tools # 查看本机frida版本 frida --version手机上启动与主机端同版本的frida-server:
adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell "/data/local/tmp/frida-server -l 0.0.0.0:6666 &"提示:frida-server版本必须与主机端frida版本严格一致。我遇到过一次主机15.2.0、设备15.1.24,结果大部分hook都注册失败,报错完全没有提示,排查了半天。
3.2 核心脚本hook点设计
Frida-unpack的脚本核心部分,我用一个尽量精简的版本做演示。以下脚本hook了OpenMemory和OpenFile两个入口,适配大多数壳:
// frida_unpack.js function dumpDex(startAddr, size) { var file = new File("/data/local/tmp/dump_" + Date.now() + "_" + size + ".dex", "wb"); var content = startAddr.readByteArray(size); file.write(content); file.close(); send({type: "dump", size: size, path: "/data/local/tmp/dump_" + Date.now() + "_" + size + ".dex"}); } // Hook Art DexFile::OpenCommon 适配高版本 var openCommon = Module.findExportByName("libart.so", "_ZN3art7DexFile10OpenCommonEPKhmRNS_6MemMapERKNS_10OatDexFileE"); if (openCommon) { Interceptor.attach(openCommon, { onEnter: function(args) { var begin = args[0]; var size = args[1].toInt32(); if (size > 0 && size < 100 * 1024 * 1024) { // 过滤异常大小 try { dumpDex(begin, size); } catch (e) { console.log("[*] dump error: " + e); } } } }); } // Hook DexFile::OpenMemory var openMemory = Module.findExportByName("libart.so", "_ZN3art7DexFile10OpenMemoryEPKhmRKS6_NS_12DexFileVariantE"); if (openMemory) { Interceptor.attach(openMemory, { onEnter: function(args) { var begin = args[0]; var size = args[1].toInt32(); if (size > 0 && size < 100 * 1024 * 1024) { try { dumpDex(begin, size); } catch (e) { console.log("[*] dump error: " + e); } } } }); }这段脚本的思路:通过Module.findExportByName从libart.so里找导出函数的符号地址。你可能会问,为什么符号名这么长,这是C++的name mangling机制,Android ART源码里的DexFile::OpenCommon方法,编译到release版本后符号就是_ZN3art7DexFile10OpenCommonE...。不同Android版本的符号名可能不一样,建议先用adb shell grep或者frida-trace扫一下当前设备上libart.so的导出符号。
dump下来的dex不会立刻写到主机端,我习惯先在设备上落盘,然后统一adb pull回本地。这样比较稳,脚本里如果用rpc逐块回传,大数据量时容易丢包。
3.3 主机端Python调度与文件修复
设备端负责dump,主机端负责调度与修复。我用一个Python脚本启动Frida attach到目标App进程并加载JS脚本:
# unpack_main.py import frida import sys import os import time device = frida.get_usb_device() session = device.attach("com.example.target") # 目标包名 with open("frida_unpack.js", "r", encoding="utf-8") as f: script_code = f.read() script = session.create_script(script_code) def on_message(message, data): if message["type"] == "send": print("[+]", message["payload"]) elif message["type"] == "error": print("[*]", message["stack"]) script.on("message", on_message) script.load() print("[*] Hook loaded. Waiting for dex dump...") time.sleep(300) # 根据App启动流程调整挂机时间这个脚本一般配合手动操作:启动脚本后,再去App上点击需要触发的页面。有些壳的dex是在App冷启动时就解密,有的则是等某个功能页面打开时才加载。所以我一般建议:先冷启动一遍,再热启动一遍,再手动触发几个核心页面,保证各类时机覆盖。
拿到dump文件后,批量修复dex头。这里用到一个比较轻量的修复脚本:
# fix_dex.py import hashlib import zlib import struct import sys def fix_dex(file_path): with open(file_path, "rb") as f: data = bytearray(f.read()) if data[:4] != b"dex\n": print(f"[!] {file_path} is not a dex file") return file_size = struct.unpack("<I", data[32:36])[0] # 如果文件大小字段与实际不符,修正 if file_size != len(data): data[32:36] = struct.pack("<I", len(data)) file_size = len(data) # 修正签名 (SHA-1 hash of all bytes after signature field) data[12:32] = b"\x00" * 20 signature = hashlib.sha1(data[32:]).digest() data[12:32] = signature # 修正checksum (Adler32 over entire file) data[8:12] = b"\x00" * 4 checksum = zlib.adler32(data) data[8:12] = struct.pack("<I", checksum) with open(file_path.replace(".dex", "_fixed.dex"), "wb") as f: f.write(data) print(f"[+] Fixed: {file_path}") if __name__ == "__main__": for path in sys.argv[1:]: fix_dex(path)修复的核心逻辑有三步:
- 修正
file_size字段:dex头部偏移为32字节处存放文件大小,以data[32:36]读取。如果实际读取大小和头部记录不一致,统一为实际大小。 - 修正
signature:dex头部偏移12字节处是20字节的SHA-1签名,它计算的是从第32字节开始到文件末尾的哈希值。所以先把原签名清零,再重新计算。 - 修正
checksum:偏移8字节处是4字节Adler32校验值,计算范围为整个文件。同样先清零再计算。
这三步做完,修复后的dex就可以直接用jadx、GDA或者baksmali打开反编译了。
3.4 使用frida-unpack脱壳的完整流程演示
将整个流程串起来,我惯用的操作顺序如下:
准备阶段:安装App到设备,确保能正常打开。跑一次
frida-ps -U确认frida-server连接正常、能列出目标包名。启动脚本:
python3 unpack_main.py冷启动触发:通过
adb shell am force-stop 目标包名杀掉进程,然后手动点击App图标启动,观察控制台是否输出dump信息。热启动与页面遍历:脚本保持挂机状态,在App内多点点几个页面,尤其是有登录、支付、列表刷新等功能的页面,这些地方往往隐藏着动态加载的dex或加固分包。
拉取dump文件:
adb pull /data/local/tmp/ 本地保存路径/- 批量修复:
python3 fix_dex.py dump_*.dex- 反编译验证:用jadx打开修复后的dex文件,确认能正常解析。
实际操作中,一个常规加固App通常在冷启动阶段能dump到1~3个dex,其中主dex是业务代码,其余可能是壳的壳dex或空壳dex。判断哪个是真正业务dex,可以看文件大小——动辄几十MB的是业务dex,几KB的是壳类。
4. 常见问题与排查技巧实录
4.1 高频问题速查
我把自己和身边人用frida-unpack时踩过的坑整理了一张速查表,按出现频率排序:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
Unable to attach: process not found | 包名错误或App未启动 | frida-ps -U查看准确包名,先启动App再attach |
| Hook注册成功但无dump输出 | 壳开放在其他进程 | 检查是否有子进程,尝试frida-trace排查加载函数 |
| dump到大小异常(如0字节) | hook点不对或时机过早 | 尝试hook其他ART函数,延长脚本等待时间 |
dex打开报checkSum mismatch | 修复脚本未执行或修复有误 | 确认修复脚本正确读取dex头,先验证魔数 |
| 设备端崩溃/重启 | frida-server被检测或版本不兼容 | 更换frida版本,或者用-l 0.0.0.0避免默认端口特征 |
| dump出来的dex反编译后内容极少 | 那可能不是业务dex | 检查大小,或者尝试内存搜索dex\n魔数手动dump |
4.2 三种脱壳失败场景的深度排查
场景一:App有反Frida检测
现在市面上的加固方案几乎都内置了反调试、反Frida。比较常见的手段是检测/data/local/tmp/frida-server、检测端口27042、检测/proc/self/maps中是否有frida相关so,以及hook某些Java方法来探测。
排查思路:先用frida-ps -U确认注入层是否能正常工作;如果App直接闪退,大概率踩上了检测。此时可以考虑临时改frida-server的文件名,或者用frida-gadget注入方案,把frida库打包进App内替换原so。更进阶的做法是hook掉检测函数的返回值——但这类操作比较复杂,一句两句说不清,建议先从改文件名和换非默认端口入手。
场景二:壳动态加载时机晚
有些壳的dex不是冷启动就加载,而是在某个业务模块首次使用时才解密。比如一个游戏App,主界面没问题,点进战斗场景才dump到新dex。
遇到这种情况,不要只等着。我习惯的做法是:脚本挂机的同时,用adb模拟点击或monkey遍历页面。也可以配合frida-trace先跟踪一下ClassLoader的加载路径,确认业务代码到底在什么时机被加载。
经验:搞不定脱壳时机时,先别慌着写复杂hook。先在App里把能点的页面全点一遍,把登录、切换账号、刷新列表这几个动作做完,往往就有收获。
场景三:修复后反编译报错
几天前我在分析某社交App时,dump出来一个30MB的大dex,修复后jadx能打开,但反编译到某个类直接报“class not found”之类错误。后来查明原因是壳在加载dex时对部分class_def做了延迟初始化,运行到对应类时才在内存中patch。
这种情况比较棘手,简单的dump修复顶不住。建议方案有两个:一是用frida-dexdump这类带内存扫描功能的工具,主动从堆上搜dex\n魔数并dump;二是在App运行过程中使用ART的DexFile数量统计,确认是否有多个dex叠加。再有就是针对特定壳,去买商业脱壳机或者定制ROM,纯Frida路线可能需要接受“能dump大部分但个别类残缺”的现实。
4.3 几项提升脱壳成功率的独家细节
分享几个我在实践中验证过的小细节,常规教程不会写。
第一,多dump几次再修复。同一个App、同一个页面,不同时间点dump出来的dex可能不一样。我遇到过壳在第一次启动时加载了完整业务dex,第二次启动时就改成动态加载分包的情况。所以脱壳不要只跑一次,多跑几轮,把不同状态下的dump文件都存下来,再做修复。
第二,hook点不要只盯libart.so。有些加固方案的dex加载并不走标准的ART接口,而是绕过ART,自己用C++内存映射方式加载。碰到这种,就需要hookmmap、memcpy这些底层函数,或者干脆用Frida的Memory.scan对整个进程内存扫魔数。Frida的Memory.scan在大范围内存扫描时速度并不快,但针对性扫描某个模块区间时表现还不错。
第三,学会给dump脚本做“节流”。如果dump太频繁,设备IO和内存压力会变大,导致App卡死甚至被壳检测。我一般在dump函数里加个简单同步锁,每dump一个文件后sleep一小段。牺牲一点速度,换来稳定性。
第四,关注OatDexFile。Android 9以上,ART更倾向于把dex转成oat(AOT编译)。有些壳会先把dex编译成oat再执行,这种情况下直接dump内存dex可能拿到的是oat文件而非dex。你需要先用oatdump工具把oat转回dex,或者hook住脱壳更上游的LoadDexFile接口,在oat编译之前拿到原始dex。
5. 进阶思路:从一次性脱壳到自动化分析
如果你已经把Frida-unpack跑顺了,下一步自然是想把整套流程自动化。这个方向值得投入,因为现代App更新频繁,每次更新壳都可能变化,人工跑脱壳效率太低。
我的自动化方案是:
- 用
frida-server常驻设备,通过Python脚本批量管理目标App。 - 准备一个手机集群(几台不同Android版本的机器),装好同一个样本的不同版本。
- 每次新样本进来,先冷启动脱壳,dump成功则修复并自动提交到分析平台。
- 若dump失败,自动记录hook日志、Capture调用栈,供后续人工分析。
这套流水线的核心价值不是省那几分钟,而是帮你积累“壳行为特征库”——哪些壳在哪个版本、哪个时机加载dex,hook哪个符号最优。这些经验数据多了之后,新样本进来几乎可以秒判定走哪条脱壳路线。
自动化过程中需要注意设备状态的监控。机器跑时间长了,frida-server可能被系统杀掉、设备温度过高导致App自启失败,这些都需要定时巡检。我在实践中加了两个简单的判断:定期检查frida-ps -U的返回状态,如果设备掉线就自动重连;每次dump完对比文件大小是否异常,异常则重试。
6. 脱壳后的下一步:反向利用与安全分析
脱壳拿到dex只是第一步,后面的事情才真正检验功力。大多数人的实际场景是恶意样本分析——从脱壳后的代码里找C2服务器地址、找敏感权限调用链、找加密算法实现。这时候Frida同样有用:你可以在干净环境跑起脱壳后的样本,hook掉关键的字符串比较、加解密函数,把样本的行为链路完整画出来。
另一种场景是做自家App的安全自查。开发团队想知道自己的加固方案是否能扛住市面上的通用脱壳工具,会专门用Frida-unpack打一遍自己的App。脱壳成功并不意味着绝对不安全,指标是:脱壳后你能在多短时间内定位到核心算法。比如一个金融App,如果脱壳后反编译能找到登录加密逻辑的入口,说明加固力度不够;如果找到了加密函数但核心密钥藏在Native层,说明加固是有层次的。
这里我想强调的是,脱壳技术没有善恶之分,关键看用途。安全研究、漏洞挖掘、合规审计都是正当方向,拿到脱壳能力后,更应该用于发现和修复问题,而不是去做不该做的事。
最后一个实际建议:把每次脱壳的样本、dump记录、修复脚本版本都留档。你会遇到一个App脱壳成功后,更新两个版本又脱不出来的情况——这时候翻旧档,对照上一次用的hook点和修复脚本,能省下大量返工时间。工具会过时,但你的排查流程和经验积累不会过时。这些拿时间堆出来的判断力,才是比任何脚本都值钱的东西。