Chrome 1-Day漏洞挖掘与利用:CVE-2020-15999深度解析

本文详细分析了Chrome中CVE-2020-15999漏洞的利用过程,涉及Freetype库的堆缓冲区溢出、TCMalloc堆管理机制、WebSQL内存布局操控,以及通过构造虚假对象实现任意读和执行流控制的完整攻击链。

Chrome 1-Day Hunting - Uncovering and Exploiting CVE-2020-15999

引言

本文详细介绍了在Linux版Google Chrome 86.0.4222.0中利用CVE-2020-15999漏洞的过程。虽然CVE-2020-15999是字体加载库Freetype中的基于堆的缓冲区溢出漏洞而非Chrome本身的问题,但由于Chrome广泛使用该库,我们能够在浏览器渲染器中实现代码执行。本文重点不在漏洞分析,而在其利用过程,因为详细的解释和分析可在此处找到。本质上,包含位图(即栅格图像)的Truetype字体文件将其存储在字体的sbix表中。当Freetype加载sbix表中尺寸超过int16限制的嵌入式PNG图像时,会发生整数溢出到缓冲区溢出(IO2BO)。可在本文最后部分找到在渲染器中实现代码执行并弹出计算器的PoC。

漏洞详情

在开始理解利用过程之前,必须强调漏洞的能力和限制。对于此漏洞,由于我们控制嵌入式PNG文件的尺寸,因此可以控制发生溢出的堆缓冲区的大小。虽然PNG数据可以通过ADAM7隔行扫描以非线性(即非连续写入)方式写入位图缓冲区,但我们的漏洞利用不涉及隔行扫描,使得缓冲区溢出本质上是从任意大小缓冲区进行的线性缓冲区溢出。

1
2
3
4
5
6
7
8
// pngshim.c
if ( bitdepth != 8                          	||
	!( color_type == PNG_COLOR_TYPE_RGB   	||
   	color_type == PNG_COLOR_TYPE_RGB_ALPHA ) )
{
  error = FT_THROW( Invalid_File_Format );
  goto DestroyExit;
}

加载嵌入式PNG文件时,Freetype强制要求图像必须是位深度为8的RGB或RGBA图像,这意味着每个像素作为位图数据写入时占用4字节,因此将我们的缓冲区溢出粒度限制为4字节。

 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
// pngshim.c
static unsigned int
multiply_alpha( unsigned int  alpha,
          	unsigned int  color )
{
  unsigned int  temp = alpha * color + 0x80;
  return ( temp + ( temp >> 8 ) ) >> 8;
}
// 省略...
static void
premultiply_data( png_structp	png,
            	png_row_infop  row_info,
            	png_bytep  	data )
{
  unsigned int  i = 0, limit;
  // 省略...
  limit = row_info->rowbytes;
  for ( ; i < limit; i += 4 )
  {
    unsigned char*  base  = &data[i];
    unsigned int	alpha = base[3];
    if ( alpha == 0 )
      base[0] = base[1] = base[2] = base[3] = 0;
    else
    {
      unsigned int  red   = base[0];
      unsigned int  green = base[1];
      unsigned int  blue  = base[2];
      if ( alpha != 0xFF )
      {
        red   = multiply_alpha( alpha, red   );
        green = multiply_alpha( alpha, green );
        blue  = multiply_alpha( alpha, blue  );
      }
      base[0] = (unsigned char)blue;
      base[1] = (unsigned char)green;
      base[2] = (unsigned char)red;
      base[3] = (unsigned char)alpha;
    }
  }
}
// 省略...
case PNG_COLOR_TYPE_RGB_ALPHA:
  	png_set_read_user_transform_fn( png, premultiply_data );
  	break;

最后,我们发现Freetype在写入位图数据后应用了一个称为预乘alpha的预处理步骤。在此步骤中,像素的每个RGB值被重新计算为alpha通道(A)值的分数而非绝对值。例如,假设像素的R值为17,A值为30。由于8位整数的最大值为255,像素的R值将被重新计算为17/255 * 30 = 2。再举一例,RGBA值0x41ffffff在此步骤后将转换为0x41414141。这意味着我们的缓冲区溢出内容将被此步骤修改,使其遵循每4字节中最高有效字节是4字节中最大值的格式,因此限制了我们可以写入的内容。据我们所知,无法在Freetype中禁用预乘alpha。

总之,利用此漏洞,我们可以从任意大小的堆缓冲区引起线性缓冲区溢出,但对缓冲区内容有一些限制。

TCMalloc

与大多数用C编写的项目一样,Freetype项目使用malloc类函数进行动态内存管理。在Chrome中,对这些函数的调用被路由到Google的堆实现,称为TCMalloc。TCMalloc是Chrome内部使用的多种堆实现之一,并不用于大多数内存分配(例如Javascript)。因此,我们需要选择破坏Freetype的内存结构(毕竟它们在同一堆上)或找到Chrome中另一个也使用TCMalloc堆的组件。在我们的案例中,前者不是合适的选择,因为缓冲区溢出发生在字体加载过程的早期,因此没有机会创建合适的堆布局。因此,我们决定选择第二种方法,即在Chrome中找到另一个目标组件。我们选择的目标是HTML5 WebSQL,因为通过从Javascript发出SQL语句可以轻松控制堆布局。请注意,WebSQL是通过在Chrome中嵌入开源sqlite3引擎实现的,因此本文中这两个术语将互换使用。

在我们进入实际利用之前,有必要了解TCMalloc背后的机制。由于官方指南在此处的解释比我所能做的要好得多,我仅假设读者已阅读该指南进行简要说明。为了避免因频繁分配较小尺寸类而产生的堆噪声,对于此利用,我们将使用尺寸类高于0x1000的块。当TCMalloc中的中间端没有更多块来服务我们的请求时,它将请求span以分解为单个块,从而给我们提供可以溢出的连续块。

利用

大纲/摘要

对于此漏洞利用,大致思路是通过制造SQLite依赖的虚假内存结构来泄漏堆指针、获得任意读和执行流控制。

以下是漏洞利用的简要TLDR:

  • 通过覆盖SQLite列名的NULL字节的NULL终止符,我们获得OOB读,帮助泄漏指向堆块的指针
  • 我们在先前提到的堆块中制造一个虚假对象(Expr),并使用部分覆盖将列的默认值指针指向堆块。由于Expr对象包含我们控制的字符串指针,向具有默认值的列插入行会插入从我们控制的字符串指针读取的字符串,从而给我们任意读
  • 我们创建堆布局,使得可以使用任意读泄漏Chrome的基地址
  • 我们在同一堆块中制造一个虚假vtable对象,并使用部分覆盖指向它。在结束SQL事务时,SQLite将调用虚假vtable中的方法进行清理,从而给我们执行流控制

现在,让我们继续细节。

初始泄漏

从此处极其有用的指南中,我们了解到表和列对象在WebSQL中的SQL事务之间保留在内存中。如果我们创建一个包含0xff0字符的单列表,WebSQL将分配一个0x1000大小的块。此块直到我们删除表才会被释放。

因此,我们通过创建3个表,每个表有一个略低于0x1400字节的单列,分配3个大小为0x1400的块。根据TCMalloc的计算,大小为0x1400的块位于大小为0x4000的span中,意味着只有3个块可以适合一个span。以大约1/3的几率,这3个分配将落入同一span,从而使它们相邻,允许缓冲区溢出。

为了执行实际的缓冲区溢出,还存在另一个障碍:WebSQL和Chrome中的字体加载由不同的线程完成。事实上,Chrome有一个名为CompositorTileW的渲染器工作线程池,其中任何一个线程都可以在请求时加载我们的字体。由于线程缓存隔离了线程之间的块,渲染器工作线程无法使用WebSQL线程释放的块,反之亦然。

1
2
3
4
5
6
7
8
// thread_cache.cc
void ThreadCache::ListTooLong(FreeList* list, uint32 cl) {
  size_ += list->object_size();
  // 省略...
  if (PREDICT_FALSE(size_ > max_size_)) {
    Scavenge();
  }
}

为了将空闲块从WebSQL线程缓存传递到其他线程,我们将触发两次对函数ThreadCache::Scavenge的调用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// thread_cache.cc
void ThreadCache::Scavenge() { // 为便于阅读而缩短
  for (int cl = 0; cl < Static::num_size_classes(); cl++) {
    FreeList* list = &list_[cl];
    const int lowmark = list->lowwatermark();
    if (lowmark > 0) { // (2)
      const int drop = (lowmark > 1) ? lowmark/2 : 1;
      ReleaseToCentralCache(list, cl, drop);
    }
    list->clear_lowwatermark(); // (1) void clear_lowwatermark() { lowater_ = length_; }
  }
  IncreaseCacheLimit();
}

在第一次调用ThreadCache::Scavenge时,函数将线程缓存中每个空闲列表的lowater_成员变量设置为空闲列表上的块数(1)。在后续调用中,它将从每个空闲列表释放lowater_块的一半到中央空闲列表(2),其中块对所有线程可用。如果我们的目标块在WebSQL的线程缓存上,触发两次对Scavenge的调用将允许从字体渲染器线程访问该块。

另一个需要注意的问题是,在整个漏洞利用过程中,每次我们想要执行缓冲区溢出时,我们重复先前提到的步骤将新块放入中央空闲列表,而不是使用我们已经溢出的块。这是因为当我们加载字体时,线程池中的一个渲染器线程将从中央空闲列表检索该块作为位图缓冲区,这样做时,该块现在将最终进入该特定渲染器的线程缓存,与其他渲染器线程隔离。如果我们加载另一个字体文件,我们尚未找到方法确保同一渲染器线程会加载字体,从而给我们的漏洞利用带来必要的不可预测性。

最简单的方法是通过列名分配大量内存,然后删除表以释放它们,快速达到触发Scavenge所需的内存限制。

使用先前描述的技术,我们将标记为Column 1的块释放到中央空闲列表,随后加载我们第一个精心制作的字体。此字体具有尺寸为0x10007 * 0xb6的嵌入式PNG,但仅保存尺寸为15 * 0xb6的图像数据。尽管存在差异,libpng仍将可用数据量加载到内存中。不协调的尺寸将导致渲染器将0x2aa8(4 * 15 * 0xb6 = 0x2aa8)字节写入最初由列1占用的块(7 * 0xb6 * 4 = 0x13e8),溢出到列2和列3的全部内容。由于溢出覆盖了列2的NULL字节(SQLite中的列名是NULL终止字符串),当SQLite尝试读取列2时,它还将读取列3的内容,直到找到后者的终止NULL字节。

现在,我们删除包含列3的表以释放其块。这样做时,列3的前16字节分别更改为掩码的前后指针,将其链接到线程缓存空闲列表。然而,由于它当前是空闲列表上的唯一块,其前后指针均为NULL(但与掩码值XOR),因此不产生有用信息。为了补救这一点,我们在释放列3之前分配并释放列4,以便列3的前向指针指向有意义的内存位置。

泄漏列2的值 presents yet another challenge as there appeared to be no trivial way to show the column name in memory with available SQL statements. The solution turned out to be extremely simple: running ‘SELECT * FROM table’ will provide the full desired column name within the error message returned.

1
2
3
4
// sqlite3.c中的注释
/* For every "*" that occurs in the column list, insert the names of
  ** all columns in all tables.  And for every TABLE.* insert the names
  ** of all columns in TABLE.

这背后的原因是SQLite将SQL语句中的星号扩展为表中列名的完整集合,随后对同一组列进行数据库查找。

1
2
3
4
5
6
// sqlite3.c
hCol = sqlite3StrIHash(zCol); // hCol现在是列2 + 溢出数据的哈希
for(j=0, pCol=pTab->aCol; j<pTab->nCol; j++, pCol++){
  if( pCol->hName==hCol && sqlite3StrICmp(pCol->zName, zCol)==0 ){
      // 列名匹配成功
      // 从这里我们继续使用找到的列

在查找过程中,SQLite对语句中的列名进行哈希处理,并将其与内存中存储的哈希进行比较。由于我们的缓冲区溢出仅更改列2的内容(而非其哈希存储在内存中其他地方),查找将引发带有扩展SQL语句的错误消息。泄漏内容后,我们对列3的前两组8字节进行XOR操作,以获得指向列4的指针泄漏。

任意读

随着列4的内存位置泄漏,是时候获得任意读原语了。获得任意读——以及随后的执行流控制——背后的思路基本相同:由于我们知道列4的内存位置,我们可以在列4中制造一个虚假对象,随后覆盖另一个指针指向列4的虚假对象以影响程序行为。

对于任意读,我选择为列的pDflt字段制造一个虚假的Expr对象。pDflt字段是一个Expr对象,包含指向表示列默认值的字符串的指针。通过制造一个具有指向我们选择地址的字符串指针的虚假Expr对象,我们可以使用INSERT … DEFAULT VALUES语句读取该位置的数据。

覆盖pDflt字段并非易事:列对象固定为小尺寸,意味着它们容易受到堆噪声的影响。为了解决这个问题,我们依赖一个有趣的事实:表的列数组存储为对象数组而非指向对象的指针。

通过将我们的缓冲区对齐在列对象数组之前,我们可以溢出到第一个列对象的初始字段,并部分覆盖pDflt指针的低32位以指向列4。然而,由于缓冲区溢出是线性的,我们最终完全覆盖了zName字段。在这种情况下,我选择用由于预乘alpha预处理而固定的vsyscall内存位置覆盖它。

泄漏Chrome基地址

有了任意读,我们所能做的仍然有限,因为我们缺乏有用的内存位置来读取。泄漏列4的内存位置仅揭示了列4所在span的内存位置,我们不能简单地读取span相邻的位置,因为由于堆随机性,内容不固定。我们可以通过读取列4的前后指针并遍历空闲列表来泄漏更多内存位置,但即使使用这种方法,我们也只能泄漏尺寸类为0x1400的块的内存位置。为了解决这个问题,我们需要块包含指向其他尺寸类块的指针。实现这一点的一种方法是创建具有任意大小字符串作为名称的列数组。

通过使用我们的任意读原语跟随块中列的zName字段,我们现在可以泄漏任何尺寸类块的位置。通过解引用堆上的db->aVTrans数组(稍后提到),我们获得SQLite虚拟表(fts3Module)的函数表的内存位置。由于此函数表位于Chrome可执行内存空间中,我们可以通过减去一个偏移量来简单获取Chrome二进制文件的基地址,为漏洞利用的下一部分铺平道路。

执行流控制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// sqlite3.c
VTable **aVTrans;         	/* 具有打开事务的虚拟表 */
// 省略...
static void callFinaliser(sqlite3 *db, int offset){
  int i;
  if( db->aVTrans ){
    VTable **aVTrans = db->aVTrans;
    db->aVTrans = 0;
    for(i=0; i<db->nVTrans; i++){
      VTable *pVTab = aVTrans[i];
      sqlite3_vtab *p = pVTab->pVtab;
      if( p ){
        int (*x)(sqlite3_vtab *);
        x = *(int (**)(sqlite3_vtab *))((char *)p->pModule + offset);
        if( x ) x(p); // <- 给出调用位置和第一个参数
      }
      pVTab->iSavepoint = 0;
      sqlite3VtabUnlock(pVTab);
    }
    sqlite3DbFree(db, aVTrans);
    db->nVTrans = 0;
  }
}

获得执行流控制的方法与获得任意读大致相似,但这次,我们不是溢出到列对象数组,而是溢出到db->aVTrans,即具有打开事务的虚拟表指针数组。在结束每个END TRANSACTION语句时,sqlite3函数callFinaliser将检查aVTrans中的每个虚拟表,并调用其函数表中的方法来执行数据提交。

通过将虚拟表指向列4与另一个虚假对象,我们可以控制调用的提交方法的地址,以及其第一个参数。对于最终利用,我选择调用setcontext+offset,它设置第一个参数指向位置中存储的几乎所有寄存器。我使用它用mprotect将堆的内存权限更改为RWX,随后使用jmp rsp小工具跳转到堆上的shellcode。

挑战

在本节中,我们将讨论详细利用方法的一些问题以及一些可以进行的潜在改进。

堆布局可靠性

此利用方法更明显的挑战之一是实现我们想要的堆布局的可靠性问题。例如,在漏洞利用的第一部分,为了获得初始泄漏,我们提到有大约1/3的几率3个块将完美落入一个span。在其他情况下,块可能落入不同的span,而我们0x2aa8字节的过度溢出通常会导致Chrome的其他(随机)组件崩溃。堆的这种明显随机性并非TCMalloc有意为之(与Windows不同),很可能是

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