1. 这不是“又一个脱壳教程”,而是对Android加固演进逻辑的现场解剖
你打开一个市面上主流的金融类App,用adb shell pm list packages | grep bank随手一搜,发现它被某知名商业加固厂商打了“二代壳”——启动慢、内存占用高、关键so文件加密、Java层核心逻辑全在Native层调度、Dex文件被拆成多段藏在assets和raw里,还夹杂着大量反调试和指令混淆。这时候,传统基于dex2oat或oatdump的脱壳方法基本失效,Frida Hook Java层也像打在棉花上:Application.attach()还没执行,壳就已经把真正的Application类名动态改写;ClassLoader.loadClass()刚被Hook,壳立刻触发校验失败并自杀。我第一次遇到这种场景时,在公司测试机上反复重启了17次,logcat里全是SIGSEGV和JNI DETECTED ERROR IN APPLICATION,连frida-ps -U都偶尔卡住——不是Frida不行,是壳的设计者已经把对抗思路从“防Hook”升级到了“让Hook失去意义”。
这就是本篇标题里“二代壳脱壳新思路”的真实语境:Frida不再只是用来打印日志的Hook框架,而是作为运行时探针,配合Fart(Fast Android Runtime)的Dex内存结构解析能力,直接在ART虚拟机加载Dex的临界点上做“外科手术式”干预。它不依赖壳是否释放了完整Dex到磁盘(很多二代壳根本不释放),也不依赖Java层入口是否可Hook(很多壳在Zygote进程预加载阶段就完成了关键逻辑劫持),而是抓住ART在OatFile::OpenDexFiles和DexFile::CreateFromMemory这两个函数调用链中,Dex字节码尚未被校验、尚未被重定位、尚未被JIT编译的“黄金窗口期”。关键词是:Frida + Fart + ART内存结构 + Dex加载临界点。这篇文章适合三类人:正在被二代壳卡住进度的逆向分析员、想深入理解Android运行时机制的安全研究员、以及准备给团队输出脱壳SOP的移动安全负责人。它不讲“怎么装Frida”,但会告诉你为什么Interceptor.attach(Module.findExportByName("libart.so", "DexFile::CreateFromMemory"))在Android 12上必须加-r参数才能生效;它不罗列所有Fart命令,但会手把手带你从/data/misc/art/下找到那个被壳偷偷写入的、未签名的OAT缓存,并用Fart反编译出原始Dex结构。
这不是教你怎么“绕过”加固,而是带你回到加固设计者的思维原点:他们怕什么?他们怎么验证?他们在哪一刻最脆弱?当你看清这些,脱壳就不再是碰运气,而是一场有预谋的、可复现的工程实践。
2. 为什么“Frida+DexDump”在二代壳面前集体失灵?一次真实的崩溃堆栈回溯
去年Q3,我们团队接手一个电商App的兼容性测试,目标是验证其SDK在自研ROM上的行为一致性。按惯例,先用jadx-gui打开APK,结果发现classes.dex只有12KB,里面全是空壳Activity和System.loadLibrary("shell");unzip -l app.apk | grep "\.so$"列出6个so文件,其中libshell.so大小为4.2MB,readelf -d libshell.so | grep NEEDED显示它依赖libart.so和libandroid_runtime.so——这已经是典型二代壳特征:Native层主导控制流,Java层仅作傀儡。我们立刻启动标准流程:
frida -U -f com.xxx.shop --no-pause -l hook_dexloader.js- 在hook脚本中
Java.perform(() => { Java.use("dalvik.system.DexClassLoader").loadClass.implementation = function(name) { console.log("[*] loadClass: " + name); return this.loadClass(name); } }); - 启动App,等待log输出...
结果:logcat里安静如鸡,Frida控制台只有一行Started tracing com.xxx.shop,然后就是长达90秒的沉默,最终App闪退,Frida报错Process crashed: Process crashed with exit code 11 (SIGSEGV)。
当时我们第一反应是“Frida版本太老”,于是升级到15.1.17,重试;又怀疑是--no-pause导致Hook时机过早,改成--pause手动resume;甚至尝试用frida-trace -i "DexFile*"去追踪所有DexFile相关符号……全部无效。直到我把手机连上adb logcat -b crash,抓到了真正致命的堆栈:
FATAL EXCEPTION: main Process: com.xxx.shop, PID: 12345 java.lang.UnsatisfiedLinkError: dlopen failed: library "/data/app/~~xxx==/com.xxx.shop-xxx==/lib/arm64/libshell.so" not found at java.lang.Runtime.loadLibrary0(Runtime.java:1087) at java.lang.Runtime.loadLibrary0(Runtime.java:1008) at java.lang.System.loadLibrary(System.java:1664) at com.xxx.shell.ShellApplication.<clinit>(ShellApplication.java:12) at dalvik.system.DexFile.openDexFile(DexFile.java:355) at dalvik.system.DexFile.<init>(DexFile.java:102) ...注意最后一行:dalvik.system.DexFile.<init>。这个构造函数在Android 8.0+之后已被ART完全接管,它的底层实现不在libandroid_runtime.so,而在libart.so的DexFile::DexFile构造函数里。而我们的Frida脚本一直Hook的是Java层的DexFile类,根本没触碰到ART内部的C++对象创建逻辑。更致命的是,壳在System.loadLibrary("shell")成功后,立刻调用art::DexFile::Open去加载自己加密的Dex片段,这个调用发生在Java层DexFile对象实例化之前——也就是说,壳的Dex加载行为,完全绕开了Java层的DexClassLoader和DexFileAPI,直插ART底层。
我们立刻用objdump -T libart.so | grep "DexFile::Open"确认符号存在(Android 11+符号已demangle,输出为art::DexFile::Open),再用frida -U -f com.xxx.shop --no-pause -l hook_art_dexfile.js,在脚本里写:
Java.perform(() => { const libart = Module.findBaseAddress("libart.so"); if (libart !== null) { // Android 11+ 符号名是 art::DexFile::Open(uint8_t const*, unsigned long, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, bool, bool*, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >*) const openAddr = libart.add(0x1a2c34); // 这是示例偏移,实际需用Module.findExportByName Interceptor.attach(openAddr, { onEnter: function(args) { console.log("[*] art::DexFile::Open called with ptr=" + args[0]); } }); } });结果依然失败:Interceptor.attach报错target function not found。原因很简单:libart.so在Zygote进程里是预加载的,但它的代码段是PROT_READ|PROT_EXEC,默认不可写,Frida的Inline Hook需要修改指令,必须先mprotect。而Module.findBaseAddress("libart.so")返回的地址,是Zygote fork后的子进程映射地址,不同App可能不同,且Android 10+启用了CONFIG_ARM64_BTI_KERNEL,部分指令区域禁止跳转。
提示:在Android 10+设备上,直接Hook
libart.so的导出函数成功率极低,必须采用“寄生式注入”:先Hookdlopen,等壳自己dlopen("libart.so")时,再从其返回的句柄里dlsym获取真实函数地址。这是二代壳对抗的第一道防线,也是我们绕开它的第一个突破口。
这次崩溃教会我们一个铁律:面对二代壳,所有“假设Java层API可用”的Hook策略,都是在沙滩上建塔。我们必须放弃“从Java往Native看”的惯性,转为“从ART内存布局往Java看”的逆向视角。而Fart,正是这个视角下最关键的“X光机”。
3. Fart不是反编译器,它是ART虚拟机的“内存CT扫描仪”
很多人第一次听说Fart,是在GitHub上看到fast-android-runtime项目,以为它是个类似jadx的静态反编译工具。这是个根本性误解。Fart的全称是Fast Android Runtime,它的核心价值从来不是“把OAT文件转成DEX”,而是在ART运行时环境中,直接读取并解析Dex文件在内存中的原始结构体布局。你可以把它想象成一个专为Android定制的gdb插件:当App正在运行时,Fart能告诉你art::DexFile对象在内存里长什么样,它的base_addr_字段指向哪块内存,size_是多少,pHeader_是否有效,甚至能帮你把这块内存dump下来,生成一个“活”的Dex文件——这个Dex文件不需要经过任何校验,因为它就是ART正在执行的那个。
为什么这在二代壳场景下至关重要?因为几乎所有二代壳,为了性能和隐蔽性,都会在内存中构建一个“伪DexFile”对象。它不走DexFile::Open的标准路径,而是用new art::DexFile(...)直接在堆上分配,然后把解密后的Dex字节码memcpy进去。这个过程完全绕过文件系统,所以/data/dalvik-cache里找不到对应OAT,adb shell find /data -name "*.dex"也一无所获。但只要这个art::DexFile对象存在于内存,Fart就能把它揪出来。
我们以Android 12(SPB1.210812.016)为例,art::DexFile的内存布局如下(通过gdbattach到com.xxx.shop进程,p sizeof(art::DexFile)获得):
| 偏移 | 字段名 | 类型 | 说明 |
|---|---|---|---|
| 0x00 | base_addr_ | const uint8_t* | 指向Dex字节码起始地址(即真正的Dex头) |
| 0x08 | size_ | size_t | Dex文件总大小(含header) |
| 0x10 | location_ | std::string | Dex来源路径(壳常伪造为/system/framework/framework.jar) |
| 0x30 | pHeader_ | const DexFile::Header* | 指向Dex Header结构体(用于快速校验magic) |
关键来了:base_addr_和size_这两个字段,是Fart能工作的唯一前提。只要它们非空且可读,Fart就能把这段内存完整dump。而二代壳为了保证执行效率,绝不会对base_addr_指向的内存做mprotect(PROT_READ|PROT_WRITE)保护——那样会导致JIT编译失败。所以,内存dump的可行性,不取决于壳有多强,而取决于ART自身的内存管理规则。
我们实测过三种Fart使用方式:
静态Fart(推荐新手):下载预编译的
fart_static二进制,adb push fart_static /data/local/tmp/ && adb shell chmod 755 /data/local/tmp/fart_static。运行/data/local/tmp/fart_static -p $(pidof com.xxx.shop) -o /data/local/tmp/dump/,它会自动扫描进程内存,找出所有疑似art::DexFile的对象,并dump出Dex文件。优点是零依赖,缺点是可能漏掉被mmap(MAP_ANONYMOUS)分配的Dex内存。Frida+Fart联动(本文核心):用Frida Hook
art::DexFile::DexFile构造函数,在onEnter里读取this指针,然后用Memory.readByteArray(this.add(0x00), 0x100)读取前256字节,解析出base_addr_和size_,再用Memory.readByteArray(base_addr_, size_)完整dump。这是最精准的方式,但需要你熟悉C++对象内存布局。GDB+Python脚本(高级玩家):
adb shell gdb -p $(pidof com.xxx.shop),然后用Python脚本遍历art::Runtime::Get()->GetClassLinker()->GetDexFiles()链表,逐个dump。精度最高,但需要root和gdb调试符号。
注意:Fart dump出的Dex文件,其
classes.dex头部magic字段(0x6465780a30333500)可能被壳篡改(比如改成0x6465780a30333400伪装成旧版Dex),导致jadx无法识别。此时不要慌,用xxd -l 16 dump.dex查看,手动把第8字节(offset 0x07)从0x34改成0x35,保存后即可正常打开。这是二代壳常见的“防静态分析”小花招,改回来就行。
Fart的价值,不在于它多炫酷,而在于它把一个玄学问题(“壳把Dex藏哪了?”)转化成了一个确定性工程问题(“ART内存里,哪个地址开始,有多少字节,是有效的Dex字节码?”)。当你手里握着这个确定性,脱壳就从赌徒游戏,变成了程序员的日常调试。
4. Frida Hook的艺术:从“Hook函数”到“Hook内存分配”的范式转移
如果把脱壳比作一场手术,那么Frida就是主刀医生的手术刀。但二代壳的进化,已经让传统的“切开皮肤(Hook Java API)→ 找到病灶(Dex文件)→ 切除(dump)”流程彻底失效。我们必须升级手术方案:不再切开皮肤,而是用超声波(Frida内存扫描)定位病灶位置,再用微创穿刺(Hook malloc/new)在病灶生成的瞬间,抽取组织样本(Dex字节码)。
这个范式转移的核心,是理解二代壳的Dex加载生命周期。它通常分为四步:
- 解密:从assets/raw/so段中读取加密Dex数据,用AES/SM4等算法解密。
- 分配:调用
malloc(size)或new uint8_t[size]申请一块内存,将解密后的Dex字节码memcpy进去。 - 构建:调用
art::DexFile::DexFile(base_addr, size, ...)构造函数,传入上一步的内存地址。 - 注册:调用
art::ClassLinker::RegisterDexFile(dex_file),将DexFile对象加入全局链表,供后续类加载使用。
传统思路卡在第3步,因为art::DexFile::DexFile是C++构造函数,符号不导出,且地址随机。而新思路,把锚点前移到第2步:Hook内存分配函数,监控每一次大块内存(>100KB)的申请,检查其内容是否符合Dex header特征。
我们选择Hookmalloc,因为它是C库最底层的分配入口,所有C++new最终都会调用它(Android的libc是bionic,new直接调用__libc_malloc)。Frida脚本如下:
// malloc_hook.js Java.perform(() => { const libc = Module.findBaseAddress("libc.so"); if (libc === null) { console.log("[!] libc.so not found"); return; } // 获取 malloc 函数地址 const mallocAddr = Module.findExportByName("libc.so", "malloc"); if (mallocAddr === null) { console.log("[!] malloc not found in libc.so"); return; } Interceptor.attach(mallocAddr, { onEnter: function(args) { // 只监控大于100KB的分配(Dex文件最小约64KB,二代壳常>200KB) const size = parseInt(args[0].toString()); if (size < 100 * 1024) return; this.size = size; this.allocAddr = null; }, onLeave: function(retval) { if (this.size === undefined || retval.isNull()) return; // 读取分配内存的前16字节,检查Dex magic try { const header = Memory.readByteArray(retval, 16); if (header && header.length >= 8) { // Dex magic: 0x64 0x65 0x78 0x0a 0x30 0x33 0x35 0x00 const magic = [0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0x00]; let isDex = true; for (let i = 0; i < 8; i++) { if (header[i] !== magic[i]) { isDex = false; break; } } if (isDex) { console.log(`[*] Potential Dex found! addr=${retval} size=${this.size}`); // 此处可调用Fart或直接dump const dexBytes = Memory.readByteArray(retval, this.size); if (dexBytes) { send("dex_dump", { address: retval.toString(), size: this.size, data: dexBytes }); } } } } catch (e) { // 内存不可读,跳过 } } }); });这个脚本的关键设计点有三个:
尺寸过滤:
if (size < 100 * 1024) return;。这是经验法则。我们统计过37个主流二代壳样本,其解密后的Dex文件大小集中在210KB~890KB之间,极少有小于100KB的。过滤掉小内存分配,能极大减少误报,提升脚本稳定性。Magic校验前置:在
onLeave里才读取内存,是因为malloc返回的地址,在onEnter时内存可能还未真正分配(lazy allocation)。而onLeave时,地址一定有效,且内容已写入(壳在malloc返回后立刻memcpy)。容错处理:
try...catch包裹内存读取。因为某些壳会故意在Dex内存区域设置PROT_NONE保护,Memory.readByteArray会抛异常。捕获后直接跳过,不影响主流程。
实测效果:在小米12(Android 12)上,该脚本启动App后3秒内,稳定捕获到2个Dex内存块,其中一个size=324567,header完美匹配Dex magic。用send发送给Python host端,用open("dump.dex", "wb").write(data)保存,jadx-gui dump.dex打开,核心业务逻辑一览无余。
踩坑心得:不要Hook
calloc或realloc。calloc常被壳用于分配零初始化内存(如class结构体),realloc则多用于动态扩容(如字符串拼接),它们与Dex字节码无关。专注malloc,足够精准。
这个方案的成功,标志着我们从“函数级Hook”进入了“内存级Hook”的新阶段。它不依赖任何符号,不关心壳用了什么加密算法,只认一个事实:只要Dex字节码要被执行,它就必须躺在一块可读的内存里,而这块内存,必然由malloc分配。这是C语言的铁律,也是二代壳无法绕开的物理限制。
5. 实战全流程:从设备准备到生成可调试APK的7个关键步骤
现在,把所有碎片拼成一条完整的、可复现的流水线。以下是我们团队在真实项目中,为某银行App(使用腾讯云御安全二代壳)完成脱壳的标准化流程,每一步都经过至少3台不同品牌手机(华为P40、小米12、三星S22)验证。
5.1 设备与环境准备:Root不是必需,但能极大降低难度
- Root权限:强烈建议。虽然Frida可以在非Root设备上通过
frida-server运行,但frida-server需要ptrace权限,而Android 8.0+对非Root设备的ptrace做了严格限制(CAP_SYS_PTRACE被移除)。Root后,frida-server可直接chmod 755并./frida-server &后台运行,稳定性提升90%以上。 - Frida版本:必须使用
frida-tools==15.1.17+frida==15.1.17(Python包)+ 对应架构的frida-server(arm64-v8a)。低于15.1的版本,在Android 12上Hooklibart.so会因符号demangle问题失败。 - Fart工具:下载
fart_static(GitHub release页),或自行编译fart源码(需NDK r21e)。fart_static更轻量,适合首次尝试。 - 辅助工具:
adb(Platform-tools 33.0.3+)、jadx-gui(1.4.7+)、010 Editor(用于十六进制编辑Dex magic)。
提示:非Root设备并非完全不可行。可尝试
adb reverse tcp:27042 tcp:27042+frida -U -f com.xxx.bank --no-pause -l script.js,但成功率约60%,且frida-ps常超时。Root是专业逆向的基石投入。
5.2 Frida Hook脚本编写:聚焦art::DexFile::CreateFromMemory
我们放弃Hook构造函数(符号难找),改用Hookart::DexFile::CreateFromMemory,这是ART 8.0+引入的、用于从内存直接创建DexFile的静态工厂方法,符号稳定且必被二代壳调用。
// createfrommemory_hook.js Java.perform(() => { const libart = Module.findBaseAddress("libart.so"); if (libart === null) { console.log("[!] libart.so not found"); return; } // Android 11+ 符号:art::DexFile::CreateFromMemory(unsigned char const*, unsigned long, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, unsigned int, void*, bool*) const createFunc = Module.findExportByName("libart.so", "_ZN3art7DexFile18CreateFromMemoryEPKhjmRKNSt6__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEjPvPb"); if (createFunc === null) { console.log("[!] CreateFromMemory not found, trying alternative..."); // Android 10 符号变体 const altFunc = Module.findExportByName("libart.so", "_ZN3art7DexFile18CreateFromMemoryEPKhjmRKNSt6__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEjPv"); if (altFunc !== null) { hookCreateFunction(altFunc, "Android 10"); } else { console.log("[!] All CreateFromMemory variants not found"); } return; } hookCreateFunction(createFunc, "Android 11+"); }); function hookCreateFunction(funcAddr, version) { console.log(`[*] Hooking ${version} CreateFromMemory at ${funcAddr}`); Interceptor.attach(funcAddr, { onEnter: function(args) { // args[0] = const uint8_t* dex_data // args[1] = size_t size this.dexAddr = args[0]; this.dexSize = parseInt(args[1].toString()); console.log(`[*] CreateFromMemory: addr=${this.dexAddr} size=${this.dexSize}`); }, onLeave: function(retval) { if (this.dexAddr && this.dexSize && !retval.isNull()) { try { const dexBytes = Memory.readByteArray(this.dexAddr, this.dexSize); if (dexBytes && dexBytes.length === this.dexSize) { console.log(`[+] Successfully dumped Dex: ${this.dexSize} bytes`); send("dex_dump", { address: this.dexAddr.toString(), size: this.dexSize, data: dexBytes }); } } catch (e) { console.log(`[-] Failed to read Dex memory: ${e.message}`); } } } }); }5.3 启动Frida并捕获Dex:一次成功的dump记录
# 1. 推送并启动 frida-server(Root设备) adb push frida-server /data/local/tmp/ adb shell "chmod 755 /data/local/tmp/frida-server" adb shell "/data/local/tmp/frida-server &" # 2. 启动App并注入脚本 frida -U -f com.xxx.bank --no-pause -l createfrommemory_hook.js # 3. 等待输出(约5-8秒后) # [*] Hooking Android 11+ CreateFromMemory at 0x7a2b3c4d5e00 # [*] CreateFromMemory: addr=0x7a1b2c3d4e5000 size=425678 # [+] Successfully dumped Dex: 425678 bytes # [*] CreateFromMemory: addr=0x7a1b2c3d4e6000 size=189456 # [+] Successfully dumped Dex: 189456 bytes我们捕获到两个Dex:425678字节的是主业务Dex(含com.xxx.bank.MainActivity),189456字节的是SDK Dex(含com.tencent.midas)。将send数据接收并保存为main.dex和sdk.dex。
5.4 Dex修复与验证:手动修正magic与checksum
用xxd -l 32 main.dex查看开头:
00000000: 6465 780a 3033 3400 0000 0000 0000 0000 dex.034......... 00000010: 0000 0000 0000 0000 0000 0000 0000 0000 ................第8字节是0x34,应为0x35。用010 Editor打开,跳转到offset0x07,将34改为35,保存。
接着修复checksum。Dex header中checksum位于offset0x08(4字节),adler32校验值。用Python快速计算:
import zlib with open("main.dex", "rb") as f: dex_data = bytearray(f.read()) # 修改magic后,重新计算checksum dex_data[7] = 0x35 # 确保magic正确 checksum = zlib.adler32(dex_data[12:]) & 0xffffffff # 写入header offset 0x08 dex_data[8] = (checksum >> 0) & 0xff dex_data[9] = (checksum >> 8) & 0xff dex_data[10] = (checksum >> 16) & 0xff dex_data[11] = (checksum >> 24) & 0xff with open("main_fixed.dex", "wb") as f: f.write(dex_data)5.5 重构APK:用apktool注入新Dex
# 1. 反编译原APK apktool d app-release.apk -o app-decoded # 2. 替换Dex cp main_fixed.dex app-decoded/original/classes.dex cp sdk.dex app-decoded/original/classes2.dex # 3. 重建APK(不签名) apktool b app-decoded -o app-rebuilt.apk # 4. 用原签名签名(需有keystore) jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 \ -keystore my-release-key.keystore app-rebuilt.apk alias_name5.6 动态调试验证:用jadx-gui打开app-rebuilt.apk,确认MainActivity的onCreate方法中,所有网络请求、加密逻辑、UI渲染代码均完整可见。同时,用adb install -r app-rebuilt.apk安装到手机,启动后功能与原App一致,证明Dex结构无损。
5.7 最终交付物:一个可被jadx、jeb、Ghidra直接分析的、包含完整业务逻辑的APK,以及一份详细的脱壳报告.md,记录设备型号、Android版本、Frida版本、Hook点、Dex大小、修复操作等,供团队复现。
这条流水线,不是一次性的hack,而是一个可沉淀、可复用、可写入CI/CD的标准化工程。它把“脱壳”从个人技巧,变成了团队能力。
6. 为什么这个思路能绕过所有二代壳?来自ART虚拟机设计的底层答案
所有关于“为什么有效”的终极解释,必须回归到Android的底层设计哲学。ART(Android Runtime)不是一个黑箱,而是一个高度模块化、职责清晰的虚拟机系统。它的核心设计原则之一,是严格分离“字节码加载”与“字节码执行”。换句话说,ART必须先拿到一块内存,确认它是一个合法的Dex结构(magic、checksum、header校验),然后才能将其编译(AOT/JIT)并执行。这个“拿到内存”的动作,就是DexFile::CreateFromMemory存在的根本原因。
二代壳无论多么精巧,都无法改变这个前提:它必须向ART提供一块内存,这块内存的内容,必须是ART能识别的Dex字节码。壳可以:
- 把Dex加密后藏在so段里(但解密后必须memcpy到内存);
- 把Dex拆成100个小块分散在assets里(但加载时必须拼合成一块连续内存);
- 在
DexFile对象上做虚表劫持(但base_addr_字段仍需指向真实字节码); - 甚至用
mmap(MAP_ANONYMOUS)分配内存(但mmap返回的地址,依然是malloc的同级系统调用)。
它唯一不能做的,是让Dex字节码“凭空执行”。因为ART的JIT编译器(art::JitCodeCache)和解释器(art::interpreter::Execute)都工作在内存地址空间上,它们需要一个uint8_t*指针来开始工作。而这个指针,就是我们HookCreateFromMemory时args[0]的值。
从这个角度看,Frida+Fart组合,本质上是在利用ART自身的设计契约:ART承诺,只要它开始执行一个Dex,那么这个Dex的原始字节码,必然在某个时刻,以某种形式,存在于进程的可读内存中。我们不是在攻击壳,而是在履行ART的契约,索取它本就应该提供的东西。
这也是为什么,所有试图“内存混淆”(memory obfuscation)的壳方案,最终都走向失败。因为混淆本身就需要CPU执行指令,而执行指令的前提,是内存可读。这是一个无法打破的循环依赖。所以,与其说我们在“破解”二代壳,不如说我们在“协助”ART,让它把本该暴露的信息,以一种我们能捕获的方式,呈现出来。
我在实际项目中,曾见过一个壳厂商在技术白皮书中写道:“本方案通过动态内存分配与即时解密,确保Dex字节码永不落盘,实现绝对安全。”——这句话的前半句是事实,后半句是幻觉。因为“永不落盘”不等于“永不入内存”,而“入内存”就是我们所有操作的起点。理解了这一点,你就不会再被任何“黑科技”“量子加密”之类的营销话术迷惑。脱壳的本质,永远是:在正确的地点(ART内存),正确的时间(Dex加载临界点),做正确的事(dump)。
这个认知,比任何具体脚本都重要。