JS代码审计[一] 学习笔记
首先感谢星盟安全团队和
变量
javascript变量声明有3种方式,var,let,const
对于var变量,存在一种机制:声明提升
声明提升
JS中的变量提升指的是在代码执行前,变量声明就已经被提升(即复制)到了当前作用域的最顶部。这是因为JS引擎在编译的时候,就将所有的变量声明了,因此在执行的时候,所有的变量都已经完成声明
举例:
1 | console.log(a) |
实际执行情况:
1 | var a |
补充:js会将变量的声明提升到js顶部执行,对于var a = 2这种语句,会拆分开,将var a这步进行提升。
变量提升的本质其实是js引擎在编译的时候,就将所有的变量声明了,因此在执行的时候,所有的变量都已经完成声明。
当有多个同名变量的时候,函数声明会覆盖其他的声明。如果有多个函数声明,则由最后一个函数声明覆盖之前的所有声明。
而对于let和const,就不存在变量提升,
1 | console.log(a) |
总结:
特点 | var | let | const |
---|---|---|---|
作用域 | 全局或函数 | 块级 | 块级 |
重复声明 | 允许 | 不允许 | 不允许 |
绑定全局对象 | 是 | 否 | 否 |
变量提升 | 是,初始化为 undefined | 否(暂时性死区) | 否(暂时性死区) |
可变性 | 可以更新和重新赋值 | 可以更新和重新赋值 | 必须初始化,不能更新和重新赋值 |
RHS和LHS
RHS和LHS是两种变量引用的方式,分别代表右值引用和左值引用。通常是指赋值操作的左侧和右侧,但不一定是等号的左右,而是根据变量是被查询,还是被赋值来判断
举例:
1 | var a=1 //LHS,a被赋值 |
补充
RHS和LHS的区别在于,当变量不存在时,RHS会抛出 ReferenceError 异常,而 LHS 会隐式地创建一个全局变量(非严格模式下)。例如:
1 | console.log(b); // ReferenceError: b is not defined |
JS内置类型
JS一共有七种内置类型,分别是:null、undefined、boolean、number、string、object、symbol。
这些类型可以使用 typeof 运算符来判断
1 | typeof null // "object" |
null 是内置类型,但是 typeof null 返回的是 “object”。这是因为 JavaScript 最初的实现中,JavaScript 中的值是由一个表示类型的标签和实际数据值表示的。对象的类型标签是 0。由于 null 代表的是空指针(大多数平台下值为 0x00),因此,null 的类型标签是 0, typeof null 也因此返回 “object” 。这是一个历史遗留的 bug,但是为了兼容性,没有被修复
null和undefined
null 和 undefined 都是 JavaScript 中的特殊值,表示变量的值为空。但是它们有以下区别]:
- null 表示一个空对象,是一个关键字,可以被赋值给变量。undefined 表示未定义,不是一个关键字,不能被赋值给变量。
- null 是 Object 的一个特殊值,表示这个对象不是有效对象,是一个不存在的对象的占位符;undefined 是 Global 的一个属性,表示一个未知的值。
- null 意味着一个明确的没有指向的空值,而 undefined 则意味着一个未知的值。
- typeof null 返回 “object”,而 typeof undefined 返回 “undefined”。Object.prototype.toString.call(null) 返回 “[object Null]”,而 Object.prototype.toString.call(undefined) 返回 “object Undefined”。
- null == undefined 返回 true,但是 null === undefined 返回 false。
- null 转化为 number 时,会转换成 0;undefined 转换为 number 时,会转换为 NaN。null + 1 等于 1,而 undefined + 1 等于 NaN。
- JSON.stringify 会将值为 undefined 的内容删除,而将值为 null 的内容保留。
补充:
从源码层面来讲,null 和 undefined 的区别主要在于它们的内部表示和类型标记。在 JavaScript 最初的实现中,值以 32位 存储。前 3位 表示数据类型的标记,其余位则是值。对于所有的对象,它的前 3位 都以 000 作为类型标记位。在 JavaScript 早期版本中,null 被认为是一个特殊的值,用来对应 C 中的 空指针 。但 JavaScript 中没有 C 中的指针,所以 null 意味着什么都没有或者 void 并以 全0 (32个) 表示。因此每当 JavaScript 读取 null 时,它的前 3位 将它视为 对象类型 ,这也是为什么 typeof null 返回 “object” 的原因。而 undefined 则是 Global 对象的一个属性,在最初版本中没有被赋予类型标记和值,在后续版本中被赋予了类型标记 001 和全0 的值。
JS数组
创建方式
- 使用Array()构造函数
1 | let arr1 = new Array("apple", "banana", "cherry"); // 创建一个包含三个元素的数组 |
- 使用数组字面量,用方括号括起一组逗号分隔的元素
1 | let arr3 = ["apple", "banana", "cherry"]; // 创建一个包含三个元素的数组 |
- 使用 Array.of() 静态方法,可以传入多个元素作为参数,创建一个包含这些元素的新数组。与 Array() 构造函数不同的是,如果只传入一个数字参数,它不会创建一个长度为该数字的空数组,而是创建一个包含该数字的单元素数组。
1 | let arr5 = Array.of("apple", "banana", "cherry"); // 创建一个包含三个元素的数组 |
- 使用 Array.from() 静态方法,可以从一个类似数组或可迭代的对象中创建一个新的数组实例
1 | let str = "hello"; // 字符串是类似数组的对象 |
添加元素
1 | let arr=[1,2,3] |
删除元素
1 | let arr=[1,2,3] |
JS数组的特性
1.数组越界访问
JS的数组越界访问后不会报错,而是返回undefined
1 | let arr=[1,2,3] |
2.数组delete删除元素
JS的数组用delete删除其中一个元素后,元素所占的空间依然不变,只是值变成了undefined
1 | let arr=[1,2,3] |
用pop,shfit,splice删除元素后实测不会有上述问题
3.跨索引添加元素
如果跨越现存的最大索引去添加一个元素,那么最大索引与添加元素的索引之间会用undefined填充,并且数组长度也会相应扩展
1 | let arr=[1,2,3] |
4.添加属性
JS数组本质上是对象,所以也可以添加属性
1 | let arr=[1,2,3] |
5.访问属性
访问js数组成员可以通过2种方式来实现
- 索引访问
1 | let arr=[1,2,3] |
- 字符串形式的键访问
1 | let arr=[1,2,3] |
- 如果字符串键值能够被强制转换为十进制,那么也可以用字符串形式访问索引
1 | let arr=[1,2,3] |
JS字符串
js的字符串可以理解为字符数组,但是字符数组可以变,而字符串不能变
浮点数
1 | 0.1+0.2===0.3 |
具体原因:为什么 JavaScript 中 0.1+0.2 不等于 0.3 ?
特殊类型
NaN
NaN不是一个数字,而是表示数字逻辑运算错误这种情况
1 | 10/"lanb0" |
但是判断NaN只能用isNaN判断,而不能用表达式
1 | var a=10/"lanb0" |
但isNaN的判断逻辑是:“是不是NaN,或者是不是数字‘,
1 | isNaN("lanb0") |
所以准确判断还是用Number.isNaN()
1 | Number.isNaN("lanb0") |
Infinity
1 | 10/0 |
Infinity 属性用于存放表示正无穷大的数值。
负无穷大是表示负无穷大一个数字值。
在 Javascript 中,超出 1.797693134862315E+308 的数值即为 Infinity,小于 -1.797693134862316E+308 的数值为无穷小。
那为什么10/0是无穷大呢
粗略地说,当x倾向于零时,可以将1/0看作1 / x的极限,这也跟js的浮点数有关
特殊值的运算
1 | 1+Math.pow(2,9999) |
个人理解为js对某些特殊值的运算采用了极限的思想,再加上浮点数的舍入机制,导致js在某些情况下的运算很难摸清规律
函数
函数创建
1 | function add(a,b){return a+b} |
2.函数表达式结合匿名函数
1 | const add=function(a,b){return a+b} |
3.立即执行函数IIFE
1 | (function(){console.log("hello")})() |
4.箭头函数
1 | const add=(a,b)=>{return a+b} |
作用域
var,let,const
特点 | var | let | const |
---|---|---|---|
作用域 | 全局或函数 | 块级 | 块级 |
js的作用域总结一句话就是:内部作用域可以访问外部作用域
遮蔽效应:在内部作用域使用变量时,内部作用域的变量会覆盖外部作用域的同名变量
改变运行时的作用域
eval
和php,python一样,js的eval也是把字符串当做代码执行,执行期间的作用域相当于调用eval的作用域,也就是evalExample函数的作用域,所以完成了对msg的覆写
1 | function evalExample(){ |
with
with接受一个对象,后面跟着一个块作用域,在with的块作用域中可以直接使用对象的属性而无需加对象名
1 | var obj={ msg : "i am lanb0" } |
这里的作用域从内到外分别是:
with块=>evalExample函数=>全局作用域
全局/函数作用域 略过
没啥可说的,就是函数可以访问到全局作用域,但是全局访问不到函数作用域
闭包
首先看文章解释:
[1]: https://baike.baidu.com/item/javascript%E9%97%AD%E5%8C%85/1570527 “javascript闭包”
[2]: https://blog.csdn.net/linfeng_meng/article/details/126709451 “JS闭包详解_孟琳丰的博客-CSDN博客”
这是我找的2篇比较精简而且讲的比较透彻的文章,然后再看看老师上课给的例子
1 | for(var i=0;i<=5;i++){ |
我们预期的是每隔1秒输出当前i的大小,从0开始每次递增1,但结果确实每隔一秒输出一个6
本质原因我认为是2点,
一是setTimeout函数是将要回调的函数安排在了当前主线程的任务之后执行,也就牵扯到到了js的异步任务:各个任务推入”任务队列”中,只有在当前的所有同步任务执行完毕,才会将队列中的任务”出队”执行。所以不论我们把timeout的延迟设定多少,即使为0,也得等当前的同步任务6次for循环执行完后,才开始执行回调函数,而此时的i已经是6了
二是var的作用域问题,在上面的讲解中我们都知道了var会把变量的作用域提到全局,而又因为回调函数内部调用了变量i,当前作用域内没有i,于是向外寻找,找到了最外部的i,此时为6,所以每个回调函数都打印了6,也就是说这6次回调函数打印的都是同一个i
最优解决方案是将var换成let,
1 | for(let i=0;i<=5;i++){ |
一开始我不理解,因为我认为let在每次执行完后,它的作用域已经销毁了,所以被let定义的i也理应随之消失,但是有闭包机制,闭包机制下的回调函数知道他要使用i这个变量,并且let是块作用域,虽然说正常情况下每个i在执行完当前循环后就没了(如果没有闭包机制),不像var定义的全局变量i都是同一个**(因为var的作用域是全局,即使执行完了for循环也依然存在全局i**),
但是闭包机制的存在会在内存中保留当前回调函数需要的块级作用域的i,也就是说在内存中前前后后保留了0-5,共计6个不同的i供回调函数使用
理解了这个,我们也就自然理解了之后老师提到的内存泄漏的安全隐患,因为如果这种i太多,当前栈不够用,就可能发生溢出了
JS对象
关于js对象类型,基本数据类型和封装类型,拆解封装就不赘述了
对象属性
属性 | 含义 |
---|---|
value | 属性的值 |
writable | 属性是否可写 |
enumerable | 属性是否可枚举 |
configurable | 属性是否可配置 |
Object.getOwnPropertyDescriptor方法用来获取一个对象属性的描述符
Object.defineProperty()方法可以修改或创建对象属性,并设置他们的描述符
例如
1 | let obj = {name: "Alice", age: 25}; |
对于可枚举和可配置的概念,具体解释为:
- enumerable 为 true 时,表示属性可以在 for…in 循环或 Object.keys() 方法中被枚举。默认值为 true。
- configurable 为 true 时,表示属性可以被删除或修改。默认值为 true。
当configurable为false时,无法删除或修改属性的特性,但是可以修改属性的值。如果你想让属性完全不可变,你还需要将writable设为false。
this
- 在全局上下文中,this指向全局对象,如window或globalThis
- 在函数上下文中,非严格模式下,this指向全局对象;严格模式下,this指向undefined
1 | // 开启严格模式 ; |
严格模式的作用域
如果你在一个函数内部使用”use strict”,那么只有这个函数内部的代码是适用于严格模式的规范的,函数外部的代码不受影响。例如:
1 | function foo(){ |
如果你想让整个脚本文件都遵循严格模式的规范,你需要把”use strict”放在文件的第一行,如:
1 | // 开启严格模式 ; |
注意:如果在一个函数a内部开启严格模式,然后这个函数内部调用了另一个非严格模式的函数b,那么严格模式不会作用与函数b
函数和方法
函数和方法的区别主要在于它们是如何被调用的。一般来说,函数是指独立存在的、可以被直接调用的代码块,如:
1 | function foo(){ |
方法是指属于某个对象的、通过对象属性访问的函数,如:
1 | var obj = { |
在函数上下文中,this的值取决于函数是如何被调用的。如果函数是直接被调用的,那么this的值默认指向全局对象,如:
1 | function foo(){ |
但是,如果函数是作为对象的属性或方法被调用的,那么this的值指向该对象,如:
1 | var obj = { |
在方法上下文中,this的值总是指向调用该方法的对象,无论该方法是定义在哪个对象上,如:
1 | var obj1 = { |
显式绑定
使用call,apply,bind方法可以显示的绑定this的指定对象
1 | function foo(){ |
注意:bind方法必须有一个函数变量可供返回
new绑定
new关键字会将this绑定到新创建的对象中
1 | function Person(name){ |
箭头函数
箭头函数没有自己的this,其中的this会继承包含他的普通函数或全局作用域
绑定优先级
目前已知的绑定规则有四种:默认绑定、隐式绑定、显式绑定和new绑定。
默认绑定是指函数直接被调用时,this指向全局对象(非严格模式)或undefined(严格模式)。
隐式绑定是指函数作为对象的属性或方法被调用时,this指向该对象。
显式绑定是指使用call,apply,bind等方法来改变函数执行时的this指向。
new绑定是指使用new操作符来调用构造函数时,this指向创建的实例对象。
目前已知的绑定优先级是:new绑定 > 显式绑定 > 隐式绑定 > 默认绑定。
绑定丢失(setTimeOut)
setTimeOut造成的绑定丢失是指当我们把一个函数作为第一个参数传递给setTimeOut时,这个函数会在指定的延迟时间后被调用,但是这个函数的this指向会丢失原来的对象,而指向全局对象(非严格模式)或undefined(严格模式)。
setTimeOut造成的绑定丢失的根本原因是因为当我们把一个函数作为第一个参数传递给setTimeOut时,我们只是传递了一个函数的引用,并没有指定它属于哪个对象,所以当这个函数被调用时,它会使用默认绑定规则来确定this指向。
setTimeOut造成的绑定丢失可以通过使用显式绑定(call,apply,bind)或箭头函数来避免,因为这些方法可以保证函数的this指向不被修改。
1 | var name = "global"; |
测试
下面的代码输出什么?
1 | const obj={ |
答案:
hello Alice
hello global
第一个很好理解,隐式绑定,greet1是个普通函数,被obj调用,所以this指向obj对象上下文
第二个不太好理解,我是这样理解的,箭头函数没有自己的this这句话倒不如说是箭头函数和创建它时的那个创建者共享一个作用域
,greet2是在obj中创建的,greet2与创建它的obj共享一个作用域,而obj对象是在全局作用域下创建的,所以greet2箭头函数的作用域也是全局作用域,所以this指向的就是全局作用域