代码 浏览器签得通、脚本签不通:CryptoJS 还原里最容易出错的八处

2026-11-08 09:01:34

浏览器签得通、脚本签不通:CryptoJS 还原里最容易出错的八处

在浏览器里抓包,key、iv、mode、padding 全抄下来了,Python/Node 里跑一遍,密文长度对不上,或者长度对上了内容不对。请求发过去 401 / "签名错误" / "Nonce 重复"。

这时候多半会回头怀疑 key 抄错了,然后对着压缩代码再抠两小时。多数情况下 key 没错,错的是下面这几件事里的某一件。

以下代码为通用写法,未在具体站点实测;各站的 key、盐值、字段名都不一样,别照抄。

一、CryptoJS.AES.encrypt 传字符串 key,走的是口令派生而不是原始密钥

CryptoJS 的 encrypt 对第二个参数做了类型判断:

// key 是 WordArray:当原始密钥用,输出没有盐
CryptoJS.AES.encrypt(
  CryptoJS.enc.Utf8.parse(data),
  CryptoJS.enc.Utf8.parse(key),
  { iv: CryptoJS.enc.Utf8.parse(iv), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }
).toString();

// key 直接传字符串:走 OpenSSL 兼容的 EVP_BytesToKey,MD5 迭代 + 8 字节随机盐
CryptoJS.AES.encrypt(data, "some-passphrase").toString();

第二种情况下,toString() 出来的 Base64 解开后前 8 个字节是 ASCII Salted__,后面跟 8 字节盐,再后面才是密文。Base64 开头通常是 U2FsdGVkX1。

判断方法:

echo 'U2FsdGVkX1+xxxxx' | base64 -d | head -c 8
# 输出 Salted__ 就说明是口令模式

口令模式下抄到的那个"key"其实是口令,不是密钥,必须在本地重跑一遍 KDF:

import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

def evp_bytes_to_key(password: bytes, salt: bytes, key_len=32, iv_len=16):
    d, prev = b"", b""
    while len(d) < key_len + iv_len:
        prev = hashlib.md5(prev + password + salt).digest()
        d += prev
    return d[:key_len], d[key_len:key_len + iv_len]

key, iv = evp_bytes_to_key(b"some-passphrase", salt)

盐是每次随机生成的,所以同一明文两次加密的结果不同。浏览器里连续两次请求的密文完全不同、长度还一样,先怀疑这个,而不是怀疑有随机 IV。

二、key/iv 的解析函数,比字符串长度重要

看到 32 个字符的 key,直觉是 AES-256。但要看代码里是 Utf8.parse 还是 Hex.parse:

CryptoJS.enc.Utf8.parse("20171109124536982017110912453698") // 32 字节 → AES-256
CryptoJS.enc.Hex.parse("20171109124536982017110912453698")  // 16 字节 → AES-128

同一串字符,两种解析方式差一个密钥长度,密文完全不同,而且长度都能对上。Python 侧对应 bytes.fromhex() 和 .encode(),选错就是静默错误。

iv 必须是 16 字节。有些站 iv 全 0,或者直接拿 key 的前 16 字节,代码里看不出来。抓两个不同明文的样本,本地试几种 iv 组合,能对上一个就够了。

三、输出编码和大小写

CryptoJS 里两个很容易混的输出:

  • encrypted.toString() → Base64
  • encrypted.ciphertext.toString() → hex,小写

请求里是 32 位大写 hex 的,多半是 md5(str).toUpperCase(),不是 AES。40 位是 SHA1,64 位是 SHA256,Base64 且长度是 16 的倍数才是 AES 密文。

这一步唯一可靠的做法是拿一组真实输入输出对拍:

import hashlib
ts = "1710000000000"
print(hashlib.md5((ts + "bdc739ff2dcf").encode()).hexdigest().upper())

对上了才算还原成功,对不上就别往下写业务代码。

四、重新序列化一遍 body,签名就变了

JSON.stringify 的键序不是"你看到的顺序"那么简单:整数样式的键会被提前并按数值升序排列。

JSON.stringify({ b: 1, 2: "x", a: 2 })
// '{"2":"x","b":1,"a":2}'

Python 的 dict 保留插入顺序,json.dumps 不会重排数字键。所以"把抓包 body 解析成 dict,再 dumps 回去算签名"这个动作,在含数字键、或服务端按 raw body 签名的情况下必错。

正确做法是签名原文直接用抓到的原始 body 字符串,不做解析再序列化。同一个坑在别的场景也出现过:小程序的 signData 就不能重新序列化,原样字符串参与验签。

还要注意排序是不是 sort() 了。有些站是"取所有 value,Array.prototype.sort() 后拼接"——sort() 默认按 UTF-16 码元排,Python 的 sorted() 按码点排,ASCII 范围内一致,遇到中文或代理对就有差异。

五、时间戳、nonce、以及被塞进签名的环境字段

三种常见问题,症状都是"本地算出来和浏览器不一样":

  • 单位:10 位是秒,13 位是毫秒。parseInt(Date.now()/1000) 之后又减一个常量(比如某个基准时间的秒数)的写法很常见,常量抄漏就全错。
  • 有效期:签名窗口通常 5 分钟,nonce 一次一用。重放同一个请求会返回 "Nonce repeated" 之类的错误——这不是算法错了。用 curl 直接重放抓包请求来验证参数是否动态生成,比改代码快。
  • 环境指纹进签名:userAgent、language、时区、screen、canvas、webgl 都可能被拼进签名串。你在 Node 里补出来的 navigator 和真实浏览器不一致(jsdom 的 navigator.platform、缺失的 webdriver、performance.now() 起点不同),请求能发出去,签名永远不对。

判断靠一次对比:Hook 住加密函数,把每次调用的完整入参打出来,同一个明文调两次:

const origin = window.getSign;
window.getSign = function (...args) {
  const r = origin.apply(this, args);
  console.log("[getSign]", JSON.stringify(args), "=>", r);
  return r;
};

入参完全一致、输出不一致 → 里面有时间戳/随机数/环境值;入参一致、输出一致 → 是纯函数,直接搬到本地复现。

六、Hook 自己被发现,以及 Hook 生效太晚

不少站点会做 Function.prototype.toString 检查,看关键函数是不是 [native code]:

Function.prototype.toString.call(JSON.stringify).includes("[native code]")

Hook 之后这一句返回 false,页面直接走异常分支——看到的是"页面行为怪"或者"sign 一直为空",而不是报错。

补上 toString 伪造:

(function () {
  const _toString = Function.prototype.toString;
  const fake = new WeakMap();
  Function.prototype.toString = function () {
    if (fake.has(this)) return fake.get(this);
    return _toString.call(this);
  };
  function hook(obj, name, label) {
    const origin = obj[name];
    const wrapped = function (...args) {
      console.log("[hook]", label, args, "=>", origin.apply(this, args));
      return origin.apply(this, args);
    };
    fake.set(wrapped, `function ${name}() { [native code] }`);
    obj[name] = wrapped;
  }
  hook(JSON, "stringify", "JSON.stringify");
  hook(XMLHttpRequest.prototype, "setRequestHeader", "setRequestHeader");
})();

时机同样关键。控制台里贴的 Hook 只对之后的调用生效。页面在首屏脚本里就完成了签名的话,Hook 永远拦不到:

  • Tampermonkey 脚本加 // @run-at document-start
  • 或者 Sources 面板启用 Overrides,把改过的 JS 存成本地版本

七、XHR 断点断在哪儿,决定你找错多久

XHR/fetch Breakpoints 断下来的时候,断点在 send() 附近,此时参数早就组装、加密完了。执行的顺序是:

参数组装 → 加密 → 发包 → 响应

断点在第一行,看到的是成品密文,容易误判"加密发生在这里"。正确动作是打开 Call Stack,从下往上逐帧看局部变量,找到"上一帧还是明文、这一帧变成密文"的那个分界——加密函数就在这两帧之间。

另外,全局搜索参数名记得带引号搜 "sign"、'sign'。压缩不改字符串常量,这一条几乎从没失效过。

八、兜底方案:别急着还原算法

算法一时半会扣不出来(JSVMP、强力混淆、环境检测叠加),可以先走 RPC:起一个真实浏览器上下文,把加密函数挂到 window 上,本地进程通过 CDP 或本地 HTTP 调它算签名。

代价要说清:每次签名都要过一次浏览器,单机并发上不去,内存和 CPU 都难看,只适合短期过渡或低量任务。长期要么把算法还原,要么换官方接口。

什么时候不该继续往下挖

站点有开放平台或官方 API 的,优先接官方。逆向的维护成本不在第一次还原,而在对方改一版你就得重来一次。而且签名算法本身是对方的风控资产,用途上也别越线。

参考资料:

复制全文 生成海报 JS逆向 CryptoJS AES 签名 抓包

推荐文章

程序员茄子在线接单