首页
社区
课程
招聘
[原创]4.0微信朋友圈媒体解密全解析
发表于: 2026-2-18 01:53 7918

[原创]4.0微信朋友圈媒体解密全解析

2026-2-18 01:53
7918

本文记录了 WeFlow 项目中实现朋友圈图片/视频/实况照片解密的完整过程——从硬搬 DLL 函数来调用,到用纯 TypeScript 独立实现。

如果你尝试直接访问朋友圈图片的 CDN 地址(形如 
508K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6E0L8i4y4F1M7#2)9J5k6i4q4H3K9h3y4Q4x3X3g2U0L8W2)9J5c8Y4y4F1M7#2)9J5c8W2)9J5k6g2)9J5k6g2)9J5k6b7`.`.) ,你会发现下载下来的文件根本不是正常的 JPEG 或 PNG——它是 加密过的

微信服务器在返回媒体数据时,会在 HTTP 响应头里带上一个字段:

这个 x-Enc: 1 就是在告诉客户端:"这个数据是加密的,你得解密后才能用。" 而解密需要一个 key(密钥),存储在朋友圈动态的 XML 数据里。

那加密用的是什么算法?图片、视频、实况照片的加密方式一样吗?这就是本文要逐一解答的问题。

在谈解密之前,首先要搞清楚:图片/视频的 URL、鉴权令牌和密钥从哪来,怎么请求?

朋友圈的数据存储在微信本地的 WCDB 数据库 (~\xwechat_files\wxid***\db_storage\sns\sns.db) 中。每条动态都有一段 XML 格式的原始数据,里面包含了媒体信息。不同类型的动态,XML 结构有所不同:

普通图片动态:

视频动态(Type 15):

实况照片(Live Photo):

数据库中存的 URL 不能直接用,需要做几个处理:

微信用不同的域名/路径来分发图片和视频:

代码上用一个简单的判断:

微信的 CDN 会检查 User-Agent,普通浏览器 UA 会被拒绝:

图片请求可以加  Accept-Encoding: gzip, deflate, br (CDN 可能对图片做 gzip 压缩返回)。但视频请求不要加,视频流通常不压缩,加上可能导致异常。

作为一个逆向小白,我最开始的思路非常朴素:既然微信客户端自己能解密,那我直接调用它的解密函数不就行了?

用 IDA Pro 打开 Weixin.dll(约 170MB),基地址 0x180000000。

第一步:搜字符串。 在 IDA 的字符串窗口里搜 x-Enc ,找到它位于地址 0x1885282ac

第二步:交叉引用。 右键 → "跳转到交叉引用",发现有 9 个函数引用了这个字符串。其中 sub_1845B0BC0 是 HTTP 响应头解析函数:

第三步:顺藤摸瓜。 在收包处理函数 sub_1845B4360 里,找到检查这个标志的代码:

就这样,通过 字符串搜索 → 交叉引用 → 追踪调用链,定位到了真正的解密函数:sub_182674280
,位于 DLL 偏移 0x2674280

IDA 反编译后,这个函数其实很短(约 0x134 字节),核心逻辑如下:

核心就是:用 seed 初始化一个状态机,持续产生 8 字节密钥流,然后逐字节 XOR。 标准的流密码结构。

既然找到了函数地址,最简单的做法是直接调用它。用 koffi(Node.js 的 FFI 库)加载Weixin.dll :

深入看 sub_1845E3860(状态机初始化函数),一进函数就看到一组硬编码常量和特征操作:

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 位版本。

核心循环 sub_1845E3430 里最有特征的一段:

对比ISAAC原始 C 实现的位移量完全一致。

在 sub_182674280 的 XOR 循环中:

每个 64 位密钥块在 XOR 之前会被转成大端序(Big-Endian)。这个细节如果搞错,解密出来就是乱码。

理解了算法后,就可以彻底摆脱 DLL,用纯 TypeScript 重写。

完整实现只有约 130 行代码,核心分三部分:

1. 初始化 init()

2. 生成随机数 isaac64()

3. 生成大端序密钥流

图片的解密最简单——整个文件每个字节都被 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.)

核心代码:

视频解密和图片有三个关键差异。

图片的密钥在 media[].key字段里,但视频的密钥藏在整条动态 XML 的 <enc key="..." /> 标签中,需要正则提取:

视频文件动辄几十 MB,微信不可能对整个文件做流式 XOR。只加密头部就够了——MP4 的关键元数据(ftypmoov atom)都在前面,头部损坏就无法播放:

图片小,可以在内存里拼接。视频要先流式写入临时文件,下载完再读出来解密:

另外视频请求不要设置 Accept-Encoding: gzip,视频流不走压缩。

实况照片本质上是一张静态图片 + 一段短视频的组合,分别用各自的逻辑处理。

核心解密算法完全一样——都是 ISAAC-64 生成密钥流然后 XOR。区别只在于密钥来源、加密范围和下载方式。

BigInt 在 JavaScript 中性能不太好,解密大视频时会比较慢。所以项目中做了三层实现:

WASM 版本通过 Emscripten 编译,在 Node.js 中用 vm.createContext沙箱加载,暴露  WxIsaac64类调用。

本人对于逆向基本是一窍不通 所以本文由我和claude opus 4.6在cc的研究基础上共同完成 感谢阅读到这里

本文基于 WeFlow 项目的实际开发过程撰写,逆向分析使用 IDA Pro 对 Weixin.dll 进行。

x-Enc: 1
x-Enc: 1
<media>
 
  <url>696K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3#2E0M7$3&6K6i4K6u0W2M7i4m8A6j5#2)9J5k6h3y4F1i4K6u0r3M7$3&6K6i4K6u0r3i4K6u0W2i4K6u0W2i4K6u0W2i4K6t1$3L8s2c8Q4x3@1u0Q4x3V1j5`.url>
 
  <thumb>a29K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3#2E0M7$3&6K6i4K6u0W2M7i4m8A6j5#2)9J5k6h3y4F1i4K6u0r3M7$3&6K6i4K6u0r3i4K6u0W2i4K6u0W2i4K6u0W2i4K6u0r3x3e0f1H3i4K6t1$3L8s2c8Q4x3@1u0Q4x3V1j5`.thumb>
 
  <token>xxxxxxxxxxxxxx</token>
 
  <key>123456</key>
 
</media>
<media>
 
  <url>3b0K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3#2E0M7$3&6K6i4K6u0W2M7i4m8A6j5#2)9J5k6h3y4F1i4K6u0r3M7$3&6K6i4K6u0r3i4K6u0W2i4K6u0W2i4K6u0W2i4K6t1$3L8s2c8Q4x3@1u0Q4x3V1j5`.url>
 
  <thumb>c92K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3#2E0M7$3&6K6i4K6u0W2M7i4m8A6j5#2)9J5k6h3y4F1i4K6u0r3M7$3&6K6i4K6u0r3i4K6u0W2i4K6u0W2i4K6u0W2i4K6u0r3x3e0f1H3i4K6t1$3L8s2c8Q4x3@1u0Q4x3V1j5`.thumb>
 
  <token>xxxxxxxxxxxxxx</token>
 
  <key>123456</key>
 
</media>
<media>
 
  <url>609K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4y4F1M7%4k6A6k6r3g2G2k6r3!0%4L8X3I4G2j5h3c8Q4x3X3g2Q4x3X3g2Q4x3X3g2Q4x3V1k6$3K9h3c8W2L8#2)9J5k6g2)9J5k6g2)9J5k6g2)9J5y4X3I4@1i4K6y4n7i4K6u0r3url>
 
  <thumb>4c5K9s2c8@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" />
<media>
 
  <url>278K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4y4F1M7%4k6A6k6r3g2G2k6r3!0%4L8X3I4G2j5h3c8Q4x3X3g2Q4x3X3g2Q4x3X3g2Q4x3V1k6$3K9h3c8W2L8#2)9J5k6g2)9J5k6g2)9J5k6g2)9J5y4X3I4@1i4K6y4n7i4K6u0r3url>
 
  <thumb>17bK9s2c8@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" />
<media>
 
  <url>be1K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3#2E0M7$3&6K6i4K6u0W2M7i4m8A6j5#2)9J5k6h3y4F1i4K6u0r3M7$3&6K6i4K6u0r3i4K6u0W2i4K6u0W2i4K6u0W2i4K6t1$3L8s2c8Q4x3@1u0Q4x3V1j5`.url>     <!-- 静态图片 -->
 
  <key>123456</key>
 
  <token>xxx</token>
 
  <livePhoto>
 
    <url>3f4K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4y4F1M7%4k6A6k6r3g2G2k6r3!0%4L8X3I4G2j5h3c8Q4x3X3g2Q4x3X3g2Q4x3X3g2Q4x3V1k6Q4x3X3g2Q4x3X3g2Q4x3X3g2Q4x3U0k6D9N6q4)9K6b7W2)9J5c8R3`.`.url<!-- 视频部分 -->
 
    <token>xxx</token>
 
  </livePhoto>
 
</media>
 
<enc key="2105122989" /> <!-- 视频的密钥 -->
<media>
 
  <url>03cK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3#2E0M7$3&6K6i4K6u0W2M7i4m8A6j5#2)9J5k6h3y4F1i4K6u0r3M7$3&6K6i4K6u0r3i4K6u0W2i4K6u0W2i4K6u0W2i4K6t1$3L8s2c8Q4x3@1u0Q4x3V1j5`.url>     <!-- 静态图片 -->
 
  <key>123456</key>
 
  <token>xxx</token>
 
  <livePhoto>
 
    <url>e5bK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4y4F1M7%4k6A6k6r3g2G2k6r3!0%4L8X3I4G2j5h3c8Q4x3X3g2Q4x3X3g2Q4x3X3g2Q4x3V1k6Q4x3X3g2Q4x3X3g2Q4x3X3g2Q4x3U0k6D9N6q4)9K6b7W2)9J5c8R3`.`.url<!-- 视频部分 -->
 
    <token>xxx</token>
 
  </livePhoto>
 
</media>
 
<enc key="2105122989" /> <!-- 视频的密钥 -->
// 1. HTTP → HTTPS
 
let 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`;
 
}
// 1. HTTP → HTTPS
 
let 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 特征 类型 说明
mmsns.qpic.cn/sns/... 图片 朋友圈图片
vweixinthumb... 缩略图 视频封面,本质是图片
snsvideodownload... 视频 视频文件
const isVideoUrl = (url: string) => {
 
    if (url.includes('vweixinthumb')) return false;  // 排除视频缩略图
 
    return url.includes('snsvideodownload') || url.includes('video') || url.includes('.mp4');
 
};
const isVideoUrl = (url: string) => {
 
    if (url.includes('vweixinthumb')) return false;  // 排除视频缩略图
 
    return url.includes('snsvideodownload') || url.includes('video') || url.includes('.mp4');
 
};
headers: {
 
    'User-Agent': 'MicroMessenger Client',
 
    'Accept': '*/*',
 
    'Connection': 'keep-alive'
 
}
headers: {
 
    'User-Agent': 'MicroMessenger Client',
 
    'Accept': '*/*',
 
    'Connection': 'keep-alive'
 
}
0x1845b1323  lea  rdx, aXEnc        ; "x-Enc"
 
0x1845b132e  call sub_1826819D0     ; 在响应头中查找
 
0x1845b1359  mov  [rcx+1428h], al   ; 把结果写到对象 +0x1428 偏移处
0x1845b1323  lea  rdx, aXEnc        ; "x-Enc"
 
0x1845b132e  call sub_1826819D0     ; 在响应头中查找
 
0x1845b1359  mov  [rcx+1428h], al   ; 把结果写到对象 +0x1428 偏移处
0x1845b4473  cmp  byte ptr [rax+1428h], 1   ; 是否需要解密?
 
0x1845b447a  jnz  loc_1845B4525              ; 不需要就跳过
 
; ...
 
0x1845b44f3  call sub_182674280              ; 调用解密函数!
0x1845b4473  cmp  byte ptr [rax+1428h], 1   ; 是否需要解密?
 
0x1845b447a  jnz  loc_1845B4525              ; 不需要就跳过
 
; ...
 
0x1845b44f3  call sub_182674280              ; 调用解密函数!
// 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];
 
    }
 
}
// 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];
 
    }
 
}
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 就是解密后的数据
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 到自己进程
黑盒调用 完全不知道算法原理,出问题无法调试
// 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;
// 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;
 

传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

最后于 2026-3-3 00:29 被xunchahaha编辑 ,原因:
收藏
免费 20
支持
分享
最新回复 (11)
雪    币: 104
活跃值: (700)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
xed
2
和视频号一样是isaac64
2026-2-20 14:45
0
雪    币: 6291
活跃值: (3295)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
感谢你的贡献,论坛因你而更加精彩!
2026-2-24 11:43
0
雪    币: 231
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
4
xed 和视频号一样是isaac64
并非 还是有细微差异
2026-3-3 00:30
0
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
5
666
2026-3-3 15:00
0
雪    币: 19
活跃值: (408)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
谢谢分享~
2026-3-8 04:54
0
雪    币: 217
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
7
谢谢分享
2026-3-10 15:48
0
雪    币: 400
活跃值: (65)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8
感谢分享
6天前
0
雪    币: 2595
活跃值: (5016)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
9
666
6天前
0
雪    币: 217
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
10
感谢分享
6天前
0
雪    币: 318
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
11
求助大佬,安卓微信小程序游戏,号被限制社交了,游戏内赠送道具分享出来的图链接只能分享给自己或文件传输助手,求帮转成明文链接。不白忙活
6天前
0
雪    币: 259
活跃值: (447)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
12
学习
5天前
0
游客
登录 | 注册 方可回帖
返回