Skip to content

实战:一次 WebP 图片前端加密的 JS 逆向全记录

1. 引言

偶然在一个页面上审查 <img> 元素时,看到这样一段代码:

html
<img src="blob:https://www.xxx.com/3ed054ca-00ec-462f-b442-13a24ba4481b"
     data-src="https://cdn.xxx.com/xxx.webp"
     data-decrypted="1">

三个明显反常的信号:

  • srcblob: 前缀的临时地址,复制到新标签页打开直接 404;
  • data-src 看起来是正规的 webp URL,把它填回 src 里图片依然打不开
  • data-decrypted="1" 这个自定义属性暗示"已解密"——意味着页面上看到的图是 JS 解密后的产物。

这就是一个典型的"前端二进制加密图片"方案。本文以这条线索为起点,完整记录从"发现加密标识 → 定位 Worker → 定位主线程密钥 → 离线复刻"的实际逆向路径。

读者预期基础:会用 Chrome DevTools 的 Network / Sources / Console,看得懂基础 JS。

2. 环境与工具

用途工具备注
前端分析Chrome DevToolsNetwork / Sources / Debugger / Console
脚本复刻Node.js ≥ 18内置 fetch + crypto,零依赖
备选脚本Python 3 + pycryptodomepip install pycryptodome
抓包(可选)Fiddler / Charles一般场景 DevTools 足够

3. 整体思路概览

"前端加密图片"的典型套路,拆开就是四步:

  1. 服务器返回的 .webp 文件不是真图,而是被对称算法加密后的二进制密文;
  2. 页面 JS 用 fetch 把密文当 ArrayBuffer 拿下来;
  3. 丢给 Web Worker(后台线程)做解密,避免卡住 UI;
  4. 解密后的真实字节流包成 BlobURL.createObjectURLblob: URL → 塞回 <img src>,并打上 data-decrypted="1" 的标记。

逆向的核心目标:摸清算法、找到密钥,用脚本把步骤 2–4 在本地离线复现。

4. 实战逆向:从 DOM 标记一路追到密钥

这是本次逆向真正走过的路径,按顺序还原。

4.1 第一步:从 data-decrypted 这个"加密标识"入手

DOM 上的 data-decrypted="1" 是业务自定义属性,不是任何框架或浏览器的内置东西——它一定是网站自己的代码写上去的。这就给了一个极强的搜索线索。

直接在 DevTools 的响应数据里搜 decrypted(Network 面板 → 右键 → Search in all responses,或者 Sources 面板 Ctrl+Shift+F 全局搜)。很快命中一个 Web Worker 脚本:

js
// 加密 Worker(简化展示)
importScripts('https://.../crypto-js.min.js');

const decryptImageCryptoJS = function(buffer, base64Key) {
  const key = CryptoJS.enc.Utf8.parse(atob(base64Key));
  const bytes = new Uint8Array(buffer);
  const iv = CryptoJS.lib.WordArray.create(bytes.slice(0, 16));
  const ciphertext = CryptoJS.lib.WordArray.create(bytes.slice(16));
  const decrypted = CryptoJS.AES.decrypt(
    { ciphertext }, key,
    { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }
  );
  // ...转回 Uint8Array 返回
};

self.onmessage = function (e) {
  const { buffer, url, mimeType, base64Key } = e.data;
  const decryptedBuffer = decryptImageCryptoJS(buffer, base64Key);
  const blob = new Blob([decryptedBuffer], { type: mimeType });
  const blobUrl = URL.createObjectURL(blob);
  self.postMessage({ url, blobUrl, success: true });
};

只看这段代码就能抽出三个事实

  • 算法:AES-CBC + PKCS7
  • IV = 密文前 16 字节
  • 密钥来源:atob(base64Key),再按 UTF-8 字节当 key。

唯一未知数就是:base64Key 这个字段是主线程通过 postMessage 传进来的——它从哪来?

4.2 第二步:锁定主线程的密钥生成位置

既然 Worker 收到的字段名叫 base64Key,那主线程一定有一处代码正好构造了这个名字的变量/属性。于是切到 Sources 面板,Ctrl+Shift+F 全局搜 base64Key(保留大小写)。

匹配结果里有几处:Worker 文件自己的 e.data.base64Key(已知)、postMessage 调用点、还有一处赋值语句

js
const a = decode(_0x8d7d85(-0x190, -0x161, -0x1ea, -0x1ba));
const b = decode('/F?ctY');
const c = decode(_0x405bd0(0x126, 0x187, 0x165, 0x1e1));
const base64Key = btoa(a + b + c);

这段被 javascript-obfuscator 风格的混淆包裹过:_0xXXXX(...) 是字符串解码器,decode(...) 是站点自定义的进一步解码函数。但透过混淆壳能看出骨架:

  • 三段字面量 a / b / c,经过 decode() 得到三段明文;
  • 拼接后用 btoa(...) 做 Base64 编码,就是最终的 base64Key

关键观察:等号右边的 base64Key 这个名字本身没被混淆——说明它是源码里"裸"定义的变量名,混淆器刻意保留了它(大概率是因为它要作为对象属性名传给 Worker,且另一侧强依赖这个名字)。这反而给我们提供了一个稳定的抓手。

另一侧,顺着 base64Key 也很快找到了 postMessage 调用点(去混淆后):

js
const payload = {
  buffer,
  url,
  mimeType: getMimeTypeFromUrl(url),
  base64Key,
};
decryptWorker.postMessage(payload, [buffer]);

到这里整条数据链路就对上了:主线程拼 key → 打包 → postMessage → Worker 解密 → 生成 blob URL → 塞回 <img>

⚠️ 中途踩过的弯路:最初搜 postMessage / MessageChannel 想定位调用点,结果跳到了 React Scheduler 的时间切片源码(MessageChannel + unstable_now + 5ms 判断)。那段是库代码,不是业务代码。教训:尽量搜业务特征词base64Keydata-decrypteddecryptWorker),别搜通用 API 名。

4.3 第三步:断点取值,让浏览器把 key 算给你看

decode 函数被混淆得很重,硬逆向算法成本很高——但根本没必要。浏览器每次加载图片都会执行这一行,我们只要断点一次、让它把计算结果"吐出来"即可。

操作步骤:

  1. const base64Key = btoa(a + b + c) 这一行左边行号点一下,打上断点;
  2. 刷新页面或滚动触发图片加载,执行流在断点处停下;
  3. 切到 Console 面板,直接求值:
js
a             // "6X+b6"
b             // ".E>bsX"
c             // "b}+=N"
a + b + c     // "6X+b6.E>bsXb}+=N"     ← 刚好 16 字节 ASCII
base64Key     // "NlgrYjYuRT5ic1hifSs9Tg=="

几个关键洞察瞬间到位:

  • 拼接明文长度 16 字节 → 对应 AES-128-CBC
  • 三段都是 ASCII 可见字符,atob(base64Key) 回来的就是这 16 字符,Utf8.parse 后字节完全一致,可以直接把明文字符串当 AES key
  • btoa 在整个链路里只是一层"传输编码",不是真正的密钥加工。

4.4 回顾:为什么这条路走得通

阶段抓手为什么有效
发现异常data-decrypted="1"业务自定义属性,只有网站代码会写
定位 Worker全局搜 decryptedWorker 端 postMessage 时会带这个字段/标记
定位主线程搜 Worker 消费的字段名 base64Key收发两端字段名必须一致,且名字不易被混淆
取出真值在赋值行打断点读 Console混淆阻止人看懂,但阻止不了运行时求值

这四步是可复用的通用套路:从"能看得见的异常标记"出发,顺着"收发两端必须共用的名字"搭桥,最后用断点把运行时结果抓出来。

5. 算法拆解

把整条链路翻译成大白话:

步骤做了什么类比
① 下载密文fetch(data-src) 拿一坨 ArrayBuffer收到一个密封箱
② 取 IV前 16 字节箱面贴着批次号
③ 取密文第 17 字节起全部箱里的加密货物
④ AES-CBC 解密key=16字节明文, iv, PKCS7用万能钥匙拆封
⑤ 包 Blobnew Blob([bytes], {type:'image/webp'})重新装进浏览器能识别的袋子
⑥ 生成 blob URLURL.createObjectURL(blob)给袋子贴临时门牌

用到的关键概念:

  • ArrayBuffer:一块原始字节内存,像没贴刻度的水管;
  • Uint8Array:架在 ArrayBuffer 上的"刻度尺",按下标访问每个字节(0–255);
  • AES-CBC:对称分组加密,每 16 字节一组,组间有链式依赖 + 初始 IV;
  • PKCS7 Padding:最后一组不足 16 字节时的标准补齐规则;
  • Base64 / btoa / atob:二进制 ↔ 可见文本的无损编码,方便在 JSON/HTML/URL 里传。

6. 还原解密脚本

6.1 Node.js 版(Node ≥ 18,零依赖)

js
// decrypt-webp.js
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');

// === 密钥:断点里抄出来的三段拼接结果 ===
const PLAIN_KEY = '6X+b6.E>bsXb}+=N';  // 16 字节 → AES-128

/** 解密一段加密 webp 二进制 */
function decryptImage(encBuffer) {
  const key = Buffer.from(PLAIN_KEY, 'utf8');   // 16 字节 AES key
  const iv  = encBuffer.subarray(0, 16);        // 前 16 字节 IV
  const ct  = encBuffer.subarray(16);           // 剩余是密文

  const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
  // setAutoPadding 默认 true,对应 PKCS7
  return Buffer.concat([decipher.update(ct), decipher.final()]);
}

/** 下载 → 解密 → 存盘 */
async function fetchAndDecrypt(dataSrcUrl, outPath, extraHeaders = {}) {
  const resp = await fetch(dataSrcUrl, {
    headers: {
      'User-Agent': 'Mozilla/5.0',
      'Referer': new URL(dataSrcUrl).origin + '/',  // 防盗链必带
      ...extraHeaders,
    },
  });
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);

  const enc = Buffer.from(await resp.arrayBuffer());
  const img = decryptImage(enc);

  fs.mkdirSync(path.dirname(outPath), { recursive: true });
  fs.writeFileSync(outPath, img);
  return outPath;
}

// 用法
(async () => {
  await fetchAndDecrypt(
    'https://cdn.xxx.com/aaa.webp',
    './out/aaa.webp'
  );
})();

6.2 Python 版

python
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

PLAIN_KEY = b'6X+b6.E>bsXb}+=N'  # 16 字节

def decrypt_image(enc: bytes) -> bytes:
    iv, ct = enc[:16], enc[16:]
    cipher = AES.new(PLAIN_KEY, AES.MODE_CBC, iv)
    return unpad(cipher.decrypt(ct), AES.block_size)  # PKCS7 去填充

def fetch_and_decrypt(url, out_path):
    enc = requests.get(url, headers={
        'User-Agent': 'Mozilla/5.0',
        'Referer': 'https://www.xxx.com/',
    }).content
    with open(out_path, 'wb') as f:
        f.write(decrypt_image(enc))

fetch_and_decrypt('https://cdn.xxx.com/aaa.webp', 'aaa.webp')

7. 验证结果与调试过程

7.1 快速自检:看 Magic Number

解密成功的 webp,前 12 字节必须满足:

52 49 46 46  ?? ?? ?? ??  57 45 42 50
'R' 'I' 'F' 'F'  (size)   'W' 'E' 'B' 'P'
js
console.log(img.subarray(0, 12).toString('hex'));
// 期望:52494646???????? 57454250

如果开头不是这个:

开头 hex实际格式处理
52494646 + 57454250WebP ✅成功
89504e47PNG站点用同一把 key 加密多种格式,按 .png
ffd8ffe0 / ffd8ffe1JPEG.jpg
全乱解密失败查下表

7.2 典型错误 checklist

现象根因修复
final()bad decryptkey 长度不对打印 PLAIN_KEY.length 确认 16
全乱IV 位置错 / padding 错对照 Worker 源码 bytes.slice(0,16) 和 PKCS7
wrong final block length密文非 16 倍数(多半是下载被截断 / 返回了 HTML 错误页)打印 HTTP status + enc.length,补 Referer/Cookie
403 Forbidden防盗链从浏览器 Copy as cURL 照抄请求头
解出来前几字节对、后面乱字节被 Uint16Array/Uint32Array 误读全程用 Uint8Array / Buffer

7.3 与浏览器对比

最稳的终检:

  1. 在浏览器上右键加密图 → "在新标签打开",看到的是浏览器渲染后的真图;
  2. 和脚本解出来的文件尺寸、像素、内容对比一致即通过。

8. 术语小抄

  • Blob:浏览器里装二进制的"文件对象",可直接喂给 <img> / <video>
  • ArrayBuffer:只读的原始字节内存,不能直接读写,必须配"视图";
  • Uint8Array / DataView:架在 ArrayBuffer 上的视图,让你按字节/整数/浮点访问;
  • 异或 ^:相同为 0、不同为 1,最神奇特性是"异或两次等于没动"(a ^ k ^ k === a),简陋加密常用;
  • Base64:二进制 ↔ 文本的无损编码,长度膨胀约 4/3;btoa 编码、atob 解码;
  • 端序:多字节整数"高位在前还是低位在前"的约定;本案全按字节处理,没踩到;
  • 混淆 vs 加密:混淆只是让代码难读,算法和密钥都在前端(本案属此类);真加密的密钥来自服务端且与身份绑定;
  • Web Worker:浏览器的后台线程,解密/压缩放进去不卡 UI,主从靠 postMessage 通信;逆向时在 onmessage 打断点,能一步跳到主线程调用栈。

9. 总结与方法论

9.1 本次案例的五步通用套路

  1. 从异常标记出发:DOM 上的 data-* / blob: / 奇怪的响应体,都是入口线索;
  2. 顺着名字搭桥:收发两端必须共用的字段名(base64Keydecrypted)往往没被混淆,是最稳的抓手;
  3. 断点取运行时值:凡是"浏览器能算出来的",就不要手动逆向——打断点 + Console 求值永远最便宜;
  4. 翻译成脚本:对照 Worker 源码的算法三要素(模式、IV 位置、padding),Node/Python 内置库一把梭;
  5. Magic Number 校验:二进制资源都有文件头特征,是判断"解密是否成功"的最客观信号。

9.2 向其他资源类型迁移

资源Magic Number迁移要点
PNG / JPEG89504E47 / FFD8FFE0同图片流程
MP3494433 (ID3) / FFFB常见分片 + AES
MP4....66747970 (ftyp)可能走 MSE 分段加密
HLS——.m3u8#EXT-X-KEY 自带 AES-128-CBC
JSON 接口——响应体整段 Base64 + AES,找 decrypt 关键字

9.3 合规边线

技术可行 ≠ 合规可行。加密本身就是站点方"不希望内容被直接获取"的明确信号。动手前请务必评估:

  • 内容是否公开、是否有版权保护;
  • 是否违反目标网站的服务条款或 robots.txt
  • 是否涉及付费、隐私、未授权内容。

本文方法仅限用于个人学习、已取得授权的内容、以及自有项目的技术实践。


核心收获一句话:前端能渲染的所有加密资源,原则上都能被离线复现——因为算法和密钥终究要到浏览器里执行。真正拦住你的永远是合规边界和工程量,而不是技术本身。