简介:CFF_Explorer 是一款面向程序员、逆向工程师与安全研究人员的 Windows 文件格式分析工具,尤其擅长解析 PE 可执行文件,可查看并编辑文件头、节区、资源、导入导出表及代码签名等关键结构,适用于软件调试、恶意软件分析与格式研究等场景。资源包共 16 个文件,约 2.07MB,包含 5 个 xml 签名定义、3 个 cff 脚本、3 个 pdf 技术文档、2 个 exe 主程序与扩展向导、1 个 dll、1 个 zip 及 1 个 msi 安装包,覆盖主程序、扩展模块、脚本示例与说明文档,便于按需安装与查阅。目前已有 778 人学习下载。通过其中的技术文档与脚本样例,读者可掌握 PE 结构解析、签名配置、脚本自动化及扩展开发思路,快速上手文件格式分析与逆向调试工作。
1. CFF_Explorer:把二进制里的 CFF 表变成能读的线索
做逆向分析或者恶意样本排查时,最头疼的不是没有工具,而是工具给了你一堆十六进制,却没人告诉你这些字节之间是什么关系。CFF_Explorer 这个标题指向的就是这类场景:面对一个二进制文件,里面藏着 CFF(Compact Font Format)相关的表结构,你需要把它从原始字节里挖出来、解析清楚、还原成可读的字段和引用关系。它解决的不是“有没有字体”这种表层问题,而是“这个二进制里到底嵌了什么字体资源、这些资源怎么组织、有没有异常数据”这种落地排查需求。适合谁?做样本分析、做嵌入式固件审计、做文档格式逆向的从业者。如果你只关心怎么用现成字体渲染文字,这篇不用看;但如果你需要从一堆二进制里把 CFF 表拎出来做结构比对、异常检测或者格式转换,那接下来的步骤就是照着抄的。
2. CFF 表在二进制里长什么样:先看懂再动手
2.1 CFF 的索引结构与偏移跳转逻辑
CFF 格式的核心不是“字体轮廓”本身,而是一套紧凑的索引和偏移机制。它把字符串、子程序、字符集、编码、私有字典等数据分成多个 INDEX 结构,每个 INDEX 开头是 count(2 字节),然后是 offSize(1 字节),接着是 count+1 个偏移量,最后才是实际数据。偏移量是相对于 INDEX 起始位置的,不是文件绝对偏移。这意味着你不能直接拿文件偏移去读数据,必须先定位到 INDEX 的起始地址,再根据 offSize 决定每个偏移占几个字节。
常见做法是先用十六进制工具找到 CFF 表的起始位置。在 OpenType 字体里,CFF 表通常由CFF标签标识,表头前 4 字节是版本,接着是 Name INDEX、Top DICT INDEX、String INDEX、Global Subr INDEX。Top DICT 里会包含 CharStrings、Private、Charset 等关键偏移。如果你拿到的二进制不是标准 OpenType 封装,而是裸 CFF 数据,那就直接从第一个 INDEX 开始解析。
我一般会先写一个最小解析器,只读 Name INDEX 和 Top DICT INDEX,确认偏移计算是否正确。这一步翻车的概率很高,因为 offSize 可能是 1、2、3、4,很多人默认按 2 字节读,结果后面全错。下面这段 Python 代码演示了如何安全读取一个 INDEX 结构:
import struct def read_index(data, offset): # 读取 count,2 字节大端 count = struct.unpack('>H', data[offset:offset+2])[0] if count == 0: return [], offset + 2 off_size = data[offset+2] # 读取 count+1 个偏移量 offsets = [] pos = offset + 3 for i in range(count + 1): val = int.from_bytes(data[pos:pos+off_size], 'big') offsets.append(val) pos += off_size # 数据区起始位置 data_start = pos items = [] for i in range(count): start = data_start + offsets[i] - 1 end = data_start + offsets[i+1] - 1 items.append(data[start:end]) return items, data_start + offsets[-1] - 1逻辑说明:count 为 0 时直接返回空列表,偏移量从 1 开始计数,所以计算实际地址时要减 1。参数说明:data是完整二进制字节串,offset是 INDEX 起始位置。返回的items是每个条目的原始字节,next_offset是下一个结构的起始位置。这个函数是后面所有解析的基础,建议先拿一个已知正常的 CFF 文件测试,确认能读出 Name INDEX 里的字体名。
2.2 用 fontTools 快速验证解析结果
自己写解析器虽然可控,但验证阶段用成熟库更省时间。fontTools 的cffLib模块可以直接加载 CFF 表并输出结构。常见做法是先用 fontTools 读一遍,把 Top DICT 里的关键字段打印出来,再和自己解析的结果比对。如果两者不一致,优先怀疑自己的偏移计算。
from fontTools.cffLib import CFFFontSet from fontTools.ttLib import TTFont def inspect_cff_with_fonttools(path): font = TTFont(path) if 'CFF ' not in font: print('no CFF table') return cff = font['CFF '].cff top = cff.topDictIndex[0] print('FontName:', top.FontName) print('CharStrings count:', len(top.CharStrings.charStrings)) print('Private dict:', top.Private) print('Charset:', top.charset)逻辑说明:TTFont会自动解析 OpenType 封装,font['CFF '].cff拿到 CFFFontSet,topDictIndex[0]是第一个字体集的 Top DICT。参数说明:path是字体文件路径。输出里的CharStrings count是字形数量,Private是私有字典,Charset是字符集映射。如果这一步报错,说明文件可能不是标准 OpenType 封装,或者 CFF 表被裁剪过,需要回到裸解析路径。
提示:fontTools 对损坏或非标准 CFF 的容错有限,遇到异常时不要死磕,先用十六进制工具确认 CFF 表头是否存在。
3. 从二进制里提取 CFF 表的完整操作路径
3.1 定位 CFF 表的三种入口
不同二进制里 CFF 表的藏法不一样。第一种是标准 OpenType 字体,表目录里有CFF标签,直接按表目录偏移读。第二种是 PDF 内嵌字体,CFF 数据在 FontFile3 流里,需要先解压流再解析。第三种是固件或样本里裸嵌的 CFF 数据,没有表目录,只能靠特征字节搜索。
我一般会按这个顺序排查:先看文件头是不是OTTO或true,如果是,走表目录;如果不是,搜索\x01\x00\x04\x02这类 CFF 头部特征;如果还没有,搜索FontFile3或CFF字符串。下面是一个按表目录定位 CFF 的脚本:
import struct def find_cff_table(path): with open(path, 'rb') as f: data = f.read() if data[:4] not in (b'OTTO', b'true', b'ttcf'): print('not a standard sfnt') return None num_tables = struct.unpack('>H', data[4:6])[0] pos = 12 for i in range(num_tables): tag = data[pos:pos+4] offset = struct.unpack('>I', data[pos+8:pos+12])[0] length = struct.unpack('>I', data[pos+12:pos+16])[0] if tag == b'CFF ': print('CFF table at', offset, 'length', length) return data[offset:offset+length] pos += 16 print('CFF table not found') return None逻辑说明:sfnt 头部 12 字节,表目录每项 16 字节,tag 占 4 字节,offset 在 8 到 12 字节,length 在 12 到 16 字节。参数说明:path是文件路径。返回的是 CFF 表的原始字节。如果返回 None,说明这个文件没有标准 CFF 表,需要换其他入口。
3.2 解析 Top DICT 里的关键偏移
拿到 CFF 表原始字节后,下一步是解析 Top DICT。Top DICT 是一个 INDEX,里面存的是操作符和操作数。操作符占 1 到 2 字节,操作数可以是整数、实数或数组。关键操作符包括:CharStrings(17)、Private(18)、Charset(15)、Encoding(16)、ROS(12 30)等。
常见做法是写一个 DICT 解析器,把操作符和操作数映射成字典。下面这段代码只处理最常见的整数操作数,足够覆盖大部分场景:
def parse_dict(data): result = {} operands = [] i = 0 while i < len(data): b0 = data[i] if b0 <= 21: if b0 == 12: op = (12, data[i+1]) i += 2 else: op = b0 i += 1 result[op] = operands operands = [] elif b0 == 28: operands.append(int.from_bytes(data[i+1:i+3], 'big', signed=True)) i += 3 elif b0 == 29: operands.append(int.from_bytes(data[i+1:i+5], 'big', signed=True)) i += 5 elif b0 == 30: # 实数解析略,按需补全 i += 1 elif 32 <= b0 <= 246: operands.append(b0 - 139) i += 1 elif 247 <= b0 <= 250: operands.append((b0 - 247) * 256 + data[i+1] + 108) i += 2 elif 251 <= b0 <= 254: operands.append(-(b0 - 251) * 256 - data[i+1] - 108) i += 2 else: i += 1 return result逻辑说明:操作符小于等于 21,12 是双字节操作符前缀。操作数编码规则:32 到 246 是单字节整数,247 到 250 是正双字节,251 到 254 是负双字节,28 是三字节整数,29 是五字节整数。参数说明:data是 Top DICT 的原始字节。返回的字典里,键是操作符编号,值是操作数列表。拿到CharStrings偏移后,就可以去解析字形数据了。
注意:Private DICT 的解析需要先读
Private操作符的两个操作数:size 和 offset。offset 是相对于 CFF 表起始位置的,不是文件绝对偏移。
4. CFF_Explorer 避坑与排查:那些让我重跑三遍的细节
4.1 偏移基准搞错导致全表错位
现象:解析出来的 CharStrings 数量对,但每个字形的数据全是乱码。原因:CFF 内部所有偏移都是相对于 CFF 表起始位置,不是文件起始位置。如果你从文件头开始算,后面全错。解决:在解析前先记录 CFF 表的绝对偏移,所有内部偏移都加上这个基址。我一般会在代码里显式写一个cff_base变量,每次计算地址时都加它。
4.2 offSize 为 3 或 4 时偏移读取越界
现象:读 INDEX 时偏移量明显偏大,导致数据区定位到文件末尾之外。原因:offSize 不总是 1 或 2,有些字体为了支持大字符集会用 3 甚至 4。如果你固定按 2 字节读,后面全乱。解决:先读 offSize,再动态决定每个偏移占几个字节。上面read_index函数已经处理了这种情况,但要注意int.from_bytes的字节序必须是大端。
4.3 实数操作数解析不完整导致 DICT 丢键
现象:Top DICT 里明明有 Private 操作符,但解析结果里找不到。原因:Private 的操作数可能是实数(操作符 30),而很多简易解析器直接跳过实数,导致操作数列表为空,后续操作符被错误合并。解决:补全实数解析,或者至少在遇到 30 时把后续字节按实数格式读出来。实数格式是 nibble 编码,每字节两个 nibble,直到遇到 0xF 结束。
4.4 子程序索引递归深度过大导致栈溢出
现象:解析某些字体时程序卡死或报递归深度超限。原因:CFF 的 CharStrings 可以调用 Global Subr 和 Local Subr,子程序里还能再调子程序。如果递归解析时不加深度限制,遇到恶意构造的循环引用就会爆栈。解决:设置最大递归深度,比如 64 层,超过就报错并记录当前偏移。我一般会在解析器里加一个depth参数,每次递归加一,到阈值直接返回。
4.5 字符集格式判断错误导致字形名映射失败
现象:CharStrings 数量正确,但字形名全是glyph000这种默认名。原因:Charset 有三种格式:0 表示按 SID 顺序,1 和 2 表示按范围。如果你只按格式 0 解析,遇到格式 1 或 2 就会读错。解决:先读 Charset 的第一个字节判断格式,格式 0 后面跟 numGlyphs-1 个 SID,格式 1 和 2 后面是范围结构。格式 2 的范围项是 2 字节,格式 1 是 1 字节,别搞混。
5. 进阶:把 CFF_Explorer 做成可复用的结构比对工具
5.1 用哈希指纹快速判断两个 CFF 表是否同源
做样本比对时,不需要逐字节 diff,先算几个关键结构的哈希。我一般会提取 Name INDEX、Top DICT 的操作符序列、CharStrings 的数量和总长度,拼成一个指纹字符串,再算 SHA256。如果两个文件的指纹一致,基本可以判定同源;如果不一致,再去看具体哪个字段变了。下面是一个指纹生成函数:
import hashlib def cff_fingerprint(cff_data): name_index, next_off = read_index(cff_data, 4) top_dict_index, next_off2 = read_index(cff_data, next_off) top_dict = parse_dict(top_dict_index[0]) if top_dict_index else {} charstrings_off = top_dict.get(17, [None])[0] charstrings_count = 0 if charstrings_off: cs_index, _ = read_index(cff_data, charstrings_off) charstrings_count = len(cs_index) raw = b''.join(name_index) + str(sorted(top_dict.keys())).encode() + str(charstrings_count).encode() return hashlib.sha256(raw).hexdigest()逻辑说明:先读 Name INDEX 和 Top DICT INDEX,提取操作符键集合和 CharStrings 数量,拼成原始字节后哈希。参数说明:cff_data是 CFF 表原始字节。这个指纹对结构变化敏感,但对无关字节填充不敏感,适合快速筛选。
5.2 用差异表定位异常字段
指纹只能告诉你“不一样”,不能告诉你“哪里不一样”。我一般会再生成一张差异表,把两个 CFF 的 Top DICT 操作符、CharStrings 数量、Private 字典大小列出来对比。下面是一个简单的对比输出:
| 字段 | 文件 A | 文件 B | 是否一致 |
|---|---|---|---|
| Name INDEX 条目数 | 1 | 1 | 是 |
| Top DICT 操作符集合 | {17,18,15} | {17,18,15,12,30} | 否 |
| CharStrings 数量 | 256 | 258 | 否 |
| Private 大小 | 32 | 48 | 否 |
这张表能直接看出哪个字段变了。如果 CharStrings 数量多了 2 个,但 Name INDEX 没变,那可能是子集化时多保留了字形。如果 Top DICT 多了ROS操作符,说明这个字体是 CID-keyed 的,解析逻辑要换。
5.3 一个我踩过的坑:不要用文件大小判断 CFF 表完整性
早期我图省事,用文件大小减去表目录偏移来估算 CFF 表长度,结果遇到带填充的字体就翻车。CFF 表长度必须从表目录的 length 字段读,不能靠减法。后来我养成习惯,所有长度字段都从结构里读,不自己算。这个习惯在解析嵌套结构时尤其重要,因为外层长度和内层长度经常不一致。
5.4 验证方法:用已知字体做回归测试
每次改完解析器,我会拿三个字体跑一遍:一个标准 OpenType 字体、一个 PDF 内嵌子集字体、一个手工构造的边界字体(比如 offSize=4、Charset 格式 2)。标准字体验证基本功能,子集字体验证偏移计算,边界字体验证异常处理。如果三个都过,基本可以放心用。这个习惯帮我省了很多后悔药,因为 CFF 的边界情况比想象的多。
希望帮到你。
本文还有配套的精品资源,点击获取