MongoDB aggregation injection — $facet allowlist bypass → cross-collection read
When it applies
- An API runs a user-controlled aggregation pipeline on a fixed collection
(
db.collection('x').aggregate(userPipeline)), usually exposed as apipelinequery/body parameter for "advanced" search. - The server defends with a stage allowlist — only screens the top-level stage
names (
$match/$project/$sort/$limit/$facetallowed;$lookup/$unionWith/$group/…rejected with something like"invalid or disallowed pipeline stage"). - Tell-tale that a
pipelineparam even exists: sending the normal search term as a Mongo operator object (?q[$ne]=x) returns a hint such as"Operator-form queries not accepted on 'q'. Use the 'pipeline' parameter…".
Why it works
The allowlist inspects only the outermost stage keys. $facet runs sub-pipelines
whose stages are never re-screened by the app, so a disallowed read stage placed
inside a $facet sub-pipeline reaches MongoDB unchecked. MongoDB itself still forbids
a few stages inside $facet ($out/$merge/$collStats/$indexStats/$listCatalog/$documents),
but it permits $lookup and $unionWith there — and those read other collections
in the same database. That turns a "search our metadata" endpoint into "read any
collection in this DB".
Method
- Confirm the pipeline sink & allowlist. Baseline
?pipeline=[{"$limit":1}]returns docs;?pipeline=[{"$count":"n"}]/$group/ top-level$unionWith→ "disallowed stage". - Leak the namespace. Trigger a Mongo error (e.g.
$facetcontaining a stage Mongo rejects) — the 500 body usually includes"ns":"<db>.<collection>". Now you know the DB. - Read a sibling collection (drop the base docs first so output is only the target):
[{"$facet":{"r":[ {"$match":{"<anyfield>":"__none__"}}, {"$unionWith":{"coll":"<target_collection>","pipeline":[{"$limit":20}]}} ]}}]$unionWithhere is legal ONLY inside$facet(top-level → allowlist rejects it). - Enumerate collection names you don't know: build one
$facetwith many sub-pipelines, each$unionWith-ing a candidate name +$limit:1; names that return docs exist & are non-empty. Brute the same naming style as any collection you already know (e.g. knownmds_entries⇒ trypending_invites,operator_accounts,invite_tokens). - Loot the target collection (invite tokens, password hashes, session docs, API keys) and pivot (register/login, crack hashes, forge sessions).
Tools
curl -G --data-urlencode 'pipeline=…'(single-quote so the shell doesn't eat$), or a short Pythonurllibhelper that JSON-encodes and URL-encodes the pipeline (cleaner for building big$facetmaps and parsing results). Seeaegis/exploit-dev/*.pyfor a worked helper.
Gotchas
- Shell
$expansion:"?q[$ne]=x"in double quotes becomes?q[]=xin bash/zsh — you'll wrongly conclude "not injectable". Use single quotes /--data-urlencode. $unionWith/$lookupare same-database only. Cross-DB{from:{db,coll}}is a hard MongoDB block ("not supported for db: X") for everything except a couple ofconfig.*system namespaces (empty on standalone). If the app data seems missing, it's almost always a collection-name you haven't guessed in the same DB — not a different DB.$collStats/$listCatalog/$documentscan't nest in$facet(Mongo rejects) — so you can't cheaply list empty collections; rely on$unionWithname-brute for non-empty ones.- Empty
$unionWith/$lookupresult = collection empty or absent; the two look identical.
Verify success
Step 3/4 returns documents whose fields differ from the base collection (e.g. a
token/password/session field where the endpoint should only ever return metadata).
That is data exfiltration from a collection the endpoint never intended to expose.
Learned on
AEGIS lab, 2026-08 — GET /api/v1/aegis-mds/search?pipeline= on aegis_mds.mds_entries;
$facet→$unionWith pending_invites leaked an unredeemed 64-hex WebAuthn invite token →
register/begin (attestation:"none") → software authenticator → login. Full worked example
in aegis/notes.md (§4–6).