学习一下V8利用的基本知识。在之前的利用中发现很多知识点需要现查,希望这次能统一补习一下,例如对象的种类、表示,JIT的优化策略等等。资源是Sakura大佬的博客。也当作是为下学期的实习做准备吧。

浏览器结构

我们总说的“pwn掉浏览器”,浏览器里面究竟有什么?哪些东西可以作为我们pwn的目标呢?参考链接

基本架构

现代浏览器的结构如图:

browser

这里的箭头应该都是双向箭头。牛老师看到这个图一定会打死我。

  • User Interface 用户交互的界面,用户和Browser Engine交互的界面。如地址栏、前进后退按钮等。

  • Browser Engine 浏览器引擎,用于协调User Interface和Render Engine。提供刷新、前进后退等方法,错误、加载进度等等。

  • Render Engine 渲染引擎,提供可视化展示。解析js, html, xml,User Interface的layout。重点功能是html解释器。

  • Networking 处理网络请求,进行缓存等。

  • JS Engine JS引擎,用于执行js代码,将结果传输到Render Engine执行。

  • UI Background 绘制基本的窗口组件

  • Data Storage 管理用户数据,如书签,cookie,偏好设置等。注意到这里可能会存储很多敏感歇息,也是攻击的目标。

不同的浏览器架构会有所不同,使用的解释器、引擎也可以是自己开发的。

主流浏览器架构

浏览器 浏览器渲染引擎 JS引擎
Firefox Gecko Spider-Monkey
Chrome Blink V8
IE Trident JScript(IE 1~8) Chakra(IE 9-11)
Safari WebKit JavaScriptCore
Microsoft Edge EdgeHTML Chakra

Firefox

firefox

火狐使用的浏览器/渲染引擎是Gecko,使用的JS引擎是Spider-Monkey。

记一下名字还是比较有用的,因为很可能被拿来改一改变成题目名字…

Chrome

image-20200704145704590

大致结构就是这样,不知道这个WebKit是咋回事…Chrome使用的应该是Blink和V8,可以参考这个:

image-20200704151356467

具体各个组件是怎么工作的可以参考帮助文档,也是很好的学习资料。

IE

image-20200704192828089

给的这个图有点诡异…

Safari

WebKit Diagram

JS引擎是JSC。这名字起的够直接的。

Microsoft Edge

image-20200704194238489

漏洞利用的学习方法

主要有两种:

  1. 通过patch的方法引入漏洞
  2. 学习已有的CVE,在两次的commit中找到漏洞点,可能会有PoC

步骤:

  1. 通过patch和commit hash编译目标
  2. 通过patch找到是哪一个进程存在漏洞
  3. 定位漏洞的调用链,找到触发的方法。可以通过工具ag来定位,代码查找速度比较快。安装
  4. 编写利用的js代码,一般可能会涉及到addrof和fakeobj,以及任意地址读写的原语
  5. Getshell。一般来说通过JIT来getshell是比较常见的方法

V8

js引擎就是执行js代码的程序,是浏览器的组成部分之一。V8就是Chrome中使用的js引擎。

编译器流程

一个JS引擎的基本的流程是,输入js代码,将代码转化为抽象语法树(AST),根据AST生成汇编指令。但是V8之所以这么迷人,还有一个重点就是执行得很快,这就涉及到V8的代码优化做的很好。生成AST之后,通过AST生成字节码,此时还不是汇编码。通过一些反馈,对字节码进行优化和编译,最后变成可以执行的汇编码。

image-20200706145523524

编译器历史

整个流程中,有四个编译器,除了图中的FullCode Generator、Crankshaft、TurboFan还有一个Ignition。这个应该是比较老的结构了。

image-20200706150059378

  • FullcodeGenerator (旧的)baseline编译器
  • Crankshaft (旧的)优化编译器
  • Turbofan (新的)优化编译器
  • Ignition(新的)baseline编译器

2008

直接通过CodeGenerator,从AST生成汇编代码。但是存在很多的冗余代码。

image-20200706150547795

2010

为了优化,引入了Crankshaft。

image-20200706150707784

这个时候的编译就变成了两条线,左边和原来差不多,也就是baseline的线。右边是经过优化的线。两边可以互相转化。

2014

引入了Turbofan,为了适应JS的规范。

image-20200706150904946

2016

引入了Ignition,用于生成中间代码bytecode。

image-20200706151009236

*2017~Now *

去掉了旧编译器FullcodeGenerator和Crankshaft。终极版本:

image-20200706161622231

处理流程:JS代码,生成AST,生成中间代码,优化,变成最后的汇编代码。

image-20200706161757775

编译器优化

最开始的FullcodeGenerator并没有怎么考虑到优化的问题,所以生成汇编代码相对来说速度很快,但是执行起来速度较慢。为了加快运行速度,有了以下优化:Inlining,Hidden Class,Inline caching和Turbofan(JIT)。

Inlining

提前内联尽可能多的代码。方法就是,被调用函数的主体替换调用函数的代码行的过程。这个简单的步骤让之后的优化更有意义。

个人觉得有点像宏展开?编译原理的课上说过,优化的时候基本以代码块为单位进行优化。如果我们把被调用的函数直接“展开”到调用的位置,这样代码块就变得更大,可以优化的东西就比较多了。否则只能在一个一个小块的内部进行优化。

Hidden Class

Javascript是基于原型的,也就是prototype。JS只有一种结构,就是对象:Object。每一个Object都有一个私有属性__proto__,指向他的构造函数的原型对象prototype。该原型对象也有自己的原型对象,层层向上,最后到的原型对象是null。null没有原型,是最后的环节。

另外,JS也是一个动态类型的语言。在对象实例化之后可以添加和删除属性。

使用语言的人很爽,开发语言的人很痛苦 : )

现在考虑一下属性该如何实现。大部分的js解释器都是使用类似于哈希表的结构来存储属性在内存中的位置,这使得检索属性的开销比较大。这是因为,像Java等非动态编程语言,在编译之前,属性的类型都确定下来了,内存布局也就是确定的,例如某个属性的偏移和长度,运行的过程中也不能动态的添加和删除,所以找起来很容易;但是JS显然是不可以的。

在V8中,使用的是Hidden Class的方法来实现快速存取。当一个新的属性加入时,对象就会更新自己的hidden class。这并不是首创的方法,在Self语言中也是使用了这种方法。

以这段代码为例:

1
2
3
4
5
6
function Point(x,y) {
this.x = x;
this.y = y;
}

var obj = new Point(1,2);

这样的构造函数是生活中非常常见的代码。

hidden class的变化步骤如图:

hiddenclass

具体细节可以参考这篇文章。属性的变化过程中,Hidden class C0和C1虽然被替换掉了,但是不会从内存中删掉,之后仍然可以重复使用。

需要注意的是,hidden class的结构和赋值的顺序有关——先x后y和先y后x最后的hidden class是不同的。例如:

1
2
3
4
5
6
7
8
9
10
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;
var p2 = new Point(3, 4);
p2.b = 7;
p2.a = 8;

因此,为了写出“更快”的代码,添加属性的顺序应该相同,这样就会共享hidden class。

Map

再细化说明一下,在V8中,用于描述对象结构的数据结构是maps,也就是上图中的hidden class。图中maps的变化称作transitions。如果使用相同的构造函数,对象的maps应该是相同的。所以如果我们可以重复使用maps,就不需要每次构造同一种对象就创建一个新的map了。参考。再之后的JSObject描述我们再细说。至少现在我们知道map对于Object是一个很重要的结构,map指针几乎就是Object的类型。

Descriptor Array

可以看到hidden class中存储了这个对象的很多信息,比如属性的数量,以及prototype的引用等等。

image-20200707003519527

这里的hiddenclass就是map,其中有一个descriptor array,其中包含的就是关于属性的信息,也就是properties指针。

Properties & Elements

在JS代码中很多对象都会用到“属性”,用起来和字典差不多,用字符串作为key,用任何对象作为value。除了在迭代的时候,key是否是整数类型可能会有一些区别,其他情况没啥太大区别。

{a: "foo", b: "bar"}为例,可以看到有两个已命名的属性,也就是a和b。这里属性名并没有使用整数。但是如果使用整数的话,例如{1:"a"; 2:"b"}或者[1, 2, 3]["a", "b"],一般称作为elements。注意到["a", "b"]其实相当于{0:"a"; 1:"b"}

image-20200706221431320

properties和elements使用的是不同的数据结构,这样处理起来更高效。参考

属性的种类可以做如下区分:

In-object properties & normal properties

In-object properties指的是直接在对象结构中存储的属性,不需要在外部存储,这样在寻找的时候不需要重新定位。这是最快的属性。能放进去几个in-object属性取决于对象的初始大小,如果超过了这个大小就需要使用properties指针才存储了。虽然使用properties指针意味着增加了一层定位,但是属性的数量可以在外部空间增长,不受对象本身大小的限制了。

image-20200706222516616

可以看到图中的in-object properties 1 2就是在对象本身的空间内存储的,而properties 1…N是需要通过properties指针才能找到的。

Fast properties & slow properties

所谓的fast properties就是那些“线性存储”的属性,可以直接通过下标和properties指针来找到对应的value。但是如果一个对象频繁地增加或者删除属性,在时间和空间上消耗都很大。于是V8设计了慢属性。对象中包含一个字典,用于存储属性。属性的信息不再存储在properties指针中,而是直接存储在字典中。这样就不需要更新hidden class就可以增加和删除属性。由于之后的inline caching并不会处理字典,所以这种模式下要慢一些。

Inline caching

Inline caching依赖于对相同方法的重复调用往往发生在相同类型的对象上的结论。

V8维护了在最近的方法调用中作为参数传递的对象类型的缓存,并使用这些信息来假设将来作为参数传递的对象类型。如果V8能够很好地假设将传递给方法的对象类型,那么它就可以绕过找出如何访问对象属性的过程,而是使用以前查找对象隐藏类时存储的信息。

每当在特定对象上调用一个方法时,V8引擎必须对该对象的hidden class执行查找,以确定访问特定属性的偏移量。在对同一个hidden class成功调用了两次相同的方法之后,V8省略了hidden class的查找,而只是将属性的偏移量添加到对象指针本身。对于以后对该方法的所有调用,V8假设hidden class没有改变,并使用以前查找中存储的偏移量直接跳转到特定属性的内存地址。这大大提高了执行速度。

Inline caching也是相同类型的对象共享hidden class如此重要的原因。如果创建了两个具有相同类型但是hidden class不同的对象,V8将不能使用inline caching,因为即使这两个对象具有相同的类型,它们对应的hidden class也会为它们的属性分配不同的偏移量。

目前的理解就是,先比较一下map的值是否一致,如果一致就可以很快找到需要的属性等,如果不是就老老实实找。

Just In Time

重新编译为更高效的JIT代码。可以看作是两个线程,主线程正常执行代码,另一个线程Runtime-Profile测量使用状态,根据测量结果判断是否需要优化。例如一个函数调用调用了很多次,那么这个函数就可以尝试去做优化。Turbofan会再次将源码编译为汇编语言,替换掉正在执行的代码。

优化的目标主要是多次调用的函数或循环。例如超过1000次、10000次,就会被设置为优化的目标。如果被判定为是hot code,就会在另一个线程重新编译这部分的代码,然后通过jmp语句让主线程跳转到优化的代码中。

Turbofan

由于Crankshaft已经被移除了,我们了解一下Turbofan,参考。工作流程如图所示。

image-20200707112843750

image-20200707114323935

image-20200707114458625

首先的工作是构造图,之后对这个图进行优化和化简,最后生成汇编代码。

优化的手段有:

  • Inline 内联函数调用
  • trimming 删除未到达的节点
  • type 类型推断
  • typed-lowering 根据类型将表达式和指令替换为更简单的处理
  • loop-peeling 取出循环内的处理
  • loop-exit-elimination 删除loop-exit
  • load-elimination 删除不必要的读取和检查
  • simplified-lowering 使用具体的值来简化指令
  • generic-lowering 将JS前缀指令转换为更简单的调用和stub调用
  • dead-code-emilination 删除不可达的代码

在之前的题目中我们也遇到了删去类型检查的JIT题目。

垃圾回收机制

在学习利用脚本的时候经常看到使用gc()这样的函数,一般功能就是大量地分配内存,如10000次的new String等。参考

GC的主要职责就是识别哪些对象是废弃不用的,并且将占用的空间释放。GC使用的并不是只有glibc的heap空间,会用mmap来分配一些空间使用,称作GC区域。V8中的HeapObject会使用这部分的空间。另外一个就是heap区域。如果不是JS对象的话,会在这里管理。

在GC中空间分为两个部分,也就是两种管理策略,分别为Old generation和Young generation。除此之外的称作Other,其实是关于Large Object Space的。

image-20200707123914481

Young generation

New Space

基本上所有新创建的对象都会放在这里,并且受GC管理。

需要注意的是code object,map object,large object不在这里。

Cheney算法

管理的时候使用的是Cheney算法,为了使用该算法,设计了To Space和From Space。最开始,对象都放在To Space。

gc

如果内存不够用了,会将To Space中的对象拷贝到From Space中。这一步其实是通过交换指针来实现的。之后,仅仅将living object拷贝到To Space。之后按照顺序拷贝。还有一些object是拷贝到old generation的。

两次在young generation中幸存下来的对象会拷贝到old generation,而不是 From Space。

现在To Space的空间会剩出来很多,可以进行分配了。From Space中的对象就被丢弃了。

Old generation

Old Space

用于存放存活时间长的对象:在Young generation中两次GC都存活的对象。在Old Space中GC的次数比较少,所以比较稳定。如果一个对象被放到了Old Space,该对象不会受到GC更改layout的影响。

所以说想要利用稳定,最好都放到Old Space中。

Code Space

用于存放JIT的code object。这一块是有rwx全限的。

Map Space

仅用来保存Map Object。利用的时候并不关心其中的实现细节。

Other

Large Object Space

存放超过600KB或更大的Object的地方。通过mmap直接分配。

对利用的影响

  1. 如果在利用中需要多次的分配内存,那我们希望对象都在Old Space中,这样对象不会到处乱跑,地址就不会改变。具体的方法就是提前有意识地激活GC,将对象移动到Old Space。
  2. 可以看到很多对象并不是使用Glibc的对空间的,所以很多Glibc下的堆利用都不适用了。

V8中的对象模型

在攻击的时候了解对象的结构是很重要的。可以配合着%DebugPrint来看一下具体的结构。

继承关系

image-20200707145342463

这个图可能更好看一些:

image-20200708195933219

Object & Tagged Object

由以下两种类型组成:Smi和HeapObject。

Smi

small integer,整数值。32位系统:带符号的31位;64位系统:带符号的32位。

设计Smi就是为了存取更快,直接存储在Object中,而不是通过一个指针来访问。

但是有一个问题:如何区分该位置存储的是值还是指针呢?区别就是LSB是否是1。如果是1,表示是tagged,表示这个值是一个指针,用的时候-1即可。如果不是1,那就是一个值。之所以可以这么设计是因为GC处理内存是对齐的,32位4字节对齐,64位8字节对齐,因此LSB始终是0.

HeapObject

除了Smi之外的其他类,或者是不在Smi范围内的整数。始终有一个指向Map的指针。HeapObject基本都是通过GC来管理,所以一般分配在GC区域,而不是堆区域。

HeapNumber

继承自HeapObject,对象的值是double。结构如图:

image-20200707164008261

String

保存字符串对象。

image-20200708133320538

JSObject

image-20200708134311868

主要就是properties和elements的区别。

  • properties: a.x
  • elements:a[0]

JSFunction

image-20200708134436690

需要注意的就是里面的kCodeEntryOffset*,如果是一个JIT优化过的函数的额话,这是一个指向JIT页面的位置,是可读可写可执行的。通过这里可以达到远程代码执行的效果,例如通过任意地址读写原语将这里的代码改为shellcode,然后执行该函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
0x2000e0194ab1: [Function] in OldSpace
- map = 0x118f885024d1 [FastProperties]
- prototype = 0x2000e0184669
- elements = 0x141e26e02251 <FixedArray[0]> [HOLEY_ELEMENTS]
- initial_map =
- shared_info = 0x1074bd5c38b1 <SharedFunctionInfo b>
- name = 0x141e26e078b1 <String[1]: b>
- formal_parameter_count = 1
- kind = [ NormalFunction ]
- context = 0x2000e0183d91 <FixedArray[281]>
- code = 0x3ffcaff22f01 <Code BUILTIN> <=========== JIT page here
- interpreted
- bytecode = 0x141e26e22ae9
- source code = (x) {
return x + 1;
}
- properties = 0x141e26e02251 <FixedArray[0]> {
#length: 0x141e26e7db19 <AccessorInfo> (const accessor descriptor)
#name: 0x141e26e7db89 <AccessorInfo> (const accessor descriptor)
#arguments: 0x141e26e7dbf9 <AccessorInfo> (const accessor descriptor)
#caller: 0x141e26e7dc69 <AccessorInfo> (const accessor descriptor)
#prototype: 0x141e26e7dcd9 <AccessorInfo> (const accessor descriptor)
}

- feedback vector: 0x141e26e22b71: [FeedbackVector] in OldSpace
- length: 1
SharedFunctionInfo: 0x1074bd5c38b1 <SharedFunctionInfo b>
Optimized Code: 0
Invocation Count: 4
Profiler Ticks: 0
Slot #0 BinaryOp MONOMORPHIC
[0]: 1

JSArray

这个应该是经常用到的对象了。

image-20200708165525199

在之前的题目中我们遇到了array的oob,类型混淆等等。

JSArrayBuffer

超级常见了。这块主要是两个东西:ArrayBuffer和TypedArray。

ArrayBuffer

说白了只是一个缓冲区。结构中有一个backing store指针,指向的位置就是缓冲区。

TypedArray

想要读写缓冲区的内容,需要通过TypedArray来访问。

image-20200708193306196

根据不同的type来读取不同的数值。我们在写利用的时候经常会有f2i,i2f这样的函数就是通过这个方法转换的。

使用的方法:

  • 直接创建TypedArray

    1
    2
    var a = new Uint8Array(100);
    var b = new Uint8Array([1, 2, 3,4]);

    这里就不需要自己来创建ArrayBuffer了。

  • 先创建ArrayBuffer,再创建TypedArray

    1
    2
    3
    var a = new ArrayBuffer(8);
    var b = new Uint8Array(a);
    var c = new Uint8Array(a, 0, 4); // 指定起始位置

同一个ArrayBuffer可以被不同的TypedArray共享。缓冲区内的数据并不会改变,只是改变了读取的方式。在利用的时候经常会遇到泄露,但是泄露出来的值是double类型的,此时可以通过这个方法来转化为整型。

V8题目的非预期解

这个也是为了在出题的时候做好patch。如果题目被很快秒的话可以试试hhh。

主要是在这里:d8的shell支持的命令:

image-20200708194551666

通过read读文件

image-20200707202747503

通过import泄露文件

image-20200707202831153

通过绝对路径可能会报错,适用相对路径就可以了:read('../../../../../../flag.txt');

通过load泄露文件

image-20200708194334252

patch

path的话就把支持的这几个函数patch掉就可以了,位置在sample/shell.cc,源代码。所以之后在看patch的时候,如果patch的位置在shell.cc中,多半是在patch这里的非预期。

下载

PDF版本的可以直接在这里下载。