深入分析JavaScriptCore WebAssembly漏洞的利用方法

 在这篇文章中,我们将为读者深入介绍JavaScriptCore的WebAssembly子系统中的一个安全漏洞,JavaScriptCore是WebKit和Apple Safari浏览器中的JavaScript引擎。需要说明的是,这个漏洞已在Safari 14.1.1中得到了修复。实际上,这个漏洞是通过源代码审查发现的,并在Pwn2Own 2021中用于实现远程代码执行。在下一篇文章中,我们将详细介绍内核模式的沙箱逃逸技术。

下面是我们在Pwn2Own 2021期间成功利用该漏洞的一个演示视频:

https://blog.ret2.io/assets/img/p2o_2021_rce_demo.mp4

WebAssembly概述

WebAssembly,又称为wasm,是一种使用二进制表示法的类汇编语言,主要用于Web环境。与高度动态和复杂的JavaScript范式相比,WebAssembly显得异常简洁。WebAssembly仅有四种原始值类型(32/64位的整数和浮点数),以及一个相对较小的指令集,这些指令都是在栈式机(stack machine)上运行的。

像许多汇编语言一样,wasm可以用人类可读的文本格式手工编写。然而,现实世界的wasm应用程序通常是用编译器构建的。目前,越来越多的高级语言开始支持编译为wasm语言,像Emscripten这样的项目也开始支持基于LLVM的任意语言。

WebAssembly LLInt

当JavaScriptCore运行传统的JavaScript代码时,它利用了四级执行模式,以逐步优化代码。当引擎认为一个函数是“热”函数(如果该函数经常被调用的话,就会出现这种情况)的时候,或者包含一个迭代次数足够多的循环的时候,引擎就会将这个函数传递到下一个层级,以进行更深入的优化处理。

wasm执行流水线采用了类似的方法,不过它具有三个层级。对WebAssembly模块进行解析后产生字节码,将提供给一个解释器:wasm llint(低级解释器)。在llint之后,还有两个JIT(just-in-time)编译器:BBQ(Build Bytecode Quickly,快速构建字节码)编译器和OMG(Optimized-Machine code Generator,优化型机器码生成器)编译器。

1.png

三级wasm执行流水线

在本文中,我们只关注llint解释器;特别是解析过程和字节码生成过程。对于wasm模块的代码段中的每个函数,都会进行相应的解析处理,并一次性生成相应的字节码。同时,解析器的逻辑是通用的,并根据负责代码生成的上下文对象进行模板化。对于llint来说,这个对象就是一个生成字节码的LLIntGenerator对象。

解析过程与代码生成

解析器将负责验证函数的有效性,这将涉及所有堆栈操作和控制流分支的类型检查。Wasm函数都具有非常结构化的控制流,并以构造块的形式(可以是一个通用块,一个循环块,或一个if条件块)出现。块是嵌套的,而分支指令只能针对一个外围块(enclosing block)。从解析器的角度来看,每个块都有自己的表达式堆栈,与外围块的表达式堆栈是分开的。通过多值规范,每个块可以具有参数类型和返回类型的签名。参数从当前表达式堆栈中弹出,并用作新块堆栈的初始值;当分支出块时,返回值将被压入外围块的堆栈。

FunctionParser会跟踪控制栈和表达式栈上的类型。而LLIntGenerator则跟踪各种元数据,包括当前的整体堆栈大小(对各个块的堆栈大小进行汇总)和整个解析过程中出现的最大堆栈大小。当前的堆栈大小有助于将抽象的堆栈位置变成本地的堆栈偏移,而堆栈大小的最大值将决定在函数序言中保留多少堆栈空间。

让我们看一下一些简单的wasm函数的例子。实际上,这个函数不需要参数,并返回一个32位的整数。下面展示的是解析器/生成器在解析指令之前的状态。 1.png

根据调用惯例的规定,优先选用寄存器来传递参数,然后才选用堆栈进行传递。llint会为所有可能的参数寄存器保留堆栈槽,而不管函数是否接受那么多参数。在x86_64架构上,有2个调用方保存的寄存器,6个参数GPR和8个参数FPR,这就是为什么m_stackSize从16开始的原因。

m_expressionStack跟踪解释器堆栈上的类型(而不是值;这里只是解析,不是执行)。在这个例子中,压入了一个i32: 1.png

然后,再压入一个i32:

1.png

xor指令将弹出前两个i32操作数,并压入一个i32结果值:

1.png

现在来看看一个使用块的例子。这个函数不需要参数,并返回一个64位的整数。

初始状态:1.png

一个i32值被压入,从而增加了当前堆栈的大小:

1.png

当我们到达这个块时,系统将创建一个新的表达式堆栈,并将所有参数(在本例中是一个i32值)从当前堆栈中弹出并压入新的堆栈中。然后,一个控制堆栈条目被创建,并指向当前堆栈(内层块的堆栈),新堆栈成为当前堆栈。当前的堆栈大小不会发生改变。

1.png

转换指令将从堆栈中弹出i32,并压入一个i64值:

1.png

当这个块结束时,其返回类型被移到内层的堆栈上,控制条目被弹出,内层的堆栈成为当前堆栈(在某种意义上讲,控制堆栈就是一个堆栈的堆栈):

1.png

漏洞分析

m_maxStackSize字段的用途,就是记录函数执行过程中所需的最大堆栈槽数。因此,它会经常进行更新,主要是在每次向表达式堆栈压入数据时:

    ExpressionType push(NoConsistencyCheckTag)
    {
        m_maxStackSize = std::max(m_maxStackSize, ++m_stackSize);
        return virtualRegisterForLocal(m_stackSize - 1);
    }

当解析完成后,进行必要的处理,以满足堆栈对齐要求(16字节对齐),并将其存储到生成的FunctionCodeBlock的m_numCalleeLocals字段中:

    std::unique_ptr
    {
        ...
        m_codeBlock->m_numCalleeLocals =
            WTF::roundUpToMultipleOf(stackAlignmentRegisters(), m_maxStackSize);
        ...
    }

当函数被实际调用时,llint序言将使用m_numCalleeLocals来确定栈帧大小(即sub rsp, 0x...),以及它是否大到足以触发堆栈溢出异常:

    macro wasmPrologue(codeBlockGetter, codeBlockSetter, loadWasmInstance)
        ...
 
        # Get new sp in ws1 and check stack height.
        loadi Wasm::FunctionCodeBlock::m_numCalleeLocals[ws0], ws1
        lshiftp 3, ws1
        addp maxFrameExtentForSlowPathCall, ws1
        subp cfr, ws1, ws1
 
        bpa ws1, cfr, .stackOverflow
        bpbeq Wasm::Instance::m_cachedStackLimit[wasmInstance], ws1, .stackHeightOK
 
    .stackOverflow:
        throwException(StackOverflow)
 
    .stackHeightOK:
        move ws1, sp
        ...

只要压入操作的次数足够多,m_maxStackSize最终将被设置为UINT_MAX,或者0xffffffff。当解析完成后,LLIntGenerator::finalize会将最大堆栈尺寸向上舍入,以便对齐:将0xffffffff向上舍入到2的倍数会引起整数溢出,从而变成0。

这将导致m_numCalleeLocals的值为0,而这个值决定了函数序言期间的栈帧大小。因此,调用该函数时,实际上并没有为栈帧分配任何空间,也不会触发堆栈溢出异常。该函数实际上可以随意使用堆栈槽,而llint认为没有必要为栈帧分配内存空间……

触发漏洞

为了触发这个漏洞,我们需要创建一个wasm函数,执行大约2^32次压入操作。当然,不排除还有触发该漏洞的其他方法,但最终还是需要借助于滥用多值规范和解析器对不可达代码的处理方式。

多值规范允许块具有任意数量的返回值,而JavaScriptCore并没有规定相应的上限。也就是说,我们能够创建具有大量返回值的块。

对于执行不到的代码,解析器会进行一些非常基本的分析,以确定代码到底是不可达的,还是僵尸代码。例如,一个显式的不可达操作码或一个无条件分支使其后面的代码(在同一块内)不可达。当具有不可达代码的块结束时,生成器的操作就好像该块格式良好一样,并将声明的返回类型压入内层的堆栈。

在某些情况下,这可能是必需的行为:if-else的一个分支抛出不可达异常,而另一个分支的行为正常。另外,以返回值类型检查失败为由拒绝函数为无效是错误的,因为异常无论如何都会从函数中跳出来。

无论如何,我们可以用以下模式滥用这种行为:

    ;; "real" code we want to execute can be placed here
    block [signature with N ret values]
        unreachable
    end
    ;; the unreachable block ends, N types are pushed onto the stack
    ;; parsing continues as if the subsequent code was reachable

这使得我们可以用很少的实际代码将相当多的值压入解析器的表达式堆栈中。

为此,我们首先想到的做法就是直接将这种模式串联起来,从而实现N次压入操作,但是这种做法实际上是不可行的,因为表达式堆栈是用WTF::Vector实现的,它提供了一个32位的长度字段,同时,还会对调整长度的操作进行适当的检查,以确保分配的内存长度不超过32位。该向量(vector)的元素是TypedExpression对象,其长度为8,这意味着堆栈大小的上限为2^32 / 8 = 2^29 = 0x20000000。另外,调整长度时,也未必严格按照2的n次幂来进行调整,所以,实际的上限还要小一些。

为了解决这个问题,我们可以使用嵌套块,因为在前面的例子中看到,每个嵌套块都会有自己的表达式堆栈,也就是自己的向量。

    ;; "real" code we want to execute can be placed here
    block [signature with N ret values]
        unreachable
    end
    ;; current stack has N values, maximum is N
    block
        ;; new block has an empty expression stack
        block [signature with N ret values]
            unreachable
        end
        ;; current stack has N values, maximum is 2N
        block
            ...
        end
    end

通过使每个块都具有0x10000000个返回值,并嵌套16个这样的块,我们可以将m_maxStackSize设置为0xffffffff,一旦解析完成就会发生溢出。

每个向量将占用大约2GB空间,共有16个向量,所以,它们总共占用32GB内存空间。这可能看起来有点不切实际,但凭借macOS在压缩内存方面的魔力,分配和使用所有这些内存大约只需要2.5分钟(当然,具体时间会因硬件而异),这完全在Pwn2Own的时间限制内,即每次进行漏洞利用的时长为5分钟。

实现信息泄漏

如果将m_numCalleeLocals设置为0,那么,执行wasm函数时,llint就不会对栈帧进行递减操作,这样的话,将导致以下堆栈布局:

            | ...            |
            | loc1           |
            | loc0           |
            | callee-saved 1 |
            | callee-saved 0 |
rsp, rbp -> | previous rbp   |
            | return address |

正如之前简单提过的,loc0到loc13将由6个GPR和8个FPR组成,这些都是调用惯例指定的潜在参数,所以为了访问loc0和loc1,我们需要接受两个i64参数。

在llint中,某些操作被指定为慢速路径,并通过调用本地C++代码来实现这些操作。在慢速路径处理过程中发生的任何本地堆栈压入操作,都有可能覆盖被调用方保存的寄存器和本地寄存器,具体如上图所示。我们的目标,就是选择一个这样的慢速路径,使其调用将用代码地址和堆栈地址覆盖loc0和loc1。然后,在从慢速路径返回时,我们可以“正常”使用本地变量来对泄漏的数据进行运算。

在这里,我们将调用slow_path_wasm_out_of_line_jump_target,因为“Out-of-line”跳转目标适用于偏移量过大,无法直接用字节码格式编码的分支。在我们的例子中,只要偏移量不低于0x80,就能满足我们的要求:

    block
        ;; branch out of block
        ;; an unconditional `br 0` will not work as the filler would be dead code
        i32.const 1
        br_if 0
        ;; filler code here...
        ;; such that the offset from the above branch
        ;; to the end of the block is >= 0x80
    end

上述代码模式将执行对slow_path_wasm_out_of_line_jump_target的本地调用,具体如下所示:

 1.png

现在,在loc0中有一个返回地址,它将指向JavaScriptCore dylib,同时,在loc1中有一个堆栈地址,为我们提供了实现远程代码执行所需的信息泄露功能。

当然,也许其他的慢速路径处理程序也能很好地工作;我们之所以选择这个路径,是因为它非常简单,换句话说,利用它实现的exploit在不同的WebKit版本上正常工作的可能性要更大一些。

绕过防护页面机制

记住,我们可以执行的函数并没有为任何基于堆栈的操作分配栈帧。例如,一个压入操作可能会在rbp-0x40处写入本机堆栈,而随后的压入操作则可能在rbp-0x48处执行写入操作,以此类推,这里并没有相应的约束。所以,从理论上说,这个函数应该能够对界外堆栈槽(有大的负偏移量,例如rbp-0x10000)执行写入操作。这样的话,我们就能够覆盖当前堆栈下面的任何内存。

这一点在主线程的上下文中没有太大的帮助,因为主线程的堆栈下面并没有进行任何映射(至少,缺乏可靠和已知的偏移量)。然而,线程的堆栈是在专用虚拟内存区域中连续递增的地址上连续分配的。例如:

STACK GUARD   70000b255000-70000b256000 [ 4K   ] ---/rwx stack guard for thread 1
Stack         70000b256000-70000b2d8000 [ 520K ] rw-/rwx thread 1
STACK GUARD   70000b2d8000-70000b2d9000 [ 4K   ] ---/rwx stack guard for thread 2
Stack         70000b2d9000-70000b35b000 [ 520K ] rw-/rwx thread 2

假设有问题的wasm函数在线程2中执行,那么,线程1的堆栈就会成为破坏的目标。现在,利用该漏洞的唯一障碍就是内存的防护页……幸运的是,llint在原始优化方面还有许多小窍门可资利用。

当压入一个常量值时,生成器实际上并没有发出指令将常量值写入堆栈槽。相反,它将常数添加到一个“常数池”中,随后针对该堆栈槽的任何读取操作,都将从常数池而不是堆栈中获取相应的数据。任何对堆栈槽的写入操作,实际上也都是对这个常数池执行写入操作。对于某些控制流来说,常量也可以被“具体化()”(显式地将常量值写入堆栈),但是通过去控制流来避免这种情况也不是什么难事。

为了阐释这一点,请看下面的代码:

    i32.const 1
    i32.const 2
    i32.const 3
    i32.add

在执行上述代码的过程中,写入本机堆栈的唯一值是5,即3+2的计算结果。

这种行为将使我们能够通过压入大量无用的常量来轻松绕过防护页面。

ROP

通过覆盖受害线程堆栈上的值(这是一种不太常见的浏览器漏洞利用技术),我们可以立即获得ROP。这样做的好处是,既不需要逐步建立越来越强大的原语,也不需要addrof或fakeobj,只需要一个过时的ropchain即可。

由于我们泄漏的指针存储在本地文件中,因此,对应的gadget将如下所示:

    local.get 0 ;; JavaScriptCore dylib address
    i64.const
    i64.add ;; the addition will write the gadget to the stack

同样的,对目标堆栈地址进行写入操作时,可以将local.get 1用作基址。写入常数时,可以用0进行逐位或运算来完成。

为了执行shellcode,需要让ropchain执行一些非常重要的工作。在启用SIP的情况下,只有在用mmap创建页面时指定了一个特殊的标志MAP_JIT(0x800),才允许对该页面进行rwx保护。由于线程堆栈没有用这个标志进行映射,所以,我们无法直接为堆栈上shellcode设置相应的保护权限并返回到这些代码所在地址。

相反,我们将使用ExecutableAllocator::allocate函数在现有的rwx JIT区域中保留一个地址,并通过memcpy将我们的shellcode复制到该地址处,然后返回到该地址处。通常情况下,第一阶段的shellcode只是一个简短的stub,用来下载一个更大的第二阶段的shellcode,例如,实现沙盒逃逸的exploit。 

综上所述,如果把所有的代码片段放在一起,最终将得到如下所示的wasm函数: 

    ;; take 2 i64 args which will become our leaks
    ;; note that args are referenced as locals 0 and 1
    (func $foo (param i64 i64)
 
        ;; cause an out of line jump to populate leaks
        block
            i32.const 1
            br_if 0
            ;; filler ...
        end
 
        ;; subtract offset to JavaScriptCore dylib base
        local.get 0
        i64.const
        i64.sub
        local.set 0
        ;; and similarly as needed for the stack address
 
        ;; push a ton of constants to hop over the guard page
        i64.const 0
        i64.const 0
        i64.const 0
        ;; and so on ...
 
        ;; prepend a "ROP sled" so we dont need to be spot-on with the stack offset
        local.get 0
        i64.const
        i64.add
        ;; repeat to write the sled...
 
        ;; write the ropchain
        local.get 0
        i64.const
        i64.add
        ;; and so on, calling ExecutableAllocator::allocate to reserve rwx space
        ;; then copy the shellcode and return to it
 
        ;; append code to overflow m_maxStackSize
        block
            block
                unreachable
            end
            block
                block
                    unreachable
                end
                ;; and so on ...
            end
        end
    )

小结

Safari 14.1.1中,整数溢出漏洞已被修复,分配的CVE ID为CVE-2021-30734。该补丁利用生成器具有校验功能的算术操作来处理堆栈长度的运算。

需要说明的是,该exploit的源代码仅限于教育用途,读者可以从这里下载

在浏览器的渲染器进程中实现了任意代码执行后,典型的漏洞利用链的下一步,就是实现某种形式的沙盒逃逸,为此,通常需要利用具有更高权限的进程或内核。在接下来的文章中,我们将介绍如何利用内核驱动程序来实现任意的内核代码执行。