<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>工具 on Sarv&#39;s Blog</title>
    <link>https://sarv.blog/tags/%E5%B7%A5%E5%85%B7/</link>
    <description>Recent content in 工具 on Sarv&#39;s Blog</description>
    <generator>Hugo -- 0.144.2</generator>
    <language>zh-CN</language>
    <lastBuildDate>Tue, 19 Aug 2025 00:00:00 +0800</lastBuildDate>
    <atom:link href="https://sarv.blog/tags/%E5%B7%A5%E5%85%B7/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>WXAM（WXGF）图片格式解析</title>
      <link>https://sarv.blog/posts/wxam/</link>
      <pubDate>Tue, 19 Aug 2025 00:00:00 +0800</pubDate>
      <guid>https://sarv.blog/posts/wxam/</guid>
      <description>&lt;h3 id=&#34;概述&#34;&gt;概述&lt;/h3&gt;
&lt;p&gt;微信 4 的正式版本更新了图片加密算法，从测试版的通用 AES 密钥调整为每台设备不同的  AES 密钥。&lt;br&gt;
老样子，从内存数据中将 AES 密钥扒拉出来，然后就遇到了 WXAM 图片数据。&lt;br&gt;
经过不少时间的分析和处理，chatlog 目前已经能够初步解析 WXAM 图片了，写篇博客记录一下。&lt;/p&gt;</description>
      <content:encoded><![CDATA[<h3 id="概述">概述</h3>
<p>微信 4 的正式版本更新了图片加密算法，从测试版的通用 AES 密钥调整为每台设备不同的  AES 密钥。<br>
老样子，从内存数据中将 AES 密钥扒拉出来，然后就遇到了 WXAM 图片数据。<br>
经过不少时间的分析和处理，chatlog 目前已经能够初步解析 WXAM 图片了，写篇博客记录一下。</p>
<p>相关代码：<a href="https://github.com/sjzar/chatlog/blob/a16b689/pkg/util/dat2img/wxgf.go">https://github.com/sjzar/chatlog/blob/a16b689/pkg/util/dat2img/wxgf.go</a></p>
<h3 id="相关资料">相关资料</h3>
<p>由于是内部格式，网上能找到的资料少的可怜，唯一的官方信息是18 年腾讯工程团队发布的文章：<a href="https://cloud.tencent.com/developer/article/1028362">如何节省 1TB 图片带宽？解密极致图像压缩-腾讯云开发者社区-腾讯云</a><br>
明确了几点信息，WXAM 格式的目标是极致压缩比，并且应该是没有加密逻辑。</p>
<p>其余资料：</p>
<ul>
<li><a href="https://ppwwyyxx.com/blog/2025/wechat-dump-10-years/#WXGF-%E8%A7%A3%E7%A0%81">写在 wechat-dump 项目的第十年 - Yuxin&rsquo;s Blog</a>  Yuxin Wu 大佬尝试直接使用 Android 微信的 <code>libwechatcommon.so</code> 进行解码。</li>
<li><a href="https://github.com/recarto404/WxDatDecrypt">GitHub - recarto404/WxDatDecrypt: 解密与查看微信 4.1 的图片，将微信缓存的 dat 文件解密为原始图片格式</a>
recarto404 大佬发布了支持 wxgf 的微信图片查看器，使用的是 Windows 微信的 <code>VoipEngine.dll</code> 进行解码。</li>
<li><a href="https://signal-labs.com/fuzzing-wechats-wxam-parser/">Fuzzing WeChat’s Wxam Parser | Advanced Offensive Cybersecurity Training</a>  Christopher Vella 写了一篇模 wxam 糊测试案例，同样是分析了<code>VoipEngine.dll</code></li>
</ul>
<p>以上就是全网能找到的有价值的 WXAM 图片格式信息了，还有例如微信官方在一次更新时，错误的将 wxgf 图片分发给了小程序，导致图片无法解析（哈哈哈哈哈：<a href="https://developers.weixin.qq.com/community/develop/doc/0000c84a7080f8cb5322246ff6b800?jumpto=comment&amp;commentid=00082af34d4eb01b5a225787966c">微信开放社区</a><br>
虽然资料较少，但是目标还挺清晰的，直接利用官方的 dll 进行分析，只要分析出编码机制，就能编写转换代码了，计划通！直到我遇到了&hellip;哈哈先卖个关子。</p>
<h3 id="分析过程">分析过程</h3>
<p>使用 IDA 打开 <code>VoipEngine.dll</code> 文件，直接按照关键词查询，就能看到. 晰的函数名称。<br>
<img loading="lazy" src="https://qupfile.cloudvdn.com/IMG-20250819000537497.png"></p>
<p>从 <code>wxam_dec_wxam2pic_5</code> 函数入口开始分析，写点注释，做点笔记，稍微耐心一点，一般都能梳理出整体的处理框架。 <br>
现在做逆向分析比之前要轻松不少，可读性较差的 pseudocode 可以直接丢给 LLM，能够得到比较容易理解的结果，甚至 IDA Pro 还有 MCP（还没试过）。<br>
猜测是因为有较重的历史包袱，所以 WXAM 的代码中充满各种分支判断和参数配置，所以在分析时尽量按照层级处理，不要追着 call 一路 F7，会迷路的。</p>
<p>第一轮函数分析，感觉良好，这是一个结构清晰的函数，感觉马上就能完成解析。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>sub_7FFD9B5D2540            // 入口
</span></span><span style="display:flex;"><span>├─ wxam_dec_isWXGF_5        // 判断 Header
</span></span><span style="display:flex;"><span>├─ wxam_dec_init_5          // 初始内存分配
</span></span><span style="display:flex;"><span>├─ wxam_dec_decode_buffer_5 // 第一次调用 decode
</span></span><span style="display:flex;"><span>├─ wxam_dec_get_option_5    // 第一次调用 get option
</span></span><span style="display:flex;"><span>├─ wxam_dec_get_option_5    // 第二次调用 get option
</span></span><span style="display:flex;"><span>├─ wxam_dec_decode_buffer_5 // 第二次调用 decode
</span></span><span style="display:flex;"><span>├─ wxam_dec_get_option_5    // 第二次调用 get option
</span></span><span style="display:flex;"><span>├─ sub_7FFD9B5D1B50         // 图像处理（核心？）
</span></span><span style="display:flex;"><span>└─ wxam_dec_uninit_5        // 收尾
</span></span></code></pre></div><p>第二轮函数分析，感觉良好，专注在 <code>sub_7FFD9B5D1B50</code> ，学习如何将 RGBA32 转为 JPG，alpha 通道如何处理，直到我看到了 <code>lea rcx, String2; &quot;JPEGMEM&quot;</code>，原来看了半天是 <code>libjpeg</code>。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>sub_7FFD9B5D1B50    // 逐行图像处理，4 转 <span style="color:#ae81ff">3</span>
</span></span><span style="display:flex;"><span>├─ sub_7FFD9B5913C8 // 内存分配，分配值为宽度*3
</span></span><span style="display:flex;"><span>├─ sub_7FFEA5A8FC10 // 初始化错误/消息管理器结构体 类似 libjpeg jpeg_std_error（？？？）
</span></span><span style="display:flex;"><span>├─ sub_7FFEA5A8FCE0 // 创建解码器对象
</span></span><span style="display:flex;"><span>├─ sub_7FFEA5A900C0 // 类似 jpeg_source_mgr
</span></span><span style="display:flex;"><span>├─ sub_7FFEA5A90E80 // 类似 jpeg_read_header
</span></span><span style="display:flex;"><span>├─ sub_7FFEA5A90FE0 // 设置自定义解压选项 rdx 接收 1-100 的值，猜测是质量
</span></span><span style="display:flex;"><span>├─ sub_7FFEA5A914B0 // 类似 jpeg_start_decompress
</span></span><span style="display:flex;"><span>├─ sub_7FFEA5A91570 // 循环调用 jpeg_read_scanlines，看到 lea rcx, String2; <span style="color:#e6db74">&#34;JPEGMEM&#34;</span> （WTF）
</span></span><span style="display:flex;"><span>├─ sub_7FFEA5A8FE10 // jpeg_finish_decompress
</span></span><span style="display:flex;"><span>└─ sub_7FFEA5A8FE00 // jpeg_destroy_decompress 清理函数 避免内存泄露
</span></span></code></pre></div><p>第三轮函数分析，感觉良好，回过头开始分析 <code>wxam_dec_decode_buffer_5</code>，了解了 WXAM 的 Header 处理逻辑。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>wxam_dec_decode_buffer_5 // rcx handle; rdx 数据指针; r8 数据大小
</span></span><span style="display:flex;"><span>└─ sub_7FFD9B5CB1E0         // 解析核心函数 rdx 数据指针 r8; r8 数据大小
</span></span><span style="display:flex;"><span>   ├─ sub_7FFEA5A5F2B0      // 数据预处理
</span></span><span style="display:flex;"><span>      ├─ sub_7FFEA5A5DD40   // 解析 Header
</span></span><span style="display:flex;"><span>      └─ sub_7FFEA5A5C900   // 解析额外参数（version &gt;<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span> 且 <span style="color:#f92672">[</span>rbx+59h<span style="color:#f92672">]</span> 为 0）
</span></span><span style="display:flex;"><span>   ├─ sub_7FFEA5A5B990      // 数据区块解析
</span></span><span style="display:flex;"><span>&lt;...&gt;
</span></span></code></pre></div><p>第四轮函数分析，分析到一半看到 <code>A110</code> 函数天都塌了，好好好，<code>A110</code> 指向了内部的 Vcodec2 视频解码库，微信你用 HEVC 存静态图片！怪不得你压缩率高！</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>sub_7FFD9B5CB1E0     // 解析核心函数
</span></span><span style="display:flex;"><span>   ├─ sub_7FFEA5A5F2B0  // 数据预处理
</span></span><span style="display:flex;"><span>   ├─ WxAMFrame_new     // 创建帧
</span></span><span style="display:flex;"><span>   └─ sub_7FFEA5A5C1D0  // 解码流程控制
</span></span><span style="display:flex;"><span>      ├─ sub_7FFCC4C4A110 // 解析一个原始的、包含视频元数据和/或图像数据的 NALU <span style="color:#f92672">(</span>Network Abstraction Layer Unit<span style="color:#f92672">)</span> 码流
</span></span><span style="display:flex;"><span>      └─ sub_7FFCC4BFFB60 // 图像转换或处理
</span></span><span style="display:flex;"><span>         └─ sub_7FFCC4C1BEE0 // <span style="color:#66d9ef">case</span> <span style="color:#ae81ff">3</span> 图像格式转换调度器
</span></span><span style="display:flex;"><span>            └─ sub_7FFCC4C1D390 // mode <span style="color:#ae81ff">0</span> 通用处理函数 YUV420P 到 BGRA32 的转换函数
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>sub_7FFCC4C4A110 <span style="color:#f92672">(</span>解码器主循环/NAL流处理器<span style="color:#f92672">)</span> - 顶层控制器，负责从输入比特流中读取数据，识别并分离出独立的NAL单元，然后将其送往NAL分发器进行处理。
</span></span><span style="display:flex;"><span>└─ sub_7FFCC4C4BE80 <span style="color:#f92672">(</span>NAL单元分发器<span style="color:#f92672">)</span> - 接收单个NAL单元，判断其类型（如SPS, PPS, VPS, 或 VCL Slice），并将包含图像编码数据的VCL单元送入核心解码流水线。
</span></span><span style="display:flex;"><span>   └─ <span style="color:#f92672">[</span>VCL Slice NAL 单元处理分支<span style="color:#f92672">]</span>
</span></span><span style="display:flex;"><span>      ├─ sub_7FFCC4C51050 <span style="color:#f92672">(</span>Slice头解析器<span style="color:#f92672">)</span> - 从比特流中读取并解析Slice Header，提取解码当前画面切片所需的全部语法元素（如切片类型、参考帧信息等）。
</span></span><span style="display:flex;"><span>      ├─ sub_7FFCC4CB5550 <span style="color:#f92672">(</span>参考帧列表构建器<span style="color:#f92672">)</span> - 根据Slice Header中的信息，构建解码器进行帧间预测所依赖的参考图像列表（List <span style="color:#ae81ff">0</span> 和 List 1）。
</span></span><span style="display:flex;"><span>      └─ sub_7FFCC4C4DC00 <span style="color:#f92672">(</span>Slice解码与图像重建循环<span style="color:#f92672">)</span> - 作为核心驱动引擎，按编码树单元<span style="color:#f92672">(</span>CTU<span style="color:#f92672">)</span>的顺序，循环处理整个Slice，调用具体模块完成像素重建。
</span></span><span style="display:flex;"><span>         ├─ sub_7FFCC4C4CC40 <span style="color:#f92672">(</span>单个CTU解码器<span style="color:#f92672">)</span> - 解码流程的核心，负责对一个编码树单元<span style="color:#f92672">(</span>CTU<span style="color:#f92672">)</span>完成所有关键转换：熵解码<span style="color:#f92672">(</span>CABAC<span style="color:#f92672">)</span>、反量化、反变换、预测（帧内/帧间）以及最终的像素块重建。
</span></span><span style="display:flex;"><span>         └─ sub__7FFCC4C4EA20 <span style="color:#f92672">(</span>环路滤波器<span style="color:#f92672">)</span> - 对重建后的像素块执行去块效应（Deblocking）和样本自适应偏移（SAO）滤波，以减少编码失真、提升最终图像质量。
</span></span></code></pre></div><p>至此，从 IDA 的分析告一段落，<code>wxam2pic</code> 函数的逻辑是，首先调用 <code>Vcodec2</code> 将 HEVC NALU 处理为 YUV420 数据，然后转为 RGBA32 数据，最后使用 <code>libjpeg</code> 处理为 JPG 图像输出；处理逻辑中存在大量分支，支持不同的编解码器，没有再一一分析。<br>
使用 HEVC 保存数据确实在压缩率上非常有优势，但是这就让我们无法简单实现图片转换了，使用 Golang 处理音视频数据转封装比较简单，而处理复杂运算的转码就有些力不从心，手搓一个 HEVC 解码器也不现实，所以只能考虑其他方案。<br>
最简单的方案就是额外调用 <code>ffmpeg</code> 进行转码处理，为了兼容部分没有安装 ffmpeg 的用户，考虑给一个兜底方案，用转封装的形式提供 MP4 格式的图片（认真脸）。</p>
<h3 id="wxam-数据结构">WXAM 数据结构</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    +-----------------------------------------------------------------+
</span></span><span style="display:flex;"><span>    |                        WXGF Header Chunk                        |
</span></span><span style="display:flex;"><span>    +--------------------------------+--------------------------------+
</span></span><span style="display:flex;"><span>    |         Magic <span style="color:#e6db74">&#39;wxgf&#39;</span> <span style="color:#f92672">(</span>4B<span style="color:#f92672">)</span>      |       Header Length <span style="color:#f92672">(</span>1B<span style="color:#f92672">)</span>       |
</span></span><span style="display:flex;"><span>    +--------------------------------+--------------------------------+
</span></span><span style="display:flex;"><span>    |          Version <span style="color:#f92672">(</span>2B<span style="color:#f92672">)</span>          |            Width <span style="color:#f92672">(</span>2B<span style="color:#f92672">)</span>          |
</span></span><span style="display:flex;"><span>    +--------------------------------+--------------------------------+
</span></span><span style="display:flex;"><span>    |           Height <span style="color:#f92672">(</span>2B<span style="color:#f92672">)</span>          |     Other Args <span style="color:#f92672">(</span>Variable<span style="color:#f92672">)</span>      |
</span></span><span style="display:flex;"><span>    +-----------------------------------------------------------------+
</span></span><span style="display:flex;"><span>    |                  -- End of Main Header --                       |
</span></span><span style="display:flex;"><span>    +-----------------------------------------------------------------+
</span></span><span style="display:flex;"><span>    |                  WXGF Extra Header Chunk<span style="color:#f92672">(</span>s<span style="color:#f92672">)</span>                     |
</span></span><span style="display:flex;"><span>    |          <span style="color:#f92672">(</span>Repeats <span style="color:#66d9ef">until</span> a 0x00 flag is encountered<span style="color:#f92672">)</span>             |
</span></span><span style="display:flex;"><span>    +--------------------------------+--------------------------------+
</span></span><span style="display:flex;"><span>    |   Extra Header Flag <span style="color:#f92672">(</span>1B, !<span style="color:#f92672">=</span>0<span style="color:#f92672">)</span>  |  Extra Header Sub Flag <span style="color:#f92672">(</span>1B<span style="color:#f92672">)</span>    |
</span></span><span style="display:flex;"><span>    +--------------------------------+--------------------------------+
</span></span><span style="display:flex;"><span>    |  Extra Header Case Length <span style="color:#f92672">(</span>1B<span style="color:#f92672">)</span> |  Extra Header Case Data <span style="color:#f92672">(</span>Var<span style="color:#f92672">)</span>  |
</span></span><span style="display:flex;"><span>    +-----------------------------------------------------------------+
</span></span><span style="display:flex;"><span>    |                  -- End of Extra Header<span style="color:#f92672">(</span>s<span style="color:#f92672">)</span> --                   |
</span></span><span style="display:flex;"><span>    +-----------------------------------------------------------------+
</span></span><span style="display:flex;"><span>    |                    WXGF Partition Header Chunk                  |
</span></span><span style="display:flex;"><span>    |              <span style="color:#f92672">(</span>Starts with the 0x00 byte from above<span style="color:#f92672">)</span>             |
</span></span><span style="display:flex;"><span>    +-----------------------------------------------------------------+
</span></span><span style="display:flex;"><span>    |                    Partition Header <span style="color:#f92672">(</span>Variable<span style="color:#f92672">)</span>                  |
</span></span><span style="display:flex;"><span>    +-----------------------------------------------------------------+
</span></span><span style="display:flex;"><span>    
</span></span></code></pre></div><p>WXAM 数据结构由 3 部分组成，分别是 Header、Extra Header、Data Partition。<br>
Header 部分比较清晰，其数据长度由 0x04 位置的值控制，Other Args 部分的数据是非 byte 对齐的，按 bit 控制，参数包括帧率、视频质量、压缩选项、高级选项等，宽高信息是大端序；<br>
Extra Header 仅在 Version 为 2 时存在，通过 offset 首位是否为 0x00 判断是否结束，目前有 18 个参数分支，支持缓冲区设置、量化参数、编码器设置等；<br>
Data Partition 由多个数据分片组成，每个分片头部有类型、格式、长度信息，会应用从 Header 中读取的压缩相关参数信息；一般静态图片只有一个 Partition 保存大量数据，动态图片每一帧会作为一个 Partition。</p>
<h3 id="数据处理">数据处理</h3>
<p>刚开始，确实是硬着头皮在写完整的 Header 解析，反复在 IDA 中调试确认参数。后面考虑到完整解析 Header 的复杂性与实际收益不成正比（我们最终只需要裸流数据），索性放弃解析具体参数了。我决定采用一个更直接高效的方案，直接找 HEVC NALU 的 Header (<code>0x00000001</code> 或 <code>0x000001</code>)。因为在 HEVC 编码规范中，会通过在数据中插入 <code>0x03</code> 以避免和起始码冲突，所以干扰项只有 WXAM 的各个 Header，反复查询几次就能准确定位到所有 Partition 的数据了。<br>
然后是如何使用这些数据块，正常的单帧图片，一般是某个 Partition 的数据量占比特别大，那就直接使用这个 Partition 做解析即可。从 Partition 中获取的数据是 Annex B 格式的 HEVC NALU 裸流，ffmpeg 可以直接识别，非常方便；兜底方案采用 <code>github.com/Eyevinn/mp4ff</code> 库，调试一会也能正常出数据了，设置为 1 秒的 mp4 文件，想象大家打开图片时看到个 1 秒的视频一脸懵逼的样子就想笑（嘿嘿。<br>
多帧动画方面，WXAM 的方案还挺优雅的，多个 Partition 组成了两路交替的视频流，其中一路流是遮罩，另一路流是正常动画，在解码时利用遮罩视频处理出透明度。 <br>
遮罩流的类型甚至已经处理为 hevc (Rext), gray(tv)，可惜目前绝大部分播放器无法支持多路流遮罩的场景，只能由内部解码器处理。 <br>
在当前我们的方案中，如果使用 <code>ffmpeg</code> 处理，会将多帧动画处理为 gif，遮罩流的透明度也是正常处理；兜底方案的话，虽然使用了多路 MP4 的方案，但是遮罩流大概率是不工作的。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>Input <span style="color:#75715e">#0, hevc, from &#39;anime.mp4&#39;:</span>
</span></span><span style="display:flex;"><span>  Stream <span style="color:#75715e">#0:0: Video: hevc (Main), yuv420p(tv), 128x128, 25 fps, 30 tbr, 1200k tbn</span>
</span></span><span style="display:flex;"><span>Input <span style="color:#75715e">#1, hevc, from &#39;mask.mp4&#39;:</span>
</span></span><span style="display:flex;"><span>  Stream <span style="color:#75715e">#1:0: Video: hevc (Rext), gray(tv), 128x128, 25 fps, 30 tbr, 1200k tbn</span>
</span></span></code></pre></div><h3 id="使用方式">使用方式</h3>
<p>使用新版本 <code>chatlog</code> ，重新执行获取密钥功能（为了获取图片 AES 密钥），就能支持新版本的图片解析。如果本地没有安装 <code>ffmpeg</code> 的话，WXGF 文件会被转封装为 MP4 文件，这样做的目的是让浏览器可以直接解析。<br>
更推荐的使用方式是安装 <code>ffmpeg</code> 命令行工具，这样能够正常将 WXGF 文件转码为 JPG 图片，多帧动画将被转码为 GIF 动画。只需要 PATH 路径中有 <code>ffmpeg</code> 命令行工具，<code>chatlog</code> 就会自动检测并使用 <code>ffmpeg</code>。<br>
Windows 用户可以直接在 <code>ffmpeg</code> 官网下载 <a href="https://github.com/BtbN/FFmpeg-Builds/releases">BtbN</a> / <a href="https://www.gyan.dev/ffmpeg/builds/">gyan.dev</a> 提供的预编译版本，下载后需要将 <code>ffmpeg.exe</code> 路径加入系统 PATH 中，稍微搜索就能找到很多教程。macOS 用户可以使用 <a href="https://formulae.brew.sh/formula/ffmpeg"><code>brew install ffmpeg</code></a> 命令进行安装，非常方便。<br>
如果需要在自己的项目中集成，可以直接使用 <code>github.com/sjzar/chatlog/pkg/util/dat2img</code> 这个 package，代码比较简单，直接 vibe coding 一下一般都能集成。</p>
<h3 id="总结">总结</h3>
<p>好的，总结一下。本次对 WXAM (WXGF) 格式的分析，我们了解到微信为了实现极致的图像压缩，采用了 HEVC 视频编码来存储静态甚至动态图片，并通过自定义的 <code>Vcodec2</code> 库进行解码。其解码流程大致为：<code>HEVC NALU -&gt; YUV420P -&gt; RGBA32 -&gt; JPG/原始像素</code>。<br>
在无法手搓 HEVC 解码器的情况下， 我们通过特征码扫描绕过复杂的私有 Header，直接提取 HEVC 裸流，并使用 <code>ffmpeg</code> 进行解码和格式转换，成功实现了对单帧、多帧动画 WXAM 图片的解析。<br>
以上就是本次 WXAM（WXGF）图片格式的分析和处理过程，希望对你有所帮助，我也被动看了不少 <code>libjpeg</code> 和 HEVC 解码的逻辑，感觉要长脑子了。</p>
<p>最后，给咱的另一个小项目 <a href="https://imgmcp.com">ImgMCP</a> 打个广告，有生图生视频需求的老板们来看看，Veo3、GPT-Image-1、Midjourney 统统清仓价清仓价了喂。</p>
]]></content:encoded>
    </item>
    <item>
      <title>微信聊天记录解密</title>
      <link>https://sarv.blog/posts/chatlog/</link>
      <pubDate>Tue, 25 Mar 2025 00:00:00 +0800</pubDate>
      <guid>https://sarv.blog/posts/chatlog/</guid>
      <description>&lt;h3 id=&#34;概述&#34;&gt;概述&lt;/h3&gt;
&lt;p&gt;为了整理历史聊天记录，翻阅了不少网络资料和代码库，很多项目的目标都是如何 1:1 还原微信聊天界面，这和我的需求有些出入，我更希望能够有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Local First 且查询自由，支持任意时段任意对象的全文检索。&lt;/li&gt;
&lt;li&gt;能够非常简单的和大模型工具配合，帮助我从聊天记录中获取相关信息。&lt;br&gt;
既然没有合适的工具，那就考虑自己实现一个，于是就有了 chatlog 这个项目。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;项目地址：&lt;a href=&#34;https://github.com/sjzar/chatlog&#34;&gt;https://github.com/sjzar/chatlog&lt;/a&gt;&lt;/p&gt;</description>
      <content:encoded><![CDATA[<h3 id="概述">概述</h3>
<p>为了整理历史聊天记录，翻阅了不少网络资料和代码库，很多项目的目标都是如何 1:1 还原微信聊天界面，这和我的需求有些出入，我更希望能够有：</p>
<ul>
<li>Local First 且查询自由，支持任意时段任意对象的全文检索。</li>
<li>能够非常简单的和大模型工具配合，帮助我从聊天记录中获取相关信息。<br>
既然没有合适的工具，那就考虑自己实现一个，于是就有了 chatlog 这个项目。</li>
</ul>
<p>项目地址：<a href="https://github.com/sjzar/chatlog">https://github.com/sjzar/chatlog</a></p>
<h3 id="微信本地数据库">微信本地数据库</h3>
<p>微信的聊天数据都保存在本地的数据库文件中，这就是我们要聊的第一部分——微信本地数据库。<br>
微信本地数据库文件是一组加密后的 SQLCipher 文件，由腾讯的<a href="https://github.com/Tencent/wcdb"> WCDB 项目</a> 支持。<br>
SQLCipher 的机制是以增加 10-15% 的开销，100% 加密本地的 SQLite 文件，并提供与 SQLite 相同的 API。<br>
首次在设备登录微信时，会计算出一组密钥用于加密本地的数据库文件，这组密钥在每个账户、每台设备上都是不一样的。<br>
在同账号同设备上更换密钥的频率不高，更换密钥意味着需要对全部数据重新加密，重度使用的微信账号数据量不小，所以一般是微信大版本更新时更换密钥。<br>
虽然微信在各个平台不同大版本中均使用 WCDB 项目，但是加密方式却有所区别。下面是总结的信息：</p>
<table>
  <thead>
      <tr>
          <th>平台</th>
          <th>版本</th>
          <th>加密方式</th>
          <th>加密参数</th>
          <th>备注</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Windows</td>
          <td>3.x</td>
          <td>SQLCipher v3</td>
          <td>Page Size 4096 / KDF 迭代 64000 / HMAC 算法 SHA-1 / KDF 算法 SHA-1</td>
          <td></td>
      </tr>
      <tr>
          <td>Windows</td>
          <td>4.0</td>
          <td>SQLCipher v4</td>
          <td>Page Size 4096 / KDF 迭代 256000 / HMAC 算法 SHA-256 / KDF 算法 SHA-256</td>
          <td>SQLCipher v4 默认配置</td>
      </tr>
      <tr>
          <td>macOS</td>
          <td>3.x</td>
          <td>SQLCipher v3</td>
          <td>Page Size 1024 / HMAC 算法 SHA-1</td>
          <td>比较特殊，直接使用原始密钥</td>
      </tr>
      <tr>
          <td>macOS</td>
          <td>4.0</td>
          <td>SQLCipher v4</td>
          <td>Page Size 4096 / KDF 迭代 256000 / HMAC 算法 SHA-256 / KDF 算法 SHA-256</td>
          <td>与 Windows 4.x 一致</td>
      </tr>
  </tbody>
</table>
<p>了解完数据库加密方式后，简单聊下数据结构。<br>
不同版本的数据结构差异较大，例如 Windows 3.x 版本的数据库，聊天记录是保存在多个 db 文件中，并按时间分库，最新的聊天记录都在同一个文件里，在同一个 db 文件中，所有聊天记录都在一张大表里；macOS 3.x 版本的数据库，聊天记录是按照聊天对象分表的，每个聊天对象有单独的表，再将这些表分配到多个 db 文件中，这意味着如果部分聊天对象记录较多，那么对应的 db 文件就比较大；4.0 版本的微信，数据结构又发生了变化，结合了上述两个方案的特点，先按照时间划分 db 文件，再为每个聊天对象设置单独的表，并且这回 Windows 和 macOS 采用的是相同的方案。</p>
<p>4.0 版本的数据结构其实是更像 Windows 3.x 版本，这也愈发让人觉得 macOS 3.x 版本的数据结构特别奇怪，甚至不像是同一个团队出的产品。<br>
例如在其他版本中，群聊叫做 ChatRoom，而在 macOS 3.x 中叫做 Group；在其他版本中，群成员的信息是保存在 Contact 表中的，而在 macOS 3.x 版本中群成员是独立维护的。（吐槽的原因是由于 chatlog 项目希望支持所有主流版本的微信，所以为了 macOS 3.x 做了不少兼容工作）</p>
<h3 id="本地数据库解密">本地数据库解密</h3>
<p>目前网上的解密方式，一般是从微信进程中获取密钥，再通过密钥解密数据库后查询数据，这也是为什么 Windows 下工具较多，而 macOS 下没有特别方便的工具，因为 Windows 下获取微信内存数据比较简单，可以通过 Windows API 直接读取内存数据，而在 macOS 下有安全机制限制访问其他进程的内存数据，需要先关闭 SIP 才能操作。</p>
<p>获取密钥有几种方式，一种是通过内存基址计算偏移来获取，获取密钥速度快，缺点就是需要手动维护每个小版本的内存基址，代表项目是 <a href="https://github.com/xaoyaoo/PyWxDump/blob/master/pywxdump/WX_OFFS.json">xaoyaoo/PyWxDump</a> 。<br>
另一种方式就是通过内存数据暴力搜索，微信在运行时会打开数据库文件句柄，那么通常在内存数据中会有密钥信息，完整扫描整块内存区域不太可行，但是通过一些数据特征缩小扫描范围，就让这个方案变得可行了，代表项目是 <a href="https://github.com/0xlane/wechat-dump-rs">0xlane/wechat-dump-rs</a>。<br>
第三种方案是 Hook 方案，向微信进程注入动态库后直接调用相关函数获取密钥数据，通常使用 Hook 方案的项目重点都不在获取密钥，而是对微信本身的功能做魔改，例如防撤回、找单删好友、抢红包等，这个方案也是封号风险最大的。</p>
<p>chatlog 项目采用的是方案二，也就是通过读取微信内存，暴力搜索查找数据密钥。<br>
经过测试，Windows 系统下一般 20~30ms 可以找到密钥，macOS 系统下需要约 20 秒的时间，其中大部分时间是在用于 dump 内存数据。<br>
这个方案的封号风险我认为不大，项目并不会操作微信的 API 做额外的请求，获取密钥后的所有操作都和微信进程不相关了。</p>
<h3 id="macos-版本的解密方案">macOS 版本的解密方案</h3>
<p>Windows 平台下有不少成熟的解密项目，但是 macOS 这边相关项目较少，这也给我研究解密带来不少困难。<br>
网上流传最广的 <a href="https://github.com/xaoyaoo/PyWxDump/blob/master/doc/MAC%E6%95%B0%E6%8D%AE%E5%BA%93%E8%A7%A3%E5%AF%86.md">macOS 微信解密方案</a>，是利用 lldb 的断点功能，在设置 sqlite3_key 的地方下断，然后登录微信触发断点后，从寄存器中获取密钥。<br>
这个方案只在 3.8.5 以下版本的微信中可用，更高版本的微信就无法通过这个方案获取密钥了。<br>
我的思路还是先把内存 dump 出来，然后分析内容，既然选择内存暴力破解，那么只要选好特征，避免全量扫描即可。<br>
本地有加密的数据库文件和 dump 出来的内存二进制文件，反复测试后获得密钥数据，再通过密钥数据归纳特征，就有了现在的解密方案。</p>
<h3 id="数据处理">数据处理</h3>
<p>有了密钥后就需要对数据库文件进行处理，刚开始想的是利用密钥对数据库文件做一个即时解密的转换层，这样不需要额外保存一份数据文件。但是由于对 sqlite 不太熟悉，这个方案难度较大，索性还是先将数据解密成标准的 sqlite 文件再进行读取。<br>
由于不同版本的微信数据库结构还有差异，还简单做了 wrap 处理，多媒体数据还没来得及处理，不过这个难度不大，总会处理完的。<br>
关于多媒体数据，我的想法是仍然放在微信数据目录内，通过 chatlog 的 HTTP 服务做文件代理。例如聊天记录中引用某个文件，就转换为 chatlog 提供的 localhost  HTTP 链接，访问链接就读取到了微信数据目录内的相关文件。优点是无需额外保存一份多媒体数据，缺点就是部分数据可能需要即时转码。不过方案肯定还能优化，后续再聊了。</p>
<h3 id="mcp">MCP</h3>
<p>好的，到了最关键的时刻，辛辛苦苦把聊天记录弄出来，就是要把自己的数据用起来。<br>
基于 MCP 的 SSE 方案写了服务，目前能够支持 ChatWise、Claude Desktop、Monica Code、Cline 等工具，但是只有 ChatWise 是原生支持 SSE，其他方案都是通过 mcp-proxy 转发。<br>
先看下效果：</p>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js"></script>

<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/fancyapps/fancybox@3.5.7/dist/jquery.fancybox.min.css" />
<script src="https://cdn.jsdelivr.net/gh/fancyapps/fancybox@3.5.7/dist/jquery.fancybox.min.js"></script>

<a data-fancybox="gallery" href="https://qupfile.cloudvdn.com/IMG-20250325003314415.png">
<figure class="align-center">
    <img loading="lazy" src="https://qupfile.cloudvdn.com/IMG-20250325003314415.png" width="400"/> <figcaption>
            <p>ChatWise</p>
        </figcaption>
</figure>
</a>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js"></script>

<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/fancyapps/fancybox@3.5.7/dist/jquery.fancybox.min.css" />
<script src="https://cdn.jsdelivr.net/gh/fancyapps/fancybox@3.5.7/dist/jquery.fancybox.min.js"></script>

<a data-fancybox="gallery" href="https://qupfile.cloudvdn.com/IMG-20250325003352392.png">
<figure class="align-center">
    <img loading="lazy" src="https://qupfile.cloudvdn.com/IMG-20250325003352392.png" width="400"/> <figcaption>
            <p>Claude Desktop</p>
        </figcaption>
</figure>
</a>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js"></script>

<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/fancyapps/fancybox@3.5.7/dist/jquery.fancybox.min.css" />
<script src="https://cdn.jsdelivr.net/gh/fancyapps/fancybox@3.5.7/dist/jquery.fancybox.min.js"></script>

<a data-fancybox="gallery" href="https://qupfile.cloudvdn.com/IMG-20250325003612045.png">
<figure class="align-center">
    <img loading="lazy" src="https://qupfile.cloudvdn.com/IMG-20250325003612045.png" width="400"/> <figcaption>
            <p>Monica Code</p>
        </figcaption>
</figure>
</a>
<p>MCP 服务支持 stdio 和 SSE 两种传输方式，但是由于项目不完善，只有 SPEC 没落地，连官方的 Claude  Desktop 都不支持 SSE 服务器。<br>
stdio 方案是由本地聊天工具起子进程，通过 stdin 和 stdout 和子进程传输数据。<br>
如果有外部服务想作为 MCP，在现在的 Claude Desktop 上只能想办法弄个子进程服务，把相关请求代理出来。<br>
SSE 方案，是基于 HTTP 协议做的，用长+短两个连接来连 Client 和 Server。<br>
长连接就是 SSE，Client 发给 Server，Server 返回一个 sessionid，然后连接保持住不断开，后续服务端向客户端发数据都用这个 SSE 连接。<br>
客户端向服务端请求就再发起一个 HTTP 请求给服务端，叫做 /messages，然后服务端给这个 /messages 请求返回一个202，再用 SSE 连接返回需要的数据，这样实现双向通信。<br>
但是这个方案也有坑，就是没约定 SSE 连接多久发心跳，断了咋重连，所以在 SSE 断开后，客户端再发 /messages，服务端就无法响应了。<br>
好消息是官方也注意到这个问题了，支持了新的流式 HTTP 模式：<a href="https://github.com/modelcontextprotocol/specification/pull/206">[RFC] Replace HTTP+SSE with new &ldquo;Streamable HTTP&rdquo; transport by jspahrsummers · Pull Request #206 · modelcontextprotocol/specification · GitHub</a>。<br>
还想吐槽的是，官方将服务器提供的内容归类为 Resource、Prompt、Tool，但是目前官方的 Claude Desktop 还不支持 Resource。<br>
本来想将 chatlog 作为 Resource 的，写完代码发现没法用，只能作为 Tool 调用，希望接下来尽快完善～</p>
<h3 id="terminal-ui">Terminal UI</h3>
<p>临近结尾，回过头聊一下项目的 TUI 吧。<br>
这次用的是 <a href="https://github.com/rivo/tview">rivo/tview</a> 项目，第一次使用这个项目，效果看起来还不错。<br>
想到前两年做的命令行项目，还是读取 readline 后自己维护页面，现在的 TUI 效果真是好太多了。</p>
<h3 id="总结">总结</h3>
<p>以上就是本次项目的一些碎碎念，总结下来就是写了个微信聊天记录解密项目，希望这个工具能够帮到大家。</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
