拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Android客户端签名机制解析:从sig到__NS_sig3的逆向还原实战

1. 项目概述与核心需求1.1 为什么客户端要设计签名先聊点背景。做过安卓端接口联调的朋友应该都有印象很多大厂的App对外请求时URL或请求体里总带着一两个看不懂的参数字段比如sig、__NS_sig3。你把它删掉直接请求服务端大概率直接回一个错误码但你把它原样带上请求就通了。这个“多出来的东西”就是客户端签名。签名的作用并不复杂服务端要在无状态HTTP协议下确认这个请求来自自己家的App而不是别人写的脚本或爬虫。所以客户端要把请求参数、时间戳、设备信息之类的数据按约定好的规则拼起来做一次不可逆的摘要运算生成一个固定长度的字符串连同请求一起发给服务端。服务端拿到后用同样算法验算一遍一致就放行不一致就拒绝。某手下文就用“目标App”代指的v8.x版本里sig和__NS_sig3就是这套机制的两个产物。sig出现得早逻辑相对简单__NS_sig3是后加的拼的参数更多、参与计算的字段更杂。这篇博文是我的签名计算方法系列第二篇重点把这两个参数的生成逻辑拆开讲清楚并给出可落地的还原路径。1.2 这套方案适合谁这篇内容适合三类读者做安卓应用安全测试的工程师需要理解目标App的接口防护机制做爬虫或数据采集开发的同学需要在不破坏对方服务的前提下合法合规地调试自己的请求对客户端签名感兴趣的Coder想搞明白一个App的签名参数从抓包到还原算法完整链路到底长什么样。不管你是哪种身份我先劝一句签名算法分析是一把双刃剑。理解它是为了做安全评估、漏洞挖掘、数据合规校验别把它用在批量刷接口、薅羊毛、绕过风控这类事上。本文所有方法都基于正常调试和逆向学习场景请守住底线。1.3 先认识sig和__NS_sig3在某个版本的v8.x目标App里抓包会看到类似下面的请求头POST /rest/n/feed/list?__NS_sig3abcdef...sigxyz123...k123456两个参数都挂在query里看起来长得差不多但承担的角色不一样sig最基础的签名参与计算的参数通常是请求路径、query参数、时间戳和盐值salt拼接后的摘要。生成成本低服务端校验快。__NS_sig3增强型签名参与拼接的字段更多包含了设备指纹、用户身份、时间窗口、甚至部分请求体的Hash。sig更像是基础凭证__NS_sig3才能代表一次“活体请求”。服务端的校验策略往往是先验sig的合法性如果sig不对直接拒绝如果sig正确但__NS_sig3不匹配放入风控池。所以两边的计算都必须正确。下面我按“抓包定位 → So层定位 → 算法还原 → 链路验证 → 常见坑位”的顺序带你走一遍完整流程。2. 环境准备与参数定位2.1 抓包环境分析签名参数第一步肯定是要拿到真实的请求样本。推荐的环境组合是一台已Root的安卓测试机Android 7~10或使用模拟器配合方案注意模拟器容易被App检测Charles或Burp Suite配置好SSL代理证书用于HTTP/HTTPS抓包Frida框架用于动态Hook验证参数变化IDA Pro或Ghidra用于So库静态分析。实际操作中我习惯先在手机上装好Charles证书把代理指向电脑的8888端口打开目标App随便刷几个页面就能看到大量请求暴露在抓包列表里。这时候不要急着看某个请求先找一个关键接口比如Feed流、用户主页这类高频接口记录下它的完整URL、请求头、请求体并和你不带签名时发送的请求做一次对比签名字段就躺在差异里。注意事项目标App大概率做了防抓包检测。如果安卓7以上系统不信任用户证书请求会话会直接TLS握手失败。解决办法是把抓包证书装进系统证书目录或者用JustTrustMe这类工具绕过证书校验。但这只是为了本地分析别在生产环境乱搞。2.2 对比法锁定签名参数拿到几十个请求样本后你会想确认哪些字段是签名参数哪些只是普通业务参数。这里有个笨但有效的办法控制变量法。假设有一个接口路径是/rest/n/feed/listquery里有k、page、sig、__NS_sig3四个参数。你通过Frida Hook住这个接口的调用点直接篡改query里某个参数的值观察服务端返回。改动page服务端正常返回这说明page不参与签名校验或者说改动它不会导致签名失效改动sig服务端立刻报错说明sig是必需签名改动__NS_sig3服务端报错或者返回风控码说明sig3也是必需签名。需要特别提醒的是不要以为只有query里的参数才是签名参与对象。有些版本会把签名参数计算范围扩大到POST请求体、HTTP头里的Cookie、设备信息字段等。所以我在做对比实验时会一次性Hook住HTTP请求发出前的最终状态确保观察到的是客户端真实发送出去的字符串而不是某个中间变量。2.3 从动态调用栈回溯Native方法sig和__NS_sig3大概率不是在Java层算的而是在So库里通过JNI调用完成的。原因很简单Java层写签名就太容易反编译还原了把算法下沉到Native层至少能提高逆向门槛。确认这一点的方式也简单在Frida里Hook住所有可能发起网络请求的库比如OkHttp的Interceptor打印调用时的Java堆栈。一旦发现在进入网络层之前有类似nativeSig的本地方法被调用那么签名计算基本就在So层。我拿到过一次很典型的堆栈java.lang.Throwable at com.kuaishou.android.security.SigHelper.getSig(SigHelper.java:xxx) at com.kuaishou.android.network.HttpUtils.buildParams(HttpUtils.java:xxx) at com.kuaishou.android.network.HttpUtils.get(HttpUtils.java:xxx)SigHelper.getSig就是目标本地方法。在这里再用Frida Hook它的输入输出记录每次调用传入了哪些参数、返回了什么字符串。把这些日志和目标App的请求参数一对照签名的输入输出关系就清晰了。3. So层定位与算法还原思路3.1 用IDA打开So库找函数确认了Java层入口下一步就是把So库拉出来用IDA分析。目标App的So库一般会在安装包的lib/armeabi-v7a或lib/arm64-v8a目录下挑出包含SigHelper相关JNI绑定的那个库直接丢进IDA。打开IDA后先看导出表Exports搜sig、sign、hash这类关键词。大多数情况下你不会看到一个叫Java_com_..._getSig的导出函数因为新版本App会故意隐藏JNI导出符号。这时需要回到Frida日志里拿到的函数地址或者用hook_so的方式在So加载后按偏移找到目标函数。找到函数之后F5反编译成伪代码算法雏形基本就出来了。举个常见结构jstring Java_com_kuaishou_android_security_SigHelper_getSig( JNIEnv *env, jobject thiz, jstring url, jstring params) { char *curl rest/n/feed/list; char *cparams k123page1; char input[512] {0}; strcat(input, curl); strcat(input, cparams); strcat(input, salt); // salt 是编译进So里的常量 char output[33] {0}; md5(input, output); return (*env)-NewStringUTF(env, output); }当然这只是我简化后的骨架真实So库的逻辑会掺杂很多混淆代码、虚假分支、指令替换。但万变不离其宗核心流程就三步拿输入、拼字符串、做摘要。3.2 签名算法常见的几种套路根据我实验下来商用App的签名算法可以归纳为这几类纯MD5型把所有query参数按字典序排序加上盐值直接MD5。优点是快缺点是一眼就能识别。HMAC-SHA型用固定Key对参数字符串做HMAC-SHA256/HMAC-SHA1。比MD5安全一些至少肉眼看不出来怎么拼的。多次哈希型先MD5得到一份摘要再用这份摘要拼上另一个Salt做SHA256或者倒序再算一次。难度升级但只要耐心点也能还原。自定义混淆型在哈希前加替换表、字节反转、补位等操作甚至直接用RC4/AES加密后再转Hex。这种最费时间需要逐行核对伪代码。回到sig和__NS_sig3根据我在v8.x版本上实测和对照网上已公开的信息sig走的是复杂一点的MD5多重拼接型__NS_sig3则是在sig基础上增加了设备ID和用户ID的参与并改用了HMAC-SHA256。3.3 重点关注的反调试与混淆对抗在分析So库时会遇到两种常见干扰方式第一种是反调试。App检测到调试器或Frida附加会直接让签名函数返回错误结果。表现形式很迷惑你在静态分析里看到的算法和动态执行的结果对不上。解决思路是先用反Frida检测插件跳过检测点再执行动态Trace。第二种是字符串混淆。So库里的盐值、密钥通常不会明文存放而是拆成几段运行时再拼接还原。你在IDA里搜不到完整salt只能看到char a1[]a1b2这样的碎片。这时用Frida Hook住内存中的字符串拼接函数比如strcat、strcpy在运行时把拼好的完整盐值打出来效率比在IDA里苦苦追踪高得多。4. 算法还原与代码级实现4.1 先还原sig的流程我在分析时用Frida打印了getSig的Java层参数和返回值。某次请求的入参如下url /rest/n/feed/list params k100146page1pullType2 返回 f8f1d5cb22dd895b12b0d4d9df0a9f2f自然要先猜这个MD5结果是把哪些字段拼起来算出来的。最简单的验证方法是拿你抓包URL里所有可见参数与已知盐值做拼接尝试。我在v8.x分析过程中发现sig的拼接顺序是sig MD5( url路径 ? 字典序排列后的query参数 salt )注意这里的query参数其实不包含sig和__NS_sig3而且排序用的是字典序不是原来的请求顺序。这个细节非常容易踩坑。我先把算法用Python还原出来import hashlib import urllib.parse def build_sig(url_path: str, raw_query: str, salt: str) - str: # 去掉 sig 和 __NS_sig3 参与后的自身循环 params urllib.parse.parse_qsl(raw_query, keep_blank_valuesTrue) filtered [(k, v) for k, v in params if k not in (sig, __NS_sig3)] # 字典序排序 filtered.sort(keylambda x: x[0]) query_str urllib.parse.urlencode(filtered) raw_string f{url_path}?{query_str}{salt} return hashlib.md5(raw_string.encode(utf-8)).hexdigest() # 实测验证 print(build_sig(/rest/n/feed/list, k100146page1pullType2, your_salt_here))我用这份代码对抓包样本测试时发现大部分样本能对上前几位但偶发后段不匹配。排查后才明白query里如果有中文或特殊字符客户端用的是UTF-8编码并且不做URL转义而Python的urlencode默认会转成%xx格式。所以在这个环节要去做一次“先取原始字符串拼接再做摘要”的处理而不是依赖现成库的转码。4.2 再拆__NS_sig3的生成相比sig__NS_sig3明显更“重”。我通过动态Hook 静态分析结合确认它的生成流程为取设备维度信息比如did、deviceId、appId、os版本取用户维度信息登入状态下的userId、session_token的Hash取请求维度信息请求路径、query参数、请求体摘要、时间戳秒级或毫秒级把以上内容拼接成一个JSON结构或特定顺序的字符串用So库内置的Secret Key执行HMAC-SHA256结果是Base64或Hex视参数名的“长相”而定。我建议你把__NS_sig3的还原拆分两步。第一步先确保拿到的静态输入变量和App完全一致。这个可以通过Hook传入源来确认比如userId到底是字符串0还是登录后的123、did有没有做截断处理。第二步才是去复现HMAC-SHA256的Key。下面是我用Python复现__NS_sig3时的一个轮廓import hmac import hashlib import json import time def build_ns_sig3(url_path: str, query: str, did: str, user_id: str, secret: bytes) - str: ts str(int(time.time() * 1000)) payload { url: url_path, query: query, did: did, userId: user_id, timestamp: ts, } # 固定顺序转为紧凑字符串 raw_str json.dumps(payload, separators(,, :), sort_keysTrue) digest hmac.new(secret, raw_str.encode(utf-8), hashlib.sha256).digest() return digest.hex() # 实际的Key不是明文需要从So里动态获取 print(build_ns_sig3(/rest/n/feed/list, k100146, 1234, 5678, bsecret_key_here))这里要特别解释一下secret是从So库中动态提取出来的而不是源代码里能看到的一个常量。我一般是在Frida里Hook住HMAX初始化的上下文观察HMAC_CTX的Key区域抓一段内存dump出来再转成字节串。这个方案比静态分析省力得多。4.3 时间戳与随机数的一致性__NS_sig3里大概率包含时间戳这意味着签名是有时效性的。如果你在分析和验证时App客户端本地和服务端有网络延迟或测试机和Charles所在电脑时间偏差过大签名会失效。在还原过程中我建议在Hook层直接打印System.currentTimeMillis()的调用点找到签名函数使用的准确时间值并把它一起拼进验证脚本这样就能规避时间差带来的干扰。另一个思路是在测试机上与电脑同步时间或者直接用NTP校准减少这类变量。注意某些版本的__NS_sig3不只依赖当前时间戳还依赖一个“会话内随机数”。这随机数是在App启动时生成的存到了SharedPreferences或文件里。你要从客户端本地文件把它读出来才能在离线环境下重算签名。5. 常见问题与排查技巧实录5.1 哈希值对不上却在中间环节正确这个问题非常常见。我遇到过很多次脚本算出的MD5和抓包里的sig前8位一致后24位不对。这种多半是Usr编码或排序的锅。排查顺序确认是否去掉参与签名的自身参数确认参数排序是否为字典序而非原始顺序确认url路径是否带前导斜杠确认是否存在“先URL解码再拼接”的情况确认号的编码。query里的空格和在不同系统里有不同解析方式Python的parse_qsl会把转成空格但Java客户端可能保留原字符。按这个顺序排查大多数哈希不一致的问题都能定位。5.2 多So库版本导致算法漂移目标App有v8.x多个小版本同一个签名函数在不同版本里的实现可能不一样。比如v8.0.1的sig用的是单次MD5v8.2.3就换成了“MD5后再Base64”。如果你手里的包和线上版本不一致静态分析结果自然对不上。我的建议是分析前先确认App版本号并在Frida脚本里记录版本信息。如果后续线上版本更新旧的签名计算方式可能失效此时不需要推翻重来只需要对比新旧版本So库的diff定位变化点即可。5.3 Frida附加后签名结果变化这不是算法问题而是反调试机制触发。目标App检测到Frida附加到进程后会让签名函数内部走一条“假分支”返回一个伪造的签名。你静态分析时看到的真逻辑在运行时根本没执行。面对这种场景推荐两种应对方式使用定制版Frida并在启动阶段hook掉反调试检测函数跳过检查点在So库加载完成后立即对所有JNI函数入口打上Inline Hook绕开可能存在的定时检查机制。如果反调试检测点覆盖得很深我会考虑改用Unidbg。Unidbg能在PC上直接模拟执行So库代码完全不依赖真实App环境绕开大部分反调试检测而且方便打日志。5.4 常见错误速查表现象可能原因解决方向sig永远差最后几位参数排序不对或salt多拼了字段用Frida打印完整拼接串去对比__NS_sig3报校验失败时间戳过期或随机数变化Hook时间函数锁定时间戳Frida附加后签名不同反调试导致走假分支绕过反调试或改用Unidbg中文参数值乱码编码方式不一致确认是UTF-8原始编码还是URL编码相同参数每次签名不同参与计算里有随机设备ID或会话ID找到随机数来源并固定它突然全部签名失效App版本更新算法变更重新拉取新版So库做差异对比这张表是我实际调试中最高频遇到的六个问题。如果你也卡在这些地方按表格里的解决方向走一遍通常能省下半天排查时间。6. 签名计算过程的动态验证6.1 用Frida写一个最小验证脚本算法还原到一定程度后不能只靠拍脑袋。我习惯写一个最小Frida脚本直接hook签名函数把“入参 出参”全部打出来再和手动计算的摘要做交叉验证。下面是一个基础模板// frida -U -f com.example.app -l sig_hook.js Java.perform(function () { var SigHelper Java.use(com.kuaishou.android.security.SigHelper); SigHelper.getSig.implementation function (url, params) { var result this.getSig(url, params); console.log([url] url); console.log([params] params); console.log([sig] result); return result; }; var SigHelper2 Java.use(com.kuaishou.android.security.SigHelper); SigHelper2.getNSsig3.implementation function (url, params, did, userId) { var result this.getNSsig3(url, params, did, userId); console.log([url] url); console.log([params] params); console.log([did] did); console.log([userId] userId); console.log([__NS_sig3] result); return result; }; });这个脚本的价值不只是打印日志。当你反复改动入参时能看到签名结果的变化趋势从而推断参与计算的范围大小。比如只改userId签名结果就变了说明userId参与计算只改did结果没变说明这个版本的did可能没参与。6.2 离线复算链路当你已经从So库里提取到盐值、密钥并确定好拼接顺序后就可以建一个离线复算链路了。链路结构大致是输入抓包得到的原始URL、请求头、设备信息、时间戳预处理按App端规则提取路径、参数、排序、编码摘要计算用Python/Node/PHP任选一种你顺手的语言复现MD5/HMAC-SHA256输出对比把计算结果和抓包里的签名比对一致则链路验证通过。我建议这个过程分阶段做先只对一两条请求做复算跑通了再扩展到批量样本。因为批量验证时一旦出现失败你会分不清是算法问题还是样本本身的问题。6.3 自动化回归样本集算法还原不是一次性工作。App每次更新都可能微调签名逻辑所以我会维护一个“黄金样本集”从不同页面、不同用户状态抓取几百个请求记录原始入参和期望出参。每次App升级后把这些样本跑一遍就能快速判断签名算法是否变化、变化出在哪个环节。样本集以JSON格式存储字段结构类似{ url: /rest/n/feed/list, query: k100146page1pullType2, did: a1b2c3d4, userId: 0, timestamp: 1600000000, expected_sig: f8f1d5cb22dd895b12b0d4d9df0a9f2f, expected_sig3: abcdef1234567890abcdef1234567890 }有了这份样本集你还能顺手验证本地脚本的幂等性——同一份输入跑一百次是否得到同一份结果。这对排查随机数参与签名的情况特别有效。7. 采坑心得与最后建议7.1 别迷信单一工具我在整个分析过程里用过IDA、Ghidra、Frida、Unidbg各种工具轮番上阵。实际体验是没有哪个工具能解决所有问题。IDA的反编译结果清晰但新版本App会加入OLLVM混淆这时候伪代码的可读性大幅下降不如动态Trace实在。Ghidra免费且支持脚本适合批量改函数名但它的反编译器在部分ARM64指令上会“翻车”。Frida动态调试灵活但容易触发反调试。Unidbg离线模拟稳定但无法覆盖所有系统调用部分So库初始化时会卡住。所以我的建议是静态分析用IDA把大体流程看明白动态分析用Frida做关键点确认遇到反调试死磕时切Unidbg离线跑。多工具交叉验证才能减少漏判和误判。7.2 关注签名参与字段的最小集很多时候问题不在于签名的摘要算法有多复杂而在于参与签名的字段范围不对。用控制变量法把字段逐个加进来测你会找到一个“最小参与字段集”。只包含这个集合里的字段时签名能对上多加一个或少一个都会错。这个集合不仅能帮你精确定位算法还能帮你减少后续构造请求时的体力消耗。7.3 给后来者的一句话研究客户端签名本质上是在研究“如何证明你是一个真实客户端”。这个过程考验的是耐心和系统性思维而不是某一条捷径。我个人在分析完sig和__NS_sig3后最大的体会是先把输入输出关系完全搞清楚再谈算法细节。很多人一上来就盯So库跳转忽视了对请求日志和参数差别的对比结果绕了一整天还在原地打转。如果你也能静下心从抓包开始一步步把变量控制住哪怕没有现成脚本也能把签名逻辑还原出来。希望这篇整理能帮你少踩几个我踩过的坑后面的路就顺畅多了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门