Almanac
Almanac is a private journaling app — write dated entries, keep your photos, and carry the whole archive between devices. The catch is the "carry it across" part: exporting your capsule and importing it on another device are two halves of the same trusting handshake.
The Scenario
Almanac is a three-person studio out of Galway that has been keeping people's journals since 2019. Their whole pitch is "your years, kept" — no lock-in, export to a single portable .capsule file anytime, restore it on a new phone in under a minute. To make moving devices effortless, the import feature reconstructs your archive object straight from the uploaded file and shows you its title and entry count before merging. It reconstructs that object a little too faithfully.
Challenge Intel
Synopsis
The "Import an archive" feature reconstructs the uploaded capsule with a naive pickle.loads() and renders the result's title back to the user. A crafted capsule whose title is a __reduce__ gadget runs code on the server and renders the flag in-band as the imported capsule's title.
What It Is
Almanac's Export produces base64(pickle.dumps(capsule_dict)) where capsule_dict is {"title", "created", "entries"}. The Import route (/import) does base64.b64decode() then pickle.loads() on the supplied blob with no validation, type-checking, or safe-loader — the canonical Python insecure-deserialization sink. The player recognises the format from their OWN export (the base64 decodes to a byte stream beginning with the 0x80 pickle-protocol opcode; the UI never names "pickle"), then crafts a malicious capsule. The intended payload is a dict whose "title" value is an object with a __reduce__ method returning (eval, ("__import__('subprocess').check_output(['cat','/flag.txt']).decode().strip()",)). On pickle.loads, __reduce__ executes: eval runs the command, and the object is reconstructed AS the string the command returned — i.e. the flag. The app's normal confirmation line, "Imported capsule: '<title>'", then prints that flag verbatim, in-band. Jinja2 autoescape stays on; a flag has no HTML metacharacters, so the player can at most XSS their own session — a non-issue. The flag is at /flag.txt (chmod 644, written by entrypoint) and is reachable ONLY through this code execution: there is no /flag route, no debug endpoint, and no page that prints it.
Who It's For
Players comfortable with the idea that deserializing attacker-controlled data can execute code. Assumes basic Python and the ability to run a short local script to build the payload. The twist over a trivial RCE is recognising the export's binary format from first principles and using a clean-rendering gadget so the flag arrives as readable text, not a crash.
Skills You'll Practice
- Recognising a base64'd Python pickle stream from its 0x80 opcode
- Writing a __reduce__ gadget that returns a command-execution callable
- Choosing eval(...) so the reconstructed object IS the clean output string
- Driving an import/restore feature as an insecure-deserialization sink
What You'll Gain
- pickle.loads on untrusted input is remote code execution, full stop
- Import/restore and 'portable backup' features are classic deserialization sinks
- Output encoding (autoescape) does not mitigate a deserialization RCE
- Never deserialize a format the user can hand-craft without a strict allowlist