ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

App签名参数逆向实战:从抓包到Frida Hook再到RPC封装

App签名参数逆向实战:从抓包到Frida Hook再到RPC封装 做爬虫或者接口自动化的兄弟多多少少都会撞上签名参数。以小红书为例你翻接口请求列表时会看到x-s、x-t、x-s-common这几个常客而在部分端上还会冒出一个x-mini-signature。这玩意儿每次请求都在变你要是直接忽略后端大概率回你一个k456之类的错误码。本文不打算把整加密库从头到脚扒一遍只把针对这类请求签名的完整分析链路走一遍从抓包定位参数到 jadx 静态找线索再到 Frida 动态把函数拖出来看个清楚最后用 RPC 的方式把签名函数变成外部可调用的接口让业务脚本和 App 内部的加密逻辑彻底打通。这套流程适用于大多数“请求参数里带动态签名”的 App 逆向场景不局限于某一家。文章里所有代码都是实操过的写法你可以直接抄作业但建议还是先理解每一步在干什么毕竟真实环境里函数名、包名都会变方法才是最有价值的。1. 从一次抓包发现 x-mini-signature 开始1.1 环境配置Android Charles Frida 一套到底先把基本环境准备好我的惯用组合是一台 Android 9 或 10 的真机模拟器也能用但有些 App 有模拟器检测真机省事Charles 4.x 配合手机代理专门看 HTTPS 明文流量Frida 14/15/16 都行关键在于frida-server要和电脑端的frida版本完全一致Python 3.8装好frida-tools和frida模块这里重点说几个容易踩的坑。第一手机必须能正常连代理并且 Charles 上要装好 CA 证书。现在的 App 普遍做了证书校验你光装证书可能不够很可能需要配合一个类似“JustTrustMe”之类的 Xposed 模块或者用 Frida 主动绕过校验证书。不过证书这块不是本文重点我们就假设已经能看到明文请求了。第二frida-server的架构必须对齐。常见 Android 设备的 CPU 一般是 arm64-v8a少部分老设备是 armeabi-v7a。你可以用adb shell getprop ro.product.cpu.abi确认一下再下载对应架构的frida-server推到手机里adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server 电脑端执行frida-ps -U如果能看到设备上跑的进程列表说明连接畅通。万一提示unable to connect先检查frida-server和电脑端frida版本是否一致这是最常见的问题。1.2 观察请求签名参数和明文参数的对应关系Charles 里过滤掉无关域名找到某个业务接口。比如搜索接口/api/sns/v1/search/notes查看请求 body通常里面带着业务参数keyword、page、page_size之外还有一个x-mini-signature。先别急着找算法我习惯先做几个小实验不管什么请求x-mini-signature都会出现吗相同参数连续请求两次签名值一样吗验证是否有随机因子改动某一个业务参数比如page从 1 改成 2签名变不变把签名参数删掉再发一次请求后端返回什么这些实验能快速告诉我们签名和哪些请求数据耦合以及签名是否依赖时间戳或随机数。比如你换个参数它就变说明签名大概率的计算范围包含请求参数如果同样的请求参数发两次签名还不同那大概率混入了时间戳或者一次性 nonce。把这些点记录下来后面分析时会省很多力气。我见过不少人一上来就反编译找函数结果搞了半天不知道在看哪个方法因为根本没确认这个签名对应的具体请求上下文。2. 静态分析定位签名入口2.1 jadx 打开安装包顺着字符串找线索把 APK 拖进 jadx-gui等反编译完成之后直接全局搜索x-mini-signature。这一步基本能定位到代码里的使用点。你可能会看到类似这样的 Java 代码public final class ApiRequest { public static void addSign(MapString, String params) { params.put(x-mini-signature, SignProvider.generateSign(params)); } }有时候签名参数名会被拆开拼接比如x-mini- signature所以搜索时可以换个关键词比如搜x-mini、signature、甚至直接搜generateSign。如果一个 App 做了代码混淆这类字符串多数还在因为保险起见服务端要按固定 key 取参数key 名基本不会混淆。找不到的话再考虑通过抓包得到的签名值关联搜索值字符串例如在 APK 里搜你抓到的某个签名片段但那通常没用因为签名是动态生成的。接着要看SignProvider.generateSign到底做了什么。用 jadx 点击进入这个类可能它是一个纯 Java 实现也可能它最终调用了 native 方法。常见写法是public class SignProvider { static { System.loadLibrary(secsign); } public static native String generateSign(Map params); }如果看到native关键字恭喜加密核心逻辑就在 so 库里。这种情况 Java 层只是传参和取结果。2.2 从可疑类到 hook 点的确定静态分析最核心的产出其实是回答一个问题算签名的函数入口到底在哪不管能不能在 Java 层直接看完整个算法我们都应该先确定一个“可 hook 的 Java 方法”。上面例子里SignProvider.generateSign(Map params)就很适合作为突破口。但如果代码是混淆过的方法名可能变成a.b.c.d()这种东西。这时怎么确认它就是算签名的函数我的做法是在 jadx 里选中候选方法右键Find Usage看谁调用它、把它的返回值放到哪个参数里。如果调用者把结果put到一个 Map并且 key 是x-mini-signature那就基本断定了。另外还有一个技巧搜索与设备信息相关的关键词比如device_id、oaid、timestamp看看哪个函数的入参包含这些字段同时返回String。签名函数通常长这样public static String a(String path, MapString, String header, MapString, String params, String deviceId) { ... }返回的字符串通常是一段十六进制或 base64 乱码。遇到这种就直接把它作为 Hook 目标。3. Frida 动态 Hook把加密函数当场扒干净3.1 让目标函数停下来Hook Java 方法的正确姿势静态分析拿到类名和方法名接下来就是 Frida 上场。写一个最基础的 Frida JS 脚本对目标方法进行 hook打印入参和返回值即可Java.perform(function () { var SignProvider Java.use(com.example.xhs.SignProvider); SignProvider.generateSign.implementation function (params) { var result this.generateSign(params); console.log(params params.toString()); console.log(result result); return result; }; });然后用命令行启动frida -U -f com.example.xhs -l hook.js --no-pause-f是冷启动目标 App--no-pause表示不要停在入口直接跑起来。这是为了不错过启动早期可能发生的签名调用。为什么用-f而不用attach因为签名可能会在 App 刚启动时就被调用比如初始化上报接口。如果你先打开 App 半天再 attach 上去那个调用早就过去了自然 hook 不到。所以做启动阶段的分析一定要从spawn模式开始。跑起来之后去 App 里手动触发一次搜索请求你会在控制台看到类似下面的输出params {keyword美食, page1, page_size20} result 5f6a2c8d91d23e3f9b8c7a1d4e5f6a70到这里至少能确认这个函数确实被调用了并且返回值就是请求里的x-mini-signature。3.2 从 Java 到 Nativeso 层函数追踪现实里SignProvider.generateSign经常是native方法。你直接按上面的方式 hook也能拿到 Java 层的入参和返回值。但如果想知道 native 内部算了什么就得看 so 库。先通过 Java hook 打印出 Java 层调用的native方法所在 lib可以这样Java.perform(function () { var SignProvider Java.use(com.example.xhs.SignProvider); SignProvider.generateSign.implementation function (params) { var result this.generateSign(params); var libs Process.enumerateModules(); libs.forEach(function (lib) { // 通常把带security/sign/crypto关键字的模块打出来 if (lib.name.toLowerCase().indexOf(sign) ! -1 || lib.name.toLowerCase().indexOf(sec) ! -1) { console.log(module: lib.name base lib.base); } }); return result; }; });拿到模块名之后可以用Module.findExportByName直接找导出函数。但很多 so 会把敏感函数隐藏起来不一定有导出符号。这时候更通用的做法是 HookRegisterNatives把 Java native 方法和底层函数地址映射关系打印出来var RegisterNatives Module.findExportByName(null, RegisterNatives); Interceptor.attach(RegisterNatives, { onEnter: function (args) { var env args[0]; var clazz args[1]; var methods args[3]; // 这里需要解析 JNINativeMethod 数组根据 methodCount 打印每个成员 }, onLeave: function (retval) {} });这段代码解释起来篇幅不小而且不同 Android 版本的 JNIEnv 结构差异不大你可以拿标准的JNINativeMethod结构体去解析。一旦拿到 native 函数地址就可以继续Interceptor.attach到那个地址去读入参和返回值。不过真正实战中不一定非要扒到 so 内部不可。因为你想调通签名接口Java 层 hook 到返回值就够了RPC 层也是调 Java 方法。分析 so 内部的目的是为了彻底复刻算法但如果我们接受“动态调用原函数”这种方案是不需要完全还原算法细节的。这也是后面走 RPC 路线的核心逻辑。3.3 用 hook 结果反推签名规则虽然不用完整逆向 so但为了让心里有数我一般会从现有输出反推一下签名构成。比如签名结果是 32 位十六进制像是 MD5入参里包含业务字段和时间戳再次改变时间戳签名立刻变化那基本能猜出签名算法大致是MD5(bizParams timestamp secret)之类的结构。至于 secret 藏在哪、拼接顺序什么样可以再 Hook 常见哈希函数做交叉验证。比如 hooklibc.so里常见的MD5_Update、EVP_DigestUpdate等函数看它处理的原始字符串是什么。不过我一般不会在这步过度纠结。因为签名算法一旦更新你之前还原的规则可能全废。与其费劲逆向不如直接把调用原函数做成 RPC 服务这样就算算法内部换了只要方法入口不变上层业务就完全不受影响。4. RPC 调用落地让 Python 直接调 App 里的签名函数4.1 frida-rpc 基础框架RPC 是 Frida 自带的能力核心就是在 JS 脚本里暴露rpc.exports让外部通过 Python 或 Nodejs 调用 JS 中定义的函数。先写一个rpc_sign.jsrpc.exports { appsign: function (jsonStr) { return Java.performNow(function () { var SignProvider Java.use(com.example.xhs.SignProvider); var params Java.use(java.util.HashMap).$new(); var jsonObject JSON.parse(jsonStr); for (var key in jsonObject) { params.put(key, String(jsonObject[key])); } return SignProvider.generateSign(params); }); } };这里用Java.performNow而不是Java.perform原因是performNow可以在当前调用结束时同步返回结果Python 端用exports_sync调用时更方便。注意rpc.exports必须写在脚本的顶层不能包在Java.perform里面。否则 Frida 加载后无法正确注册 RPC 入口。4.2 写一个简单的签名服务客户端Python 端代码很简单import frida import sys import json device frida.get_usb_device() session device.attach(com.example.xhs) script session.create_script(open(rpc_sign.js, encodingutf-8).read()) script.load() params { keyword: 美食, page: 1, page_size: 20, timestamp: 1730000000 } result script.exports_sync.appsign(json.dumps(params)) print(result)如果你想把这套能力做成一个独立服务可以再用 Flask 或 FastAPI 包一层 HTTP 接口from flask import Flask, request, jsonify import frida, json app Flask(__name__) session None script None def init_frida(): global session, script device frida.get_usb_device() session device.attach(com.example.xhs) script session.create_script(open(rpc_sign.js, encodingutf-8).read()) script.load() app.route(/sign, methods[POST]) def sign(): data request.get_json() if data is None: return jsonify({code: 400, message: empty body}) sig script.exports_sync.appsign(json.dumps(data)) return jsonify({code: 0, data: sig}) if __name__ __main__: init_frida() app.run(host127.0.0.1, port5000)这里一定要绑定127.0.0.1不要图省事监听0.0.0.0。因为签名服务等于把 App 内部的算法能力裸奔到网络里一旦端口暴露在局域网就会被别人滥用。4.3 稳定性和并发问题实战处理实际跑签名服务最大的问题不是签名逻辑而是稳定性。第一个坑App 进程崩溃或被系统回收。此时 Python 端的 session 会失效再调用时直接异常。解决办法是加一个重连机制每次调用前检查 session 是否还活着断开就重新attach。def get_script(): global session, script try: script.exports_sync.ping() # 在 rpc 里加一个 ping 函数 except Exception: try: session.detach() except Exception: pass init_frida() return script第二个坑并发调用。Frida 的 RPC 不是并行的同一个 session 即使你同时发多个请求它也是串行执行。如果业务端高并发调你的 HTTP 签名服务会有一部分请求排队甚至超时。我建议在 HTTP 层做一个单进程队列或者直接限制并发数避免同时涌进大量调用把手机端拖死。第三个坑参数格式。签名函数往往需要特定类型的入参比如 Long、Integer、List。JSON 转出来默认都是字符串某些 App 的签名函数会校验类型导致结果崩溃。这种时候可以在 JS 侧根据方法签名做类型转换或者在 Python 端精确构造类型后再 JSON 序列化。经验之谈能传字符串就传字符串大多数签名函数内部会自行处理。第四个坑Android 系统省电策略。手机息屏一段时间后CPU 会降频甚至网络断开Frida 连接依然在但响应极慢。可以考虑用adb shell svc power stayon true保持屏幕常亮或者用充电器持续供电。5. 这个过程中最容易被坑的几个点5.1 签名参数动态变化可能是设备指纹参与有时候你会发现同一个请求参数在不同手机上的签名值完全不一样。这说明签名算法里混入了设备相关因子比如deviceId、oaid、installId。这类因子通常在 App 启动时从服务端拉取或者本地生成后存储。处理这种情况一个技巧是同时 Hook 签名函数入参里没出现的那些静态信息。你可以打印出整个进程里和device相关的字符串或者通过Java.enumerateLoadedClasses搜索Device/Oaid/IdProvider这样的类找到最终参与签名的 ID把它的取值也暴露到 RPC 返回结果里。另一种情况签名值每次都不同但算法固定那是时间戳参与。你只需要保证调用签名时传入的timestamp和最终发包时用的timestamp一致即可否则服务端校验时间窗口就会导致请求失败。我在 RPC 层会同时返回签名和时间戳避免业务端自行拼时间导致不一致。5.2 hook 不到函数怎么办排查 frida attach 时机这是新手最容易卡住的地方。脚本明明写了日志一点输出都没有原因有很大概率是目标类还没加载。这时可以试试 new 一个对象触发类加载或者在Java.perform后加一个延迟轮询等类出现再 hook。App 启动早期调用发生在注入之前。需要用frida -f冷启动并且不要带--pause或者带--pause后在脚本里完成 hook 再resume。方法被内联或隐藏。极个别情况下签名方法直接写在 native 层且没有经过 Java 层调用这时 Java hook 当然看不见。那就得从 so 入手或者用frida-trace -U -f com.example.xhs -i sign跟踪所有包含sign的导出函数。我在实战中更常用一种“漏斗法”先 hook 所有可能含sign字符串的 Java 方法再慢慢收窄。也可以先不加过滤把Java.enumerateLoadedClasses里所有类名打印出来搜sign、security、crypto这些关键词然后逐个试。5.3 合规红线与稳定性取舍逆向分析这种技术本身是中性的。做安全研究、接口测试、个人自动化都是正当用途。但我要说句实实在在的话不要拿着签名接口去批量抓取平台用户数据更不要做撞库、刷接口这类事。一来是法律风险二来平台风控也不是摆设高并发签名请求很快会被识别导致账号封禁、IP 封禁。另外文章里的所有代码都是方法论演示真实 App 的签名函数名、参数结构、Native 库名肯定会不一样。你要做的是掌握这套“定位- Hook- 抽象- RPC”的流程而不是把我这个示例套到所有 App 上。我在实际项目里最深的体会是**与其追求还原每一个加密算法不如把 RPC 服务层做稳。**因为算法会变函数入口却相对稳定。只要你把“调用原函数”的能力沉淀成了服务未来某天 App 更新了签名算法你只需要重新分析一遍入口改一行类名业务代码完全不用动。这大概就是动态分析和 RPC 结合的最大价值。
返回列表