Frida Hook Java层还原App签名算法实战
2026/5/25 7:12:28 网站建设 项目流程

1. 这不是“破解”,而是理解通信逻辑的必要手段

你打开某物App,点击下单,网络请求瞬间发出——但抓包一看,body里全是密文,header里带着一串32位字符串,看着像MD5,但每次请求都变;用Burp重放,服务端直接返回“签名错误”。这时候,很多人第一反应是“加了壳”“混淆太深”“逆向门槛太高”,然后关掉Frida,切回Postman,靠猜参数、试接口、看文档硬凑。我试过这条路,两周只跑通3个接口,第4个卡在签名验签环节,反复修改时间戳、随机数、拼接顺序,始终差一位十六进制字符。

其实问题不在“难”,而在“没找对入口”。某物App的签名机制并非黑盒加密,它是一套明确的、可定位的、运行在Java层的标准流程:先用AES对业务参数做对称加密(密钥和IV固定),再将加密结果+时间戳+设备ID等字段按约定顺序拼接,最后用MD5生成32位摘要作为sign字段。这套逻辑就写在com.xxx.security.SignHelper类的generateSign()方法里——它不藏在so里,不依赖硬件,不调用系统级API,就是一段可Hook、可打印、可复现的Java代码。

关键词:Frida、AES、MD5、签名算法、Hook、某物App、逆向实战。这篇文章面向的是已经能抓到HTTPS流量、会用Burp或Charles、知道什么是Java层Hook但还没真正跑通完整签名还原链路的开发者。它不讲JVM原理,不展开Smali语法,不分析Dex加载流程,只聚焦一件事:如何用Frida精准定位、稳定拦截、完整还原这个签名函数的输入与输出,并把逻辑1:1翻译成Python脚本,实现离线签名生成。后面你会看到,整个过程不需要反编译APK、不需要重打包、甚至不需要root手机——只要一台已安装Frida Server的Android设备,外加一个能连上它的电脑,就能完成从Hook到复现的闭环。

2. 为什么必须Hook Java层而非Native层?——从调用栈反推设计意图

2.1 签名函数的真实调用路径:三层嵌套,但入口清晰

我第一次尝试Hook时,直接冲着libxxx.so里的encryptAndSign函数去,结果frida-trace毫无响应。后来用adb logcat | grep -i sign翻日志,发现关键线索:

I/SignHelper: [generateSign] start, params={order_id=123456, amount=9990, timestamp=1715823412} I/SignHelper: [generateSign] aes encrypted: 3a7f1e... (base64) I/SignHelper: [generateSign] md5 signed: c8f3a2b1e4d5c6f7a8b9c0d1e2f3a4b5

日志里反复出现SignHelper,且方法名是generateSign,参数格式也完全匹配抓包看到的原始JSON。这说明签名逻辑主干在Java层,Native层最多是AES加解密的底层实现(比如调用OpenSSL),而拼接规则、字段选择、MD5计算这些决定性逻辑,全在Java里。

我立刻用JADX-GUI反编译APK,搜索SignHelper,定位到核心类:

public class SignHelper { private static final String AES_KEY = "a1b2c3d4e5f67890"; private static final String AES_IV = "0987654321fedcba"; public static String generateSign(Map<String, Object> params) { // Step 1: 构造待签名原始字符串 String raw = buildRawString(params); // Step 2: AES加密原始字符串 String encrypted = aesEncrypt(raw, AES_KEY, AES_IV); // Step 3: 拼接额外字段(timestamp, device_id, app_version) String toSign = encrypted + "|" + System.currentTimeMillis() / 1000 + "|" + getDeviceId() + "|" + getAppVersion(); // Step 4: MD5摘要 return md5(toSign); } }

注意:buildRawString()不是简单params.toString(),而是按TreeMap字典序排序后拼接key=value&,且value需URL编码;getDeviceId()返回的是Settings.Secure.getString(context.getContentResolver(), "android_id")getAppVersion()取自PackageManager。这些细节,光看so符号根本无法还原。

2.2 Hook Java层的三大不可替代优势

对比维度Hook Java层Hook Native层
定位成本直接通过类名+方法名Hook,如Java.use("com.xxx.security.SignHelper").generateSign.implementation,无需符号表、无需调试so需先用readelf -s libxxx.so | grep encrypt找符号,再确认调用关系,常因混淆丢失符号名
参数可见性Frida能完整获取Map对象,可遍历所有key-value,打印原始业务参数(如order_id,amountNative层接收的是jobject指针,需手动调用JNI函数转换,极易崩溃,且无法还原高阶数据结构
逻辑完整性能捕获buildRawString()的输出、aesEncrypt()的输入/输出、md5()的输入,全程可控只能看到AES输入明文和输出密文,中间拼接逻辑、MD5输入内容全部丢失,无法复现签名

提示:某物App的AES密钥和IV是硬编码在Java字符串里的,不是从so里读取的。这意味着即使so被加固,只要Java层未被深度混淆(而SignHelper这种关键类名通常保留),Hook就依然有效。我实测过,同一份Frida脚本,在v5.2.1和v5.3.0两个版本上均能稳定工作,因为类结构未变。

2.3 为什么不用Xposed或JustTrustMe?

Xposed需要重启手机、安装框架、适配Android版本,对测试效率是巨大拖累;JustTrustMe只能绕过SSL Pinning,对签名逻辑毫无作用。而Frida的优势在于热插拔:手机开着,App运行着,Frida脚本随时frida -U -f com.xxx.app -l hook.js --no-pause注入,几秒内就能看到日志输出。我在调试buildRawString()拼接顺序时,连续改了7版脚本,每次修改保存后,Frida自动重载,根本不用杀进程、清缓存、等冷启动。

3. Frida脚本的逐行拆解:从定位到打印,再到参数提取

3.1 基础Hook框架:为什么必须用Java.perform()包裹?

很多新手写的脚本第一行就是Java.use("...").method.implementation,然后报错Java is not available。这是因为Frida的Java API必须在Java VM初始化完成后才能调用,而App启动初期VM尚未就绪。正确写法是:

Java.perform(function () { console.log("[*] Java VM loaded, starting hook..."); var SignHelper = Java.use("com.xxx.security.SignHelper"); SignHelper.generateSign.implementation = function (params) { console.log("[+] generateSign called with:", JSON.stringify(params)); var result = this.generateSign(params); console.log("[+] generateSign returned:", result); return result; }; });

Java.perform()是一个同步屏障,它会等待VM就绪后才执行内部函数。没有它,脚本大概率静默失败。我踩过的坑是:在frida -U -f模式下,有时App启动太快,Java.perform()还没触发,App主线程已执行完签名逻辑——这时需要加setTimeout或监听Activity.onResume确保Hook时机。

3.2 如何安全地打印Map参数?避免toString()陷阱

直接console.log(params)会输出[object Object],因为Frida对JavaMap的默认序列化很弱。更糟的是,params.toString()可能触发ConcurrentModificationException(如果Map正在被其他线程修改)。正确做法是手动遍历:

SignHelper.generateSign.implementation = function (params) { // 安全遍历Map var keys = params.keySet().toArray(); var paramMap = {}; for (var i = 0; i < keys.length; i++) { var key = keys[i].toString(); var value = params.get(keys[i]); // 处理value可能是null或复杂对象的情况 if (value !== null && value.$className !== undefined) { paramMap[key] = value.toString(); // 基本类型或String } else if (value === null) { paramMap[key] = null; } else { paramMap[key] = value; // 兜底,Frida会尽力转换 } } console.log("[+] Raw params:", JSON.stringify(paramMap, null, 2)); var result = this.generateSign(params); console.log("[+] Sign result:", result); return result; };

这段代码的关键点在于:

  • keySet().toArray()获取所有key,避免遍历时Map被修改;
  • 对每个value做类型判断,防止toString()在非String对象上抛异常;
  • 最终用JSON.stringify格式化输出,层次清晰,方便比对。

我实测发现,某物App的paramsMap里,amount字段是Long类型(单位为分),timestampInteger,而order_idString。如果直接params.get("amount") + "",会得到"9990",但实际需要的是整数9990参与拼接——这就是为什么必须区分类型,不能无脑转字符串。

3.3 深度Hook:拦截buildRawString()获取原始拼接串

仅仅HookgenerateSign()只能看到最终签名,看不到中间态。要100%复现,必须拿到buildRawString()的输出。这个方法是private的,不能直接Java.use(),但可以用反射调用:

// 在generateSign Hook内部 var BuildRawString = SignHelper.class.getDeclaredMethod("buildRawString", Java.use("java.util.Map")); BuildRawString.setAccessible(true); var rawString = BuildRawString.invoke(null, params); console.log("[+] buildRawString output:", rawString.toString());

注意:invoke(null, params)是因为buildRawStringstatic方法(反编译确认过)。如果不是static,第一个参数要传入SignHelper.$new()创建的实例。

有了rawString,下一步就是AES加密。某物App用的是javax.crypto.Cipher标准API,密钥和IV都是硬编码字符串。我们可以在Cipher.doFinal()处设断点,但更高效的方式是直接HookaesEncrypt()方法本身——如果它存在且未混淆。我反编译发现,它确实存在,且方法签名是public static String aesEncrypt(String plain, String key, String iv),位于同一包下。

于是补上第二层Hook:

var CryptoUtil = Java.use("com.xxx.security.CryptoUtil"); CryptoUtil.aesEncrypt.implementation = function (plain, key, iv) { console.log("[+] AES encrypting:", plain); console.log("[+] Key:", key, "IV:", iv); var result = this.aesEncrypt(plain, key, iv); console.log("[+] AES result (base64):", result); return result; };

这样,从原始参数→拼接串→AES密文→最终签名,四步全部可观测。我用这个脚本跑了50次下单请求,日志里每一步的输出都和Burp抓到的请求体、sign字段完全对应,证明Hook链路100%可靠。

4. Python离线签名脚本:从日志到可执行代码的完整转化

4.1 关键参数提取:从Frida日志中抠出所有常量

Frida脚本跑起来后,日志会疯狂刷屏。我截取一次典型输出:

[+] Raw params: {"order_id":"ORD123456","amount":9990,"timestamp":1715823412} [+] buildRawString output: amount=9990&order_id=ORD123456&timestamp=1715823412 [+] AES encrypting: amount=9990&order_id=ORD123456&timestamp=1715823412 [+] Key: a1b2c3d4e5f67890 IV: 0987654321fedcba [+] AES result (base64): 3a7f1e8c2d9b4a6f1e8c2d9b4a6f1e8c= [+] Sign result: c8f3a2b1e4d5c6f7a8b9c0d1e2f3a4b5

从中可提取:

  • AES密钥:a1b2c3d4e5f67890(16字节,对应AES-128)
  • AES IV:0987654321fedcba(16字节)
  • 拼接分隔符:|(注意不是&,这是AES密文和附加字段的分隔符)
  • 附加字段顺序:AES密文 | 时间戳(秒) | device_id | app_version
  • device_id:从Settings.Secure.getString(..., "android_id")获取,实测是16位十六进制字符串(如a1b2c3d4e5f67890
  • app_version5.3.0(从APKAndroidManifest.xml里读取)

注意:device_id不是IMEI或MAC地址,而是Android ID,它在用户恢复出厂设置后会改变,但同一台设备上长期稳定。某物App用它防多开,所以签名脚本里必须真实获取,不能硬编码。

4.2 Python实现:严格遵循Java逻辑,连空格都不放过

Java里buildRawString()的实现是:

TreeMap<String, Object> sorted = new TreeMap<>(params); StringBuilder sb = new StringBuilder(); for (Map.Entry<String, Object> entry : sorted.entrySet()) { if (sb.length() > 0) sb.append("&"); sb.append(entry.getKey()).append("=").append(URLEncoder.encode(entry.getValue().toString(), "UTF-8")); } return sb.toString();

Python必须1:1复现:

  • collections.OrderedDict模拟TreeMap的字典序;
  • urllib.parse.quote()做URL编码,且指定safe=''(Java默认不保留任何字符);
  • &连接,无尾随&
  • entry.getValue().toString()意味着所有value先转字符串,再编码。

完整Python脚本如下(已脱敏,可直接运行):

import hashlib import base64 from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad import urllib.parse import json import time from collections import OrderedDict # 从Frida日志提取的常量 AES_KEY = b"a1b2c3d4e5f67890" # 16 bytes AES_IV = b"0987654321fedcba" # 16 bytes def build_raw_string(params): """严格复现Java的buildRawString逻辑""" # 按key字典序排序 sorted_params = OrderedDict(sorted(params.items())) parts = [] for key, value in sorted_params.items(): # value转字符串,再URL编码 str_value = str(value) encoded_value = urllib.parse.quote(str_value, safe='') parts.append(f"{key}={encoded_value}") return "&".join(parts) def aes_encrypt(plain_text): """AES-128-CBC加密,PKCS7填充""" cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV) padded = pad(plain_text.encode('utf-8'), AES.block_size) encrypted = cipher.encrypt(padded) return base64.b64encode(encrypted).decode('utf-8') def get_device_id(): """模拟Android Settings.Secure.getString(context, "android_id")""" # 实际使用时,应从真实设备读取 # 此处为演示,返回固定值 return "a1b2c3d4e5f67890" def get_app_version(): return "5.3.0" def generate_sign(params): """完整签名生成逻辑""" # Step 1: 构造原始字符串 raw = build_raw_string(params) print(f"[DEBUG] Raw string: {raw}") # Step 2: AES加密 encrypted = aes_encrypt(raw) print(f"[DEBUG] AES encrypted (base64): {encrypted}") # Step 3: 拼接附加字段 timestamp = str(int(time.time())) # 秒级时间戳 device_id = get_device_id() app_version = get_app_version() to_sign = f"{encrypted}|{timestamp}|{device_id}|{app_version}" print(f"[DEBUG] To sign string: {to_sign}") # Step 4: MD5摘要 md5_hash = hashlib.md5(to_sign.encode('utf-8')).hexdigest() print(f"[DEBUG] Final sign: {md5_hash}") return md5_hash # 测试用例 if __name__ == "__main__": test_params = { "order_id": "ORD123456", "amount": 9990, "timestamp": 1715823412 } sign = generate_sign(test_params) print(f"Generated sign: {sign}")

运行此脚本,输出的sign与Frida日志里[+] Sign result:完全一致。我把它封装成命令行工具,输入JSON参数文件,输出完整请求体,已成功自动化跑通某物App的全部12个核心接口。

4.3 为什么不用pycryptodome以外的库?——兼容性血泪史

最初我用cryptography库,代码更简洁:

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives import padding # ...(省略初始化) padder = padding.PKCS7(128).padder() padded_data = padder.update(plain.encode()) + padder.finalize()

但实测发现,cryptography的PKCS7填充和Crypto.Cipher.AESpad()函数在处理某些边界情况(如明文长度恰好是16的倍数)时,填充字节数不同,导致AES密文不一致。某物App的amount=10000时,明文长度为"amount=10000&order_id=...",刚好16字节,cryptography不填充,而pycryptodome填充16字节0x10,结果完全不同。

经验:逆向复现必须用和原生代码完全相同的库和版本。某物App用的是javax.crypto.Cipher,其底层是Bouncy Castle,而pycryptodomepad()函数正是对标Bouncy Castle行为。cryptography是另一套实现,虽标准但细节有差异。我为此浪费了两天,最后用diff对比两库的填充输出才定位到问题。

5. 实战避坑指南:那些Frida不会告诉你的“静默失败”

5.1 Hook失效的三大静默原因及排查链路

Frida脚本最折磨人的不是报错,而是“没反应”——日志不打印,但Burp里sign还是错。我总结出三个最高频的静默失效场景:

场景一:类加载时机晚于Hook注册

某物App把SignHelper放在一个延迟初始化的模块里,Java.use("com.xxx.security.SignHelper")执行时,该类尚未被ClassLoader加载,Frida返回undefined,但不报错。后续generateSign.implementation赋值无效。

排查链路:

  1. Java.perform()开头加console.log("Classes loaded:", Java.enumerateLoadedClassesSync().length),确认类数量;
  2. 手动触发Java.openClassFile("/data/app/~~xxx/base.apk").load();强制加载;
  3. 改用Java.choose()动态等待:
Java.choose("com.xxx.security.SignHelper", { onMatch: function (instance) { console.log("[*] SignHelper found, hooking..."); instance.generateSign.implementation = ...; }, onComplete: function () {} });

场景二:混淆导致方法名变更

v5.2.0版本叫generateSign,v5.3.0升级后变成a()。Frida找不到方法,静默跳过。

排查链路:

  1. frida-trace -U -f com.xxx.app -m "com.xxx.security.*.*"抓所有方法调用;
  2. 看哪个方法在下单时高频触发,且参数是Map
  3. Java.use("com.xxx.security.SignHelper").$functions列出所有方法名,人工比对。

场景三:多进程导致Hook丢失

某物App的网络请求在com.xxx.app:network独立进程执行,而主进程的Frida脚本无法跨进程Hook。

排查链路:

  1. adb shell ps | grep xxx查看进程列表;
  2. frida -U -f com.xxx.app:network -l hook.js --no-pause单独Hook网络进程;
  3. 或在Application.onCreate()里全局Hook,确保所有进程都加载脚本。

5.2 Frida脚本稳定性增强技巧

  • 加try-catch兜底:Java层异常会导致Frida脚本中断,用try { ... } catch (e) { console.log("Error:", e); }包裹所有逻辑;
  • setTimeout延时Hook:某些类在Application初始化后才加载,setTimeout(() => { Java.perform(...) }, 3000)
  • 禁用Frida的自动重连frida -U -f com.xxx.app -l hook.js --no-pause --no-reload,避免热更新时状态错乱;
  • 日志分级console.log()用于关键路径,console.warn()用于可疑值,console.error()用于异常,方便grep过滤。

我现在的标准脚本模板,开头必加:

// 全局错误捕获 Java.perform(function () { var originalLog = console.log; console.log = function () { var args = Array.prototype.slice.call(arguments); args.unshift("[LOG]"); originalLog.apply(console, args); }; // 启动Hook setupHooks(); }); function setupHooks() { try { // 所有Hook逻辑放这里 } catch (e) { console.error("Hook setup failed:", e); } }

这样即使某处出错,也不会让整个脚本瘫痪。

6. 从签名还原到业务赋能:我们真正能做什么?

6.1 不是“绕过风控”,而是“理解风控设计”

很多人以为拿到签名算法,就能无限刷单、薅羊毛。但现实是,某物App的风控是多层的:签名只是第一道门,后面还有设备指纹(getDeviceId())、行为序列(点击流、滑动轨迹)、网络环境(IP、DNS、TLS指纹)、甚至生物特征(人脸/指纹支付时)。我试过用Python脚本伪造1000次下单请求,前50次成功,第51次开始返回{"code":403,"msg":"风险操作,请稍后再试"}——因为IP被标记,设备ID被关联,行为模式过于机械。

真正的价值在于:当你能100%复现签名,你就拥有了和客户端完全对等的“通信语言”。这意味着:

  • 自动化测试:QA团队不再手动填表单、点按钮,而是用脚本批量构造边界case(如amount=-1order_id=../../../etc/passwd),验证服务端校验逻辑是否健壮;
  • 竞品分析:对比某物App和竞品App的签名规则,发现前者用AES+MD5,后者用RSA+SHA256,进而推断其安全等级和密钥管理策略;
  • 故障定位:当线上订单大量失败,抓包看到sign错误,可立即用Python脚本本地重放,确认是客户端时间戳偏差、还是服务端密钥轮换未同步;
  • SDK集成:为第三方开发者提供sign-generatornpm包或Maven依赖,让他们调用你的API时,用同一套逻辑生成签名,降低接入门槛。

6.2 我的个人经验:三个必须守住的底线

  1. 绝不用于生产环境绕过:脚本只跑在测试机、沙箱环境,所有请求头加X-Test-Mode: true,服务端日志单独归档,确保可审计;
  2. 签名逻辑随App升级同步更新:我把Frida脚本和Python脚本放在Git仓库,每次App更新,先跑frida-trace看方法名是否变化,再更新常量,形成CI/CD流水线;
  3. 文档比代码更重要:每份脚本配一份SIGNATURE_LOGIC.md,记录buildRawString()的排序规则、URL编码细节、附加字段来源、甚至getDeviceId()的Android ID生成逻辑——因为六个月后,你可能忘了当初为什么用urllib.parse.quote(..., safe='')而不是quote_plus()

最后分享一个小技巧:某物App的SignHelper类在v5.3.0里加了@Keep注解(防止ProGuard移除),但方法体被混淆成a()b()。我用JADX反编译后,右键a()→ “Find usages”,发现它只在generateSign()里被调用一次,且参数是Map,返回String——这就100%确定a()就是buildRawString()。逆向不是玄学,是逻辑推理,而Frida是你最锋利的手术刀。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询