使用多级IR和VAST在C代码中寻找漏洞

本文介绍了VAST工具如何利用多级中间表示(IR)和MLIR技术,在不同抽象层次进行程序分析,有效检测C代码中的缓冲区溢出等安全漏洞,并通过实际案例演示了Sequoia漏洞的检测过程。

使用多级IR和VAST在C代码中寻找漏洞

中间表示(IRs)是逆向工程师和漏洞研究人员用来看清全局的工具。IRs用于在不同抽象层查看程序,使分析人员能够理解低级代码异常和高级逻辑错误。但问题是,漏洞查找工具往往局限于选择特定的IR,因为漏洞并非均匀存在于所有抽象层级。

我们开发了一个名为VAST的新工具,通过提供"IR塔"来解决这个问题,允许程序分析从最适合分析目标的表示开始,然后根据需要向上或向下工作。例如,分析人员可能希望对基于栈的缓冲区溢出执行以下三种操作之一:(1)识别它。(2)分类它。(3)修复它。

现在需要选择正确的IR。某些漏洞属性只在特定抽象层级才明显。在LLVM IR中很容易识别缓冲区溢出,因为LLVM IR中的栈缓冲区具有高度特征性(即通过alloca指令创建)。这是用于识别的最佳IR。

对于分类,如果缓冲区位于程序内存中的敏感数据附近,缓冲区溢出可能从常见错误变成安全威胁。这只有在LLVM IR级别以下,接近或处于机器代码级别时才变得清晰,此时缓冲区与其他敏感信息融合在一起,形成"栈帧"。

最后一部分是沟通和修复。缓冲区溢出的原因可能是缓冲区索引类型转换的副作用,这在程序的抽象语法树(AST)(最高级别的IR)中是显而易见的。过去无法将这些事实联系起来,但VAST的IR塔正在改变这一点。漏洞跨越语义鸿沟,分析也应该如此。

VAST的中间表示塔

单个系统如何跨越这个所谓的语义鸿沟?VAST工作的关键是MLIR:多级中间表示项目。MLIR是一个与LLVM相关的基础设施项目,使领域特定语言和IR的开发更加容易。它提供了一个框架来有效描述操作和类型,并将它们分组为"方言"。方言就像嵌入式语言,可以混合和匹配。想象一下如果LLVM允许你添加新指令,这就是方言的力量!MLIR提供了基于规则的方言转换、模式匹配和其他功能的实用程序。

VAST使用MLIR构建"IR塔",其中每个塔级是一个MLIR方言,对应于C/C++编译过程中的抽象级别。我们的目标是进行下一代程序分析,但归根结底,VAST只是Clang的一个新编译器中端。它消耗Clang抽象语法树(AST)并生成LLVM IR。随着进一步发展,我们可以将其用作Clang的替代品并进行实时测试。

我们将通过使用VAST的高级(hl)方言为Sequoia漏洞编写一个简单的检查器来展示VAST和MLIR的功能。该漏洞是由用于确定缓冲区大小的整数值溢出引起的。当无符号整数在函数调用之前隐式转换为有符号整数时,会发生整数溢出。我们查找漏洞的方式将模仿Jordy Zomer的《‘Sequoia’漏洞变体分析》文章中介绍的CodeQL查询。

编写基于VAST的漏洞检查器

如果您想自己尝试这个示例,我们已经提供了代码。

我们从包含漏洞的代码开始。在Linux内核中发现的一个特定Sequoia漏洞变体中,函数seq_buf_path调用d_path,将无符号的size_t size值传递给有符号参数int buflen。然后使用int buflen参数计算通过DECLARE_BUFFER宏声明的struct prepend_buffer __name的大小。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#define DECLARE_BUFFER(__name, __buf, __len) \
    struct prepend_buffer __name = {.buf = __buf + __len, .len = __len}

char *d_path(const struct path *path, char *buf, int buflen)
{
    DECLARE_BUFFER(b, buf, buflen);
    ...
}

int seq_buf_path(struct seq_buf *s, const struct path *path, const char *esc)
{
    char *buf;
    size_t size = seq_buf_get_buf(s, &buf);
...
    if (size) {
        char *p = d_path(path, buf, size);
        ...
    }
...
}

来自编写LLVM工具的背景,最好的起点是一个简单的MLIR分析过程。VAST附带vast-opt,类似于LLVM的opt工具,允许在.mlir文件上运行过程。因此将vast-opt复制到main中,删除所有不需要的内容。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
auto main(int argc, char** argv) -> int
{
  // 注册方言
  mlir::DialectRegistry registry;
  vast::registerAllDialects(registry);
  mlir::registerAllDialects(registry);

  register_sequoia_checker_pass();

  return mlir::failed(
        mlir::MlirOptMain(argc, argv, "VAST Sequoia Bug Checker\n", registry));
}

接下来,我们基于MLIR过程基础设施文档创建一个简单的"Hello World"过程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
struct sequoia_checker_pass
    : public mlir::PassWrapper<sequoia_checker_pass,
             mlir::OperationPass<mlir::ModuleOp>>
{
  auto getArgument() const -> llvm::StringRef final { return "sequoia"; }

  auto getDescription() const -> llvm::StringRef final
  {
    return "Checks for the sequoia bug in VAST hl dialect code";
  }

  void runOnOperation() override
  {
    llvm::errs() << "Hello World!" << '\n';
  }
};

void register_sequoia_checker_pass()
{
  mlir::PassRegistration<sequoia_checker_pass>();
}

下一步是获取输入.mlir文件。幸运的是,VAST还附带vast-front,这是VAST及其方言的C/C++前端。将有问题的Linux代码提取到extract.c中,运行vast-front,得到extract.hl.mlir,然后输入到vast-checker。类似Opt的工具通常会输出其管道中的代码,无论是否更改。代码中没有什么有趣的内容,因此可以管道到/dev/null。现在工具管道已设置好。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$ vast-front -vast-emit-mlir=hl -o extract.hl.mlir extract.c
$ cat extract.hl.mlir
...
hl.func external @seq_buf_path (%arg0: !hl.ptr<...> ...) -> !hl.int {
  %4 = hl.var "buf" : !hl.lvalue>
  %5 = hl.var "size" : !hl.lvalue> = {
    %14 = hl.ref %arg0 : !hl.ptr<...>
    %15 = hl.implicit_cast %14 LValueToRValue : !hl.lvalue<...> -> !hl.ptr<...>
    %16 = hl.ref %4 : !hl.lvalue>
    %17 = hl.addressof %16 : !hl.lvalue> -> !hl.ptr<...>
    %18 = hl.call @seq_buf_get_buf(%15, %17) : (!hl.ptr<...>, ...) -> ...
    hl.value.yield %18 : !hl.typedef<"size_t">
  } ...
}
...
$ vast-checker -sequoia extract.hl.mlir > /dev/null
Hello World!

hl方言MLIR代码紧密遵循Clang AST的结构。这是有意设计的,以便VAST无缝融入Clang的编译过程。然而,与Clang AST不同,MLIR具有单静态赋值(SSA)结构,这使得迭代使用定义链变得容易,并简化了数据流分析。

LLVM IR也是如此。然而,与LLVM IR不同,MLIR代码非常通用,每个操作和类型的语义都由方言作者定义,任何具有语义值的都是操作或类型。这对于MLIR模块和函数都是如此,并且与过程管理器如何运行MLIR过程有关。

为了使过程运行,我们使其在任何vast::hl::FuncOp实例上操作,这大致对应于C函数。为了提高效率,我们将过程限制在vast::hl::CallOp实例上运行,这对应于函数调用。这也将模仿查询的工作方式。

但在过程运行之前,我们必须认识到vast-checker中的MLIR过程管理器只对嵌套顶层的操作运行过程,但Sequoia MLIR代码在该级别只包含hl.typedef、hl.struct和hl.func操作。由于这种对操作嵌套的强调,MLIR过程管理器只允许过程在嵌套结构中位置事先已知的操作上运行。调用不是这样的操作,因为它们可以在循环和if条件中任意嵌套。

因此,最终过程在FuncOp上运行,在runOnOperation中我们遍历FuncOp中嵌套的操作。在遇到CallOp时触发提供给walk函数的回调。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
struct sequoia_checker_pass
    : public mlir::PassWrapper<sequoia_checker_pass,
             mlir::OperationPass<vast::hl::FuncOp>> {
...
void runOnOperation() override
{
  using vast::vast_module;
  using vast::hl::CallOp;

  auto fop = getOperation();

  auto check_for_sequoia = [&](CallOp call) {...};

  fop.walk(check_for_sequoia);
}
...
};

查看CodeQL查询,在定位调用后的首要任务是检查其任何参数是否是无符号到有符号转换的结果。这些转换可能会溢出并在被调用函数中引起问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
auto is_unsigned_to_signed_cast(mlir::Operation* opr) -> bool
{
  using vast::vast_module;
  using vast::hl::CastKind;
  using vast::hl::CStyleCastOp;
  using vast::hl::ImplicitCastOp;
  using vast::hl::TypedefType;
  using vast::hl::strip_elaborated;
  using vast::hl::getBottomTypedefType;
  using vast::hl::isSigned;
  using vast::hl::isUnsigned;

  auto check_cast = [&](auto cast) -> bool
  {
    if (cast.getKind() == CastKind::IntegralCast) {
      auto from_ty = strip_elaborated(cast.getValue().getType());
      if (auto typedef_ty = from_ty.template dyn_cast<TypedefType>()) {
        auto mod = mlir::cast<vast_module>(getOperation()->getParentOp());
        from_ty  = getBottomTypedefType(typedef_ty, mod);
      }
      return isUnsigned(from_ty) && isSigned(cast.getType());
    }
    return false;
  };

  return llvm::TypeSwitch<mlir::Operation*, bool>(opr)
        .Case<ImplicitCastOp, CStyleCastOp>(check_cast)
        .Default(/*defaultResult=*/false);
}

VAST API在这里为我们完成了大部分工作。我们根据类隔离转换操作,然后根据CastKind属性隔离整数转换。最后,我们测试操作数的有符号性。甚至typedef的使用也被API覆盖。

在发现调用使用可能溢出的转换后,是时候检查被调用函数体中的指针算术了。首先,我们编写一个小辅助函数从CallOp获取被调用函数。之后,has_ptr_arith_use执行CodeQL查询的数据流部分。它检查函数参数是否涉及指针算术。这将指示潜在的漏洞。为了进行此检查,我递归地迭代上述使用定义链,寻找任何指针类型操作数的算术运算。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static auto is_arith_op(mlir::Operation* opr) -> bool
{
  using vast::hl::AddIOp;
  using vast::hl::SubIOp;

  return llvm::TypeSwitch<mlir::Operation*, bool>(opr)
        .Case<AddIOp, SubIOp>([](mlir::Operation*) { return true; })
        .Default(/*defaultResult=*/false);
}

static auto has_ptr_operand(mlir::Operation* opr) -> bool
{
  using vast::hl::PointerType;

  auto is_ptr_type = [](mlir::Value val) -> bool
  { return val.getType().isa<PointerType>(); };

  return llvm::any_of(opr->getOperands(), is_ptr_type);
}

static auto has_ptr_arith_use(mlir::Operation* opr) -> bool
{
  if (opr == nullptr) {
    return false;
  }

  if (is_arith_op(opr) && has_ptr_operand(opr)) {
    return true;
  }

  return llvm::any_of(opr->getUsers(), has_ptr_arith_use);
}

一切就绪后,我添加了一个简单的打印来报告结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void runOnOperation() override
{
  ...
  auto check_for_sequoia = [&](CallOp call)
  {
    for (const auto& arg : llvm::enumerate(call.getArgOperands())) {
      if (is_unsigned_to_signed_cast(arg.value().getDefiningOp())) {
        auto mod    = mlir::cast<vast_module>(getOperation()->getParentOp());
        auto callee = get_callee(call, mod);
        auto param  = callee.getArgument(arg.index());
        if (llvm::any_of(param.getUsers(), has_ptr_arith_use)) {
          llvm::errs()
              << "Call to " << callee.getSymName() << " in "
              << fop.getSymName()
              << " passes an unsigned value to a signed argument (index "
              << arg.index()
              << ") and then uses it in pointer arithmetic.\n";
        }
      }
    }
  };
  ...
}

然后像之前一样运行检查器。但这次结果更有趣。

1
2
$ vast-checker -sequoia extract.hl.mlir > /dev/null
Call to `d_path` in `seq_buf_path` passes an unsigned value to a signed argument (index `2`) and then uses it in pointer arithmetic.

这里我们检测到了我们开始的Sequoia漏洞变体。

使用VAST在高、低和中间层级搜索漏洞

漏洞在某些抽象层比其他层更容易发现,这就是为什么我们正在进行的研究显示出巨大潜力。使用VAST,工具开发人员可以选择一个IR,将程序分析定制到适当的抽象层。我们邀请您跟随我们的示例分析Sequoia漏洞,并告诉我们您是否将其用于逆向工程项目。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计