在JS中,一个函数内是否可访问某个变量,要看该变量的作用域(scope)。最近在看一些函数时发现作用域提升的情况还是很多的,我把这些情况称为"变量升级"。在这里对其中一些情景进行浅层的剖析,希望有师傅可以深一步挖掘实际应用中的场景。
在此之前要区别一个官方概念叫做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);
}
不难发现变量x
、y
以及函数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();
像我这种开发功底不好、安全功底也不强的程序员,就容易会写出如下这样的代码
<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
在写这篇文章的时候恰巧打了一场midnightCTF
,其中Crossintheroof
这道题牵扯了一些变量声明的知识点,在这顺带做个总结。
XSS题目,要求我们能够alert(1)
即可
题目的全部代码如下
<?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代码也无法执行。
第一感觉就是在catch
里动手脚,怎么能进去catch
呢?可以看到题目是没有过滤<
的,那么是否通过注释使解析错误进入catch
?尝试了一下发现不行,原因如下
也就是说JS能够捕获的只是runtime errors
,不能捕捉解析器在初始分析时的错误。因此这个方法行不通
再回到try
中,既然我们要突破return
的限制,就需要找一个比它优先级还要高的语句,这时候就联想起前文提到的函数和变量的声明。
我们可以自己声明一个location
变量,局部变量的优先级高于全局的window.location
,这样就避免了跳转的执行。同时,我们用const
声明location
就可以在location
赋值时产生一个runtime error,一举多得。
最终poc如下:
xss=alert(1);%0a+const+location=1;
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
用法还是比较有趣的,它的产生方便了对象的属性调用,有了它就不需要重复引用对象本身。我们先来看一个正常的例子。
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用户,前端根据地址栏的判断进行不同模型操作
如果我们是admin
时,模型中的两个属性值就分别为page、title
。此时with
判断location
在此条作用域链中不存在,并将其升级为全局作用域,即改变了全局的location
从而产生xss
之所以说这是个极端的Demo,因为产生了先有鸡还是先有蛋的问题。如果我们成为了admin,那还要xss干嘛呢(/狗头/)
最后来看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
,这是因为
变量升级这类问题在很多函数中应该都会存在,这里只是粗浅的一瞥。待日后有时间再去进一步填坑。得力于ES6后支持let
和const
,极大的避免了这类问题,不过这也要看开发人员的规范程度。如果可以,我真心希望他们开发的不那么规范,让以后的我也能有口饭吃:)