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:
18
EXE_USAGE.md
18
EXE_USAGE.md
@@ -7,7 +7,7 @@
|
|||||||
3. 根据需要点击按钮:
|
3. 根据需要点击按钮:
|
||||||
- **① 微信解密** → 从微信进程提取密钥并解密数据库到 `decrypted/` 目录
|
- **① 微信解密** → 从微信进程提取密钥并解密数据库到 `decrypted/` 目录
|
||||||
- **② 图片密钥** → 从微信进程提取新版图片 AES 密钥
|
- **② 图片密钥** → 从微信进程提取新版图片 AES 密钥
|
||||||
- **③ 导出数据** → 将聊天记录导出为 CSV / HTML / JSON 到 `export/` 目录
|
- **③ 导出数据** → 将聊天记录导出为 CSV / HTML / JSON 到 `wechat_files/<wxid>/<联系人>/` 目录
|
||||||
- **④ 朋友圈图片** → 解密朋友圈缓存图片
|
- **④ 朋友圈图片** → 解密朋友圈缓存图片
|
||||||
- **⑤ 企业微信解密** → 从企业微信进程提取密钥并解密数据库到 `wxwork_decrypted/` 目录
|
- **⑤ 企业微信解密** → 从企业微信进程提取密钥并解密数据库到 `wxwork_decrypted/` 目录
|
||||||
- **⑥ 企业微信导出** → 选择某个人或群,导出 CSV / HTML / JSON 到 `wxwork_export/` 目录
|
- **⑥ 企业微信导出** → 选择某个人或群,导出 CSV / HTML / JSON 到 `wxwork_export/` 目录
|
||||||
@@ -42,19 +42,17 @@ wxwork_export/ ← ⑥ 导出的企业微信聊天记录
|
|||||||
messages.csv
|
messages.csv
|
||||||
messages.html
|
messages.html
|
||||||
messages.json
|
messages.json
|
||||||
export/ ← ③ 导出的聊天记录
|
wechat_files/ ← ③ 导出的聊天记录 (按 wxid + 联系人组织)
|
||||||
|
<wxid>/
|
||||||
张三/
|
张三/
|
||||||
.info ← 联系人信息(username/alias/remark/nick_name)
|
.info ← 联系人信息(username/alias/remark/nick_name)
|
||||||
message_0.db.csv ← CSV 格式(Excel 可直接打开)
|
messages.csv ← CSV 格式(Excel 可直接打开)
|
||||||
message_0.db.html← HTML 格式(浏览器打开,微信气泡样式)
|
messages.html ← HTML 格式(浏览器打开,微信气泡样式)
|
||||||
message_0.db.json← JSON 格式(程序处理用)
|
messages.json ← JSON 格式(程序处理用)
|
||||||
|
image/ ← 该联系人聊天涉及的图片
|
||||||
|
朋友圈图片/ ← ④ 解密后的朋友圈缓存图片
|
||||||
李四/
|
李四/
|
||||||
...
|
...
|
||||||
data/ ← 导出时选择“同时转换语音为 MP3”后的输出
|
|
||||||
张三/
|
|
||||||
.info
|
|
||||||
20250101_120000_1.mp3
|
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 导出格式说明
|
## 导出格式说明
|
||||||
|
|||||||
@@ -315,6 +315,15 @@ make help # 列出所有命令
|
|||||||
|
|
||||||
WCDB (微信的 SQLCipher 封装) 会在进程内存中缓存派生后的 raw key,格式为 `x'<64hex_enc_key><32hex_salt>'`。三个平台均可通过扫描进程内存匹配此模式,再通过 HMAC 校验 page 1 确认密钥正确性。
|
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 打包
|
### GUI 工具箱 & 单 exe 打包
|
||||||
|
|
||||||
提供 tkinter 图形界面 (`app_gui.py`),集成核心功能:
|
提供 tkinter 图形界面 (`app_gui.py`),集成核心功能:
|
||||||
|
|||||||
@@ -28,6 +28,21 @@ V2_MAGIC = b'\x07\x08\x56\x32' # 前 4 字节用于快速检测
|
|||||||
V2_MAGIC_FULL = b'\x07\x08V2\x08\x07' # 完整 6 字节签名
|
V2_MAGIC_FULL = b'\x07\x08V2\x08\x07' # 完整 6 字节签名
|
||||||
V1_MAGIC_FULL = b'\x07\x08V1\x08\x07' # V1 签名 (固定 key)
|
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 假阳性)
|
# 常见图片格式的 magic bytes (按长度降序排列,避免短 magic 假阳性)
|
||||||
IMAGE_MAGIC = {
|
IMAGE_MAGIC = {
|
||||||
'png': [0x89, 0x50, 0x4E, 0x47],
|
'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:
|
if sig == V1_MAGIC_FULL:
|
||||||
aes_key = b'cfcd208495d565ef' # md5("0")[:16]
|
aes_key = b'cfcd208495d565ef' # md5("0")[:16]
|
||||||
|
|
||||||
# AES 对齐: PKCS7 填充使实际密文 >= aes_size,向上对齐到 16
|
aligned_aes_size = aligned_aes_block_size(aes_size)
|
||||||
# 当 aes_size 是 16 的倍数时,还需要加 16 (完整填充块)
|
|
||||||
aligned_aes_size = aes_size
|
|
||||||
aligned_aes_size -= ~(~aligned_aes_size % 16) # 同 wx-dat 的公式
|
|
||||||
|
|
||||||
offset = 15
|
offset = 15
|
||||||
if offset + aligned_aes_size > len(data):
|
if offset + aligned_aes_size > len(data):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ if sys.platform == "win32":
|
|||||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
from config import load_config
|
from config import load_config
|
||||||
|
from decode_image import aligned_aes_block_size
|
||||||
|
|
||||||
_cfg = load_config()
|
_cfg = load_config()
|
||||||
SNS_CACHE_DIR = _cfg.get("sns_cache_dir", "")
|
SNS_CACHE_DIR = _cfg.get("sns_cache_dir", "")
|
||||||
@@ -78,7 +79,7 @@ def decrypt_dat(dat_path):
|
|||||||
if len(data) < 15:
|
if len(data) < 15:
|
||||||
return None, None
|
return None, None
|
||||||
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
|
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
|
offset = 15
|
||||||
if offset + aligned > len(data):
|
if offset + aligned > len(data):
|
||||||
return None, None
|
return None, None
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ if sys.platform == "win32":
|
|||||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
from config import load_config
|
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()
|
_cfg = load_config()
|
||||||
DECRYPTED_DIR = _cfg["decrypted_dir"]
|
DECRYPTED_DIR = _cfg["decrypted_dir"]
|
||||||
@@ -83,7 +90,7 @@ def _decrypt_sns_dat(dat_path):
|
|||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
from Crypto.Util import Padding
|
from Crypto.Util import Padding
|
||||||
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
|
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
|
offset = 15
|
||||||
if offset + aligned > len(data):
|
if offset + aligned > len(data):
|
||||||
return None
|
return None
|
||||||
@@ -228,7 +235,7 @@ def _build_sns_cache_index():
|
|||||||
try:
|
try:
|
||||||
from Crypto.Cipher import AES as _AES
|
from Crypto.Cipher import AES as _AES
|
||||||
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
|
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)
|
est_dec_size = fsize - 15 - (aligned - aes_size)
|
||||||
available = min(aligned, len(data) - 15)
|
available = min(aligned, len(data) - 15)
|
||||||
# 按 16 字节块对齐(ECB 可逐块解密)
|
# 按 16 字节块对齐(ECB 可逐块解密)
|
||||||
@@ -424,6 +431,14 @@ def _parse_media_list(timeline_obj):
|
|||||||
|
|
||||||
def _parse_timeline_xml(content_xml):
|
def _parse_timeline_xml(content_xml):
|
||||||
"""解析 SnsTimeLine 的 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:
|
try:
|
||||||
root = ET.fromstring(content_xml)
|
root = ET.fromstring(content_xml)
|
||||||
except ET.ParseError:
|
except ET.ParseError:
|
||||||
|
|||||||
@@ -357,6 +357,11 @@ def _wxwork_page_size_chain(memory_regions, starts, cipher_addr):
|
|||||||
raw_key = cipher + 0x08
|
raw_key = cipher + 0x08
|
||||||
aes_ctx = *(cipher + 0x2c)
|
aes_ctx = *(cipher + 0x2c)
|
||||||
page_size = *(*(*(cipher + 0x30) + 0x04) + 0x24)
|
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)
|
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):
|
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("未能从任何企业微信进程中提取到密钥")
|
raise RuntimeError("未能从任何企业微信进程中提取到密钥")
|
||||||
|
|
||||||
result["_db_dir"] = db_dir
|
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)
|
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]
|
missing = [rel for rel, path, sz, salt_hex, page1 in db_files if salt_hex not in key_map]
|
||||||
if missing:
|
if missing:
|
||||||
|
|||||||
10
main.py
10
main.py
@@ -171,13 +171,17 @@ def ensure_keys(keys_file, db_dir):
|
|||||||
def show_status():
|
def show_status():
|
||||||
"""显示当前数据状态"""
|
"""显示当前数据状态"""
|
||||||
cfg = {}
|
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):
|
if os.path.exists(config_file):
|
||||||
with open(config_file, encoding="utf-8") as f:
|
with open(config_file, encoding="utf-8") as f:
|
||||||
cfg = json.load(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:
|
else:
|
||||||
print("[config] 未找到 config.json")
|
print(f"[config] 未找到 {config_file}")
|
||||||
|
|
||||||
keys_files = sorted(glob.glob("all_keys*.json"))
|
keys_files = sorted(glob.glob("all_keys*.json"))
|
||||||
print(f"[keys] {len(keys_files)} 个密钥文件")
|
print(f"[keys] {len(keys_files)} 个密钥文件")
|
||||||
|
|||||||
Reference in New Issue
Block a user