JIT 生成的汇编代码安全:利用 LLVM 的控制流完整性 (CFI) 保护机制
各位听众,大家好。今天我们来探讨一个重要的安全课题:如何保护即时编译 (JIT) 生成的汇编代码,特别是利用 LLVM 的控制流完整性 (CFI) 保护机制。
JIT 编译技术在许多领域都有着广泛的应用,例如:
- 动态语言的运行时优化: JavaScript、Python 等动态语言通常会在运行时进行代码优化,以提高执行效率。
- 游戏引擎: 游戏引擎会根据硬件配置和游戏场景动态生成渲染代码,以达到最佳性能。
- 数据库系统: 数据库系统可以根据查询语句动态生成执行计划,提高查询效率。
- 机器学习框架: 机器学习框架可以根据模型结构和数据特点动态生成计算代码,加速模型训练和推理。
然而,JIT 编译也引入了新的安全风险。由于 JIT 编译器在运行时生成代码,这些代码可能会受到恶意攻击者的篡改,导致程序执行意外的行为,甚至造成安全漏洞。
JIT 编译带来的安全风险
传统的安全防护手段,例如静态代码分析,对于 JIT 生成的代码往往失效,因为这些代码是在运行时动态生成的,无法提前进行分析。攻击者可以通过多种方式篡改 JIT 生成的代码,例如:
- 代码注入: 攻击者可以将恶意代码注入到 JIT 编译器的内存空间中,然后利用 JIT 编译器执行这些恶意代码。
- 代码覆盖: 攻击者可以覆盖 JIT 生成的合法代码,替换成恶意代码。
- ROP/JOP 攻击: 攻击者可以利用 JIT 生成的代码中的指令序列 (gadgets) 构造 ROP (Return-Oriented Programming) 或 JOP (Jump-Oriented Programming) 链,控制程序的执行流程。
这些攻击手段可能会导致程序崩溃、数据泄露、权限提升等严重的安全问题。因此,我们需要采取有效的安全措施来保护 JIT 生成的汇编代码。
控制流完整性 (CFI) 的基本原理
控制流完整性 (Control Flow Integrity, CFI) 是一种重要的安全防护技术,它可以有效地防止攻击者篡改程序的控制流。CFI 的基本原理是:
- 建立控制流图 (CFG): 在编译时,编译器会分析程序的源代码,建立程序的控制流图。控制流图描述了程序中所有可能的控制流路径。
- 插入检查指令: 在运行时,程序会在关键的控制流转移点 (例如函数调用、函数返回、跳转指令) 插入检查指令。这些检查指令会验证当前的控制流是否符合预期的控制流路径。
- 强制执行控制流: 如果检查指令发现当前的控制流不符合预期的控制流路径,程序会立即终止执行,从而防止攻击者篡改程序的控制流。
举例来说,考虑以下 C 代码:
void foo() {
// ...
}
void bar() {
foo();
}
在编译时,编译器会建立控制流图,其中 bar 函数可以调用 foo 函数。在运行时,编译器会在 bar 函数调用 foo 函数之前插入检查指令,验证当前的调用目标是否为 foo 函数。如果攻击者尝试将调用目标修改为其他函数,检查指令会发现控制流异常,程序会立即终止执行。
利用 LLVM 实现 CFI 保护
LLVM 是一个强大的编译器基础设施,它提供了丰富的工具和 API,可以方便地实现 CFI 保护。LLVM 的 CFI 实现主要依赖于以下几个组件:
- Metadata: LLVM 允许在 IR (Intermediate Representation) 中添加元数据 (metadata),用于描述程序的控制流信息。例如,可以使用 metadata 标记函数的类型、调用约定等信息。
- Instrumentation: LLVM 提供了 instrumentation 机制,可以在 IR 中插入检查指令。例如,可以使用 instrumentation 插入函数调用前的类型检查指令。
- Code Generation: LLVM 的代码生成器会根据 IR 中的 metadata 和 instrumentation 信息,生成包含 CFI 检查指令的汇编代码。
LLVM 支持多种 CFI 变体,例如:
- Forward-edge CFI: 保护函数调用和跳转指令的目标地址。
- Backward-edge CFI: 保护函数返回地址。
下面我们以一个简单的例子来说明如何利用 LLVM 实现 forward-edge CFI 保护。
假设我们有以下 C 代码:
typedef void (*func_ptr_t)();
void foo() {
printf("foon");
}
void bar(func_ptr_t func) {
func();
}
int main() {
bar(foo);
return 0;
}
为了对这段代码进行 forward-edge CFI 保护,我们需要执行以下步骤:
-
编译 C 代码到 LLVM IR:
clang -emit-llvm -S -o test.ll test.c -
添加 metadata,标记函数类型:
我们需要修改
test.ll文件,为foo函数和func_ptr_t类型添加 metadata。例如,可以添加一个名为type.func_ptr_t的 metadata,用于标记所有类型为func_ptr_t的函数指针。; ModuleID = 'test.c' source_filename = "test.c" target datalayout = "e-m:e-p270:32:32-p271:32:32-i64:64-i128:128-f80:128-fp128:128-n64:64-S128" target triple = "x86_64-unknown-linux-gnu" %struct._IO_FILE = type opaque ; 添加 metadata,标记 foo 函数的类型 @foo = global i32 0, align 4, !type !0 ; Function Attrs: noinline nounwind optnone uwtable define void @foo() #0 !type !0 { entry: %call = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([5 x i8], [5 x i8]* @.str, i64 0, i64 0)) ret void } declare i32 @printf(i8* nocapture readonly, ...) #1 @.str = private unnamed_addr constant [5 x i8] c"fooA0", align 1 ; Function Attrs: noinline nounwind optnone uwtable define void @bar(void ()* %func) #0 { entry: ; 添加 CFI 检查指令 %0 = bitcast void ()* %func to void ()* call void %0() ret void } ; Function Attrs: noinline nounwind optnone uwtable define i32 @main() #0 { entry: call void @bar(void ()* @foo) ret i32 0 } attributes #0 = { noinline nounwind optnone uwtable "frame-pointer"="all" "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" } attributes #1 = { nounwind "frame-pointer"="all" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" } ; 添加 metadata 定义 !0 = !{i32 0, !"type", !"func_ptr_t"} -
插入 CFI 检查指令:
在
bar函数调用func函数之前,我们需要插入 CFI 检查指令,验证func函数的类型是否为func_ptr_t。 这部分逻辑在上面的llvm代码里已经包含, 即!type !0。 -
编译 LLVM IR 到汇编代码:
llc -o test.s test.ll -
编译汇编代码到可执行文件:
gcc -o test test.s
在运行时,如果攻击者尝试将 foo 函数替换成其他函数,CFI 检查指令会发现类型不匹配,程序会立即终止执行。
LLVM 提供了多种 CFI 实现,可以根据不同的安全需求选择合适的 CFI 变体。例如,可以使用 llvm.assume intrinsic 来添加类型假设,从而优化 CFI 检查指令的性能。
LLVM CFI 的局限性
虽然 LLVM CFI 可以有效地防止多种控制流攻击,但它也存在一些局限性:
- 性能开销: CFI 检查指令会引入一定的性能开销。例如,forward-edge CFI 需要在每个函数调用前进行类型检查,这会增加程序的执行时间。
- 兼容性问题: CFI 可能会与某些现有的代码库不兼容。例如,某些代码库可能会使用非标准的函数调用约定,这会导致 CFI 检查失败。
- 绕过风险: 攻击者可能会利用一些高级的攻击手段绕过 CFI 保护。例如,攻击者可以使用 ROP/JOP 攻击构造合法的控制流路径,从而绕过 CFI 检查。
为了解决这些局限性,我们需要不断地改进 CFI 技术,例如:
- 优化 CFI 检查指令的性能: 可以使用静态分析、动态分析等技术优化 CFI 检查指令的性能,降低 CFI 的性能开销。
- 提高 CFI 的兼容性: 可以通过修改编译器、代码库等方式,提高 CFI 的兼容性,使其能够与更多的代码库协同工作。
- 增强 CFI 的安全性: 可以使用更高级的 CFI 变体,例如细粒度 CFI、数据流完整性 (Data-Flow Integrity, DFI) 等,增强 CFI 的安全性,防止攻击者绕过 CFI 保护。
CFI 在 JIT 环境下的应用
将 CFI 应用于 JIT 环境下需要解决一些额外的挑战:
- 动态代码生成: JIT 编译器在运行时动态生成代码,这意味着 CFI 检查指令也需要在运行时动态生成。
- 性能开销: JIT 编译通常需要在短时间内完成,因此 CFI 检查指令的生成和执行必须高效。
- 代码缓存: JIT 编译器通常会将生成的代码缓存起来,以便下次使用。CFI 检查指令也需要与缓存的代码保持一致。
为了解决这些挑战,可以采用以下策略:
- 预先计算 CFI 信息: 在编译时,可以预先计算程序的控制流信息,并将这些信息存储在 metadata 中。在 JIT 编译时,可以根据 metadata 快速生成 CFI 检查指令。
- 使用轻量级的 CFI 变体: 可以选择轻量级的 CFI 变体,例如 shadow stack 等,降低 CFI 的性能开销。
- 缓存 CFI 检查指令: 可以将生成的 CFI 检查指令与缓存的代码一起存储,确保 CFI 检查指令与代码的一致性。
以下表格总结了传统AOT(Ahead-of-Time)编译和JIT编译在CFI应用上的一些差异:
| 特性 | AOT 编译 | JIT 编译 |
|---|---|---|
| 编译时机 | 编译时 | 运行时 |
| 代码生成 | 静态生成 | 动态生成 |
| CFI 信息 | 静态计算 | 动态计算或预先计算 |
| 性能开销 | 可接受,可以通过优化降低 | 更加敏感,需要轻量级 CFI 变体 |
| 代码缓存 | 无 | 有,需要保证 CFI 检查指令与缓存代码的一致性 |
示例:使用 LLVM Orc JIT 编译器实现 CFI 保护
LLVM Orc 是一个基于 LLVM 的 JIT 编译器框架,它提供了丰富的 API,可以方便地实现 CFI 保护。下面我们以一个简单的例子来说明如何使用 LLVM Orc JIT 编译器实现 forward-edge CFI 保护。
#include "llvm/ExecutionEngine/Orc/CompileUtils.h"
#include "llvm/ExecutionEngine/Orc/ExecutionUtils.h"
#include "llvm/ExecutionEngine/Orc/IRCompileLayer.h"
#include "llvm/ExecutionEngine/Orc/JITTargetMachineBuilder.h"
#include "llvm/ExecutionEngine/Orc/ObjectLayer.h"
#include "llvm/IR/DataLayout.h"
#include "llvm/IR/IRBuilder.h"
#include "llvm/IR/Mangler.h"
#include "llvm/IR/Module.h"
#include "llvm/Support/TargetSelect.h"
#include "llvm/Target/TargetMachine.h"
#include <iostream>
using namespace llvm;
using namespace llvm::orc;
int main() {
// 初始化 LLVM
InitializeNativeTarget();
InitializeNativeTargetAsmPrinter();
InitializeNativeTargetAsmParser();
// 创建 LLVM 上下文
LLVMContext Context;
// 创建模块
auto Module = std::make_unique<Module>("test", Context);
Module->setDataLayout(EngineBuilder().selectTarget()->createDataLayout());
// 创建函数类型
FunctionType *FuncType = FunctionType::get(Type::getVoidTy(Context), false);
// 创建 foo 函数
Function *FooFunc = Function::Create(FuncType, GlobalValue::ExternalLinkage, "foo", Module.get());
FooFunc->addFnAttr("type", "func_ptr_t"); // 添加 metadata
// 创建 bar 函数
Function *BarFunc = Function::Create(FuncType, GlobalValue::ExternalLinkage, "bar", Module.get());
// 创建 main 函数
Function *MainFunc = Function::Create(FunctionType::get(Type::getInt32Ty(Context), false), GlobalValue::ExternalLinkage, "main", Module.get());
// 构造 foo 函数的 IR
{
BasicBlock *BB = BasicBlock::Create(Context, "entry", FooFunc);
IRBuilder<> Builder(BB);
Builder.CreateRetVoid();
}
// 构造 bar 函数的 IR
{
BasicBlock *BB = BasicBlock::Create(Context, "entry", BarFunc);
IRBuilder<> Builder(BB);
// 获取 foo 函数的指针
Value *FooPtr = FooFunc;
// 添加 CFI 检查指令 (这里只是一个简单的示例,实际应用中需要更复杂的检查)
Value *BitCast = Builder.CreateBitCast(FooPtr, FuncType->getPointerTo());
CallInst *Call = Builder.CreateCall(FuncType, BitCast);
Builder.CreateRetVoid();
}
// 构造 main 函数的 IR
{
BasicBlock *BB = BasicBlock::Create(Context, "entry", MainFunc);
IRBuilder<> Builder(BB);
// 获取 bar 函数的指针
Value *BarPtr = BarFunc;
// 调用 bar 函数
Builder.CreateCall(BarFunc);
Builder.CreateRet(ConstantInt::get(Type::getInt32Ty(Context), 0));
}
// 创建 Orc JIT 编译器
auto JTMB = JITTargetMachineBuilder::detectHost();
auto DataLayout = JTMB.createDataLayout();
auto TM = JTMB.createTargetMachine();
auto ObjectLayer = std::make_unique<ObjectLayer>();
auto CompileLayer = std::make_unique<IRCompileLayer>(*ObjectLayer, createRTDyldObjectLinkingLayerCreator());
RTDyldObjectLinkingLayer::Resources Resources;
// 编译模块
auto M = CompileLayer->add(ResourceTracker::allocateVTable(), std::move(Module));
// 获取 main 函数的指针
auto MainSymbol = JTMB.getManglingOptions().Mangle(GlobalValue::getRealLinkageName("main"));
auto MainFuncSym = CompileLayer->findSymbol(MainSymbol, false);
auto MainFuncPtr = reinterpret_cast<int (*)()>(MainFuncSym.get().getAddress());
// 执行 main 函数
MainFuncPtr();
// 卸载模块
CompileLayer->remove(M);
return 0;
}
这个例子演示了如何使用 LLVM Orc JIT 编译器生成包含 CFI 检查指令的代码。在实际应用中,需要根据具体的安全需求选择合适的 CFI 变体,并添加更复杂的 CFI 检查指令。
总结与展望
我们讨论了 JIT 编译带来的安全风险,以及如何利用 LLVM 的控制流完整性 (CFI) 保护机制来保护 JIT 生成的汇编代码。CFI 是一种有效的安全防护技术,它可以防止攻击者篡改程序的控制流。然而,CFI 也存在一些局限性,需要不断地改进和优化。
未来,我们可以从以下几个方面进一步研究 CFI 技术:
- 细粒度 CFI: 传统的 CFI 技术通常只能保护函数级别的控制流,无法保护指令级别的控制流。细粒度 CFI 可以提供更精细的控制流保护,从而提高程序的安全性。
- 数据流完整性 (DFI): DFI 可以保护程序的数据流,防止攻击者篡改程序的数据。DFI 可以与 CFI 结合使用,提供更全面的安全保护。
- 自动化 CFI 工具: 可以开发自动化 CFI 工具,简化 CFI 的应用过程,降低 CFI 的使用门槛。
通过不断地研究和改进 CFI 技术,我们可以有效地保护 JIT 生成的汇编代码,提高程序的安全性。感谢各位的聆听。