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_id123456, amount9990, timestamp1715823412} 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(MapString, 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字典序排序后拼接keyvalue且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或JustTrustMeXposed需要重启手机、安装框架、适配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)位于同一包下。于是补上第二层Hookvar 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: amount9990order_idORD123456timestamp1715823412 [] AES encrypting: amount9990order_idORD123456timestamp1715823412 [] Key: a1b2c3d4e5f67890 IV: 0987654321fedcba [] AES result (base64): 3a7f1e8c2d9b4a6f1e8c2d9b4a6f1e8c [] Sign result: c8f3a2b1e4d5c6f7a8b9c0d1e2f3a4b5从中可提取AES密钥a1b2c3d4e5f6789016字节对应AES-128AES IV0987654321fedcba16字节拼接分隔符|注意不是这是AES密文和附加字段的分隔符附加字段顺序AES密文 | 时间戳(秒) | device_id | app_versiondevice_id从Settings.Secure.getString(..., android_id)获取实测是16位十六进制字符串如a1b2c3d4e5f67890app_version5.3.0从APKAndroidManifest.xml里读取注意device_id不是IMEI或MAC地址而是Android ID它在用户恢复出厂设置后会改变但同一台设备上长期稳定。某物App用它防多开所以签名脚本里必须真实获取不能硬编码。4.2 Python实现严格遵循Java逻辑连空格都不放过Java里buildRawString()的实现是TreeMapString, Object sorted new TreeMap(params); StringBuilder sb new StringBuilder(); for (Map.EntryString, 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编码且指定safeJava默认不保留任何字符连接无尾随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 ba1b2c3d4e5f67890 # 16 bytes AES_IV b0987654321fedcba # 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(fGenerated 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的amount10000时明文长度为amount10000order_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版本叫generateSignv5.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的签名规则发现前者用AESMD5后者用RSASHA256进而推断其安全等级和密钥管理策略故障定位当线上订单大量失败抓包看到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是你最锋利的手术刀。