才不是为了 bonus 而写的呢
哎嘛, +15%, 真香

Javascript 中的对象

在 Javascript 的世界中, “万物皆是对象”, 同时, JS 的对象和一些其它语言 (如 C++, Python) 中的对象存在一定的差异…

当我们创建一个对象的时候, 我们实际上在讨论什么?

我们用三种语言分别创建一个 “空” 的对象

class Foo{
};

int main(){
Foo foo;
}
class Foo:
pass

foo = Foo()
var foo = {}

对于 C++ 来说, 一个空的对象, 或许就是一个空的对象: 如果学习过一定时间的 C++, 就会知道这样方法创建的对象仅仅有构造函数和析构函数, 而且是默认的构造函数和析构函数

这样干净的对象, 基本不存在可以利用的漏洞 (指对象层面的漏洞, 与 C++ 其他底层的漏洞, 如内存溢出等无关)

Python 就不一样了, 一个空的对象, 实际上隐藏着许多内置方法

image064db884ae774beb.png

image-181b15cec225e4941.png

当然, 这些方法的存在肯定会导致漏洞, 例如 CTF 中的沙箱逃逸就很多的利用了 Python 这种特性

让我们回到 JS: JS 会更倾向于哪一种呢?

很显然, 是自带很多内置方法的那种

即便创建的是一个空的对象, foo 依然自带许多内置的属性和方法, 例如 toString 方法

首先要明确的一点是, JS 并没有严格区分函数和类

什么意思呢? 对于下面这段程序来说

function Foo(){
console.log("hello")
}

var foo = new Foo() // hello

Foo() // hello

在实例化 Foo 类的时候, 函数 Foo() 被调用了, 同时 Foo() 是可以作为一个函数单独调用的

这和构造函数十分相似, 也就是说, 在 JS 中, 函数 Foo() 是类 Foo 的构造函数

通过 construct 方法也可以查看 foo 的构造函数以证明我们的猜测

foo.construct   // Foo(){...}

因此可以得出第一个结论: 在 JS 中, 类和函数没有明确界限, 也就是说, 我们可以像使用类一样使用函数, 也可以像使用函数一样使用类

__proto__ 和 prototype

JS 的所有对象都有一个 prototype 属性和 __proto__ 属性

修改类的属性和方法

类可以通过修改 prototype 属性实现在程序运行过程中动态修改类的属性 (真的牛逼, 想想都知道多危险)

例如下面的程序

function Shape(name){
this.name = name
this.print_name = function(){
console.log(this.name)
}
}

Shape.prototype.change_name = function(new_name){
this.name = new_name
this.print_name()
}

var shape = new Shape("Vanadium") // Vanadium

shape.print_name()

shape.change_name("MIKU") // MIKU

在最开始, Shape 是没有 change_name 方法的, 但是通过 Shape 的 prototype 属性可以为其添加这么一个方法, 并且之后新构造的对象都会有这么一个方法

实现继承

prototype 属性可以实现继承

function BaseClass(x){
this.x = x
this.print_info = function(){
console.log(this.x)
}
}

function DerivedClass(){
}

DerivedClass.prototype = new BaseClass()
DerivedClass.prototype.x = 1

var foo = new DerivedClass()

foo.print_info() // 1

通过修改派生类 DerivedClass 的 prototype 属性, 使 DerivedClass 继承了 BaseClass

这种继承模式下, 派生类实际上继承的是基类的某个实例化, 例如在这里, DerivedClass 继承的就是 x = 1 的 BaseClass 的实例化

function DerivedClass_(){
}

DerivedClass.prototype = new BaseClass()
DerivedClass.prototype.x = 1

DerivedClass_.prototype = new BaseClass()
DerivedClass_.prototype.x = 2

DerivedClass === DerivedClass // false

原型链

对于类, 可以用 prototype 追踪到它的父类, 对于对象, 则可以通过 __proto__ 来追踪到它的父对象

var foo = new DerivedClass();

foo.__proto__ // BaseClass {x: 1, print_info: ƒ}

并且这种追踪是可以递归的, 最终会指向 null

foo.__proto__.__proto__             // {}
foo.__proto__.__proto__.__proto__ // null

上面这种像链子一般的追溯, 就称为原型链

同时我们可以猜想对象之间的一种拓扑结构

null
^
|------------
| |
obj1 obj2
^ ^
| |
| |
obj1.1 obj2.1

因此我们可以得出第二个结论: 可以通过子类修改父类, 甚至是父类的父类

原型链污染

那么问题来了, 我们都知道 JS 是一种脚本语言, 假设我们有三个对象 a, b, c, 继承关系分别是 b 继承自 a, c 也继承自 a

假如攻击者已经有了 b 的完全权限, 根据之前的讨论, 攻击者可以通过 b 来修改 a (这是显而易见的), 那么如果通过 b 修改了 a, 之前继承自 a 的 c 会不会被修改呢?

请看下面的实验程序

var a_ = {}

var b_ = {x: 1}

var c_ = {}

b_.__proto__ = a_
c_.__proto__ = a_

console.log(a_.x) // undefined
console.log(b_.x) // 1
console.log(c_.x) // undefined

b_.__proto__.x = 2

console.log(a_.x) // 2
console.log(b_.x) // 1 原因是查找顺序
console.log(c_.x) // 2

结果非常 amazing 啊, 通过 b 修改了 a 之后, c 也跟着被修改了! 即便是原型没有的属性, 都可以通过 b 赋予给 c

与之类似的, 假如攻击者控制了一个对象, 并通过修改原型, 影响了所有这个对象的兄弟对象, 也就是同一个父类的子类对象, 这就被称为原型链污染

递归漏洞

以下程序展示了一个经典的原型链污染的递归漏洞

// program
function merge(target, source){
for(let key in source){
if(key in target){
merge(target[key], source[key])
}
else{
target[key] = source[key]
}
}
}

// attack payload
let tgt = {}
let src = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')

merge(tgt, src)

console.log(tgt.a) // 1
console.log(src.b) // 2

// attack result
let t = {}

console.log(t.b) // 2

对于这个看上去没有问题的 merge 函数, 实际上只要构造一定的 payload 就可以实现原型链污染

以给出的 payload 为例, 假如 src 被定义为对象 {"a": 1, "__proto__": {"b": 2}}, 执行 merge(tgt, src) 之后, 由于 src 的 key 均没有出现在 tgt 中, 就会将 src 的 key 赋值给 tgt

在吧 src.__proto__ 赋值给 tgt 时, 根据前文所介绍的, 就实现了原型链污染, 使得任何一个对象都包含了 {"b": 2} 成员

为什么要使用 JSON.parse() ?

请看下面的例子, 如果直接用对象的方法定义, 则 __proto__ 会被隐藏, 就无法让 tgt 的 __proto__ 被修改为 src 的 __proto__

imagef733d931409e63ac.png

实例

下面分别从攻击者和防御者的角度介绍一些原型链污染的实际应用

攻击

拒绝服务攻击

JS 中的所有对象都默认包含一些方法, 比如之前提到的 toString 方法, 如果程序包含了这个方法, 攻击者又通过原型链污染的方法污染了这个方法 (?), 就会发送拒绝服务攻击

例如某个程序的功能是字符串化一个数组 arr.toString()

如果攻击者通过某种方法污染了数组的这个方法 arr.__proto__.toString(), 那么这个程序就无法工作

任意代码执行

与拒绝服务攻击类似, 例如某个程序的功能是执行某个对象的某个属性 (代码), 攻击者就可以通过污染这个对象的该属性, 以达到任意代码执行的效果

防御

原型冻结

通过原型冻结的方法可以避免对象被修改

var frozen_obj = Object.freeze({"a": 1})

console.log(frozen_obj) // {a: 1}

frozen_obj.a = 2
frozen_obj.b = 3

console.log(frozen_obj) // {a: 1}

无原型对象

通过创建无原型的对象, 可以避免原型链污染攻击

var no_proto_obj = Object.create(null)

console.log(no_proto_obj.__proto__) // undefined