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

资讯详情

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

动态签名参数逆向解析实战:从抓包拆解到签名规则复现

动态签名参数逆向解析实战:从抓包拆解到签名规则复现 1. 项目概述这次想聊的是一个我印象挺深的实战项目自建API服务时当客户端需要携带一组动态签名参数请求后端而这组参数每次会话都会刷新、算法又不透明如果服务端要联调、排障、复现异常就只能对着抓到的请求做一套“反向拆解”——也就是从入参、出参和时间戳的关系里倒推签名生成规则。一提到“逆向解析”很多人第一反应是搞灰产、绕风控。其实不是。正经的从业者在做接口兼容、老系统迁移、外包系统交接、或者是自己写SDK对接别人不公开的签名逻辑时都经常需要干这件事。只是市面上聊这个话题的文章要么偏黑产攻防要么讲得太浅。我今天想用一次真实项目经历把“动态参数与签名校验”这条链路的拆解思路、判断方法、实操步骤和遇到的坑完整写下来给同样在做接口对接、API兼容、安全联调的朋友一个参考。这篇文章会基于我实际处理过的一个案例客户端每次请求都会携带两个动态签名请求头服务端用它们做身份与完整性校验。我当时的任务不是绕过去而是把这套规则的逻辑理清楚并写出一套能在测试环境里稳定复现请求的工具脚本供QA和研发联调使用。如果你正在做第三方API对接、老系统接口兼容或者只是对“请求签名到底怎么设计的”感到好奇这篇应该对你有用。我会把思路、抓包方式、参数分析、暴力枚举策略、以及验证签名的完整过程都讲一遍最后附上我踩过的几个坑。2. 动态签名参数的“是什么”与“为什么”2.1 动态参数到底是什么先解释一下标题里提到的这类参数。在很多API系统里服务端为了确认“这个请求确实是合法客户端发出来的”而不是有人拿抓包工具手工构造的会给每个请求附加几个动态变化的参数。它们通常放在HTTP Header里也可能放在URL Query或者请求体里每次请求的值都不一样。我用过的几个典型动态参数包括时间戳类参数比如x-timestamp表示请求生成时间服务端用它判断请求是否过期。随机数类参数比如x-nonce每次请求生成一个随机字符串防止重放攻击。签名类参数比如x-sign通常是时间戳、随机数、请求路径、请求体等信息拼接后做哈希或加密的结果。而我这次处理的案例里出现了两个动态请求头。一个叫x-sap-Ri一个叫x-sap-Sec。前者看起来是一个随机会话标识后者是一个签名摘要。两者配合使用服务端既能识别“这次请求来自哪个会话”又能验证“这个会话发出的请求内容有没有被篡改”。为了便于行文下面我统一把这套机制叫“动态签名参数体系”重点讲它的拆解方法而不是特定平台内部实现。2.2 为什么需要动态签名参数可能有人会问我就做个普通接口直接校验Token不就行了吗为什么非要搞一套动态签名原因主要有三个。第一防重放。如果接口只校验一个静态Token那攻击者把请求原样重发一遍服务端分不清是合法用户操作还是攻击者重放。加了动态参数之后每个请求都有独一无二的标识重放请求会因为标识重复或过期被拒绝。第二防篡改。如果只有Token中间人把请求体里的金额从100改成10000服务端是察觉不到的。签名类参数会把请求内容计算进去请求体改了一个字节签名就对不上服务端直接拒绝。第三提升抓包成本。手工构造一个合法请求需要同时知道签名算法和会话状态这比单纯复制Token难得多。虽然防不了真正的高手但能把大多数“只会用工具改包”的人挡在门外。我处理过的这个案例就是这样请求头里除了常规的Authorization之外还必须带上x-sap-Ri和x-sap-Sec两个参数。缺一个、错一个服务端直接返回401或403。2.3 “逆向解析”这里的真实含义先说明一个概念问题。很多人一听“逆向解析”就想到反编译二进制、破解加密算法。实际上在我们做API开发对接的场景里所谓逆向解析更多时候指的是在已知输入请求参数和输出签名值的前提下通过观察、假设、验证的方式推断出签名规则的生成逻辑。这个过程不涉及任何绕开安全机制的意图只是为了实现“别的系统能正常调用这个接口”的合法需求。比如服务端SDK没开源、文档不全或者你要写一套自动化测试脚本就必须把签名逻辑“从外部看透”。它不是二进制逆向而是一种“灰盒分析”。核心方法是大量抓取样本带着假设去控制变量用代码批量验证。3. 切入实战前的准备工作3.1 关键工具清单做这类分析工具不需要多高大上关键是趁手。我这次用的组合是这样的工具用途说明Charles / Fiddler抓包抓取HTTPS请求并解密查看Postman请求复现手动修改参数、快速验证假设Python requests批量验证写脚本枚举参数、模拟签名计算Wireshark底层包分析个别情况下确认请求是否被代理处理Notepad / VS Code数据整理对比不同请求之间的差异如果你是在Linux服务器上做分析可以用mitmproxy替代Charles命令行界面也挺好用。Windows则推荐Fiddler配置代理方便。3.2 抓包环境的搭建要点这里有一个很重要的经验正式分析之前先把抓包环境搭稳否则后面所有结论都可能建立在错误数据上。以Charles为例正确的配置步骤安装并开启SSL Proxying配置好根证书。在手机或测试客户端上设置代理指向Charles所在机器的IP端口8888。用测试账号登录客户端确认能正常浏览数据验证抓包是否生效。找到目标API请求右键保存为Har文件作为后续分析的样本库。我踩过的坑是在真机上面抓包时由于部分App做了证书固定明明代理配好了请求还是走不通。后来换了一个低版本测试客户端才解决。如果你遇到同样的问题建议准备一个旧版本的客户端备用这是最省事的办法。抓包样本的收集我建议至少保留50条以上有效请求数据。为什么呢因为后续做“控制变量对比”时需要足够多的样本。如果只有三五个请求很多规律根本看不出来。4. 核心思路拆解怎么从“一头雾水”到“清晰可复现”4.1 先观察后假设再验证我一直觉得动态参数拆解这件事本质上跟做科学实验没有区别。流程是固定的观察现象、提出假设、设计实验、验证假设、得出结论。拿到那批抓包样本后我做的第一件事不是急着算签名而是把所有请求的参数整理成一张表格列在Excel里逐项对比。表格大致长这样请求序号x-sap-Rix-sap-Sec请求路径时间戳字段请求体摘要17f3a2b...91c2e8.../api/order/list1700000000...27f3a2b...84d5f1.../api/order/list1700000006...3c91f47...52b8aa.../api/order/detail1700000012...这一步的产出是“变量清单”哪些参数在变、哪些不变、变化有没有规律可循。然后我注意到两个重要现象x-sap-Ri的值在同一个登录会话内保持不变在不同会话之间变化。x-sap-Sec的值每次请求都不同而且长度固定32位看起来像MD5值。基于这两个现象我提出了三个初步假设假设Ax-sap-Ri是后端下发的会话标识客户端每次请求带着它。假设Bx-sap-Sec是对x-sap-Ri、请求路径、时间戳、请求体等字段拼接后做的MD5值。假设Cx-sap-Sec的生成还可能加入了一个固定的盐值secret salt。提出假设之后就是针对性地验证。4.2 控制变量法把不需要的“噪音”先去掉这个过程一定要时刻提醒自己不要贪多求快一次只验证一个变量。比如第一个实验验证x-sap-Sec是否只跟x-sap-Ri、请求路径和请求体有关与客服端版本、设备型号无关。做法是用同一个会话连续发送两个内容完全相同的请求观察x-sap-Sec是否相同。如果相同说明签名与时间戳无关如果不同说明加入了时间戳或随机因子。我当时连续发了两个完全相同的请求发现x-sap-Sec的值是不同的。这个实验直接推翻了一部分假设把搜索范围缩小到了“签名中必然包含时间戳或随机数”。接着我又做了一组对比实验固定会话、固定请求路径只改请求体里的一个字段值观察签名值变化。结果签名值完全不同。这说明请求体内容确实参与了签名计算。经过四五组这样的控制变量实验我基本可以确定x-sap-Sec至少包含x-sap-Ri、请求路径、请求体摘要、时间戳/随机数中的某几项。这里就进入“猜测拼接规则”的阶段。4.3 从结果倒推拼接规则这一步是整个过程中最有意思的部分也是真正体现“解析”价值的部分。先假设x-sap-Sec MD5(part1 part2 part3)然后不断尝试不同的拼接组合和分隔符与真实签名值比对。具体做法是写一个枚举脚本把从抓包里提取到的字段值做各种拼接和哈希运算然后逐一比对。我平时写的验证脚本是用Python。代码核心逻辑大概长这样import hashlib import json # 从抓包数据中提取的字段 ri_value 7f3a2b... path /api/order/list timestamp 1700000000 body_md5 hashlib.md5({page:1}.encode()).hexdigest() # 待验证的拼接规则列表 candidates [ f{ri_value}{path}{timestamp}, f{ri_value}{path}{body_md5}, f{path}{ri_value}{timestamp}, f{ri_value}{path}{timestamp}{body_md5}, f{path}{body_md5}{timestamp}{ri_value}, ] target 91c2e8... # 从抓包中拿到的真实签名 for c in candidates: m hashlib.md5(c.encode()).hexdigest() if m target: print(命中规则:, c) break else: print(本轮无命中继续尝试其他规则)最开始几次运行全都无命中。于是我开始试更复杂的拼接方式把请求体去掉、加盐、用SHA1而不是MD5、甚至两层哈希。最后命中的规则是x-sap-Sec MD5(x-sap-Ri 请求路径 时间戳 请求体摘要 固定盐值)。这里的“固定盐值”是客户端代码里写死的字符串不是服务端下发的。如果不做枚举尝试很难猜出来。而一旦知道了盐值整个签名规则就完全透明了。4.4 验证“命中规则”的可靠性找到一个命中规则不代表大功告成。只有这个规则能解释“所有抓包样本”才算真正解析成功。我会拿之前保存的50多条请求记录全部跑一遍用脚本重新计算每个样本的签名值跟原始值对比。这一步非常关键因为有时候一条样本命中只是巧合必须用全部样本做回归验证。我采用的是这样的逻辑import hashlib import json def calc_sec(ri_value, path, timestamp, body_str, saltFIXED_SALT): body_md5 hashlib.md5(body_str.encode()).hexdigest() raw f{ri_value}{path}{timestamp}{body_md5}{salt} return hashlib.md5(raw.encode()).hexdigest() # 遍历所有样本 with open(samples.json, r) as f: samples json.load(f) ok_cnt 0 for s in samples: calc calc_sec(s[ri], s[path], s[ts], s[body], s[salt]) if calc s[sec]: ok_cnt 1 print(f验证通过: {ok_cnt}/{len(samples)})这轮验证我用全量样本跑了一遍最终通过率是100%。到这一步我才敢说这套签名参数被“解析”出来了。5. 实操过程全记录从0到1复现完整链路5.1 样本收集与数据清洗先讲样本。我在测试环境用测试账号登录客户端连续点击了十几个功能页面让Charles抓下几十条请求。然后按请求类型过滤出目标API比如订单列表、订单详情、提交订单等导出成JSON格式方便脚本读取。数据清洗这一步容易被忽略但特别重要。抓包数据里会有大量冗余字段比如User-Agent、Accept-Language、设备指纹等如果不先过滤掉后续对比时会干扰判断。我习惯的做法是只保留与签名相关的字段——x-sap-Ri、x-sap-Sec、请求路径、请求方式、请求体内容、请求时间从抓包记录里读到的时间戳。清洗完的样本长这样[ { ri: 7f3a2b..., sec: 91c2e8..., path: /api/order/list, method: GET, timestamp: 1700000000, body: }, { ri: c91f47..., sec: 52b8aa..., path: /api/order/detail?id123456, method: GET, timestamp: 1700000012, body: } ]你可能注意到GET请求的“请求体”是空的。这种情况下请求参数是放在URL Query里的签名时究竟用的是完整URL还是去掉Query后的路径这本身又是一个需要验证的小变量。我当时的结论是签名只用了“路径部分”Query里的参数不参与签名计算。但是请求体POST请求里如果有内容就必须参与计算。这个结论是通过“修改Query字段但签名不变”和“修改Body字段签名马上变”两组实验对照得到的。5.2 会话标识与时间戳的关联分析现在专门说说x-sap-Ri这个值。最初我以为它就是个随机字符串没什么好分析的。后来我把多个会话的x-sap-Ri放在一起看发现它居然不是完全随机的而是有内部结构的。比如在某次测试中同一个会话的所有请求x-sap-Ri的起始几位保持不变后面几位变化。如果把会话登出再登入整个值就完全变了。这意味着它大概率是一个“服务端下发的会话令牌”可能由“会话ID 客户端随机数”拼接而成。这个判断对后续写复现脚本很关键要在测试环境构造一个合法请求必须先通过登录接口拿到服务端返回的会话信息再从中提取/拼装出x-sap-Ri。好在登录接口也有正常的业务逻辑可走不需要额外破解。实际编码时我会把“登录获取会话”和“构造请求头”做成两个函数分别封装。import requests session requests.Session() # 1. 登录获取会话 def login(username, password): payload {username: username, password: password} resp session.post(https://api.example.com/api/auth/login, jsonpayload) data resp.json() return data[session_id] # 2. 构造请求头 def build_headers(session_id, path, timestamp, body): body_md5 hashlib.md5(body.encode()).hexdigest() # 实际盐值在解析后才知道这里先用占位符 raw f{session_id}{path}{timestamp}{body_md5}SALT_PLACEHOLDER sec hashlib.md5(raw.encode()).hexdigest() return { x-sap-Ri: session_id, x-sap-Sec: sec, }写完这两个函数整个复现链路基本就通了登录拿x-sap-Ri算x-sap-Sec带上去请求目标接口服务端正常返回数据。5.3 签名计算的枚举脚本详解签名计算是整套解析的核心。这里把我实际用过的枚举脚本稍微讲详细一点因为很多朋友卡在这一步。在设计枚举脚本时我考虑了以下几类拼接变量拼接顺序会话标识在前、路径在前、时间戳在前等一共有6种排列组合。是否包含请求体分为“包含Body”和“不包含Body”两种情况。哈希算法MD5、SHA1、SHA256等。是否大小写转换计算结果转大写、转小写、保持默认。是否加盐可能会有固定盐值或用户维度的盐值。分隔符空字符串、下划线、竖线、冒号等。把这些组合全部跑一遍也就是几万次的枚举在Python里瞬间就能算完。脚本的关键设计是把所有候选规则都以“模板”的形式组织好避免写成散乱的if-else。from itertools import product def base_combinations(ri, path, ts, body_md5): parts { ri: ri, path: path, ts: ts, body_md5: body_md5, } orders [ [ri, path, ts, body_md5], [ri, path, body_md5, ts], [path, ri, ts, body_md5], [path, ri, body_md5, ts], [ts, path, ri, body_md5], [ts, path, body_md5, ri], ] for order in orders: yield .join(parts[k] for k in order)然后外层套一个“哈希函数选择”和“分隔符选择”的双重循环跑出所有组合。值得注意的是最终命中的规则里拼接用的不是空字符串而是账号体系里一个固定的字段值作为盐这个盐值恰好是业务里常见的固定常量。如果没有做过类似的拆解可能很难想到去尝试这种“业务常量参与签名”的方式。5.4 模拟请求验证脚本跑通的那一刻当枚举脚本命中第一条规则时我心里其实还没有十分把握因为可能只是这条样本运气好。我当时又做了一件事用脚本自动把抓包样本里的全部请求重放一遍——重新计算签名实际发起请求看服务端是否返回正常业务数据。这一步跟“计算值比对”不同它是端到端的真实环境验证。如果服务端返回200和业务数据说明解析结果完全可用如果返回401/403说明规则虽然碰上了样本值但时间窗口或其他因素不匹配。我跑通的那一刻请求返回了正常的订单列表JSON能确切感受到整套链路已经打通。后来我把这套逻辑写成了一个小工具输入账号密码就能自动登录、自动生成签名、自动请求接口QA那边直接拿它做数据准备用省了不少功夫。6. 常见问题与排查技巧实录6.1 签名一直不匹配的排查步骤在解析过程中遇到过很多次“明明觉得规则对了算出来就是跟抓包值不一致”的情况。根据我的经验90%的问题出在以下几个地方。先检查时间戳格式。有的接口用的是秒级时间戳有的是毫秒级有的甚至是带时区偏移的ISO时间字符串。差一个数量级结果就完全不同。所以要确认签名里的timestamp到底取自哪里。再检查请求体的序列化方式。同样一个请求体{page:1}和{page: 1}带空格的区别会直接影响MD5结果。最简单的办法是直接“原封不动”地拷贝抓包里看到的Body字符串不要去手动格式化。还有需要注意的是URL编码。如果签名规则里包含了请求路径或Query参数URL编码的差异可能导致签名不一致。比如中文参数会被编码成%E4%B8%AD空格会变成%20或这些看起来无所谓的小差别都会让最终签名值面目全非。排查时的建议操作顺序先确认时间戳来源和格式。再确认Body字符串是否与抓包完全一致。然后确认路径里是否包含Query参数。最后检查是否有隐藏的换行符或空格。按照这个顺序大部分问题都能快速定位。6.2 时间戳、请求体序列化这些隐蔽的坑这套动态签名参数解析里最隐蔽的坑不在签名算法本身而在于“数据在传输前的真实形态”。我之前有一次怎么都匹配不上后来发现客户端发送POST请求时虽然Content-Type是application/json但实际发送的字符串里多了一个换行符\n。而这个换行符在Charles的展示界面里根本看不出来只有用“Raw”视图查看原始请求时才能发现。这种细节如果不仔细能让人浪费一整天。第二个隐蔽的坑是客户端在构造签名时用的可能是“排序后的参数名拼接值”而不是请求体原文。比如POST的Body里有username和password两个字段客户端可能先把字段按字母序排列password、username再拼接值参与签名。这意味着即使请求体原文长一个样签名计算的输入也可能不同。遇到这类问题时我的独门技巧是不要只盯着一份请求看去对比“同一接口在不同参数顺序下的签名值”如果两个请求Body内容完全一样但签名值不同那就要怀疑“签名用的是我们看到的原文还是内部重新排序后的字符串”。还有一个值得提醒的点不要在移动端设备上直接分析加密请求最好先在PC端Web页面操作一遍看看有没有不带签名的老接口。很多业务系统出于兼容考虑Web端和移动端往往共用一套核心API但Web端的签名强度可能更弱、逻辑更简单拆解起来省力很多。6.3 应对“签名算法升级”的兼容策略解析工作做完了不代表一劳永逸。真正项目上线后还可能遇到签名算法升级的情况——客户端版本一更新签名规则说变就变。我遇到过的一种情况是老版本的签名算法是简单的MD5拼接新版本改成了“先AES加密再BASE64输出”。脚本里基于MD5的全部规则立刻失效。应对策略是在工具设计之初就把“签名算法”做成可插拔模式。每个版本对应一个独立的处理器类统一接口、统一输入输出这样新版本上线时只需要写一个新的处理器注册进去老版本继续沿用旧处理器。伪代码示例class SignHandlerV1: def sign(self, params): return md5_concat(params) class SignHandlerV2: def sign(self, params): return aes_then_base64(params) handlers { v1: SignHandlerV1(), v2: SignHandlerV2(), } def generate_sign(version, params): return handlers[version].sign(params)这样设计以后即使未来出现第三版、第四版算法改动成本都极低。这套思路跟写业务代码时“面向接口编程”一个道理不是炫技是真的能省以后的时间。7. 写在最后的几点经验心得动态签名参数的“逆向解析”这件事听起来好像很神秘实际做下来核心还是四个字控制变量。先观察、再假设、再验证一步一步缩小范围最后用全量样本回归确认。整个过程不需要特别高深的技术更需要的是耐心、细心和对HTTP协议细节的熟悉程度。我个人在实际操作中体会最深的一点就是抓包数据里看起来毫不起眼的字段往往就是破解的关键。比如那个最终命中的固定盐值最初我根本没有想到它存在于任何业务字段中。如果不是反复实验、反复枚举单靠肉眼观察可能永远发现不了。关于工具链我建议每一位做接口开发或者测试的朋友都提前把抓包工具用熟练尤其是“Raw视图”和“Har导出”这两个功能。真正到了联调排障的现场对HTTP请求原始形态的敏感度往往决定你解决问题的速度。如果你正在做类似的事情最后再分享一个小技巧拿抓包工具记录请求时尽量把“客户端发起请求的时刻”也记录准确。时钟偏移、抓包展示的时间与服务端记录的时间不一致是签名验证时报错的高频原因。只要把时间基准统一了很多问题会轻松很多。这个内容后续还可以这样扩展把解析出来的签名规则做成一个线上接口的模拟服务供前端联调用或者在自动化测试框架里集成一套单独的工具包随时校验线上签名参数是否符合预期规则。这些都是不难但很实用的小延伸留着以后慢慢写吧。
返回列表