fix: PR #107 后续清理 (security/正确性/一致性)

针对 4 个 review agent 在 PR #107 (5649 行巨型 PR) 找到的关键问题做最小
侵入修复。已合并代码本身能跑,这次是收紧 security + 消重 + 文档一致性。

## 安全修复

### wxwork_keys.json 落盘权限 (find_wxwork_keys.py)
含明文 16-byte raw key 的 keys 文件,之前 default umask 落盘。改成:
  1. 写 tmp 文件
  2. chmod 0o600 (Unix 严格 owner-only; Windows 上 chmod 控制只读位,
     至少避免世界可读最差情况)
  3. atomic rename
旧产物自然过期,新生成的都受保护。

### SNS XXE 防护 (export_sns.py)
朋友圈 XML 来源是不可信输入(他人发的 content),原 `ET.fromstring()`
完全没过滤,可被恶意 entity expansion / 外部实体引用攻击。加跟
`mcp_server._XML_UNSAFE_RE` 同模式的过滤(拒 `<!DOCTYPE>` / `<!ENTITY>`)
+ 200KB 大小上限。`_parse_timeline_xml` 检查后才进 ET.fromstring。

## 正确性 / 消重

### AES 对齐公式统一 (decode_image.py + decrypt_sns.py + export_sns.py)
原本三处各写一份:
  - decode_image.py:   aes_size -= ~(~aes_size % 16)   ← bitwise trick
  - decrypt_sns.py:    同上
  - export_sns.py:     aes_size + (16 - aes_size%16) if … else aes_size+16
两个公式数学等价(对 0/1/15/16/17/100/1000/12345 全部验证一致),但
bitwise trick 难读且漂移风险高。抽 `aligned_aes_block_size()` 到
decode_image.py 作 canonical 实现, 另两处 import 复用。

### 32-bit pointer 假设明确化 (find_wxwork_keys.py)
reviewer 担心 `_read_u32` 在 64-bit 进程上错位,实测 WXWork.exe 5.0.x
是 **32-bit 进程** (`Program Files (x86)\WXWork\` + PE Machine = x86),
所以 4 字节读指针是对的。加注释明确这个假设,腾讯如果升级到 64-bit
要重做整套逆向, 当前实测全部 17 db 解密通过印证。

## 一致性

### main.py show_status() 走 _config_file_path() (main.py)
原硬编码 `config_file = "config.json"` 绕开 PR #107 新引入的
`_config_file_path()`,打包成 exe 后 cwd 不一定是 exe 目录,会读到错
位置。改成 `from config import _config_file_path`。

### EXE_USAGE.md 输出目录写错 (EXE_USAGE.md)
EXE_USAGE 说导出到 `export/`,代码实际 `output_base_dir = wechat_files/
<wxid>/`,联系人下还是 `messages.csv/html/json` 而不是
`message_0.db.csv`。修正成真实结构。

## 文档

README 加两段:
  - 安全提示: keys 文件 chmod 0600 + 不要 commit 到 git
  - 朋友圈 XML XXE 防护说明

## 测试

185/185 通过 (含已有 wxsqlite3 roundtrip + image v2 + msg types filter
+ pagination hint + chat export helpers 等)。
aligned_aes_block_size 单独验证跟旧公式等价(0/1/15/16/17/100/1000/12345)。

## 未跟进 (后续 follow-up issue)

- 3 处 V1/V2/XOR 解密代码完全重复(decode_image / decrypt_sns /
  export_messages 各自实现)——抽出来工作量大,本次先抽 helper 不动
  完整解密路径,后续单独 PR
- export_messages HTML base64 内联图片可能爆几 GB,应改成可选 flag
- SNS / wxwork export / batch_decrypt_images / voice_to_mp3 测试缺位
  (0 个 test)
This commit is contained in:
ylytdeng
2026-05-17 17:00:20 +08:00
parent 1aa12c86fa
commit e5e2269947
7 changed files with 79 additions and 26 deletions

View File

@@ -7,7 +7,7 @@
3. 根据需要点击按钮:
- **① 微信解密** → 从微信进程提取密钥并解密数据库到 `decrypted/` 目录
- **② 图片密钥** → 从微信进程提取新版图片 AES 密钥
- **③ 导出数据** → 将聊天记录导出为 CSV / HTML / JSON 到 `export/` 目录
- **③ 导出数据** → 将聊天记录导出为 CSV / HTML / JSON 到 `wechat_files/<wxid>/<联系人>/` 目录
- **④ 朋友圈图片** → 解密朋友圈缓存图片
- **⑤ 企业微信解密** → 从企业微信进程提取密钥并解密数据库到 `wxwork_decrypted/` 目录
- **⑥ 企业微信导出** → 选择某个人或群,导出 CSV / HTML / JSON 到 `wxwork_export/` 目录
@@ -42,19 +42,17 @@ wxwork_export/ ← ⑥ 导出的企业微信聊天记录
messages.csv
messages.html
messages.json
export/ ← ③ 导出的聊天记录
张三/
.info ← 联系人信息username/alias/remark/nick_name
message_0.db.csv ← CSV 格式Excel 可直接打开
message_0.db.html← HTML 格式(浏览器打开,微信气泡样式
message_0.db.json← JSON 格式(程序处理用
李四/
...
data/ ← 导出时选择“同时转换语音为 MP3”后的输出
张三/
.info
20250101_120000_1.mp3
...
wechat_files/ ← ③ 导出的聊天记录 (按 wxid + 联系人组织)
<wxid>/
张三/
.info ← 联系人信息username/alias/remark/nick_name
messages.csv ← CSV 格式Excel 可直接打开
messages.html ← HTML 格式(浏览器打开,微信气泡样式
messages.json ← JSON 格式(程序处理用)
image/ ← 该联系人聊天涉及的图片
朋友圈图片/ ← ④ 解密后的朋友圈缓存图片
李四/
...
```
## 导出格式说明

View File

@@ -315,6 +315,15 @@ make help # 列出所有命令
WCDB (微信的 SQLCipher 封装) 会在进程内存中缓存派生后的 raw key格式为 `x'<64hex_enc_key><32hex_salt>'`。三个平台均可通过扫描进程内存匹配此模式,再通过 HMAC 校验 page 1 确认密钥正确性。
#### 安全提示
- `all_keys.json` / `wxwork_keys.json` 包含明文 raw key,落盘时已 `chmod 0600`(Unix)或保留默认 ACL(Windows)。**勿提交到 git 或与人共享**——拿到 key 等于拿到全部聊天解密能力。
- 解密后的 `.db` 文件是明文 SQLite,内容包括所有联系人、群、消息,**同样需要小心备份和分享**。
### 朋友圈解密的 XML 安全
`export_sns.py` 解析 SnsTimeLine 的 XML 时已加 **XXE 防护**(拒绝 `<!DOCTYPE>` / `<!ENTITY>` + 200KB 大小上限),避免恶意朋友圈 XML 通过 entity expansion 或外部实体引用执行 SSRF / 读取本地文件。`mcp_server.py` 解析其他类型 appmsg XML 同样有这层保护。
### GUI 工具箱 & 单 exe 打包
提供 tkinter 图形界面 (`app_gui.py`),集成核心功能:

View File

@@ -28,6 +28,21 @@ V2_MAGIC = b'\x07\x08\x56\x32' # 前 4 字节用于快速检测
V2_MAGIC_FULL = b'\x07\x08V2\x08\x07' # 完整 6 字节签名
V1_MAGIC_FULL = b'\x07\x08V1\x08\x07' # V1 签名 (固定 key)
def aligned_aes_block_size(aes_size):
"""V1/V2 .dat AES 区段的实际字节数 (AES-CBC + PKCS7 总额外加 16 字节 padding)。
aes_size 不是 16 倍数: aligned = 向上对齐到 16 (aes_size + (16 - aes_size%16))
aes_size 是 16 倍数: aligned = aes_size + 16 (完整 padding 块)
canonical 实现, 给 decrypt_sns.py / export_sns.py / decode_image.py 共用。
早期 wx-dat 风格的 bitwise trick `aes_size - ~(~aes_size % 16)` 数学等价但
可读性差, 现在用清晰版本。
"""
if aes_size % 16:
return aes_size + (16 - aes_size % 16)
return aes_size + 16
# 常见图片格式的 magic bytes (按长度降序排列,避免短 magic 假阳性)
IMAGE_MAGIC = {
'png': [0x89, 0x50, 0x4E, 0x47],
@@ -156,10 +171,7 @@ def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88):
if sig == V1_MAGIC_FULL:
aes_key = b'cfcd208495d565ef' # md5("0")[:16]
# AES 对齐: PKCS7 填充使实际密文 >= aes_size向上对齐到 16
# 当 aes_size 是 16 的倍数时,还需要加 16 (完整填充块)
aligned_aes_size = aes_size
aligned_aes_size -= ~(~aligned_aes_size % 16) # 同 wx-dat 的公式
aligned_aes_size = aligned_aes_block_size(aes_size)
offset = 15
if offset + aligned_aes_size > len(data):

View File

@@ -13,6 +13,7 @@ if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from config import load_config
from decode_image import aligned_aes_block_size
_cfg = load_config()
SNS_CACHE_DIR = _cfg.get("sns_cache_dir", "")
@@ -78,7 +79,7 @@ def decrypt_dat(dat_path):
if len(data) < 15:
return None, None
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
aligned = aes_size - ~(~aes_size % 16)
aligned = aligned_aes_block_size(aes_size)
offset = 15
if offset + aligned > len(data):
return None, None

View File

@@ -21,6 +21,13 @@ if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from config import load_config
from decode_image import aligned_aes_block_size
# 朋友圈 XML 来源是不可信输入 (他人朋友圈的 content), 必须挡 XXE。
# 跟 mcp_server._XML_UNSAFE_RE 保持同一过滤模式; max_len 比 mcp_server 宽松
# (朋友圈 timeline XML 含媒体列表 + 评论, 实测可达几十KB; 给 200K 余量)。
_SNS_XML_UNSAFE_RE = re.compile(r'<!DOCTYPE|<!ENTITY', re.IGNORECASE)
_SNS_XML_MAX_LEN = 200_000
_cfg = load_config()
DECRYPTED_DIR = _cfg["decrypted_dir"]
@@ -83,7 +90,7 @@ def _decrypt_sns_dat(dat_path):
from Crypto.Cipher import AES
from Crypto.Util import Padding
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
aligned = aes_size + (16 - aes_size % 16) if aes_size % 16 else aes_size + 16
aligned = aligned_aes_block_size(aes_size)
offset = 15
if offset + aligned > len(data):
return None
@@ -228,7 +235,7 @@ def _build_sns_cache_index():
try:
from Crypto.Cipher import AES as _AES
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
aligned = aes_size + (16 - aes_size % 16) if aes_size % 16 else aes_size + 16
aligned = aligned_aes_block_size(aes_size)
est_dec_size = fsize - 15 - (aligned - aes_size)
available = min(aligned, len(data) - 15)
# 按 16 字节块对齐ECB 可逐块解密)
@@ -424,6 +431,14 @@ def _parse_media_list(timeline_obj):
def _parse_timeline_xml(content_xml):
"""解析 SnsTimeLine 的 Content XML返回结构化数据"""
if not content_xml:
return None
if len(content_xml) > _SNS_XML_MAX_LEN:
return None
if _SNS_XML_UNSAFE_RE.search(content_xml):
# XXE 防护: 拒绝 DOCTYPE/ENTITY,避免恶意朋友圈 XML 通过 entity expansion
# 或外部实体引用执行 SSRF/读取本地文件
return None
try:
root = ET.fromstring(content_xml)
except ET.ParseError:

View File

@@ -357,6 +357,11 @@ def _wxwork_page_size_chain(memory_regions, starts, cipher_addr):
raw_key = cipher + 0x08
aes_ctx = *(cipher + 0x2c)
page_size = *(*(*(cipher + 0x30) + 0x04) + 0x24)
NOTE: WXWork.exe (5.0.x) 是 **32-bit** 进程,所有指针 = 4 字节 (`_read_u32`)。
腾讯如果升级到 64-bit (`Program Files\WXWork\`),结构偏移和指针大小都要重做
逆向,这套代码会直接失效——届时需要扫描入口加 IsWow64Process 检测并给出
友好报错。当前实测 5.0.8.6009 全部 17 个 db 解密成功,该 32-bit 假设有效。
"""
page_size_holder = _read_u32(memory_regions, starts, cipher_addr + 0x30)
if page_size_holder is None or not _valid_ptr(memory_regions, starts, page_size_holder, 8):
@@ -500,9 +505,18 @@ def save_wxwork_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print_
raise RuntimeError("未能从任何企业微信进程中提取到密钥")
result["_db_dir"] = db_dir
with open(out_file, 'w', encoding='utf-8') as f:
# 写文件含明文 raw key,先 atomic write 到 tmp 再 rename,中途 chmod 0600
# 限本用户读写。Windows 上 os.chmod 主要控制只读位,严格 ACL 需 win32security
# 这里至少避免世界可读的最差情况。
tmp_file = out_file + ".tmp"
with open(tmp_file, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2, ensure_ascii=False)
print_fn(f"\n密钥保存到: {out_file}")
try:
os.chmod(tmp_file, 0o600)
except OSError:
pass # Windows 上某些场景 chmod 可能失败,不阻塞主流程
os.replace(tmp_file, out_file)
print_fn(f"\n密钥保存到: {out_file} (权限已收紧为 0600)")
missing = [rel for rel, path, sz, salt_hex, page1 in db_files if salt_hex not in key_map]
if missing:

10
main.py
View File

@@ -171,13 +171,17 @@ def ensure_keys(keys_file, db_dir):
def show_status():
"""显示当前数据状态"""
cfg = {}
config_file = "config.json"
# 走 config._config_file_path() 而不是硬编码 "config.json"
# 这样打包成 exe 后 (cwd 可能任意位置) 仍能找到正确的 config
from config import _config_file_path
config_file = _config_file_path()
if os.path.exists(config_file):
with open(config_file, encoding="utf-8") as f:
cfg = json.load(f)
print(f"[config] db_dir = {cfg.get('db_dir', '?')}")
print(f"[config] {config_file}")
print(f" db_dir = {cfg.get('db_dir', '?')}")
else:
print("[config] 未找到 config.json")
print(f"[config] 未找到 {config_file}")
keys_files = sorted(glob.glob("all_keys*.json"))
print(f"[keys] {len(keys_files)} 个密钥文件")