Knockdown
A maker community for flat-pack furniture. Every build exports to one portable file you can share, and anyone can paste that file back in to restore the whole design — cut list, hardware, and the rendered preview, exactly as it shipped.
The Scenario
Knockdown started as a parametric configurator for designing knock-down
furniture in the browser. The share feature came later: makers kept asking how
to send a build to a friend, so the team bolted on "Export build" — pack the
whole project object into one portable .kdb file — and an "Import a build" page
that restores it on the other side. To make imports feel instant, the export
carries the build's cached preview along with it, and the importer rebuilds the
project verbatim from whatever file you hand it.
Challenge Intel
Synopsis
The "Export build" feature hands you base64(serialize($project)), and the Project over-serializes an internal RenderCache object whose __destruct writes a file to disk. "Import a build" calls unserialize() on whatever you paste, with no allowed_classes restriction — so a forged RenderCache drops a webshell into the webroot on request teardown, and that webshell reads the flag.
What It Is
Knockdown's build pages expose an "Export build" action that returns base64_encode(serialize($project)) where $project is a PHP `Project` object. The Project keeps a nested `RenderCache` (the last rendered preview) as a property for performance, and the naive export serializes the entire object graph — so the exported blob literally contains, nested inside the Project: O:11:"RenderCache":2:{s:4:"path";s:NN:"/app/var/cache/preview_xxx.html"; s:4:"html";s:MM:"<...preview html...>";} That leak of an internal cache object into the export is the realistic over-serialization bug — it tells the player both the class name and the exact shape of the gadget. The `RenderCache` class is the single gadget: final class RenderCache { public $path; public $html; public function __destruct() { if ($this->path === null || $this->html === null) return; file_put_contents($this->path, $this->html); } } The "Import a build" page does `unserialize(base64_decode($input))` with no allowed_classes restriction (the vuln). When the imported object graph is torn down at the end of the request, RenderCache::__destruct fires and writes $html to $path. Exploit: the player exports any build, base64-decodes it, and sees the nested RenderCache{path, html}. They forge a RenderCache with path = "/var/www/html/x.php" and html = "<?php system($_GET['c']); ?>", base64-wrap it, and import it. On request teardown the destructor drops the webshell into the webroot, and `GET /x.php?c=cat%20/flag.txt` returns the flag from /flag.txt. There is exactly one magic-method gadget in the whole app (RenderCache:: __destruct). No other class defines __destruct or __wakeup, and nothing is autoloadable beyond Project and RenderCache, so stray object injection stays inert. The legitimate export's RenderCache.path points at a harmless scratch file under /app/var/cache that the app only ever *writes* — it never include()s or executes the cache path — so importing a normal build clobbers nothing load-bearing.
Who It's For
Players who know what PHP serialized strings look like and want to graduate from cookie/role tampering to a real one-gadget RCE. Prereq: comfort reading an O:/s: serialized blob, a working mental model of PHP magic methods (__destruct in particular), and basic webshell usage. Cookie-based session auth, but export is reachable as a guest.
Skills You'll Practice
- Recognizing a PHP serialized object graph inside an app's own export blob
- Spotting an over-serialized internal object (RenderCache) leaked into an export
- Identifying __destruct → file_put_contents($path,$html) as a write primitive
- Forging a malicious serialized object with correct length prefixes by hand
- Turning an arbitrary file write into RCE by dropping a webshell into the webroot
- Reading /flag.txt through the planted webshell
What You'll Gain
- The PHP object-injection → one-gadget RCE pattern, end to end
- Why unserialize() without allowed_classes on attacker input is fatal
- How a benign-looking caching method (__destruct write-through) becomes a sink
- Hand-crafting serialized payloads and getting the s:LEN: byte counts right