控制流完整性设计文档¶
此页面记录了 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 的虚拟表连续布局
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 的位向量将如下所示
类 |
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 覆盖的区域范围内,并使用位向量的截断版本执行位向量检查。例如,我们示例类层次结构的位向量将如下发出
类 |
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();
};
虚拟表将如下布局
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 的虚拟表。
交织虚拟表¶
此步骤是交织方案与旧方案不同的地方。交织方案不是按照先前计算的顺序布局整个虚拟表,而是战略性地布局虚拟表的表条目以确保以下属性
offset-to-top 和 RTTI 字段布局属性
Itanium C++ ABI 指定 offset-to-top 和 RTTI 字段出现在地址点后面的偏移量处。请注意,libcxxabi 等库确实假定此属性。
虚拟函数条目布局属性
对于每个虚拟函数,此函数的虚拟表条目与其对应的地址点之间的距离始终相同。此属性确保动态调度仍可以使用交织的布局工作。
请注意,CFI 实现中的交织方案保证了上述两种属性,而 [1] 中提出的原始方案仅保证了第二种属性。
为了说明交织算法的工作原理,让我们继续使用运行示例。该算法首先将所有虚拟表条目分离到两个工作列表中。为此,它从分配两个工作列表开始,一个初始化为所有虚拟表中 offset-to-top 条目,按照上一步骤中计算的顺序排列,另一个初始化为所有 RTTI 条目,按照相同的顺序排列。
0 |
1 |
2 |
3 |
---|---|---|---|
A::offset-to-top |
B::offset-to-top |
D::offset-to-top |
C::offset-to-top |
0 |
1 |
2 |
3 |
|
---|---|---|---|---|
&A::rtti |
&B::rtti |
&D::rtti |
&C::rtti |
然后,对于每个虚函数,算法遍历之前计算的顺序中的所有虚表,将所有相关条目收集到一个虚函数列表中。在此步骤之后,将存在以下虚函数列表
0 |
1 |
2 |
3 |
---|---|---|---|
&A::f1 |
&B::f1 |
&D::f1 |
&C::f1 |
0 |
1 |
---|---|
&B::f2 |
&D::f2 |
0 |
---|
&C::f3 |
接下来,算法选择最长的剩余虚函数列表,并将整个列表追加到最短的工作列表,直到没有函数列表剩余,并填充较短的工作列表,使其长度相同。在本例中,f1 列表将首先添加到工作列表 1,然后 f2 列表将添加到工作列表 2,最后 f3 列表将添加到工作列表 2。由于工作列表 1 现在比工作列表 2 多一个条目,因此将一个填充条目添加到后者。在此步骤之后,两个工作列表看起来像
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 |
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,区别在于我们需要构建函数入口点的位向量,而不是虚表的位向量。
与重新排列全局变量不同,我们不能以特定顺序重新排列函数并基于函数入口点的布局进行计算,因为我们不知道特定函数最终会占用多大空间(函数的大小甚至可能取决于我们如何排列函数)。相反,我们构建一个跳转表,它是一个代码块,包含一个分支指令,用于位集中每个分支到目标函数的函数,并将任何已获取的函数地址重定向到相应的跳转表条目。通过这种方式,函数入口点之间的距离是可预测和可控的。在目标文件的符号表中,目标函数的符号也引用跳转表条目,以便在模块外部获取的地址将通过在模块内部完成的任何验证。
更具体地说,假设我们有三个函数 f
、g
、h
,它们都是同一类型,以及一个返回其地址的函数 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
因为 f
、g
、h
的地址在 2 的幂次方处均匀分布,并且函数类型不重叠(与具有基类的类类型不同),我们通常可以应用 对齐 和 消除全 1 位向量的位向量检查 优化,从而简化每个调用点的检查为范围和对齐检查。
返回语句的后向边 CFI (RCFI)¶
本节是一个提案。截至 2017 年 3 月,尚未实现。
后向边控制流(RET 指令)可以通过覆盖堆栈上的返回地址 (RA) 来劫持。各种缓解技术(例如 SafeStack、RFG、Intel CET)尝试检测或阻止堆栈上的 RA 损坏。
RCFI 以下面描述的几种不同的方式强制执行预期控制流。RCFI 严重依赖 LTO。
叶函数¶
如果 f() 是叶函数(即它没有调用,除了可能是非返回调用之外),它可以使用特殊的调用约定进行调用,该约定在 CALL 指令之前将 RA 存储在一个专用寄存器 R 中。f() 不会溢出 R 也不会使用 RET 指令,而是使用 R 中的值 JMP 到 RA。
这种 CFI 的形式是精确的,即该函数保证返回到紧随调用的点。
另一种方法是在 f() 的第一条指令中将 RA 从堆栈复制到 R,然后 JMP 到 R。这种方法更易于实现(不需要更改调用方),但更弱(当 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) 能够防止多态类型之间的无效强制转换。