RedpwnCTF 2021 - web/notes
As mentioned here, I took part in the redpwnCTF with some friends from Hacksouth. There were three web challenges that I worked on; two of which I collaborated on with a team member.
web/notes - Unsolved
The notes challenge was a web app for "Texting things to yourself, but online!", based off Fastify which is a Fast and low overhead Node.js web framework.
Looking at the source code, it was clear that it was a bit more complicated than the web/cool challenge, but in short:
- This app has frontend components which interact with an API;
- Users can register and login through the respective API functions;
- Logged in user can create notes and tag them as Public or Private;
- Logged in user can view notes;
- Visitors can view Public notes of any user.
By understanding the source code, and trying out the web app through registering a user and adding some notes, two main observations are made that may lead to an XSS vulnerability:
1. Although there is a dropdown to tag the note as Public or Private, there is no "backend" validation on this value before creating the note. So the value can really be set to any value.
# ./notes/modules/api-plugin.js
fastify.post(
'/notes',
{
schema: {
body: { type: 'object',
properties: { body: { type: 'string' }, tag: { type: 'string' } },
required: ['body', 'tag'],
},
},
},
(req) => {
if (!req.auth.login) throw error('Not logged in!', 401);
if (req.auth.username === 'admin')
throw error('No admin notes please!', 400);
db.addNote(req.auth.username, { body: req.body.body, tag: req.body.tag, });
return {};
}
);
2. When displaying the note to the user, there is some output validation on:
- Any of <>"'
are replaced with HTML entities on the body of the note.
- The tag of the note is truncated if it is more than 10 characters.
// ./notes/public/static/view/index.js
(async () => {
const request = await fetch(`/api/notes/${user}`);
const notes = await request.json();
const renderedNotes = [];
for (const note of notes) {
// this one is controlled by user, so prevent xss
const body = note.body
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll('\'', ''');
// this one isn't, but make sure it fits on page
const tag =
note.tag.length > 10 ? note.tag.substring(0, 7) + '...' : note.tag;
// render templates and put them in our array
const rendered = populateTemplate(template, { body, tag });
renderedNotes.push(rendered);
}
container.innerHTML += renderedNotes.join('');
})();
With that in mind, there may to be an XSS attack vector here making use of the available characters in the tag parameter of the note avoiding truncation, and perhaps constructing an XSS payload across multiple notes, bodies and tags.
This means that one can use the tag of the first note to start the payload, you can use the body of the second note for the bulk of the payload, and then finally the tag of the second note to make it a valid HTML. This may trigger a script to turn this into a usable XSS Β attack.
# Note 1:
{"body":"Anything","tag":"XSS payload < 10"}
# Note 2:
{"body":"Additional XXs Payload content","tag":"End the XXS payload < 10"}
Full disclosure; we could not get further than this, we did find tiny XSS payloads which we will remember forever, for example, <svg/onload="alert()"
. However we moved on and never returned to this challenge before the end of the CTF.
I did want to see how others managed to solve this challenge, and found Jokr's blog. It turns out we were very close before moving on; we tried a similar approach, but just didn't implement it correctly, in hindsight, we should have spent a bit more time on this and we would have probably figured it out.
Anyway, as mentioned above, splitting the payload across multiple notes and making use of the note's tag to initially start an XSS payload is a good start.
We did try a random attribute using the <svg>
tag during the challenge but could not get it to work. Even after seeing the solution, I experimented more with the <svg>
tag and a custom attribute similar to the solution, but to no avail. It does seem, however, that the <style>
tag is fine with a custom attribute.
# Note 1:
{"body":"Anything","tag":"<style a='"}
# Note 2:
{"body":"Anything","tag":"'onload='`"}
# Note 3:
{"body":"`;alert(`abc`)/*","tag":"*/'/>"}
At this point I am pretty sure that that we would have been able to obtain the admin cookie.
Because we did not complete this challenge, I decided not to explain the remaining steps which may have been required to obtain the challenge's flag. It is still important to at least learn something through understanding where we went wrong, which was the purpose of this post.
Notes for future:
- Take the time, and write a quick script using python, it will make it easier to test the different scenarios.
- Do not forget about
`
, it is just as useful as"
and'
. - This is a great tip:
eval(atob("Javascript in base46")
. - Don't be afraid to use the
<style>
tags, it may be less error prone. - IMPORTANT: Something else to remember, not related to the actual challenge, but at some point during experimentation, I removed the output encoding of
<>"'
and tried a<script>
tag in the body, but it didn't work, and this is because of the way the DOM is constructed usinginnerHTML
which turns out thatscript
elements inserted usinginnerHTML
do not execute when they are inserted, see: https://www.w3.org/TR/2008/WD-html5-20080610/dom.html#innerhtm0
There is one more post coming for a challenge which I struggled with and would like to gain some knowledge by reviewing and writing about the solutions made available after the CTF ended.
If you enjoyed the post, please consider to subscribe so that you receive future content in your inbox :)
Psssst, worried about sharing your email address and would rather want to hide it? Consider using a service I created to help with that: mailphantom.io
Also, if you have any questions, comments, or suggestions please feel free to Contact Me.