控制流完整性设计文档

此页面记录了 Clang 支持的 控制流完整性 方案的设计。

用于虚拟调用的前向边 CFI

此方案通过为每个用于进行虚拟调用的静态类型分配一个区域来工作,该区域位于对象文件中,其中包含一个位向量,该位向量映射到用于这些虚拟表的存储区域。位向量中的每个位对应于与正在构建位向量的静态类型兼容的虚拟表的 地址点

例如,考虑以下三个 C++ 类

struct A {
  virtual void f1();
  virtual void f2();
  virtual void f3();
};

struct B : A {
  virtual void f1();
  virtual void f2();
  virtual void f3();
};

struct C : A {
  virtual void f1();
  virtual void f2();
  virtual void f3();
};

该方案将导致 A、B 和 C 的虚拟表连续布局

A、B、C 的虚拟表布局

0

1

2

3

4

5

6

7

8

9

10

11

12

13

14

A::offset-to-top

&A::rtti

&A::f1

&A::f2

&A::f3

B::offset-to-top

&B::rtti

&B::f1

&B::f2

&B::f3

C::offset-to-top

&C::rtti

&C::f1

&C::f2

&C::f3

A、B 和 C 的位向量将如下所示

A、B、C 的位向量

0

1

2

3

4

5

6

7

8

9

10

11

12

13

14

A

0

0

1

0

0

0

0

1

0

0

0

0

1

0

0

B

0

0

0

0

0

0

0

1

0

0

0

0

0

0

0

C

0

0

0

0

0

0

0

0

0

0

0

0

1

0

0

位向量在对象文件中表示为字节数组。通过从字节数组中的索引偏移量加载并应用掩码,程序可以使用相对较短的指令序列测试位集中位的位。位向量可以重叠,只要它们使用不同的位即可。有关完整详细信息,请参阅 ByteArrayBuilder 类。

在这种情况下,假设 A 在位 0 的偏移量 0 处布局,B 在位 1 的偏移量 0 处布局,C 在位 2 的偏移量 0 处布局,字节数组将如下所示

char bits[] = { 0, 0, 1, 0, 0, 0, 3, 0, 0, 0, 0, 5, 0, 0 };

为了发出虚拟调用,编译器将组装检查对象的虚拟表指针是否在范围内且对齐,以及相关位是否在位向量中设置的代码。

例如,在 x86 上,典型的虚拟调用可能如下所示

ca7fbb:       48 8b 0f                mov    (%rdi),%rcx
ca7fbe:       48 8d 15 c3 42 fb 07    lea    0x7fb42c3(%rip),%rdx
ca7fc5:       48 89 c8                mov    %rcx,%rax
ca7fc8:       48 29 d0                sub    %rdx,%rax
ca7fcb:       48 c1 c0 3d             rol    $0x3d,%rax
ca7fcf:       48 3d 7f 01 00 00       cmp    $0x17f,%rax
ca7fd5:       0f 87 36 05 00 00       ja     ca8511
ca7fdb:       48 8d 15 c0 0b f7 06    lea    0x6f70bc0(%rip),%rdx
ca7fe2:       f6 04 10 10             testb  $0x10,(%rax,%rdx,1)
ca7fe6:       0f 84 25 05 00 00       je     ca8511
ca7fec:       ff 91 98 00 00 00       callq  *0x98(%rcx)
  [...]
ca8511:       0f 0b                   ud2

编译器依赖于链接器的合作以组装整个程序的位向量。它目前使用 LLVM 的 类型元数据 机制以及链接时优化来执行此操作。

优化

上面描述的方案是该方案的完全通用变体。大多数时候,我们能够应用以下优化中的一种或多种来提高二进制大小或性能。

事实上,如果您尝试使用当前版本的编译器运行上面的示例,您可能会发现它不会使用所描述的虚拟表布局或机器指令。我们即将介绍的一些优化会导致编译器使用不同的布局或不同的机器指令序列。

在位向量中去除前导/尾随零

如果位向量包含前导零或尾随零,我们可以从向量中去除它们。编译器将发出代码来检查指针是否在由 1 覆盖的区域范围内,并使用位向量的截断版本执行位向量检查。例如,我们示例类层次结构的位向量将如下发出

A、B、C 的位向量

0

1

2

3

4

5

6

7

8

9

10

11

12

13

14

A

1

0

0

0

0

1

0

0

0

0

1

B

1

C

1

短内联位向量

如果向量足够短,我们可以将其表示为 x86 上的内联常量。这可以在读取位向量的正确元素时为我们节省几条指令。

如果位向量适合 32 位,代码如下所示

 dc2:       48 8b 03                mov    (%rbx),%rax
 dc5:       48 8d 15 14 1e 00 00    lea    0x1e14(%rip),%rdx
 dcc:       48 89 c1                mov    %rax,%rcx
 dcf:       48 29 d1                sub    %rdx,%rcx
 dd2:       48 c1 c1 3d             rol    $0x3d,%rcx
 dd6:       48 83 f9 03             cmp    $0x3,%rcx
 dda:       77 2f                   ja     e0b <main+0x9b>
 ddc:       ba 09 00 00 00          mov    $0x9,%edx
 de1:       0f a3 ca                bt     %ecx,%edx
 de4:       73 25                   jae    e0b <main+0x9b>
 de6:       48 89 df                mov    %rbx,%rdi
 de9:       ff 10                   callq  *(%rax)
[...]
 e0b:       0f 0b                   ud2

或者如果位向量适合 64 位

11a6:       48 8b 03                mov    (%rbx),%rax
11a9:       48 8d 15 d0 28 00 00    lea    0x28d0(%rip),%rdx
11b0:       48 89 c1                mov    %rax,%rcx
11b3:       48 29 d1                sub    %rdx,%rcx
11b6:       48 c1 c1 3d             rol    $0x3d,%rcx
11ba:       48 83 f9 2a             cmp    $0x2a,%rcx
11be:       77 35                   ja     11f5 <main+0xb5>
11c0:       48 ba 09 00 00 00 00    movabs $0x40000000009,%rdx
11c7:       04 00 00
11ca:       48 0f a3 ca             bt     %rcx,%rdx
11ce:       73 25                   jae    11f5 <main+0xb5>
11d0:       48 89 df                mov    %rbx,%rdi
11d3:       ff 10                   callq  *(%rax)
[...]
11f5:       0f 0b                   ud2

如果位向量仅包含一位,则只有一个可能的虚拟表,并且检查可以仅包含一个相等比较

9a2:   48 8b 03                mov    (%rbx),%rax
9a5:   48 8d 0d a4 13 00 00    lea    0x13a4(%rip),%rcx
9ac:   48 39 c8                cmp    %rcx,%rax
9af:   75 25                   jne    9d6 <main+0x86>
9b1:   48 89 df                mov    %rbx,%rdi
9b4:   ff 10                   callq  *(%rax)
[...]
9d6:   0f 0b                   ud2

虚拟表布局

编译器将不相交层次结构的类布局在对象文件的不同区域。在最坏的情况下,不相交层次结构中的位向量只需要覆盖其不相交的层次结构。但子层次结构中的类彼此越靠近布局,这些子层次结构的位向量就需要越小(参见上面的“在位向量中去除前导/尾随零”)。GlobalLayoutBuilder 类负责有效地布局全局变量以最小化底层位集的大小。

对齐

如果特定位向量中地址点之间的所有间隙都是 2 的幂的倍数,则编译器可以通过加强虚拟表指针的对齐要求来压缩位向量。例如,给定此类层次结构

struct A {
  virtual void f1();
  virtual void f2();
};

struct B : A {
  virtual void f1();
  virtual void f2();
  virtual void f3();
  virtual void f4();
  virtual void f5();
  virtual void f6();
};

struct C : A {
  virtual void f1();
  virtual void f2();
};

虚拟表将如下布局

A、B、C 的虚拟表布局

0

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

A::offset-to-top

&A::rtti

&A::f1

&A::f2

B::offset-to-top

&B::rtti

&B::f1

&B::f2

&B::f3

&B::f4

&B::f5

&B::f6

C::offset-to-top

&C::rtti

&C::f1

&C::f2

请注意,A 的每个地址点都间隔 4 个字。这让我们可以为 A 发出一个压缩的位向量,如下所示

2

6

10

14

1

1

0

1

在调用站点,编译器将通过使用不同的旋转计数来加强对齐要求。例如,在 64 位机器上,其中地址点是 4 字对齐的(如我们示例中的 A),rol 指令可能如下所示

dd2:       48 c1 c1 3b             rol    $0x3b,%rcx

填充到 2 的幂

当然,此对齐方案在地址点实际上已正确对齐的情况下效果最佳。为了使这种情况更有可能发生,我们在虚拟表之间插入填充,这在许多情况下会将地址点对齐到 2 的幂。具体来说,我们的填充将虚拟表对齐到下一个最高 2 的幂字节;因为特定基类的地址点通常出现在虚拟表内的固定偏移量处,这通常也会将地址点对齐。

此方案在减少指令和位向量的空间开销以及增加填充形式的开销之间引入了权衡。因此,我们限制了填充量,以便对齐不超过 128 字节。通过实验发现,这个数字提供了一个很好的权衡。

为全 1 位向量消除位向量检查

如果位向量全为 1,则位向量检查是多余的;我们只需要检查地址是否在范围内且对齐。如果虚拟表已填充,则这种情况更有可能发生。

通过交织虚拟表进行虚拟调用的前向边 CFI

Dimitar 等人在 [1] 中提出了一种新颖的方法,该方法交织虚拟表。这种方法在空间方面更有效,因为不再需要填充和位向量。同时,它在性能方面也更有效,因为在交织的布局中,虚拟表的地址点是连续的,因此虚拟 vtable 指针的有效性检查始终是一个范围检查。

从高层次上讲,交织方案包含三个步骤:1) 将虚拟表组拆分为单独的虚拟表,2) 通过类层次结构的先序遍历对虚拟表进行排序,以及 3) 交织虚拟表。

LLVM 中实现的交织方案受 [1] 的启发,但有其自己的增强功能(更多信息请参见 交织虚拟表)。

将虚拟表组拆分为单独的虚拟表

Itanium C++ ABI 将类的多个单独虚拟表粘合到一个组合的虚拟表(虚拟表组)中。但是,交织方案只能使用单独的虚拟表,因此它必须首先拆分组合的虚拟表。相比之下,旧方案不需要拆分,但它在组合的虚拟表已被拆分时效率更高。GlobalSplit 通道负责将组合的虚拟表拆分为单独的虚拟表。

通过类层次结构的先序遍历对虚拟表进行排序

此步骤对于上面描述的旧方案和交织方案都是通用的。对于交织方案,由于组合的虚拟表已在先前步骤中拆分,因此此步骤确保对于任何类,所有兼容的虚拟表都将连续出现。对于旧方案,相同的属性可能不成立,因为它可能在组合的虚拟表上工作。

例如,考虑以下四个 C++ 类

struct A {
  virtual void f1();
};

struct B : A {
  virtual void f1();
  virtual void f2();
};

struct C : A {
  virtual void f1();
  virtual void f3();
};

struct D : B {
  virtual void f1();
  virtual void f2();
};

此步骤将按照vtable-of-A、vtable-of-B、vtable-of-D、vtable-of-C 的顺序排列 A、B、C 和 D 的虚拟表。

交织虚拟表

此步骤是交织方案与旧方案不同的地方。交织方案不是按照先前计算的顺序布局整个虚拟表,而是战略性地布局虚拟表的表条目以确保以下属性

  1. offset-to-top 和 RTTI 字段布局属性

Itanium C++ ABI 指定 offset-to-top 和 RTTI 字段出现在地址点后面的偏移量处。请注意,libcxxabi 等库确实假定此属性。

  1. 虚拟函数条目布局属性

对于每个虚拟函数,此函数的虚拟表条目与其对应的地址点之间的距离始终相同。此属性确保动态调度仍可以使用交织的布局工作。

请注意,CFI 实现中的交织方案保证了上述两种属性,而 [1] 中提出的原始方案仅保证了第二种属性。

为了说明交织算法的工作原理,让我们继续使用运行示例。该算法首先将所有虚拟表条目分离到两个工作列表中。为此,它从分配两个工作列表开始,一个初始化为所有虚拟表中 offset-to-top 条目,按照上一步骤中计算的顺序排列,另一个初始化为所有 RTTI 条目,按照相同的顺序排列。

工作列表 1 布局

0

1

2

3

A::offset-to-top

B::offset-to-top

D::offset-to-top

C::offset-to-top

工作列表 2 布局

0

1

2

3

&A::rtti

&B::rtti

&D::rtti

&C::rtti

然后,对于每个虚函数,算法遍历之前计算的顺序中的所有虚表,将所有相关条目收集到一个虚函数列表中。在此步骤之后,将存在以下虚函数列表

f1 列表

0

1

2

3

&A::f1

&B::f1

&D::f1

&C::f1

f2 列表

0

1

&B::f2

&D::f2

f3 列表

0

&C::f3

接下来,算法选择最长的剩余虚函数列表,并将整个列表追加到最短的工作列表,直到没有函数列表剩余,并填充较短的工作列表,使其长度相同。在本例中,f1 列表将首先添加到工作列表 1,然后 f2 列表将添加到工作列表 2,最后 f3 列表将添加到工作列表 2。由于工作列表 1 现在比工作列表 2 多一个条目,因此将一个填充条目添加到后者。在此步骤之后,两个工作列表看起来像

工作列表 1 布局

0

1

2

3

4

5

6

7

A::offset-to-top

B::offset-to-top

D::offset-to-top

C::offset-to-top

&A::f1

&B::f1

&D::f1

&C::f1

工作列表 2 布局

0

1

2

3

4

5

6

7

&A::rtti

&B::rtti

&D::rtti

&C::rtti

&B::f2

&D::f2

&C::f3

填充

最后,算法通过交替地将每个列表的头部移动到最终布局中,将两个工作列表合并到交错布局中。在此步骤之后,最终的交错布局看起来像

交错布局

0

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

A::offset-to-top

&A::rtti

B::offset-to-top

&B::rtti

D::offset-to-top

&D::rtti

C::offset-to-top

&C::rtti

&A::f1

&B::f2

&B::f1

&D::f2

&D::f1

&C::f3

&C::f1

填充

在上面的交错布局中,每个虚表的偏移到顶端和 RTTI 始终相邻,这表明布局具有第一个属性。对于第二个属性,让我们以 f2 为例。在交错布局中,f2 有两个条目:B::f2 和 D::f2。&B::f2 与其地址点 D::偏移到顶端(&B::rtti 后的下一个条目)之间的距离为 5 个条目长度,&D::f2 与 C::偏移到顶端(&D::rtti 后的下一个条目)之间的距离也是如此。

间接函数调用的前向边 CFI

在间接函数调用的前向边 CFI 下,每个唯一的函数类型都有自己的位向量,并且在每个调用点,我们需要检查函数指针是否属于函数类型的位向量。该方案的工作方式类似于虚调用的前向边 CFI,区别在于我们需要构建函数入口点的位向量,而不是虚表的位向量。

与重新排列全局变量不同,我们不能以特定顺序重新排列函数并基于函数入口点的布局进行计算,因为我们不知道特定函数最终会占用多大空间(函数的大小甚至可能取决于我们如何排列函数)。相反,我们构建一个跳转表,它是一个代码块,包含一个分支指令,用于位集中每个分支到目标函数的函数,并将任何已获取的函数地址重定向到相应的跳转表条目。通过这种方式,函数入口点之间的距离是可预测和可控的。在目标文件的符号表中,目标函数的符号也引用跳转表条目,以便在模块外部获取的地址将通过在模块内部完成的任何验证。

更具体地说,假设我们有三个函数 fgh,它们都是同一类型,以及一个返回其地址的函数 foo

f:
mov 0, %eax
ret

g:
mov 1, %eax
ret

h:
mov 2, %eax
ret

foo:
mov f, %eax
mov g, %edx
mov h, %ecx
ret

我们的跳转表(概念上)看起来像这样

f:
jmp .Ltmp0 ; 5 bytes
int3       ; 1 byte
int3       ; 1 byte
int3       ; 1 byte

g:
jmp .Ltmp1 ; 5 bytes
int3       ; 1 byte
int3       ; 1 byte
int3       ; 1 byte

h:
jmp .Ltmp2 ; 5 bytes
int3       ; 1 byte
int3       ; 1 byte
int3       ; 1 byte

.Ltmp0:
mov 0, %eax
ret

.Ltmp1:
mov 1, %eax
ret

.Ltmp2:
mov 2, %eax
ret

foo:
mov f, %eax
mov g, %edx
mov h, %ecx
ret

因为 fgh 的地址在 2 的幂次方处均匀分布,并且函数类型不重叠(与具有基类的类类型不同),我们通常可以应用 对齐消除全 1 位向量的位向量检查 优化,从而简化每个调用点的检查为范围和对齐检查。

共享库支持

实验性

上面描述的基本 CFI 模式假设应用程序是一个整体二进制文件;至少所有可能的虚/间接调用目标和整个类层次结构在链接时是已知的。使用 **-f[no-]sanitize-cfi-cross-dso** 启用的跨 DSO 模式通过允许虚调用和间接调用跨越 DSO 边界来放宽此要求。

假设以下设置:二进制文件包含多个已检测和多个未检测的 DSO。其中一些可能定期甚至频繁地使用 dlopen-ed/dlclose-d。

  • 从未检测的 DSO 中发出的调用未经过检查,可以直接工作。

  • 任何已检测的 DSO 中的调用都受到完全保护。

  • 不同已检测的 DSO 之间的调用也受到保护,并具有

    性能损失(除了整体 CFI 开销之外)。

  • 从已检测的 DSO 到未检测的 DSO 的调用

    未经过检查,可以直接工作,并具有性能损失。

  • 从已检测的 DSO 到任何已知 DSO 外部的调用

    被检测为 CFI 违规。

在整体方案中,调用点被检测为

if (!InlinedFastCheck(f))
  abort();
call *f

在跨 DSO 方案中,它变为

if (!InlinedFastCheck(f))
  __cfi_slowpath(CallSiteTypeId, f);
call *f

CallSiteTypeId

CallSiteTypeId 是调用点类型的稳定进程级标识符。对于虚调用点,相关类型是类类型;对于间接函数调用,它是函数签名。从类型到标识符的映射是 ABI 细节。在当前的实验性实现中,类型 T 的标识符按如下方式计算

  • 获取“T 的 typeinfo 名称”的经过修饰的名称。

  • 计算名称作为字符串的 MD5 哈希值。

  • 将哈希值的前 8 个字节重新解释为小端 64 位整数。

可能但不太可能的是,CallSiteTypeId 哈希中的冲突会导致更弱的 CFI 检查,这些检查仍然会是保守正确的。

CFI_Check

在一般情况下,只有目标 DSO 才能知道对类型为 CallSiteTypeId 的函数 f 的调用是否有效。为了导出此信息,每个 DSO 都实现了

void __cfi_check(uint64 CallSiteTypeId, void *TargetAddr, void *DiagData)

此函数为外部模块提供了对 DSO 中目标的 CFI 检查的访问。对于每个已知的 CallSiteTypeId,此函数使用相应的类型标识符执行 llvm.type.test。如果类型未知或检查失败,它会报告错误。根据编译器标志 -fsanitize-trap-fsanitize-recover 的值,此函数可能会打印错误、中止和/或返回给调用方。DiagData 是指向错误诊断信息的指针,如果调用方未提供此信息,则为 null

基本实现是对 DSO 支持的所有 CallSiteTypeId 值的大的 switch 语句,并且每个情况都类似于基本 CFI 模式中的 InlinedFastCheck()。

CFI 阴影

为了将 CFI 检查路由到目标 DSO 的 __cfi_check 函数,维护从可能的虚/间接调用目标到相应的 __cfi_check 函数的映射。该映射实现为一个稀疏数组,每个可能的内存页面(4096 字节)有 2 个字节。该表大部分时间保持只读。

有 3 种阴影值类型

  • CFI 检测 DSO 中的地址。

  • 未经检查的地址(“可信”的未检测 DSO)。编码为值 0xFFFF。

  • 无效地址(其他所有地址)。编码为值 0。

对于 CFI 检测的 DSO,阴影值编码了相应内存页面中所有调用目标的 __cfi_check 函数的地址。如果 Addr 是目标地址,V 是阴影值,那么 __cfi_check 的地址计算为

__cfi_check = AlignUpTo(Addr, 4096) - (V + 1) * 4096

只要 __cfi_check 按 4096 字节对齐并且位于其 DSO 中任何调用目标下方,但与它们之间的距离不超过 256MB,这就可以正常工作。

CFI_SlowPath

慢路径检查在运行时支持库中实现为

void __cfi_slowpath(uint64 CallSiteTypeId, void *TargetAddr)
void __cfi_slowpath_diag(uint64 CallSiteTypeId, void *TargetAddr, void *DiagData)

这些函数加载 TargetAddr 的阴影值,找到如上所述的 __cfi_check 的地址,并调用它。DiagData 是指向诊断数据的指针,该指针按原样传递给 __cfi_check,并且 __cfi_slowpath 代之以传递 nullptr

Compiler-RT 库包含慢路径函数的参考实现,但它们在处理 dlopen() 时存在无法解决的正确性和性能问题。建议平台提供自己的实现,通常作为 libc 或 libdl 的一部分。

位置无关可执行文件要求

跨 DSO CFI 模式要求主可执行文件被构建为 PIE。在非 PIE 可执行文件中,外部函数(从主可执行文件获取)的地址是该函数在主可执行文件中的 PLT 记录的地址。这会破坏 CFI 检查。

返回语句的后向边 CFI (RCFI)

本节是一个提案。截至 2017 年 3 月,尚未实现。

后向边控制流(RET 指令)可以通过覆盖堆栈上的返回地址 (RA) 来劫持。各种缓解技术(例如 SafeStackRFGIntel CET)尝试检测或阻止堆栈上的 RA 损坏。

RCFI 以下面描述的几种不同的方式强制执行预期控制流。RCFI 严重依赖 LTO。

叶函数

如果 f() 是叶函数(即它没有调用,除了可能是非返回调用之外),它可以使用特殊的调用约定进行调用,该约定在 CALL 指令之前将 RA 存储在一个专用寄存器 R 中。f() 不会溢出 R 也不会使用 RET 指令,而是使用 R 中的值 JMPRA

这种 CFI 的形式是精确的,即该函数保证返回到紧随调用的点。

另一种方法是在 f() 的第一条指令中将 RA 从堆栈复制到 R,然后 JMPR。这种方法更易于实现(不需要更改调用方),但更弱(当 RA 实际上存储在堆栈上时,有一个很小的窗口)。

调用一次的函数

假设 `f()` 在程序中只在一个地方被调用(假设我们可以在 LTO 模式下验证这一点)。在这种情况下,我们可以用一个带 `RA` 常量的 `JMP` 指令替换 `RET` 指令。这将**精确地**强制执行返回控制流,无论堆栈上存储了什么。

另一个变体是比较堆栈上的 `RA` 与已知的常量,如果它们不匹配,则中止;然后 `JMP` 到已知的常量地址。

在少量调用点调用的函数

我们可以将上述方法扩展到 `f()` 被多次调用(但仍然是少量调用)的情况。使用 LTO,我们知道 `RA` 的所有可能值,并且我们逐个检查它们(或使用二分搜索)与堆栈上的值。如果找到匹配项,我们 `JMP` 到已知的常量地址,否则中止。

这种保护是**近乎精确的**,即它保证控制流将转移到此函数的有效返回地址之一,但不一定是最近的 `CALL` 点。

一般情况

对于多次调用的函数,构建一个**返回跳转表**,其方式与间接函数调用的跳转表相同(见上文)。正确的跳转表条目(或其索引)由 `CALL` 传递给 `f()`(作为额外的参数),然后溢出到堆栈。`RET` 指令被替换为加载跳转表条目、跳转表范围检查以及 `JMP` 到跳转表条目。

这种保护也是**近乎精确的**。

从间接调用的函数返回

如果一个函数被间接调用,则为函数的等价类而不是单个函数构建返回跳转表。

跨 DSO 调用

考虑两个经过仪器的 DSO,`A` 和 `B`。`A` 定义 `f()`,`B` 调用它。

这种情况将类似于使用慢路径回调的跨 DSO 方案进行处理。

非目标

RCFI 不会保护 `RET` 指令
  • 在未经过仪器的 DSO 中,

  • 在经过仪器的 DSO 中,对于从未经过仪器的 DSO 中调用的函数,

  • 嵌入到其他指令中(例如 `0f4fc3 cmovg %ebx,%eax`)。

硬件支持

我们相信上述设计可以在硬件中高效地实现。将一个新的指令添加到 ISA 中,将允许用更少的字节数(更小的代码大小开销)并可能更有效地执行前向边 CFI 检查。当前的纯软件仪器需要每个检查至少 32 字节(在 x86_64 上)。硬件指令可能少于 ~ 12 字节。这样的指令将检查参数指针是否在范围内,并且是否正确对齐,如果检查失败,它将要么陷阱(在整体方案中)要么调用慢路径函数(跨 DSO 方案)。位向量查找对于硬件实现来说可能太复杂了。

//  This instruction checks that 'Ptr'
//   * is aligned by (1 << kAlignment) and
//   * is inside [kRangeBeg, kRangeBeg+(kRangeSize<<kAlignment))
//  and if the check fails it jumps to the given target (slow path).
//
// 'Ptr' is a register, pointing to the virtual function table
//    or to the function which we need to check. We may require an explicit
//    fixed register to be used.
// 'kAlignment' is a 4-bit constant.
// 'kRangeSize' is a ~20-bit constant.
// 'kRangeBeg' is a PC-relative constant (~28 bits)
//    pointing to the beginning of the allowed range for 'Ptr'.
// 'kFailedCheckTarget': is a PC-relative constant (~28 bits)
//    representing the target to branch to when the check fails.
//    If kFailedCheckTarget==0, the process will trap
//    (monolithic binary scheme).
//    Otherwise it will jump to a handler that implements `CFI_SlowPath`
//    (cross-DSO scheme).
CFI_Check(Ptr, kAlignment, kRangeSize, kRangeBeg, kFailedCheckTarget) {
   if (Ptr < kRangeBeg ||
       Ptr >= kRangeBeg + (kRangeSize << kAlignment) ||
       Ptr & ((1 << kAlignment) - 1))
         Jump(kFailedCheckTarget);
}

另一种更紧凑的编码将不使用 `kFailedCheckTarget`,而是在检查失败时会陷阱。这将允许我们用**8-9 字节**来容纳指令。跨 DSO 检查将由陷阱处理程序执行,而性能关键的检查将必须被列入黑名单并使用纯软件方案进行检查。

请注意,这种硬件扩展将补充被调用方方面的检查,例如**Intel ENDBRANCH**。此外,CFI 与 ENDBRANCH 相比有两个优势:a) 精度和 b) 能够防止多态类型之间的无效强制转换。