本文内容仅适用于PC平台上的传统架构QQ(当前最新版本9.7.23),不支持新版NTQQ
简要介绍:提取电脑版QQ导出的EIF格式表情包中的图片,并保留分组及分组内的表情排序(云端分组仅支持导出,不支持排序)
下载地址:https://pan.baidu.com/s/1EQKA3vbh5vIL-nk204Fb6g?pwd=qi8r 提取码: qi8r
使用方法:将QQ导出的EIF表情包路径输入或者直接拖入即可

云端分组(就是图标为红心的那个分组)没有导出选项,可以随便找个带本地分组的EIF文件导入,然后导入的那个分组里就有选项了。导出全部表情包后,导出的文件也会包含云端表情包内的所有表情。
顺便提供一个带分组的EIF文件 https://pan.baidu.com/s/1ehe3n_aTUzD9-2wobND91Q?pwd=uww5 提取码: uww5

「版权保护,实际版权保护」「修改表情功能是为了保护版权,完全不是强迫大家使用我们的云端表情功能和开通会员,绝无垄断目的」「实际令人安心」
欺瞒!

DOMO,进度条DESU
众所周知,近几年来QQ越来越张小龙化,腾讯为了保护版权(?)一直在对表情功能悄悄地做调整,先是PC QQ9.4.3之后无法新建本地表情分组,然后是手机QQ不再支持把表情包保存到本地,再是新版NTQQ直接移除了本地表情功能,只能把表情存储在腾讯的云端分组上,如果从旧版PCQQ直接升级到NTQQ的话将会直接丢失所有本地表情。目前QQ官网上还保留着旧版QQ的下载入口,不过在可预见的将来,想必腾讯会无慈悲地强迫PC上所有人升级到NTQQ的吧,但是PCQQ的本地表情只能导出为腾讯自己的EIF文件,无法导出为图片,如果在座的读者中有人是和我一样是在十年间收藏了上万张本地表情的compulsive hoarding(强迫囤积症)患者的话,一定会因为这回忆即将丢失而感到沮丧的吧,南无三,难道我们就只能束手就擒乖乖的等待腾讯用青蛙·疗法fvck所有人吗?否!
为了保护大家在QQ中留下的美好回忆,我写了一款用于提取EIF中表情文件,并能保持表情在QQ分组中的排序的工具,同时我也会把我分析和解密EIF表情包的过程记录下来,希望能帮助到大家。
以下是正文内容,Newbie开发者DESU,各位栋梁请多多指教


首先我们可以用7zip直接打开EIF包就可以看到文件结构,数字编号的文件夹是表情分组,0-26是本地自定义的分组(是的我分了26个分组),8213是云端表情分组,文件夹内就是表情图片,但是在此直接提取的话会报数据错误,但是没关系,接下来我会介绍如何提取其中的图片。
1Vsersion.dat - 意义不明,里面只有一个字符,猜测为版本区分相关
Face.dat - 表情索引文件,每个表情的索引就存在这里,要保持表情文件存储的顺序我们会重点分析这个文件
Face2.dat - 和Face.dat完全一致,同样意义不明,猜测为版本兼容文件相关
提取表情文件十分简单,EIF文件其实就是一种Compound File Binary(CFB,复合文件格式),由微软开发,网络上已经有很多现成的库可以使用。腾讯并没有对表情源文件做加密,所以使用库就可以直接提取,这里我是用的是https://pypi.org/project/compoundfiles/
但是要保持提取后的表情文件排序就很麻烦了,由于腾讯对表情索引文件做了加密,直接打开Face.dat只是一团无慈悲的乱码,为此我们得对Face.dat进行解密

首先我们随便抽取一行
\x00\x86\xf5\x81\xf5\x87\xf5\xbc\xf5\x91\xf5\x2e\x00\x00\x00\x8a\xd1\xf5\xd1\x9d\xd1\x96\xd1\x89\xd1\xe5\xd1\x9f\xd1\xaf\xd1\xaa\xd1\xf8\xd1\xe0\xd1\x99\xd1\x9e\xd1\x83\xd1\x85\xd1\x9a\xd1\x88\xd1\x90\xd1\xe4\xd1\x8e\xd1\x95\xd1\xe8\xd1\x94\xd1\x08\x14\x00\x98\xeb\x9f\xeb\x99\xeb\xad\xeb\x82\xeb\x87\xeb\x8e\xeb\x84\xeb\x99\xeb\x8c\xeb\x3c\x00\x00\x00\xf1\xc3\xf2\xc3\x9f\xc3\x98\xc3\xe7\xc3\x8f\xc3\x84\xc3\x9b\xc3\xf7\xc3\x8d\xc3\xbd\xc3\xb8\xc3\xea\xc3\xf2\xc3\x8b\xc3\x8c\xc3\x91\xc3\x97\xc3\x88\xc3\x9a\xc3\x82\xc3\xf6\xc3\x9c\xc3\x87\xc3\xfa\xc3\x86\xc3\xed\xc3\xa9\xc3\xb3\xc3\xa4\xc3\x08\x18\x00\x94\xe7\x93\xe7\x95\xe7\xa1\xe7\x8e\xe7\x8b\xe7\x82\xe7\x81\xe7\x8e\xe7\x9f\xe7\x82\xe7\x83\xe7\x42\x00\x00\x00\x8f\xbd\x8c\xbd\xe1\xbd\xe6\xbd\x99\xbd\xf1\xbd\xfa\xbd\xe5\xbd\x89\xbd\xf3\xbd\xc3\xbd\xc6\xbd\x94\xbd\x8c\xbd\xf5\xbd\xf2\xbd\xef\xbd\xe9\xbd\xf6\xbd\xe4\xbd\xfc\xbd\x88\xbd\xe2\xbd\xf9\xbd\x84\xbd\xf8\xbd\xdb\xbd\xd4\xbd\xc5\xbd\x93\xbd\xdf\xbd\xd0\xbd\xcd\xbd\x06\x12\x00\x89\xed\x9a\xed\xaa\xed\x9f\xed\x82\xed\x98\xed\x9d\xed\xa4\xed\x89\xed\x04\x00\x00\x00\x15\x00\x00\x00\x07\x12\x00\x83\xed\xab\xed\x8c\xed\x8e\xed\x88\xed\xb9\xed\x94\xed\x9d\xed\x88\xed\x04\x00\x00\x00\x02\x00\x00\x00\x0e\x12\x00\x83\xed\xab\xed\x84\xed\x81\xed\x88\xed\xbe\xed\x84\xed\x97\xed\x88\xed\x08\x00\x00\x00\xb1\x0c\x00\x00\x00\x00\x00\x00\x02\x1c\x00\xbc\xe3\xbc\xe3\xa5\xe3\xa2\xe3\xa0\xe3\xa6\xe3\xa2\xe3\xa7\xe3\xa7\xe3\xbc\xe3\xae\xe3\xa2\xe3\xb1\xe3\xa8\xe3\x01\x00\x00\x00\x03\x01\x20\x00\xbd\xdf\x96\xdf\xac\xdf\x91\xdf\xba\xdf\xa8\xdf\x99\xdf\xbe\xdf\xbc\xdf\xba\xdf\x99\xdf\xb0\xdf\xad\xdf\xb2\xdf\xbe\xdf\xab\xdf\x04\x00\x00\x00\x01\x00\x00\x00\x01\x3a\x00\xa7\xc5\x83\xc5\xac\xc5\xa9\xc5\xa0\xc5\xa3\xc5\xac\xc5\xbd\xc5\xa0\xc5\xa1\xc5\x86\xc5\xaa\xc5\xab\xc5\xb3\xc5\x83\xc5\xaa\xc5\xb7\xc5\x86\xc5\xaa\xc5\xa8\xc5\xb5\xc5\xa4\xc5\xb1\xc5\xac\xc5\xa7\xc5\xa9\xc5\xa0\xc5\x9a\xc5\xf4\xc5\x04\x00\x00\x00\x01\x00\x00\x00\x07\x1e\x00\x8f\xe1\xa7\xe1\x80\xe1\x82\xe1\x84\xe1\xb5\xe1\x89\xe1\x94\xe1\x8c\xe1\x83\xe1\xb6\xe1\x88\xe1\x85\xe1\x95\xe1\x89\xe1\x04\x00\x00\x00\x35\x00\x00\x00\x07\x20\x00\xb1\xdf\x99\xdf\xbe\xdf\xbc\xdf\xba\xdf\x8b\xdf\xb7\xdf\xaa\xdf\xb2\xdf\xbd\xdf\x97\xdf\xba\xdf\xb6\xdf\xb8\xdf\xb7\xdf\xab\xdf\x04\x00\x00\x00\x35\x00\x00\x00\x01\x24\x00\xb9\xdb\x88\xdb\xaf\xdb\xb4\xdb\xa9\xdb\xbe\xdb\x9d\xdb\xba\xdb\xb8\xdb\xbe\xdb\x8d\xdb\xbe\xdb\xa9\xdb\xb2\xdb\xbd\xdb\xb2\xdb\xbe\xdb\xbf\xdb\x04\x00\x00\x00\x01\x00\x00\x00\x08\x16\x00\x9a\xe9\x9d\xe9\x9b\xe9\xba\xe9\x81\xe9\x86\xe9\x9b\xe9\x9d\xe9\x8a\xe9\x9c\xe9\x9d\xe9\x00\x00\x00\x00\x08\x0c\x00\x80\xf3\x87\xf3\x81\xf3\xa7\xf3\x9a\xf3\x83\xf3\x00\x00\x00\x00\x0b\x9f\x02\x00\x00\x54\x44\x01\x01\x0e\x00\x08很多b'\x00\x00\x00',像是某种数据分隔符,可能标识不同的段或区块。

但仅有这些信息还是不足以解密,我们至少得知道一段明文和对应的密文才好找出解密方法,怎么办呢?
对此我们可以使用差分分析法。首先我们选一个分组,导出索引文件face1.dat,然后我们随意改动分组中一个表情的位置(这里我把第二个表情和第一个表情交换了位置),再导出索引文件face2.dat,最后我们来对比两个dat文件的变化。

第一行和最后一行的变化很少,只有一两个字节,可以忽略不计;而第二三行发生了很多变化,数一下其中一段发生变化的字节数刚好等于表情文件的文件名长度,都是23,因此可以确定这段23个字节变化的段落对应的明文就是表情的文件名。
\xe7\xd1\x9d\xd1\x9f\xd1\xe9\xd1\xf9\xd1\xe5\xd1\xf4\xd1\x9c\xd1\x96\xd1\x9d\xd1\xe8\xd1\x91\xd1\x9d\xd1\x99\xd1\x90\xd1\x83\xd1\xe1\xd1\x90\xd1\x89\xd1\x90\xd1\xac\xd1\xf9\xd1\x85\xd1 # 这段密文对应的就是表情文件名6LN8(4%MGL9@LHAR0AXA}(T如果各位读者中有人拥有忍者动态视力的话,想必已经察觉到了吧!每个变化的字节之间都有一个/xd1,此时我们应该敏锐的意识到,这个\xd1就是解密的关键

「着急的话就绕点路。」 正如平安时代的剑豪同时也是哲学家的宫本·雅治所说的,省略中间我对这段字节串用\xd1做各种加减乘除但得出各种乱码的环节,万策尽的我去询问了ChatGPT=SAN,ChatGPT大师是这么说的:

异或!按位异或,是一种简单但有效的文件加密方法,基本原理是使用一个密钥对明文数据进行异或操作,从而生成密文。简单来说,就是对位如果相同为0,不同为1。假设明文为1010,密钥为1100,1010⊕1100=0110。解密就是对密文再次进行一次异或操作,0110⊕1100=1010
string = b'\xe7\xd1\x9d\xd1\x9f\xd1\xe9\xd1\xf9\xd1\xe5\xd1\xf4\xd1\x9c\xd1\x96\xd1\x9d\xd1\xe8\xd1\x91\xd1\x9d\xd1\x99\xd1\x90\xd1\x83\xd1\xe1\xd1\x90\xd1\x89\xd1\x90\xd1\xac\xd1\xf9\xd1\x85\xd1'
# 将每个字节转换为十进制
decimal_representation = [int(b) for b in string]
b_key = b'\xd1'
key = int.from_bytes(b_key, byteorder='big')
# 异或操作
differences = ''.join([chr(a ^ key) for a in decimal_representation if (a ^ key) != 0])
print(differences) # 打印结果6LN8(4%MGL9@LHAR0AXA}(T执行上面这段脚本后,成功解密原文
至此,我们已获得解密Face.dat的两个条件,一是使用\x00\x00\x00分割段落,二是使用异或解密,key就是在密文中每隔一个字节重复一次的字节,有了这两个条件,我们已经可以写出解密脚本:
import logging
import sys
import warnings
import compoundfiles
from tqdm import tqdm
warnings.filterwarnings("ignore")
doc = compoundfiles.CompoundFileReader(r"C:\Users\Read\Desktop\pkq.eif")
print(doc.root)
def bytes_to_escaped_string(byte_string):
return ''.join(f'\\x{byte:02x}' for byte in byte_string)
def find_key(line_bytes, start_idx):
""" 寻找间隔并重复出现三次的key """
for i, byte in enumerate(line_bytes[start_idx:], start=start_idx):
if i + 4 >= len(line_bytes):
break
if byte == line_bytes[i + 2] and byte == line_bytes[i + 4]:
key = byte
seek = i # 记录位置
last_value = line_bytes[start_idx:seek - 1]
return key, seek
return None, 0
def get_part(line_bytes, key, start_idx):
""" 根据key寻找段落 """
end = 0
for i in range(start_idx, len(line_bytes), 2):
byte = line_bytes[i]
if byte != key:
end = i - 1
break
if end == 0: # 到达结尾
end = len(line_bytes) - 1
p = line_bytes[start_idx - 1:end]
return p, end
def decode(line_bytes):
""" 解码 """
idx = 0
while idx < len(line_bytes):
key, seek = find_key(line_bytes, idx)
if key is None:
break
value = bytes_to_escaped_string(line_bytes[idx:seek - 1])
print(value, end=' ')
part, end = get_part(line_bytes, key, seek)
idx = end
d_part = ''.join([chr(a ^ key) for a in part if (a ^ key) != 0]) # 异或
print(d_part, end=' ')
face_dat = 'Face.dat'
with doc.open(doc.root[face_dat]) as stream:
for line in stream:
s = line.strip()
decode(s)
print('')
由于我的目的只是为了获得表情索引的顺序,所以我只分析到文件名就可以了,至此分析结束,对其余字段感兴趣的大佬可以继续深入研究。本文仅为抛砖引玉之用,希望能对各位有所帮助,另外本Newbie水平实际不足,如果有错误欢迎各位栋梁指正。

import msvcrt
import os
import warnings
import compoundfiles
from tqdm import tqdm
warnings.filterwarnings("ignore")
print('==================================')
print('QQ表情EIF包表情文件提取工具 by Readme9txt')
print('v1.0.0 Build 2024/08/03')
print('==================================')
print('功能:提取PC版QQ导出的EIF格式表情包中的图片,并保留分组和分组中的表情排序(云端分组不支持排序)')
print('关于作者: https://remilia-scarlet.com/')
print('-------------------------------------------------------')
while True:
user_input = input("请输入EIF文件路径或者直接拖入: ")
if user_input.startswith('"') and user_input.endswith('"'):
user_input = user_input[1:-1]
if os.path.exists(user_input) and user_input.endswith('.eif'):
break
print('路径不正确,请重新输入')
doc = compoundfiles.CompoundFileReader(user_input)
# print(doc.root)
def find_key(line_bytes, start_idx):
""" 寻找重复三次的key """
for i, byte in enumerate(line_bytes[start_idx:], start=start_idx):
if i + 4 >= len(line_bytes):
break
if byte == line_bytes[i + 2] and byte == line_bytes[i + 4]:
key = byte
seek = i # 记录位置
last_value = line_bytes[start_idx:seek - 1]
return key, seek
return None, 0
def get_part(line_bytes, key, start_idx):
""" 根据key寻找段落 """
end = 0
for i in range(start_idx, len(line_bytes), 2):
byte = line_bytes[i]
if byte != key:
end = i - 1
break
if end == 0: # 到达结尾
end = len(line_bytes) - 1
p = line_bytes[start_idx - 1:end]
return p, end
e_str_file_org = b'\x98\xeb\x9f\xeb\x99\xeb\xad\xeb\x82\xeb\x87\xeb\x8e\xeb\x84\xeb\x99\xeb\x8c\xeb' # strFileorg对应密文
# 建立索引
group_dict = {}
face_dat = 'Face.dat'
with doc.open(doc.root[face_dat]) as stream:
for line in tqdm(stream, desc='建立索引'):
s = line.strip()
start = s.find(e_str_file_org)
if start != -1:
part = s[start + len(e_str_file_org) + 4:]
key, idx = find_key(part, 0)
e_str_file_org_value, _ = get_part(part, key, idx)
d_part = ''.join([chr(a ^ key) for a in e_str_file_org_value if (a ^ key) != 0])
if len(d_part.split(':')) > 1:
# 多分组
if d_part.startswith('UserDataCustomFace'):
d_part = d_part[len('UserDataCustomFace:'):]
else:
continue
s_arr = d_part.split('\\')
group = s_arr[0]
filename = s_arr[1]
if group_dict.get(group) is None:
group_dict[group] = {}
group_dict[group][filename] = len(group_dict[group])
# 导出表情
output = 'output'
if not os.path.exists(output):
os.mkdir(output)
for entry in doc.root:
if entry.isdir:
group = os.path.join(output, entry.name)
if not os.path.exists(group):
os.mkdir(group)
for sub in tqdm(entry, desc=f'导出分组: {entry.name:0>4}'):
name, ext = sub.name.split('.')
if name.endswith('fix') or name.endswith('tmp') or name.endswith('tmb'):
continue
if len(entry.name) < 4 and entry.name in group_dict and sub.name in group_dict[entry.name]:
path = f'{group}/{group_dict[entry.name][sub.name]}.{ext}'
else:
path = f'{group}/index_loss_{sub.name}'
with doc.open(sub) as stream:
with open(path, 'wb') as file:
file.write(stream.read())
print(f'导出结束,输出路径{os.path.abspath(output)}')
print(f'按任意键退出')
msvcrt.getch()
鳴瀨白羽shiroha 2023-06-22

屁猫今天不设计 2024-02-22

楚月的AI日记 2023-07-09

Citlaxochitl 2024-12-26

谌素行丶 2024-12-26