Checkout my new platform for sharing the tunes of your life! 🎶
Enumeration
Register an account, notice we are given a login hash to save, e.g. 25d6a4cec174932f1effd56e2273be5198c3be06ddf03ab380a7ffc4cf3ef4e8.
We can generate profile card which creates a PDF but [by default] the request/response doesn't show in burp, let's make some adjustments:
Set burp scope to https://mymusic.ctf.intigriti.io and tick the only show in-scope items option in HTTP history tab (reduces noise, especially from spotify requests).
Tick images and other binary in the the filter by MIME type section of the HTTP history options (ensuring our PDF generation is shown).
We can update three sections of our profile; First name, Last name and Spotify track code. For each element we add some HTML tags, e.g. <b>crypto</b> and try to generate a new profile card. All three elements are reflected in the PDF document, but only the Spotify track code is in bold.
Now we have found HTML injection, we can try and server-side XSS to identify the file structure.
It also revealed some new paths (/middleware/auth, /middleware/check_admin, /utils/recommendedSongs and /utils/generateProfileCard), which we'll return to shortly.
api.js deals with the register/login/update functionality, nothing particularly interesting.
constexpress=require("express");const { body,cookie } =require("express-validator");const {addUser,getUserData,updateUserData,authenticateAsUser,} =require("../controllers/user");constrouter=express.Router();router.post("/register",body("username").not().isEmpty().withMessage("Username cannot be empty"),body("firstName").not().isEmpty().withMessage("First name cannot be empty"),body("lastName").not().isEmpty().withMessage("Last name cannot be empty"), addUser);router.post("/login",body("loginHash").not().isEmpty().withMessage("Login hash cannot be empty"), authenticateAsUser);router.get("/user", getUserData).put("/user",body("firstName").not().isEmpty().withMessage("First name cannot be empty"),body("lastName").not().isEmpty().withMessage("Last name cannot be empty"),body("spotifyTrackCode").not().isEmpty().withMessage("Spotify track code cannot be empty"),cookie("login_hash").not().isEmpty().withMessage("Login hash required"), updateUserData );module.exports= router;
However, it does give us a new file to check out (/controllers/user).
Returning back to the four new endpoints we found in app/routes/index.js. The most interesting is likely to be check_admin.js. Why is this most interesting to us? Because we want to be admin, of course!
We already knew that our userData would be inserted to the PDF (that's how we were able to inject code), but what's this userOptions parameter? Let's go find out!
undefined, which means the PDF will not be written to disk
Exploitation
OK, enough with the recon. exploit time!
When we try to access the /admin endpoint, it will parse our userData JSON object and return a 403 error if it does not contain isAdmin: true.
However, if the JSON object cannot be parsed (as hinted earlier), then the code following the if statement (that returns a 403 response) will not be reached.
Does this mean we won't be rejected from viewing the admin page? Let's find out!
Attack Plan
We know we can control the userOptions which is passed to the puppeteer pdf function.
We identified the path property that allows us to control the location where the generated PDF will be stored.
We found that user objects are stored in /app/data/<login_hash>.json
Putting all this together, we create a payload that will overwrite our user object with a generated PDF.
We send this payload in the POST request used to generate a PDF (make sure to set content-type to application/json). When we login with the hash and return to /admin, we get the flag.