-
-
[原创]4.0微信朋友圈媒体解密全解析
-
发表于: 1天前 395
-
本文记录了 WeFlow 项目中实现朋友圈图片/视频/实况照片解密的完整过程——从硬搬 DLL 函数来调用,到用纯 TypeScript 独立实现。
0x00 背景:朋友圈的图片为什么打不开?
如果你尝试直接访问朋友圈图片的 CDN 地址(形如 b18K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6E0L8i4y4F1M7#2)9J5k6i4q4H3K9h3y4Q4x3X3g2U0L8W2)9J5c8Y4y4F1M7#2)9J5c8W2)9J5k6g2)9J5k6g2)9J5k6b7`.`.) ,你会发现下载下来的文件根本不是正常的 JPEG 或 PNG——它是 加密过的。
微信服务器在返回媒体数据时,会在 HTTP 响应头里带上一个字段:
1 | x-Enc: 1 |
这个 x-Enc: 1 就是在告诉客户端:"这个数据是加密的,你得解密后才能用。" 而解密需要一个 key(密钥),存储在朋友圈动态的 XML 数据里。
那加密用的是什么算法?图片、视频、实况照片的加密方式一样吗?这就是本文要逐一解答的问题。
0x01 媒体数据是怎么下载下来的?
在谈解密之前,首先要搞清楚:图片/视频的 URL、鉴权令牌和密钥从哪来,怎么请求?
一切数据都在本地数据库里
朋友圈的数据存储在微信本地的 WCDB 数据库 (~\xwechat_files\wxid***\db_storage\sns\sns.db) 中。每条动态都有一段 XML 格式的原始数据,里面包含了媒体信息。不同类型的动态,XML 结构有所不同:
普通图片动态:
1 2 3 4 5 6 7 8 9 10 11 | <media> <url>668K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3#2E0M7$3&6K6i4K6u0W2M7i4m8A6j5#2)9J5k6h3y4F1i4K6u0r3M7$3&6K6i4K6u0r3i4K6u0W2i4K6u0W2i4K6u0W2i4K6t1$3L8s2c8Q4x3@1u0Q4x3V1j5`.url> <thumb>796K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3#2E0M7$3&6K6i4K6u0W2M7i4m8A6j5#2)9J5k6h3y4F1i4K6u0r3M7$3&6K6i4K6u0r3i4K6u0W2i4K6u0W2i4K6u0W2i4K6u0r3x3e0f1H3i4K6t1$3L8s2c8Q4x3@1u0Q4x3V1j5`.thumb> <token>xxxxxxxxxxxxxx</token> <key>123456</key></media> |
视频动态(Type 15):
1 2 3 4 5 6 7 8 9 10 11 12 13 | <media> <url>2d3K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4y4F1M7%4k6A6k6r3g2G2k6r3!0%4L8X3I4G2j5h3c8Q4x3X3g2Q4x3X3g2Q4x3X3g2Q4x3V1k6$3K9h3c8W2L8#2)9J5k6g2)9J5k6g2)9J5k6g2)9J5y4X3I4@1i4K6y4n7i4K6u0r3url> <thumb>04fK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4k6%4k6h3W2^5K9h3&6@1K9s2g2E0j5W2)9J5k6g2)9J5k6g2)9J5k6g2)9J5c8Y4c8Z5N6h3#2T1L8X3q4A6L8q4)9J5k6g2)9J5k6g2)9J5k6g2)9J5y4X3I4@1i4K6y4n7i4K6u0r3thumb> <token>xxxxxxxxxxxxxx</token></media><!-- 注意:视频的密钥不在 media 里,而在外层的 enc 标签中 --><enc key="2105122989" /> |
实况照片(Live Photo):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | <media> <url>c5dK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3#2E0M7$3&6K6i4K6u0W2M7i4m8A6j5#2)9J5k6h3y4F1i4K6u0r3M7$3&6K6i4K6u0r3i4K6u0W2i4K6u0W2i4K6u0W2i4K6t1$3L8s2c8Q4x3@1u0Q4x3V1j5`.url> <!-- 静态图片 --> <key>123456</key> <token>xxx</token> <livePhoto> <url>552K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4y4F1M7%4k6A6k6r3g2G2k6r3!0%4L8X3I4G2j5h3c8Q4x3X3g2Q4x3X3g2Q4x3X3g2Q4x3V1k6Q4x3X3g2Q4x3X3g2Q4x3X3g2Q4x3U0k6D9N6q4)9K6b7W2)9J5c8R3`.`.url> <!-- 视频部分 --> <token>xxx</token> </livePhoto></media><enc key="2105122989" /> <!-- 视频的密钥 --> |
URL 修正
数据库中存的 URL 不能直接用,需要做几个处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | // 1. HTTP → HTTPSlet fixedUrl = url.replace('http://', 'https://');// 2. 图片:把缩略图 /150 改成 /0 获取原图(视频不需要)if (!isVideo) { fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1');}// 3. 拼接 token(没有 token 服务器直接 403)if (isVideo) { // 视频:token 必须放在参数最前面 return `${baseUrl}?token=${token}&idx=1${existingParams}`;} else { // 图片:token 追加到末尾 return `${fixedUrl}${connector}token=${token}&idx=1`;} |
区分图片和视频 URL
微信用不同的域名/路径来分发图片和视频:
| URL 特征 | 类型 | 说明 |
|---|---|---|
mmsns.qpic.cn/sns/... |
图片 | 朋友圈图片 |
vweixinthumb... |
缩略图 | 视频封面,本质是图片 |
snsvideodownload... |
视频 | 视频文件 |
代码上用一个简单的判断:
1 2 3 4 5 6 7 | const isVideoUrl = (url: string) => { if (url.includes('vweixinthumb')) return false; // 排除视频缩略图 return url.includes('snsvideodownload') || url.includes('video') || url.includes('.mp4');}; |
请求头伪装
微信的 CDN 会检查 User-Agent,普通浏览器 UA 会被拒绝:
1 2 3 4 5 6 7 8 9 | headers: { 'User-Agent': 'MicroMessenger Client', 'Accept': '*/*', 'Connection': 'keep-alive'} |
图片请求可以加 Accept-Encoding: gzip, deflate, br (CDN 可能对图片做 gzip 压缩返回)。但视频请求不要加,视频流通常不压缩,加上可能导致异常。
0x02 第一阶段:逆向小白,直接调 DLL
作为一个逆向小白,我最开始的思路非常朴素:既然微信客户端自己能解密,那我直接调用它的解密函数不就行了?
在 IDA 里找到解密函数
用 IDA Pro 打开 Weixin.dll(约 170MB),基地址 0x180000000。
第一步:搜字符串。 在 IDA 的字符串窗口里搜 x-Enc ,找到它位于地址 0x1885282ac 。
第二步:交叉引用。 右键 → "跳转到交叉引用",发现有 9 个函数引用了这个字符串。其中 sub_1845B0BC0 是 HTTP 响应头解析函数:
1 2 3 4 5 | 0x1845b1323 lea rdx, aXEnc ; "x-Enc"0x1845b132e call sub_1826819D0 ; 在响应头中查找0x1845b1359 mov [rcx+1428h], al ; 把结果写到对象 +0x1428 偏移处 |
第三步:顺藤摸瓜。 在收包处理函数 sub_1845B4360 里,找到检查这个标志的代码:
1 2 3 4 5 6 7 | 0x1845b4473 cmp byte ptr [rax+1428h], 1 ; 是否需要解密?0x1845b447a jnz loc_1845B4525 ; 不需要就跳过; ...0x1845b44f3 call sub_182674280 ; 调用解密函数! |
就这样,通过 字符串搜索 → 交叉引用 → 追踪调用链,定位到了真正的解密函数:sub_182674280
,位于 DLL 偏移 0x2674280。
解密函数的反编译结果
IDA 反编译后,这个函数其实很短(约 0x134 字节),核心逻辑如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | // sub_182674280 的简化伪代码// 参数:rcx=输入缓冲, rdx=长度, r8=输出缓冲, r9=seed(密钥)void decrypt(uint8_t* input, uint64_t length, uint8_t* output, uint64_t seed) { uint8_t state[4128]; // 巨大的状态缓冲区 uint64_t keyblock; init_state(state, &seed, 1); // 用 seed 初始化状态机 for (int i = 0; i < length; i++) { if ((i & 7) == 0) { // 每 8 字节取一次新的密钥块 uint64_t raw = get_next_keyblock(state); // 注意这里的字节序转换! keyblock = htonl(hi32(raw)) << 32 | htonl(lo32(raw)); } output[i] = input[i] ^ ((uint8_t*)&keyblock)[i & 7]; }} |
核心就是:用 seed 初始化一个状态机,持续产生 8 字节密钥流,然后逐字节 XOR。 标准的流密码结构。
用 koffi(FFI)直接调用
既然找到了函数地址,最简单的做法是直接调用它。用 koffi(Node.js 的 FFI 库)加载Weixin.dll :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | const WEIXIN_DLL_OFFSET = 0x2674280n;// 加载 DLL,获取内存基地址const koffi = require('koffi');const weixinLib = koffi.load(dllPath);const kernel32 = koffi.load('kernel32.dll');const getModuleHandleW = kernel32.func( 'void* __stdcall GetModuleHandleW(str16 lpModuleName)');const modulePtr = getModuleHandleW('Weixin.dll');const base = koffi.address(modulePtr) as bigint;// 计算解密函数的绝对地址const decryptAddr = base + WEIXIN_DLL_OFFSET;// 解码为可调用的函数指针const addrBox = new BigUint64Array(1);addrBox[0] = decryptAddr;const decryptPtr = koffi.decode(addrBox, 'void *');const decryptProto = koffi.proto( 'uint64 __fastcall SnsImageDecrypt(void* src, uint64 len, void* dst, uint64 key)');const decryptFn = koffi.decode(decryptPtr, decryptProto);// 调用解密const out = Buffer.allocUnsafe(data.length);decryptFn(data, BigInt(data.length), out, parsedKey);// out 就是解密后的数据 |
这种方式的问题
| 问题 | 说明 |
|---|---|
| 依赖 Weixin.dll | 用户必须本地安装了微信,且路径可找到 |
| 版本耦合 | 微信更新后函数偏移可能变化 |
| 平台限制 | 只能在 Windows 上运行 |
| 安全风险 | 加载 170MB 第三方 DLL 到自己进程 |
| 黑盒调用 | 完全不知道算法原理,出问题无法调试 |
0x03 第二阶段:搞清楚算法 —— ISAAC-64
关键线索:初始化函数里的魔数
深入看 sub_1845E3860(状态机初始化函数),一进函数就看到一组硬编码常量和特征操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | // 8 个初始变量都被设为同一个值a = b = c = d = e = f = g = h = 0x9e3779b97f4a7c15;// mix 函数中的位移操作a = (a - e); f ^= (h >> 9); h = h + a;b = (b - f); g ^= (a << 9); a = a + b;c = (c - g); h ^= (b >> 23); b = b + c;d = (d - h); a ^= (c << 15); c = c + d;e = (e - a); b ^= (d >> 14); d = d + e;f = (f - b); c ^= (e << 20); e = e + f;g = (g - c); d ^= (f >> 17); f = f + g;h = (h - d); e ^= (g << 14); g = g + h; |
0x9e3779b97f4a7c15 是黄金比例 φ 的 64 位定点表示。搜索这个常量加上那组位移量
9, 9, 23, 15, 14, 20, 17, 14,直接命中 —— 这是 ISAAC-64。
ISAAC 全称 Indirection, Shift, Accumulate, Add, and Count,是密码学家 Bob Jenkins 在 1996 年发明的 CSPRNG(密码学安全伪随机数生成器)。ISAAC-64 是它的 64 位版本。
DLL 函数与 ISAAC-64 的对应关系
| DLL 函数 | 作用 | ISAAC-64 对应 |
|---|---|---|
sub_1845E3860 |
初始化(含 mix 操作) | randinit() |
sub_1845E3430 |
生成一轮 256 个随机数 | isaac64() 核心循环 |
sub_1845E33E0 |
从池中取下一个随机数 | rand()— 逆序消费 randrsl[] |
sub_1845E36F0 |
拷贝 seed 入缓冲区 | seed() |
sub_182674280 |
XOR 解密循环 | 应用层解密 |
核心循环 sub_1845E3430 里最有特征的一段:
1 2 3 4 5 6 7 8 9 | // 四步循环,根据 i%4 使用不同位移case 0: aa = ~(aa ^ (aa << 21)); // 取反 + 左移21case 1: aa = aa ^ (aa >> 5); // 右移5case 2: aa = aa ^ (aa << 12); // 左移12case 3: aa = aa ^ (aa >> 33); // 右移33 |
对比ISAAC原始 C 实现的位移量完全一致。
一个容易忽略的细节:字节序
在 sub_182674280 的 XOR 循环中:
1 2 3 4 5 | v12 = htonl(HIDWORD(v10)); // 高 32 位 → 大端序v13 = htonl(v11); // 低 32 位 → 大端序v15 = (v13 << 32) | v12; // 重新组合 |
每个 64 位密钥块在 XOR 之前会被转成大端序(Big-Endian)。这个细节如果搞错,解密出来就是乱码。
0x04 第三阶段:完全独立实现
理解了算法后,就可以彻底摆脱 DLL,用纯 TypeScript 重写。
ISAAC-64 的 TypeScript 实现
完整实现只有约 130 行代码,核心分三部分:
1. 初始化 init()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | // 黄金比例 —— ISAAC-64 的签名常量a = b = c = d = e = f = g = h = 0x9e3779b97f4a7c15n;const mix = () => { a = (a - e) & MASK; f ^= (h >> 9n); h = (h + a) & MASK; b = (b - f) & MASK; g ^= (a << 9n); a = (a + b) & MASK; c = (c - g) & MASK; h ^= (b >> 23n); b = (b + c) & MASK; d = (d - h) & MASK; a ^= (c << 15n); c = (c + d) & MASK; e = (e - a) & MASK; b ^= (d >> 14n); d = (d + e) & MASK; f = (f - b) & MASK; c ^= (e << 20n); e = (e + f) & MASK; g = (g - c) & MASK; d ^= (f >> 17n); f = (f + g) & MASK; h = (h - d) & MASK; e ^= (g << 14n); g = (g + h) & MASK;};for (let i = 0; i < 4; i++) mix(); // 预混 4 轮// 把种子混入并写入 256 槽的 mm[] 数组for (let i = 0; i < 256; i += 8) { a = (a + randrsl[i]) & MASK; // randrsl[0] = seed // ... b~h 同理 mix(); mm[i] = a; mm[i+1] = b; /* ... */ mm[i+7] = h;} |
2. 生成随机数 isaac64()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | private isaac64() { this.cc = (this.cc + 1n) & MASK; this.bb = (this.bb + this.cc) & MASK; for (let i = 0; i < 256; i++) { let x = this.mm[i]; switch (i & 3) { case 0: this.aa ^= ~(this.aa << 21n); break; case 1: this.aa ^= (this.aa >> 5n); break; case 2: this.aa ^= (this.aa << 12n); break; case 3: this.aa ^= (this.aa >> 33n); break; } this.aa = (this.mm[(i + 128) & 255] + this.aa) & MASK; const y = (this.mm[Number(x >> 3n) & 255] + this.aa + this.bb) & MASK; this.mm[i] = y; this.bb = (this.mm[Number(y >> 11n) & 255] + x) & MASK; this.randrsl[i] = this.bb; }} |
3. 生成大端序密钥流
1 2 3 4 5 6 7 8 9 10 11 12 13 | public generateKeystreamBE(size: number): Buffer { const buffer = Buffer.allocUnsafe(size); for (let i = 0; i < Math.floor(size / 8); i++) { buffer.writeBigUInt64BE(this.getNext(), i * 8); } return buffer;} |
0x05 图片解密:全文件 XOR
图片的解密最简单——整个文件每个字节都被 XOR 加密:
WCDB 数据库
│ 解析 XML → 拿到 url, token, key
▼
修正 URL (http→https, /150→/0, 拼 token)
│
▼
HTTPS GET (UA: MicroMessenger Client, Accept-Encoding: gzip/br)
│
▼
解压 gzip/br → 拿到加密的原始数据
│
▼
检查响应头 x-Enc: 1 → 需要解密
│
▼
ISAAC-64(media.key) → 生成与图片等长的密钥流
│
▼
encrypted[i] ⊕ keystream[i] → 解密后的 JPEG/PNG/WebP
│
▼
验证文件头魔数 (FF D8 FF = JPEG, 89 50 4E 47 = PNG, etc.)
核心代码:
1 2 3 4 5 6 7 8 9 10 11 | const wasmService = WasmService.getInstance();const keystream = await wasmService.getKeystream(key, raw.length);const decrypted = Buffer.allocUnsafe(raw.length);for (let i = 0; i < raw.length; i++) { decrypted[i] = raw[i] ^ keystream[i];} |
0x06 视频解密:只加密前 128KB
视频解密和图片有三个关键差异。
差异一:密钥来源不同
图片的密钥在 media[].key字段里,但视频的密钥藏在整条动态 XML 的 <enc key="..." /> 标签中,需要正则提取:
1 2 3 4 5 6 7 | const extractVideoKey = (xml: string): string | undefined => { const match = xml.match(/<enc\s+key="(\d+)"/i); return match ? match[1] : undefined;}; |
差异二:只加密前 128KB
视频文件动辄几十 MB,微信不可能对整个文件做流式 XOR。只加密头部就够了——MP4 的关键元数据(ftyp、moov atom)都在前面,头部损坏就无法播放:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // 只生成 128KB 密钥流const keystream = await wasmService.getKeystream(key, 131072);// 只解密前 128KB(或文件长度,取较小值)const decryptLen = Math.min(keystream.length, raw.length);for (let i = 0; i < decryptLen; i++) { raw[i] ^= keystream[i];}// 验证:检查 offset 4 处是否为 'ftyp'const ftyp = raw.subarray(4, 8).toString('ascii');if (ftyp === 'ftyp') { /* 解密成功 */ } |
差异三:下载方式不同
图片小,可以在内存里拼接。视频要先流式写入临时文件,下载完再读出来解密:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | // 1. 下载到临时文件const tmpPath = path.join(os.tmpdir(), `sns_video_${Date.now()}.enc`);const fileStream = fs.createWriteStream(tmpPath);res.pipe(fileStream);fileStream.on('finish', async () => { // 2. 读出加密数据 const raw = await readFile(tmpPath); // 3. 解密前 128KB const keystream = await wasmService.getKeystream(key, 131072); const decryptLen = Math.min(keystream.length, raw.length); for (let i = 0; i < decryptLen; i++) { raw[i] ^= keystream[i]; } // 4. 写入缓存,删除临时文件 await writeFile(cachePath, raw); await unlink(tmpPath);}); |
另外视频请求不要设置 Accept-Encoding: gzip,视频流不走压缩。
0x07 实况照片:图片 + 视频的组合
实况照片本质上是一张静态图片 + 一段短视频的组合,分别用各自的逻辑处理。
数据结构
1 2 3 4 5 6 7 8 9 10 11 | SnsMedia { url: "静态图片 CDN" ← 按图片流程处理 key: "123456" ← 图片的解密密钥 token: "xxx" livePhoto: { url: "视频 CDN" ← 按视频流程处理 token: "xxx" key: (可能为空) }}rawXml 中:<enc key="2105122989" /> ← 视频的解密密钥 |
密钥的优先级
1 2 3 4 5 6 7 | // 图片部分:直接用 media.keyimageKey = m.key;// 视频部分:优先用 XML 提取的 key,其次用 livePhoto 自带的 key,最后用 media.keyvideoKey = xmlEncKey || m.livePhoto.key || m.key; |
处理流程
1 2 3 4 5 6 7 | 实况照片 │ ├── 静态图片部分 │ └── 走图片解密流程(全文件 XOR,key = media.key) │ └── 视频部分 └── 走视频解密流程(前 128KB XOR,key = XML enc key) |
0x08 三种媒体类型对比
| 图片 | 视频 | 实况照片 | |
|---|---|---|---|
| URL 域名 | mmsns.qpic.cn |
snsvideodownload |
图片 + 视频各一个 |
| 密钥来源 | media[].key |
XML 中 <enc key> |
图片用 media.key;视频用 XML key |
| 加密范围 | 全文件 | 仅前 128KB | 图片全文件;视频前 128KB |
| 下载方式 | 内存收集 chunks | 流式写临时文件 | 分别处理 |
| gzip 解压 | 需要 | 不需要 | 图片需要;视频不需要 |
| URL token 位置 | 追加到末尾 | 放在参数最前面 | 各按类型 |
| 缩略图 → 原图 | /150 → /0 |
不做替换 | 图片替换;视频不替换 |
| 验证方式 | JPEG FF D8 FF |
MP4 ftyp @ offset 4 |
各自验证 |
| 解密算法 | ISAAC-64 + XOR | ISAAC-64 + XOR | ISAAC-64 + XOR |
核心解密算法完全一样——都是 ISAAC-64 生成密钥流然后 XOR。区别只在于密钥来源、加密范围和下载方式。
0x09 WASM 加速与 Fallback
BigInt 在 JavaScript 中性能不太好,解密大视频时会比较慢。所以项目中做了三层实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | let keystream: Buffer;try { // 第一优先:WASM 版本(C 编译的 ISAAC-64,性能最好) const wasmService = WasmService.getInstance(); keystream = await wasmService.getKeystream(key, size);} catch (wasmErr) { // Fallback:纯 TypeScript BigInt 版本 const isaac = new Isaac64(key); keystream = isaac.generateKeystreamBE(size);} |
WASM 版本通过 Emscripten 编译,在 Node.js 中用 vm.createContext沙箱加载,暴露 WxIsaac64类调用。
后记
本人对于逆向基本是一窍不通 所以本文由我和claude opus 4.6在cc的研究基础上共同完成 感谢阅读到这里
致谢:
ida-mcp 基于此加上codex5.3对微信朋友圈的加密算法初步了解
本文基于 WeFlow 项目的实际开发过程撰写,逆向分析使用 IDA Pro 对 Weixin.dll 进行。
[培训]Windows内核深度攻防:从Hook技术到Rootkit实战!