跳转到内容
123xiao | 无名键客

《安卓逆向实战:基于 Frida 与 JADX 的加固 App 登录参数生成链路分析与 Hook 绕过》

字数: 0 阅读时长: 1 分钟

背景与问题

做 Android 逆向时,登录接口几乎总是“第一现场”:

  • 请求参数里有 signtokentimestampnonce
  • 明文账号密码并不直接上传
  • Java 层看起来逻辑简单,但关键实现藏在 so、反射、壳代码或动态注册方法里
  • 抓包能看到请求,却很难知道参数到底怎么拼出来的

这篇文章我换一个更贴近实战的角度:不从“找算法”入手,而是从“找链路”入手
核心目标不是直接爆破某个签名函数,而是回答下面几个问题:

  1. 登录按钮点击后,参数生成链路经过了哪些类和方法?
  2. 哪一层完成了摘要、加密、拼接和设备信息注入?
  3. App 有加固、反调试、证书检测时,Frida 应该 Hook 哪些点,才能稳定拿到明文入参与最终签名?
  4. 如果 Java 层什么都看不出来,如何判断是否已经下沉到 Native?

注意:本文内容仅用于安全研究、协议分析、企业自测与授权测试场景。不要用于未授权的目标。


前置知识与环境准备

本文默认你已经有这些基础:

  • 会安装 Android 模拟器或真机调试环境
  • 知道如何使用 adb
  • 了解基础抓包流程
  • 知道 Frida / JADX 是什么

建议环境:

  • Android 8 ~ 13 任一测试机
  • jadx-gui
  • frida-tools
  • objection(可选)
  • adb
  • 抓包工具(Charles、mitmproxy、Burp 均可)

安装示例:

pip install frida-tools
adb devices
frida-ps -U

如果要在真机上跑,记得准备匹配架构的 frida-server


分析目标与总体思路

在加固 App 里,单靠 JADX 反编译通常只能看到“表层”:

  • 壳 Application
  • 一堆混淆类名
  • 关键逻辑被抽走
  • native 方法没有实现体
  • 通过反射或代理调用真实代码

所以实战上更稳的路径通常是:

  1. 抓包定位登录请求
  2. 从请求字段反推 Java 层调用入口
  3. JADX 找按钮、Presenter、Repository、OkHttp 封装
  4. Frida Hook 请求构造点、加密函数、Native 入口
  5. 必要时绕过 SSL Pinning / Root / Debug 检测
  6. 比对 Hook 输出,画出完整参数生成链路

可以把它理解成:静态分析负责缩小范围,动态 Hook 负责拿真值。


核心原理

1. 登录参数不是“一个函数算出来的”,而是一条链路

很多中级同学一上来就搜索 md5sha1aes,其实经常会走偏。
真实 App 的登录参数更像这样生成:

flowchart TD
    A[点击登录按钮] --> B[ViewModel/Presenter 收集账号密码]
    B --> C[请求对象 RequestBody 构建]
    C --> D[注入设备信息/版本号/时间戳]
    D --> E[字段排序/拼接 canonical string]
    E --> F[摘要或 HMAC]
    F --> G[必要时再做 AES/RSA]
    G --> H[OkHttp/Retrofit 发起请求]

也就是说:

  • sign 可能只对部分字段做签名
  • password 可能先做一次本地摘要
  • token 可能来自先前设备注册接口
  • 真正关键的不是算法名,而是输入集合与顺序

2. 加固 App 常见障碍

常见阻碍主要有四类:

壳与代码延迟加载

你在 jadx 里看到的入口 Application 未必是真实业务 Application。
很多壳会在运行时解密 dex,再通过自定义 ClassLoader 加载。

混淆与反射

方法名变成 a() / b() / c(),但数据流仍在。
这时要抓“调用位置”和“参数形态”,而不是执着于类名。

Native 下沉

Java 层只有:

public static native String sign(String input);

真正实现藏在 so 里,甚至是动态注册,JADX 根本看不到。

运行时对抗

包括:

  • Frida 检测
  • ptrace / 调试检测
  • root 检测
  • 证书锁定
  • 代理检测

所以静态分析只是起点,动态对抗能力决定你能不能走到最后。


第一步:从抓包反推参数结构

先抓登录包,重点看这几类字段:

  • 固定字段:appVersionplatformchannel
  • 时变字段:timestampnonce
  • 敏感字段:passwordsign
  • 关联字段:deviceIdoaidtoken

一个很常见的登录请求体大概长这样:

{
  "username": "test",
  "password": "8f14e45fceea167a5a36dedd4bea2543",
  "timestamp": "1711111111",
  "nonce": "f2ab91c3",
  "deviceId": "aabbccdd",
  "sign": "A6D9C2E7..."
}

这时不要急着猜 sign 是怎么来的,先做三件事:

  1. 改一次用户名,看哪些字段变化
  2. 固定用户名改密码,看哪些字段变化
  3. 多发几次同样输入,看 nonce/timestamp/sign 是否同步变化

这样可以快速判断:

  • password 是否已本地摘要
  • sign 是否覆盖账号密码
  • 是否存在随机盐或设备绑定

第二步:JADX 静态定位调用入口

1. 先找登录按钮触发链路

jadx-gui 里优先搜:

  • 登录页文案:登录 / login
  • 接口路径片段:/login
  • 字段名:passwordsigntimestamp
  • Retrofit 注解:@POST

如果 App 混淆严重,先从布局、资源和字符串入手通常更快。

典型链路如下:

sequenceDiagram
    participant UI as LoginActivity
    participant P as LoginPresenter
    participant R as AuthRepository
    participant S as SignUtil
    participant N as NativeLib
    participant O as OkHttp

    UI->>P: onLoginClick(user, pwd)
    P->>R: login(user, hashPwd)
    R->>S: buildSign(params)
    S->>N: nativeSign(canonical)
    N-->>S: sign
    S-->>R: signedParams
    R->>O: POST /api/login

2. 优先盯住这些关键点

请求模型类

例如:

class LoginRequest {
    String username;
    String password;
    String timestamp;
    String nonce;
    String sign;
}

请求构造器

例如:

Map<String, String> map = new HashMap<>();
map.put("username", user);
map.put("password", pwd);
map.put("timestamp", ts);
map.put("nonce", nonce);
map.put("sign", SignUtil.a(map));

通用签名工具类

即使类名混淆,常见特征仍然明显:

  • 遍历 Map
  • Collections.sort
  • StringBuilder.append
  • MessageDigest
  • Mac.getInstance("HmacSHA256")
  • native 方法调用

3. 遇到“看得见调用,看不见实现”怎么办

比如看到:

public class x {
    public static native String a(String s);
}

这就说明关键逻辑可能在 so。此时要记录:

  • Java 调用类名和方法签名
  • 入参类型
  • 调用前后数据形态
  • so 文件名加载位置

后面 Frida 就可以从这些点切。


第三步:Frida 动态 Hook 还原参数生成链路

下面进入重点。
我的经验是:别一上来 Hook 全世界,先 Hook 关键节点,确认链路,再逐步下钻。

1. Hook Java 层登录入口

先拿到原始用户名密码输入。

Java.perform(function () {
    var LoginPresenter = Java.use("com.demo.auth.LoginPresenter");

    LoginPresenter.login.overload("java.lang.String", "java.lang.String").implementation = function (u, p) {
        console.log("[LoginPresenter.login] user=" + u + ", pwd=" + p);
        return this.login(u, p);
    };
});

运行:

frida -U -f com.demo.app -l hook_login.js

如果类找不到,不一定是你写错了,可能是:

  • 壳尚未释放真实 dex
  • 类被延迟加载
  • 包名版本不一致

这时先用 Java.enumerateLoadedClasses() 或延迟 Hook。

2. Hook 摘要与签名函数

如果静态分析里看到是 Java 层 MessageDigest,直接 Hook:

Java.perform(function () {
    var MessageDigest = Java.use("java.security.MessageDigest");
    var StringCls = Java.use("java.lang.String");

    MessageDigest.digest.overload("[B").implementation = function (input) {
        var algo = this.getAlgorithm();
        var inStr = "";
        try {
            inStr = StringCls.$new(input);
        } catch (e) {
            inStr = "<binary>";
        }

        var out = this.digest(input);
        var outHex = "";
        for (var i = 0; i < out.length; i++) {
            var v = out[i];
            if (v < 0) v += 256;
            outHex += ("0" + v.toString(16)).slice(-2);
        }

        console.log("[MessageDigest] algo=" + algo + " input=" + inStr + " output=" + outHex);
        return out;
    };
});

这个 Hook 很实用,因为它不依赖业务类名。
哪怕业务层全混淆,只要最后走了 MD5/SHA,你就能看到输入。

3. Hook Map 拼接与 sign 生成点

如果你已经在 JADX 中定位到签名工具类,比如 com.demo.sec.SignUtil

Java.perform(function () {
    var SignUtil = Java.use("com.demo.sec.SignUtil");

    SignUtil.buildSign.overload("java.util.Map").implementation = function (map) {
        console.log("====== buildSign begin ======");
        var entrySet = map.entrySet();
        var iterator = entrySet.iterator();
        while (iterator.hasNext()) {
            var entry = iterator.next();
            console.log(entry.getKey() + " = " + entry.getValue());
        }

        var ret = this.buildSign(map);
        console.log("[buildSign result] " + ret);
        console.log("====== buildSign end ======");
        return ret;
    };
});

这一步价值极高,因为你能直接拿到:

  • 签名前参与的字段集合
  • 各字段的最终值
  • 最终生成出来的 sign

很多时候只靠这一层,你就已经可以把算法复现到 Python 了。

4. Hook OkHttp 最终请求体

如果前面都定位不到,就从网络发包出口兜底。

Java.perform(function () {
    var RequestBuilder = Java.use("okhttp3.Request$Builder");

    RequestBuilder.build.implementation = function () {
        var req = this.build();
        try {
            console.log("[HTTP] " + req.method() + " " + req.url().toString());
            var headers = req.headers().toString();
            console.log("[Headers]\n" + headers);
        } catch (e) {
            console.log("[HTTP] parse error: " + e);
        }
        return req;
    };
});

更进一步可以 Hook RequestBody.writeTo(),但不同版本 OkHttp 适配略有差异,建议根据目标版本调整。


第四步:Java 层无果时,Hook Native 签名函数

很多加固 App 的 sign 最终会下沉到 so。
这时要先确认两件事:

  1. so 是否已加载
  2. native 方法是静态注册还是动态注册

1. 监听 so 加载

setImmediate(function () {
    var dlopen = Module.findExportByName(null, "android_dlopen_ext");
    if (dlopen) {
        Interceptor.attach(dlopen, {
            onEnter: function (args) {
                this.path = args[0].readCString();
            },
            onLeave: function (retval) {
                if (this.path && this.path.indexOf(".so") !== -1) {
                    console.log("[dlopen] " + this.path);
                }
            }
        });
    }
});

2. Hook JNI 动态注册

很多 so 不导出 Java_com_xxx_xxx 符号,而是通过 RegisterNatives 动态注册。

setImmediate(function () {
    var addr = Module.findExportByName("libart.so", "_ZN3art3JNI15RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi");
    if (!addr) return;

    Interceptor.attach(addr, {
        onEnter: function (args) {
            var methods = args[2];
            var count = args[3].toInt32();
            console.log("[RegisterNatives] count=" + count);

            for (var i = 0; i < count; i++) {
                var base = methods.add(i * Process.pointerSize * 3);
                var namePtr = base.readPointer();
                var sigPtr = base.add(Process.pointerSize).readPointer();
                var fnPtr = base.add(Process.pointerSize * 2).readPointer();

                var name = namePtr.readCString();
                var sig = sigPtr.readCString();
                console.log("  name=" + name + " sig=" + sig + " fn=" + fnPtr);
            }
        }
    });
});

一旦看到类似:

  • sign
  • encode
  • doCommandNative
  • getToken

这类名字,就可以继续定点 Hook 对应函数地址。

3. Hook Native 导出函数

假设你已经拿到函数地址:

var base = Module.findBaseAddress("libsign.so");
var target = base.add(0x1234);

Interceptor.attach(target, {
    onEnter: function (args) {
        console.log("[native sign] called");
        this.arg0 = args[0];
        this.arg1 = args[1];
    },
    onLeave: function (retval) {
        console.log("[native sign] ret=" + retval);
    }
});

如果入参是 jstring,就不能直接 readCString(),要回到 Java 环境转换,或者 Hook Java 声明层通常更省事。


第五步:Hook 绕过常见对抗

这部分往往决定你脚本是否“能稳定活着”。

1. SSL Pinning 绕过

很多登录接口即使抓到请求,也会因为证书锁定看不到明文。
常见绕过点:

  • okhttp3.CertificatePinner
  • 自定义 X509TrustManager
  • HostnameVerifier

示例:

Java.perform(function () {
    try {
        var CertificatePinner = Java.use("okhttp3.CertificatePinner");
        CertificatePinner.check.overload("java.lang.String", "java.util.List").implementation = function (a, b) {
            console.log("[Bypass] CertificatePinner.check: " + a);
            return;
        };
    } catch (e) {
        console.log("CertificatePinner hook failed: " + e);
    }
});

2. Root 检测绕过

很多 App 会查这些东西:

  • su 文件
  • test-keys
  • which su
  • getprop ro.debuggable

示例 Hook:

Java.perform(function () {
    var File = Java.use("java.io.File");
    File.exists.implementation = function () {
        var path = this.getAbsolutePath();
        if (path.indexOf("/su") !== -1 || path.indexOf("magisk") !== -1) {
            console.log("[Bypass] File.exists -> false : " + path);
            return false;
        }
        return this.exists();
    };
});

3. Debug / Frida 检测绕过

常见检查:

  • android.os.Debug.isDebuggerConnected()
  • 遍历进程端口
  • 搜索 frida 字符串
  • 检测 /proc/self/maps

最基础的先把 Java 层调试检测掐掉:

Java.perform(function () {
    var Debug = Java.use("android.os.Debug");
    Debug.isDebuggerConnected.implementation = function () {
        console.log("[Bypass] isDebuggerConnected -> false");
        return false;
    };
});

逐步验证清单

建议你按下面顺序验证,不要一次堆太多 Hook:

阶段 1:链路存在性确认

  • App 能正常启动
  • 能进入登录页
  • 抓包能看到登录接口
  • 登录按钮触发方法可被 Hook

阶段 2:参数流确认

  • Hook 到用户名密码原始输入
  • Hook 到密码摘要前或摘要后值
  • Hook 到 Map 或请求体构造
  • 拿到最终 sign

阶段 3:下沉确认

  • Java 层找不到完整算法
  • 发现 native 方法声明
  • 监听到目标 so 加载
  • RegisterNatives 中看到疑似签名函数

阶段 4:对抗绕过

  • SSL Pinning 绕过后抓包恢复
  • Root 检测不再影响流程
  • Frida 附加后 App 不闪退
  • 重启多次脚本仍稳定

一套更完整的可运行 Frida 脚本

下面给一份更偏实战的组合脚本,覆盖:

  • 登录入口
  • 摘要算法
  • 签名工具
  • SSL Pinning 绕过

你需要把类名改成你目标 App 的实际值。

function bytesToHex(bytes) {
    var hex = "";
    for (var i = 0; i < bytes.length; i++) {
        var v = bytes[i];
        if (v < 0) v += 256;
        hex += ("0" + v.toString(16)).slice(-2);
    }
    return hex;
}

Java.perform(function () {
    var StringCls = Java.use("java.lang.String");

    // 1. Hook 登录入口
    try {
        var LoginPresenter = Java.use("com.demo.auth.LoginPresenter");
        LoginPresenter.login.overload("java.lang.String", "java.lang.String").implementation = function (u, p) {
            console.log("\n[+] LoginPresenter.login");
            console.log("    username = " + u);
            console.log("    password = " + p);
            return this.login(u, p);
        };
    } catch (e) {
        console.log("[-] LoginPresenter hook failed: " + e);
    }

    // 2. Hook MessageDigest
    try {
        var MessageDigest = Java.use("java.security.MessageDigest");
        MessageDigest.digest.overload("[B").implementation = function (input) {
            var algo = this.getAlgorithm();
            var inputStr = "<binary>";
            try {
                inputStr = StringCls.$new(input);
            } catch (e) {}

            var out = this.digest(input);
            console.log("\n[+] MessageDigest.digest");
            console.log("    algo   = " + algo);
            console.log("    input  = " + inputStr);
            console.log("    output = " + bytesToHex(out));
            return out;
        };
    } catch (e) {
        console.log("[-] MessageDigest hook failed: " + e);
    }

    // 3. Hook 签名工具类
    try {
        var SignUtil = Java.use("com.demo.sec.SignUtil");
        SignUtil.buildSign.overload("java.util.Map").implementation = function (map) {
            console.log("\n[+] SignUtil.buildSign");
            var iterator = map.entrySet().iterator();
            while (iterator.hasNext()) {
                var entry = iterator.next();
                console.log("    " + entry.getKey() + " = " + entry.getValue());
            }

            var ret = this.buildSign(map);
            console.log("    sign = " + ret);
            return ret;
        };
    } catch (e) {
        console.log("[-] SignUtil hook failed: " + e);
    }

    // 4. SSL Pinning 绕过
    try {
        var CertificatePinner = Java.use("okhttp3.CertificatePinner");
        CertificatePinner.check.overload("java.lang.String", "java.util.List").implementation = function (host, list) {
            console.log("\n[+] Bypass CertificatePinner for " + host);
            return;
        };
    } catch (e) {
        console.log("[-] CertificatePinner hook failed: " + e);
    }

    console.log("[*] Hooks installed");
});

启动方式:

frida -U -f com.demo.app -l all_in_one.js --no-pause

参数生成链路的复原方法

当你拿到 Hook 输出后,建议按下面方式整理,不然信息很快会乱。

示例整理模板

1. 用户输入
   username = test
   password = 123456

2. 密码预处理
   md5(password) = e10adc3949ba59abbe56e057f20f883e

3. 请求字段构造
   username = test
   password = e10adc3949ba59abbe56e057f20f883e
   timestamp = 1711111111
   nonce = abcd1234
   deviceId = 7f9a...

4. 签名原文
   deviceId=7f9a...&nonce=abcd1234&password=e10...&timestamp=1711111111&username=test

5. 签名结果
   sign = 8C71D0...

6. 最终请求
   POST /api/login

把这六步串起来,你就得到了一条完整链路。
这比只知道“它用了 MD5”有价值得多。


常见坑与排查

1. 类名明明在 JADX 里,Frida 却 Hook 不到

原因通常有:

  • 类还没加载
  • 壳未释放
  • 多 dex / 动态 dex
  • 类名被你抄错了内部类符号

排查建议:

Java.perform(function () {
    Java.enumerateLoadedClasses({
        onMatch: function (name) {
            if (name.indexOf("demo") !== -1) {
                console.log(name);
            }
        },
        onComplete: function () {}
    });
});

必要时改成 spawn 模式并延迟执行 Hook。

2. Hook 了方法却没有输出

通常是重载签名不对。
例如同名方法有多个重载,参数类型必须精确匹配。

先打印重载:

Java.perform(function () {
    var Cls = Java.use("com.demo.sec.SignUtil");
    console.log(Cls.buildSign.overloads);
});

3. App 一附加 Frida 就闪退

大概率是 Frida 检测或反调试。
处理顺序建议是:

  1. spawn 再注入
  2. 只保留最小 Hook
  3. Hook isDebuggerConnected
  4. 再补 /proc、端口、字符串检测绕过

4. 抓包还是看不到明文

不要只怀疑 SSL Pinning,也可能是:

  • App 自己二次加密请求体
  • 使用 protobuf
  • gzip / deflate 压缩
  • 请求体在 Native 层构造

这时就去 Hook:

  • okio.Buffer
  • RequestBody.writeTo
  • 业务序列化方法
  • Native 入口

5. MessageDigest Hook 输出乱码

很正常,因为输入不一定是文本。
处理办法:

  • 同时打印十六进制
  • 不要强行转字符串
  • 关注调用栈和前后文

安全/性能最佳实践

这部分很容易被忽略,但在真实项目里非常重要。

1. 只 Hook 关键路径,避免全局扫射

全局 Hook HashMap.putStringBuilder.append 这种方法虽然“信息很多”,但也非常容易:

  • 造成日志爆炸
  • 拖慢 App
  • 引发卡顿甚至 ANR
  • 淹没真正有价值的调用

更好的做法是:

  • 先静态定位业务类
  • 再小范围定点 Hook
  • 最后才考虑底层兜底

2. 日志输出要结构化

建议统一格式:

[模块][方法][时间] 关键信息

例如:

[sign][buildSign][12:01:03] username=test

这样你后面对照抓包时间线会轻松很多。

3. 优先在测试机、隔离网络中进行

原因很现实:

  • 防止误触正式账号风控
  • 便于抓包和证书安装
  • 可自由 root、改 hosts、重启服务
  • 更容易复现问题

4. 遵守授权边界

即使技术上能分析,也不代表可以随便分析。
请确保是:

  • 自研 App
  • 企业授权测试目标
  • 合法课程实验环境
  • 明确许可的研究对象

5. Native Hook 注意稳定性

Native 层 Hook 一旦地址算错,App 直接崩。
建议:

  • 先确认 so 基址
  • 注意 32/64 位差异
  • 结合 RegisterNatives 确认目标函数
  • 一次只挂一个点

一个常用的分析决策图

当你不知道该从哪下手时,可以按这张图走:

flowchart TD
    A[抓到登录请求] --> B{JADX 能否定位登录入口?}
    B -- 能 --> C[Hook Presenter/Repository]
    B -- 不能 --> D[Hook OkHttp 请求出口]
    C --> E{能否看到 sign 构造?}
    E -- 能 --> F[记录字段与顺序并复现]
    E -- 不能 --> G[查 native 方法/反射调用]
    D --> G
    G --> H[监听 so 加载与 RegisterNatives]
    H --> I{存在对抗?}
    I -- 是 --> J[绕过 SSL/Root/Debug 检测]
    I -- 否 --> K[定点 Hook Native 函数]
    J --> K
    K --> L[还原完整参数链路]

总结

这类“加固 App 登录参数分析”最容易犯的错,就是把注意力过早集中在某个算法上。
但实战里真正决定成败的是:你有没有把整条参数生成链路走通。

建议你记住这套顺序:

  1. 抓包看字段变化
  2. JADX 找登录入口和请求构造
  3. Frida Hook Java 层输入、摘要、签名工具
  4. 必要时下钻 Native 与 RegisterNatives
  5. 同步处理 SSL Pinning、Root、Debug 对抗
  6. 把链路整理成“输入 → 处理 → 拼接 → 签名 → 发包”

如果你已经是中级读者,我给一个很实用的建议:
以后碰到新目标,不要先问“它是 MD5 还是 AES”,而要先问:

  • 参数在哪一层定型?
  • 参与签名的字段有哪些?
  • 字段顺序是否固定?
  • 哪一步开始下沉到 Native?
  • 对抗是阻止抓包,还是阻止 Hook?

当你能稳定回答这些问题,登录参数分析基本就进入可控状态了。


分享到:

上一篇
《Web3 中级实战:从零搭建基于智能合约的钱包登录与链上身份认证系统-436》
下一篇
《大模型应用上线实战:从 Prompt 设计、RAG 检索到效果评测的完整落地指南》