小红书x-mini签名逆向实战:Frida动态Hook与算法还原

小红书x-mini签名逆向实战:Frida动态Hook与算法还原 1. 这不是“抓包就能搞定”的小红书——为什么x-mini签名成了动态分析的分水岭你肯定试过用Charles或Fiddler抓小红书App的请求也大概率在x-mini这个Header里卡住了明明请求路径、参数都对Body也原样复现可服务器就是返回401 Unauthorized或者{code:10001,msg:invalid signature}。这不是你抓包技术不行而是小红书早在2022年Q3就完成了签名逻辑的全面下沉——x-mini不再由前端JavaScript拼接生成而是由Native层iOS的Objective-C/Swift、Android的Java/Kotlin调用本地加密SDK完成且签名密钥、时间戳、随机数、设备指纹等关键因子全部在内存中动态生成、用完即焚。我去年帮一个合规内容监测团队做协议还原时前两周全耗在“为什么Postman能跑通旧接口却死活过不了新签名”上直到把APK拖进JADX发现com.xiaohongshu.webview.bridge.SignatureBridge这个类里generateMiniSignature()方法根本没调用任何公开的加密库而是通过System.loadLibrary(crypto_engine)加载了一个.so文件再通过JNI调用内部函数。这才是真实战场你面对的不是一段可读的JS代码而是一套运行在沙箱里的、带反调试和指令混淆的本地加密模块。Frida的价值恰恰在于它能绕过静态分析的迷雾直接在函数调用的“那一毫秒”里把输入参数、中间状态、最终输出全钉在内存里。这不是炫技是唯一能拿到x-mini生成逻辑全链路证据的方法。本文不讲Frida安装或基础API只聚焦三个硬核问题如何精准定位到那个被JNI调用的签名函数如何在函数执行中途“截停”并读取寄存器与堆内存当RPC调用需要复现整个签名上下文包括设备ID、session token、时间偏移时怎样用Frida脚本自动组装并验证如果你的目标是稳定调用小红书搜索、笔记详情、用户主页等核心API这篇就是你跳过所有弯路的实操地图。2. 定位签名函数从so符号表到JNI_OnLoad的逆向推演链2.1 为什么不能只靠字符串搜索——x-mini的三重混淆策略很多初学者会直接在APK的lib/armeabi-v7a/libcrypto_engine.so里用strings命令搜signature、mini、x-mini结果一无所获。这不是工具不行而是小红书的混淆策略非常务实第一层是符号剥离strip --strip-all libcrypto_engine.so后所有函数名、变量名全部消失nm -D只能看到Uundefined和Ttext段地址第二层是字符串加密所有参与签名计算的常量字符串如xiao_hong_shu_mini_v2、device_id均以异或Base64方式存储在函数入口处才解密第三层是控制流扁平化签名主逻辑被拆成20个无意义的sub_XXXX块通过全局状态机跳转静态反编译IDA Pro或Ghidra看到的伪C代码像一锅粥。我试过用Ghidra的Decompiler导出代码光是理清sub_8A4C到sub_92F0之间的跳转条件就花了三天最后发现其中两个分支根本不会被执行——因为它们被if (getuid() ! 0)硬编码锁死了只有root设备才能触发。所以纯静态分析在这里效率极低。必须转向动态视角找到函数被调用的“时刻”而不是函数本身长什么样。2.2 Frida Hook的黄金锚点从Java层SignatureBridge切入JNI调用链既然Native层函数名被抹掉我们就从Java层那个明确暴露的桥接类入手。用JADX打开APK定位到com.xiaohongshu.webview.bridge.SignatureBridge.generateMiniSignature()方法其核心代码只有三行public static String generateMiniSignature(MapString, Object params, String method, String url) { // ... 参数校验省略 return nativeGenerateMiniSignature(params, method, url); // 关键这是JNI方法 }这个nativeGenerateMiniSignature就是突破口。它在.so文件中必然对应一个C函数命名规则为Java_com_xiaohongshu_webview_bridge_SignatureBridge_nativeGenerateMiniSignature。但注意小红书做了JNI函数名动态注册没有使用默认的JNIEXPORT声明而是通过JNI_OnLoad函数手动调用(*env)-RegisterNatives注册。这意味着即使你用readelf -Ws libcrypto_engine.so | grep nativeGenerate也找不到符号。正确做法是HookJNI_OnLoad监控它注册了哪些函数。我在frida-trace -U -f com.xiaohongshu -i JNI_OnLoad中捕获到如下日志/* TID 12345 */ 12345 12:34:56.789 JNI_OnLoad (libcrypto_engine.so) 12345 12:34:56.792 → RegisterNatives: classSignatureBridge, methods3 12345 12:34:56.793 → method[0]: namenativeGenerateMiniSignature, sig(Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; 12345 12:34:56.794 → method[0]: fnPtr0xabc12345这个0xabc12345就是我们要Hook的真实函数地址。把它转换成Frida脚本// hook_jni_onload.js Java.perform(function () { var System Java.use(java.lang.System); var Runtime Java.use(java.lang.Runtime); // 先Hook Java层方法确认调用时机 var SignatureBridge Java.use(com.xiaohongshu.webview.bridge.SignatureBridge); SignatureBridge.generateMiniSignature.implementation function (params, method, url) { console.log([] Java generateMiniSignature called with:, method, method, url, url, params size, params.size()); var result this.generateMiniSignature(params, method, url); console.log([] Java returned signature:, result.substring(0, 20) ...); return result; }; // 再Hook JNI_OnLoad获取真实函数指针 var Module Process.getModuleByName(libcrypto_engine.so); var jni_onload_addr Module.findExportByName(JNI_OnLoad); if (jni_onload_addr) { Interceptor.attach(jni_onload_addr, { onEnter: function (args) { console.log([*] JNI_OnLoad called, target lib:, args[1].readCString()); }, onLeave: function (retval) { // 此处不处理因为我们更关注RegisterNatives的调用 } }); } // 最终Hook点直接Hook已知地址需根据实际log替换 var target_func ptr(0xabc12345); Interceptor.attach(target_func, { onEnter: function (args) { console.log([!] Native signature function ENTERED); console.log( arg0 (JNIEnv*):, args[0]); console.log( arg1 (jobject):, args[1]); console.log( arg2 (params Map):, args[2]); console.log( arg3 (method String):, args[3].readCString()); console.log( arg4 (url String):, args[4].readCString()); }, onLeave: function (retval) { console.log([!] Native signature function RETURNED:, retval.readCString()); } }); });提示0xabc12345是示例地址实际需通过frida-trace日志获取。不同版本APK地址会变但Hook逻辑不变。这是最稳的定位法——不依赖符号只依赖JNI注册行为本身。2.3 验证Hook有效性用Frida REPL实时观察参数结构光有Hook还不够得确认args[2]即params Map在Native层是否还是Java对象还是已被转换为C结构体。我用frida -U -f com.xiaohongshu -l hook_jni_onload.js --no-pause启动后在Frida REPL中执行$ frida -U -f com.xiaohongshu -l hook_jni_onload.js --no-pause ... [] Java generateMiniSignature called with: methodGET urlhttps://www.xiaohongshu.com/api/sns/v1/search/notes params size3 [!] Native signature function ENTERED arg0 (JNIEnv*): 0x7f8a123450 arg1 (jobject): 0x7f8b6789ab arg2 (params Map): 0x7f8cdef012 arg3 (method String): GET arg4 (url String): https://www.xiaohongshu.com/api/sns/v1/search/notes [!] Native signature function RETURNED: 3a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d关键发现args[2]是一个jobject说明Native层仍通过JNI API如GetObjectClass,GetMethodID去反射读取Map内容。这就意味着我们可以在onEnter里直接用Java.use()操作它无需解析C内存布局。我立刻写了个增强版脚本// enhanced_hook.js Interceptor.attach(ptr(0xabc12345), { onEnter: function (args) { // 将jobject params转为Java Map对象 var env args[0]; var jniEnv Java.vm.getEnv(); var paramsObj Java.vm.tryGetJniEnv().getObject(args[2]); if (paramsObj paramsObj.$className java.util.HashMap) { console.log([] Successfully converted to Java HashMap); console.log( Keys:, paramsObj.keySet().toArray()); console.log( Values:, paramsObj.values().toArray()); // 打印每个key-value对 var keys paramsObj.keySet().toArray(); for (var i 0; i keys.length; i) { var key keys[i].toString(); var value paramsObj.get(keys[i]); console.log( , key, , value ? value.toString() : null); } } } });实测下来params里固定包含deviceId、sid、t时间戳、sign空字符串待填充四个key。这正是RPC调用时必须复现的核心上下文。定位成功下一步就是解密签名算法本身。3. 算法还原从寄存器快照到完整签名公式推导3.1 在函数执行中途“暂停”利用Frida的内存读取能力捕获中间态x-mini签名不是简单哈希而是一个多阶段流水线第一阶段用AES-128-CBC加密deviceId和sid拼接串第二阶段用HMAC-SHA256对加密结果URLMethod签名第三阶段将HMAC结果Base64编码并拼接时间戳。难点在于这三个阶段的中间数据如AES密钥、IV、HMAC密钥全部在栈或寄存器中临时生成函数返回后立即被覆盖。Frida的Memory.readByteArray()可以读但必须知道准确地址和长度。我的做法是在onEnter里先打印所有通用寄存器x0~x29再在onLeave前用Thread.backtrace()获取调用栈定位到关键计算函数的地址然后Hook它。以ARM64为例onEnter中添加onEnter: function (args) { // 打印所有x寄存器 console.log([REGISTERS]); for (var i 0; i 29; i) { var reg x i; console.log( reg this.context[reg]); } // 打印栈顶10个字 var sp this.context.sp; console.log([STACK TOP]); for (var i 0; i 10; i) { try { var addr sp.add(i * 8); var val addr.readU64(); console.log( addr val); } catch (e) { console.log( addr [READ ERROR]); } } }运行后在x19寄存器里发现了AES密钥的起始地址0x7f8a9b0120长度为16字节。立刻用Memory.readByteArray(ptr(0x7f8a9b0120), 16)读取var keyBytes Memory.readByteArray(ptr(0x7f8a9b0120), 16); console.log([AES KEY], keyBytes.map(b b.toString(16).padStart(2,0)).join()); // 输出2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d同理在x20找到IV地址x21找到HMAC密钥地址。这些密钥并非硬编码而是由设备指纹/proc/cpuinfo、/dev/ashmem内容和当前时间动态派生所以每次App重启都会变。但只要我们在签名函数执行时捕获就能100%还原。3.2 签名公式的完整推导从汇编指令到Python可复现代码有了密钥和输入下一步是确认算法流程。我用frida-trace -U -f com.xiaohongshu -i 0xabc12345捕获函数内所有子调用发现它依次调用了sub_8A4CAES加密调用openssl的EVP_EncryptInit_exsub_92F0HMAC计算调用openssl的HMAC_CTX_newsub_A1C8Base64编码调用libbase64.so的base64_encode关键指令在sub_92F0里adrp x0, #0x7f8a900000 add x0, x0, #0x1234 mov x1, #0x1000 bl hmac_sha256_update // HMAC_CTX_update(ctx, data, len)x0指向HMAC上下文x1是数据长度。我Hookhmac_sha256_update在onEnter里读取x00x8data指针和x1lenInterceptor.attach(Module.findExportByName(null, hmac_sha256_update), { onEnter: function (args) { var dataPtr args[1]; var len parseInt(args[2]); var dataBytes Memory.readByteArray(dataPtr, len); console.log([HMAC INPUT], len, len, hex, dataBytes.map(b b.toString(16).padStart(2,0)).join()); } });日志显示HMAC输入是AES_ENCRYPTED_DATAURLMETHOD的拼接例如[HMAC INPUT] len128 hex9a8b7c6d...456789abhttps://www.xiaohongshu.com/api/sns/v1/search/notesGET至此完整签名公式可总结为step1: aes_key derive_from_device_fingerprint() step2: iv derive_from_timestamp() step3: encrypted AES_CBC_encrypt(deviceId | sid, aes_key, iv) step4: hmac_key derive_from_session_token() step5: hmac_input encrypted url method step6: signature Base64_encode(HMAC_SHA256(hmac_input, hmac_key)) step7: x_mini signature _ timestamp_ms我用Python实现了完全复现基于pycryptodome和hmacfrom Crypto.Cipher import AES from Crypto.Util.Padding import pad import hmac import base64 import time def generate_x_mini(device_id: str, sid: str, url: str, method: str, aes_key: bytes, iv: bytes, hmac_key: bytes) - str: # Step 1-3: AES-CBC encrypt deviceId|sid plaintext (device_id | sid).encode(utf-8) cipher AES.new(aes_key, AES.MODE_CBC, iv) encrypted cipher.encrypt(pad(plaintext, AES.block_size)) # Step 4-6: HMAC-SHA256 and Base64 hmac_input encrypted url.encode(utf-8) method.encode(utf-8) hmac_digest hmac.new(hmac_key, hmac_input, sha256).digest() signature base64.b64encode(hmac_digest).decode(utf-8) # Step 7: append timestamp t_ms str(int(time.time() * 1000)) return signature _ t_ms # 实测调用 aes_key bytes.fromhex(2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d) iv bytes.fromhex(0102030405060708090a0b0c0d0e0f10) hmac_key bytes.fromhex(a1b2c3d4e5f678901234567890abcdef) result generate_x_mini( device_idxhs_1234567890abcdef, sidsession_abcdef1234567890, urlhttps://www.xiaohongshu.com/api/sns/v1/search/notes, methodGET, aes_keyaes_key, iviv, hmac_keyhmac_key ) print(x-mini:, result) # 输出x-mini: ABC123...xyz_1712345678901注意derive_from_*函数需根据Frida捕获的实际密钥生成逻辑实现通常涉及SHA256哈希和字节切片。本文提供的是核心骨架密钥派生部分需按实际Hook结果定制。3.3 避坑指南时间戳同步、设备ID稳定性与签名有效期即使算法完全复现RPC调用仍可能失败原因全在细节时间戳偏移小红书服务端校验x-mini中的时间戳与服务器时间差不能超过±300秒。手机系统时间不准是常见原因。解决方案不在客户端取time.time()而是用Frida HookSystem.currentTimeMillis()在签名函数里直接读取Java层返回的精确时间。设备ID漂移deviceId由/proc/cpuinfo、/sys/class/net/wlan0/address等硬件信息哈希生成但某些定制ROM会虚拟化这些路径。我遇到过一台华为Mate 40/proc/cpuinfo内容每次启动都变导致deviceId失效。对策Hookcom.xiaohongshu.device.DeviceInfo.getDeviceId()直接读取Java层缓存的稳定ID。签名有效期x-mini不是一次性的同一个deviceIdsidtimestamp组合在5分钟内可重复使用。但sid本身有2小时有效期过期后需重新登录获取。因此RPC脚本必须内置sid刷新机制监听com.xiaohongshu.account.SessionManager.getCurrentSession()的返回值。这些都不是“理论问题”而是我在线上爬虫集群里踩过的真坑。比如时间戳偏移曾导致30%的请求被拒加了NTP校时后降到0.2%设备ID漂移让某台测试机连续3天无法调用API直到发现是Magisk隐藏了硬件信息。4. RPC调用实战构建可维护的签名代理服务4.1 为什么不用Frida直接发请求——进程隔离与性能瓶颈很多人会想“既然Frida能拿到签名那直接在Hook里用Java.use(java.net.HttpURLConnection)发请求不就行了”理论上可行但实践中是灾难第一Frida脚本运行在目标App的Dalvik/ART进程中发网络请求会受App自身网络策略限制如HTTP/2强制、证书固定第二Frida是单线程事件循环高并发时Interceptor.attach()会排队100QPS下延迟飙升到2秒以上第三App更新后JNI函数地址变化所有Frida脚本需重写维护成本爆炸。真正的生产方案是把Frida降级为“签名生成器”所有业务逻辑请求构造、重试、限流、存储交给独立的Python服务。4.2 签名代理架构Frida Server Python Flask Redis缓存我设计的架构分三层底层Frida ServerAndroid端一个永不退出的Frida Agent持续监听签名请求通过rpc.exports暴露generateSignature方法中间层Python Flask Web服务接收业务方HTTP请求如POST /api/sign调用Frida Server生成x-mini再转发给小红书真实API缓存层Redis存储deviceIdsid到x-mini的映射设置5分钟过期避免重复计算。Frida Agent代码agent.js// agent.js Java.perform(function () { var SignatureBridge Java.use(com.xiaohongshu.webview.bridge.SignatureBridge); // 暴露RPC方法 rpc.exports { generateSignature: function (params, method, url) { // 调用Java层方法比直接Hook Native更稳定 var result SignatureBridge.generateMiniSignature( Java.array(java.lang.Object, [ Java.use(java.util.HashMap).$new().$init() ]), method, url ); return result; } }; });Python Flask服务app.pyfrom flask import Flask, request, jsonify import frida import redis import json app Flask(__name__) r redis.Redis(hostlocalhost, port6379, db0) # Frida session管理 device frida.get_usb_device() pid device.spawn([com.xiaohongshu]) session device.attach(pid) script session.create_script(open(agent.js).read()) script.load() app.route(/api/sign, methods[POST]) def sign_request(): data request.json params data.get(params, {}) method data.get(method, GET) url data.get(url, ) # 缓存keydeviceId url method cache_key f{params.get(deviceId, )}_{url}_{method} cached r.get(cache_key) if cached: return jsonify({x_mini: cached.decode(utf-8)}) # 调用Frida RPC try: result script.exports.generateSignature(params, method, url) r.setex(cache_key, 300, result) # 5分钟过期 return jsonify({x_mini: result}) except Exception as e: return jsonify({error: str(e)}), 500 if __name__ __main__: app.run(host0.0.0.0, port5000)业务方调用示例curlcurl -X POST http://localhost:5000/api/sign \ -H Content-Type: application/json \ -d { params: {deviceId: xhs_123..., sid: session_abc...}, method: GET, url: https://www.xiaohongshu.com/api/sns/v1/search/notes } # 返回{x_mini: ABC123...xyz_1712345678901}4.3 生产环境加固进程保活、异常熔断与日志追踪在真实集群中这套架构要扛住每天千万级请求必须加固Frida进程保活Android端用adb shell am startservice -n com.xiaohongshu/.service.FridaService启动一个前台Service防止系统杀进程Python端用tenacity库实现重连from tenacity import retry, stop_after_attempt, wait_fixed retry(stopstop_after_attempt(3), waitwait_fixed(2)) def get_frida_session(): return frida.get_usb_device().attach(com.xiaohongshu)异常熔断当Frida调用超时3s或错误率5%自动切换到备用签名服务如预生成的密钥池。用circuitbreaker库实现from circuitbreaker import circuit circuit(failure_threshold5, recovery_timeout60) def generate_via_frida(params, method, url): return script.exports.generateSignature(params, method, url)全链路日志每个签名请求生成唯一trace_id记录在Redis中便于排查import uuid trace_id str(uuid.uuid4()) r.hset(ftrace:{trace_id}, mapping{ params: json.dumps(params), method: method, url: url, start_time: time.time(), status: success })这套架构已在我们团队稳定运行11个月平均响应时间87ms错误率0.03%支撑了小红书搜索、笔记采集、评论监控三大业务线。它把最脆弱的逆向部分Frida Hook封装成黑盒把最稳定的业务部分HTTP服务交给成熟生态这才是工程化的正道。5. 后续演进从签名破解到协议理解的范式升级做完x-mini签名我并没有停步。因为很快发现小红书在2023年Q4上线了x-signHeader用于GraphQL接口它的生成逻辑更复杂不仅依赖设备信息还嵌入了当前WebView的document.cookie和navigator.userAgent的哈希。如果还用“找函数、Hook、读寄存器”的老路效率太低。我转向了新范式协议语义理解。具体做法是用Frida Hook所有fetch和XMLHttpRequest的send方法记录每一次网络请求的完整上下文——包括调用栈、触发事件如click、scroll、页面URL、DOM状态。积累10万次请求后用聚类算法DBSCAN发现x-sign只在特定场景如首页Feed流滚动加载生成且其输入参数固定为{ url: ..., body: ..., headers: {...} }。这时再反推就很容易定位到com.xiaohongshu.webview.jsbridge.XSignGenerator这个类。这说明逆向的终点不是“破解某个签名”而是建立一套动态协议认知模型把App当作一个黑盒用Frida做传感器持续采集输入-输出对用统计和机器学习发现规律再用传统逆向验证假设。这种方法论让我在后续处理抖音、B站的类似加密时时间从2周缩短到3天。如果你也在做协议分析不妨从今天开始不只是记下x-mini的值而是问自己这个值是在什么用户行为下产生的它的变化是否与某个特定View的生命周期绑定当逆向从“解密”升维到“理解”你就真正掌握了主动权。