JS代码审计[二] 学习笔记
JS面向对象机制
JavaScript是一种面向对象编程的语言,这意味着它可以使用对象来封装数据和行为,从而实现代码的复用和扩展。对象是由属性和方法组成的,属性是对象的特征,方法是对象的行为。JavaScript中有两种创建对象的方式:字面量和构造函数。
字面量
1 | // 创建一个名为person的对象 |
构造函数
1 | // 定义Person类 |
1 | p1.sayHello===p2.sayHello |
这里为什么是false呢,在我们的潜意识里,对同一个构造函数中的一个方法进行引用应该都是指向同一个地址
但事实上,至少对于js来说,用构造函数创建对象时,每个对象都会拥有自己的sayHello方法,而不是共享同一个原型方法
也就是说,每次你用new Person()创建一个新对象时,都会为这个对象分配一块新的内存空间,存储它自己的属性和方法。这样做的缺点是,如果你创建了很多对象,就会占用很多内存,并且造成不必要的重复。
这样做不止占用内存,还有其他缺点,例如
- 如果你想修改sayHello方法,你需要在每个对象上都修改,不能统一管理。
- 如果你想给Person添加其他的原型方法,你需要重新创建对象才能访问到。
为了节省内存空间,避免重复代码,实现多态等特点,JS推出了原型机制,我们可以用prototype属性来给Person的函数原型添加方法,这样所有由Person构造函数创建的对象都会继承自同一个方法引用,也就是指向同一片内存
1 | function Person(name, age, gender) { |
这里要用Person.prototype而不是this.prototype,因为Person指向的是Person的原型,而this指向新创建的对象,而新创建的对象是没有prototype属性的,所以会报错
JS原型链机制
首先,要知道js是一种基于原型的语言,也就是说它没有类(class)的概念,而是通过原型(prototype)来实现对象之间的继承和共享。原型是一个对象,它可以存储一些属性和方法,供其他对象使用。
当我们访问一个对象的属性或方法时,JavaScript会先在对象自身查找,如果没有找到,就会沿着__proto__属性向上查找原型链,直到找到或者到达Object.prototype为止。如果还没有找到,就会返回undefined
那么,Prototype和__proto__是什么呢?它们都和原型有关,但又有不同的作用。
Prototype
Prototype是函数对象特有的属性,它指向一个原型对象,这个原型对象包含了该函数作为构造函数创建的所有实例共享的属性和方法。也就是说,当你定义一个函数时,js会自动为这个函数创建一个prototype属性,它的值是一个空对象。你可以在这个对象上添加一些属性和方法,所有通过这个函数构造的实例都可以访问到这些属性和方法
这里注意,Prototype是函数对象特有的属性,否则容易混淆与__proto__的概念
__proto__
__proto__是每个对象都有的属性,它指向该对象的原型对象。也就是说,当你创建一个对象时,js会自动为这个对象创建一个proto属性,它的值是该对象的构造函数的prototype属性的值。你可以通过这个属性来访问该对象继承自原型对象的属性和方法
这里注意,__proto__是每个对象都有的属性,是每个对象,也包括函数对象
__proto__指向的就是对象原型Prototype
对上面俩概念用代码做个小总结
例如
1 | function Person(name, age) { |
但如果查看obj1,obj2对象的本身的话,可以发现是没有gender这个属性的
1 | console.log(obj1); |
只有函数对象才有Prototype属性,实例对象是没有Prototype属性的。
[[Prototype]]是现代浏览器为了方便开发者查看对象原型而提供的一个别名,它其实就是__proto__属性。也就是说,浏览器中的Prototype和__proto__是同一个东西,只不过名字不同而已。
总之,Prototype和__proto__都是用来实现原型继承的机制,但它们有以下区别:
- Prototype只有函数对象才有,而__proto__每个对象都有(包括函数对象)。
- Prototype指向一个原型对象,用来存储实例共享的属性和方法。而__proto__指向一个已存在的原型对象,用来访问继承自原型对象的属性和方法。
Object.getPrototypeOf
Object.getPrototypeOf也可以用来获取原型,它接受一个对象作为参数,返回该对象的原型对象。也就是说,它可以获取任何对象的原型,不管是构造函数的实例,还是普通的对象字面量。
1 | function Person(name, age) { |
Object.create
Object.create方法是JavaScript中创建对象的一种方式,它可以让我们指定一个对象作为新对象的原型,从而实现继承和扩展的功能。
语法如下:
1 | Object.create(proto, [propertiesObject]) |
其中,proto参数是必须的,它表示新对象的原型对象。propertiesObject参数是可选的,它表示要添加到新对象的自身属性(而不是原型属性)的描述符和名称。Object.create方法会返回一个新对象,它的原型是proto参数,它的自身属性是propertiesObject参数。
Object.create方法有以下几个特点:
- 它可以创建一个空对象,这个对象没有任何原型(连__proto__都没),例如:
1 | var obj = Object.create(null); |
区别于普通的空对象
Copy
- 它可以创建一个继承自另一个对象的对象,拥有该对象的原型属性和方法。例如:
1 | var person = { |
屏蔽效应
思考题:
1 | const obj={ |
obj.age++等效于obj.age=obj.age+1,
直接修改了obj本身的属性,所以影响到了所有继承自obj的对象
p.age++等效于p.age=p.age+1,
因为p本身没有age属性,所以js会去对象p的原型链上去找age,相当于p.age=obj.age+1,最后的效果就是没有影响到obj,反而给自己加了个age属性,并且在obj.age的基础上加了1
new关键字做了什么
首先,我们要知道JS是一门基于原型的语言,也就是说,它没有类的概念,而是通过原型对象来实现对象之间的继承和共享。
new关键字会进行如下的操作:
- 创建一个空对象(即 {} );
- 为步骤 1 新创建的对象添加属性 __proto__ ,将该属性链接至构造函数的原型对象;
- 将步骤 1 新创建的对象作为 this 的上下文;
- 如果该函数没有返回对象,则返回 this。
举个例子,假设我们有一个构造函数Person:
1 | function Person(name, age) { |
当我们用new关键字来创建一个Person的实例时:
1 | var alice = new Person("Alice", 20); |
相当于执行了以下代码:
1 | var alice = {}; // 创建一个空对象 |
这样,alice就成为了Person的实例,它继承了Person.prototype上的sayHello方法,并且有自己的name和age属性。
Constructor属性
JavaScript的constructor属性是一个特殊的属性,它存在于每一个函数的原型对象中。它指向了创建这个函数的构造函数,也就是说,它可以用来判断一个对象是由哪个构造函数生成的。
举个例子,
1 | function Person(name, age) { |
在这个例子中,我们定义了一个Person构造函数,然后用它创建了两个对象p1和p2。我们可以通过访问它们的constructor属性,得到它们的构造函数是Person。我们也可以用constructor属性来判断两个对象是否属于同一个类型,或者一个对象是否属于某个类型。
Function函数对象
Function是java内置对象,它的实现是由js引擎内部代码实现的
以V8引擎为例,部分数据类型和对象就是用C++实现的,所以当我们console.log(Function),会出现Function() { [native code] }
Function是所有函数对象的构造函数(函数对象也是对象)
所有函数对象的__proto__都指向Function.prototype(函数对象也是对象),意味着Function.prototype是所有函数对象的原型
1 | function Person(name,age){ |
还有几个有意思的点
1 | typeof(Function.prototype) |
具体原因我也说不清,因为你无法用正常的原型链来解释这些结果,只能说Function不是js代码实现的,而在C++编写时可能形成了一些神奇的特性
原型链污染
1 | function merge(target, source) { |
为什么没污染成功?甚至连obj2.age都没成功合并?
首先来说为什么obj2.age都没有成功合并的原因:
1 | obj1.hasOwnProperty('__ptoto__') |
因为proto是js的特殊属性,所以hasOwnProperty会为false,根本不会执行后面的合并步骤,这也是导致污染失败的一个重要原因
对于第1个问题,先让我们做个小实验
1 | var obj1={name : "cloud",__proto__:{age : 20}} |
结果表明,通过字面量来给对象的__proto__属性赋值,那就仅仅只是一个名称为”__proto__“的普通属性,而并非js引擎认为的会指向对象原型的__proto__,也就是说,此proto非彼proto(其实JSON.parse方法的字面量中的__proto__也只会认定为一个普通属性,但是会使自身的hasOwnPerpority(‘__proto__‘)为true)
后来,对于JSON.parse和直接字面量赋值proto这2个方法之间,我又挖掘出一些有趣的地方和特性,等后面单独开一篇讲
但是可以用JSON.parse方法来先把obj1中的proto属性解析为真正的指向原型的proto,然后再进行污染
1 | function merge(target, source) { |
VM沙箱逃逸
首先,我们要知道什么是 VM 沙箱。VM 沙箱是一种虚拟机沙箱,它是一种在一个虚拟的、受限的环境中运行代码的技术,它可以保护主机系统和其他应用程序不受不可信代码的影响。VM 沙箱可以用于执行一些不可信的、来自第三方的、或者可能有安全风险的代码。
那么,JS 这种 VM 沙箱是怎么实现的呢?其实,JS 这种 VM 沙箱并不是真正的 VM 沙箱,它只是一种模拟 VM 沙箱的方法,它利用了 JS 的一些特性和语法,来创建一个类似于 VM 沙箱的效果。
我们知道JavaScript是一种基于原型的面向对象的语言,也就是说,每个对象都有一个原型对象,原型对象也是一个对象,它可以继承另一个原型对象,这样就形成了一个原型链。当我们访问一个对象的属性或方法时,JavaScript会沿着原型链向上查找,直到找到对应的属性或方法,或者到达最顶层的原型对象为止。
在浏览器中,最顶层的原型对象是window对象,在node.js中,最顶层的原型对象是global对象。这两个对象都是全局对象,也就是说,它们包含了JavaScript的所有内置对象和函数,比如Object, Function, Array, Math, Date, console, process等等。这些内置对象和函数都是非常强大和危险的,如果被沙箱逃逸者获取到,就可以执行一些本不应该被允许的操作。
那么,如何从沙箱中获取到这些内置对象和函数呢?这就要用到this关键字了。this关键字在JavaScript中表示当前执行上下文的对象,也就是说,在不同的情况下,this指向的对象可能不同。比如,在全局作用域中,this指向全局对象,在函数中,this指向调用该函数的对象,在构造函数中,this指向创建的新对象等等。
VM与with
那么,在沙箱中,this指向什么呢?这取决于沙箱是如何创建和运行的。如果沙箱是通过eval函数或者Function构造函数创建和运行的,那么this指向全局对象。
1 | // 使用eval函数执行代码 |
如果沙箱是通过vm模块或者with语句创建和运行的,那么this指向沙箱环境中定义的一个空对象。
1 | // 使用vm模块执行代码 |
但是,这里的空对象的构造函数,指向的Object,也就是Object.prototype.constructor
1 | var obj=Object |
因为with语句中的上下文也会继承自Object.prototype
1 | var a={} |
包括vm也是
1 | var vm = require("vm"); |
所以我们可以通过constructor属性来获取外部的全局对象,进而获取其他想要的模块,例如Function函数,然后进行代码执行
由此看出,with和vm在本质上都只是提供了一个空的或者指定的上下文环境,而并没有完全与外部全局隔离,所以这也是为什么vm1被抛弃的原因,是否真的与外部全局隔离可以理解为有没有做Object.create(null)这个操作,也可以理解为上下父子级与同级的区别
RCE
with
1 | with({x: 1, y: 2}) { |
拆解:
1 | (this.constructor.constructor("return process"))() |
获取Function构造函数写一个函数引用,然后用立即调用机制去执行这个函数,结果是返回process对象
1 | process.mainModule |
process是一个全局对象,它提供了有关当前Node.js进程的信息和控制。process.mainModule是一个属性,它返回一个对象,表示当前的主模块。主模块是指启动Node.js进程的那个模块,通常是在命令行中指定的那个.js文件
1 | require("child_process").execSync("命令") |
加载child_process模块,用一个子进程去执行命令
VM
1 | const vm= require("vm") |
VM2
JS的VM2是一个安全的沙箱环境,用于运行不受信任的JS代码。它是基于Node.js官方的VM模块的替代品,主要解决了VM模块的安全问题。VM2相较于VM1有以下几个改进:
- VM2使用JavaScript的Proxy技术来防止沙箱脚本逃逸,从而保证沙箱内的代码不能访问或修改主程序的全局对象和变量。
- VM2可以设置沙箱的执行超时时间,以防止死循环攻击。
- VM2可以控制沙箱的终端输出信息,以防止敏感信息泄露。
- VM2可以受限地加载模块到沙箱内,以提供更多的功能。
- VM2可以安全地向沙箱间传递回调函数,以实现异步通信。
vm2的代码包中主要有四个文件 cli.js , contextify.js , main.js和sandbox.js
- cli.js: 这个文件是vm2的命令行接口,它可以让你在终端中运行vm2的沙箱环境。你可以使用一些选项来配置沙箱的行为,比如设置超时时间,控制台输出,模块访问等。
- contextify.js: 这个文件是vm2的核心文件,它负责创建和管理沙箱的上下文。它使用Proxy技术来拦截和修改沙箱内的对象和函数(proto,constructor等),从而防止沙箱逃逸。它还重写了require函数,来控制沙箱内可以加载哪些模块。
- main.js: 这个文件是vm2的入口文件,它导出了VM和NodeVM两个类,分别对应两种不同的沙箱模式。VM是一个简单的沙箱,只能同步地运行不受信任的代码,没有require功能。NodeVM是一个更强大的沙箱,可以异步地运行不受信任的代码,并且可以受限地加载模块。
- sandbox.js: 这个文件是vm2的辅助文件,它定义了一些沙箱内可以使用的辅助函数和对象,比如Buffer, console, setTimeout, setInterval等。这些函数和对象都经过了安全处理,以防止沙箱内的代码利用它们进行攻击。
使用案例
1 | // 引入vm2模块 |