Shopee接口逆向:x-sap-Ri与x-sap-Sec签名生成全解析 做电商平台数据采集的人这两年应该都能明显感觉到Shopee的详情页接口已经不是当年随手一抓就能返回数据的时代了。打开商品详情页随便点一个请求Headers里都会带上两个非常显眼的参数x-sap-Ri和x-sap-Sec。第一次看到的人基本都会一脸懵——这两个 Header 既不是标准的sign也不是常见的token名字完全是内部约定的风格。我最早接触这两兄弟是在排查商品价格和库存接口返回 403 的时候。服务端对缺少这两个请求头的请求直接拒绝而且返回的响应体没有任何业务数据。当时第一反应是“是不是账号风控了”后来把浏览器里正常发出去的请求完整复制一遍加上x-sap-Ri和x-sap-Sec之后接口立刻恢复正常。这就说明问题了——这两个 Header 是服务端校验请求合法性的核心凭证属于典型的前端加密请求头必须在页面内部动态生成。这文章就把我完整调试这两个参数的思路、断点定位、加密逻辑拆解和最终用 Node.js 复现的过程写清楚给同样卡在 Shopee 详情页逆向上的朋友一个可以直接落地的参考。1. 先说清楚x-sap-Ri和x-sap-Sec到底在防什么不理解反爬方的意图逆向的时候就容易走弯路。这两个请求头实际干的事可以从它们的出现位置和请求特征上一层层拆开看。1.1 请求头的大致形态与定位在浏览器开发者工具里打开任意一个 Shopee 商品详情页切到 Network 面板刷新页面后找/api/v4/item/get这个接口不同地区域名可能有差异但路径基本一致。点开 Headers拉到 Request Headers 区域能看到这样几个关键项x-api-source: pc x-sap-ri: 30f0e8d8f8f8e8c8... x-sap-sec: 1a2b3c4d x-sap-web-version: 2a1a5e5我第一次看到x-sap-ri是一长串十六进制字符串时第一反应是 MD5。等打断点进去看生成过程后才发现这是一个经过多层拼接后做的消息摘要结果。x-sap-sec则短得多通常是一个 8 到 16 位的十六进制串看起来像是某种摘要的截断或者版本标记。这两个头经常组合出现缺一个服务端都会返回异常。有必要说一下这两个参数不是固定不变的。同一个页面第一次加载和第二次刷新生成的x-sap-ri完全不同即使同一份商品数据用不同浏览器环境访问结果也不一致。这说明生成逻辑里掺入了随机数、时间戳或环境指纹类的动态因子。1.2 服务端校验的大致流程从请求到达服务端后的校验顺序推测x-sap-sec更像是一个快速过滤字段——服务端可能先用它做版本或算法标识判断如果不匹配就直接拒绝不进入耗时的完整校验。而x-sap-ri则是真正验证请求完整性的核心签名。校验逻辑大概有三层第一层Header 是否缺失缺失直接 403。第二层x-sap-sec格式和取值是否在允许范围内用于打击非浏览器环境发出的请求。第三层x-sap-ri是否与请求路径、查询参数、时间戳等要素匹配防止请求被篡改或重放。这种设计在大型电商平台里非常常见相当于“快速通道检查 深度签名验证”的双层结构。理解这一点对后续逆向很有帮助——我们要找的两个参数可能来自同一套加密逻辑也可能来自不同的模块。实际调试下来它们的生成是独立的但x-sap-sec的生成过程里会读取x-sap-ri二者在请求头里拼装时才走到一起。1.3 为什么直接搜 Hook 不到不少做爬虫的朋友习惯用hook关键词的方式定位加密参数比如在 Console 里重写XMLHttpRequest或者fetch把请求头里的值打印出来。但 Shopee 这类的签名参数直接 hook 浏览器原生对象往往只能看到最终结果看不到生成过程。原因在于前端代码里可能不是直接给XMLHttpRequest.setRequestHeader传这两个字段而是先构造一个 header 对象再经过统一请求库封装最后才发给浏览器。你在 hook 点能拿到值但拿不到函数调用栈和算法上下文。这也是为什么下文所有分析都围绕开发者工具的“搜索 断点 调用栈”三件套来做而不是依赖全局 hook。提示先定位请求头在 JS 代码里被赋值的语句再去打断点看生成处比在 hook 里大海捞针要高效得多。2. 定位加密源头从接口到断点的完整链路拿到要逆的参数名字下一步就是找到它们是在哪个 JS 文件、哪个函数里被计算出来的。很多人卡在这一步因为 Shopee 的 JS 文件做了混淆和分包加载直接按文件名找几乎找不到。正确路线是搜索字符串 → 定位赋值语句 → 回溯调用栈 → 找到核心加密函数。2.1 用全局搜索快速锁定出现位置打开 DevTools在 Network 面板里右键发起详情页请求的接口名选择“Search”或者直接在 Sources 面板里按CtrlShiftF打开全局搜索。输入x-sap-ri注意区分大小写如果搜不到可以试试小写x-sap-ri或者去掉连字符的变体搜索结果会列出所有出现该字符串的 JS 文件和行号。我这里实际操作时第一次搜索出现了多个结果主要是以下两类字段定义处例如headers: { x-sap-ri: generateRi(), x-sap-sec: generateSec() }字符串拼接处某些 logger 或埋点逻辑里也会打印请求头容易干扰判断第一遍搜索结果里优先找赋值语句集中在请求库封装层的那个文件。通常是一个几百 KB 的 vendor 或 runtime 文件加载时机在页面初始化阶段。点进去格式化代码然后在格式化后的代码里再搜索一次x-sap-ri。这里有个小技巧格式化之前先记住原始 JS 文件的行号格式化之后行号会变再搜索一次反而更容易定位。因为格式化后的代码会把压缩在一行的内容拆开搜索命中会显示具体逻辑而不是一整行超长代码。2.2 在赋值语句上打断点并触发请求找到类似下面这样的代码段t.headers t.headers || {}, t.headers[x-sap-ri] (0, n.generateRi)(), t.headers[x-sap-sec] (0, n.generateSec)(), t.headers[x-api-source] pc, t.headers[x-sap-web-version] 2a1a5e5在x-sap-ri这一行左侧点击加断点。然后刷新页面代码会在这个位置暂停。这时在 Scope 面板里能看到当前的t对象也就是即将发送的请求配置headers里这两个字段的值还没有生成。点击 DevTools 里的“Step into next function call”会跳进generateRi的实现。如果generateRi是 webpack 模块导出函数跳转后能看到一个独立的函数体。这里就是我们需要重点分析的起点。2.3 从生成函数回溯整个调用链进入生成函数后不要急着看算法先在函数内部打断点重新触发观察参数都是什么。generateRi通常接收一个配置对象里面包含url、method、params、data等信息。实际操作中它的签名大致是这样的function generateRi(e) { var t e.url; var n e.method; var r e.params; var o e.data; // 关键加密逻辑 }其中e.url是完整的接口路径e.params是查询参数对象e.data是 POST 请求体。这些信息会被拼进一个字符串里然后做摘要运算。加密函数往往依赖一个独立的工具函数比如getSignSalt或getTimestamp。这些工具函数可能定义在更底层的模块里调用栈里能看到一串嵌套关系。分析到这里大致可以画出调用链请求库封装层组装 headersgenerateRi接收请求配置并提取关键要素generateRi内部调用摘要工具函数MD5/SHA 系列摘要结果经过字符串处理和大小写转换最终作为 header 值返回这个链路理清楚后剩下的事就是逐行读懂这些函数。3. x-sap-Ri与x-sap-Sec的加密逻辑拆解整个逆向过程里最核心的就是把这两个参数的生成代码读懂。下面按我的调试顺序把关键逻辑和识别方法展开讲。3.1 x-sap-Ri拼接、加盐、摘要通过断点观察x-sap-Ri的生成逻辑大致如下先把请求参数里的关键字段按固定顺序拼成一个长字符串。常见的拼接要素包括请求路径不包含域名部分例如/api/v4/item/get查询参数按 key 排序后拼接例如itemid123shopid456POST 请求体如有当前时间戳通常精确到秒一段固定或半固定的盐值salt然后对这个字符串做消息摘要。我在实际调试中看到的是类似 MD5 的 32 位十六进制输出但并不能武断地认为只有 MD5。判断摘要算法的常见方法是在 Console 里调用CryptoJS.MD5、CryptoJS.SHA256做对照测试看输出是否与页面生成的一致。下面是一段为便于讲解而简化的示例代码实际项目中盐值和拼接格式会有变化但整体骨架类似function generateRi(e) { var t e.url; var n e.params || {}; var r e.data || {}; var i Math.floor(Date.now() / 1000); // 秒级时间戳 var o t ? sortParams(n) data JSON.stringify(r) t i saltSAP_RI_2024; return md5(o).toUpperCase(); }这里要重点看的是salt值从哪来。不少平台的盐值写在某个配置模块里可能是一段纯字符串也可能是从接口下发的。Shopee 的盐值通常就藏在一个独立模块中搜索salt、sign、secret之类的关键词可以找到。注意x-sap-Ri生成时用的时间戳如果与请求头里的时间字段不一致服务端会判定签名过期。所以复现时一定要保证所有时间相关字段统一。3.2 x-sap-Sec版本标记还是独立签名x-sap-Sec的生成相对复杂一点。它不是一个单纯的摘要而是带有版本信息的加密串。从断点里看到的形态类似于1 随机数 校验码或者某些情况下等于一个固定前缀加x-sap-Ri的碎片。我最开始以为x-sap-Sec是x-sap-Ri的再次摘要但对比日志发现两者并没有直接的转换关系。观察生成函数发现x-sap-Sec内部会调用另一个模块读取设备指纹信息包括但不限于navigator.userAgent的哈希navigator.languagescreen.width、screen.heightdocument.cookie的部分值插件列表或 Canvas 指纹这些信息经过拼接后再与x-sap-Ri的值做一次运算最终截取定长字符串作为x-sap-Sec。换句话说x-sap-Sec更像是一个“环境可信度标记”用来验证当前请求是否来自真实浏览器环境。复现x-sap-Sec的难点不在算法本身而在环境参数的获取。用 Node.js 发送请求时需要手动构造一个足够接近真实浏览器的环境信息。好消息是服务端对x-sap-Sec的校验往往只关注固定字段和摘要格式不会要求所有指纹位点完全一致。3.3 算法识别的三种方法面对一堆压缩混淆的代码怎么快速判断它用了什么加密我总结了三招第一招观察输出长度。32 位十六进制大概率是 MD540 位是 SHA164 位是 SHA256。如果输出里既有数字又有 a-f 字母基本确定是 hex 编码。第二招在函数内部搜索CryptoJS、createHash、md5、sha等关键词。压缩后的代码虽然变量名被替换但方法名通常还是保留的因为这些是库的公开 API。第三招断点进入加密函数后直接在 Console 里手动构造一个输入值调用同一个函数看输出再用本地工具如openssl dgst -md5算一次对比结果。一致的话算法就确认了。下表是我在实际调试中常用的判断对照特征可能算法备注32位hex字母在a-fMD5最常见40位hexSHA1较少见64位hexSHA256逐渐增多输出含g-z等非hexBase64编码后的摘要需反查原始字节短串8-16位截断摘要或CRC类注意函数截取逻辑4. 实战复现用Node.js还原请求头生成流程理清加密逻辑后就可以脱离浏览器在 Node.js 环境里把生成过程复刻一遍。做到这一步之前所有的逆向分析才算真正落地。4.1 抽取核心JS片段在 DevTools 里把generateRi和generateSec相关的函数完整复制出来。这里注意不要只复制主函数它依赖的工具函数也要一起抽取比如sortParams、md5、getDeviceFingerprint。一种比较省事的方式是在 Sources 面板里把整个包含加密模块的文件保存到本地在本地文件里搜索函数名手动删掉无关代码只保留核心逻辑。如果文件依赖很多 webpack 模块直接用webpack的模块导出函数是更好的方式但手工抽取对单个加密函数来说完全够用。抽取后的代码大致长这样const crypto require(crypto); function md5(str) { return crypto.createHash(md5).update(str, utf8).digest(hex); } function sortParams(params) { return Object.keys(params).sort().map(k ${k}${params[k]}).join(); } function generateRi(url, params, data) { const timestamp Math.floor(Date.now() / 1000); const raw ${url}?${sortParams(params)}data${JSON.stringify(data)}t${timestamp}saltSAP_RI_2024; return md5(raw).toUpperCase(); } function generateSec(ri, userAgent) { const uaHash md5(userAgent); const base ${ri}_${uaHash}_${ri.length}; return md5(base).slice(0, 16); }这是简化后的逻辑仅供演示整体思路。实际抽取的代码里如果引用了window、document、navigator等浏览器对象就需要做下一步的补环境处理。4.2 补环境在Node里伪造浏览器对象页面源码里很多工具函数直接访问window或document比如读取 Cookie、获取屏幕尺寸。Node.js 里没有这些对象最简单的办法是在文件开头做全局变量注入。global.window global; global.navigator { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, platform: Win32, language: zh-CN, languages: [zh-CN, en], cookieEnabled: true }; global.document { cookie: , referrer: https://shopee.sg/, title: , documentElement: { style: {} }, createElement: function() { return {}; } }; global.screen { width: 1920, height: 1080, colorDepth: 24 }; global.location { href: https://shopee.sg/product/123, hostname: shopee.sg, protocol: https: };补环境的原则是“缺什么补什么、报错缺哪个对象再补哪个”。不要一上来就补一大堆容易引入不存在的属性反而干扰后续调试。运行时如果报xxx is not defined就在代码里搜索xxx出现的位置按需补充。4.3 验证生成的签名能否通过接口校验环境补齐后写一个小脚本测试生成的请求头是否有效。先请求详情页首页拿关键 Cookie比如SPC_F、SPC_TF、SPC_ECD再带上生成的x-sap-Ri和x-sap-Sec请求商品详情接口。const axios require(axios); const url /api/v4/item/get; const params { itemid: 123456, shopid: 789012 }; const ri generateRi(url, params, {}); const sec generateSec(ri, global.navigator.userAgent); const response await axios.get(https://shopee.sg url, { params, headers: { x-api-source: pc, x-sap-ri: ri, x-sap-sec: sec, x-sap-web-version: 2a1a5e5, User-Agent: global.navigator.userAgent, Cookie: cookieStr, Referer: https://shopee.sg/category/11036082 } }); if (response.data response.data.data) { console.log(请求成功item数据正常返回); } else { console.log(请求失败响应:, response.data); }第一次跑大概率会失败常见原因是 Cookie 缺失或签名里的时间戳与服务器时间不一致。如果返回{error: 400}或者{error: 403}不要慌把响应文本打出来看具体提示再回到加密函数里找原因。提示Shopee 对 Cookie 的依赖不亚于签名参数。脚本里cookieStr必须来自真实浏览器登录或访问过同区域站点后的值。仅靠签名参数无法完全绕过风控。4.4 请求频率与并发控制签名参数能通过之后还有一个散户踩坑最多的地方请求频率。我曾经在测试阶段对同一商品 ID 连续请求 20 次第 10 次以后开始出现滑块验证码。Shopee 的详情接口存在明显的 QPS 限制不同 IP 段、不同账号状态阈值也不同。实践里比较稳妥的做法是单商品轮询间隔至少 2 秒用固定 Cookie 池轮换请求单账号单会话的请求频率不要过高出现验证码时立刻停掉当前 IP切换出口 IP等几分钟再继续尽量通过商品搜索接口拿列表再进详情页拿数据模拟真实用户路径5. 调试现场高频问题与排查技巧这部分内容是我在实际复现过程中踩过的坑汇总。逆向这种事思路对了是水到渠成思路偏了真的能把人卡到怀疑人生。5.1 断点挂起导致请求超时打断点调试的时候经常遇到页面卡住请求迟迟不发出。这是断点位置太靠前的正常现象。如果断点打在了请求库封装层函数执行流程会停在发送前浏览器不会立即发出网络请求一直等到你放行断点才继续。所以调试时要注意放行前先观察函数调用栈和参数内容改代码、刷新页面、清空缓存这类操作要在放行之后再执行。否则页面会一直停在挂起状态容易误判为请求被拦截。5.2 复现时出现环境检测报错抽取代码到 Node.js 后运行时经常报navigator is not defined或window is not defined。解决办法就是我上面说的补环境。但有一点容易被忽略某些检测代码不是直接访问window而是通过globalThis或自执行函数传参。这时候补环境的位置很关键必须在引入被检测代码之前完成注入。还有一种情况是代码里用了Object.defineProperty重写某个浏览器属性比如定义不可枚举的navigator.webdriver等。复现时如果发现环境检测特别严格可以在补环境阶段对这类属性做特殊处理。5.3 签名过期与重放校验调试过程中发现同一组签名在一两分钟内有效过几分钟再用就返回 400 了。这说明服务端对签名里的时间戳做了时效校验。解决办法是脚本每次请求前都重新生成签名不要缓存ri和sec的值。重放攻击防护方面x-sap-Ri绑定了请求参数和时间戳同一组签名无法用于其他请求路径这本身就在限制重放。所以如果你的脚本因为重试机制反复用同样的签名打同一个接口被拒绝是正常的。每个请求都调用生成函数是必须养成的习惯。5.4 常见问题速查表现象可能原因解决方案返回 403缺少x-sap-Ri或拼写大小写错误检查 header 字段名和值格式返回 400签名时效过期或参数与签名不匹配重新生成签名检查拼接顺序返回 200 但 data 为空Cookie 缺失或会话过期更新 Cookie重新访问目标页面出现验证码请求频率过高或 IP 被风控降频切换出口 IP等待解封Node 报window is not defined未补环境或补的位置不对在引入业务代码前注入全局变量生成的签名与页面不一致盐值或拼接格式理解有误加日志对比每一次拼接的中间结果5.5 一个最容易被忽略的细节x-sap-Ri的生成函数里如果请求体是 POST 且字段顺序不固定那么 JSON 序列化之前的 key 顺序也会影响签名结果。有些实现会先把对象 key 排序再序列化有些则直接按对象原有顺序。复现时一定要在浏览器里打印出真正参与拼接的字符串并在 Node 端传入完全相同的字符串做计算。我一开始在某个 POST 接口上反复失败后来发现就是JSON.stringify序列化顺序不一致导致的。这个问题特别隐蔽因为它不报错只是签名对不上。定位方法是在浏览器里把拼接的原始字符串打日志然后在 Node 端把同样的字符串打印出来一行行比对。另外签名里的大写和小写转换也要注意。页面生成的x-sap-ri是纯大写十六进制但x-sap-sec可能是小写混合。如果在代码里统一做了.toUpperCase()很容易把sec的值也转换掉导致校验不通过。正确做法是严格按照页面代码的处理方式不额外做大小写统一。最后的实操体会这两个参数我前前后后大概花了一个多星期才完全跑通。回头再看最大的感悟是逆向这种工作耐心比技巧重要。断点、搜索、调用栈这些工具每个人都会用难的是在混淆代码里保持清晰的思路一步步缩小范围而不是一上来就想着全局 hook 一举拿下。如果你正准备啃 Shopee 的签名参数我的建议是先从小接口练手比如商品搜索建议接口或者分类页接口这些请求的参数结构简单拼接逻辑更容易看清楚。等把x-sap-Ri的生成逻辑摸透了再上详情页这个复杂度更高的接口会顺手很多。还有一点自己写脚本验证签名时记得把每次请求的 headers、响应状态码、响应体完整记录下来。遇到问题回头看日志比反复猜测要高效得多。采集这件事本身就是个长期工程反爬策略会更新签名参数也会变化但分析思路和调试手段是通用的掌握这套方法论遇到新的加密参数也不会慌。