十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JS逆向实战:接口参数加解密与签名还原完整指南

JS逆向实战:接口参数加解密与签名还原完整指南 接手一个数据采集项目打开控制台一看接口参数全是sign8f14e45fceea167a5a36dedd4bea2543这样的东西那一刻的心情谁懂这不是个别现象。现在稍微有点规模的服务端都会在接口层做参数签名校验。前端 JS 在发送请求之前把请求体的关键字段按约定规则拼接再走一遍 MD5、AES、RSA 之类的算法生成一个签名参数或者加密字段。你直接发裸请求服务端返回的不是数据而是一句“非法请求”。JS逆向里的参数加解密分析干的就是这件事——从一段压缩混淆过的前端代码里把加密逻辑扒出来还原出发送请求时的那条完整链路。这篇文章不是讲某一个具体网站怎么破解那既违规也没意义。我把它当成一张“工具地图”来写把这些年用到的识别思路、Hook 技巧、常见算法特征、补环境方案、坑点记录全部汇总一遍。如果你刚接触逆向或者正在被某个签名参数卡住照着这篇的思路走大概率能省下两天瞎折腾的时间。1. 参数加解密到底在解决什么问题1.1 服务端为什么要在接口层加签名很多人第一次遇到加密参数时第一反应是“这玩意纯粹是给爬虫添堵”。这么说也没错但站在服务端开发的角度看签名机制解决的问题远不止防爬一点。第一个作用是防篡改。请求参数一旦明文暴露用户在浏览器里改几个字段就能伪造请求。比如一个查询订单的接口把订单号改成别人的如果服务端不做校验数据就泄露了。给所有参数加一个签名参数一变签名就校验不过服务端可以直接拒绝请求。第二个作用是防刷。典型的做法是签名里混入时间戳服务端判断时间差超过几秒的请求直接丢弃。这样即使抓到了完整报文也没法离线重放。第三个作用是防批量操作。很多签名算法会把业务语义编进去比如账号、设备指纹、会话凭证服务端能从中判断请求是否来自真实用户操作。理解了这三个目的逆向思路就清晰了你要还原的不是某个神秘的算法而是“服务端校验的那一刻期待看到什么”。签名只是一个约定你只要按约定的规则复现一遍服务端就认你。1.2 逆向分析的标准流程做参数加解密逆向我一直用一条固定流程抓包、定位、分析、还原、验证。五个步骤按顺序走基本不会乱。抓包是第一步。打开浏览器的开发者工具Network 面板里找到目标接口看请求参数里有哪些字段哪个是动态生成的哪个是写死的。重点留意带sign、token、nonce、ts、timestamp、encrypt这类名字的参数。然后确定这个参数的格式是纯数字、十六进制、Base64还是一串看起来无规律的乱码。这个特征会直接影响后面的算法判断千万别跳过。定位是第二步。用 XHR 断点或者事件断点在请求发出的位置停下来顺着调用栈一路往上层翻。签名参数一定是在发送前某个函数里生成的找到那个函数你就拿到钥匙了。分析是第三步。这一步要看函数的具体实现分清它用的是标准库的crypto-js、jsencrypt还是自己造轮子手写的算法。前者好办找到调用参数就结束后者麻烦一点要把它的运算逻辑抠出来。还原是第四步。把加密逻辑用你熟悉的语言重写一遍比如 Node.js 或者 Python跑通后再和浏览器里生成的结果比对。验证是最后一步。用还原出的脚本发一次真实请求服务端返回正常数据说明整条链路已经通了。很多人死在“浏览器里签名对脚本里签名错”这关后面第 6 章我会专门列坑。2. 先识别加密类型再动手2.1 从输出长度和字符集反推算法逆向的第一步不是去读代码而是看“结果长得像什么”。加密算法都有自己的指纹特征就像看人先看身高体型。我整理了一张速查表平时直接拿它对照特征可能算法说明32 位十六进制字符 0-9a-fMD5、部分 HMAC-MD5最常见长度固定 3240 位十六进制SHA-1老系统里还能见到64 位十六进制SHA-256、SHA-512 截断现代网站常用以 Base64 字符集结尾带Base64 编码后的密文通常配合其他算法使用输出超长Base64 后约 88~344 字符RSA、SM2 等非对称算法和密钥长度强相关固定 64 位十六进制且每次相同输入结果相同可能是国密 SM3金融类项目多见结果带/并且等号结尾可能是 AES/CBC Base64CryptoJS 默认输出格式这个表不是绝对标准只是一个快速分类工具。另外注意一个细节很多站点会把加密结果做二次处理比如先 MD5 再转大写或者先 Base64 再 URL 编码。我见过一个接口把 AES 密文又做了一次 Base64结果密文里混进了和/一眼看过去根本不像是 AES。这时候别急着下结论多抓几个请求对比找出输入和输出之间的稳定关系。还有一个小技巧把参数名本身也纳入判断。如果参数叫sign大概率是拼接后做哈希如果叫data或者params大概率是整体加密如果叫token则可能是服务端下发的凭证和本地算法无关。参数名是前端程序员起的名多少会暴露设计意图。2.2 从 JS 代码特征定位加密函数光看输出还不够最终还是得回到代码里。前端加密有一个绕不开的规律任何加密算法都必须执行一段 JS 代码而这段代码一定会包含某些“特征函数名”。标准库的加密工具类函数名非常有辨识度。看到CryptoJS.MD5、CryptoJS.AES.encrypt、JSEncrypt、sm2.doEncrypt基本可以锁定算法。这时在 DevTools 的 Sources 面板里全局搜索函数名瞬间就能找到调用点。麻烦的是站点把代码做了混淆函数名被改得面目全非。这种情况也别慌还有别的特征可以抓。比如调用加密算法时必然要传原始数据进去而这个原始数据几乎都是字符串拼接操作的产物再比如加密产物通常会被赋值给某个对象的属性而那个属性名刚好是接口请求参数名。所以我的做法是倒着查在 Network 面板里看到sign参数后在代码里全局搜索sign:或者sign找到赋值语句再反推它右边的表达式来自哪个函数。用 DevTools 的 Search 功能也好用 Fiddler 的响应体搜索也好本质是一样的——把一个看似不可能下手的混淆代码缩小到一个几行代码构成的函数范围里。2.3 常用工具链搭配工具不在多顺手的就那么几样。浏览器 DevTools 是我主力。调试、搜索、断点、Scope 查看一个面板全搞定。用 Chrome 的话记得开启 Source Map 支持虽然多数压缩代码没有 map 文件但有的时候开发环境会意外暴露能省不少事。抓包工具方面桌面端我用 Fiddler 多一些主要看重它的断点修改响应功能可以临时改响应体看前端行为移动端场景用 Charles抓 HTTPS 需要装证书iOS 和 Android 的操作略有区别这里不展开。本地跑还原逻辑Node.js 是首选原因后面会说到——补环境时它和浏览器 V8 引擎同源很多坑能少踩。Python 更适合用于最后发请求对接业务比如 requests 库本身就很好用。还要推荐一个现代浏览器自带的“黑科技”Hook 能力。它不是某一个独立工具而是通过覆写 JS 原生函数来实现监控效果。比如你在控制台执行一段脚本把JSON.stringify或Math.random包一层打印调用记录。这样即使整个代码混淆得不成样子只要它内部用了这些通用函数你也能拿到线索。后面第 4 章的实操里我会演示具体写法。3. 常见加密实现与还原思路3.1 MD5 签名最经典的参数防篡改方案MD5 在参数签名里出现频率最高原因很简单它计算快、输出固定、实现简单。服务端从来不在乎 MD5 本身的“安全性”只看重它的“一致性”——同样的输入必须产生同样的输出这样服务端用同一套规则比对就能判断参数有没有被改过。前端做 MD5 签名一般有两种方式。第一种是引用了crypto-js库代码长这样const CryptoJS require(crypto-js); function getSign(params) { const sorted Object.keys(params).sort(); let str ; for (const key of sorted) { str ${key}${params[key]}; } str secretKeyabc123; return CryptoJS.MD5(str).toString(); }第二种是站点自己内置了一个 MD5 算法的 JS 实现整段代码压缩成几行函数名毫无意义。这种就更依赖断点定位找到调用那个“加密函数”的位置然后把入参和出参都记下来再去对比。还原 MD5 签名一般分三步。第一步把参与签名的字段确认清楚是全部参数参与还是只截取了其中几个第二步确认拼接顺序是按字段名排序、按原顺序还是干脆按 JSON 序列化后的整体字符串第三步确认有没有固定盐值或者附加字段。只要这三件事确定签名规则就出来了。我在 Node.js 里复现时通常用内置的crypto模块不用额外装依赖const crypto require(crypto); function md5(str) { return crypto.createHash(md5).update(str).digest(hex); } const params { name: test, age: 18 }; const sorted Object.keys(params).sort(); let base sorted.map(k ${k}${params[k]}).join() secretKeyabc123; console.log(md5(base));有两处细节容易踩坑。一个是拼接用的分隔符、,、空字符串的最终结果天差地别另一个是“空值”处理比如某个字段值为空时到底是拼空字符串还是直接丢弃必须用浏览器里真实生成的签名反推校验。3.2 AES 对称加密加解密都藏在前端AES 是对称加密前端和后端共用一把密钥。它比 MD5 重一般用于加密整个请求体或者响应体而不是只做一个签名。识别 AES 最直接的信号是输出 Base64 字符串并且里面可能带有填充后的补齐痕迹。打开代码以后看到的典型调用长这样const CryptoJS require(crypto-js); const key CryptoJS.enc.Utf8.parse(0123456789abcdef); // 16字节密钥 const iv CryptoJS.enc.Utf8.parse(fedcba9876543210); // 16字节初始向量 function encrypt(data) { const encrypted CryptoJS.AES.encrypt(JSON.stringify(data), key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.toString(); }还原 AES 的关键就是找四个东西密钥、初始向量 IV、工作模式和填充方式。前两个是硬参数后两个有默认值——CryptoJS 默认 ECB 模式PKCS7 填充。如果站点没有显式指定 mode 和 padding那大概率就是默认值。有时候密钥不会直接明文写在代码里而是经过一次 Base64 解码或者 hex 转换放在变量里。你需要在断点位置把变量值打出来拿到处理后的最终密钥。解密时用 Node.jsconst crypto require(crypto); function aesDecrypt(ciphertext, keyHex, ivHex) { const decipher crypto.createDecipheriv( aes-128-cbc, Buffer.from(keyHex, hex), Buffer.from(ivHex, hex) ); let decrypted decipher.update(ciphertext, base64, utf8); decrypted decipher.final(utf8); return decrypted; }顺带提一句AES 加密的结果一般不是十六进制而是 Base64如果看到长度不对多想想是不是做了CryptoJS.enc.Base64.stringify之类的二次转换。还有个小概率情况是站点自实现了 AES 轮函数那工作量会大很多但这种属于非标准实现一般只有高度定制化的 App 才会这么干。3.3 RSA 非对称加密公钥和补位是突破口RSA 在前端最常见的用途是加密登录密码。服务端下发公钥前端加密传输私钥留在服务端解密。表面上看这方案很安全但实际上无论你怎么加密明文和加密逻辑都在浏览器里跑逆向者不需要破解 RSA——只需要把公钥和加密流程原样拿走就行了。前端 RSA 加密最常见的是jsencrypt库const JSEncrypt require(jsencrypt); const encryptor new JSEncrypt(); encryptor.setPublicKey(-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...\n-----END PUBLIC KEY-----); const encrypted encryptor.encrypt(password123);拿到公钥之后在 Python 里用rsa库或者Crypto库原样复刻import rsa pub_key rsa.PublicKey.load_pkcs1_openssl_pem(public_key_pem.encode()) cipher rsa.encrypt(bpassword123, pub_key) import base64 print(base64.b64encode(cipher).decode())这里最容易翻车的点是“补位方式”。jsencrypt 默认使用 PKCS#1 v1.5 填充而很多语言库默认是 PKCS#1 OAEP两者对同一段明文产生的密文完全不同如果直接套用 Python 原生加密签名永远对不上。对接时一定要先确认填充模式再动手。还有一类做法是 RSA 混合 AES前端生成一个随机的 AES 密钥用 RSA 加密这个密钥再用 AES 加密业务数据最终把两部分拼在一起发给服务端。这种方式在 App 端特别常见网页端这几年也越来越多。遇到这种情况别想着直接还原 RSA先把 AES 部分剥出来拿到随机生成的 AES key 再看 RSA 部分怎么传递的。3.4 Base64、编码与各种拼接套路很多人看不起 Base64不就是个编码嘛。但实际逆向过程中Base64 往往是最后一层伪装。我遇到过不少接口做了 MD5 之后又 Base64 一次或者把 JSON 先 Base64 编码再当参数传。这种“加密”一点安全性都没有但确实能挡住一部分不熟悉编码转换的新手。识别方法很简单参数以结尾或者包含/字符大概率有 Base64 参与。在控制台里直接把参数丢进去atob()一下密文立刻现原形。除了 Base64还有几种常见的“伪加密”套路值得留意字符串翻转把签名结果 reverse 一下再传服务端收到后先 reverse 再比较简单映射把0123456789abcdef替换成一套自定义字符集看起来像乱码实际上只是换了一张码表多次叠加先 MD5 得到 32 位再把这 32 位里的数字取出来重新拼接再走一遍 MD5编码嵌套AES 密文再经过 Base64 和 URL encode层数多一点就很容易让人误判算法。对付这类套路唯一的笨办法就是把浏览器里实际运行的函数从头到尾走一遍每个中间结果都打印出来。你在断点里亲眼看到“字符串翻转”那一行时所有的伪装就都破了。不要嫌麻烦这类问题一旦找到了规律写还原脚本就是顺手的事。4. 完整实操从抓包到本地复现签名这一章我带大家走一遍真实案例的完整流程。为了不涉及具体站点我用一个通用化场景演示某个查询接口请求参数里有timestamp、nonce、sign三个字段sign是 32 位大写 MD5。4.1 通过 XHR 断点定位请求发起位置打开目标页面按 F12 进入 DevTools切到 Sources 面板。右侧是事件断点区域展开 XHR/fetch 相关的选项勾选URL contains输入接口路径的关键字。比如接口是/api/query就填query。此时刷新页面或者触发一次查询浏览器会在XMLHttpRequest.send()这一行自动暂停。这是整条链路的起点因为任何 AJAX 请求最终都要走这个方法。点到暂定位置后界面右侧会展示 Call Stack里面是一串函数调用记录。从下往上翻找名字里带query、send、request、build字样的函数。双击任意一层左侧代码区会跳到对应函数这里很可能就是生成请求参数的代码。4.2 在调用栈里跟踪加密函数的输入输出定位到可疑函数后先不要急着一路往下点。我的习惯是先在函数的第一行下一个断点然后点击“恢复脚本执行”让代码重新跑一遍。等它再次停在断点上时左侧 Scope 面板会列出当前作用域里的所有变量。大多数情况下你能在这里看到请求参数的原始对象里面可能已经有timestamp和nonce但sign还是空的。接着点击“单步执行”一行一行往下走观察sign是什么时候被赋值上去的。当代码执行到某个函数调用且返回结果被赋值给sign时那个函数就是加密函数。点进函数内部看它的参数和返回值——参数是拼接好的字符串返回值是 32 位十六进制字符串到这里算法基本就锁定了。还有个更快的办法直接在 Console 里手动调用。在加密函数内部断点时输入md5(test)回车看输出。如果浏览器环境里加载了这个函数你能立刻得到测试结果比一步步单步执行快得多。4.3 用 Node.js 本地还原整个加密过程在浏览器里确认了加密规则后我习惯在本地写一个 Node.js 脚本做离线验证。这样做的好处有两个一是本地跑没有浏览器环境干扰速度快二是便于和团队其他人共享作为接口调用的标准签名工具。假设从断点里看到的规则是把参数按 key 的字典序排序拼接成k1v1k2v2加上固定的盐值saltabc123做一次 MD5然后转大写。脚本可以这样写const crypto require(crypto); function generateSign(params, salt abc123) { const keys Object.keys(params).sort(); const base keys.map(k ${k}${params[k]}).join() salt${salt}; return crypto.createHash(md5).update(base).digest(hex).toUpperCase(); } const reqParams { timestamp: 1712345678901, nonce: 8f14e45f, keyword: 测试, page: 1 }; console.log(generateSign(reqParams));跑一遍脚本把结果和浏览器里抓到的真实sign做对比。这里有一点需要注意timestamp和nonce每次请求都会变所以对比时要用同一组值也就是把抓包时那个请求里的timestamp和nonce原封不动地喂给脚本。4.4 与 Python 业务代码对接本地验证通过后如果是交给 Python 业务脚本去调用接口我不会在 Python 里重写一遍加密逻辑而是直接让 Python 调用 Node.js 脚本生成签名。这样维护成本最低前端规则变了只改 Node 脚本就够了。node sign.js --keyword 测试 --page 1用 Python 的subprocess或者直接调用 Node 的 HTTP 服务都可以。如果签名生成频率不高每次请求时现调现算就行import subprocess import json result subprocess.run( [node, sign.js, --keyword, 测试], capture_outputTrue, textTrue, checkTrue ) print(result.stdout)如果要做大规模请求更推荐的方式是把 Node 脚本包装成一个常驻的本地服务Python 只负责业务逻辑签名交给服务来算。这套方案实测下来比纯 Python 复现稳定得多毕竟前端的加密逻辑用的是同一套代码兼容性问题几乎没有。5. 进阶补环境与 OB 混淆的处理5.1 补环境的基本思路有时候你把加密函数找到了但没法直接在浏览器里测试因为代码里到处是window、document、navigator的调用。这时你可以把前端代码搬到本地用 Node.js 跑但必须补齐它依赖的浏览器环境。补环境说白了就是“伪造一个浏览器”。在 Node.js 里创建一个 vm 沙箱往里面注入各种浏览器对象让代码觉得它还在浏览器里跑。const vm require(vm); const sandbox { window: {}, document: {}, navigator: { userAgent: Mozilla/5.0 }, location: { href: https://example.com }, console: console }; // 让 window 指向自身很多代码会直接访问 window.something sandbox.window sandbox; vm.createContext(sandbox); const code require(fs).readFileSync(target.js, utf-8); vm.runInContext(code, sandbox);这个过程没有想象中那么复杂真正的难点在于“需要补到什么程度”。有些代码会检测navigator.webdriver有些会检查document.cookie还有些会读取document.createElement的结果。最好的方式是跑起来以后看报错缺什么就补什么。我通常用 Proxy 包裹沙箱对象对未定义的属性返回一个“万能值”减少反复重启脚本的次数。有一点要提醒补环境不是越全越好。你注入的属性如果干扰了原代码的逻辑判断反而会让加密结果出错。我的原则是“最小补全”——只补代码真正用到的部分。5.2 OB 混淆的特征与定位方法OB 混淆obfuscator 工具近几年越来越常见特征非常明显代码开头有一个大型数组几十上百个字符串被塞进数组里后面跟着一个移位函数通过整数索引把字符串取出来。运行过程中真正的业务代码都是用_0x48f2(0x1a3)这种调用代替常量字符串。这种混淆对阅读代码是巨大障碍但对定位加密函数反而有帮助。原因在于不管怎么混淆函数之间的调用关系还是存在的。你可以直接在混淆代码里搜索接口路径字符串有些混淆不会加密所有字符串接口 URL 可能还是明文。如果搜不到就搜stringified的迹象或者搜索sign、appid这类参数名。找到关键调用位置后不需要把整个文件还原成可读状态只要复制一段上下文在浏览器控制台里手动执行观察输出即可。OB 混淆产生的可执行文件只要带回浏览器环境它自己会把字符串还原出来。如果确实需要完整还原可以借助 AST 工具把数组、移位函数和调用点做替换。比如用babel/parser解析代码识别_0x48f2(0x1a3)这种节点把执行结果替换回去生成可读代码。不过这块工程量不小通常只有在代码量极大或者需要长期维护时才值得投入。6. 常见问题与排查技巧实录6.1 签名对不上的几类典型情况我把自己踩过以及帮别人排查时见过的高频问题整理成了一张表按出现频率排序现象可能原因排查方法本地脚本签名和浏览器不一致参与签名的字段没找全或者拼接顺序不对在浏览器断点处打印参与拼接的原字符串逐字对比每次都生成不同的签名有时间戳、随机数或者动态 token 参与运算对比两次请求的差异字段找到动态因子签名长度与预期算法不符中间做了 Base64、URL 编码或大小写转换在断点里逐层打印中间结果代码找到但补环境一直报错缺少特定浏览器对象或属性用 Proxy 拦截属性访问观察报错信息补缺同样的代码浏览器能跑、Node 里不行依赖了 DOM API或者检测了运行环境补环境优先处理 navigator、window 相关属性抓包数据里没有 sign 参数请求可能经过了 Service Worker 或自定义协议检查 Network 面板的 Initiator追踪来源其中“参与签名字段没找全”是最常见的错误。很多后端会把某些后端才知道的信息也塞进签名里比如用户 ID。你在前端代码里看不出这个字段的值从哪来但一对比浏览器签名和本地签名就会发现差异。这时候需要去接口的前置请求里找看是不是登录时下发了一个易变的值被存在 localStorage 或者全局变量里。6.2 提高定位效率的三个习惯最后分享几个我实际工作中总结的习惯算不上高深但很提效。第一个习惯是“先看请求参数再想对齐逻辑”。不要一上来就去搜加密函数先把一次请求里所有参数的来源梳理清楚哪些是页面渲染时写死的哪些是 JS 运行时生成的哪些是从 cookie 或 localStorage 读出来的。答案往往藏在这些参数之间的关系里。第二个习惯是“断点下在赋值语句而不是函数入口”。很多人在可疑函数入口下断点结果函数被调用了无数次每步都是噪音。我的做法是直接在sign xxx()这一行下断点这一步只有在真正生成签名时才会触发效率高得多。第三个习惯是“保存好浏览器断点现场”。在关键位置打上断点后用控制台的copy(对象)把变量值导出成 JSON加上注释存到一个本地笔记里。万一后面排查问题需要回溯这些现场数据比截图有用一百倍。还有如果代码更新导致原来的断点失效可以用 DevTools 的脚本片段功能把关键打印逻辑固化下来下次访问直接注入。做参数加解密分析这件事说到底就是一个“还原约定”的过程。服务端定了一套规则前端按照规则执行你只需要把规则从代码里提取出来再用自己的语言复述一遍。真正的难点从来不是某个算法有多难破解而是你在定位的过程中有没有足够的耐心去观察每一个细节。每次我在断点里看到那行拼接字符串的代码时都忍不住感叹一句原来它写的是这么一回事。我从一开始就被这个东西折磨过无数次后来慢慢发现只要按着“抓包、定位、分析、还原、验证”的顺序走下去再乱的加密也能理顺。希望这篇汇总能帮你少走一些弯路。
返回列表