ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

微信DAT文件转JPG:单字节异或解密与批量还原实战

微信DAT文件转JPG:单字节异或解密与批量还原实战 手机相册清空过、电脑重装过、微信聊天记录迁移过一轮之后很多人才发现当年在聊天窗口里点开过的那张图只剩下本地缓存里一个叫xxx.dat的怪文件。双击打不开改后缀成 .jpg 也没用扔进看图软件直接报错。这不是文件坏了是微信 PC 端在落地缓存时给图片套了一层极轻量的字节变换把原本的 JPG 头打乱了所以任何标准解码器都认不出它。这篇内容要解决的就是微信 DAT 文件转 JPG 图片这件事怎么在一堆按哈希命名的缓存里认出哪些是图片、怎么在不依赖任何第三方商业软件的前提下把它们批量还原成可正常打开的 JPG/PNG/GIF、以及还原过程中那些文档里不会写的坑。适合三类人看一是想找回自己聊天记录里图片的普通用户二是做数据归档、电子取证辅助整理的技术人员三是想搞清楚这类缓存格式设计思路的开发者。全文给出的脚本可以直接跑不写死任何魔法数字换版本也不至于全盘失效。1. 微信把图片存成了什么目录结构、文件特征与设计动机1.1 三个平台、四个版本的缓存目录对照动手之前先定位战场。微信 PC 端的文件布局在几个大版本之间改过不止一次很多人搜到的教程路径对不上原因就在这。下面这张表是我在几台机器上实际翻过的结果不同小版本可能有微调但主干结构基本一致。平台 / 版本常见根目录位置图片主要落点Windows 微信 3.xC:\Users\用户\Documents\WeChat Files\wxid\FileStorage\Image\年-月\、FileStorage\MsgAttach\hash\Image\Windows 微信 4.xC:\Users\用户\Documents\xwechat_files\wxid\msg\attach\hash\年-月\Img\Windows 企业微信C:\Users\用户\Documents\WXWork\企业ID\Cache\Image\、Data\hash\Image\macOS 微信~/Library/Containers/com.tencent.xinWeChat/Data/下的多层哈希目录Message/MessageTemp\hash\Image\打开这些目录你会看到两种命名风格一种是纯哈希串比如3f8a1c...dat长度固定、没有语义这是按内容摘要去重后的结果同一张图在多个会话里出现也只存一份另一种是带时间戳的日期目录比如2024-03方便按时间批量清理。哈希命名带来的直接后果是你不能靠文件名判断这张图是哪天发的、发给谁的只能靠文件系统的修改时间或者所在目录的日期去反推。这一点在整理阶段很关键后面第 5 节会专门讲归档策略。另外提醒一句动手前先把整个FileStorage或者msg目录整体复制一份出去再做处理。脚本本身是只读的但谁也不能保证中途手滑原始缓存一旦被覆盖写找回的难度会陡增。1.2 .dat 并不是一种格式它只是一层壳很多人误以为 .dat 是一种图片格式其实它连格式都算不上本质就是原始字节流 一次固定的字节异或。也就是说壳里面的东西可能是 JPG、PNG、GIF、BMP也可能是语音的 SILK、视频的 MP4、甚至是一段被截断的残片。扩展名统一写成 .dat只是让系统不要自动关联到看图软件上去。这里有个很容易被忽略的点同一台机器上不同目录下的 dat 未必是同一种内容。Image目录下绝大多数是 JPG 缩略图MsgAttach里可能混着原图和视频封面Audio目录下则基本是 SILK 语音。如果你拿一个只认图片的脚本去硬扫整个WeChat Files结果会得到一堆看起来像图片但打不开的文件然后在排查上浪费大量时间。正确做法是分目录处理先小范围试跑确认识别率之后再全量。1.3 为什么是单字节异或站在设计者的角度想一下这个选择其实非常合理。微信 PC 端处理的是海量小文件一个用户几十万张缓存图很常见。如果每一张图在落盘时都要走一遍完整的加密算法CPU 和 IO 都会吃不消而单字节异或的开销几乎为零——加解密是同一个操作甚至可以在流式读取时用查表法一次性处理整块内存。同时它又达成了最核心的目的让文件无法被系统或通用软件直接识别。JPG 的文件头是FF D8 FF这三个字节被异或之后就完全变样了Windows 不会给它缩略图看图软件不会自动打开普通用户也就不会一不小心把缓存目录当成相册。这是一种低成本伪装安全性谈不上高但挡掉 99% 的误操作足够了。理解了这层动机后面的所有技术动作就顺理成章了既然变换是可逆的单字节运算只要有任何一个已知明文片段就能把密钥反推出来。2. 核心原理从文件头反推密钥的完整推导2.1 常见文件头速查与十六进制观察所有标准文件格式的开头都有一段固定的魔数这是规范里写死的用来让解码器快速判断类型。先把常用的几张表列出来后面写脚本、手工验算都要用。格式文件头十六进制可读字符典型扩展名JPEGFF D8 FF乱码.jpg / .jpegPNG89 50 4E 47 0D 0A 1A 0A乱码 .PNG.....pngGIF47 49 46 38GIF8.gifBMP42 4DBM.bmpWEBP52 49 46 46 偏移 8 处57 45 42 50RIFFWEBP.webpSILK 语音02 23 21 53 49 4C 4B乱码 #!SILK.silkAMR 语音23 21 41 4D 52#!AMR.amr观察一个 dat 文件头最方便的工具是命令行。Windows 上用certutil -encodehex或者装个 HxDLinux 和 macOS 上直接xxd -l 16 文件名.dat就能看到前 16 个字节。假设你看到的结果是c8 ef e2 8a ...记住这几个数下一步要用。2.2 手工演算一遍看清密钥是怎么来的现在做一次完整推导。假设某个 dat 文件的开头是C8 EF E2我们猜它是被异或处理过的 JPG明文头应该是FF D8 FF。第一步用第一个字节求密钥0xC8 XOR 0xFF 0x37第二步用这个候选密钥验证后面两个字节0xEF XOR 0x37 0xD8 ✓与 JPG 第二字节吻合 0xE2 XOR 0x37 0xD5 ✗JPG 第三字节应该是 0xFF第二个字节对上了说明方向没错第三个字节对不上这种情况非常常见——因为有些缓存文件的第三、第四字节并不严格遵循完整文件头尤其是缩略图在生成时做过裁剪。工程上的判断标准是前两个字节吻合就足以确认不要苛求全字节匹配否则识别率会掉得很惨。再验一个 PNG 的情况。如果 dat 头是BE 67 79 70用0x37去异或0xBE XOR 0x37 0x89 ✓ 0x67 XOR 0x37 0x50 ✓ 0x79 XOR 0x37 0x4E ✓ 0x70 XOR 0x37 0x47 ✓四个字节全中这就是一张 PNG。你会发现一个有意思的现象同一批文件里密钥往往是同一个。旧版本微信 PC 端长期使用0x37这个值所以网上大量脚本直接把它写死。2.3 把 0x37 写死为什么会翻车写死密钥是这类脚本最常见的失败原因具体会在三种场景出问题。第一种是版本迭代。不同大版本、不同平台、甚至企业微信和微信之间密钥取值都可能不一样。你从网上抄来的0x37脚本在别人机器上跑得飞起在自己机器上导出的全是花屏九成是密钥对不上。第二种是目录里混着未加密文件。前面说过MsgAttach里可能同时存在加密缓存和直接落盘的原始文件。如果对一张已经是明文的 JPG 再做一次0x37异或FF会变成C8好好的图直接报废。写死的脚本没有这张不用处理的判断能力只能一刀切。第三种是密钥可能随文件变化。虽然不常见但确实存在某些版本对不同类型的缓存使用了不同变换参数的情况。唯一稳妥的做法是逐文件推断读取文件头遍历候选魔数谁能让首字节异或结果同时满足前两个字节就采信谁。这也是第 3 节脚本的核心逻辑。3. 手写批量转换脚本从识别到导出3.1 环境与目录规划脚本用 Python 3 写标准库就够跑不装任何第三方包也能完成识别和转换Pillow 只在做完整性体检时才需要属于可选项。整套东西建议按这样的结构摆放wechat_dat/ ├── convert.py # 主脚本 ├── verify.py # 可选的完整性校验脚本 ├── input/ # 复制出来的 .dat保持原目录结构 └── output/ # 还原结果按 jpg/png/other 分类强调一下input目录的用法永远不要直接在微信原始目录上跑脚本。先把目标目录整体复制到input下脚本只读input、只写output原始数据零风险。这一步看似多余但你一旦遇到需要反复调参重跑的情况就知道有多省心。3.2 识别函数一次读取、逐个比对核心思路是准备一张明文魔数表然后拿文件头去逐个试。关键技巧在于候选密钥由首字节异或得出用第二字节验证这样既快又准。# -*- coding: utf-8 -*- 微信 .dat 缓存文件识别与还原 # (明文魔数, 输出扩展名) MAGICS [ (b\xff\xd8\xff, jpg), (b\x89\x50\x4e\x47\x0d\x0a\x1a\x0a, png), (b\x47\x49\x46\x38, gif), (b\x42\x4d, bmp), (b\x52\x49\x46\x46, webp), (b\x00\x00\x00\x18\x66\x74\x79\x70, mp4), (b\x23\x21\x41\x4d\x52, amr), (b\x02\x23\x21\x53\x49\x4c\x4b, silk), ] MAX_LEN max(len(m) for m, _ in MAGICS) def guess_key(head: bytes): 从文件头推断异或密钥返回 (key, ext)失败返回 (None, None) if len(head) 2: return None, None for magic, ext in MAGICS: key head[0] ^ magic[0] # 用前两个字节做一致性验证兼顾识别率与准确率 if (head[1] ^ key) ! magic[1]: continue # webp 需要额外确认偏移 8 处是 WEBP if ext webp: if len(head) 12: continue tag bytes(b ^ key for b in head[8:12]) if tag ! bWEBP: continue return key, ext return None, None这段代码有两个地方值得说明。第一我只用前两个字节做验证不是偷懒。实测中如果把三个或四个字节全部要求匹配JPG 的识别率会明显下降因为缩略图头部偶尔会有偏差而两字节验证在几万个样本上几乎没有误判——不同魔数前两字节的组合差异足够大撞车概率极低。第二webp 单独处理因为它的魔数特征分散在开头和偏移 8 两处只看开头会把所有 RIFF 容器包括 WAV 音频都误判成图片。3.3 转换函数字节翻译表与分块读写单字节异或最容易写慢的写法是bytes(b ^ key for b in data)这种逐字节生成器在大文件上会慢得让人怀疑人生。正确姿势是预生成一张 256 字节的翻译表然后用bytes.translate()底层是 C 实现速度差一个数量级。from pathlib import Path def make_table(key: int) - bytes: 生成 256 字节的异或翻译表 return bytes(b ^ key for b in range(256)) def convert_file(src: Path, dst_dir: Path, chunk: int 1 20): 还原单个 dat 文件返回输出路径无法识别返回 None with open(src, rb) as f: head f.read(MAX_LEN) key, ext guess_key(head) if key is None: return None # key 0 意味着本来就是明文直接按原样落盘 table make_table(key) dst_dir.mkdir(parentsTrue, exist_okTrue) out dst_dir / f{src.stem}.{ext} idx 1 while out.exists(): out dst_dir / f{src.stem}_{idx}.{ext} idx 1 f.seek(0) with open(out, wb) as g: while True: buf f.read(chunk) if not buf: break g.write(buf.translate(table)) return out几个细节。分块大小取 1MB 是个折中值太小会增加系统调用次数太大在机械盘上会拖慢随机读。重名处理用递增后缀因为哈希命名虽然理论上不重复但不同目录下的同名文件合并导出时会撞车。key 0的情况一定要保留那就是明文直出不做任何加工。3.4 批量遍历、分类归档与去重单文件能转了接下来是批量。我这里按内容类型分目录存放而不是全部堆在一起——因为实际跑一遍下来你大概率会收获一批 MP4 和 SILK混在图片堆里会非常难受。from collections import Counter def batch(src_root: Path, dst_root: Path): stats Counter() seen {} # md5 - 输出路径用于去重 for p in src_root.rglob(*.dat): with open(p, rb) as f: head f.read(MAX_LEN) key, ext guess_key(head) if ext is None: stats[unknown] 1 continue bucket dst_root / (image if ext in (jpg, png, gif, bmp, webp) else other) out convert_file(p, bucket) if out is None: stats[failed] 1 continue digest md5_of(out) if digest in seen: out.unlink() # 内容重复删掉后导出的那份 stats[dup] 1 else: seen[digest] out stats[ext] 1 return stats去重这一步不是可有可无的。哈希命名只保证同一个会话目录内不重复跨目录、跨账号、跨备份批次的重合非常普遍。我在一次 3.2 万文件的样本里去重前后差了将近 4000 张接近 12%。用 MD5 比对1MB 分块读几十 G 的量跑完也就几分钟。4. 实操过程中的坑与排查手册4.1 典型故障速查表上面这套流程我前后跑了不下十几次踩过的坑整理成一张表遇到问题直接对号入座。现象可能原因处理方式转换后全是花屏、颜色错乱密钥推断错误或用了写死的0x37改用逐文件推断打印候选 key 观察分布大量文件识别为 unknown目录里混了非图片内容先xxd抽几个看头确认是否语音/视频输出图片打不开提示损坏原文件本身就是残片未下载完用 Pillow 体检剔除失败项转换后是明文但仍是 .datkey 0分支没处理保留原样直出或复制为对应扩展名图片能开但只有上半截原缓存是渐进式 JPG 的未完成片段无法修复只能放弃脚本报PermissionError微信正在运行文件被占用退出微信后重跑或先复制再处理输出目录爆满未做去重重复文件占空间加 MD5 去重4.2 那些看着像图片其实不是的 dat这是最容易浪费时间的一类问题。你在MsgAttach里看到某个 dat 有 2MB满心欢喜地转换结果输出一个打不开的文件。原因往往藏在文件头里。最常见的三种冒牌货第一种是 SILK 语音头是02 23 21 53 49 4C 4B转出来会是 .silk 后缀需要用专门的解码器转成 wav/mp3 才能听第二种是 MP4 视频头是00 00 00 18 66 74 79 70这在MsgAttach里比例不低很多图片其实是视频的第一帧封面和视频本体共用一个哈希目录第三种是 AMR头是23 21 41 4D 52常见于早期的语音消息。所以脚本里的魔数表一定要写全而不是只认图片。只认图片的后果是这些文件被归入 unknown 一走了之你还以为是脚本识别能力不行。我在第一批实验里就是因为没加 SILK白白在几个 G 的音频上折腾了半天。注意识别到非图片类型时不要急着转成 JPG 强行打开格式不对就是不对改后缀名不会让内容变得可解码只会让你在排查时更难判断问题出在哪一环。4.3 转换后能打开但显示不全 / 图像花屏花屏基本可以板上钉钉地判定为密钥错了。判断方法很简单随便挑一个花屏文件十六进制看开头如果转换后的前两字节不是FF D8或者89 50那就是这一批的密钥整体推断有偏差。还有一种更隐蔽的情况同一目录下存在密钥不同的文件。这在跨版本迁移过来的历史数据里出现过。表现是大部分图正常少数几张花屏。这时候逐文件推断的优势就体现出来了因为它对每个文件独立求 key天然适应混布。至于只剩上半截的图基本可以放弃治疗。这类文件的典型特征是大小正好卡在某个整数倍上比如 65536 字节或者 131072 字节说明当时下载中断落盘的本来就是不完整片段。异或还原不会凭空补出丢失的数据硬修也只能补个灰底。唯一的补救方向是从手机端或者其他备份里重新取一份。5. 提效工作流校验、归档与跨平台差异5.1 用 Pillow 做批量体检转出来 5 万张图总不能一张一张点开看。批量体检用 Pillow 最省事它读文件头的时候就会拒绝掉结构不对的文件。from PIL import Image from pathlib import Path def verify_all(root: Path): bad [] for p in root.rglob(*): if p.suffix.lower() not in (.jpg, .jpeg, .png, .gif, .bmp, .webp): continue try: with Image.open(p) as im: im.verify() # 只校验结构不解码像素速度快 except Exception as e: bad.append((p, str(e))) return badverify()只检查文件结构完整性不做像素解码速度比真正加载快得多。跑完拿到 bad 列表再决定是丢弃还是单独归档。注意verify()之后这个 Image 对象就不能再用了需要重新打开才能读尺寸信息这是 Pillow 的一个常见陷阱。5.2 按时间线归档与命名策略还原出来的文件都叫哈希名这在人看来毫无意义。整理阶段我一般按年-月分目录用文件修改时间作为依据import os import time from pathlib import Path def organize(src: Path, dst: Path): for p in src.rglob(*.jpg): ts os.path.getmtime(p) ym time.strftime(%Y-%m, time.localtime(ts)) target dst / ym / p.name target.parent.mkdir(parentsTrue, exist_okTrue) p.rename(target)需要说明的是文件修改时间不等于拍摄时间或发送时间。缓存写入时间通常会晚于原图创建时间但在批量下载的情况下两者相差不大用来做粗略的时间轮廓足够了。如果你需要更精确的时间只能去读微信本地数据库那属于另一个话题复杂度高很多。文件名保持哈希不要改哈希是唯一的改了之后一旦出现重名反而更难追溯来源。5.3 企业微信、Mac 端和手机端的差异处理企业微信在 Windows 上的缓存结构和微信个人版很像同样的异或思路同样的逐文件推断方法脚本可以直接复用只要把起始目录换成WXWork\企业ID\Cache\Image就行。区别在于企业微信的目录层级更深哈希目录更多批量跑之前建议先统计一下文件总数心里有个数。macOS 上的路径藏在~/Library里而且嵌套了多层无序的哈希目录用find或者 Python 的rglob去遍历比较省事。同样是逐文件推断密钥不要预设。至于手机端安卓上的微信图片缓存通常不以 .dat 形式存在而是直接是加密后的文件格式和解法都不同不在这次讨论范围内。如果你手上只有手机优先考虑用微信自带的迁移功能把数据搬到 PC 再处理这条路要顺得多。最后分享一个我自己跑完几轮之后总结的顺序先小目录试跑确认识别率再全量跑先原样导出再考虑去重和归档先备份再动手。这三条按顺序做基本不会出大问题。至于那些已经残缺的缓存片段接受它修不回来比在它身上耗一整天要划算。
返回列表