实战:一次 WebP 图片前端加密的 JS 逆向全记录
1. 引言
偶然在一个页面上审查 <img> 元素时,看到这样一段代码:
<img src="blob:https://www.xxx.com/3ed054ca-00ec-462f-b442-13a24ba4481b"
data-src="https://cdn.xxx.com/xxx.webp"
data-decrypted="1">三个明显反常的信号:
src是blob:前缀的临时地址,复制到新标签页打开直接 404;data-src看起来是正规的 webp URL,把它填回src里图片依然打不开;data-decrypted="1"这个自定义属性暗示"已解密"——意味着页面上看到的图是 JS 解密后的产物。
这就是一个典型的"前端二进制加密图片"方案。本文以这条线索为起点,完整记录从"发现加密标识 → 定位 Worker → 定位主线程密钥 → 离线复刻"的实际逆向路径。
读者预期基础:会用 Chrome DevTools 的 Network / Sources / Console,看得懂基础 JS。
2. 环境与工具
| 用途 | 工具 | 备注 |
|---|---|---|
| 前端分析 | Chrome DevTools | Network / Sources / Debugger / Console |
| 脚本复刻 | Node.js ≥ 18 | 内置 fetch + crypto,零依赖 |
| 备选脚本 | Python 3 + pycryptodome | pip install pycryptodome |
| 抓包(可选) | Fiddler / Charles | 一般场景 DevTools 足够 |
3. 整体思路概览
"前端加密图片"的典型套路,拆开就是四步:
- 服务器返回的
.webp文件不是真图,而是被对称算法加密后的二进制密文; - 页面 JS 用
fetch把密文当ArrayBuffer拿下来; - 丢给 Web Worker(后台线程)做解密,避免卡住 UI;
- 解密后的真实字节流包成
Blob→URL.createObjectURL→blob: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 脚本:
// 加密 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 调用点、还有一处赋值语句:
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 调用点(去混淆后):
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 判断)。那段是库代码,不是业务代码。教训:尽量搜业务特征词(base64Key、data-decrypted、decryptWorker),别搜通用 API 名。
4.3 第三步:断点取值,让浏览器把 key 算给你看
decode 函数被混淆得很重,硬逆向算法成本很高——但根本没必要。浏览器每次加载图片都会执行这一行,我们只要断点一次、让它把计算结果"吐出来"即可。
操作步骤:
- 在
const base64Key = btoa(a + b + c)这一行左边行号点一下,打上断点; - 刷新页面或滚动触发图片加载,执行流在断点处停下;
- 切到 Console 面板,直接求值:
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 | 全局搜 decrypted | Worker 端 postMessage 时会带这个字段/标记 |
| 定位主线程 | 搜 Worker 消费的字段名 base64Key | 收发两端字段名必须一致,且名字不易被混淆 |
| 取出真值 | 在赋值行打断点读 Console | 混淆阻止人看懂,但阻止不了运行时求值 |
这四步是可复用的通用套路:从"能看得见的异常标记"出发,顺着"收发两端必须共用的名字"搭桥,最后用断点把运行时结果抓出来。
5. 算法拆解
把整条链路翻译成大白话:
| 步骤 | 做了什么 | 类比 |
|---|---|---|
| ① 下载密文 | fetch(data-src) 拿一坨 ArrayBuffer | 收到一个密封箱 |
| ② 取 IV | 前 16 字节 | 箱面贴着批次号 |
| ③ 取密文 | 第 17 字节起全部 | 箱里的加密货物 |
| ④ AES-CBC 解密 | key=16字节明文, iv, PKCS7 | 用万能钥匙拆封 |
| ⑤ 包 Blob | new Blob([bytes], {type:'image/webp'}) | 重新装进浏览器能识别的袋子 |
| ⑥ 生成 blob URL | URL.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,零依赖)
// 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 版
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'console.log(img.subarray(0, 12).toString('hex'));
// 期望:52494646???????? 57454250如果开头不是这个:
| 开头 hex | 实际格式 | 处理 |
|---|---|---|
52494646 + 57454250 | WebP ✅ | 成功 |
89504e47 | PNG | 站点用同一把 key 加密多种格式,按 .png 存 |
ffd8ffe0 / ffd8ffe1 | JPEG | 按 .jpg 存 |
| 全乱 | 解密失败 | 查下表 |
7.2 典型错误 checklist
| 现象 | 根因 | 修复 |
|---|---|---|
final() 抛 bad decrypt | key 长度不对 | 打印 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 与浏览器对比
最稳的终检:
- 在浏览器上右键加密图 → "在新标签打开",看到的是浏览器渲染后的真图;
- 和脚本解出来的文件尺寸、像素、内容对比一致即通过。
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 本次案例的五步通用套路
- 从异常标记出发:DOM 上的
data-*/blob:/ 奇怪的响应体,都是入口线索; - 顺着名字搭桥:收发两端必须共用的字段名(
base64Key、decrypted)往往没被混淆,是最稳的抓手; - 断点取运行时值:凡是"浏览器能算出来的",就不要手动逆向——打断点 + Console 求值永远最便宜;
- 翻译成脚本:对照 Worker 源码的算法三要素(模式、IV 位置、padding),Node/Python 内置库一把梭;
- Magic Number 校验:二进制资源都有文件头特征,是判断"解密是否成功"的最客观信号。
9.2 向其他资源类型迁移
| 资源 | Magic Number | 迁移要点 |
|---|---|---|
| PNG / JPEG | 89504E47 / FFD8FFE0 | 同图片流程 |
| MP3 | 494433 (ID3) / FFFB | 常见分片 + AES |
| MP4 | ....66747970 (ftyp) | 可能走 MSE 分段加密 |
| HLS | —— | .m3u8 的 #EXT-X-KEY 自带 AES-128-CBC |
| JSON 接口 | —— | 响应体整段 Base64 + AES,找 decrypt 关键字 |
9.3 合规边线
技术可行 ≠ 合规可行。加密本身就是站点方"不希望内容被直接获取"的明确信号。动手前请务必评估:
- 内容是否公开、是否有版权保护;
- 是否违反目标网站的服务条款或
robots.txt; - 是否涉及付费、隐私、未授权内容。
本文方法仅限用于个人学习、已取得授权的内容、以及自有项目的技术实践。
核心收获一句话:前端能渲染的所有加密资源,原则上都能被离线复现——因为算法和密钥终究要到浏览器里执行。真正拦住你的永远是合规边界和工程量,而不是技术本身。