Table of Contents Challenge Overview Background Source code analysis Bypassing URL check with path traversal Content injection in analytical-engine library Utilising base-uri hijacking for reflected XSS through broken error route handling Client-side prototype pollution and DOM clobbering with HTML named entities Final Exploit Script Conclusion
Hello everyone! My name is Strellic, member of team WinBARs on HTB, and I wrote the guest web challenge "AnalyticalEngine" for this year's HackTheBox University CTF Qualifiers. The challenge was to hack a theoretical general-purpose mechanical computer simulator website that only ran using punch cards.
This year's Uni CTF had a steampunk theme, and while researching steampunk ideas for inspiration, I ended up reading about Charles Babbage and his theoretical "Analytical Engine" on the Wikipedia article for Steampunk, which gave me inspiration for this challenge.
AnalyticalEngine was the hardest web challenge in the qualifier, only getting one solve two hours before the end of the CTF. In this blog post, I’ll be giving an overview of as well as my approach to solving the challenge.
Challenge Overview

Background
AnalyticalEngine was a web challenge, and it was just a simple site that allowed you to run custom punch cards for a JavaScript simulation of Charles Babbage's theoretical Analytical Engine. The JavaScript simulator used the “analytical-engine” npm package, which you can find on GitHub here. To solve the challenge, players had to find an XSS vulnerability in the analytical engine implementation, and then apply some complex DOM clobbering and prototype pollution to bypass the strict CSP on the site and gain JS execution to steal the flag.

The challenge was written as a NodeJS + Express web app. There was a large input field where you could input a custom punch card, and an execute button where your punch card would run and view the output.

The site lets you store and load punch cards, and checking out bot.js shows us that the flag is actually stored in the localStorage of the site, which the site uses to store saved punch cards:
await page.evaluate((flag) => {
localStorage.saved = JSON.stringify({ "Default": { "flag": flag } });
}, FLAG);
So, the goal is to either leak all of the stored cards or gain JavaScript execution on the page to obtain the flag.
Here’s an example of a custom punch card you can run:
N0 3
N1 5
+
L1
L0
P
This example card sets two variables to 3 and 5, adds them together, and then prints the result.

The analytical-engine npm package also supported drawing SVG curves, and this functionality was the main vulnerability the players were supposed to exploit in the punch card simulator.
Here, in the analytical-engine npm package source, there was a vulnerability where you could get XSS by drawing a malicious curve to the screen.
Thankfully, besides writing two lines of punch card code to draw a malicious curve, being able to write punch cards in this weird setup wasn’t really necessary to solve the challenge; the real difficulty came from exploiting the complex setup for interactions between the “front-end” and the “engine”.
I think understanding the source code and how the application works are pretty important to understand the solution, so this next part where I describe the setup is pretty long.
Source code analysis
From auditing the source code, you can see that the website works via iframe postMessage communication. The main page has an iframe tag:
which is referenced in JavaScript and is sent messages via postMessage. The site works by loading all of the analytical engine code inside of the /engine iframe, and the main page where you interact with the site just sends and receives messages from this iframe.
The /engine URL is the page with the custom logic for running & executing punch cards. In the /engine page, bundle.js and engine.js are embedded:
and the hint at the top of engine.js says that bundle.js is just NodeJS code ran through browserify:
/*
// browerserify code running in bundle.js
// provided for your convenience :>
const AE = require('analytical-engine');
window.run = (cards) => {
let inf = new AE.Interface();
inf.submitProgram(cards);
inf.runToCompletion();
return inf;
};
*/
So, bundle.js was just a client-side port of the analytical-engine npm module, and engine.js uses window.run() to run punch cards.
Players needed to gain JavaScript execution or exfiltrate the saved punch cards to get the flag, but there was a very strict CSP:
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString("hex");
res.setHeader("Content-Security-Policy", `
default-src 'self';
style-src
'self'
https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css
https://fonts.googleapis.com/css
https://use.fontawesome.com/releases/v5.12.0/css/all.css;
font-src
https://fonts.gstatic.com/s/
https://use.fontawesome.com/releases/v5.12.0/webfonts/;
connect-src 'self' https://raw.githubusercontent.com/cakenggt/;
object-src 'none';
base-uri 'self';
script-src 'nonce-${res.locals.nonce}' 'unsafe-inline';
`.trim().replace(/\s+/g, " "));
res.setHeader("X-Frame-Options", "SAMEORIGIN");
if(req.cookies.debug) res.locals.debug = req.cookies.debug;
if(req.session.msg) {
res.locals.msg = req.session.msg;
req.session.msg = null;
}
next();
});
which made getting arbitrary JS execution and bypassing this CSP very difficult.
The front-end page embeds main.js, which is the first half of the postMessage communication. It has a strict check that only allows messages from the engine frame:
window.onmessage = (e) => {
let {
data,
origin,
source
} = e;
if (origin !== location.origin || source !== $("#engine").contentWindow) {
return;
}
// snip
which looks pretty strong. The communication between the two pages happens by sending packets with specific “types” back and forth. The main page implemented the following message types:
run: gets and displays the output from the engine
msg: alerts a message
load: loads a card sent by the engine
download: loads a card sent by the engine, and then sends the run type
cookie: sets the cookie
list: updates the list of saved cards
The engine page embeds engine.js, which was the second half of the postMessage communication. Its check was weaker than the front-end page's check:
window.onmessage = async (e) => {
let {
data,
origin
} = e;
let isDebug = $("#developerMode") && $("#developerMode").innerHTML === "1";
if (!isDebug && origin !== location.origin) {
return;
}
// snip
allowing communications from an arbitrary source if the debug flag was set, which is only set if the element with ID developerMode has innerHTML "1".
The engine page implemented the following message types:
load: loads a card from CardStorage
list: lists cards
save: saves a new card
download: downloads a card from a URL
run: runs a card using window.run()
debug: sends a message to the parent to set the debug cookie to “1”, enabling debug on the next load, and refreshes the page
So, debug mode is set (aka the developerMode element is set) whenever the cookie is set to 1. Looking at how this is implemented on the server, we see in views/engine.hbs:
{
