推理执行端通道的 C++ 开发人员指南
本文为开发人员提供指导,帮助识别和缓解 C++ 软件中的推理执行侧通道硬件漏洞。 这些漏洞可能会跨信任边界泄露敏感信息,并且可能会影响在支持推测性、无序执行指令的处理器上运行的软件。 此类漏洞在 2018 年 1 月首次进行了描述,可以在 Microsoft 安全公告中找到更多背景信息和指南。
本文提供的指南与以下各类漏洞相关:
CVE-2017-5753,也称为 Spectre 变体 1。 此硬件漏洞类别与可能由于条件分支错误预测导致的推理执行引起的侧通道相关。 Visual Studio 2017 中的 Microsoft C++ 编译器(从版本 15.5.5 开始)包括对
/Qspectre
开关的支持,该开关为与 CVE-2017-5753 相关的一组有限且可能易受攻击的编码模式提供编译时缓解。 Visual Studio 2015 Update 3 也通过 KB 4338871 提供了/Qspectre
开关。/Qspectre
标志的文档提供了有关其效果和用法的详细信息。CVE-2018-3639,也称为推理存储绕过 (SSB)。 此硬件漏洞类别与可能由于内存访问错误预测导致的在依赖存储前推理执行负载引起的侧通道相关。
在发现这些问题的研究团队之一完成的标题为《Spectre 和 Meltdown 案例》的演示文稿中,可以看到对推理执行侧通道漏洞的无障碍介绍。
什么是推理执行侧通道硬件漏洞?
现代 CPU 通过利用指令的推理和无序执行来提供更高的性能。 例如,这通常通过预测分支(条件和间接)的目标来实现,使 CPU 能够在预测分支目标处开始推理执行指令,从而避免停滞,直到实际分支目标得到解决。 如果 CPU 后来发现发生了错误预测,会丢弃所有以推测方式计算的计算机状态。 这可确保错误预测的推测在体系结构上没有可见的影响。
虽然推理执行不影响体系结构上的可见状态,但它会在非体系结构状态中留下残留的痕迹,例如 CPU 使用的各种缓存。 而正是这些推理执行残留的痕迹会导致侧通道漏洞。 为了更好地理解这一点,来看看以下代码片段,其中提供了 CVE-2017-5753(边界检查绕过)的示例:
// A pointer to a shared memory region of size 1MB (256 * 4096)
unsigned char *shared_buffer;
unsigned char ReadByte(unsigned char *buffer, unsigned int buffer_size, unsigned int untrusted_index) {
if (untrusted_index < buffer_size) {
unsigned char value = buffer[untrusted_index];
return shared_buffer[value * 4096];
}
}
在此示例中,为 ReadByte
提供了缓冲区、缓冲区大小和进入该缓冲区的索引。 索引参数由 untrusted_index
指定,在权限较低的上下文(如非管理过程)中提供。 如果 untrusted_index
小于 buffer_size
,则该索引处的字符会从 buffer
读取,并用于索引到由 shared_buffer
所指的内存的共享区域中。
从体系结构的角度来看,此代码序列是完全安全的,因为它可以保证 untrusted_index
始终小于 buffer_size
。 但是,如果存在推理执行,CPU 可能会错误预测条件分支并执行 if 语句的主体,即使 untrusted_index
大于或等于 buffer_size
也是如此。 因此,CPU 可能会以推测方式读取 buffer
的边界之外的字节(这可能是一个机密),然后会使用该字节值计算后续通过 shared_buffer
加载的地址。
虽然 CPU 最终将检测到此错误预测,但残留的副作用可能会留在 CPU 缓存中,显示出从 buffer
的边界之外读取的字节值的相关信息。 由在系统上运行的权限较低的上下文通过探测访问 shared_buffer
中的每个缓存行的速度,可以检测出这些副作用。 为实现此目的,可以采取如下步骤:
在
untrusted_index
小于buffer_size
时多次调用ReadByte
。 攻击上下文可能会导致受害者上下文调用ReadByte
(例如,通过 RPC),这样分支预测器就会被训练为不被采取,因为untrusted_index
小于buffer_size
。刷新
shared_buffer
中的所有缓存行。 攻击上下文必须在由shared_buffer
所指的内存的共享区域中刷新所有缓存行。 由于内存区域是共享的,因此可以直接使用内部函数(例如_mm_clflush
)来实现。在
untrusted_index
大于buffer_size
时调用ReadByte
。 攻击上下文会导致受害者上下文调用ReadByte
,使其错误地预测出分支不会被采取。 这会导致处理器通过推测执行 if 块的主体,因为untrusted_index
大于buffer_size
,从而导致在边界外读取buffer
。 因此,使用一个可能的机密值(从边界外读取)对shared_buffer
进行索引,从而导致 CPU 加载相应的缓存行。读取
shared_buffer
中的每个缓存行,查看访问速度最快的缓存行。 攻击上下文可以读取shared_buffer
中的每个缓存行,并检测出加载速度明显快于其他缓存行的那个缓存行。 这就是可能在步骤 3 中引入的缓存行。 由于在本例中,字节值和缓存行之间存在 1:1 的关系,因此,攻击者可以推断出边界外读取的字节的实际值。
上述步骤举例介绍了如何结合使用一种称为“刷新 + 重新加载”的技术,以及利用 CVE-2017-5753 的实例。
哪些软件方案可能会受到影响?
使用安全开发生命周期 (SDL) 等过程开发安全软件通常要求开发人员确定其应用程序中存在的信任边界。 信任边界存在于应用程序可能与信任度较低的上下文提供的数据进行交互的位置,例如系统上的另一个进程,或者内核模式设备驱动程序中的非管理用户模式进程。 涉及推理执行侧通道的新一类漏洞与现有软件安全模型中隔离设备上的代码和数据的许多信任边界相关。
下表概述了软件安全模型,开发人员可能需要在这些模型中关注这些漏洞:
信任边界 | 说明 |
---|---|
虚拟机边界 | 在从另一个虚拟机接收不受信任的数据的单独虚拟机中隔离工作负载的应用程序可能存在风险。 |
内核边界 | 从非管理用户模式进程接收不受信任的数据的内核模式设备驱动程序可能存在风险。 |
进程边界 | 从本地系统上运行的另一个进程接收不受信任的数据的应用程序(例如,通过远程过程调用 (RPC)、共享内存或其他进程内通信 (IPC) 机制)可能存在风险。 |
enclave 边界 | 在安全 enclave(如 Intel SGX)内执行且从 enclave 外部接收不受信任的数据的应用程序可能存在风险。 |
语言边界 | 解释或实时 (JIT) 编译和执行用较高级别语言编写的不受信任的代码的应用程序可能存在风险。 |
将攻击面暴露给上述任何信任边界的应用程序应审查攻击面上的代码,以确定并缓解可能存在推理执行侧通道漏洞的实例。 应该注意的是,暴露给远程攻击面(如远程网络协议)的信任边界尚未被证实存在推理执行侧通道漏洞的风险。
可能易受攻击的编码模式
由于存在多种编码模式,导致可能会出现推理执行侧通道漏洞。 本部分介绍可能易受攻击的编码模式,并提供每种模式的示例,但应当认识到这些主题可能存在变化。 因此,建议开发人员将这些模式作为示例,而不是所有可能易受攻击的编码模式的详尽清单。 当前软件中可能存在的相同类别的内存安全漏洞也可能存在于推理且无序的执行路径中,包括但不限于缓冲区溢出、边界外的数组访问、未初始化的内存使用、类型混淆等。 攻击者可以用来利用体系结构路径上的内存安全漏洞的基元也可能同样适用于推理路径。
一般情况下,如果条件表达式对可以被信任度较低的上下文控制或影响的数据进行操作,就会出现与条件分支错误预测相关的推理执行侧通道。 例如,这可能包括 if
、for
、while
、switch
或三元语句中使用的条件表达式。 对于上述每个语句,编译器可能会生成一个条件分支,CPU 随后可以在运行时预测分支目标。
对于每个示例,在开发人员可以引入屏障作为缓解措施的位置,都插入一个带有短语“推理屏障”的注释。 这将在“缓解措施”一节中更详细地讨论。
推理边界外加载
此类别的编码模式涉及条件分支错误预测,导致推理边界外的内存访问。
数组边界外加载馈送负载
此编码模式是最初介绍的适用于 CVE-2017-5753(边界检查绕过)的易受攻击的编码模式。 本文的背景信息部分详细介绍了此模式。
// A pointer to a shared memory region of size 1MB (256 * 4096)
unsigned char *shared_buffer;
unsigned char ReadByte(unsigned char *buffer, unsigned int buffer_size, unsigned int untrusted_index) {
if (untrusted_index < buffer_size) {
// SPECULATION BARRIER
unsigned char value = buffer[untrusted_index];
return shared_buffer[value * 4096];
}
}
类似地,数组边界外加载可能会与因错误预测而超出其终止条件的循环一起出现。 在此示例中,当 x
大于或等于 buffer_size
时,与 x < buffer_size
表达式关联的条件分支可能会错误预测并推测性地执行 for
循环的主体,从而导致推理边界外加载。
// A pointer to a shared memory region of size 1MB (256 * 4096)
unsigned char *shared_buffer;
unsigned char ReadBytes(unsigned char *buffer, unsigned int buffer_size) {
for (unsigned int x = 0; x < buffer_size; x++) {
// SPECULATION BARRIER
unsigned char value = buffer[x];
return shared_buffer[value * 4096];
}
}
数组边界外加载馈送间接分支
这种编码模式涉及的情况是,条件分支错误预测可导致对函数指针数组的边界外访问,这会导致到边界外读取的目标地址的间接分支。 以下代码片段提供了一个示例来演示此情况。
在此示例中,通过 untrusted_message_id
参数将不受信任的消息标识符提供给 DispatchMessage。 如果 untrusted_message_id
小于 MAX_MESSAGE_ID
,则它用于索引到函数指针数组中,并分支到相应的分支目标。 此代码在体系结构上是安全的,但如果 CPU 错误预测条件分支,可能会导致 DispatchTable
在其值大于或等于 MAX_MESSAGE_ID
时由 untrusted_message_id
进行索引,从而导致外界外的访问。 这可能会导致从数组边界外派生的分支目标地址进行推理执行,这可能会导致信息泄漏,具体取决于推理执行的代码。
#define MAX_MESSAGE_ID 16
typedef void (*MESSAGE_ROUTINE)(unsigned char *buffer, unsigned int buffer_size);
const MESSAGE_ROUTINE DispatchTable[MAX_MESSAGE_ID];
void DispatchMessage(unsigned int untrusted_message_id, unsigned char *buffer, unsigned int buffer_size) {
if (untrusted_message_id < MAX_MESSAGE_ID) {
// SPECULATION BARRIER
DispatchTable[untrusted_message_id](buffer, buffer_size);
}
}
与数组边界外加载馈送其他负载一样,此情况也可能与因错误预测而超出其终止条件的循环一起出现。
数组边界外存储馈送间接分支
虽然前一个示例演示了推理边界外加载如何影响间接分支目标,但边界外存储还可以修改间接分支目标,例如函数指针或返回地址。 这可能会导致从攻击者指定的地址进行推理执行。
在此示例中,通过 untrusted_index
参数传递不受信任的索引。 如果 untrusted_index
小于 pointers
数组的元素计数(256 个元素),则 ptr
中提供的指针值被写入 pointers
数组。 此代码在体系结构上是安全的,但如果 CPU 错误预测条件分支,可能会导致在堆栈分配的 pointers
数组边界之外以推理方式写入 ptr
。 这可能会导致对 WriteSlot
返回地址的推理损坏。 如果攻击者可以控制 ptr
的值,则当沿推理路径返回 WriteSlot
时,它们可能会导致从任意地址进行推理执行。
unsigned char WriteSlot(unsigned int untrusted_index, void *ptr) {
void *pointers[256];
if (untrusted_index < 256) {
// SPECULATION BARRIER
pointers[untrusted_index] = ptr;
}
}
同样,如果在堆栈上分配了名为 func
的函数指针局部变量,则在发生条件分支错误预测时,可能会推测性地修改 func
所指向的地址。 当调用函数指针时,这可能会导致从任意地址进行推理执行。
unsigned char WriteSlot(unsigned int untrusted_index, void *ptr) {
void *pointers[256];
void (*func)() = &callback;
if (untrusted_index < 256) {
// SPECULATION BARRIER
pointers[untrusted_index] = ptr;
}
func();
}
应当注意的是,这两个示例都涉及对堆栈分配的间接分支指针进行推理修改。 对于全局变量、堆分配的内存,甚至是某些 CPU 上的只读内存,也可能发生推理修改。 对于堆栈分配的内存,Microsoft C++ 编译器已采取措施,使推理修改堆栈分配的间接分支目标更加困难,例如通过对局部变量重新排序,使缓冲区作为 /GS
编译器安全功能的一部分放置在安全 Cookie 的旁边。
推理类型混淆
此类别处理会导致推理类型混淆的编码模式。 在推理执行期间,沿非体系结构路径使用不正确的类型访问内存时,会发生这种情况。 条件分支错误预测和推理存储绕过都可能会导致推理类型混淆。
对于推理存储绕过,在编译器重用多个类型变量的堆栈位置的情况下,可能会发生这种情况。 这是因为可以绕过类型 A
的变量的体系结构存储,从而允许在分配变量之前推理执行类型 A
的加载。 如果以前存储的变量是一个不同的类型,这可能会为推理类型混淆创造条件。
对于条件分支错误预测,以下代码片段将用于描述推理类型混淆可能会引起的不同情况。
enum TypeName {
Type1,
Type2
};
class CBaseType {
public:
CBaseType(TypeName type) : type(type) {}
TypeName type;
};
class CType1 : public CBaseType {
public:
CType1() : CBaseType(Type1) {}
char field1[256];
unsigned char field2;
};
class CType2 : public CBaseType {
public:
CType2() : CBaseType(Type2) {}
void (*dispatch_routine)();
unsigned char field2;
};
// A pointer to a shared memory region of size 1MB (256 * 4096)
unsigned char *shared_buffer;
unsigned char ProcessType(CBaseType *obj)
{
if (obj->type == Type1) {
// SPECULATION BARRIER
CType1 *obj1 = static_cast<CType1 *>(obj);
unsigned char value = obj1->field2;
return shared_buffer[value * 4096];
}
else if (obj->type == Type2) {
// SPECULATION BARRIER
CType2 *obj2 = static_cast<CType2 *>(obj);
obj2->dispatch_routine();
return obj2->field2;
}
}
导致边界外加载的推理类型混淆
这种编码模式涉及的情况是,推理类型混淆可能导致边界外或类型混淆的字段访问,其中加载的值会馈送后续加载地址。 这类似于数组边界外编码模式,但它是通过另一种编码序列表现出来的,如上所示。 在此示例中,攻击上下文可能导致受害者上下文多次使用类型 CType1
的对象执行 ProcessType
(type
字段等于 Type1
)。 这将产生这样的效果,训练第一个 if
语句的条件分支以预测不采取。 然后,攻击上下文可能会导致受害者上下文使用类型 CType2
的对象执行 ProcessType
。 如果第一个 if
语句的条件分支错误预测并执行 if
语句的主体,从而将类型 CType2
的对象强制转换为 CType1
,则这可能会导致推理类型混淆。 由于 CType2
小于 CType1
,因此对 CType1::field2
的内存访问将导致可能为机密的数据的推理边界外加载。 然后,使用此值从 shared_buffer
中加载,这可能会产生可观察到的副作用,就像前面所述的数组边界外示例一样。
导致间接分支的推理类型混淆
这种编码模式涉及的情况是,推理类型混淆可能导致推理执行期间出现不安全的间接分支。 在此示例中,攻击上下文可能导致受害者上下文多次使用类型 CType2
的对象执行 ProcessType
(type
字段等于 Type2
)。 这将产生这样的效果,训练第一个 if
语句的条件分支以预测采取,训练第二个 else if
语句的条件分支以预测不采取。 然后,攻击上下文可能会导致受害者上下文使用类型 CType1
的对象执行 ProcessType
。 如果预测采取第一个 if
语句的条件分支,并预测不采取 else if
语句的条件分支,从而执行 else if
的主体并将类型 CType1
的对象强制转换为 CType2
,则这可能会导致推理类型混淆。 由于 CType2::dispatch_routine
字段与 char
数组 CType1::field1
重叠,因此可能会导致到意外分支目标的推理间接分支。 如果攻击上下文可以控制 CType1::field1
数组中的字节值,则它们也许能够控制分支目标地址。
推理未初始化的使用
此类别的编码模式涉及的情况是,推理执行可能会访问未初始化的内存并使用它来馈送后续负载或间接分支。 为了能够利用这些编码模式,攻击者需要能够控制或有意义地影响所使用的内存的内容,而不被所使用的上下文初始化。
导致边界外加载的推理未初始化使用
推理未初始化的使用可能会导致使用攻击者控制的值进行边界外加载。 在下面的示例中,index
的值在所有体系结构路径上被分配为 trusted_index
,并假定 trusted_index
小于或等于 buffer_size
。 但是,根据编译器生成的代码,可能会出现推理存储绕过,使从 buffer[index]
和依赖表达式的加载在赋值给 index
之前执行。 如果发生这种情况,则 index
的未初始化值将用作 buffer
的偏移量,这可能使攻击者在边界外读取敏感信息,并通过 shared_buffer
的依赖负载通过侧通道传达此信息。
// A pointer to a shared memory region of size 1MB (256 * 4096)
unsigned char *shared_buffer;
void InitializeIndex(unsigned int trusted_index, unsigned int *index) {
*index = trusted_index;
}
unsigned char ReadByte(unsigned char *buffer, unsigned int buffer_size, unsigned int trusted_index) {
unsigned int index;
InitializeIndex(trusted_index, &index); // not inlined
// SPECULATION BARRIER
unsigned char value = buffer[index];
return shared_buffer[value * 4096];
}
导致间接分支的推理未初始化使用
推理未初始化的使用可能会导致间接分支,其中分支目标由攻击者控制。 在下面的示例中,routine
被分配给 DefaultMessageRoutine1
或 DefaultMessageRoutine
,具体取决于 mode
的值。 在体系结构路径上,这将导致 routine
始终在间接分支之前被初始化。 但是,根据编译器生成的代码,可能会发生推理存储绕过,使通过 routine
的间接分支在赋值给 routine
之前进行推理执行。 如果发生这种情况,攻击者也许能够从任意地址进行推理执行,前提是攻击者可以影响或控制 routine
的未初始化值。
#define MAX_MESSAGE_ID 16
typedef void (*MESSAGE_ROUTINE)(unsigned char *buffer, unsigned int buffer_size);
const MESSAGE_ROUTINE DispatchTable[MAX_MESSAGE_ID];
extern unsigned int mode;
void InitializeRoutine(MESSAGE_ROUTINE *routine) {
if (mode == 1) {
*routine = &DefaultMessageRoutine1;
}
else {
*routine = &DefaultMessageRoutine;
}
}
void DispatchMessage(unsigned int untrusted_message_id, unsigned char *buffer, unsigned int buffer_size) {
MESSAGE_ROUTINE routine;
InitializeRoutine(&routine); // not inlined
// SPECULATION BARRIER
routine(buffer, buffer_size);
}
缓解选项
通过更改源代码,可以缓解推理执行侧通道漏洞。 这些更改可能涉及缓解漏洞的特定实例,例如添加“推理屏障”,或者对应用程序设计进行更改,使推理执行无法访问敏感信息。
通过手动检测的推理屏障
开发人员可以手动插入“推理屏障”,以防止推理执行沿非体系结构路径继续。 例如,开发人员可以在条件块主体中的危险编码模式之前插入推理屏障,可以是在块的开头(在条件分支之后),也可以在需要关注的第一个负载之前。 这将通过序列化执行来防止条件分支错误预测在非体系结构路径上执行危险代码。 推理屏障序列因硬件体系结构而异,如下表所述:
体系结构 | CVE-2017-5753 的推理屏障内部函数 | CVE-2018-3639 的推理屏障内部函数 |
---|---|---|
x86/x64 | _mm_lfence() | _mm_lfence() |
ARM | 当前不可用 | __dsb(0) |
ARM64 | 当前不可用 | __dsb(0) |
例如,可以使用 _mm_lfence
内部函数缓解以下代码模式,如下所示。
// A pointer to a shared memory region of size 1MB (256 * 4096)
unsigned char *shared_buffer;
unsigned char ReadByte(unsigned char *buffer, unsigned int buffer_size, unsigned int untrusted_index) {
if (untrusted_index < buffer_size) {
_mm_lfence();
unsigned char value = buffer[untrusted_index];
return shared_buffer[value * 4096];
}
}
通过编译器时检测的推理屏障
Visual Studio 2017 中的 Microsoft C++ 编译器(从版本 15.5.5 开始)包括对 /Qspectre
开关的支持,该开关自动为一组有限的可能易受攻击的编码模式(与 CVE-2017-5753 相关)插入一个推理屏障。 /Qspectre
标志的文档提供了有关其效果和用法的详细信息。 请务必注意,此标志并不涵盖所有可能易受攻击的编码模式,因此,开发人员不应依赖它来全面缓解此类漏洞。
屏蔽数组索引
在可能发生推理边界外加载的情况下,通过添加逻辑来显式绑定数组索引,可以在体系结构和非体系结构路径上对数组索引进行强绑定。 例如,如果可以将数组分配给一个与 2 的幂对齐的大小,则可以引入简单的掩码。 下面的示例对此进行了说明,其中假定 buffer_size
与 2 的幂对齐。 这可确保 untrusted_index
始终小于 buffer_size
,即使发生条件分支错误预测,并且使用大于或等于 buffer_size
的值传入 untrusted_index
也是如此。
应当注意的是,此处执行的索引屏蔽可能会受到推理存储绕过的影响,具体取决于编译器生成的代码。
// A pointer to a shared memory region of size 1MB (256 * 4096)
unsigned char *shared_buffer;
unsigned char ReadByte(unsigned char *buffer, unsigned int buffer_size, unsigned int untrusted_index) {
if (untrusted_index < buffer_size) {
untrusted_index &= (buffer_size - 1);
unsigned char value = buffer[untrusted_index];
return shared_buffer[value * 4096];
}
}
从内存中删除敏感信息
另一种可用于缓解推理执行侧通道漏洞的技术是从内存中删除敏感信息。 软件开发人员可以寻找机会重构应用程序,使敏感信息在推理执行期间无法进行访问。 为此,可以重构应用程序的设计,将敏感信息隔离到单独的进程中。 例如,Web 浏览器应用程序可以尝试将与每个 Web 源关联的数据隔离到单独的进程中,从而阻止一个进程推测性地执行访问跨源数据。