JIT生成的汇编代码安全:利用LLVM的Control Flow Integrity (CFI) 保护机制

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 的基本原理是:

  1. 建立控制流图 (CFG): 在编译时,编译器会分析程序的源代码,建立程序的控制流图。控制流图描述了程序中所有可能的控制流路径。
  2. 插入检查指令: 在运行时,程序会在关键的控制流转移点 (例如函数调用、函数返回、跳转指令) 插入检查指令。这些检查指令会验证当前的控制流是否符合预期的控制流路径。
  3. 强制执行控制流: 如果检查指令发现当前的控制流不符合预期的控制流路径,程序会立即终止执行,从而防止攻击者篡改程序的控制流。

举例来说,考虑以下 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 保护,我们需要执行以下步骤:

  1. 编译 C 代码到 LLVM IR:

    clang -emit-llvm -S -o test.ll test.c
  2. 添加 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"}
  3. 插入 CFI 检查指令:

    bar 函数调用 func 函数之前,我们需要插入 CFI 检查指令,验证 func 函数的类型是否为 func_ptr_t。 这部分逻辑在上面的llvm代码里已经包含, 即 !type !0

  4. 编译 LLVM IR 到汇编代码:

    llc -o test.s test.ll
  5. 编译汇编代码到可执行文件:

    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 生成的汇编代码,提高程序的安全性。感谢各位的聆听。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注