穿透加固壳:Android加固与脱壳的底层逻辑

作者:奇安信洞鉴 发布:2026-09-23 17:30 收录:2026-09-23 21:12 4 次阅读 约 3583 字
摘要:反编译一打开,全是空方法体,代码去哪了?
在软件著作权侵权纠纷中,判断两套软件是否“相似”,是电子数据司法鉴定里最具技术含量的一类问题。
软件相似性鉴定绝非“把两份代码放在一起比一比”那么简单。真实的涉案检材往往经过加固处理:反编译工具一打开,只能看到一片空方法体或跳板指令,真正的业务逻辑被牢牢藏了起来;即便还原出代码,还要剔除大量第三方库、框架代码等“非原创”部分,才能得到真正能说明问题的相似比例。层层拆解下来,第一道、也是最关键的门槛,就是加固壳。
本文以本次司法鉴定科学研究院组织的“电子数据相似性鉴定(软件)”能力验证(2026 AFS-46)为例。以其中的 APK 加固识别环节为切入点,聚焦开源加固壳 dpt-shell,讲清楚它的加固原理、核心源码,以及识别与脱壳思路,帮你建立“识壳 → 脱壳 → 还原 → 分析”的完整逆向链路。



01

认识 dpt-shell:一款函数级代码抽取壳

dpt-shell(github.com/luoyesiqiu/dpt-shell)是一款开源的 Android 应用加固壳,属于“函数级代码抽取壳”。要理解它,先分清 Android 加固里的两种主流形态:
  • 整体壳:把整个 DEX 文件加密打包,运行时整体解密加载。静态下看不到任何代码,但解密后是完整的 DEX。
  • 抽取壳:把每个方法的代码数据(CodeItem)从 DEX 里“掏空”,在原位置填入 NOP 指令;被掏出的 CodeItem 加密后另存,运行时在类加载时再“填回去”。静态看方法体是空的,必须还原才能看到真实逻辑。dpt-shell 走的就是这条路。
图 1 整体壳与函数级抽取壳的对比
函数级抽取的杀伤力在于:反编译工具(如 jadx)直接打开加固后的 DEX,只能看到一片空方法体或跳板,看不到任何业务逻辑,从而让静态分析基本失效。



02

加固原理:方法体是怎么被“抽空”又“还魂”的

dpt-shell 的加固可以拆成三个阶段,理解这三步,整个壳的机制就通了。


2.1 抽取阶段
加固时,dpt-shell 会遍历原始 DEX 中的每一个方法,把方法的代码数据(DEX 结构中的 CodeItem)整体抽走,在原位置填入 NOP(空操作)指令。这样一来,加固产物里的方法体就是“空壳”。


2.2 存储阶段
被抽走的 CodeItem 不会凭空消失,而是被加密后塞进 APK 的 assets 目录:
  • assets/OoooooOooo —— 加密存储的方法代码数据(CodeItem),是还原逻辑的唯一来源。
  • assets/dshelldata_001 —— 壳配置文件,使用 AES-256-CBC 加密,保存壳运行所需的各种配置。
  • assets/vwwwwwvwww/libe01ce7566771bf96.so —— Native 库,支持 arm/arm64/x86/x86_64 四种架构,负责运行时解密回填。


2.3 回填阶段
运行期,Native 库会 Hook ART 虚拟机的 ClassLinker::DefineClass 函数。每当一个类要被加载时,壳代码抢在真正定义类之前介入,把这个类里所有方法的 CodeItem 解密后写回 DEX 内存,再调用原始的 DefineClass 完成类加载。因此:静态文件是空的,内存里才是完整的——这是理解 dpt-shell 一切行为的总钥匙。


2.4 整体链路一览
图 2 dpt-shell 加固与运行时还原全流程



03

源码分析:核心函数逐行拆解

下面按调用链,从入口一路拆到最核心的解密回填函数。代码取自 dpt-shell 开源仓库,为便于阅读做了精简,关键逻辑保持不变。


3.1 入口:initdpt / dpthook
App 启动后,Native 层的 init_dpt 是加固逻辑的入口。它先解密自身 .bitcode 段,再安装各类 Hook。
init_dpt()(dpt.cpp)——加固逻辑入口
void init_dpt() {
DECRYPT_BITCODEdecrypt_bitcode(); // 解密 .bitcode 段(RC4)
dpt_hook(); // 安装 Hook
}
dpthook()(dpthook.cpp)——安装各类 Hook
void dpt_hook() {
bytehook_init(BYTEHOOK_MODE_AUTOMATIC, false);
g_sdkLevel = android_get_device_api_level();
hook_execve(); // Hook execve
hook_mmap(); // Hook mmap(脱壳对抗的关键点)
hook_write(); // Hook write
bool ok = hook_DefineClass();
if (!ok) hook_LoadClass(); // DefineClass 失败则退回 LoadClass
}


3.2 Hook 目标:ClassLinker::DefineClass
dpt-shell 用 Dobby 框架,把 ART 里负责“定义类”的 ClassLinker::DefineClass 函数,替换成自己的 DefineClassV22/V21。这是“类加载前抢跑”的实现手段。
hookDefineClass()(dpthook.cpp)——定位并 Hook DefineClass
DPT_ENCRYPT bool hook_DefineClass() {
const char *path = GetClassLinkerDefineClassLibPath();
char sym[256] = {0};
find_symbol_in_elf_file(path, sym, ARRAY_LENGTH(sym), 2,
"ClassLinker", "DefineClass");
void *addr = DobbySymbolResolver(path, sym);
// 用 Dobby 把 ClassLinker::DefineClass 替换成 DefineClassV22
return DobbyHook(addr, (dobby_dummy_func_t) DefineClassV22,
(dobby_dummy_func_t *) &g_originDefineClassV22) == 0;
}
DefineClassV22()(dpt_hook.cpp)——先回填,再加载
DPT_ENCRYPT void *DefineClassV22(void *thiz, void *self,
const char *descriptor, size_t hash, void *class_loader,
const void *dex_file, const void *dex_class_def) {
if (LIKELY(g_originDefineClassV22 != nullptr)) {
patchClass(descriptor, dex_file, dex_class_def); // 先回填 CodeItem
return g_originDefineClassV22(thiz, self, descriptor, hash,
class_loader, dex_file, dex_class_def);
}
return nullptr;
}


3.3 类加载前回填:patchClass
patchClass 负责解析一个类的 class_data 结构,把它的直接方法、虚方法逐个交给 patchMethod 回填。DEX 的类数据用 ULEB128 变长编码,需要按顺序逐项读出。
patchClass()(dpt_hook.cpp)——遍历方法并回填
DPT_ENCRYPT void patchClass(const char *descriptor,
const void *dex_file, const void *dex_class_def) {
// 1. 垃圾类检测:发现非法调用直接崩溃(对抗脱壳机)...// 2. 依据 SDK 版本解析 DexFile 结构,拿到 begin/location/dexSize...auto *class_def = (dex::ClassDef *) dex_class_def;
if (LIKELY(class_def->class_data_off_ != 0)) {
auto *class_data = (uint8_t *) begin + class_def->class_data_off_;
size_t read = 0;
// ULEB128 变长编码依次读出字段数、方法数
read += DexFileUtils::readUleb128(class_data, &static_fields_size);
read += DexFileUtils::readUleb128(class_data + read, &instance_fields_size);
read += DexFileUtils::readUleb128(class_data + read, &direct_methods_size);
read += DexFileUtils::readUleb128(class_data + read, &virtual_methods_size);
// 跳过字段区,再读出直接方法与虚方法
read += DexFileUtils::getFieldsSize(class_data + read, static_fields_size);
read += DexFileUtils::getFieldsSize(class_data + read, instance_fields_size);
read += DexFileUtils::readMethods(class_data + read, directMethods, direct_methods_size);
read += DexFileUtils::readMethods(class_data + read, virtualMethods, virtual_methods_size);
// 逐个方法回填 CodeItem
for (uint64_t i = 0; i < direct_methods_size; i++)
patchMethod(begin, location, dexSize, dexIndex,
directMethods[i].method_idx_delta_,
directMethods[i].code_off_);
for (uint64_t i = 0; i < virtual_methods_size; i++)
patchMethod(begin, location, dexSize, dexIndex,
virtualMethods[i].method_idx_delta_,
virtualMethods[i].code_off_);
}}


3.4 核心中的核心:patchMethod 解密回填
这是 dpt-shell 最关键的函数:把加密的 CodeItem 解密,写回到 DEX 内存中方法指令所在的位置。注意它的解密密钥设计——用“壳密钥 + 方法索引”拼接作为 RC4 密钥,做到每个方法使用不同的密钥,增加还原难度。
patchMethod()(dpt_hook.cpp)——RC4 解密并写回内存
DPT_ENCRYPT ALWAYS_INLINE void patchMethod(uint8_t *begin,
const char *location, uint32_t dexSize, int dexIndex,
uint32_t methodIdx, uint32_t codeOff) {
// location:类描述符,用于日志定位;核心解密回填逻辑见下
auto it = dexMap.find(dexIndex);
if (LIKELY(it != dexMap.end())) {
auto codeItem = it->second->at(methodIdx);
if (LIKELY(codeItem != nullptr)) {
if (codeOff == 0) return; // 无方法体,跳过
// 目标地址:DEX 内存中该方法指令区(begin + codeOff)
auto *dexCodeItem = (dex::CodeItem *)(begin + codeOff);
auto *realInsnsPtr = (uint8_t *)(dexCodeItem->insns_);
// 加密后的指令数据
const uint8_t *enc = codeItem->getInsns();
uint32_t sz = codeItem->getInsnsSize();
// RC4 密钥 = aes_key(壳配置密钥) + methodIndex(4 字节)
uint8_t rc4_key[sizeof(g_shell_config.aes_key) + sizeof(uint32_t)];
memcpy(rc4_key, g_shell_config.aes_key, sizeof(g_shell_config.aes_key));
uint32_t methodIndex = codeItem->getMethodIdx();
memcpy(rc4_key + sizeof(g_shell_config.aes_key), &methodIndex,
sizeof(methodIndex));
struct rc4_state state;
rc4_init(&state, rc4_key, sizeof(rc4_key));
rc4_crypt(&state, enc, realInsnsPtr, sz); // 解密写回 DEX 内存
}}}
图 3 patchMethod 解密回填 CodeItem 的原理


3.5 数据来源:init_app 读取 CodeItem
回填所需的 CodeItem 从哪里来?答案是 App 启动时从 assets 里读出并解析,构建成以“方法索引”为键的 dexMap,供 patchMethod 查询。
init_app()(dpt.cpp)——读取并解析 CodeItem 数据
DPT_ENCRYPT void init_app(JNIEnv *env, jclass) {
void *package_addr = nullptr; size_t package_size = 0;
load_package(env, &package_addr, &package_size);
if (!g_codeItemFileData.has_value()) {
// 从 assets 读取加密后的 CodeItem 数据(即 OoooooOooo)
auto entry = read_zip_file_entry(package_addr, package_size,
AY_OBFUSCATE(CODE_ITEM_NAME_IN_ZIP));
if (entry.has_value()) g_codeItemFileData = std::move(entry);
}
auto [entry_data, entry_size] = g_codeItemFileData.value();
readCodeItem((uint8_t *) entry_data, entry_size); // 解析成 dexMap
pthread_mutex_lock(&g_write_dexes_mutex);
extractDexesInNeeded(env, package_addr, package_size); // 释放被抽空的 DEX
pthread_mutex_unlock(&g_write_dexes_mutex);
unload_package(package_addr, package_size);
}


3.6 对抗能力:反调试 / 反 Frida / 垃圾类保护
dpt-shell 不只是“藏代码”,还带了一套对抗分析的手段,这也是它难以脱壳的原因。简要列出关键点:
  • 反 Frida:通过扫描进程内存映射里的 "frida-agent" 字符串、遍历线程名找 "frida-thread",检测到 Frida 注入后立即崩溃。
  • 反调试:利用 ptrace(PTRACE_TRACEME) 自追踪,一旦失败(说明已被调试器附加)就崩溃。
  • 进程保护:通过 fork() 创建子进程互相监控、保护,防止被轻易终止。
  • 垃圾类保护:在 patchClass 里检查“垃圾类”是否被恶意调用,若类描述符倒数第二个字符是数字(正常类名不会这样)则视为非法调用触发崩溃。
dpt_crash()——不同架构下的崩溃实现
__arm__extern "C" void dpt_crash() { __asm__ __volatile__("b ."); } // 死循环defined(__aarch64__)extern "C" void dpt_crash() { __asm__ __volatile__("b ."); } // 死循环extern "C" void dpt_crash() { *(int*)0 = 0; } // 触发段错误



04

实战:如何识别并脱掉 dpt-shell



4.1 识别特征
判断一个 APK 是否被 dpt-shell 加固,有几处强特征可查(在真实鉴定中正是靠这些定位到壳的):
  • 入口类 MainActivity 里出现字符串 "dpt-shell seem not working.\nApplication:"。
  • assets 目录同时存在 OoooooOooo 与 dshelldata_001 两个标志性文件。
  • assets/vwwwwwvwww/ 下存在 Native 库 libe01ce7566771bf96.so。
  • AndroidManifest.xml 中 Application 类名被替换为代理类,如 oUqSLEomaDCs.ProxyApplication(不同 APK 名字不同,但都是 *.ProxyApplication 形式)。
图 4 加固后 APK 的关键识别特征


4.2 脱壳思路
既然静态文件里方法体是空的,直接反编译加固后的 DEX 拿不到真实代码,脱壳思路有两条:
  • 内存 dump:在 App 运行时(类已被加载、CodeItem 已回填到内存后),从进程内存中 dump 出完整的 DEX。这是通用做法,但 dpt-shell 的 mmap/write Hook 与反调试会给 dump 制造障碍。
  • 静态还原:不走运行时,而是直接解析 assets/OoooooOooo 的数据结构,把每个 methodIndex 对应的加密指令 解密后原位写回 DEX 的 code_off 位置,再重算 DEX 头 SHA1 签名与 Adler-32 校验和。这需要对壳的数据格式与加密算法有精确理解,也是针对性最强、最干净的做法。
在软件相似性鉴定的场景里,识别出壳之后必须先脱壳、拿到真实 DEX,才能进一步做“非原创文件排除”与“代码相似度比对”——壳只是第一关,而它往往决定了后续分析的可行性与准确性。



05

小结

dpt-shell 的思路可以浓缩成一句话:把方法体抽走加密藏起来,运行时 Hook DefineClass 再逐个还回来。它由一条清晰的调用链串起来:
核心调用链
init_dpt()└─ dpt_hook()   └─ hook_DefineClass() // Dobby Hook ART      └─ DefineClassV22() // 类加载前介入         └─ patchClass() // 解析类数据、遍历方法            └─ patchMethod() // RC4 解密写回 CodeItem               └─ 原始 DefineClass() // 完成真正的类加载
理解这一套机制后,再看它的识别特征(OoooooOooo、ProxyApplication、"dpt-shell seem not working")和脱壳思路(内存 dump / 静态还原),就能形成“识壳 → 脱壳 → 还原真实代码 → 后续分析”的完整逆向链路。



06

结语

dpt-shell 的整套设计可以浓缩成一句话:把方法体抽走加密藏起来,运行时 Hook DefineClass 再逐个还回来。它的精妙之处不在于加密算法本身,而在于“抽空”与“还魂”的时机选择——静态文件里永远是空的,只有类真正被加载的那一刻,代码才在内存中短暂完整地存在。
这给逆向与鉴定从业者带来两点启发:
  • 第一,逆向分析早已不是“跑一个反编译工具”这么简单。读懂壳的工作机制、在正确的时机取数,往往比工具本身更重要。
  • 第二,加固与脱壳本质上是同一枚硬币的两面:理解了壳如何“藏”,自然就知道如何“找”——无论是识别特征(OoooooOooo、ProxyApplication、特征字符串),还是脱壳思路(内存 dump / 静态还原),背后都是同一套机制在起作用。
技术在不断迭代,壳的对抗手段(反调试、反 Frida、垃圾类保护)也会越来越复杂,但“抽取—存储—回填”这条底层逻辑,始终是理解函数级抽取壳这一类加固的锚点。吃透一个 dpt-shell,你收获的不只是一款壳,更是一套面向所有加固壳、可复用的逆向方法论。

奇安信集团旗下有北京、上海、西安三家司法鉴定所:北京网神洞鉴科技有限公司司法鉴定所、上海盘石洞鉴科技有限公司司法鉴定所与陕西西安洞鉴云侦声像资料司法鉴定所。其中,上海所是上海第一家通过 CNAS 认证认可的民营计算机类司法鉴定机构。三所均通过了CMA资质认定,是目前国内少数能够通过自主研发软件进行取证与分析的电子数据司法鉴定机构,具有独立的实验室场所,其中包括:案件受理区、数据恢复区、手机取证区、计算机取证区、屏蔽室、无尘工作间和物证室,并配备多种国内外先进的技术检验及辅助设备。经过多年的时间积累和发展,现拥有一批胜任鉴定工作的专业技术人员,以专业的技能和丰富的经验,来开展声像资料和电子数据司法鉴定工作。开展的鉴定服务范围包括:声像资料鉴定、电子数据司法鉴定、计算机证据固定和获取、手机终端取证与分析、数据恢复、密码破解以及涉及电子设备的民事调查等。

转载声明:本文转载自原发布平台 (作者:奇安信洞鉴), 原文标题《穿透加固壳:Android加固与脱壳的底层逻辑》, 查看原文。 版权归原作者及原发布平台所有,本站仅作收录与展示,未对正文内容作实质性修改; 若涉及侵权请联系本站处理。