最近在寻找某电子书时遇到了一个采用自定义 .zjjd 文件格式来保护 PDF 电子书资源的 Web 端阅读器。它的前端代码不仅经过了深度混淆,还套上了一层 WebAssembly (Wasm) 的渲染引擎壳子。
初步分析
在最初的抓包分析中,我并没有找到任何 .pdf 链接,只有几个静态资源和数据分片:
libmupdf.wasm.br:原文件约 21.7MB,解压后约 38.2MB,是经过 Brotli 压缩的 MuPDF WebAssembly 渲染核心。libmupdf.js:负责将上面的 Wasm 引擎解压并实例化。0.zjjd、1.zjjd...:这是按页码请求的文档数据文件。大小约 300KB,没有%PDF-标记,没有 ZIP 结构,字节分布表现出极高的信息熵。
初步分析可知这不是一个把 PDF 静态扔在服务器上的简易网站,而是通过 libmupdf.js 和 libmupdf.wasm.br 还原 .zjjd ,并没有直接在网络上暴露真实的 PDF。
寻找解密链路
既然文件是加密的,那么它一定会在进入 MuPDF 渲染前被还原。
通过监控控制台日志,我注意到了 MuPDF 引擎抛出的一个隐蔽警告:warning: ignoring CMap range (0-0) that is outside of the codespace。
这是解析 PDF 字体/字符映射时的典型错误,也就是说最后交给 MuPDF 的是标准的 PDF 字节流。
顺着这条线索,我定位到了阅读页的核心业务 Chunk(chunk-78d10754.e585c9ee.js)。尽管代码被重度混淆,但它依然暴露出了一套 API 组合:
ArrayBuffer + SHA-256 + deriveKey + openDocumentFromBuffer。
到这里,整条链路已经闭环:
前端按页请求 .zjjd -> 业务 JS 使用 Web Crypto API 解密 -> 得到明文 PDF 的 ArrayBuffer -> 传递给 MuPDF Worker 进行渲染。
拦截
既然明确了 .zjjd 经过解密后会变成 PDF 流给 MuPDF,那么根本不需要去死磕混淆。
通过下面这段 Hook 代码,可直接覆盖原生的 Worker.prototype.postMessage。因为主线程在解密完成后,必然要通过这个方法把二进制流传给 Worker。
// Hook Worker 的 postMessage 通信
const oldPostMessage = Worker.prototype.postMessage;
Worker.prototype.postMessage = function (msg, transfer) {
try {
// 拦截发往 MuPDF 的指令
if (Array.isArray(msg) && msg[0] === "openDocumentFromBuffer") {
const data = msg[1]?.[0];
const magic = msg[1]?.[1];
// 如果载荷是 Uint8Array,说明明文 PDF 已经解密完成准备渲染
if (data instanceof Uint8Array) {
console.log("🔥 成功拦截");
console.log("-> 伪装文件名:", magic);
console.log("-> ASCII 头:", new TextDecoder().decode(data.slice(0, 16)));
// 自动打包为 PDF 并触发下载
const blob = new Blob([data], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `decrypted_page.pdf`;
a.click();
}
}
} catch (e) {
console.log("hook error:", e);
}
return oldPostMessage.call(this, msg, transfer);
};
这段脚本注入后,随着在页面上滚动鼠标加载电子书,可以看到这控制台打印出传给 worker 的参数是 [Uint8Array(330508), 'a.pdf'],即系统将解密出的单页 PDF 数据流包装成名为 a.pdf 的虚拟文件塞给了 MuPDF。
简易脚本自动脱壳
仅仅在浏览器里拿缓存是不够的。
在随后对 Web Crypto API 的底层 Hook,我拿到了加密系统的所有信息:
- 基础密钥 (BaseKey):前端硬编码的字符串
xSeZw1dY2HKAj3yk。 - 派生算法:PBKDF2 (SHA-256, 65536 次迭代)。
- 文件结构:每一个
.zjjd文件的前 24 个字节并非加密内容,而是动态的参数。其中[0:8]字节为 Salt,[8:24]字节为 AES-CBC-128 需要的 IV。
import os
import glob
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
def decrypt_zjjd(input_path, output_path):
# 硬编码基础密码
base_key = b"xSeZw1dY2HKAj3yk"
with open(input_path, "rb") as f:
data = f.read()
# 动态剥离文件头中的 Salt (8字节) 和 IV (16字节)
if len(data) <= 24: return False
salt, iv, encrypted_payload = data[:8], data[8:24], data[24:]
# 派生密钥
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(), length=16, salt=salt,
iterations=65536, backend=default_backend()
)
derived_key = kdf.derive(base_key)
# AES-CBC 内存解密
cipher = Cipher(algorithms.AES(derived_key), modes.CBC(iv), backend=default_backend())
decryptor = cipher.decryptor()
decrypted_data = decryptor.update(encrypted_payload) + decryptor.finalize()
# 写入 PDF 明文
with open(output_path, "wb") as f: f.write(decrypted_data)
return True
if __name__ == "__main__":
# 批量执行脱壳
zjjd_files = sorted(glob.glob("./*.zjjd"))
print(f"[*] 共发现 {len(zjjd_files)} 个加密块,开始脱壳...")
for file_path in zjjd_files:
output_path = file_path.replace(".zjjd", ".pdf")
if decrypt_zjjd(file_path, output_path):
print(f" [+] 脱壳成功: {os.path.basename(file_path)}")
print("\n 全部解密完成,可以使用 copy /b *.pdf final.pdf 合并成书。")