本文仅用于安全研究与技术交流,请勿用于未授权分析。
作者 :Android 安全与逆向实验室
首发平台 :掘金技术社区
案例 :国内某主流壁纸 App(月活数百万,360 加固抽取型,13 个 DEX)
核心收获:掌握 360 抽取壳的攻击面判定、内存脱壳踩坑避障、Java 层 Activity 生命周期 Smali 重建、GreenDao 数据库时序错乱 NPE 自愈技巧。
0x00 前言与背景
在 Android 应用安全与逆向工程中,加固厂商的代际演进 一直是攻防对抗的焦点。许多初学者在面对大型商业 App 时,一旦看到 libjiagu.so、StubApp 或者 APKiD 提示「360 加固」,第一反应往往是望而生畏,认为必须去深啃复杂的 Native SO 反混淆与 VMP 虚拟机指令还原。
但实际上,绝大多数商业加固方案在成本与性能之间做了工程妥协 。以本篇分析的「某壁纸 App」为例,其采用的是典型的 360 抽取型加固(Method Extraction Packer)。
本文将从第一性原理出发,带大家还原加固壳剥离后的「空壳」Activity,并手把手用纯 Smali 代码完成生命周期的原生重构与数据库自愈。
0x01 360 抽取型加固的攻击面分析(第一性原理)
1. 抽取型加固的底层机制
360 加固在保护 APK 时,核心策略分为两部分:
- DEX 动态加载 :将原本的业务 DEX(本案例多达 13 个)加密存放在 assets 或 Native 内存中,由
com.stub.StubApp在attachBaseContext阶段解密并注入 ClassLoader。 - 函数抽取(Extraction) :将四大组件的关键生命周期方法(如
SplashActivity.onCreate、MainActivity.onCreate、WpDetailActivity.onCreate)抽空,并在 DEX 中修改为public native void onCreate(Bundle savedInstanceState)。实际逻辑由libjiagu.so在运行时动态绑定 JNI 并解释执行。
2. 加固厂商的天然盲区
加固壳为了保证 App 的渲染性能和内存占用,不可能把所有业务类与工具方法都抽成 Native。
- ❌ 被抽取的 :仅仅是标准生命周期入口(
onCreate、onResume等几行代码)。 - ✅ 完整保留在 DEX 中的 :所有的数据模型(
BaseData、VideoData)、布局 ID、View 查找(findViewById)、子初始化方法(initView、bindAiActions)以及网络工具类。
攻防结论 :我们根本不需要逆向
libjiagu.so。脱壳后只需分析 JADX 明文源码,找出initView等子函数的调用顺序,用标准 Java/Smali 语法重写onCreate即可!
0x02 内存脱壳:frida-dexdump 热附加 vs 冷启动避坑
在进行 DEX Dump 时,常规教程往往推荐:
bash
# ⚠️ 踩坑命令:冷启动脱壳
frida-dexdump -U -f com.xxx.wallpaper -d -o dump_dex
踩坑血泪:为什么冷启动 Dump 出来的 DEX 是残缺的?
在多 DEX(MultiDex)的大型商业 App 中,加固壳通常采用**懒加载(Lazy Load)**机制。冷启动瞬间,除了主 DEX 和部分基础库,其他的 10 几个子 DEX 根本还没有被解密映射到内存中。 冷启动 Dump 会导致导出的部分 DEX 仅有 4KB 的文件头,JADX 打开直接报错 DEX parsing error。
正确姿势:热附加(Attach PID)
让 App 彻底启动,在手机上滑动几屏、进入详情页,确保所有 DEX 全部解密加载完毕后,再进行 PID 附加 Dump:
bash
# 1. 查询当前稳定运行的主进程 PID
adb shell pidof com.xxx.wallpaper
# 2. 针对指定 PID 进行热附加 Dump
frida-dexdump -U -p <PID> -d -o work/wallpaper/dump_dex
Dump 产物经过 dexlib2 校验后,会得到 13 个完整的业务 DEX(大小从几百 KB 到数 MB 不等)。
0x03 核心难点一:还原「空壳」Activity 生命周期
脱壳后的 DEX 如果直接去掉 libjiagu.so 运行,系统在拉起 MainActivity 或 WpDetailActivity 时会立即抛出:
yaml
java.lang.UnsatisfiedLinkError: No implementation found for void com.xxx.wallpaper.ui.detail.WpDetailActivity.onCreate(android.os.Bundle)
我们需要对关键 Activity 的 Smali 代码进行 Java 原生重建。
1. 闪屏页 SplashActivity.smali 极速直跳
原版闪屏页包含开屏广告逻辑和 360 壳的校验。我们直接将其简化为「0 毫秒直达主页」:
smali
.method protected onCreate(Landroid/os/Bundle;)V
.registers 4
.param p1, "savedInstanceState"
invoke-super {p0, p1}, Landroidx/appcompat/app/AppCompatActivity;->onCreate(Landroid/os/Bundle;)V
# 构造 Intent 跳转至 MainActivity
new-instance v0, Landroid/content/Intent;
const-class v1, Lcom/xxx/wallpaper/ui/main/MainActivity;
invoke-direct {v0, p0, v1}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V
invoke-virtual {p0, v0}, Lcom/xxx/wallpaper/SplashActivity;->startActivity(Landroid/content/Intent;)V
# 结束闪屏
invoke-virtual {p0}, Lcom/xxx/wallpaper/SplashActivity;->finish()V
return-void
.end method
2. 详情页 WpDetailActivity.smali 完整生命周期重建
通过 JADX 查看脱壳 DEX,虽然 onCreate 是 native 的,但类内部包含清晰的子函数:initView()、bindAiActions()、y() 等。 从 R.layout.activity_wp_detail 获取十六进制布局 ID 为 0x7f0d047f,重建如下:
smali
.method protected onCreate(Landroid/os/Bundle;)V
.registers 3
.param p1, "savedInstanceState"
# 1. 必须调用 super.onCreate
invoke-super {p0, p1}, Landroidx/appcompat/app/AppCompatActivity;->onCreate(Landroid/os/Bundle;)V
# 2. 绑定真实布局文件 (R.layout.activity_wp_detail)
const v0, 0x7f0d047f
invoke-virtual {p0, v0}, Lcom/xxx/wallpaper/ui/detail/WpDetailActivity;->setContentView(I)V
# 3. 按照 JADX 静态调用拓扑,依次回调所有子初始化函数
invoke-virtual {p0}, Lcom/xxx/wallpaper/ui/detail/WpDetailActivity;->initView()V
invoke-virtual {p0}, Lcom/xxx/wallpaper/ui/detail/WpDetailActivity;->bindAiActions()V
invoke-virtual {p0}, Lcom/xxx/wallpaper/ui/detail/WpDetailActivity;->y()V
return-void
.end method
0x04 核心难点二:脱壳后时序错乱引发的 GreenDao 崩溃自愈
在完成 Activity 重建后,应用成功启动,但进入主页或点击壁纸时,发生了致命 Crash:
csharp
FATAL EXCEPTION: main
java.lang.NullPointerException: Attempt to invoke virtual method 'com.xxx.wallpaper.data.db.DaoSession.getOriginUnlockDao()' on a null object reference
at com.xxx.wallpaper.manager.OriginManager.isUnLocked(OriginManager.java:45)
at com.xxx.wallpaper.ui.detail.controller.RightViewController.initData(RightViewController.java:180)
崩溃根因追踪
在原版加固 APK 中,360 壳代理了 StubApp,并在 Application.attachBaseContext 极早期通过 JNI 强行完成了 GreenDaoManager.getInstance().init(context) 的初始化。 脱壳后,我们将入口直接指向了业务 App 类。当 UI 渲染线程异步拉起 Controller 时,GreenDaoManager.mDaoSession 仍然为 null!
优雅解决方案:在 Smali 单例中注入自愈式懒加载
无需去改动复杂的 App.onCreate 时序,直接对数据库管理器 GreenDaoManager.smali 动外科手术:在每次获取 Session 时增加自愈检测。
smali
.method public getDaoSession()Lcom/xxx/wallpaper/data/db/DaoSession;
.registers 3
# 读取当前 mDaoSession 实例
iget-object v0, p0, Lcom/xxx/wallpaper/data/db/GreenDaoManager;->mDaoSession:Lcom/xxx/wallpaper/data/db/DaoSession;
# 如果不为 null,直接跳转返回
if-nez v0, :cond_ready
# 🌟 自愈逻辑:若为 null,立即同步调用 this.init() 进行数据库自愈初始化
invoke-virtual {p0}, Lcom/xxx/wallpaper/data/db/GreenDaoManager;->init()V
:cond_ready
iget-object v0, p0, Lcom/xxx/wallpaper/data/db/GreenDaoManager;->mDaoSession:Lcom/xxx/wallpaper/data/db/DaoSession;
return-object v0
.end method
改动效果:无论后续业务代码在任何子线程、任何 Activity 阶段调用数据库,只要检测到未初始化就会自动补齐,彻底消灭了脱壳后的时序 NPE 崩溃。
0x05 总结与启示
- 加固不可怕,找准攻击面:对于抽取型壳,核心资产(数据模型与视图逻辑)依然暴露在 Java 层,生命周期还原的本质是「看源码填空」。
- 多 DEX Dump 必用热附加:避免冷启动导致的 DEX 缺失与解析错误。
- 单例防御性编程在逆向中的妙用:对于脱壳引起的时序紊乱,在底层单例 Getter 注入自愈式懒加载,是最稳妥、影响面最小的修复手段。
在下一篇《Android 高级逆向实战(二)》中,我们将深入该壁纸 App 的业务协议层,揭秘其多层防御「洋葱」鉴权模型,以及如何利用「本地信封」特性实现 0 广告、0 阻碍的超清原画动态壁纸秒速下载!