在 x64 间接调用附近看到一个 64 位常量,并不意味着它是地址或机器码摘要。XFG 为调用点和目标函数编码类型信息;要解释一次匹配,必须同时看类型归一化、前端序列化、后端掩码,以及运行时对标记位的处理。
分析范围固定为 Visual Studio 2019 16.8.0 Preview 2.1 的 C 编译流程,c1.dll 和 c2.dll 均为 19.28.29213.0。这些内部编码属于该预览实现,不作为后续工具链的稳定接口,也不直接推广到 c1xx.dll 处理的 C++ 类型。
先区分调用点常量与入口标记
CFG 的基本边界 是间接调用目标是否有效。此处的 XFG 路径进一步比较函数类型标识。用于观察的 C 原型如下:
typedef float (*FPTR)(float, float);
float difference(float a, float b) {
return b - a;
}
int main(void) {
FPTR fn = difference;
return fn(1.00001f, 2.00002f) > 0;
}案例使用预览工具链的 x64 开发者命令行,命令为 cl /Zi /guard:xfg example.c。这是版本限定的编译输入,不是对任意当前 MSVC 的选项承诺;若优化把间接调用折叠为直接调用,还应先确认实际调用点是否保留。
| 位置 | 值 | 用途 |
|---|---|---|
调用点 R10 |
0x99743F3270D52870 |
预期函数类型 |
调用点 RAX |
目标函数地址 | 间接调用目标 |
| 目标入口前 8 字节 | 0x99743F3270D52871 |
带最低位标记的类型值 |
调度经 __guard_xfg_dispatch_icall_fptr 进入。相关比较可摘成下面两条指令;它们不是完整、连续的调度器反汇编,中间还有目标地址相关检查。
or r10, 1
cmp r10, [rax-8]最低位相差 1 因而不是类型冲突。应该比较调度器实际使用的形式,而不是直接比较文件中两个十六进制常量。
前端与后端分别决定什么
| 阶段 | 组件或函数 | 产物 |
|---|---|---|
| 原型收集 | c1.dll!XFGHelper__ComputeHash_1 |
参数、调用约定、返回类型 |
| 类型编码 | XFGHasher、XFGTypeHasher |
有序字节序列及嵌套类型摘要 |
| 前端摘要 | XFGHasher::get_hash |
SHA-256 的前 8 字节 |
| 后端编码 | c2.dll!XfgIlVisitor::visit_I_XFG_HASH |
施加位掩码后的调用点常量 |
| 目标侧标记 | 函数入口前的数据 | 调用点形式再设置最低位 |
XFGHasher::add_function_type 追加参数数目、各参数类型摘要、可变参数标记和调用约定,随后 add_type 追加返回类型摘要。摘要以字节串参与下一层哈希,转换成 64 位整数时才需要明确小端解释。
XFGHelper__GetHashForType 可以复用 Type_t 的缓存结果。缓存改变计算成本,不应改变编码内容。这里研究的是输入给哈希函数的类型表示,不是函数体、变量名称或源文件的摘要。
函数原型序列的字段顺序
定义 H(x) = SHA256(x)[:8]。普通 C 原型按以下顺序构造前端输入:
| 字段 | 宽度 | 编码 |
|---|---|---|
| 参数数目 | 4 字节 | 归一化后的 u32_le 计数 |
| 参数类型 | 每项 8 字节 | 按声明顺序连接类型摘要 |
| 可变参数标记 | 1 字节 | 普通非可变参数函数为 0 |
| 调用约定 | 4 字节 | u32_le(convention & 0x0f) |
| 返回类型 | 8 字节 | 返回类型摘要 |
在该实现中,默认 x64 调用约定内部值 0x201 留下低四位 1,__vectorcall 的 0x208 留下 8。这些数值是编译器内部编码;Microsoft 的 x64 调用约定 与 __vectorcall 文档用于理解 ABI 语义,并未承诺这里的内部枚举值。
参数计数来自 RealNumberOfParameters() 等内部表示。可变参数和某些特殊条目存在调整路径,因此通用编码器应从归一化后的原型出发,而不是机械地数源码逗号。与虚信息有关的分支没有在所述 C 测试中命中,其完整语义仍待独立验证。
归一化先于递归类型哈希
参数处理会执行数组或函数类型退化,并清理普通路径上的部分顶层修饰位,包括 const 和 volatile。这不代表所有层级的限定符都被删除。
const void * 的限定符位于被指向的 void 上;void * const 的限定符位于指针本身。递归编码前先确定限定符属于哪一层,是计算能否匹配的关键。
类型序列以“限定符字节、类型组字节、该组数据”开头。内部修饰位 0x800 与 0x40 分别表示 const、volatile,压缩后落在限定符字节的 bit 0、bit 1。无限定为 0,两者皆有为 3。
| 类型分支 | 编码要点 | 边界 |
|---|---|---|
| 基础类型,组 1 | 限定符、01、基础类型码 |
float=0x0b、void=0x0e、本例 size_t=0x88 |
| 标签类型,组 2 | 名称参与编码 | 匿名名称为 <unnamed>;<local> 的完整触发条件未确定 |
普通指针 0x102、函数指针 0x106,组 3 |
限定符、03、被引用类型摘要、02 |
指针与被引用对象是两个层级 |
函数对象 0x101,组 3 |
原型字段、返回类型摘要,末尾标记 01 |
不应与指向它的指针合并 |
| 带计数的数组,组 3 | 限定符、03、u64_le(count)、元素摘要、06 |
另有省略计数的特殊分支;参数数组还可能先退化 |
组选择涉及内部类型值的 0x100、0x200、0x400 标志,并非直接使用 C 语法类别编号;泛型特殊路径也不应按普通基础类型继续编码。标签类型的观察则说明,同名、匿名、作用域和成员布局之间的等价关系,应由实际编码决定,单凭内存布局一致得不出哈希一定一致的结论。
基础类型的三个明确例子中,float 的内部类型值为 0x26,void 为 0x40,本例无符号 64 位 size_t 为 0x4019。它们分别映射到表中的单字节编码。因而 void 的输入为 00 01 0e,const void 为 01 01 0e;差异再向外传播到指针摘要。
后端掩码不是摘要的透明搬运
encoded = (frontend & 0xFFFDBFFF7EDFFB70) | 0x8000060010500070
entry = encoded | 1frontend 是截断摘要按小端解释的整数。AND 固定一部分位,OR 又强制设置一部分位,所以最后的 64 位存储宽度不等于保留了 64 位自由摘要信息。
对这两个特定掩码,仍可随输入变化的位集合为 AND_MASK & ~OR_MASK,即 0x7ffdb9ff6e8ffb00,置位数为 44。下面的模型会验证这一纯位运算结论。
用两个原型验证编码链
memcpy 原型同时覆盖普通指针、被指向类型的 const、无符号整数和指针返回值:
void *memcpy(void *dest, const void *src, size_t count);它的前端输入长 41 字节。下方字节来自随后脚本的计算;这是序列化模型的输出,不是可执行文件中的一段机器码。
parameter_count0x00–0x03H(void*)0x04–0x0BH(const void*)0x0C–0x13H(size_t)0x14–0x1Bis_variadic0x1Ccalling_convention0x1D–0x20H(return_type)0x21–0x28
脚本仅实现已明确覆盖的类型组合,不解析 C 源码,不处理全部特殊数组、标签作用域或 C++ 语义。它另用 float(float, float) 校验第二个独立原型,避免只凭一组最终数值下结论。
import hashlib, struct
def h8(data):
return hashlib.sha256(data).digest()[:8]
def primitive(code, qualifiers=0):
return h8(bytes([qualifiers, 1, code]))
def pointer(referenced_hash, qualifiers=0):
return h8(bytes([qualifiers, 3]) + referenced_hash + b"\x02")
def function_bytes(params, result, variadic=0, convention=1):
return (struct.pack("<I", len(params)) + b"".join(params)
+ bytes([variadic]) + struct.pack("<I", convention & 0x0f)
+ result)
def callsite_hash(payload):
front = int.from_bytes(h8(payload), "little")
return (front & 0xFFFDBFFF7EDFFB70) | 0x8000060010500070
void_ptr = pointer(primitive(0x0e))
const_void_ptr = pointer(primitive(0x0e, qualifiers=1))
size_t_hash = primitive(0x88)
payload = function_bytes([void_ptr, const_void_ptr, size_t_hash], void_ptr)
front = int.from_bytes(h8(payload), "little")
callsite = callsite_hash(payload)
entry = callsite | 1
assert len(payload) == 41
assert front == 0x1da7d393d6b63a72
assert callsite == 0x9da5979356d63a70
f32 = primitive(0x0b)
assert callsite_hash(function_bytes([f32, f32], f32)) == 0x99743f3270d52870
assert void_ptr != const_void_ptr
free_mask = 0xFFFDBFFF7EDFFB70 & ~0x8000060010500070
assert free_mask.bit_count() == 44
print(f"serialized bytes: {len(payload)}")
print(f"frontend: 0x{front:016x}")
print(f"callsite: 0x{callsite:016x}")
print(f"entry: 0x{entry:016x}")
print("PASS: memcpy; float(float,float); pointee const; 44 variable mask bits")$ python xfg_prototype_model.py
serialized bytes: 41
frontend: 0x1da7d393d6b63a72
callsite: 0x9da5979356d63a70
entry: 0x9da5979356d63a71
PASS: memcpy; float(float,float); pointee const; 44 variable mask bits计算出的 memcpy 调用点值为 0x9da5979356d63a70,与案例中的 R10 常量一致;float 原型也得到 0x99743f3270d52870。这校验了所用模型的字节顺序、类型递归、掩码和标记位关系。执行的是上述离线计算,没有运行该历史 Visual Studio 预览工具链,因而不把结果描述为一次新的编译器回归测试。
从最早的分歧定位错误
- 1
锁定编译器与语言
记录
c1.dll、c2.dll版本及 x64 C 输入,先排除把其他版本或 C++ 行为混入模型。 - 2
检查归一化后的原型
确认数组、函数参数退化,区分顶层限定符和被指向类型的限定符。
- 3
比较每个类型摘要
单独打印参数和返回类型的 8 字节结果,再检查参数顺序、字段宽度与调用约定。
- 4
比较两个编码阶段
先比较前端摘要,再施加后端掩码,最后处理入口标记;不要用最终常量反向猜所有步骤。
- 5
核对真实调用路径
确认二进制保留间接调用并进入预期调度器,而不是被内联、去虚化或折叠为直接调用。
XFG 类型标识的关键不在 SHA-256 本身,而在哈希前保留了哪些类型信息。输入归一化定义了被比较的等价类;截断、掩码和运行时标记则决定这些信息怎样进入实际检查。
参考资料
编译器类型哈希相关研究:Francisco Falcon。下列官方资料用于 CFG 与调用约定背景,不作为预览版内部编码的稳定性保证。