From 87e066d113f670da72becf00674dc6e2f4c10b37 Mon Sep 17 00:00:00 2001 From: xaoyo Date: Wed, 27 Sep 2023 21:08:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=EF=BC=8C=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- decrypted/get_wx_decrypted_db.py | 5 +- parse_db/README.md | 246 +++++++++++++++++++++++++++++++ parse_db/__init__.py | 11 ++ parse_db/parse.py | 241 ++++++++++++++++++++++++++++++ 4 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 parse_db/README.md create mode 100644 parse_db/__init__.py create mode 100644 parse_db/parse.py diff --git a/decrypted/get_wx_decrypted_db.py b/decrypted/get_wx_decrypted_db.py index 93c4e53..d8c95f0 100644 --- a/decrypted/get_wx_decrypted_db.py +++ b/decrypted/get_wx_decrypted_db.py @@ -250,6 +250,9 @@ def merge_media_msg_db(db_path: list, save_path: str): def main(keys: list = None): decrypted_ROOT = os.path.join(os.getcwd(), "decrypted") + if keys is None: + print("keys is None") + return False user_dirs = get_wechat_db() for user, db_path in user_dirs.items(): # 遍历用户 @@ -286,7 +289,7 @@ def main(keys: list = None): merge_copy_msg_db(EmotionDecryptPaths, EmmotionDbPath) shutil.rmtree(decrypted_path_tmp) # 删除临时文件 - + return True if __name__ == '__main__': diff --git a/parse_db/README.md b/parse_db/README.md new file mode 100644 index 0000000..5735fcc --- /dev/null +++ b/parse_db/README.md @@ -0,0 +1,246 @@ +# 微信PC端各个数据库简述 + +* 说明:针对 .../WeChat Files/wxid_xxxxxxxx/Msg下的各个文件解密后的内容进行概述 +* 未作特别说明的情况下,“聊天记录数据”指代的数据结构上都和Multi文件夹中的完整聊天记录数据相同或类似。 +* 本文档仅供学习交流使用,严禁用于商业用途及非法用途,否则后果自负 + +## 一、微信小程序相关 + +微信小程序的相关数据,包括但不限于: + +* 你使用过的小程序 RecentWxApp +* 星标的小程序 StarWxApp +* 各个小程序的基本信息 WAContact + +用处不大,不过可以看到你使用过的小程序的名称和图标,以及小程序的AppID + +## 二、企业微信相关 + +### BizChat + +企业微信联系人数据,包括但不限于: + +* 在微信中可以访问的企业微信会话ChatInfo +* 一部分会话的信息ChatSession(未确认与ChatInfo的关系;这其中的Content字段是最近一条消息,疑似用于缓存展示的内容) +* 包括群聊在内的聊天涉及的所有企业微信用户身份信息UsrInfo +* 该微信账号绑定的企业微信身份MyUsrInfo +* 特别说明:未经详细查证,这其中的聊天是否包含使用普通微信身份与企业微信用户发起的聊天,还是只包含使用绑定到普通微信的企业微信身份与其它企业微信身份发起的聊天。 + +### BizChatMsg + +* 企业微信聊天记录数据,包括所有和企业微信聊天的数据。 +* 与BizChat一样,未确定涉及的范围究竟是只有企业微信-企业微信还是同时包含普通微信-企业微信。 +* 另外,此处的消息与Multi文件夹中真正的微信消息不同的是在于没有拆分数据库。 + +### OpenIM 前缀 + +* 这个也是企业微信的数据,包括联系人、企业信息、与企业微信联系人的消息等。 +* 这个是普通微信-企业微信的数据,上面biz前缀的是企业微信-企业微信 +* 这个不常用,而且也没有全新的数据结构,不再详细说了。 + +### PublicMsg + +* 看起来像是企业微信的通知消息,可以理解为企业微信的企业应用消息 + +## 三、微信功能相关 + +### Emotion + +顾名思义表情包相关,包括但不限于以下内容: + +* CustomEmotion:顾名思义用户手动上传的GIF表情,包含下载链接,不过看起来似乎有加密(内有aesKey字段但我没测试) +* EmotionDes1 和 EmotionItem 应该也是类似的内容,没仔细研究 +* EmotionPackageItem:账号添加的表情包的集合列表(从商店下载的那种) + +ps:用处不大,微信的MSG文件夹有表情包的url链接,可以直接网络获取聊天记录中的表情包。 + +### Favorite + +* FavItems:收藏的消息条目列表 +* FavDataItem:收藏的具体数据。没有自习去看他的存储逻辑,不过大概可以确定以下两点 + * 即使只是简单收藏一篇公众号文章也会在 FavDataItem 中有一个对应的记录 + * 对于收藏的合并转发类型的消息,合并转发中的每一条消息在 FavDataItem 中都是一个独立的记录 +* FavTags:为收藏内容添加的标签 + +### Misc + +* 有BizContactHeadImg和ContactHeadImg1两张表,应该是二进制格式的各个头像 + +### Sns + +微信朋友圈的相关数据: + +* FeedsV20:朋友圈的XML数据 +* CommentV20:朋友圈点赞或评论记录 +* NotificationV7:朋友圈通知 +* SnsConfigV20:一些配置信息,能读懂的是其中有你的朋友圈背景图 +* SnsGroupInfoV5:猜测是旧版微信朋友圈可见范围的可见或不可见名单 + +### FTS(搜索) + +* 前缀为 FTS 的数据库可能都和全文搜索(Full-Text Search)相关(就是微信那个搜索框) + +### FTSContact + +有一堆表 + +* FTSChatroom15_content 和 FTSContact15_content + 分别对应的是微信“聊天”界面会展示的消息会话(包括公众号等)和“联系人”界面会出现的所有人(有的时候并不是所有联系人都会出现在“聊天”中),信息包含昵称、备注名和微信号,也和微信支持搜索的字段相匹配。 + +### FTSFavorite + +搜索收藏内容的索引 + +* 命名方式类似上面一条 + +ps:对于收藏内容通过文字搜索,电脑版是把所有东西拼接成一个超长字符串来实现的。这对于文本、链接等没啥问题,但是对于合并转发消息,就会出现搜索\[图片] +这一关键词。 + +### MultiSearchChatMsg + +* 这个数据库前缀不一样,但是看内容和结构应该还是一个搜索相关,搜索的是聊天记录中的文件 +* 存储了文件名和其所在的聊天 +* 不过FTSMsgSearch18_content和SessionAttachInfo两张表记录数量有显著差异,不确定是哪个少了或是怎样。 + +### HardLink(文件在磁盘存储的位置) + +* 将文件/图片/视频的文件名指向保存它们的文件夹名称(例如2023-04),有用但不多。 + +### Media + +* ChatCRVoice和MediaInfo 可能为语音信息 + +## 三、MicroMsg (联系人核心) + +一个数据库,不应该和分类平级,但是我认为这是分析到目前以来最核心的,因此单独来说了。 + +### AppInfo(表) + +一些软件的介绍,猜测可能是关于某些直接从手机APP跳转到微信的转发会带有的转发来源小尾巴的信息 + +### Biz 前缀 + +与公众号相关的内容,应该主要是账号本身相关。 + +能确定的是 BizSessionNewFeeds 这张表保存的是订阅号大分类底下的会话信息,包括头像、最近一条推送等。 + +### ChatInfo + +保存“聊天”列表中每个会话最后一次标记已读的时间 + +### ChatRoom 和 ChatRoomInfo + +存储群聊相关信息 + +* ChatRoom:存储每个群聊的用户列表(包括微信号列表和群昵称列表)和个人群昵称等信息 +* ChatRoomInfo:群聊相关信息,主要是群公告内容,与成员无关 + 顺便再吐槽一下,微信这个位置有一个命名出现异常的,别的表前缀都是ChatRoom,而突然出现一个ChatroomTool + +### Contact + +顾名思义,联系人。不过这里的联系人并不是指你的好友,而是所有你可能看见的人,除好友外还有所有群聊中的所有陌生人。 + +* Contact:这张表存储的是用户基本信息,包括但不限于微信号(没有好友的陌生人也能看!)、昵称、备注名、设置的标签等等,甚至还有生成的各种字段的拼音,可能是用于方便搜索的吧 +* ContactHeadImgUrl:头像地址 +* ContactLabel:好友标签 ID 与名称对照 + +### PatInfo + +存了一部分好友的拍一拍后缀,但是只有几个,我记得我电脑上显示过的拍一拍似乎没有这么少? + +### Session + +真正的“聊天”栏目显示的会话列表,一个不多一个不少,包括“折叠的群聊”这样子的特殊会话;信息包括名称、未读消息数、最近一条消息等 + +### TicketInfo + +这张表在我这里有百余条数据,但是我实在没搞明白它是什么 + +## 四、FTSMSG + +FTS 这一前缀了——这代表的是搜索时所需的索引。 + +其内主要的内容是这样的两张表: + +* FTSChatMsg2_content:内有三个字段 + * docid:从1开始递增的数字,相当于当前条目的 ID + * c0content:搜索关键字(在微信搜索框输入的关键字被这个字段包含的内容可以被搜索到) + * c1entityId:尚不明确用途,可能是校验相关 +* FTSChatMsg2_MetaData + * docid:与FTSChatMsg2_content表中的 docid 对应 + * msgId:与MSG数据库中的内容对应 + * entityId:与FTSChatMsg2_content表中的 c1entityId 对应 + * type:可能是该消息的类型 + * 其余字段尚不明确 + +特别地,表名中的这个数字2,个人猜测可能是当前数据库格式的版本号。 + +## 五、MediaMSG (语音消息) + +这里存储了所有的语音消息。数据库中有且仅有Media一张表,内含三个有效字段: + +* Key +* Reserved0 与MSG数据库中消息的MsgSvrID一一对应 +* Buf silk格式的语音数据 + +## 六、MSG(聊天记录核心数据库) + +内部主要的两个表是`MSG`和`Name2ID` + +### Name2ID + +* `Name2ID`这张表只有一列,内容格式是微信号或群聊ID@chatroom +* 作用是使MSG中的某些字段与之对应。虽然表中没有 ID 这一列,但事实上微信默认了第几行 ID 就是几(从1开始编号)。 + +### MSG + +* localId:字面意思消息在本地的 ID,暂未发现其功用 +* TalkerId:消息所在房间的 ID(该信息为猜测,猜测原因见 StrTalker 字段),与Name2ID对应。 +* MsgSvrID:猜测 Srv 可能是 Server 的缩写,代指服务器端存储的消息 ID +* Type:消息类型,具体对照见表1 +* SubType:消息类型子分类,暂时未见其实际用途 +* IsSender:是否是自己发出的消息,也就是标记消息展示在对话页左边还是右边,取值0或1 +* CreateTime:消息创建时间的秒级时间戳。此处需要进一步实验来确认该时间具体标记的是哪个时间节点,个人猜测的规则如下: + * 从这台电脑上发出的消息:标记代表的是每个消息点下发送按钮的那一刻 + * 从其它设备上发出的/收到的来自其它用户的消息:标记的是本地从服务器接收到这一消息的时间 +* Sequence:次序,虽然看起来像一个毫秒级时间戳但其实不是。这是`CreateTime` + 字段末尾接上三位数字组成的,通常情况下为000,如果在出现两条`CreateTime` + 相同的消息则最后三位依次递增。需要进一步确认不重复范围是在一个会话内还是所有会话。`CreateTime` + 相同的消息则最后三位依次递增。需要进一步确认不重复范围是在一个会话内还是所有会话。 +* StatusEx、FlagEx、Status、MsgServerSeq、MsgSequence:这五个字段个人暂时没有分析出有效信息 +* StrTalker:消息发送者的微信号。特别说明,从这里来看的话,上面的`TalkerId` + 字段大概率是指的消息所在的房间ID,而非发送者ID,当然也可能和`TalkerId`属于重复内容,这一点待确认。 +* StrContent:字符串格式的数据。特别说明的是,除了文本类型的消息外,别的大多类型这一字段都会是一段 XML + 数据标记一些相关信息。通过解析xml可以得到更多的信息,例如图片的宽高、语音的时长等等。 +* DisplayContent:对于拍一拍,保存拍者和被拍者账号信息 +* Reserved0~6:这些字段也还没有分析出有效信息,也有的字段恒为空 +* CompressContent:字面意思是压缩的数据,实际上也就是微信任性不想存在 StrContent + 里的数据在这里(例如带有引用的文本消息等;采用lz4压缩算法压缩) +* BytesExtra:额外的二进制格式数据 +* BytesTrans:目前看这是一个恒为空的字段 + +表1:MSG.Type字段数值与含义对照表(可能可以扩展到其它数据库中同样标记消息类型这一信息的字段) + +| 分类`Type` | 子分类`SubType` | 对应类型 | +|----------|--------------|-------------------------------------------------------------| +| 1 | 0 | 文本 | +| 3 | 0 | 图片 | +| 34 | 0 | 语音 | +| 43 | 0 | 视频 | +| 47 | 0 | 动画表情(第三方开发的表情包) | +| 49 | 1 | 类似文字消息而不一样的消息,目前只见到一个阿里云盘的邀请注册是这样的。估计和57子类的情况一样 | +| 49 | 5 | 卡片式链接,CompressContent 中有标题、简介等,BytesExtra 中有本地缓存的封面路径 | +| 49 | 6 | 文件,CompressContent 中有文件名和下载链接(但不会读),BytesExtra 中有本地保存的路径 | +| 49 | 8 | 用户上传的 GIF 表情,CompressContent 中有CDN链接,不过似乎不能直接访问下载 | +| 49 | 19 | 合并转发的聊天记录,CompressContent 中有详细聊天记录,BytesExtra 中有图片视频等的缓存 | +| 49 | 33/36 | 分享的小程序,CompressContent 中有卡片信息,BytesExtra 中有封面缓存位置 | +| 49 | 57 | 带有引用的文本消息(这种类型下 StrContent 为空,发送和引用的内容均在 CompressContent 中) | +| 49 | 63 | 视频号直播或直播回放等 | +| 49 | 87 | 群公告 | +| 49 | 88 | 视频号直播或直播回放等 | +| 49 | 2000 | 转账消息(包括发出、接收、主动退还) | +| 49 | 2003 | 赠送红包封面 | +| 10000 | 0 | 系统通知(居中出现的那种灰色文字) | +| 10000 | 4 | 拍一拍 | +| 10000 | 8000 | 系统通知(特别包含你邀请别人加入群聊) | + diff --git a/parse_db/__init__.py b/parse_db/__init__.py new file mode 100644 index 0000000..a69538a --- /dev/null +++ b/parse_db/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*-# +# ------------------------------------------------------------------------------- +# Name: __init__.py.py +# Description: +# Author: xaoyaoo +# Date: 2023/09/27 +# ------------------------------------------------------------------------------- + + +if __name__ == '__main__': + pass diff --git a/parse_db/parse.py b/parse_db/parse.py new file mode 100644 index 0000000..bf758ee --- /dev/null +++ b/parse_db/parse.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*-# +# ------------------------------------------------------------------------------- +# Name: parse.py +# Description: 解析数据库内容 +# Author: xaoyaoo +# Date: 2023/09/27 +# ------------------------------------------------------------------------------- +import sqlite3 +import pysilk +from io import BytesIO +import wave +import pyaudio +import requests +import hashlib + +from PIL import Image +import xml.etree.ElementTree as ET + + +def get_md5(data): + md5 = hashlib.md5() + md5.update(data) + return md5.hexdigest() + + +def parse_xml_string(xml_string): + """ + 解析 XML 字符串 + :param xml_string: 要解析的 XML 字符串 + :return: 解析结果,以字典形式返回 + """ + + def parse_xml(element): + """ + 递归解析 XML 元素 + :param element: 要解析的 XML 元素 + :return: 解析结果,以字典形式返回 + """ + result = {} + + # 解析当前元素的属性 + for key, value in element.attrib.items(): + result[key] = value + + # 解析当前元素的子元素 + for child in element: + child_result = parse_xml(child) + + # 如果子元素的标签已经在结果中存在,则将其转换为列表 + if child.tag in result: + if not isinstance(result[child.tag], list): + result[child.tag] = [result[child.tag]] + result[child.tag].append(child_result) + else: + result[child.tag] = child_result + + # 如果当前元素没有子元素,则将其文本内容作为值保存 + if not result and element.text: + result = element.text + + return result + + if xml_string is None or not isinstance(xml_string, str): + return None + try: + root = ET.fromstring(xml_string) + except Exception as e: + return xml_string + return parse_xml(root) + + +def read_img_dat(input_data): + # 常见图片格式的文件头 + img_head = { + b"\xFF\xD8\xFF": ".jpg", + b"\x89\x50\x4E\x47": ".png", + b"\x47\x49\x46\x38": ".gif", + b"\x42\x4D": ".BMP", + b"\x49\x49": ".TIFF", + b"\x4D\x4D": ".TIFF", + b"\x00\x00\x01\x00": ".ICO", + b"\x52\x49\x46\x46": ".WebP", + b"\x00\x00\x00\x18\x66\x74\x79\x70\x68\x65\x69\x63": ".HEIC", + } + fomt = "un" # 文件格式 + + if isinstance(input_data, str): + with open(input_data, "rb") as f: + input_bytes = f.read() + + t = 0 + for hcode in img_head: + t = input_bytes[0] ^ hcode[0] + for i in range(1, len(hcode)): + if t == input_bytes[i] ^ hcode[i]: + fomt = img_head[hcode] + else: + break + else: + break + else: + return False + + if fomt == "un": + print("未知文件格式") + return False + + out_bytes = bytearray() + for nowByte in input_bytes: # 读取文件 + newByte = nowByte ^ t # 异或解密 + out_bytes.append(newByte) + + md5 = get_md5(out_bytes) + return fomt, md5, out_bytes + +def read_emoji(cdnurl, is_show=False): + headers = { + "User-Agent": "Mozilla/5.0 (Linux; Android 10; Redmi K30 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Mobile Safari/537.36" + + } + r1 = requests.get(cdnurl, headers=headers) + rdata = r1.content + + if is_show: # 显示表情 + img = Image.open(BytesIO(rdata)) + img.show() + return rdata + + +def decompress_CompressContent(data): + """ + 解压缩Msg:CompressContent内容 + :param data: + :return: + """ + if data is None or not isinstance(data, bytes): + return None + i = 0 + uncompressed_data = [] + + while i < len(data): + # 读取第一个字节 + byte1 = data[i] + # 从高四位得到无匹配的明文长度Lh + Lh = byte1 >> 4 + Li = byte1 & 0x0F # 从低四位得到匹配的数据长度Li + if Lh == 0x0f: + # 继续读取下一个字节L1 + i = i + 1 + L1 = data[i] + Lh = L1 + 0x0f + + while data[i] == 0xFF: + # 继续读取下一个字节,并累加 + i = i + 1 + Lh += data[i] + i += 1 + uncompressed_data.extend(data[i:i + Lh]) + i = i + Lh + + # 读取匹配的偏移量Offset + bias = data[i:i + 2] + offset = int.from_bytes(bias, byteorder='little') + i = i + 2 + + # 读取匹配的数据长度Li + if Li != 0x0F: + # 实际的匹配压缩长度即为Li = Li + 4 + Li += 4 + else: + # 从偏移量后面的可选匹配长度区域读取一个字节M1 + M1 = data[i] + Li += M1 + while M1 == 0xFF: + # 继续读取下一个字节M2 + i += 1 + M1 = data[i] + Li += M1 + Li += 4 + # 复制匹配的数据到解压缩数据缓冲区 + uncompressed_data.extend(uncompressed_data[-offset:-offset + Li]) + # break + + # 转换为字符串 + uncompressed_data = bytes(uncompressed_data) # .decode('utf-8') + return uncompressed_data + + +def read_audio_buf(buf_data, is_play=False, is_wave=False, rate=24000): + silk_file = BytesIO(buf_data) # 读取silk文件 + pcm_file = BytesIO() # 创建pcm文件 + + pysilk.decode(silk_file, pcm_file, rate) # 解码silk文件->pcm文件 + pcm_data = pcm_file.getvalue() # 获取pcm文件数据 + + silk_file.close() # 关闭silk文件 + pcm_file.close() # 关闭pcm文件 + if is_play: # 播放音频 + def play_audio(pcm_data, rate): + p = pyaudio.PyAudio() # 实例化pyaudio + stream = p.open(format=pyaudio.paInt16, channels=1, rate=rate, output=True) # 创建音频流对象 + stream.write(pcm_data) # 写入音频流 + stream.stop_stream() # 停止音频流 + stream.close() # 关闭音频流 + p.terminate() # 关闭pyaudio + + play_audio(pcm_data, rate) + + if is_wave: # 转换为wav文件 + wave_file = BytesIO() # 创建wav文件 + with wave.open(wave_file, 'wb') as wf: + wf.setparams((1, 2, rate, 0, 'NONE', 'NONE')) # 设置wav文件参数 + wf.writeframes(pcm_data) # 写入wav文件 + rdata = wave_file.getvalue() # 获取wav文件数据 + wave_file.close() # 关闭wav文件 + return rdata + + return pcm_data + + +def read_audio(MsgSvrID, is_play=False, is_wave=False, DB_PATH: str = "", rate=24000): + if DB_PATH == "": + return False + + DB = sqlite3.connect(DB_PATH) + cursor = DB.cursor() + sql = "select Buf from Media where Reserved0='{}'".format(MsgSvrID) + DBdata = cursor.execute(sql).fetchall() + + if len(DBdata) == 0: + return False + + data = DBdata[0][0] # [1:] + b'\xFF\xFF' + + pcm_data = read_audio_buf(data, is_play, is_wave, rate) + + return pcm_data + + +if __name__ == '__main__': + pass