17、一代壳脱壳实战:整体加固原理、内存dump DEX、修复DEX文件头与结构

各位同学,今天我们来聊点硬核的。一代壳,也就是整体加固,是Android加固领域最基础的形态。虽然现在很多App都上了VMP、Dex2C这些高级玩法,但一代壳依然是入门脱壳的必修课。我个人习惯把一代壳比作「给DEX文件穿了一件外套」——你看到的APK里,真正的DEX被加密或隐藏了,运行时才在内存中还原。

说白了,整体加固的核心就三步:加密原始DEX → 替换为壳DEX → 运行时解密并加载。我们今天要做的,就是抓住它解密后的那个瞬间,把DEX从内存里「捞」出来,再修复好让它能正常分析。

整体加固原理:壳是怎么「骗」过系统的?

先看一张图,理解一下整体加固的完整流程:

整体加固运行流程 原始APK 包含原始DEX 加固处理 加密原始DEX + 注入壳DEX 加固后APK 壳DEX + 加密数据 安装运行 系统加载壳DEX 壳代码执行 解密原始DEX到内存 内存中的原始DEX 解密后完整DEX ← 脱壳点:从这里dump DEX →

流程其实不复杂。加固厂商把原始DEX文件加密,然后塞进一个「壳DEX」里。这个壳DEX本身是一个合法的、可以正常加载的DEX文件,里面包含了解密逻辑。当App启动时,系统先加载壳DEX,壳代码拿到控制权后,在内存中解密原始DEX,再通过DexClassLoader或直接调用openDexFile等底层API来加载真正的业务代码。

关键点:原始DEX一定会在内存中以明文形式存在。这是整体加固的「死穴」——你加密得再好,运行时总得解密。我们要做的,就是在这个时间窗口内把数据捞出来。

内存dump DEX:抓住那个瞬间

怎么捞?最直接的方法就是内存dump。我早期做脱壳时,用过好几种方案,这里给大家梳理一下:

方法 原理 优点 缺点
ddms/hprof dump 通过DDMS或adb导出heap dump 操作简单,无需root 数据量大,DEX可能不完整
frida脚本dump Hook关键函数,读取内存 精准定位,可自动化 需要frida环境,部分壳有反frida
Xposed模块dump Hook ClassLoader加载流程 稳定可靠 需要Xposed框架,兼容性一般
自定义Loader 修改系统源码,编译自定义ROM 最底层,几乎无法检测 门槛高,需要刷机

我个人最常用的是frida脚本dump。为什么?因为它灵活,而且可以针对不同加固厂商做定制。你想想看,每个壳的解密时机、解密方式都不一样,frida可以让我们动态调整Hook点。

这里分享一个我常用的frida dump脚本核心逻辑:

// frida dump DEX 核心代码片段
function dumpDex() {
    // 枚举所有加载的DEX
    Java.enumerateClassLoaders({
        onMatch: function(loader) {
            try {
                var baseDexClassLoader = Java.use('dalvik.system.BaseDexClassLoader');
                var pathListField = baseDexClassLoader.getDeclaredField('pathList');
                pathListField.setAccessible(true);
                var pathList = pathListField.get(loader);
                
                var dexElementsField = pathList.getClass().getDeclaredField('dexElements');
                dexElementsField.setAccessible(true);
                var dexElements = dexElementsField.get(pathList);
                
                for (var i = 0; i < dexElements.length; i++) {
                    var dexElement = dexElements[i];
                    var dexFileField = dexElement.getClass().getDeclaredField('dexFile');
                    dexFileField.setAccessible(true);
                    var dexFile = dexFileField.get(dexElement);
                    
                    if (dexFile != null) {
                        var mCookieField = dexFile.getClass().getDeclaredField('mCookie');
                        mCookieField.setAccessible(true);
                        var mCookie = mCookieField.get(dexFile);
                        
                        // 通过cookie读取DEX数据
                        var dexData = readDexFromCookie(mCookie);
                        if (dexData != null && dexData.length > 0) {
                            // 保存到文件
                            saveToFile('/data/local/tmp/dex_' + i + '.dex', dexData);
                        }
                    }
                }
            } catch(e) {
                console.log('Error: ' + e);
            }
        },
        onComplete: function() {}
    });
}

小技巧:很多壳会在解密后立即释放或清零内存。我建议在壳的Application.attachBaseContext()Application.onCreate()之后延迟几百毫秒再dump,成功率会高很多。

修复DEX文件头与结构:让Jadx能读懂

好,DEX dump出来了。但直接扔进Jadx或GDA,大概率报错。为什么?因为内存中的DEX数据可能不完整,或者文件头被篡改了。我曾经遇到过一个壳,它把DEX的checksumsignature字段清零了,导致所有反编译工具都拒绝解析。

修复DEX文件头,说白了就是让DEX文件恢复成一个「合法」的状态。DEX文件头结构如下:

偏移 大小 字段 说明
0x00 8 bytes magic 固定为"dex\n035\0"
0x08 4 bytes checksum adler32校验和
0x0C 20 bytes signature SHA-1签名
0x20 4 bytes file_size 文件总大小
0x24 4 bytes header_size 固定为0x70
0x28 4 bytes endian_tag 0x12345678
0x2C 4 bytes link_size 链接段大小
0x30 4 bytes link_off 链接段偏移
0x34 4 bytes map_off map段偏移
0x38 4 bytes string_ids_size 字符串ID数量
0x3C 4 bytes string_ids_off 字符串ID偏移
0x40 4 bytes type_ids_size 类型ID数量
0x44 4 bytes type_ids_off 类型ID偏移
0x48 4 bytes proto_ids_size 原型ID数量
0x4C 4 bytes proto_ids_off 原型ID偏移
0x50 4 bytes field_ids_size 字段ID数量
0x54 4 bytes field_ids_off 字段ID偏移
0x58 4 bytes method_ids_size 方法ID数量
0x5C 4 bytes method_ids_off 方法ID偏移
0x60 4 bytes class_defs_size 类定义数量
0x64 4 bytes class_defs_off 类定义偏移
0x68 4 bytes data_size 数据段大小
0x6C 4 bytes data_off 数据段偏移

修复时,我们主要关注几个字段:

  • magic:必须是"dex\n035\0",如果不是,直接改回去。
  • checksum:用adler32算法重新计算整个文件(从0x0C开始到文件末尾)的校验和。
  • signature:用SHA-1重新计算(从0x20开始到文件末尾)。
  • file_size:如果dump出来的数据长度不对,需要修正。
  • header_size:固定0x70,如果被改了,改回来。
  • endian_tag:固定0x12345678。

这里给一个Python修复脚本的核心逻辑:

import hashlib
import zlib
import struct

def fix_dex_header(dex_data):
    """修复DEX文件头"""
    # 1. 修复magic
    if dex_data[0:4] != b'dex\n':
        dex_data = b'dex\n035\0' + dex_data[8:]
    
    # 2. 修复endian_tag
    struct.pack_into('<I', dex_data, 0x28, 0x12345678)
    
    # 3. 修复header_size
    struct.pack_into('<I', dex_data, 0x24, 0x70)
    
    # 4. 修复file_size(如果已知实际大小)
    actual_size = len(dex_data)
    struct.pack_into('<I', dex_data, 0x20, actual_size)
    
    # 5. 修复signature (SHA-1)
    sha1 = hashlib.sha1(dex_data[0x20:]).digest()
    dex_data = dex_data[:0x0C] + sha1 + dex_data[0x20:]
    
    # 6. 修复checksum (adler32)
    checksum = zlib.adler32(dex_data[0x0C:]) & 0xFFFFFFFF
    struct.pack_into('<I', dex_data, 0x08, checksum)
    
    return dex_data

# 使用示例
with open('dump.dex', 'rb') as f:
    data = bytearray(f.read())

fixed_data = fix_dex_header(data)

with open('fixed.dex', 'wb') as f:
    f.write(fixed_data)

注意:有些壳会修改DEX的内部数据结构,比如篡改string_idsclass_defs的偏移。这种情况下,光修文件头是不够的,还需要根据DEX的map_off指向的map段来重建索引表。嗯,这个属于进阶内容,我们后面章节会详细讲。

实战中的坑与经验

做一代壳脱壳,有几个坑我踩过,跟大家分享一下:

  • dump时机不对:太早,DEX还没解密;太晚,内存已经被释放。我建议用frida的StalkerInterceptor来监控DexFile::openArtMethod::Invoke等关键函数,精准定位解密点。
  • DEX被分片存储:有些壳会把DEX分成多个块,分别加密。dump出来的可能只是其中一块。这时候需要分析壳的加载逻辑,找到所有分片再合并。
  • 文件头被深度篡改:比如把magic改成其他值,或者把header_size改大。修复时不能只改固定偏移,要结合DEX文件格式规范来逐字段校验。
  • 反调试/反Hook:很多壳会检测frida、Xposed等工具。我一般会用frida的--no-pause参数,或者配合frida-gadget注入来绕过检测。

我的建议:刚开始练手时,找一些老版本的加固App(比如360加固3.x、腾讯加固1.x),这些壳的加密逻辑相对简单,适合入门。等熟练了再挑战新版壳。

好了,一代壳脱壳的核心思路就这些。说白了就是:找到解密点 → dump内存 → 修复文件头。每一步都有很多细节,但框架就是这个框架。你动手试几个App,很快就能找到感觉。

最后提醒一句:脱壳技术请用于合法用途,比如分析自己的App、做安全研究。未经授权脱别人的壳,可能会惹上法律麻烦。嗯,这个大家心里有数就好。


公众号:蓝海资料掘金营,微信deep3321