
前阵子拿到一套很有意思的安全赛题代号叫“wsgsig secdd-challenge”。这套题目表面上是给某出行平台的内部安全挑战赛准备的实际上拆开来看它把真实业务里最常见的三类问题——签名算法被逆向、加密参数被绕过、请求重放——全都揉到了一起。我花了两天时间完整走了一遍从黑盒分析到最终构造合法请求拿结果的流程踩了不少坑也沉淀出一些通用打法。这篇文章就把整个拆解过程、关键原理、实操步骤和排查经验完整记录下来希望能给正在研究接口安全、签名校验或者准备类似CTF/实战演练的同学一些参考。1. 赛题概况与整体思路拆解1.1 赛题背景与核心需求先说这个题目的结构。整套环境模拟的是一个带有签名校验机制的Web服务客户端向服务端发起请求时需要携带一组由特定算法计算出的签名参数服务端收到请求后会重新计算签名做比对不一致就直接拒绝。题目名称里“wsgsig”可以理解为某种Web服务签名机制的代号“secdd”则指向安全防护与加解密相关的场景。这种设计在真实业务里太常见了。现在但凡有点规模的系统对外暴露的接口基本都不会裸奔——要么有签名校验要么有加密传输要么两者都有。签名的作用是保证请求参数在传输过程中没有被篡改加密的作用是保证数据内容不会直接被第三方读取。这套题目等于把这两个问题塞进了一个可实操的环境里让我能在“合法请求长什么样、签名怎么算的、加密怎么解开的”这几个问题上反复验证自己的判断。我在拿到题面之后的第一反应不是直接去看代码或者跑扫描器而是先想清楚这题目究竟想考什么。答案其实很明确签名机制的分析能力和构造合法请求的能力。前者考验你能不能从黑盒或者带部分信息的灰盒环境中还原出签名的生成逻辑后者考验你能不能把改过的参数、新的时间戳、正确的签名组合成一次能被服务端接受的请求。从攻防两端的角度来看这套能力在真实业务里就是做接口安全测试的底层基本功。1.2 为什么这类签名校验题目值得花时间深挖我遇到过不少人觉得签名校验就是加个密、算个哈希没什么技术含量。但真正落到实操层面你会发现它牵扯到的问题远比想象中复杂。首先是签名不等于加密。签名解决的是“参数是否被篡改”的问题加密解决的是“内容是否被偷看”的问题。很多人在分析时会在这两个概念上绕晕导致走了弯路。比如有些题目表面上给你一堆密文实际上签名用的只是拼接后的一段明文参数加密只是用来传输对称密钥的壳。其次是签名算法的可还原性。业界常用的签名方案无外乎几种MD5家族、HMAC-SHA系列、以及基于非对称加密的数字签名。每种方案的逆向难度不同。MD5拼接参数的签名很容易通过输入输出对比猜出规则HMAC要是有固定的密钥还行如果密钥是从服务端动态下发的就得先解决取密钥的问题RSA/ECDSA这类非对称签名则会直接给逆向者出难题。这套题目把几种可能性都覆盖了一部分正好用来系统练习。再就是时间戳与随机数的对抗。为了防止重放攻击签名中基本都会包含时间戳甚至随机数。这意味着你就算完全逆向出了签名算法如果请求里的时间戳是旧值签名照样过不了。这个细节卡住了很多半途而废的人也是后面我要重点讲的部分。1.3 整体解题思路地图我自己的解题流程分成五个阶段这张图即使换一道类似题目也完全通用环境与信息收集部署好本地环境确认目标地址、接口列表、请求样例拿到一份含签名的合法请求作为分析样本。定位校验逻辑通过修改单个参数观察响应差异找出哪个部分是签名校验、哪个部分是加密字段、哪个部分是防重放的时间戳。逆向签名过程结合已知参数样本和响应反馈推断签名生成规则。必要时抓取加密JS、反编译二进制或者用输入输出对做差分分析。构造合法请求按还原出的算法用新时间戳和新参数重新计算签名组装请求并验证通过。总结与自动化写个脚本把整个签名计算和请求发送流程固化下来方便后续批量测试和复用。后续的章节我会按这个顺序展开重点讲每一步“为什么要这么做”以及“怎么判断自己的方向是对的”。2. 核心机制解析与前置知识储备2.1 签名算法的常见结构与识别技巧在拆解这套题的过程中我对接口签名机制的整体形态做了系统梳理。大多数签名逻辑都可以抽象成三层结构第一层是参与签名的参数集合。有些实现会把所有请求参数都拼进去有些只选择除签名本身以外的固定几项。判断哪些参数参与签名最朴素的方法是把某个参数的值改掉看签名是否还通过。如果通过说明该参数没有参与签名如果不通过说明在签名集合内。第二层是参数的组织方式。常见的组织方式包括按字典序排序后拼接a1b2c3直接按固定顺序拼接成字符串1|2|3拼接后再加上盐值或密钥secretKey queryString嵌套JSON序列化后参与计算第三层是散列或加密算法。这是最容易通过特征判断的部分——MD5是32位十六进制、SHA1是40位十六进制、SHA256是64位十六进制、HMAC在输出长度之外没有明显特征但可以通过源码或行为推断、AES则有填充和初始化向量的问题。如果你想快速判断算法就把同一组参数多提交几次观察签名长度和字符集。64位且为十六进制字符大概率是SHA25632位则大概率是MD5但也存在SHA256截断或者自定义散列的可能。实践中最稳妥的方式还是拿到足够多的输入输出对直接拿常见算法套算对得上就是它。2.2 防重放机制时间戳、nonce与服务端校验签名本身防的是篡改但如果没有防重放措施攻击者拿到一个合法请求后可以无限次重复提交。所以绝大多数签名方案都会加入时间戳或一次性随机数nonce。时间戳的校验逻辑一般是“当前时间戳 - 请求时间戳”的绝对值不能超过某个阈值比如300秒。有些严格的服务会直接把这个阈值设定为60秒甚至更短这种情况下构造请求时必须确保时间偏差在允许范围内。如果你在本地测试系统时间和目标服务器时间相差很大一定要先同步时间。nonce的校验逻辑一般是服务端维护最近一段时间的nonce集合使用过的nonce如果再次出现就拒绝。这种情况比时间戳难处理一些因为你需要保证每次请求的nonce是全新的。好在很多实现里nonce可以直接用UUID或者随机字符串代替。在这套题目里我观察到时间戳和nonce的配对方式是比较典型的签名中拼接时间戳请求参数中附带时间戳服务端先校验时间戳是否新鲜再校验签名是否匹配。也就是说直接拿旧请求改签名的地方是行不通的必须同步更新签名里的时间戳、请求参数里的时间戳再重新计算整个签名。2.3 加密字段的定位与处理思路题目名称中的“secdd”后半段指向加解密场景。在真实的签名机制里有些系统会把敏感字段加密后再参与签名或者对传输内容做整体加密。这类题目的分析顺序我建议是先解密再定位明文参与签名的部分最后重新计算签名。有个原则必须记住密文的可识别性。如果密文是Base64编码的直接解码就能看到二进制结构如果密文是十六进制字符串长度往往是明文的整数倍尤其AES这类分组加密算法。分析时需要关注的不是密文本身而是解密之后得到的明文结构——参与签名的到底是解密后的明文、还是加密前的原始请求体这两个是完全不同的路径。我在这套题里碰到的情况比较直接请求体中的某个字段是经过加密的加密后的结果也参与到了签名计算中。这种做法看起来很安全——改动一个字符都会导致签名失效。但反过来想只要逆向出加密逻辑和签名逻辑构造合法请求反而是顺理成章的事。3. 实操全过程记录从黑盒分析到请求伪造3.1 环境部署与初始信息搜集我习惯在本地跑一套独立环境来复现这类题目这样既能随意断点调试又不会因为服务端策略变化而影响分析。题目提供的环境包含一个后端服务、一个模拟客户端客户端会向服务端发起带签名和加密参数的请求。部署完成后第一步不是急着提交乱七八糟的payload而是先抓一个完整请求出来。操作方式很简单在客户端点击某个功能按钮同时用一个HTTP抓包工具记录请求内容。抓到之后把请求头、请求体原样保存一份作为所有分析的基准样本。这个基准样本里包含了接口路径比如/api/order/create和/api/user/info请求头里的固定字段和动态字段请求体中的明文参数、密文字段和签名字段拿到基准样本后我对请求体做了一个快速拆分。以其中一个典型请求为例它大致长这样{ app_id: test_app, timestamp: 1712332800, nonce: a1b2c3d4e5f6a7b8c9d0e1f2, data: U2FsdGVkX1..., sign: 9f2c4b7e6d1a3f8c0b2e4d6a8f1c3e5b }这里timestamp和nonce很直观data是一串看起来像Base64的密文sign是64位十六进制字符串——和SHA256的输出长度完全吻合。我先做了两个测试。第一个测试是把timestamp改成一个明显过期的时间结果显示签名校验错误说明时间戳参与校验。第二个测试是把data中的任意一个字符改掉同样签名校验错误说明data参与了签名计算。这个结果直接确认了我对参数集合的猜测app_id、timestamp、nonce、data四个字段全部在签名覆盖范围内。3.2 定位签名校验点与区分主校验和辅助校验很多系统会有两层左右的校验逻辑一层是“格式校验”一层是“签名校验”。格式校验做的事情包括参数是否齐全、字段格式是否正确、时间戳是否过期签名校验做的事情则是重新计算签名并与请求中的sign字段做比对。这两层校验失败的响应往往不同可以利用响应差异来区分当前遇到的是哪一层。我提交了一个时间戳过期的请求服务端返回的错误信息是“timestamp expired”。这说明时间戳校验发生在签名校验之前或者时间戳被单独检查。接着我把timestamp设置为当前时间把nonce保持不变其他字段也保持不变再次提交这时候错误信息变成了“sign invalid”。这个结果说明签名比对是在时间戳通过之后才进行的。这个发现很重要意味着构造请求时必须先确保时间戳在有效窗口内再去处理签名问题。还有一个小细节值得提有些系统的时间戳校验不会告诉你“过期”还是“非法”而是把时间校验也塞进签名校验的异常信息里导致你分不清到底是时间问题还是签名问题。解决的办法就是用“控制变量法”——先把时间戳设置为当前时间附近的值排除时间因素再排除其他干扰项。3.3 逆向签名生成过程输入输出对与源码定位签名算法是整个题目的核心。我在初识样本中看到的签名是64位十六进制最像SHA256。现在要验证的就是一件事sign SHA256(某种拼接规则 data值)到底成不成立。验证步骤是这样的从基准样本中拿到app_id、timestamp、nonce、data以及sign。尝试几种常见拼接方式比如app_id timestamp nonce datadata nonce timestamp app_idapp_idxxxtimestampxxxnoncexxxdataxxx按字典序对每种拼接结果计算SHA256哈希与目标签名做比对。前几种常规尝试都失败了。这就说明要么拼接规则里带了额外盐值secret_key要么算法不是SHA256要么拼接格式里有隐藏的分隔符。这时我决定去客户端的代码里找线索。我打开了模拟客户端对应的前端资源目录扫描了半天看到一个与签名相关的脚本模块。这个模块里定义了一个名为generateSignature的函数内部实现是这样的function generateSignature(params, secretKey) { var keys Object.keys(params).sort(); var parts keys.map(function(k) { return k params[k]; }); var queryString parts.join(); var raw queryString key secretKey; return sha256(raw).hex(); }这套逻辑非常清晰参数按键的字母序排列拼成a1b2这种标准查询字符串格式最后在结尾拼上key密钥整体做SHA256转十六进制输出。这说明我之前的失败原因不是拼接规则错而是缺了盐值。只要补上密钥就能算出正确签名。但密钥怎么来的这里藏了一个常见设计漏洞——客户端在初始化时从配置接口获取了这个密钥而脚本中有一段代码把密钥挂载到了全局对象上。我返回到脚本上下文里搜索secretKey的赋值位置很快找到了值sk_live_test_2024_x9k2。这个操作让我想到了真实业务里经常出现的问题密钥写在JavaScript前端代码里、写在Android APK里、写在配置文件中没做混淆。理论上客户端要计算签名必然会在某个环节接触到密钥。密钥放在客户端让计算变得可行同时也让逆向变得可行。3.4 解密data字段一类典型的对称加密处理解决了签名算法另一块硬骨头是data字段的解密。data的密文以U2FsdGVkX1开头这个前缀我印象很深——这是CryptoJS在AES加密后附带Salt信息的特征。具体的格式是Salted__这8个字节加8字节Salt后面才是真正的密文。这意味着data字段很可能是用了OpenSSL兼容的AES-128-CBC或AES-256-CBC加密密钥可以从某个固定的地方获取。我在脚本里继续搜索与加密相关的函数调用发现了一个encryptData函数核心逻辑是用AES-CBC模式加密密钥是32字节的固定字符串初始化向量则是前16字节。关键代码大概是var key CryptoJS.enc.Utf8.parse(32byteFixedKeyStringForAES); var iv CryptoJS.enc.Utf8.parse(16byteFixedIVString); var encrypted CryptoJS.AES.encrypt(plaintext, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 });拿到这段实现后解密就只是个顺手的操作了。我用Python的pycryptodome库按同样的参数做了解密把data字段还原成了明文。解密后我看到的是一个JSON对象里面包含真实的业务参数比如用户名、订单编号、金额等。这里有个非常关键的认知加密的作用是防止业务参数被直接读取但不改变签名计算的方式。签名计算的是加密后的data值不是解密后的明文。如果我构造请求时先改了业务参数再用明文去参与签名一定会失败。正确的顺序是构造业务参数明文。用固定密钥加密得到新的密文。用新密文作为data字段参与签名计算。提交完整请求。很多人在这里会犯一个迷糊明明解密时用的是密钥A为什么加密时也可以用密钥A答案是这就是对称加密的特性同一个密钥既可以加密也可以解密前提是你在本地完全复刻了对端的加密逻辑。3.5 构造合法请求参数更新、签名重算与请求重放签名算法、密钥、加密方式全部确认之后构造一个完全合法的请求就变成了流水线作业。我用Python写了完整的构造脚本大致流程如下import hashlib import time import uuid import base64 import json from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad SECRET_KEY sk_live_test_2024_x9k2 AES_KEY b32byteFixedKeyStringForAES AES_IV b16byteFixedIVString def aes_encrypt(plaintext): cipher AES.new(AES_KEY, AES.MODE_CBC, AES_IV) padded pad(plaintext.encode(utf-8), AES.block_size) encrypted cipher.encrypt(padded) return base64.b64encode(encrypted).decode(utf-8) def gen_sign(params): keys sorted(params.keys()) parts [f{k}{params[k]} for k in keys] raw .join(parts) key SECRET_KEY return hashlib.sha256(raw.encode(utf-8)).hexdigest() def build_request(): timestamp int(time.time()) nonce uuid.uuid4().hex business_data { user_id: u_10086, order_id: order_20240318_001, amount: 99.00, currency: CNY } data_enc aes_encrypt(json.dumps(business_data, separators(,, :))) params { app_id: test_app, timestamp: str(timestamp), nonce: nonce, data: data_enc } params[sign] gen_sign(params) return params if __name__ __main__: print(json.dumps(build_request(), indent2))脚本里的几个关键点说明一下时间戳必须是当前Unix时间戳的字符串形式不能带毫秒。nonce每次都要重新生成不能复用旧值。data的加密结果始终在变因为AES-CBC模式下相同的明文在固定的IV下虽然会得到相同的密文但你仍然要保证每次加密用的明文是你当前构造的参数版本。签名计算的输入是params字典中除了sign字段之外的所有字段做字典序排序后的查询字符串。脚本跑出来之后我直接把生成的请求提交到服务端返回的状态码是200响应体里包含了正常的业务数据。这意味着整个逆向与重放过程完全成功——我手里现在握着一张能随时访问该接口的“合法通行证”。这个结果在实战里就是最典型的业务漏洞场景掌握了签名规则的人可以直接构造任意参数提交给服务端服务端会认账。如果接口本身没有额外的风控策略比如IP限频、设备指纹、用户行为分析这个漏洞就会被无限放大。4. 常见问题与排查技巧实录4.1 签名长度正确但始终校验失败这是我第一次尝试套公式时遇到的最大障碍。拿到64位十六进制字符串自然就想到SHA256但直接拼接参数老是验不过。后来检查源码才发现密钥参与拼接的格式是keyxxx不是常见的secret_keyxxx或者saltxxx。这类问题在真实环境中最难查因为你根本不知道盐值的名字和位置。排查方法不要用猜的去找客户端里生成签名的实际代码。如果代码是混淆过的就先做格式化搜关键词比如sha256、hmac、sign、md5、encrypt这类高频词。如果只有抓包数据没有代码就尝试暴力枚举拼接方式把参数顺序、分隔符、盐值名、盐值位置全部作为变量去碰撞。4.2 时间戳不同步导致反复“过期”我在第一次构造完新请求时本地系统时间没有问题但提交后依然提示时间戳错误。仔细排查后发现本机时间差目标服务器5分钟左右超过了服务端设置的容差窗口。这种情况下你除非能把本机时间校准否则无论签名算得多正确都会失败。经验是调研阶段就先看一下目标返回的响应头里的Date字段或者任何时间相关字段以目标服务器的时间为基准来生成时间戳。不要默认“我的电脑时间就是准的”容器环境里差几分钟很正常。4.3 同一段明文加密结果不稳定有朋友会问如果密钥和IV都固定为什么每次生成的密文不一样有可能的原因是服务端在密文中带了随机Salt比如CryptoJS的Salted__前缀格式。这类加密方式下即使明文和密钥都相同生成的密文也不一样因为每次加密都会生成一个新的随机Salt。判断的方法就是看密文开头有没有Salted__特征字节。如果你在逆向时忽略了Salt的存在直接用固定IV去解密结果必然失败。正确做法是从密文中提取Salt按OpenSSL的推导方式计算出真实的密钥和IV再解密。4.4 请求体解码陷阱URL编码与Base64变体在抓包和重放时我一直注意到请求体里data字段的Base64字符串里会出现和/字符这在标准的Base64编码里正常。但有的时候后端接口期望URL安全的Base64变体把替换为-、把/替换为_如果直接拿标准Base64去提交服务端解析密文会出现填充错误报“wrong final block length”之类的异常。处理方式很简单发送请求前把标准Base64转换为URL安全的Base64或者请求时用表单/JSON体而不是URL参数传递。JSON请求体的话一般不需要额外转换但如果是GET请求的参数拼接就必需要做URL编码。4.5 客户端脚本调试受阻有些环境里客户端代码做了重度混淆函数名、变量名全是单字母可读性极差。我的建议是不要试图读懂所有逻辑而是只关注输入输出接口。运行脚本后在生成签名的函数前后各打印一次关键变量的值用动态调试代替静态分析。比对着正常请求和篡改后请求的输出变化来定位关键函数比一个变量一个变量地去猜快得多。4.6 请求重放被限频或者风控拦截这道题本身可能没有风控但实战接口十有八九会有。如果你发现自己构造的请求明明签名正确却被直接拒绝或者返回验证码那大概率是触发了其他维度的规则。常见的有IP频率限制、设备标识校验、请求方指纹校验。这时候别硬怼频率先把正常用户的行为曲线复制下来把请求节奏降到接近真人的水平或者寻找可以复用设备标识的条件。5. 实操心得与避坑总结整套题解下来我最深的感觉是安全测试的价值不来自“发现了一个签名是SHA256”而是来自“能完整地把一个合法请求从头到尾构造出来”。前者只是静态知识后者才是在真实攻防里用得上的动态能力。有几个习惯建议所有做这块的人刻意培养每拿到一个请求样本先拆字段不要急着爆破。把固定参数、动态参数、签名参数、加密字段划分清楚问题就解决了一半。所有推测都要用实验验证。怀疑时间戳过期就改时间怀疑密钥写在前端就搜索源码怀疑算法是SHA256就写脚本算一遍。空想不会出结果动手才会。构造请求脚本尽量做到自动化。签名、加密、时间戳、nonce、请求发送、结果校验全部写在同一个脚本里后续做批量测试或者参数变异时会省下大量重复劳动。这套“wsgsig secdd-challenge”做完之后我又顺手把同类型的题目练了几个发现整个流程基本是通用的。差别主要在加密算法的类型和密钥的位置上但分析路径始终一致。最后再分享一个小技巧不管题目多复杂先从抓包拿到一个“确定合法的请求”开始。这个样本就是你所有分析的锚点。没有它你会陷入“不知道正确答案长什么样”的泥潭有了它后续每一步验证都有明确参照。做接口安全分析锚点思维比任何技巧都重要。