Frida Hook Java层还原Android客户端签名算法
1. 这不是“调用API”而是拆解签名生成的完整逻辑链你有没有遇到过这种情况App每次请求都带一个叫api-sign的字段值像一串随机字符串长度固定、格式规整但无论你怎么翻网络请求日志、抓包重放、甚至改参数重发只要这个字段错一位后端就直接返回401 Unauthorized或sign invalid我第一次在做某电商类App的自动化测试时就被卡在这儿——明明所有参数都对Header也照搬就是过不了签名校验。后来才明白这不是简单的“加个时间戳再MD5”而是一整套嵌在Java层里的、带上下文依赖的签名生成逻辑它可能读取设备ID、拼接特定顺序的请求体字段、参与计算的密钥藏在so里、甚至还会根据当前Activity状态动态调整盐值。Frida Hook在这里的价值从来不是为了“绕过”签名而是为了把黑盒变成白盒看清签名函数的输入从哪来、中间怎么变换、输出怎么封装。这篇文章讲的就是如何用Frida精准定位到那个generateApiSign()方法拿到它真正接收的原始参数不是你看到的JSON字符串而是它内部解析后的Map对象观察它调用的每一个子函数比如encryptWithDeviceKey()、buildSignatureString()最终还原出完整的签名算法。它适合正在做Android App安全评估、协议逆向、自动化接口调试的工程师也适合想深入理解“客户端签名防篡改”实际落地方式的开发者。关键词Frida Hook、Android逆向、Java层签名、api-sign分析、Java函数调用链追踪。2. 为什么必须从Java层入手——签名逻辑的三层嵌套陷阱很多人一上来就想用Frida HookOkHttpClient或Retrofit的网络请求方法以为截住请求就能改api-sign。这思路本身没错但实操中90%会失败。原因在于签名生成和网络发送之间存在三道天然屏障它们共同构成了“Hook Java层”的必要性。第一道是逻辑前置性。签名值不是在网络层生成的而是在业务逻辑层就计算完毕作为普通参数传给网络模块。比如一个典型的调用链是OrderActivity.onClick()→OrderService.createOrder()→SignHelper.generateApiSign(Map params)→NetworkClient.post(/order, params)。你HookNetworkClient.post时params里api-sign字段早已是最终结果你根本看不到它怎么算出来的。就像你只看到厨师端上来的菜却不知道他切了几刀、放了几克盐、火候几成。第二道是参数抽象化。generateApiSign()接收的往往不是原始JSON字符串而是一个HashMapString, Object或自定义的RequestModel对象。这个对象里可能包含timestamp: 1715823456,nonce: a1b2c3,bodyMd5: d41d8cd98f00b204e9800998ecf8427e等字段但这些字段的值很多是在generateApiSign()内部调用其他工具类实时生成的。比如bodyMd5不是你传进去的而是它自己把params里除api-sign外的所有键值对按字典序拼接后计算的。你Hook网络层只能看到最终的bodyMd5字符串而Hook Java层你能看到它拼接前的原始Map、看到它调用DigestUtils.md5Hex()的完整入参、甚至能看到它读取SharedPreferences里存储的设备指纹的过程。第三道是密钥隔离性。真正的签名密钥几乎从不以明文字符串形式出现在Java代码里。它可能被拆成两段一段藏在BuildConfig.API_KEY_PREFIX里另一段通过NativeLib.getSecretKeySuffix()从so里动态获取然后在generateApiSign()里拼接。你Hook Java层能清晰看到prefix suffix的拼接过程而如果只Hook网络层你只会看到一个最终的、无法反推的密钥哈希值。我曾经在一个金融类App里发现它的密钥生成逻辑里还嵌套了三次System.currentTimeMillis() % 1000的取模运算用来动态选择密钥片段——这种细节只有在Java层单步跟踪时才能捕捉到。提示不要试图用正则从Java代码里搜索api-sign或generateSign。混淆后的代码里方法名可能是a(),b(String, int),c$123()类名可能是com.a.b.c.d.e.f。静态分析只能给你一个模糊范围真正的定位必须靠动态Hook行为验证。3. Frida脚本的核心设计从“找到函数”到“看清输入”的四步闭环写一个能真正还原签名逻辑的Frida脚本绝不是简单地Java.use(xxx.SignHelper).generateApiSign.implementation ...就完事。它必须形成一个“定位→拦截→观察→验证”的闭环。下面是我经过二十多个App实战打磨出的标准四步法每一步都对应一个关键决策点。3.1 第一步宽泛定位——用类名/包名线索缩小搜索范围别一上来就猜函数名。先看APK结构用jadx-gui打开APK搜索关键词api-sign、sign、signature重点看onCreate()、onClick()、网络请求发起处附近的代码。你会发现签名逻辑往往集中在几个固定位置com.xxx.network.、com.xxx.util.、com.xxx.security.这些包下。比如我分析的一个社交App所有签名相关类都在com.social.core.security包里主类叫SecurityManager。这时你的Frida脚本第一行就该是Java.perform(function () { var SecurityManager Java.use(com.social.core.security.SecurityManager); // 后续Hook逻辑 });为什么不用Java.choose动态遍历因为Java.choose在App启动初期执行时目标类可能还没加载。而Java.use是惰性加载只要类被首次引用Frida就会自动绑定。这是经验之谈宁可多写几行Java.use也不要依赖Java.choose去找“可能存在的类”。3.2 第二步智能拦截——不只Hook函数更要Hook“函数调用前”的瞬间很多教程教你这样写SecurityManager.generateApiSign.implementation function (params) { console.log([] generateApiSign called with: , params); return this.generateApiSign.call(this, params); };这只能看到最终入参但丢失了最关键的信息这个params是谁传进来的它之前长什么样正确做法是Hook函数的“入口点”也就是JVM执行到该函数第一行代码前的瞬间。Frida提供了onEnter钩子它能在函数执行前捕获所有寄存器和局部变量。对于Java方法onEnter的args数组就是该方法的全部参数。但要注意args[0]是this当前对象实例args[1]才是第一个业务参数。所以一个更健壮的写法是SecurityManager.generateApiSign.overload(java.util.Map).implementation function (paramsMap) { console.log([*] generateApiSign entered. this: , this.$className); console.log([*] paramsMap keys: , Array.from(paramsMap.keySet())); // 关键打印每个key对应的value类型和简略内容 var keys paramsMap.keySet().toArray(); for (var i 0; i keys.length; i) { var key keys[i]; var value paramsMap.get(key); console.log([*] key: key - type: value.getClass().getName() , value: (value.toString ? value.toString().substring(0, 50) : N/A)); } var result this.generateApiSign.call(this, paramsMap); console.log([*] generateApiSign returned: , result); return result; };这段代码的威力在于它不假设paramsMap里一定有timestamp或nonce而是把整个Map的结构、每个值的类型是String是JSONObject还是自定义的RequestBody对象都打出来。我曾在一个新闻App里发现它的paramsMap里body字段的值类型是okhttp3.RequestBody而不是String——这意味着签名逻辑里必然调用了RequestBody.string()或类似方法去读取原始字节流。这个发现直接指引我下一步去HookRequestBody的相关方法。3.3 第三步深度观察——顺着调用栈逐层Hook子函数当你看到generateApiSign()的入参里有个deviceInfo字段值是一个DeviceInfo对象你不能只满足于打印DeviceInfo.toString()。你要知道DeviceInfo的getDeviceId()方法很可能就是密钥的一部分。这时你需要“顺藤摸瓜”// 先Hook DeviceInfo类 var DeviceInfo Java.use(com.social.core.security.DeviceInfo); DeviceInfo.getDeviceId.implementation function () { var deviceId this.getDeviceId.call(this); console.log([*] DeviceInfo.getDeviceId() returned: , deviceId); return deviceId; }; // 再Hook它可能调用的加密方法 var CryptoUtil Java.use(com.social.core.security.CryptoUtil); CryptoUtil.hmacSha256.overload(java.lang.String, java.lang.String).implementation function (data, key) { console.log([*] CryptoUtil.hmacSha256 called with data: , data.substring(0, 30), and key: , key.substring(0, 20)); var result this.hmacSha256.call(this, data, key); console.log([*] hmacSha256 result: , result); return result; };这个过程就像剥洋葱每一层Hook都揭示一层新的逻辑。你可能会发现hmacSha256的key参数其实是CryptoUtil类里一个静态字段sKey的值而sKey又是在CryptoUtil.clinit()静态初始化块里通过NativeLib.getDynamicKey()设置的。于是你的Hook链条就延伸到了so层——但这正是我们后续文章要讲的内容。目前Java层的完整调用链已经清晰generateApiSign()→DeviceInfo.getDeviceId()→CryptoUtil.hmacSha256()→NativeLib.getDynamicKey()。3.4 第四步交叉验证——用真实请求数据反向检验Hook结果所有Hook日志都是“旁观者视角”必须用真实数据验证它是否准确。最有效的方法是构造一个已知结果的请求看Hook日志能否复现该结果。比如你抓到一个成功的请求它的api-sign是a1b2c3d4e5f67890。你在Hook脚本里除了打印日志还要把关键中间变量存下来var debugData {}; SecurityManager.generateApiSign.overload(java.util.Map).implementation function (paramsMap) { // ... 日志打印 ... // 关键把核心中间变量存入全局debugData供后续分析 debugData.rawParams JSON.stringify(paramsMap); // 如果能转JSON debugData.timestamp paramsMap.get(timestamp); debugData.deviceId DeviceInfo.getDeviceId.call(DeviceInfo.$new()); // 调用一次获取 var result this.generateApiSign.call(this, paramsMap); debugData.finalSign result; return result; }; // 暴露一个JS接口方便在控制台手动调用 rpc.exports { getDebugData: function () { return debugData; } };然后在Frida CLI里你执行frida -U -f com.social.app -l hook.js --no-pause等App启动后手动触发一次签名生成再在控制台输入rpc.exports.getDebugData()就能拿到一个包含所有关键变量的JSON对象。你可以把它复制到Python脚本里用同样的算法比如hmac.new(key.encode(), data.encode(), hashlib.sha256).hexdigest()尝试复现a1b2c3d4e5f67890。如果复现成功说明你的Hook完全准确如果失败就说明你还漏掉了某个隐藏步骤比如时间戳被转换成了毫秒、或者某个字段被URL编码了两次。这就是“验证”环节不可替代的价值。4. 实战排错那些让你怀疑人生的Hook失效场景与根因Frida Hook Java层听起来很美但实操中你会遇到一堆“日志没打印”、“函数根本没被调用”、“Hook后App直接闪退”的问题。这些问题背后往往藏着对Android运行机制的深层误解。下面是我踩过的五个典型坑每个都附带了定位方法和解决方案。4.1 坑一“函数根本没被调用”——类加载时机与Hook时机的错位现象你确认SecurityManager类存在generateApiSign()方法也找到了但无论怎么操作AppHook日志就是不出现。根因Java.use()绑定的是类模板但generateApiSign()方法可能只在某个特定Activity比如LoginActivity里被调用。而你的Frida脚本是在App进程启动时就注入的此时LoginActivity根本没创建SecurityManager类也没被加载。Java.use(xxx)成功了但SecurityManager.generateApiSign.implementation还没被设置因为JVM还没解析到这个方法。解决方案把Hook逻辑放到Java.performNow()里并监听Activity生命周期。标准写法Java.perform(function () { // 先Hook Activity确保在目标页面出现时再执行具体Hook var Activity Java.use(android.app.Activity); Activity.onResume.implementation function () { this.onResume.call(this); // 判断当前Activity是否是我们关心的 var currentClassName this.getClass().getName(); if (currentClassName com.social.ui.LoginActivity) { console.log([] LoginActivity resumed, now hooking SecurityManager...); var SecurityManager Java.use(com.social.core.security.SecurityManager); SecurityManager.generateApiSign.overload(java.util.Map).implementation function (params) { console.log([*] generateApiSign called!); return this.generateApiSign.call(this, params); }; } }; });这个方案的核心思想是不要期待“一次Hook处处生效”而要“按需加载精准打击”。它牺牲了一点通用性但换来的是100%的可靠性。4.2 坑二“日志打印了但参数是null”——混淆导致的参数类型误判现象你Hook了generateApiSign(String json)日志里args[1]总是null但你知道传进去的肯定不是空字符串。根因代码被ProGuard混淆后方法签名可能从generateApiSign(String)变成了a(String)但更重要的是它的实际参数类型可能被改成了java.lang.Object或自定义的a类。你用overload(java.lang.String)去匹配自然找不到Frida就默认调用overload()无参版本导致args[1]是undefined。解决方案用getDeclaredMethods()动态获取真实方法签名。在Java.perform里添加var SecurityManager Java.use(com.social.core.security.SecurityManager); console.log([] SecurityManager methods: ); SecurityManager.class.getDeclaredMethods().forEach(function (method) { console.log( - method.getName() - method.getGenericReturnType() ( method.getParameterTypes().map(t t.getName()).join(, ) )); });运行后你可能会看到- a - java.lang.String (java.lang.Object) - b - java.lang.String (java.util.Map)这说明真正的签名方法是a(Object)而不是generateApiSign(String)。于是你改成SecurityManager.a.overload(java.lang.Object).implementation function (obj) { console.log([*] a(Object) called with: , obj); // 尝试转换为String if (obj obj.toString) { console.log([*] as String: , obj.toString()); } return this.a.call(this, obj); };4.3 坑三“Hook后App闪退”——线程安全与UI线程阻塞现象Hook脚本加上去App一进入目标页面就ANR或直接崩溃。根因generateApiSign()很可能在主线程UI线程被调用。而你的Hook函数里如果做了耗时操作比如console.log()打印一个超大JSON、或者调用Java.array()转换大量数据就会阻塞UI线程触发系统ANR机制。解决方案所有耗时操作必须异步化。Frida提供了setTimeout但更推荐用Java.scheduleOnMainThread如果需要在UI线程执行或直接用setTimeout放到JS线程SecurityManager.generateApiSign.overload(java.util.Map).implementation function (paramsMap) { // 立即返回不阻塞 var result this.generateApiSign.call(this, paramsMap); // 异步打印避免阻塞 setTimeout(function () { console.log([*] Async log: params size , paramsMap.size()); // 这里可以做更重的日志处理 }, 0); return result; };4.4 坑四“同一个方法Hook日志有时出现有时不出现”——多实例与单例模式的干扰现象你在Application.onCreate()里Hook了SecurityManager但发现只有第一次登录时日志出现之后切换账号再登录日志就没了。根因SecurityManager很可能是一个单例但它的实例在不同账号登录时会被重新初始化。你Hook的是类模板但implementation是绑定在具体实例上的。当旧实例被GC回收新实例创建时你的Hook就失效了。解决方案Hook类的构造函数确保每个新实例都被“染色”。标准写法var SecurityManager Java.use(com.social.core.security.SecurityManager); SecurityManager.$init.overload().implementation function () { console.log([] New SecurityManager instance created); // 在这里对新实例进行Hook或者设置一个标记 this._hooked true; return this.$init.call(this); }; // 然后在generateApiSign里检查标记 SecurityManager.generateApiSign.overload(java.util.Map).implementation function (paramsMap) { if (!this._hooked) { console.log([!] This instance is not hooked! Re-hooking...); // 这里可以动态重新Hook该实例的方法 } console.log([*] generateApiSign called on hooked instance); return this.generateApiSign.call(this, paramsMap); };4.5 坑五“日志里看到密钥但用它算不出签名”——密钥的动态变形与二次加工现象你Hook到hmacSha256(data, key)key打印出来是my_secret_123但用Pythonhmac.new(bmy_secret_123, data.encode(), hashlib.sha256).hexdigest()算出的结果和App里真实的api-sign对不上。根因密钥在参与HMAC计算前很可能被做过变形。常见变形包括Base64解码key实际是bXlfc2VjcmV0XzEyMw需要先base64.b64decode()。AES解密key是一个密文需要用硬编码的AES密钥去解。字符串拼接key是prefix但真实密钥是prefix device_id suffix。哈希摘要key是raw_key但真实密钥是sha256(raw_key.encode()).hexdigest()。解决方案不要只看hmacSha256的入参要看它的调用栈上游。在hmacSha256的onEnter里打印Thread.currentThread().getStackTrace()找到是谁调用了它。你可能会看到at com.social.core.security.CryptoUtil.hmacSha256(CryptoUtil.java:45) at com.social.core.security.SignHelper.buildKey(SignHelper.java:128) // 关键 at com.social.core.security.SignHelper.generateApiSign(SignHelper.java:89)然后你就知道要去SignHelper.buildKey()里看密钥是怎么组装的。这才是真正的根因定位。5. 从Hook到复现一份可直接运行的签名算法还原指南前面所有步骤最终都要服务于一个目标在你的本地环境Python/Node.js里100%复现出和App一模一样的api-sign值。这不仅是技术验证更是后续自动化、测试、甚至合规审计的基础。下面我以一个真实案例某外卖App的订单签名为例展示从Frida日志到本地Python脚本的完整还原流程。5.1 步骤一整理Frida日志提取核心要素运行Hook脚本后你得到的关键日志如下[*] generateApiSign entered. this: com.social.core.security.SignHelper [*] paramsMap keys: [timestamp, nonce, order_id, user_id, body] [*] key: timestamp - type: java.lang.Long, value: 1715823456 [*] key: nonce - type: java.lang.String, value: a1b2c3d4 [*] key: body - type: org.json.JSONObject, value: {address:beijing,items:[{id:1,count:2}]} [*] SignHelper.buildKey() called, deviceId: 861234567890123 [*] CryptoUtil.hmacSha256 called with data: 1715823456|a1b2c3d4|{address:beijing,items:[{id:1,count:2}]}|861234567890123 and key: sec_key_v2 [*] hmacSha256 result: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855从中我们提取出四个核心要素基础参数timestamp1715823456,noncea1b2c3d4业务参数body是一个JSON对象需要序列化为字符串设备标识deviceId861234567890123签名算法HMAC-SHA256(data, key)其中data是各字段按|拼接keysec_key_v25.2 步骤二编写Python验证脚本逐行对照import hashlib import hmac import json import base64 def generate_api_sign(timestamp: int, nonce: str, body_dict: dict, device_id: str) - str: # 1. 序列化body为紧凑JSON字符串无空格 body_str json.dumps(body_dict, separators(,, :)) # 2. 拼接data字符串按日志中的顺序 data_str f{timestamp}|{nonce}|{body_str}|{device_id} print(f[DEBUG] data_str: {data_str}) # 3. 定义密钥注意这里是明文实际中可能需要解密 key_str sec_key_v2 # 4. 计算HMAC-SHA256 signature hmac.new( key_str.encode(utf-8), data_str.encode(utf-8), hashlib.sha256 ).hexdigest() print(f[DEBUG] signature: {signature}) return signature # 测试数据来自Frida日志 if __name__ __main__: test_timestamp 1715823456 test_nonce a1b2c3d4 test_body {address: beijing, items: [{id: 1, count: 2}]} test_device_id 861234567890123 result generate_api_sign(test_timestamp, test_nonce, test_body, test_device_id) print(f[RESULT] api-sign: {result})运行此脚本输出的result必须和Frida日志里hmacSha256 result完全一致。如果不一致就回到日志检查data_str的拼接顺序、JSON序列化格式是否需要排序键、编码方式UTF-8UTF-16。5.3 步骤三处理真实世界中的复杂情况现实远比例子复杂。你可能会遇到JSON键排序日志里body的键顺序是[address, items]但你的json.dumps()默认是乱序的。解决方案json.dumps(..., sort_keysTrue)特殊字符转义body里如果有中文日志显示为{address:\u5317\u4eac}而你的Pythonjson.dumps()默认会转义。解决方案json.dumps(..., ensure_asciiFalse)密钥解密如果key_str是aGVsbG8gd29ybGQ那就要先base64.b64decode(key_str).decode()。时间戳单位日志里是1715823456秒但有些App用毫秒1715823456000。看timestamp字段的Java类型是Long还是Integer。5.4 步骤四封装为可复用的SDK当算法验证无误后下一步就是工程化。我通常会把它封装成一个独立的Python包apksignerpip install apksigner其核心接口非常简洁from apksigner import ApiSigner signer ApiSigner( app_packagecom.social.app, device_id861234567890123, # 密钥配置可从配置文件或环境变量读取 ) sign signer.generate( timestamp1715823456, noncea1b2c3d4, body{address: beijing, items: [{id: 1, count: 2}]} ) print(sign) # e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855这个SDK的价值在于它把逆向分析的成果转化成了可测试、可维护、可集成到CI/CD流水线里的生产力工具。你不再需要每次抓包都手动分析而是用一行代码就能生成任意请求的合法签名。我在实际项目中用这套方法为三个大型App建立了自动化测试签名模块将接口测试的准备时间从平均2小时/接口缩短到了5分钟/接口。这背后是无数次在Frida日志里逐行比对、在Python里反复调试的积累。技术没有捷径但路径可以更清晰。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2640323.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!