谈谈JavaSript中的变量升级

在JS中,一个函数内是否可访问某个变量,要看该变量的作用域(scope)。最近在看一些函数时发现作用域提升的情况还是很多的,我把这些情况称为”变量升级”。在这里对其中一些情景进行浅层的剖析,希望有师傅可以深一步挖掘实际应用中的场景。

在此之前要区别一个官方概念叫做Hoisting(变量提升)

Hoisting(变量提升)

我们先来看看MDN Web 文档中写了一个Hoisting(变量提升)的例子

var x = 1;  
console.log(x + " " + y);  // '1 undefined'
catName("Chloe")        //'Choloe'

var y = 2;
function catName(name) {
    console.log("我的猫名叫 " + name);
}

不难发现变量xy以及函数catName在代码执行前被声明,那么等效的代码形式如下:

var x=1;
var y;
function catName(name) {
    console.log("我的猫名叫 " + name);
}
y = 2;
console.log(x + " " + y);  // '1 undefined'
catName("Chloe")        //'Choloe'

这样的一种声明方式就被叫做变量提升,从概念的字面意义上说,它意味着变量和函数的声明会在物理层面移动到代码的最前面。可这么说并不准确,毕竟JavaScript是单线程语言,执行肯定是按顺序。但也不是逐行的分析和执行,而是一段一段地分析执行,会先进行编译阶段然后才是执行阶段。

在编译阶段,会检测到所有的变量和函数声明。所有这些函数和变量声明都被添加到名为JavaScript数据结构内的内存中–即执行上下文中的变量对象Variable object(VO)。如果你对这部分感兴趣可以看冴羽牛的:JavaScript深入之变量对象

当然在函数内部的声明也是如此

var username = 'hpdoger';

function echoName(){
    console.log(username);  //undefiend
    var username = 'wuyanzu';
}

echoName();

那么了解了这个概念,接下来才到了今天要探讨的主题–变量升级

变量升级

声明错误

不论在最外层还是函数内部,不加限制类型的函数、变量会自动升级为全局作用域

function echoName(){
    var username = 'hpdoger'
    nickname = 'wuyanzu';
}

function CheckVal(){
    console.log(nickname); //wuyanzu
    console.log(username); //Uncaught ReferenceError: username is not defined
}

echoName();
CheckVal();

案例1-Fake Protect

像我这种开发功底不好、安全功底也不强的程序员,就容易会写出如下这样的代码

<html>
<body>
<script>
  const whiteList = ['index.html','404.html','hpdoger.html'];

  function init(){
      const Content = new Object();
      Content["title"] = "XSS Demo";
      page = location.hash.slice(1);

      if(!whiteList.includes(page)){
        Content["page"] = "404.html";
      }else{
        window.page = page;
      }
      return Content;
  }

  function loadPage(page){
      window.open(page);
  }

  let Content = init();
  alert(Content["title"]);
  page?loadPage(page):loadPage(Content.page);

</script>
</body>

</html>

这是一个实用性为0的XSS防御案例,代码本意是为了location.hash.slice(1)进行过滤,如果在白名单之内就定义window.page,之后我们优先判断全局的page来open,否则使用Content["page"]进行open。

然而,由于使用了page = location.hash.slice(1);这样的写法,导致整个过滤是无效的。恶意payload仍能被升级为全局变量,相当于自己给自己写了个xss

-w856

案例2-midnightCTF(Crossintheroof)

在写这篇文章的时候恰巧打了一场midnightCTF,其中Crossintheroof这道题牵扯了一些变量声明的知识点,在这顺带做个总结。

XSS题目,要求我们能够alert(1)即可
-w538

题目的全部代码如下

<?php
 header('X-XSS-Protection: 0');
 header('X-Frame-Options: deny');
 header('X-Content-Type-Options: nosniff');
 header('Content-Type: text/html; charset=UTF-8');

if(!isset($_GET['xss'])){
    if(isset($_GET['error'])){
        die('stop haking me!!!');
    }

    if(isset($_GET['source'])){
        highlight_file('index.php');
        die();
    }

    die('unluky');
}

 $xss = $_GET['xss']?$_GET['xss']:"";
 $xss = preg_replace("|[}/{]|", "", $xss);

?>
<script>
setTimeout(function(){
    try{
        return location = '/?i_said_no_xss_4_u_:)';
        nodice=<?php echo $xss; ?>;
    }catch(err){
        return location = '/?error='+<?php echo $xss; ?>;
    }
    },500);
</script>
<script>
/* 
    payload: <?php echo $xss ?>

*/
</script>
<body onload='location="/?no_xss_4_u_:)"'>hi. bye.</body>

注释符肯定不能bypass,仅剩一个功能点就是setTimeout。可是在try里开门见山的就return location了,导致后面即使可以注入JS代码也无法执行。
-w654

第一感觉就是在catch里动手脚,怎么能进去catch呢?可以看到题目是没有过滤<的,那么是否通过注释使解析错误进入catch?尝试了一下发现不行,原因如下
-w808

也就是说JS能够捕获的只是runtime errors,不能捕捉解析器在初始分析时的错误。因此这个方法行不通

再回到try中,既然我们要突破return的限制,就需要找一个比它优先级还要高的语句,这时候就联想起前文提到的函数和变量的声明。

我们可以自己声明一个location变量,局部变量的优先级高于全局的window.location,这样就避免了跳转的执行。同时,我们用const声明location就可以在location赋值时产生一个runtime error,一举多得。

最终poc如下:

xss=alert(1);%0a+const+location=1;

-w872

for循环遍历

for中使用var定义变量时也存在升级的问题。这种案例到处都是,我们就仿照菜鸟教程上关于for示例的写法来打印一个数组

names = ['55kai','pdd','dasima'];
for (var i=0;i<names.length;i++)
{ 
    if(names[i] === 'dasima'){
      console.log('wuhu~');
    }else{
      console.log(names[i]);
    }
}

此时我们在控制台中打印i会得到结果3,说明变量i随着循环的进行被提升为for范围外层的变量。然而这个提升的程度不是在全局作用域,而是提升为当前作用域下的变量。假如我们在函数内循环,i的作用范围也就限制于函数内,这点和PHP是相同的。

倘若我们没有加var的限制,变量i依然会被提升为全局作用域,相当于在上个例子的基础上套了个娃。

with表达

-w816
-w702

with用法还是比较有趣的,它的产生方便了对象的属性调用,有了它就不需要重复引用对象本身。我们先来看一个正常的例子。

myobj = {
    name : 'hpdoger',
    sex : 'boy'
}

console.log(myobj.name)  //hpdoger
console.log(myobj.sex)  //boy

with(myobj){
    console.log(name)  //hpdoger
    console.log(sex) //boy
}

进行一点小拓展:Javascript中对非基本数据类型的引用是引用传递,那么也就是说我们可以通过with来返回一些意料之外的东西

比如返回Object.prototype来污染原型链

function foo(obj) {
    with (obj) {
        return __proto__
    }
}

aa = {
    name: 'boy'
}

foo(aa).name='admin'

console.log("".name) //admin

或者借助window来动态回调个函数从而xss

function genevil(foo) {
    with (foo) {
      return alert;
    }
}
genevil(window)`/hpdoger/`;

又或者是返回一个Function的构造类来RCE,参照Confidence2020-Web题解

flag = "flag{aaaa}";

function anonymous() {
    with(par)return constructor
}

function par(a) {
    console.log(123);
}

console.log(anonymous``)
console.log(anonymous`` `return flag` ``)

然而它还有一个隐藏的问题就是变量升级,这是很多开发人员不喜欢用with的原因,也是这篇文章要讨论的内容。话说回去,这次我们对变量的声明严格定义后,是否还会产生此类问题呢?看下面一个Demo

function getUrlParam(name) {
  var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
  var r = window.location.search.substr(1).match(reg);
  if (r != null) return unescape(r[2]); return null;
}

function init(Content,values='index.html'){
    with (Content) {
        title = 'XSS Demo';
        page = values;
        location = values;
    }
}

let values = location.hash.slice(1);
let uid = getUrlParam("role");

let ContentAdmin = {
  title:'',
  page:''
}

let ContentUser = {
  title:'',
  location:''
}

if(uid!=="admin"){
  init(ContentUser,values);
}else{
  init(ContentAdmin,values);
}

想象这样一个场景:开发为了方便代码的更改,于是在with中把所有类的属性都添加进去,有利于不同类属性的统一赋值。当然示例中的例子有些极端,你可以把这个Demo当作简单的XSS-Challenge来看,正常功能就比如我们是xx用户,前端根据地址栏的判断进行不同模型操作

-w815

如果我们是admin时,模型中的两个属性值就分别为page、title。此时with判断location在此条作用域链中不存在,并将其升级为全局作用域,即改变了全局的location从而产生xss

-w866

之所以说这是个极端的Demo,因为产生了先有鸡还是先有蛋的问题。如果我们成为了admin,那还要xss干嘛呢(/狗头/)

this的绑定

最后来看JS中this的指向问题,一个简单的例子如下:

var myobj = {
    getbar : function(){
        console.log(this.bar);
    },
    bar: 1

};

var bar = 2;
var getbar = myobj.getbar;

myobj.getbar()//1
getbar(); //2

我们在控制台运行这段代码,getbar()打印的是全局变量bar的值。这与之前的几个例子有所不同,它并没有提升某个变量的作用域而是将this整体的作用域上升到了全局,可以简单的这样理解


默认的 this 绑定, 就是说在一个函数中使用了 this, 但是没有为 this 绑定对象. 这种情况下, 非严格默认(strict), this 就是全局变量 Node 环境中的 global, 浏览器(Chrome)环境中的 window.


与之前几个例子有异曲同工之处,倘若我们没有严格界定this而去调用某个函数,那么也可能存在变量污染的情况。

如果你把这段代码用node执行,它打印this.bar的结果就为undefined,这是因为
-w1175

最后

变量升级这类问题在很多函数中应该都会存在,这里只是粗浅的一瞥。待日后有时间再去进一步填坑。得力于ES6后支持letconst,极大的避免了这类问题,不过这也要看开发人员的规范程度。如果可以,我真心希望他们开发的不那么规范,让以后的我也能有口饭吃:)

not found!