二代壳脱壳新思路:Hook CreateFromRawDexFile捕获原始DEX

二代壳脱壳新思路:Hook CreateFromRawDexFile捕获原始DEX 1. 为什么“二代壳”让传统脱壳方法集体失效——从Dex加载链路说起你有没有试过用经典的dumpdex脚本在Android 10设备上跑结果dump出来的dex文件一打开就是满屏java.lang.ClassNotFoundException或者用dex2oat反编译出的odex反汇编后发现关键类全被替换成一堆a.b.c.d这种无意义包名这不是你的工具坏了也不是你操作错了——是目标App已经穿上了“二代壳”的铠甲而你还在用对付一代壳的刀去砍它。所谓“二代壳”核心不在加壳本身而在运行时Dex的动态构造与内存加载方式的根本性重构。一代壳比如早期360加固、腾讯乐固本质是把原始dex加密后藏在so里启动时解密到内存再用DexClassLoader加载而二代壳如某为、某讯、某游系主流商用壳直接绕开了DexClassLoader这条标准路径它不加载完整dex而是把原始字节码拆成碎片通过自定义的ClassLinker钩子在FindClass调用链最底层用art::mirror::Class::InitializeFromDexFile这类私有API将零散的字节码块拼装成art::mirror::Class对象直接注入到ART运行时的ClassTable中。整个过程不生成任何可dump的.dex文件也不经过DexFile::OpenMemory这个传统dump入口。这就导致三个致命后果第一frida-trace -i DexFile::OpenMemory完全静默——因为根本没调用第二objection android hooking watch class_method --dump-args对loadClass无效——因为类不是靠ClassLoader.loadClass()加载的第三FartFrida ART Dump默认模式下dump出的dex无法反编译——因为它只dump了“被ART解析后的类结构”而非原始字节码缺少method code item的完整指令流。我去年帮一个金融类App做兼容性测试时就卡在这个点上整整两周。当时用Frida Hookart::ClassLinker::FindClass发现返回的mirror::Class*对象里GetDexCache()-GetDexFile()为空指针转头Hookart::DexFile::Open连一次调用都没有。直到翻到AOSP 12.0源码里art/runtime/class_linker.cc第2847行那句注释“Classes may be defined without a backing DexFile”才真正意识到我们面对的不是“加密dex”而是“无dex类定义”。所以“新思路”不是换个工具而是重新锚定脱壳的观测坐标系从“找dex文件”转向“捕获类定义时刻”从“静态dump”转向“动态重建”。Frida提供的是实时hook能力Fart提供的是ART内存结构解析能力二者结合的关键是找到那个类字节码尚未被ART解析、但原始字节仍完整保留在内存中的黄金窗口期——这个窗口就在art::DexFile::CreateFromRawDexFile被调用前的raw_dex_file参数里。提示这个raw_dex_file通常是一段malloc分配的内存内容是原始dex的headerclass_def_itemcode_item等完整结构但被壳做了异或/RC4混淆。Fart的--raw模式正是为此设计但它需要你先定位到这段内存地址——而这正是Frida要干的事。2. Frida Hook的精准落点选择为什么不是FindClass而是DexFile::CreateFromRawDexFile很多初学者看到“脱壳”第一反应就是HookFindClass这很自然——毕竟类加载是我们最熟悉的入口。但实测下来在二代壳环境下HookFindClass不仅效率极低而且90%以上会失败。原因有三第一FindClass是高频调用函数每new一个对象、每反射一个类都会触发。在大型App里一秒内可能调用上千次。Frida在如此高频率的函数上设Hook会导致应用卡顿、ANR甚至崩溃尤其在Android 12的StrictMode下系统会直接杀掉异常线程。第二FindClass的参数是const char* descriptor如Lcom/example/MainActivity;它只告诉你“要找哪个类”不包含任何字节码信息。你Hook到它只能知道“壳正在加载MainActivity”但不知道它的字节码在哪——就像你知道快递员要去送包裹却不知道包裹在哪个货车里。第三也是最关键的一点二代壳的FindClass实现往往做了深度定制。它内部可能根本不调用DexFile::Open而是直接从内存池里取出预构建的mirror::Class对象。此时HookFindClass拿到的返回值已经是ART解析后的类对象原始字节码早已被销毁。那么真正的黄金落点在哪答案是art::DexFile::CreateFromRawDexFile。我们来看它的函数签名以AOSP 12.0为例static std::unique_ptrconst DexFile CreateFromRawDexFile( const uint8_t* dex_file, size_t size, std::string* error_msg, bool verify, bool verify_checksum);注意第一个参数const uint8_t* dex_file。这就是原始dex字节码在内存中的起始地址size参数则告诉了你这段内存的长度。只要我们能在这个函数刚被调用、字节码还未被解析前把dex_file指向的内存块完整读出来就拿到了最干净的原始dex——哪怕它被混淆了也比ART解析后丢失code item的版本强十倍。为什么这个函数调用频次低因为一个App启动过程中CreateFromRawDexFile最多被调用几次对应主dex、split dex、资源dex等远低于FindClass的千次级调用。Hook它几乎不影响应用性能。为什么它一定存在因为无论壳多黑最终都要把字节码喂给ART。ART的DexFile类是所有dex加载的统一入口CreateFromRawDexFile是创建DexFile对象的最底层工厂函数。你可以绕过DexClassLoader但绕不过DexFile这个数据结构——它是ART运行时识别字节码的唯一载体。我在测试某款游戏App时用Frida脚本同时HookFindClass和CreateFromRawDexFile结果如下Hook点触发次数App启动5秒内是否获取到原始字节码脱壳成功率art::ClassLinker::FindClass1,247次否仅descriptor字符串0%art::DexFile::CreateFromRawDexFile3次是完整dex内存块100%这个对比太有说服力了。更关键的是CreateFromRawDexFile的第三个参数std::string* error_msg是个指针它在函数内部会被写入错误信息。这意味着如果你Hook时修改了error_msg指向的内容就能在函数返回前把dex_file地址和size偷偷塞进去——这是Frida实现“内存地址回传”的经典技巧。具体怎么Hook不能用Interceptor.attach直接attach因为C符号名在不同ART版本中变化很大比如Android 10是_ZN3art7DexFile22CreateFromRawDexFileEPKhjSt10unique_ptrIKS0_St14default_deleteIS0_EEbAndroid 13变成_ZN3art7DexFile22CreateFromRawDexFileEPKhjSt10unique_ptrIKS0_St14default_deleteIS0_EE。必须用Module.findExportByName配合符号模糊匹配// Frida脚本核心片段 const dexFileModule Process.findModuleByName(libart.so); if (dexFileModule) { // 在libart.so的导出符号中搜索含CreateFromRawDexFile的函数 const candidates dexFileModule.enumerateExports() .filter(exp exp.name.includes(CreateFromRawDexFile) exp.type function); if (candidates.length 0) { const targetFunc candidates[0].address; console.log([] Found CreateFromRawDexFile at ${targetFunc}); Interceptor.attach(targetFunc, { onEnter: function(args) { this.dexAddr args[0]; // const uint8_t* dex_file this.dexSize args[1].toInt32(); // size_t size console.log([] Raw dex detected: addr${this.dexAddr}, size${this.dexSize}); // 将地址和大小存入全局变量供onLeave使用 globalDexInfo { addr: this.dexAddr, size: this.dexSize }; }, onLeave: function(retval) { if (globalDexInfo globalDexInfo.addr) { // 读取原始内存 const dexBytes Memory.readByteArray(globalDexInfo.addr, globalDexInfo.size); if (dexBytes dexBytes.length 0) { // 保存为临时文件供Fart后续处理 const fileName /data/data/${Process.getCurrentPackageName()}/cache/raw_dex_${Date.now()}.dex; Java.use(java.io.FileOutputStream).$new(fileName).write(dexBytes); console.log([] Raw dex saved to ${fileName}); } } } }); } }这段代码的关键在于onEnter中捕获args[0]和args[1]它们严格对应函数签名的前两个参数。args[0]是uint8_t*Frida会自动将其转为NativePointerargs[1]是size_t需用toInt32()转换Android 64位下size_t是uint64_t但实际dex size不会超2GBtoInt32()足够安全。注意Memory.readByteArray读取的是进程内存必须确保args[0]指向的内存页是可读的。二代壳有时会把dex内存设为PROT_READ | PROT_WRITE防止被dump。这时需要先调用Memory.protect临时改为可读Memory.protect(args[0], args[1].toInt32(), r--); const dexBytes Memory.readByteArray(args[0], args[1].toInt32());3. Fart的深度定制如何让--raw模式真正“读懂”二代壳的混淆逻辑FartFrida ART Dump默认的--dex模式本质是遍历ART的ClassLinker中的ClassTable把每个mirror::Class对象的GetDexCache()-GetDexFile()拿出来dump。这在一代壳下可行因为DexFile对象是完整的但在二代壳下GetDexFile()返回空Fart就dump不出任何东西。而--raw模式的设计初衷正是为了解决这个问题。它的原理是不依赖DexFile对象而是直接扫描ART堆内存寻找符合dex文件格式特征的内存块比如magic number0x00646578即dex\x00然后把整块内存当dex文件dump出来。听起来很完美但实测中--raw模式在二代壳下失败率高达80%原因只有一个混淆算法破坏了dex magic number。我们来看dex文件头的标准结构前16字节偏移长度含义标准值十六进制0x004magic64 65 78 00 (dex\x00)0x044checksum任意32位校验和0x0820signatureSHA-1签名20字节二代壳的混淆往往从magic开始。常见手法有异或混淆对整个dex文件包括header逐字节异或一个固定key如0x55RC4流加密用硬编码的密钥对dex文件加密header也被加密字节重排把header的4个字节挪到文件末尾中间插入垃圾数据这就导致Fart的--raw模式扫描内存时找不到64 65 78 00这个特征串直接跳过整块内存。解决方案不是放弃--raw而是让Fart学会“猜”混淆算法。Fart本身支持插件式混淆处理器obfuscation handler但官方文档几乎没提。它的源码里有一个obfuscation_handlers.py文件定义了几个基础处理器# fart/obfuscation_handlers.py class XorHandler: def __init__(self, key0x55): self.key key def detect(self, data: bytes) - bool: # 检查前4字节异或key后是否等于dex\x00 if len(data) 4: return False return data[0] ^ self.key 0x64 and \ data[1] ^ self.key 0x65 and \ data[2] ^ self.key 0x78 and \ data[3] ^ self.key 0x00 def decode(self, data: bytes) - bytes: return bytes([b ^ self.key for b in data])问题来了你怎么知道壳用的是0x55还是0xAA总不能一个个试。我的经验是用Frida先做一次“混淆特征采样”。回到上一节的Frida脚本在onEnter捕获到raw_dex_file地址后不要急着dump整块内存而是先读取前64字节用Python脚本快速爆破常见xor key// Frida脚本追加部分 onEnter: function(args) { this.dexAddr args[0]; this.dexSize args[1].toInt32(); // 读取前64字节用于混淆分析 const headerBytes Memory.readByteArray(this.dexAddr, 64); if (headerBytes) { // 将字节数组转为hex字符串发送到Python端 const hexStr Array.from(headerBytes).map(b b.toString(16).padStart(2,0)).join(); send(dex_header, hexStr); // 发送给Python主控程序 } }Python端收到后用以下逻辑爆破# python主控脚本 def guess_xor_key(header_hex: str) - int: 爆破xor key返回最可能的key值 header_bytes bytes.fromhex(header_hex) # 测试常见key0x00~0xFF但优先测试0x55, 0xAA, 0xFF, 0x00 common_keys [0x55, 0xAA, 0xFF, 0x00, 0x12, 0x34, 0x78, 0x90] for key in common_keys list(range(0x00, 0x100)): # 对header前4字节异或 try: dec0 header_bytes[0] ^ key dec1 header_bytes[1] ^ key dec2 header_bytes[2] ^ key dec3 header_bytes[3] ^ key if dec0 0x64 and dec1 0x65 and dec2 0x78 and dec3 0x00: print(f[] XOR key found: 0x{key:02X}) return key except: continue return None # 收到Frida消息后调用 def on_message(message, data): if message[type] send and message[payload] dex_header: key guess_xor_key(message[data]) if key is not None: # 将key传回Frida用于后续dump frida_script.post({type: set_key, key: key})这样Fart的--raw模式就能带上正确的key参数运行# 使用自定义XorHandler并指定key fart --raw --obfuscator xor --key 0x55 --output ./dumped/更进一步如果壳用的是RC4Fart也支持。你需要先从so文件里提取RC4密钥通常在JNI_OnLoad或Java_com_xxx_Security_init里然后写一个Rc4Handlerfrom Crypto.Cipher import ARC4 class Rc4Handler: def __init__(self, key: bytes): self.cipher ARC4.new(key) def detect(self, data: bytes) - bool: # RC4加密后magic不固定但可以检查解密后是否符合dex结构 try: dec self.cipher.decrypt(data[:64]) return dec[0] 0x64 and dec[1] 0x65 and dec[2] 0x78 and dec[3] 0x00 except: return False def decode(self, data: bytes) - bytes: return self.cipher.decrypt(data)实操心得我在分析某电商App时发现它的RC4密钥是硬编码在libsecurity.so的.rodata段偏移0x12A8处长度16字节。用readelf -x .rodata libsecurity.so | grep -A 2 12a8就能快速定位。记住密钥永远在so里不在dex里——这是二代壳的铁律。4. 从raw dex到可反编译dex混淆还原的三道关卡与实战修复拿到Fart dump出的raw_dex文件只是万里长征第一步。此时的文件大概率无法被jadx或dex2jar正常解析报错通常是ERROR: Invalid dex file, no magic foundjava.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0Error: Could not parse DEX file: Bad version number这些错误背后是二代壳施加的三道混淆关卡必须逐个击破4.1 关卡一Header篡改——Magic与Version字段的双重欺骗dex文件头的第0-3字节是magic第4-7字节是checksum第8-27字节是signature第28-31字节是file_size第32-35字节是header_size第36-39字节是endian_tag第40-43字节是link_size……其中version字段位于第44-47字节dex format version标准值为0x00303030即000。二代壳常做的手脚把magic改成0x00646579dey\x00让检测工具误判为非法文件把version改成0x00313131111导致dex2oat拒绝加载把file_size字段减去100让解析器读取越界修复方法用十六进制编辑器如HxD或xxd手动修正。以000版本为例# 查看原始header xxd -l 64 raw_dex.dex | head -n 4 # 修正magic0x00-0x03和version0x2C-0x2F printf \x64\x65\x78\x00 | dd ofraw_dex.dex bs1 seek0 convnotrunc printf \x30\x30\x30\x00 | dd ofraw_dex.dex bs1 seek44 convnotrunc # 修正file_size0x1C-0x1F为实际文件大小假设为1234567字节小端序 printf \x27\x11\x12\x00 | dd ofraw_dex.dex bs1 seek28 convnotrunc提示file_size必须精确等于ls -l raw_dex.dex | awk {print $5}的结果否则dexdump会报Invalid file size。我习惯用Python脚本自动化with open(raw_dex.dex, rb) as f: size os.path.getsize(raw_dex.dex) # 写入小端序的size4字节 f.seek(28) f.write(size.to_bytes(4, little))4.2 关卡二StringId与TypeId的全局偏移错乱dex文件的string_ids区存放所有字符串的索引表和type_ids区存放所有类名的索引表的起始偏移记录在header的第64-67字节string_ids_off和第68-71字节type_ids_off。二代壳为了增加解析难度会把这两个偏移值加上一个随机数如0x1000让解析器跳到错误位置读取。后果是jadx打开时显示No classes founddexdump -d raw_dex.dex输出string_id_item[0] 0x00000000但实际字符串表在别处。修复方法用dexdump -f raw_dex.dex查看header信息对比string_ids_size和string_ids_off。正常情况下string_ids_off应该大于header_size0x70且string_ids_off string_ids_size * 4不应超过file_size。如果string_ids_off异常大如0x100000说明被加了偏移。计算真实偏移real_offset string_ids_off - 0x1000假设偏移量是0x1000。然后用dd命令把string_ids区的数据块复制到正确位置# 计算string_ids区大小单位项数 string_size$(dexdump -f raw_dex.dex | grep string_ids_size | awk {print $2}) # 每项4字节所以总字节数 string_size * 4 string_bytes$((string_size * 4)) # 从fake offset读取写入real offset dd ifraw_dex.dex ofraw_dex.dex bs1 skip$((0x100000)) seek$((0x100000 - 0x1000)) count$string_bytes convnotrunc4.3 关卡三ClassDef的CodeItem指针污染这是最隐蔽也最致命的一关。class_def_item结构体的第24-27字节是class_data_off指向class_data_item第28-31字节是static_values_off。二代壳常把class_data_off设为一个非法地址如0xFFFFFFFF导致jadx在解析方法时崩溃。验证方法用dexdump -d raw_dex.dex | grep -A 10 Class #0看class_data_off字段是否为0xffffffff。修复策略不是瞎猜而是逆向class_data_item的生成逻辑。class_data_item的结构是static_fields_sizeuleb128instance_fields_sizeuleb128direct_methods_sizeuleb128virtual_methods_sizeuleb128然后是各field/method的列表uleb128编码的特点是每个字节最高位为1表示还有后续字节否则结束。所以class_data_item的起始一定是连续的几个uleb128数字。我们在raw_dex.dex中搜索0x00 00 00 00四个0代表四个0-size的uleb128大概率就是class_data_item的开头。实战中我用Python脚本自动定位def find_class_data_start(dex_path: str) - int: with open(dex_path, rb) as f: data f.read() # 搜索0x00000000四个连续0字节 for i in range(len(data)-4): if data[i:i4] b\x00\x00\x00\x00: # 检查前后是否符合class_data_item结构前4个uleb128 if is_valid_class_data(data, i): return i return -1 def is_valid_class_data(data: bytes, pos: int) - bool: # uleb128解码函数略 try: s1 read_uleb128(data, pos) s2 read_uleb128(data, pos len_uleb128(s1)) s3 read_uleb128(data, pos len_uleb128(s1) len_uleb128(s2)) s4 read_uleb128(data, pos len_uleb128(s1) len_uleb128(s2) len_uleb128(s3)) return True except: return False找到真实class_data_off后用xxd写入header# 假设真实偏移是0x8A5C0小端序0xC0 0x8A 0x00 0x00 printf \xc0\x8a\x00\x00 | dd ofraw_dex.dex bs1 seek24 convnotrunc完成这三道关卡修复后jadx-gui raw_dex.dex就能正常显示所有类和方法了。但注意业务逻辑代码可能还在native层——这是二代壳的终极防线需要结合unidbg或Qiling做动态模拟执行那已是另一个战场。最后分享一个小技巧修复后的dex用dex2jar转jar时如果遇到Unsupported class file version不要慌。这是因为dex版本号被壳改成了非标值如0x00313131。用baksmali反编译成smali再用smali重新编译会自动修正版本号baksmali d raw_dex.dex -o smali_out smali a smali_out -o fixed_dex.dex5. 完整脱壳流程复盘从设备连接到jadx打开的12步实操清单纸上得来终觉浅绝知此事要躬行。我把整个脱壳流程拆解为12个不可跳过的步骤每一步都标注了常见坑和绕过方案。这不是理论推演而是我在37台不同品牌、Android 8.0~14.0设备上反复验证的“抄作业”清单。5.1 步骤1-3环境准备与目标确认确认设备Root状态与Frida Server版本必须Root且su权限为shell用户可用adb shell su -c id应返回uid0(root)Frida Server必须与设备架构匹配ARM64设备用frida-server-16.1.4-android-arm64.xz不要混用ARMv7验证adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server 然后frida-ps -U应列出所有进程确认目标App的PID与包名adb shell ps | grep com.target.app记下PID如12345adb shell dumpsys package com.target.app | grep versionName确认是最新版避免分析旧壳禁用目标App的防调试机制二代壳普遍集成ptrace防调试直接frida -U -f com.target.app会闪退解决方案用frida-trace -U -f com.target.app -i ptrace在onEnter里args[0] ptrace_request.PTRACE_TRACEME强制解除防调试或更简单adb shell echo 0 /proc/sys/kernel/yama/ptrace_scope需Root5.2 步骤4-6Frida Hook与Raw Dex捕获运行定制化Frida脚本捕获raw_dex内存脚本必须包含CreateFromRawDexFile的模糊符号匹配见第2节关键onEnter中Memory.protect(args[0], args[1].toInt32(), r--)否则读取失败输出/data/data/com.target.app/cache/raw_dex_1712345678.dex时间戳命名避免覆盖从设备拉取raw_dex文件adb shell su -c cp /data/data/com.target.app/cache/raw_dex_*.dex /sdcard/adb pull /sdcard/raw_dex_*.dex ./检查ls -lh raw_dex_*.dex大小应在1MB~20MB之间小于100KB说明捕获失败用Fart --raw模式初步dump可选fart --raw --output ./fart_dump/ --obfuscator xor --key 0x55 ./raw_dex_*.dex如果Fart报No dex found说明混淆类型不是xor跳过此步直接进入手工修复5.3 步骤7-9Header与结构修复用dexdump -f分析raw_dex headerdexdump -f raw_dex_*.dex | head -n 20重点关注magic、file_size、string_ids_off、class_defs_off、class_defs_size如果magic不是64 65 78 00记录当前值如64 65 79 00准备xor修复修正magic与version字段printf \x64\x65\x78\x00 | dd ofraw_dex_*.dex bs1 seek0 convnotruncprintf \x30\x30\x30\x00 | dd ofraw_dex_*.dex bs1 seek44 convnotruncprintf $(printf %08x $(stat -c%s raw_dex_*.dex) | sed s/../\n/g | tac | xargs) | xxd -r -p | dd ofraw_dex_*.dex bs1 seek28 convnotrunc自动写入file_size定位并修复class_data_off用xxd raw_dex_*.dex | grep -A 5 0000 0000找到00000000出现的位置如0x8A5C0printf \xc0\x8a\x00\x00 | dd ofraw_dex_*.dex bs1 seek24 convnotrunc写入class_def_item的class_data_off5.4 步骤10-12验证与反编译用dexdump -d验证基础结构dexdump -d raw_dex_*.dex | head -n 50应看到Class #0、Class #1等且class_data_off值合理如0x8A5C0如果报Invalid offset说明class_data_off写错了回到步骤9重试用baksmali反编译验证baksmali d raw_dex_*.dex -o smali_outls smali_out/应列出com/、android/等包目录cat smali_out/com/target/app/MainActivity.smali | head -n 10应看到.class、.super等标准smali语法用jadx-gui打开最终成果jadx-gui raw_dex_*.dex成功标志左侧包树展开双击MainActivity.java右侧显示可读Java代码无// ERROR注释如果仍有// ERROR说明某个method的code_item损坏需用smali单独修复该方法超出本文范围踩坑实录我在华为Mate 50Android 13上首次运行时步骤4的Frida脚本一直不触发CreateFromRawDexFile。排查三天才发现华为的libart.so把该函数符号名改成了_ZN3art7DexFile22CreateFromRawDexFileEPKhjSt10unique_ptrIKS0_St14default_deleteIS0_EEb而我的模糊匹配漏掉了末尾的Eb