简介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 这个标题指向的就是这类场景面对一个二进制文件里面藏着 CFFCompact Font Format相关的表结构你需要把它从原始字节里挖出来、解析清楚、还原成可读的字段和引用关系。它解决的不是“有没有字体”这种表层问题而是“这个二进制里到底嵌了什么字体资源、这些资源怎么组织、有没有异常数据”这种落地排查需求。适合谁做样本分析、做嵌入式固件审计、做文档格式逆向的从业者。如果你只关心怎么用现成字体渲染文字这篇不用看但如果你需要从一堆二进制里把 CFF 表拎出来做结构比对、异常检测或者格式转换那接下来的步骤就是照着抄的。2. CFF 表在二进制里长什么样先看懂再动手2.1 CFF 的索引结构与偏移跳转逻辑CFF 格式的核心不是“字体轮廓”本身而是一套紧凑的索引和偏移机制。它把字符串、子程序、字符集、编码、私有字典等数据分成多个 INDEX 结构每个 INDEX 开头是 count2 字节然后是 offSize1 字节接着是 count1 个偏移量最后才是实际数据。偏移量是相对于 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): # 读取 count2 字节大端 count struct.unpack(H, data[offset:offset2])[0] if count 0: return [], offset 2 off_size data[offset2] # 读取 count1 个偏移量 offsets [] pos offset 3 for i in range(count 1): val int.from_bytes(data[pos:posoff_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[i1] - 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拿到 CFFFontSettopDictIndex[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 (bOTTO, btrue, bttcf): 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:pos4] offset struct.unpack(I, data[pos8:pos12])[0] length struct.unpack(I, data[pos12:pos16])[0] if tag bCFF : print(CFF table at, offset, length, length) return data[offset:offsetlength] 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 字节操作数可以是整数、实数或数组。关键操作符包括CharStrings17、Private18、Charset15、Encoding16、ROS12 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[i1]) i 2 else: op b0 i 1 result[op] operands operands [] elif b0 28: operands.append(int.from_bytes(data[i1:i3], big, signedTrue)) i 3 elif b0 29: operands.append(int.from_bytes(data[i1:i5], big, signedTrue)) 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[i1] 108) i 2 elif 251 b0 254: operands.append(-(b0 - 251) * 256 - data[i1] - 108) i 2 else: i 1 return result逻辑说明操作符小于等于 2112 是双字节操作符前缀。操作数编码规则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 条目数11是Top DICT 操作符集合{17,18,15}{17,18,15,12,30}否CharStrings 数量256258否Private 大小3248否这张表能直接看出哪个字段变了。如果 CharStrings 数量多了 2 个但 Name INDEX 没变那可能是子集化时多保留了字形。如果 Top DICT 多了ROS操作符说明这个字体是 CID-keyed 的解析逻辑要换。5.3 一个我踩过的坑不要用文件大小判断 CFF 表完整性早期我图省事用文件大小减去表目录偏移来估算 CFF 表长度结果遇到带填充的字体就翻车。CFF 表长度必须从表目录的 length 字段读不能靠减法。后来我养成习惯所有长度字段都从结构里读不自己算。这个习惯在解析嵌套结构时尤其重要因为外层长度和内层长度经常不一致。5.4 验证方法用已知字体做回归测试每次改完解析器我会拿三个字体跑一遍一个标准 OpenType 字体、一个 PDF 内嵌子集字体、一个手工构造的边界字体比如 offSize4、Charset 格式 2。标准字体验证基本功能子集字体验证偏移计算边界字体验证异常处理。如果三个都过基本可以放心用。这个习惯帮我省了很多后悔药因为 CFF 的边界情况比想象的多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?