17

谈谈JavaSript中的变量升级

 4 years ago
source link: https://hpdoger.cn/2020/04/01/title:%20%E8%B0%88%E8%B0%88JavaSript%E4%B8%AD%E7%9A%84%E5%8F%98%E9%87%8F%E5%8D%87%E7%BA%A7/?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

在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,极大的避免了这类问题,不过这也要看开发人员的规范程度。如果可以,我真心希望他们开发的不那么规范,让以后的我也能有口饭吃:)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK