尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Frida的小红书x-mini签名逆向与RPC服务化实战

基于Frida的小红书x-mini签名逆向与RPC服务化实战 搞逆向的都知道小红书的 x-mini 签名算是国内 App 加密协议里比较有代表性的一个。它藏在请求头里每个请求都会带服务端拿它做风控校验。做爬虫、做数据采集、或者单纯想研究移动端签名机制的朋友大概率都跟它打过照面。我这次实战的目标很直接把 x-mini 从生成到校验的完整链路扒清楚再用 Frida 把签名函数变成可远程调用的 RPC 接口让 Python 那边能直接拿签名去拼请求。整个过程涉及 Frida 动态插桩、Native 层算法逆向、以及 RPC 服务搭建算是一套移动端逆向的完整闭环。无论你是刚接触安卓逆向的初学者还是已经在搞协议分析的中级选手这篇文章的思路和踩坑记录应该都能用得上。1. 整体思路拆解先搞清楚 x-mini 到底是怎么来的1.1 认识目标 x-mini 签名先说说 x-mini 是什么。打开小红书 App随便点开一个接口用抓包工具看请求头你会看到 x-mini、x-b3-traceid、x-legacy-sid 之类的一串字段。其中 x-mini 就是一个随着请求动态变化的签名值长度不长但每次请求都不一样而且同一时间窗内不同接口的 x-mini 也不一样。它的核心作用就是防篡改、防伪造服务端收到请求后会按照同样的算法重新算一遍签名如果对不上轻则返回风控错误重则直接封掉这次请求的账号凭证。x-mini 的生成逻辑并不是写在 Java 层而是下沉到了 Native 层——也就是 .so 文件里。这是国内厂商比较常见的做法因为 Java 层代码反编译太容易了随便拿个 jadx 一拖逻辑一目了然。放到 so 层之后至少你得先定位到导出函数、分析汇编指令、还要搞清楚 JNI 调用约定门槛一下子高了不少。x-mini 的算法属于时间戳 固定盐 请求参数做哈希或对称加密后再编码的典型结构但细节上做了不少魔改下文我会具体讲。1.2 完整的分析路线从抓包到 RPC 的一条龙流程我的分析路线基本分成五步每一步都有明确产出抓包拿到真实请求的 x-mini 样本确认它的位置和形态用 Frida 动态插桩溯源定位 Java 层哪个方法在生成 x-mini跟着调用链下沉到 Native 层找到 so 文件里的核心函数Hook 关键函数抓输入输出结合静态分析还原算法把还原好的逻辑或者直接 hook 原始函数封装成 Frida RPC 接口给 Python 调用。这五步走完你就拥有了一个可长期使用的签名服务请求进来Python 调一下 Frida 的 RPC 接口签名值立刻返回然后再去拼业务请求。整个过程不需要理解每一行汇编但需要对 Frida 的 API 足够熟练同时对 Android 的 binder、JNI、ELF 结构有个基本认知。我特别想强调一点思路比工具重要。网上很多人一上来就开 frida 瞎 hook看到什么 hook 什么最后收获一堆日志却拼不出完整链路。正确做法是先想清楚签名一定是在请求发出前生成的所以顺着网络请求的生命周期去逆推效率会高很多。2. 环境准备与前置侦察先把家伙事儿备齐2.1 Frida 环境搭建的关键细节Frida 的安装本身不难pip install frida-tools 加上下载对应版本的 frida-server 就行。但这里面有两个非常容易踩坑的地方。第一个坑是版本匹配。电脑端的 frida 版本必须和手机里的 frida-server 版本完全一致否则连上之后会出现 unable to connect to remote frida-server 或者加载脚本时各种莫名其妙报错。我的做法是先在电脑上跑 frida --version 拿到版本号再去 GitHub releases 页面下载完全同版本的 frida-server。注意安卓模拟器和真机还要区分架构模拟器一般是 x86_64真机基本上是 arm64-v8a下错架构直接跑不起来。第二个坑是 frida-server 的启动方式。常规操作是adb push frida-server /data/local/tmp/ adb shell su chmod 755 /data/local/tmp/frida-server /data/local/tmp/frida-server 但很多改版 ROM 或者加固过的环境会有反调试启动后 frida-server 进程会马上被杀。我建议启动前先看看进程是否存活用adb shell ps | grep frida确认。如果被杀说明 App 里有针对 frida-server 端口的检测后面会讲到绕过办法。2.2 抓包与定位确认签名存在的确切位置我用的是 Charles 安卓模拟器的方式设置了系统代理后能抓到 HTTPS 明文流量前提是把 Charles 的 CA 证书装进系统证书目录。这一步在 Android 7 以上比较麻烦因为默认不再信任用户证书需要把证书改名为系统证书格式后 push 到 /system/etc/security/cacerts/并且挂载为可写。抓包这一步的目的不只是拿到 x-mini 这个值更重要的是确认它的触发条件。我建议做一个动作来触发一个典型接口比如搜索、点赞、评论这些。抓到一个完整的请求后重点看几样东西x-mini 的具体位置是 Header 还是 Cookie它的长度大概是多少是不是固定长度同一个接口连续请求两次x-mini 的变化规律时间戳驱动还是随机数驱动。比如我当时观察到 x-mini 是一个固定长度的十六进制字符串连续请求下只有后几位在变前面有一段比较稳定。这个特征就暗示它的结构可能是固定盐 时间戳/随机数的组合体为后面的算法还原提供了方向。2.3 用 Frida 做第一个插桩实验在正式分析算法之前我习惯先写一个全局 hook 脚本把 okhttp3 的拦截器或者 HttpURLConnection 的请求头打印出来用来确认 x-mini 是在哪一层被加进请求的。Frida 脚本大致长这样Java.perform(function() { var OkHttpClient Java.use(okhttp3.OkHttpClient); OkHttpClient.newCall.overload(okhttp3.Request).implementation function(request) { console.log(request.url().toString()); console.log(request.headers().toString()); return this.newCall(request); }; });这段脚本的作用是把经过 OkHttpClient 的所有请求 URL 和 Header 打印出来。运行后会发现 x-mini 的注入点其实是在这之前的某个拦截器里但至少能确认 x-mini 是在网络层被附加的。如果运气好直接就能看到这个 Header 是谁 set 进去的那么离目标函数就更近了。这一步的核心思路是分层溯源先确定哪个请求带签名再确定哪个组件负责加签名然后继续往上追找到实际做签名计算的位置。整个过程像剥洋葱层层递进Frida 就是我们剥洋葱的那双手。3. 核心解密从 Java 层 Hook 到 Native 层算法还原3.1 锁定 Java 层的调用入口既然网络库是 OkHttp我直接尝试 hook okhttp3 的 Request 类或者拦截器链。实际操作中我发现 x-mini 是通过一个自定义拦截器加进去的它调用了一个加密工具类的某个静态方法。利用 Frida 的栈回溯功能可以很快看到调用来源Java.perform(function() { var XMiniUtil Java.use(com.xingin.mars.manul.SignUtil); XMiniUtil.genMiniSign.implementation function(args) { var stack Java.use(android.util.Log).getStackTraceString(Java.use(java.lang.Throwable).$new()); console.log(stack); return this.genMiniSign(args); }; });当我把这个 hook 挂上再触发一次请求终端立刻打印出了完整的 Java 调用栈。从调用栈里可以很清楚地看到签名工具是在网络请求拦截器那一层被调用的方法名和类名都比较直白有些厂商会故意混淆掉接下来就要看这个 genMiniSign 到底做了什么。如果运气不够好方法被混淆成了 a、b、c 之类的名字那也没关系。Java 层的方法入口定位可以靠参数特征来判断——一个方法接收了 URL、Method、Body 或一些关键参数然后返回一个 String而且这个 String 恰好被放进 Header那八成就是它。多打印几个候选方法的返回值对比一下看看哪个返回值出现在抓包里的 x-mini就能锁定。3.2 沿着 JNI 调用下沉到 Native 层Java 层的 genMiniSign 通常不会自己写具体的加密算法而是通过 System.loadLibrary 加载某个 .so再声明一个 native 方法。在 jadx 里看反编译结果类似这样public static native String genMiniSign(String url, String method, String body, String nonce, String timestamp);注意这里有一堆参数这是典型的签名算法输入。时间戳、随机数、请求路径、请求体这些东西经过某种算法处理最后生成 x-mini。此时我有了一个很重要的判断依据算法输入的变量是什么将直接决定算法的类型和结构。接下来操作分成两条线静态线把相关的 .so 从 APK 里提取出来用 IDA 或 Ghidra 打开找到对应的导出函数看有没有字符串引用、常量数组、密钥材料动态线用 Frida hook 这个 native 方法打印所有参数的传入值和函数的返回值。两条线同时走互补验证。3.3 动态 Hook抓取 Native 函数输入与输出用 Frida 的 Interceptor 对 native 函数做 hook要拿到具体地址。可以先通过 Java.use 拿到 native 方法的 method 对象再用 getAddress 或直接找 so 的导出符号var signSo Module.findBaseAddress(libxxx.so); var nativeFuncAddr Module.findExportByName(libxxx.so, Java_com_xingin_mars_manul_SignUtil_genMiniSign); if (nativeFuncAddr) { Interceptor.attach(nativeFuncAddr, { onEnter: function(args) { console.log(arg0 JNIEnv: args[0]); // 后面是 jstring 参数需要转成 JS string var env args[0]; this.url Java.vm.getEnv().getStringUtfChars(args[2], null).readCString(); console.log(url: this.url); }, onLeave: function(retval) { console.log(retval: Java.vm.getEnv().getStringUtfChars(retval, null).readCString()); } }); }这段代码的思路是不管数字签名逻辑多复杂我先把输入输出全部记录下来。拿到若干组输入 - 输出的对应关系这组数据就是我后续分析算法的最有力依据。需要注意的一点是JNI 函数的第一个参数是 JNIEnv 指针第二个是 jobject从第三个开始才是真实的参数。很多新手 hook native 方法时数错参数位置导致打印出来的全是乱码这个问题我在 5.3 节还会展开讲。3.4 算法特征分析从哈希到魔改加密在拿到了十几组输入输出样本之后我把数据丢给一个小脚本做特征对比。这里的分析策略是输入是否包含时间戳如果包含x-mini 应该会随时间变化输出长度是否恒定恒定的话大概率是摘要或分组加密后的十六进制编码同样的输入是否产生同样的输出如果两次同样参数得到完全一样的 x-mini说明算法是确定性的没有随机盐。实测下来x-mini 的输出长度是固定的且包含时间因子。我用同一个 URL、同一个 Body、不同的 timestamp 去触发 native 函数发现返回值差异很规律说明 timestamp 是算法输入的一部分。再去做差分分析可以推断算法内部大概率的伪代码是raw salt timestamp nonce url body digest 魔改哈希(raw) x-mini base64 变种(digest)这里的 salt 是写死在 so 里的称为盐值。提取的方式有点运气成分从 so 的 .rodata 段能搜到一串看起来像乱码但长度稳定的字符串试着替换到算法里结果一致的话就说明盐值找到了。当然如果盐值是运行时拼接出来的就需要用 Frida 在函数内部下断点观察内存中的临时字符串来获取。这一步我只还原了算法骨架并没有完全还原内部的每一行汇编。但在实操中我们做 RPC 并不需要自己用 Python 重新实现一遍签名算法直接让原始 native 函数继续工作Frida 负责调用和转发就是最稳妥的方案。接下来的 RPC 章节就建立在hook 原函数这个思路上。4. 搭建 Frida RPC 服务让签名变成可复用接口4.1 为什么需要 RPC把签名能力从手机上拿下来算法分析完成后面临一个很现实的问题业务脚本是跑在 PC 上的而签名函数是跑在手机里的。如果每次签名都手动去控制台执行 Frida 打印日志那效率太低。RPC 的意义就是把手机里能算签名这个能力变成 PC 上一个函数调用的事情。Frida 本身提供了 rpc.exports 机制可以在注入脚本里导出一组方法PC 端通过 Python frida 库的 script.exports.xxx 来调用。每当 Python 需要签名时消息会通过 frida 的 transport 层传递到手机上的 JS 运行时JS 代码调用已经 hook 好的 native 函数然后返回结果。整个过程像远程过程调用所以叫 RPC。这里我要特别说明一点RPC 调用和rpc 框架在上下文里面指的是两件不同的事。我们这里说的 RPC是 frida 内置的远程调用能力和常说的 gRPC、JSON-RPC 这类框架没有直接关系。如果你对 JSON-RPC、Thrift 那套有兴趣也可以在这个基础上把签名服务再包一层 HTTP 接口做成局域网内的签名中心但那是后话。4.2 用 rpc.exports 暴露签名函数把上一节的 native hook 改造一下封装成 RPC 接口。核心思路是在 JS 脚本里定义一个可供外部调用的函数函数内部完成对 native 方法的调用。示例脚本如下function genSignature(url, method, body, timestamp, nonce) { var result null; Java.perform(function() { var SignUtil Java.use(com.xingin.mars.manul.SignUtil); result SignUtil.genMiniSign(url, method, body, nonce, timestamp); }); return result; } rpc.exports { sign: genSignature };这里有个细节Java.perform 是异步的但 rpc.exports 的方法默认可以返回同步结果。上面的代码把 result 保存在闭包变量里等 Java.perform 执行完再返回实测是没问题的。如果遇到某些场景必须异步也可以返回 Promisefrida 的 rpc 层会自动 await。我建议用同步写法配合 Java.perform简单直接不容易出问题。但这类直接调用 Java 方法的方式有个前提Java.use 找到的类和方法没有被强混淆或加固掉。如果类名被 VMP 壳藏起来了Java.use 可能直接抛异常。那种情况就得退回到纯 Interceptor 方式直接 hook native 导出符号然后在 RPC 接口里手动构造 JNI 参数这会复杂一些。幸好小红书这个目标没有做到那一步。4.3 Python 端调用实践PC 端的 Python 代码用 frida 库连接手机加载上面的 JS 脚本随后就能直接调用import frida import time device frida.get_usb_device() pid device.spawn([com.xingin.xhs]) session device.attach(pid) script session.create_script(open(xmini_rpc.js, encodingutf-8).read()) script.load() signature script.exports_sync.sign( /api/sns/web/v1/homefeed, GET, , str(int(time.time())), abcdef123456 ) print(x-mini:, signature)这里我故意用了 script.exports_sync 而不是 script.exports原因是新版 frida 对同步调用有更明确的 API。在我的 frida 15.x 环境下exports_sync 能确保调用是阻塞的返回结果就是字符串。用这种方式每次请求前先调一下 sign 拿到 x-mini再拼到请求头里发出去。你也可以把 sign 函数再包一层做成一个独立的 HTTP 服务这样就不需要每台业务机都连着手机了。4.4 多接口复用与并发优化实际业务中肯定不止一个接口而且请求量大时会产生并发问题。我在实践里碰到过这样的情况业务脚本用线程池同时发 20 个请求每个线程都调 sign 方法结果 frida 的 rpc 通道开始报错出现 cannot finish rpc call in 30 seconds: nul 这类 timeout 异常。这个错误的根因是 frida 的 RPC 通道本身是串行的JS 侧的 Java.perform 一次只能处理一个主线程调用。当 Python 侧同时发起多个 RPC 请求frida 内部会排队超时时间一到就报错。解决方案有两个在 Python 侧加锁把 RPC 调用串行化保证同时只有一个请求在跑在 JS 侧维护一个已经算好的签名缓存命中直接返回减少 RPC 调用次数。我的做法是加了一个简单的缓存加锁import threading lock threading.Lock() def get_signature(url, method, body): with lock: return script.exports_sync.sign( url, method, body, str(int(time.time())), gen_random_nonce() )实测下来加了锁之后并发问题基本消失了虽然牺牲了一点吞吐但换来的是稳定。如果对吞吐有更高要求可以尝试多开几个 frida-server 连不同端口不过这是另一个话题了。5. 常见问题与排查技巧实录5.1 Frida 注入失败与进程崩溃最常见的报错是unable to connect to remote frida-server这种八成是版本不一致或 frida-server 没启动。先adb shell ps | grep frida看进程如果存在再用frida-ps -U测试连接。还有一种是 target 进程启动后马上崩溃往往是 App 检测到了 frida 的痕迹或者 so 库在启动阶段就执行了反调试逻辑。我的排查顺序是确认 frida-server 以 root 权限运行关掉 App 的多开、分身环境很多模块在分身环境里会出现诡异的崩溃用frida -U -f com.xingin.xhs --no-pause方式启动其中 --no-pause 让主线程不要被挂起减少被检测的风险。如果还不行就得考虑把 frida-server 改名为其他名字比如 xhs_fs同时修改默认端口/data/local/tmp/xhs_fs -l 127.0.0.1:9999 frida -H 127.0.0.1:9999 -f com.xingin.xhs这种方式相当于给 frida-server 做了个轻量伪装能绕过一部分按名字扫描的检测策略。5.2 RPC 超时问题cannot finish rpc call in 30 seconds这个报错我在网上见过很多次也在自己项目里踩过。原因是 frida 的 RPC 调用在指定时间内没拿到返回值默认超时 30 秒。常见诱因有三个第一个是 JS 侧的 Java.perform 卡住了说明当前 Java 主线程被占用无法执行新的方法这时考虑在 JS 中用 setImmediate 延迟调用或者改用 Interceptor 方式直接在 native 层返回第二个是 Python 侧并发调用过多把 RPC 通道堵死解决办法我上一个章节已经写了加锁串行化第三个是目标方法内部依赖网络或者随机等待导致计算时间过长这种情况只能增大 frida 的超时时间或者在 JS 侧做异步回调。我还习惯在 JS 脚本里加一个全局错误捕获把异常信息通过 console.log 打出来这样 Python 端即使报 timeout也能从 frida 日志里看到真正的原因。5.3 JNI 参数偏移拿错导致的乱码这个坑特别典型。很多新手在 hook native 方法时以为第一个参数就是 Java 层传入的那个 String结果打印出来是一堆地址或者乱码。实际上 JNI native 方法的固定签名是JNIEXPORT jstring JNICALL Java_pkg_class_method(JNIEnv *env, jobject thiz, jstring input)如果方法不是 static 的第二个参数是 jobjectthis 指针如果是 static 方法第二个参数是 jclass。真正的业务参数从第三个开始。所以在插桩时一定把 env 和 thiz/jclass 跳过去。如果你看到一个 Hook 日志里参数全都是 0x7xxxxxxxxx 这种大地址基本上就是没跳过。另外 jstring 不能用 Interceptor 的参数直接读要通过 JNIEnv-GetStringUtfChars 转一下。为了方便我一般先用 Frida 的 Java.vm.getEnv().getStringUtfChars() 方法来转换。5.4 签名参数时效性与风控对抗x-mini 既然包含时间戳就一定有时效性。实测下来签名请求在生成后几十秒内有效超过这个窗口就会被服务端拒绝。因此在落地 RPC 服务时我一般会要求业务侧尽量在拿到签名后立刻使用不建议做长时间的本地缓存。风控对抗是这个领域绕不开的话题。但我必须强调这些技术研究的目的是为了合法合规的数据采集和安全测试我个人不鼓励把签名能力用于恶意刷量、批量注册、侵犯用户隐私等领域。做逆向心里要拎得清边界。如果发现账号被风控了最合理的处理是降低频率、改善网络环境、通过正规渠道申请权限或使用官方开放 API。技术解决方案永远只是辅助合规才是底牌。5.5 一个小技巧把 RPC 服务再封装成 HTTP最后分享一个小技巧。如果你不想每台业务机都直连手机可以用 Python 的 Flask 或 FastAPI 把签名服务再包装一层 HTTP 接口让整个团队都能通过 HTTP 调用签名能力。大概思路from flask import Flask, request, jsonify app Flask(__name__) app.route(/sign, methods[POST]) def sign_api(): data request.get_json() sig get_signature(data[url], data[method], data[body]) return jsonify({x-mini: sig}) app.run(host0.0.0.0, port5000)这样手机只需要保证 frida-server 一直在线其他同事的代码里只需要一个 requests.post 就能拿到签名整个协作体验会顺畅很多。我在实际项目里就是这样做的一台测试机专门跑签名服务团队里其他人不再需要关心 Frida 是怎么工作的只需要请求本地接口。这个项目做下来我个人最深的感受是Frida 真正厉害的地方不在于它提供了多少现成的 hook API而在于它能把运行时的应用变成一块可以随时观察和操作的内存画布。配合 RPC 机制它甚至能把手机里的能力无缝移植到 PC 端业务链路中这种动静结合、远程调用的思路在移动端逆向里是通用的。无论是 x-mini 还是其他签名算法只要是走Java 入口 Native 实现这条路这套方法论就能复用。
返回列表