周末模糊测试赚取20万美元:Solana rBPF虚拟机漏洞挖掘深度解析

本文详细分析了在Solana rBPF虚拟机中发现的两个高危漏洞:资源耗尽漏洞和.rodata持久化污染漏洞。通过模糊测试技术发现这些漏洞,涉及JIT编译器优化缺陷、内存管理错误和x86指令集处理问题,最终获得20万美元漏洞赏金。

周末模糊测试赚取20万美元:第二部分

以下是我在Solana rBPF中发现的两个漏洞的详细分析报告。Solana rBPF是一个自描述的"用于eBPF程序的Rust虚拟机和JIT编译器"。这些漏洞已根据Solana安全政策进行负责任的披露,并获得了工程师和Solana业务发展主管的发布许可。

在第一部分中,我讨论了模糊测试器的开发。这里,我将介绍我发现这些漏洞的过程以及向Solana报告的过程。

漏洞1:资源耗尽

我向Solana报告的第一个bug异常棘手;它只在高度特定的情况下发生,模糊测试器能够发现它证明了通过重复试验可以发现极其复杂的输入。

初步调查

触发崩溃的输入反汇编为以下汇编代码:

1
2
3
4
5
entrypoint:
  r0 = r0 + 255
  if r0 <= 8355838 goto -2
  r9 = r3 >> 3
  call -1

这个特定的指令集导致了内存泄漏。程序执行时大致进行以下步骤:

  1. 将r0(从0开始)增加255
  2. 如果r0小于或等于8355838,跳回前一条指令
  3. 与前一步结合,循环将执行32767次(共65534条指令)
  4. 将r9设置为r3 * 2^3,由于r3从零开始,结果为零
  5. 调用不存在的函数
  6. 不存在的函数应触发未知符号错误

这个特定测试案例的特别之处在于其极其特异性;即使将255或8355838的值稍微改变,泄漏就会消失。

有缺陷的优化

在jit.rs的第420行有一段文字描述了一个优化,Solana应用此优化以减少更新指令计数器的频率。

简而言之,他们只在到达块末尾或调用时更新或检查指令计数器,以减少更新和检查计数器的次数。这个优化是完全合理的;我们不关心在块中间耗尽指令,因为后续指令仍然是"安全的",而且如果我们遇到退出,那已经是块的末尾。换句话说,这个优化应该对程序的最终状态没有影响。

问题可以在漏洞的补丁中看到,维护者将第1279行移到了第1275行。

武器化

让我们仔细看看report_unresolved_symbol函数:

1
2
3
pub fn report_unresolved_symbol(&self, insn_offset: usize) -> Result<u64, EbpfError<E>> {
    // 函数实现细节
}

注意name是成为堆分配字符串的值。name的值由ELF中的重定位查找确定,如果我们编译自己的恶意ELF,实际上可以控制这个值。

制作恶意ELF

在BPF中创建未解析的重定位实际上非常简单。我们只需要创建一个具有非常非常长的函数名的函数,该函数只声明未定义。

我创建了两个文件来制作恶意ELF:

evil.h

1
2
#define EVIL do_evil_$(printf 'a%.0s' {1..1048576})
void EVIL();

evil.c

1
2
3
4
5
6
7
#include "evil.h"

void entrypoint() {
  asm(" goto +0\n"
      " r0 = 0\n");
  EVIL();
}

通过我们的恶意ELF,其函数名长达1MiB,report_unresolved_symbol会将name变量设置为长函数名。结果,每次执行都会泄漏整整1MiB的内存,而不是微不足道的7个字节。

报告

在制作好漏洞利用后,我于1月31日通过电子邮件提交了bug报告,Solana在短短三小时内就确认了这个bug。

Solana将此bug分类为拒绝服务(非RPC)并奖励了10万美元。

漏洞2:持久的.rodata污染

我报告的第二个bug很容易发现,但很难诊断。虽然bug发生频率很高,但不清楚到底是什么导致了bug。

初步调查

触发崩溃的输入反汇编为以下汇编:

1
2
3
4
5
entrypoint:
    or32 r9, -1
    mov32 r1, -1
    stxh [r9+0x1], r0
    exit

触发的崩溃类型是JIT与解释器退出状态的差异;JIT以Ok(0)终止,而解释器以以下错误终止:

1
Err(AccessViolation(31, Store, 4294967296, 2, "program"))

看起来我们的JIT实现有某种形式的越界写入。

x86的诅咒

让我们使payload更清晰并直接执行,然后将其放入gdb以查看JIT编译器生成的确切代码。

问题的根本原因在于X86Instruction::cmp_immediate函数中操作数大小处理错误。它创建了一个使用0x81操作码的x86指令,但该操作码只定义为16位、32位和64位寄存器操作数。如果要使用8位寄存器操作数,需要使用0x80操作码变体。

概念证明

现在创建一个PoC,以便检查bug的人员可以验证它。像上次一样,我们将创建一个ELF,以及几个演示bug效果的不同示例。

value_in_ro.c

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
typedef unsigned char uint8_t;
typedef unsigned long int uint64_t;

extern void log(const char*, uint64_t);

static const char data[] = "howdy";

extern uint64_t entrypoint(const uint8_t *input) {
  log(data, 5);
  char *overwritten = (char *)data;
  overwritten[0] = 'e';
  overwritten[1] = 'v';
  overwritten[2] = 'i';
  overwritten[3] = 'l';
  overwritten[4] = '!';
  log(data, 5);

  return 0;
}

这个程序执行时有以下输出:

1
2
3
4
5
6
7
8
howdy
evil!
evil!
evil!
evil!
evil!
evil!
evil!

报告

在组装好我的概念证明、影响分析等之后,我于2月4日向Solana提交了以下报告。

该bug在仅仅4小时内就被修补了。Solana将此bug分类为拒绝服务(非RPC)并奖励了10万美元。

奖金处理

Solana在处理我的付款方面表现出了极大的灵活性。我打算将资金捐赠给德克萨斯A&M网络安全俱乐部,我在那里获得了进行这项研究和这些漏洞利用所需的许多技能。Solana非常愿意绕过他们列出的政策,直接以美元捐赠资金,而不是让我自己处理代币,这由于税收原因会大大影响我可以捐赠的金额。

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