Frida Hook Java层还原App签名算法实战
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,amount) | Native层接收的是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类型(单位为分),timestamp是Integer,而order_id是String。如果直接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)是因为buildRawString是static方法(反编译确认过)。如果不是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×tamp=1715823412 [+] AES encrypting: amount=9990&order_id=ORD123456×tamp=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_version:5.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.AES的pad()函数在处理某些边界情况(如明文长度恰好是16的倍数)时,填充字节数不同,导致AES密文不一致。某物App的amount=10000时,明文长度为"amount=10000&order_id=...",刚好16字节,cryptography不填充,而pycryptodome填充16字节0x10,结果完全不同。
经验:逆向复现必须用和原生代码完全相同的库和版本。某物App用的是
javax.crypto.Cipher,其底层是Bouncy Castle,而pycryptodome的pad()函数正是对标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赋值无效。
排查链路:
- 在
Java.perform()开头加console.log("Classes loaded:", Java.enumerateLoadedClassesSync().length),确认类数量; - 手动触发
Java.openClassFile("/data/app/~~xxx/base.apk").load();强制加载; - 改用
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找不到方法,静默跳过。
排查链路:
- 用
frida-trace -U -f com.xxx.app -m "com.xxx.security.*.*"抓所有方法调用; - 看哪个方法在下单时高频触发,且参数是
Map; - 用
Java.use("com.xxx.security.SignHelper").$functions列出所有方法名,人工比对。
场景三:多进程导致Hook丢失
某物App的网络请求在com.xxx.app:network独立进程执行,而主进程的Frida脚本无法跨进程Hook。
排查链路:
adb shell ps | grep xxx查看进程列表;frida -U -f com.xxx.app:network -l hook.js --no-pause单独Hook网络进程;- 或在
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=-1、order_id=../../../etc/passwd),验证服务端校验逻辑是否健壮; - 竞品分析:对比某物App和竞品App的签名规则,发现前者用AES+MD5,后者用RSA+SHA256,进而推断其安全等级和密钥管理策略;
- 故障定位:当线上订单大量失败,抓包看到sign错误,可立即用Python脚本本地重放,确认是客户端时间戳偏差、还是服务端密钥轮换未同步;
- SDK集成:为第三方开发者提供
sign-generatornpm包或Maven依赖,让他们调用你的API时,用同一套逻辑生成签名,降低接入门槛。
6.2 我的个人经验:三个必须守住的底线
- 绝不用于生产环境绕过:脚本只跑在测试机、沙箱环境,所有请求头加
X-Test-Mode: true,服务端日志单独归档,确保可审计; - 签名逻辑随App升级同步更新:我把Frida脚本和Python脚本放在Git仓库,每次App更新,先跑
frida-trace看方法名是否变化,再更新常量,形成CI/CD流水线; - 文档比代码更重要:每份脚本配一份
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是你最锋利的手术刀。
