APP攻防4:LSPosed模块与HOOK实战-绕过检测与逻辑验证
安卓逆向进阶:LSPosed 模块与 HOOK 实战——绕过检测、算法解密与逻辑验证
学习自B站小迪安全
反编译、改 Smali、回编译、重签名——这条路能解决大部分问题,但它有两个绕不开的痛点:一是每次试错都要走一遍完整重打包流程,二是一旦 App 有签名校验,改完包直接闪退。另一条路是 HOOK:不碰 APK 文件,而是在 App 运行时把它的方法"劫持"过来,用自己的代码替换掉。改错了瞬间生效、瞬间撤销,不重打包、不受签名校验影响。
一、开始之前:前置知识与工具清单
1.1 前置知识(仅列要点)
- Smali 基础:寄存器
v0..vN/p0..pN、const/4、invoke-*、return、if-*、goto、方法标签(:label)。Hook 与改 Smali 是互补关系,两者都要会。 - Java 基础:类、方法重载(overload)、getter/setter、
private/final/static、反射(Class.forName、getDeclaredMethod、setAccessible)。 - Java 字节码与 ART 运行时基础概念:知道"方法调用"在运行时是可以被替换的,这是 Hook 成立的前提。
- Android Root 与 Magisk 基本概念:Root 是什么、Magisk 如何"无系统修改"地获取 Root。
- Xposed / LSPosed 框架原理:为什么它能 Hook 应用而不改 APK。
- 常见反检测手段:Root 检测、模拟器检测、代理/VPN 检测、调试器检测、签名校验的实现思路。
- APK 结构基础:
classes.dex、AndroidManifest.xml、多 DEX。 - ADB 基础:
connect、shell、install、logcat。 - 常见加密算法特征:MD5 / SHA / AES / DES / RSA / Base64 / HMAC 的输出形态(第七章有对照表)。
- JavaScript 基础:能读懂
function、return、Java.use()这类写法(Frida 脚本需要)。
1.2 工具清单(仅列要点)
| 类别 | 工具 | 用途 |
|---|---|---|
| Root 方案 | Magisk | 当前 Android 社区获取 root 权限的主流开源工具 |
| Hook 框架 | LSPosed(Magisk 模块) | 高版本 Android 上的 Xposed 替代方案,本文核心 |
| Hook 模块 | 算法助手 | LSPosed 模块,集成常见功能 + 自定义 HOOK |
| Hook 框架(备选) | Xposed(原版) | 仅适用于 Android 8 及以下 |
| Hook 引擎(备选) | Frida | 无需 root 环境(部分场景)的通用动态插桩 |
| 设备 | 模拟器或可 root 真机 | 需支持 Magisk |
| 反编译(静态对照) | Jadx-Gui | 读 Java 代码、全局搜索 |
| 反编译/回编译 | apktool | 反编译出 smali、改完回编译 |
| 签名 | keytool / zipalign / apksigner | 重打包后签名与对齐 |
| 抓包 | Burp Suite 等 | 抓取接口、观察参数 |
| 代理转发 | Postern | App 级代理转发,绕过直连式代理检测 |
| 动态调试 | JEB | 断点调试,与 Hook 配合定位 |
| 信息获取 | 开发助手 / 开发者助手 | 查看布局、控件 id、当前 Activity |
二、先建立整体认知:两条技术路线的分工
逆向修改有两条路线。理解它们的边界,比记住任何一个工具都重要。
| 维度 | 路线一:改包(静态) | 路线二:Hook(运行时) |
|---|---|---|
| 手段 | 反编译 → 改 Smali/XML → 回编译 → 重签名 | 不改文件,运行时替换方法实现 |
| 代表工具 | apktool、MT 管理器、NP 管理器 | LSPosed、算法助手、Frida |
| 生效时机 | 安装后永久生效 | 只在运行时生效,进程结束即消失 |
| 试错成本 | 高(每次改都要走完整重打包流程) | 低(改脚本/配置,重启 App 即可) |
| 受签名校验影响 | 会(重打包必然破坏原签名) | 不会(APK 文件未被改动) |
| 受加固影响 | 需先脱壳 | 不需要(Hook 的是内存中解密后的代码) |
| 能否改 Native 逻辑 | 需改 .so,难度高 |
可以(Frida Native Hook) |
| 适合场景 | 需要长期稳定的修改、改界面资源 | 快速验证假设、绕过检测、抓算法、有签名校验的目标 |
2.1 一个实用的判断口诀
想改界面/资源 → 改包(Hook 改不了静态资源)
有签名校验、改包就闪退 → Hook
只想验证一个值是几 → Hook
要长期稳定的效果 → 改包
遇到加固、脱不了壳 → Hook
实战中的最佳组合是两者配合:用 Hook 快速试错、快速定位(几秒钟一次迭代),确认方案后再决定是否落成改包。先 Hook 验证,后改包固化——这是效率最高的顺序。
三、四个必须搞清的前置概念
这四点搞不清,后面的环境搭建会变成纯粹的"照抄步骤、不知道为什么"。
3.1 Magisk
Magisk 是当前 Android 社区用来获取 root 权限的主流开源方案。
它的关键特点是"无系统修改"(systemless):它不直接改动 /system 分区的内容,而是通过挂载的方式在启动时"叠加"你的修改。带来的好处是:
- 可以随时卸载、还原到原状态;
- 不破坏系统完整性(对某些检测更"干净");
- 通过模块(Module)机制扩展功能——LSPosed 就是一个 Magisk 模块。
3.2 LSPosed(LSP 框架)
Xposed 框架只支持 Android 8 及以下,因此在高版本 Android 上应当使用 Magisk + LSPosed。
理解这条演进关系:
Android 8 及以下 → Xposed / EdXposed 可用
Android 8.1+ → LSPosed(基于 Zygisk,作为 Magisk 模块运行)
LSPosed 的工作方式:它在应用进程启动时注入,然后根据你配置的"作用域"(scope),只对自己关心的类做方法替换。因此它:
- 不需要改 APK,不受签名校验影响;
- 不需要脱壳,因为它 Hook 的是运行时加载到内存里的代码;
- 可以按 App 粒度启用/禁用,影响面可控。
3.3 HOOK 技术
HOOK(钩子)技术的本质就是劫持调用,将自身的代码"融入"被钩住(Hook)的程序的进程中。
用一句话概括它的工作机制:
正常调用: 业务代码 ──调用──► 目标方法 ──返回──► 业务代码
Hook 之后: 业务代码 ──调用──► [你的代码] ──??──► 业务代码
│
├── 可以选择:直接返回伪造值(不执行原方法)
├── 可以选择:先执行原方法,打印参数和返回值,再返回原结果
└── 可以选择:改掉参数,再调用原方法
这三种模式覆盖了逆向中的全部需求:
| 模式 | 用途 | 典型场景 |
|---|---|---|
| 拦截并伪造返回值 | 绕过判断 | 让 isDeviceRooted() 恒返回 false |
| 打印后透传 | 观察 | 打印 AES 的密钥与明文,不改行为 |
| 修改参数后调用 | 篡改输入 | 把加密前的明文改掉 |
3.4 算法助手
算法助手是 LSPosed 下的一个模块,集成了常见功能外加自定义 HOOK,方便逆向调试。
它把很多"每次都要自己写脚本"的操作做成了界面配置,典型能力包括:
- 常见检测的屏蔽(Root 检测、模拟器检测、调试检测等,即"自带屏蔽 hook");
- 自定义 HOOK:填类名、方法名、返回类型、返回值,即可完成一次方法替换——不用写一行代码;
- 算法相关辅助:观察常见加密方法的输入输出,辅助定位加密过程。
它的定位:把你从"写脚本"里解放出来,用填表的方式完成 80% 的常规 Hook。复杂逻辑(比如要根据原返回值做判断)仍然需要 Frida 脚本。
四、环境搭建:Magisk + LSPosed + 算法助手
4.1 整体流程
① 设备 Root(Magisk)
↓
② Magisk 中开启 Zygisk
↓
③ 安装 LSPosed 模块(Zygisk 版)
↓
④ 重启设备
↓
⑤ LSPosed 管理器中启用模块 + 配置作用域
↓
⑥ 安装并启用算法助手
↓
⑦ 验证生效
4.2 第一步:获取 Root(Magisk)
真机路径(概括):
- 解锁 Bootloader(会清空数据,务必先备份);
- 提取本机固件中的
boot.img; - 用 Magisk App 的"修补 Boot 镜像"功能生成
magisk_patched.img; fastboot flash boot magisk_patched.img;- 开机后安装 Magisk App,确认 Root 状态。
模拟器路径(更省事,适合学习):
| 模拟器 | 说明 |
|---|---|
| 夜神 / 逍遥 | 设置中直接开启 root;Xposed 可在应用市场下载安装器后激活 |
| 雷电 / MuMu | 支持通过 Magisk 获取 root,或使用带 root 的版本 |
# 验证 root 是否生效
adb shell su -c "id"
# 期望输出包含 uid=0(root)
4.3 第二步:开启 Zygisk
Zygisk 是 Magisk 提供的"注入 Zygote 进程"的能力——LSPosed 依赖它才能在每个应用进程启动时完成注入。
Magisk App → 设置 → 找到 Zygisk 开关 → 打开 → 重启设备
若 Magisk 中没有 Zygisk 选项,说明 Magisk 版本过低(需 24 以上),先升级 Magisk。
4.4 第三步:安装 LSPosed
- 下载 LSPosed 的 Zygisk 版本(注意:不是 Riru 版);
Magisk App → 模块 → 从本地安装,选择下载的 zip;- 重启设备;
- 重启后通常会自动弹出 LSPosed 管理器的安装提示;若未弹出,手动安装管理器 APK。
# 通过 adb 推送并安装(可选)
adb push LSPosed-vX.X.X-zygisk-release.zip /sdcard/
# 然后在 Magisk App 的"模块"页面中从本地安装
4.5 第四步:启用模块与配置作用域
这是最容易漏掉、也最容易失败的一步。
LSPosed 管理器 → 模块 → 启用目标模块(开关打开)
→ 点击模块名 → 勾选"作用域"(要生效的 App)
→ 强制停止目标 App(或重启设备)
作用域(scope)必须勾对。LSPosed 只会对你勾选的 App 生效——不勾,等于没装。这是"我明明启用了模块,为什么没效果"的第一大原因。
修改作用域后,必须强制停止目标 App 使其重新启动进程,配置才会被加载。
4.6 第五步:安装并启用算法助手
- 安装算法助手 APK;
- 在 LSPosed 管理器中启用"算法助手"模块;
- 在作用域中勾选目标 App(以及算法助手自身);
- 重新打开目标 App。
4.7 验证是否生效
最直接的验证方式:让模块帮你 Hook 一个你已知返回值的方法,看日志里有没有打印。
# 观察 LSPosed / 算法助手的日志
adb logcat | grep -i "lsposed\|算法助手"
若日志中能看到模块的加载与 Hook 记录,说明环境就绪。
五、实操案例一:绕过 Root / 模拟器 / 代理检测
目标:App 一启动就提示"检测到您使用的是模拟器或者设备已经 root,不允许继续使用",需要绕过这一整套检测。
5.1 原始检测逻辑
反编译后,会看到这样一个检测方法(包名与类名已做脱敏替换为等价占位形态,语义不变):
private final void K() {
LogUtils.b("AppCheck", "===AppUtils.isAppRoot():" + com.blankj.utilcode.util.c.r()
+ " DeviceUtils.isDeviceRooted() " + w.q());
if (!A() && !m.a(this.activity)) {
if (a((Context) this)) {
h("检测到您使用了代理软件,不允许继续使用");
return;
}
h.a().a(com.example.app.b.a.n, "");
x();
return;
}
h("检测到您使用的是模拟器或者设备已经root,不允许继续使用");
}
5.2 逻辑解读(先读懂,再动手)
这段代码是一条"一票否决"式检测链,可以拆成三层:
第 1 层:日志(只是打印,不影响逻辑)
AppUtils.isAppRoot() → 返回 String,"1" 表示有 root,"0" 表示无
DeviceUtils.isDeviceRooted() → 返回 boolean
第 2 层:环境判定入口
if (!A() && !m.a(this.activity))
第 3 层:三种拒绝路径,各自弹提示
├── 代理检测命中 → h("检测到您使用了代理软件,不允许继续使用") → return
├── 模拟器/root → 走到最后的 h("检测到您使用的是模拟器或者设备已经root...")
└── 正常路径 → h.a().a(...) + x() ← 注意这两行是"放行后的初始化"
关键的认知点:
- 这是多层独立检测——Root 检测、模拟器检测、代理检测各管一段。只绕过一层,其他层仍会拦你。
- 最后的
h("检测到您使用的是模拟器或者设备已经root...")是"兜底拒绝"——只要前两个条件不满足,就会落到这里。 h.a().a(...)与x()是放行后的正常初始化。绕过时如果连这两行也跳过了,可能出现"检测过了但功能不正常"。
5.3 涉及的两个第三方库方法
日志里的 com.blankj.utilcode.util.c.r() 与 com.blankj.utilcode.util.w.q() 来自一个广泛使用的开源 Android 工具库(AndroidUtilCode),经混淆后:
| 混淆后的调用 | 语义 | 返回类型 | 含义 |
|---|---|---|---|
com.blankj.utilcode.util.c.r() |
AppUtils.isAppRoot() |
String |
"1" = 设备有 root,"0" = 无 |
com.blankj.utilcode.util.w.q() |
DeviceUtils.isDeviceRooted() |
boolean |
true = 已 root |
这里有个很有价值的经验:遇到大面积混淆时,认出第三方开源库是突破口。这些库的混淆映射在开源仓库里是公开的,一旦确认"这是某开源库的某个方法",语义立刻清晰,Hook 目标也就确定了。库代码不会因为目标 App 的加固而改变行为。
5.4 三种绕过方案
方案 1:改 Smali,用 goto 直接跳到方法末尾
在 K() 方法的开头插入一条无条件跳转,跳到方法末尾,跳过整段检测:
.method private final K()V
.registers 3
goto :end # ← 新增:直接跳到方法末尾
# ... 原有检测逻辑保持不变 ...
:end # ← 新增标签
return-void
.end method
风险提示:
goto :end会同时跳过后面的h.a().a(...)和x()。如果这两个调用是必要的初始化,App 可能出现异常行为。更稳妥的做法:只跳过"拒绝"的分支,保留"放行"路径。例如把拒绝分支的条件指令取反,让它走不到:
# 原:命中检测则拒绝
if-eqz v0, :cond_reject
# 改:把它改成永不为真,直接落到放行路径
# (v0 是上面比较结果,改成不可能成立的条件即可)
方案 2:Magisk + LSPosed + 算法助手自带的屏蔽 Hook(推荐)
这是三条方案里最省事的。算法助手里已经内置了常见检测的屏蔽功能,只需:
- 打开算法助手 → 找到"屏蔽/绕过"相关功能项;
- 勾选与 Root 检测 / 模拟器检测 对应的屏蔽开关;
- 在 LSPosed 作用域中确认目标 App 已勾选;
- 强制停止并重启目标 App。
为什么优先推荐这条:不改包(不受签名校验影响)、不写代码(配置即生效)、可随时撤销。
方案 3:针对代理检测的处理
代理检测单独占一条代码路径(a((Context) this) 为真则提示"检测到您使用了代理软件")。两种处理方式:
方式 A ——用 Postern 转发流量(不改代码)
Postern 在 App 层接管流量后再转发到 PC 上的代理工具,使 App 检测不到系统代理设置:
Postern 配置:
新建规则 → 匹配目标 App 的包名
动作:Proxy → 指向 <攻击机IP>:8080
(PC 端 Burp 正常监听 8080)
方式 B ——Hook 掉检测方法
用算法助手的自定义 HOOK,或 Frida 脚本把检测结果改掉。
5.5 用 Frida 精确 Hook 这两个库方法
算法助手的自定义 HOOK 适合"改返回值",而需要在日志里看到原始值时,Frida 更合适。
Java.perform(function () {
// ① AppUtils.isAppRoot() → 返回 String("1" 表示有 root)
// 混淆后类名为 com.blankj.utilcode.util.c,方法名 r
var AppUtils = Java.use("com.blankj.utilcode.util.c");
AppUtils.r.implementation = function () {
var orig = this.r();
console.log("[HOOK] isAppRoot() 原始值 = " + orig + " → 强制返回 \"0\"");
return "0";
};
// ② DeviceUtils.isDeviceRooted() → 返回 boolean
// 混淆后类名为 com.blankj.utilcode.util.w,方法名 q
var DeviceUtils = Java.use("com.blankj.utilcode.util.w");
DeviceUtils.q.implementation = function () {
var orig = this.q();
console.log("[HOOK] isDeviceRooted() 原始值 = " + orig + " → 强制返回 false");
return false;
};
});
运行:
frida -U -f <目标包名> -l bypass_detect.js
注意返回类型必须一致:
| 原方法返回类型 | Hook 时应返回 | 常见错误 |
|---|---|---|
String |
"0" |
返回数字 0 → 类型不匹配,抛异常 |
boolean |
false |
返回 "false" 字符串 → 类型不匹配 |
int |
0 |
返回 "0" 字符串 → 类型不匹配 |
方法重载的坑:如果方法有重载版本(同名不同参数),
AppUtils.r这样直接赋值会报错,需要指定签名:AppUtils.r.overload().implementation = function () { ... };
5.6 算法助手中的等价配置(不写代码)
在算法助手的自定义 HOOK 中逐条添加:
| 序号 | 类名 | 方法名 | 返回类型 | 伪造返回值 |
|---|---|---|---|---|
| 1 | com.blankj.utilcode.util.c |
r |
java.lang.String |
0 |
| 2 | com.blankj.utilcode.util.w |
q |
boolean |
false |
这就是"填表式 Hook"的全部内容——四个字段,一次配置,永久生效(只要模块启用)。
六、实操案例二:逻辑验证——让会员判断恒为真
目标:绕过接口校验,让客户端的会员判断始终成立。
6.1 原始逻辑
if (userBean.getIs_vip() == 1) {
String format = String.format("到期時間:%s",
c.d.a.f.i.a(userBean.getExpired_str()));
if (userBean.getVip_level() == 4) {
format = "永久無限觀看";
} else {
this.v.setVisibility(0);
}
}
6.2 逻辑解读
这段逻辑有两个层级,必须同时满足:
| 判断 | 含义 | 通过条件 |
|---|---|---|
getIs_vip() == 1 |
是否是会员 | 必须返回 1 |
getVip_level() == 4 |
会员等级 | 4 对应"永久无限观看"这一最高等级;其他值走 else 分支 |
字段语义推断:
is_vip:1= 有效会员,0= 非会员(典型布尔式整型字段);vip_level:等级枚举,4是"永久"这一特殊档位;expired_str:到期时间字符串,仅在非永久时展示。
注意这个细节:
vip_level == 4时直接覆盖成"永久無限觀看",不再显示到期时间——说明4在设计上就是一个特殊的"永久"标记值,而不是普通等级。改返回值时选4而非随便挑一个数,正是为了命中这条"永久"分支。
6.3 关键障碍:接口校验
在这个案例中,客户端在展示前会调用一个接口做校验:
/api/mv/watching
这条接口的作用:向服务端确认当前用户是否有观影权限。思路就是屏蔽掉这个接口的调用,让客户端的本地判断直接生效。
6.4 三种方案
方案 A:改 Smali,直接改 getter 的返回值
# getIsvip() 修改为恒返回 1
.method public getIsvip()I
.registers 2
const/4 v0, 0x1 # 原本是 iget v0, p0, ...->is_vip:I
return v0
.end method
# getVip_level() 修改为恒返回 4
.method public getVip_level()I
.registers 2
const/4 v0, 0x4 # 4 对应"永久"
return v0
.end method
对应的关键指令(记住这两行就够):
| 目标 | 修改内容 |
|---|---|
getIsvip → 1 |
const/4 v0, 0x1 |
getVip_level → 4 |
const/4 v0, 0x4 |
方案 B:Hook Bean 的 getter(推荐)
UserBean 是数据模型类,它的 getter 被界面层频繁调用——Hook getter 是最"一击命中"的做法。
| 序号 | 类名 | 方法名 | 伪造返回值 | 说明 |
|---|---|---|---|---|
| 1 | com.example.app.bean.UserBean |
getIsvip |
1 |
让"是否会员"恒为真 |
| 2 | com.example.app.bean.UserBean |
getVip_level |
4 |
让等级恒为"永久" |
Frida 实现:
Java.perform(function () {
var UserBean = Java.use("com.example.app.bean.UserBean");
UserBean.getIsvip.implementation = function () {
var orig = this.getIsvip();
console.log("[HOOK] getIsvip() 原始值 = " + orig + " → 强制返回 1");
return 1;
};
UserBean.getVip_level.implementation = function () {
var orig = this.getVip_level();
console.log("[HOOK] getVip_level() 原始值 = " + orig + " → 强制返回 4");
return 4;
};
});
算法助手等价配置:同样是填表——类名、方法名、返回类型 int、返回值 1 / 4。
方案 C:Hook 网络层,改掉接口响应
如果客户端确实依赖 /api/mv/watching 的返回,可以直接在网络层劫持:
Java.perform(function () {
// Hook OkHttp 的响应体构造,匹配目标接口后替换 JSON
// 注意:需根据目标实际使用的网络框架调整 Hook 点
var RealResponseBody = Java.use("okhttp3.ResponseBody");
// ... 具体实现依框架版本而定
});
这一路比较脆弱:依赖具体网络框架(OkHttp / Retrofit / HttpURLConnection)的版本与实现。优先用方案 B,因为 Bean 的 getter 是稳定的业务抽象层。
6.5 效果验证与边界说明
验证方式:
# 观察 Hook 日志,确认 getter 被实际调用(而不是"根本没走到")
adb logcat | grep -i "HOOK"
必须明确的边界:
| 修改内容 | 实际效果 |
|---|---|
| 界面显示为"永久無限觀看" | ✅ 客户端显示层生效 |
| 本地已缓存的资源可播放 | ✅ 本地功能生效 |
| 需要服务端下发的内容 | ❌ 无效——服务端仍按真实身份鉴权 |
屏蔽 /api/mv/watching 调用 |
⚠️ 只是让客户端不再发起这个请求,服务端并未被欺骗 |
核心结论:屏蔽接口调用 ≠ 绕过服务端校验。 如果服务端的鉴权逻辑是完备的,客户端所有的本地修改都只能改变"显示"和"本地放行",拿不到任何真实的服务端权益。这正是该问题的安全价值——它证明了服务端必须独立校验,不能信任客户端传来的任何状态。
七、实操案例三:加密算法解密
核心公式:
算法 + 逆向 = 找解密
环境组合:Magisk + LSPosed + 算法助手。
整体思路:先定位"在哪加密",再拿到"用什么加密、用什么密钥",最后"在本地把算法复现出来"。
7.1 三步走流程
① 定位加密发生的位置
├── 抓包看密文形态(判断大方向)
└── 静态搜索加密 API(找到调用点)
② 拿到算法与密钥
├── 算法助手:观察常见算法的输入输出
└── Frida:Hook 加密 API,打印密钥 / IV / 明文 / 密文
③ 本地复现验证
└── 用标准库算一遍,和抓到的密文比对
7.2 密文形态快速判据
| 算法 | 输出长度特征 | 字符集 | 快速判据 |
|---|---|---|---|
| MD5 | 32 位 | 0-9a-f |
长度恰好 32 的 hex |
| SHA-1 | 40 位 | 0-9a-f |
长度恰好 40 的 hex |
| SHA-256 | 64 位 | 0-9a-f |
长度恰好 64 的 hex |
| Base64 | 4 的倍数 | A-Za-z0-9+/ |
末尾常有 = 或 == |
| AES | 16 字节的倍数 | 二进制 / Base64 | hex 长度是 32 的倍数 |
| DES / 3DES | 8 字节的倍数 | 二进制 / Base64 | hex 长度是 16 的倍数 |
| RSA | 等于密钥长度 | 二进制 / Base64 | 1024 位 → hex 256 字符 |
| HMAC | 与底层摘要一致 | hex / Base64 | 常出现在 sign 字段 |
| 时间戳 | 10 位(秒)/ 13 位(毫秒) | 纯数字 | 纯数字且接近当前时间 |
| UUID / 随机数 | 32/36 位 | hex + - |
每次请求都变 |
7.3 定位加密位置
静态搜索(Jadx-Gui 全局搜索):
加密 API:
MessageDigest / digest / "MD5" / "SHA-256"
Cipher / getInstance / "AES" / "DES" / "RSA"
Base64 / encodeToString / decode
SecretKeySpec / IvParameterSpec / Mac.getInstance
Signature / sign
业务关键字:
sign / signature / password / encrypt / encode
timestamp / nonce / appSecret / key / salt
SecretKeySpec是最有价值的关键字——它命中的位置附近,往往就是硬编码密钥所在。
7.4 用算法助手观察算法
算法助手提供的算法相关能力可以直接观察常见加密方法的调用,包括:
- 方法被调用的参数;
- 方法的返回值;
- 调用堆栈(从而知道"是谁在调用加密")。
操作要点:在算法助手中开启算法监控/日志功能,勾选目标 App 的作用域,触发一次加密行为,然后在日志中查看输出。
7.5 用 Frida Hook 加密 API(完整脚本)
当算法助手不够灵活时(比如需要根据原值做条件判断),用 Frida 精确定位。
// 十六进制转字符串
function bytesToHex(bytes) {
var hex = [];
for (var i = 0; i < bytes.length; i++) {
var b = (bytes[i] & 0xff).toString(16);
if (b.length === 1) b = "0" + b;
hex.push(b);
}
return hex.join("");
}
Java.perform(function () {
// ① 摘要算法:MD5 / SHA-1 / SHA-256
var MessageDigest = Java.use("java.security.MessageDigest");
MessageDigest.digest.overload().implementation = function () {
var algo = this.getAlgorithm();
var result = this.digest();
console.log("[DIGEST] algo=" + algo + " result=" + bytesToHex(result));
return result;
};
// ② 对称密钥:直接拿到 AES / DES 的密钥
var SecretKeySpec = Java.use("javax.crypto.spec.SecretKeySpec");
SecretKeySpec.$init.overload("[B", "java.lang.String").implementation = function (key, algo) {
console.log("[KEY] algo=" + algo + " key(hex)=" + bytesToHex(key));
return this.$init(key, algo);
};
// ③ 初始化向量 IV
var IvParameterSpec = Java.use("javax.crypto.spec.IvParameterSpec");
IvParameterSpec.$init.overload("[B").implementation = function (iv) {
console.log("[IV] iv(hex)=" + bytesToHex(iv));
return this.$init(iv);
};
// ④ 对称加解密入口
var Cipher = Java.use("javax.crypto.Cipher");
Cipher.doFinal.overload("[B").implementation = function (input) {
console.log("[CIPHER] algo=" + this.getAlgorithm() + " IN=" + bytesToHex(input));
var out = this.doFinal(input);
console.log("[CIPHER] OUT=" + bytesToHex(out));
return out;
};
// ⑤ Base64
var Base64 = Java.use("android.util.Base64");
Base64.encodeToString.overload("[B", "int").implementation = function (data, flags) {
var r = this.encodeToString(data, flags);
console.log("[BASE64] IN=" + bytesToHex(data) + " OUT=" + r);
return r;
};
// ⑥ HMAC
var Mac = Java.use("javax.crypto.Mac");
Mac.doFinal.overload("[B").implementation = function (input) {
console.log("[HMAC] algo=" + this.getAlgorithm() + " IN=" + bytesToHex(input));
var out = this.doFinal(input);
console.log("[HMAC] OUT=" + bytesToHex(out));
return out;
};
});
运行:
frida -U -f <目标包名> -l crypto_trace.js
触发一次加密行为后,终端会输出算法名、密钥、IV、明文、密文——"逆算法"变成了"读日志"。
若以上全部没有命中:说明加密发生在 Native 层(
.so)。此时需要转向 Frida Native Hook 或 IDA 静态分析,Hook 目标通常是 OpenSSL 的EVP_EncryptUpdate/EVP_DigestUpdate等函数。这属于下一步的进阶内容。
7.6 本地复现验证
用 Hook 到的密钥与 IV,在本地用标准实现算一遍,与抓包拿到的密文比对:
# AES-128-CBC 验证示例
echo -n "待加密的明文" | openssl enc -aes-128-cbc \
-K <十六进制密钥> -iv <十六进制IV> -base64
# MD5 验证示例
echo -n "拼接后的原始串" | md5sum
| 比对结果 | 结论 | 下一步 |
|---|---|---|
| 完全一致 | 标准算法,参数已完全还原 | 算法还原完成 |
| 不一致 | 存在魔改(自定义 S 盒、异常轮数、额外异或、加盐顺序不同) | 需 Hook 更内层的方法,对比每一步的中间值 |
| Hook 不到 Java 层 API | 加密在 Native 层 | 转向 .so 分析 |
魔改算法的排查思路:把加密过程拆成若干步(如"拼接 → 摘要 → 截取 → 再拼接"),在每一步的边界处 Hook,观察中间值,逐段比对直到找到与标准实现的分歧点。
7.7 算法还原的正当用途
- 接口自动化测试:构造合法签名参数,做批量接口测试与回归;
- 协议分析与兼容:为自研客户端或迁移方案提供协议参考;
- 安全审计:检查是否存在客户端硬编码密钥——这是明确的设计缺陷,意味着任何拿到 APK 的人都能解开通信内容;
- 漏洞报告:将"密钥硬编码 / 算法强度不足"作为风险项提交给厂商。
八、HOOK 技术要点与常见陷阱
8.1 返回类型必须严格匹配
| 原方法返回类型 | 正确返回 | 典型错误 |
|---|---|---|
boolean |
false / true |
返回 "false" 字符串 |
String |
"0" |
返回数字 0 |
int / long |
0 / 1 / 4 |
返回 "1" 字符串 |
void |
不 return | 画蛇添足地 return 值 |
类型不匹配会让方法抛异常,App 行为可能比原来更糟。
8.2 方法重载必须指定 overload()
同名同参数不同的方法在 Java 中很常见。不指定签名时 Frida 会报错:
// 报错:ambiguous
Klass.method.implementation = function () { ... };
// 正确:显式指定
Klass.method.overload().implementation = function () { ... }; // 无参
Klass.method.overload("java.lang.String", "int").implementation = ...; // 指定参数类型
8.3 Hook 要在类加载之后
若在类被加载前就 Java.use(),会失败。解决办法是用 Java.perform() 包裹(本节的脚本都已包裹),或在启动阶段用 --runtime 参数延后。
8.4 作用域与进程重启
- 作用域没勾对 → 完全不生效(第一大原因);
- 改完作用域没重启 App 进程 → 不生效。修改后必须强制停止目标 App,让新进程重新加载模块配置。
8.5 反 Hook 检测
部分 App 会检测 Hook 框架(扫描已知模块类名、检测 Xposed 特征、检测 /proc/self/maps 中的注入痕迹)。应对思路:
- 在算法助手中启用相应的"隐藏/绕过"功能;
- 用 Frida 在启动早期 Hook 检测方法;
- 使用 LSPosed 的"隐藏"能力(如对目标 App 隐藏模块自身)。
8.6 别忽略日志
Hook 脚本的第一件事永远是 console.log 打印原始值。理由很简单:
如果打印不出来,说明方法压根没被调用——这时候你应该去找"真正被调用的那个方法",而不是继续伪造返回值。
"没被调用"和"被调用了但返回值没生效"是两个完全不同的问题,日志是唯一能区分它们的手段。
九、常见问题速查
| 现象 | 原因 | 处理 |
|---|---|---|
| 模块启用了但完全没效果 | LSPosed 作用域未勾选目标 App | 勾选作用域并强制停止 App |
| 改了作用域还是没效果 | App 进程未重启 | 强制停止,重新打开 |
| 提示"检测到模拟器/root" | 只绕过了一层检测 | 逐层处理:Root / 模拟器 / 代理 |
| 提示"检测到代理软件" | 命中代理检测分支 | 用 Postern 转发,或 Hook 检测方法 |
| Hook 后 App 崩溃 | 返回类型不匹配 / 误 Hook 了重载方法 | 核对返回类型,补 overload() |
Frida 报 ambiguous |
方法有重载 | 加 .overload(...) 指定签名 |
| Hook 打印不出日志 | 方法未被调用 | 换 Hook 目标,从上层调用链入手 |
| 会员显示变了但功能不可用 | 只改了显示层,或受限在服务端 | 见 6.5 的边界说明 |
| 加密 API 全部 Hook 不到 | 加密在 Native 层 | 转向 .so 分析 |
| 应用检测到 Hook 框架 | 反 Hook 检测 | 启用模块的隐藏功能,或早期 Hook 检测方法 |
十、安全与合规边界
本文所有技术内容仅适用于获得合法授权的测试场景——你自己的设备、你所在组织书面授权的目标、以及公开的测试靶场。
- Root、安装 Hook 框架、附加到目标进程读取内存,均属侵入性操作;对未授权目标实施可能违反《网络安全法》《数据安全法》及目标平台服务条款。
- 绕过安全检测(Root/模拟器/代理检测)的正当用途是:验证检测机制的有效性、评估其可被绕过的难度、并向厂商提出加固建议。
- Hook 到的密钥、令牌、账号数据属于凭据类敏感信息,必须妥善保管,测试结束后按流程销毁;文中出现的手机号、用户 ID、令牌均为脱敏占位符。
- 客户端逻辑修改与服务端接口屏蔽不会带来任何真实权益,其安全价值在于暴露"服务端信任客户端状态"这一设计缺陷,应通过厂商漏洞报送渠道反馈,不得公开传播或用于牟利。
十一、参考链接
工具与教程
- HOOK 环境安装参考(Magisk & XP & LSP 框架):https://blog.csdn.net/danran550/article/details/132256027
- 脱壳相关资料:https://mp.weixin.qq.com/s/poQPuvaQPadQxRu_WGvy1A
资料索引
- 配套资料(录像、素材、工具包等)下载地址:https://docs.qq.com/doc/DQ3Z6RkNpaUtMcEFr
附:流程与配置速查卡
# —— 验证 Root ——
adb shell su -c "id" # 期望出现 uid=0(root)
# —— 安装 LSPosed 模块(通过 Magisk App 的"从本地安装")
adb push LSPosed-vX.X.X-zygisk-release.zip /sdcard/
# —— 观察模块日志 ——
adb logcat | grep -i "lsposed\|算法助手\|HOOK"
# —— 用 Frida 做等价 Hook(不依赖 LSPosed)——
frida -U -f <目标包名> -l bypass_detect.js
frida -U -f <目标包名> -l crypto_trace.js
# —— Postern 转发(绕过代理检测)——
# Postern → 新建规则 → 匹配目标 App 包名 → Action: Proxy → <攻击机IP>:8080
核心配置速查
绕检测(填表式 Hook)
| 类名 | 方法名 | 返回类型 | 伪造返回值 |
|---|---|---|---|
com.blankj.utilcode.util.c |
r |
java.lang.String |
0 |
com.blankj.utilcode.util.w |
q |
boolean |
false |
逻辑验证(让会员判断恒为真)
| 类名 | 方法名 | 返回类型 | 伪造返回值 |
|---|---|---|---|
com.example.app.bean.UserBean |
getIsvip |
int |
1 |
com.example.app.bean.UserBean |
getVip_level |
int |
4 |
Smali 等价写法
const/4 v0, 0x1 # 让 int / boolean 恒为 1(true)
const/4 v0, 0x4 # 让等级恒为 4(永久)
const/4 v0, 0x0 # 让 int 恒为 0
环境搭建五步
① Magisk 获取 Root
② Magisk 设置 → 打开 Zygisk → 重启
③ 安装 LSPosed(Zygisk 版)→ 重启
④ LSPosed 管理器 → 启用模块 → 勾选作用域 → 强制停止 App
⑤ 安装算法助手 → 启用 → 勾选作用域 → 重启 App

浙公网安备 33010602011771号