AIGC标识 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)

真机路径(概括):

  1. 解锁 Bootloader(会清空数据,务必先备份);
  2. 提取本机固件中的 boot.img;
  3. 用 Magisk App 的"修补 Boot 镜像"功能生成 magisk_patched.img;
  4. fastboot flash boot magisk_patched.img;
  5. 开机后安装 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

  1. 下载 LSPosed 的 Zygisk 版本(注意:不是 Riru 版);
  2. Magisk App → 模块 → 从本地安装,选择下载的 zip;
  3. 重启设备;
  4. 重启后通常会自动弹出 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 第五步:安装并启用算法助手

  1. 安装算法助手 APK;
  2. 在 LSPosed 管理器中启用"算法助手"模块;
  3. 在作用域中勾选目标 App(以及算法助手自身);
  4. 重新打开目标 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()  ← 注意这两行是"放行后的初始化"

关键的认知点:

  1. 这是多层独立检测——Root 检测、模拟器检测、代理检测各管一段。只绕过一层,其他层仍会拦你。
  2. 最后的 h("检测到您使用的是模拟器或者设备已经root...") 是"兜底拒绝"——只要前两个条件不满足,就会落到这里。
  3. 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(推荐)

这是三条方案里最省事的。算法助手里已经内置了常见检测的屏蔽功能,只需:

  1. 打开算法助手 → 找到"屏蔽/绕过"相关功能项;
  2. 勾选与 Root 检测 / 模拟器检测 对应的屏蔽开关;
  3. 在 LSPosed 作用域中确认目标 App 已勾选;
  4. 强制停止并重启目标 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 算法还原的正当用途

  1. 接口自动化测试:构造合法签名参数,做批量接口测试与回归;
  2. 协议分析与兼容:为自研客户端或迁移方案提供协议参考;
  3. 安全审计:检查是否存在客户端硬编码密钥——这是明确的设计缺陷,意味着任何拿到 APK 的人都能解开通信内容;
  4. 漏洞报告:将"密钥硬编码 / 算法强度不足"作为风险项提交给厂商。

八、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 中的注入痕迹)。应对思路:

  1. 在算法助手中启用相应的"隐藏/绕过"功能;
  2. 用 Frida 在启动早期 Hook 检测方法;
  3. 使用 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、令牌均为脱敏占位符。
  • 客户端逻辑修改与服务端接口屏蔽不会带来任何真实权益,其安全价值在于暴露"服务端信任客户端状态"这一设计缺陷,应通过厂商漏洞报送渠道反馈,不得公开传播或用于牟利。

十一、参考链接

工具与教程

资料索引


附:流程与配置速查卡

# —— 验证 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
posted @ 2026-09-23 21:00  xsec  阅读(129)  评论(0)    收藏  举报