Naughty or Nice
Naughty or Nice is a challenge in which we are given the source code as well as a hosted copy of a web app that we need to hack in order to find the flag.
Prompt
All the Santa’s nice elves have been added to the naughty list by the wicked elves and Santa is mad! He asked you to hack into the admin account of the Naughty or Nice portal and retrieve the magic flag that will let Santa finally banish the evil elves from the north pole!
First Impressions
In this challenge, our app starts with a greeting card that, when you click on it, displays a “naughty” list and a “nice” list. And, as the prompt warned us, everybody is on the naughty list and there isn’t anybody on the nice list! :o
Navigating to the login page it looks pretty standard. I enter a few common passwords but don’t expect anything since this CTF is not focused on that.
In the source of the login page, the only thing that stands out to me is how they’re using a variable to construct the URL to use for the backend login request.
<!-- /login -->
<script>
const intent = 'login';
</script>
<script type="text/javascript" src="/static/js/auth.js"></script>
await fetch(`/api/${intent}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
There’s a link to register an account so let’s go ahead and register one. On this page there’s the same javascript for talking to the backend only now the intent
is register
. It seems like this was just a way to make generic backend requests.
Once registered and logging back in we get sent to /dashboard
but get an Access Denied error!
When we logged in however we were issued a JWT with the following content:
// header
{
"alg": "RS256",
"typ": "JWT"
}
// data
{
"username": "myuser",
"pk": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhtBBU8Wk1LbaoktqXSxn\nKB0t7aX0UNlEG8BKcwqByjq5icvYLpqrqJudn3QadIkB7baI6Doz4CBH52q2qir3\nf3XaIdSO0b6HYLUF7H8dvym2fY6isaYu5olWsf2VLzZL39ayzI3B7PdmjNnQGC7n\n4XXN7ZL6Uzy72Hk4CeLofIqpoG8Ls1OjjpA48MCggIKembcYbVxBGvApGheuPd4C\nf4dtLae51itGJj+FmLBPEW5mM88NtDPdNENUxShIhF7q9gSYMssIQxE3GxOE5aKx\nfaXgI6GfTJR6Y6fGpvN5cX4f0uz8OG0qHqQn4/MpeVEJhGtmOLCaX2DBxfFDgSoB\nMwIDAQAB\n-----END PUBLIC KEY-----",
"iat": 1638713500
}
The algorithm used here is RS256
which is an assymetric algorithm in which we are given the public key as a means to verify the the issuer of the token we received. The app doesn’t use this key however and instead uses the signature that was made with its private key. There is a fairly well known class of vulnerabilities that apps might let an attacker change the algorithm to HS256
(which is most common imho). When that happens, the attacker can forge the message by signing it with the given public key. This happens when a vulnerable app’s implementation of JWT validation erroneously uses the public key when it attempts to verify the token.
If that’s the case, we’ll be able to forge our JWT to make us another user. Let’s review the source and come back to this idea.
Source code review
In this challenge we’re working with a node.js app built using the express framework. The flag is in the download as /flag
.
In package.json
we’re installing nunjucks 3.2.0
which is a templating library with a known prototype pollution vulnerability. Let’s keep that in mind and keep digging.
database.js
In database.js
an admin
user is created with a random password when the app first signs up. When we forge our cookies, that is the username we will use.
Also noted while we’re here is that all of the queries are prepared so I’m not expecting any SQL injection vulnerabilities unless something interacts with the database outside of this class.
JWTHelper.js
In JWTHelper.js
we confirm what we suspected: that the token verification also accepts RS256
based on the public key.
async verify(token) {
return (await jwt.verify(token, publicKey, { algorithms: ['RS256', 'HS256'] }));
}
This means that this app is vulnerable and we will be able to forge our web token, allowing us to become admin
.
CardHelper.js
CardHelper.js
contains how the naughty/nice card is generated. Looking closely at it, it creates a template string containing nunjucks
template code but also accesses some variables directly; those variables are NiceNames
and NaughtyNames
. If we can control either of those variables, this would be Server Side Template Injection (SSTI)!
routes/index.js
A brief summary of the lesser-important routes:
GET /
generates our greeting card using theCardHelper
class discussed earlierGET /login
does what it says on the tinGET /register
does what it says on the tinGET /dashboard
renders a different template based on whether we’re theadmin
user (dashboard) or not (access denied)- Note: When you see this type of code, it’s worth checking how the API checks permissions too. An app could just render the other template but also allow calls directly to the API. In this challenge, this is not the case and the API routes below do correctly check that the user is
admin
.
- Note: When you see this type of code, it’s worth checking how the API checks permissions too. An app could just render the other template but also allow calls directly to the API. In this challenge, this is not the case and the API routes below do correctly check that the user is
POST /api/login
does what we already know: validates our credentials then issues us a signed JWTPOST /api/register
does what it says on the tinGET /logout
does what it says on the tin
Okay, now the good stuff, API routes!
GET /api/elf/list
allowsadmin
to list all of the elvesPOST /api/elf/edit
allowsadmin
to change elf names and whether they were naughty or nice
Looking at POST /api/elf/edit
, it doesn’t appear to sanitize the input for any special characters or sequences for the nunjucks
template language such as {{
or anything. This means that by changing the name of any of the elves, we may very well get our SSTI!
Putting it all together
To get the flag in this challenge we have to do two things:
- Forge our cookie to make ourselves the admin user
- User our admin privileges to edit the name of one of the elves to use the SSTI to get Remote Code Execution
JWT Forging
To forge the JWT in my cookie and gain access as admin
, I :
- Logged in as normal and copied the JWT out of my cookies; at this point the JWT is a valid token that identifies me as my legitimate user and is signed by the app
- Copied the JWT into https://jwt.io
- Changed the algorithm in the JWT’s header from
RS256
toHS256
-
Copied the public key from the original JWT, base64-encoded it, then copied it into the “your secret” field and checked the “secret is base64 encoded” checkbox; as it turns out, the JWTs from the app are signed with the PEM-encoded public keys and not the raw bytes of the key. This threw me off for a short while but you can see it happen right here:
const publicKey = keyPair.exportKey('public') const privateKey = keyPair.exportKey('private')
As it happens,
exportKey
returns the PEM-encoded versions of the keys. - Change the username in the JWT to
admin
At this point, I now have a valid JWT that I can set in my cookies and access the dashboard I previously did not have access to.
Server Side Template Injection to RCE
If you look up the documentation for nunjucks the very first thing at the top of their docs is a warning about letting users write their own templates :)
User-Defined Templates Warning nunjucks does not sandbox execution so it is not safe to run user-defined templates or inject user-defined content into template definitions. On the server, you can expose attack vectors for accessing sensitive data and remote code execution. On the client, you can expose cross-site scripting vulnerabilities even for precompiled templates (which can be mitigated with a strong CSP). See this issue for more information.
In the issue linked above there are two other links that show how to exploit the prototype pollution in nunjucks. My payload was slightly different; I forgot where I got it from but I stumbled upon it while googling around.
In any case, changing an elve’s name to the following gets us our flag!
{{range.constructor("return global.process.mainModule.require('child_process').execSync('cat /flag.txt')")()}}