Overview
Machine author: irogir. Main skills: side-channel enumeration (timing attack), mass
assignment, LFI + PHP wrappers, arbitrary file upload (timestamp brute force), git history
leak, Axel .axelrc misconfiguration for arbitrary file write as root.
Chain: timing attack, user enumeration, password guess, mass assignment (role=1), LFI
(image.php), php://filter (source disclosure), arbitrary upload + MD5(time()) brute
force, RCE, git log in a backup, password reuse, SSH as aaron,
sudo netutils (axel), .axelrc default_filename, overwrite
/root/.ssh/authorized_keys, SSH as root.
Reconnaissance
| |
22/tcpOpenSSH 7.6p1 (Ubuntu)80/tcpApache 2.4.29, “Simple WebApp”, redirects to./login.php,PHPSESSIDwithoutHttpOnly
| |
Interesting files (302 to login.php, so they need a session): upload.php, profile.php,
header.php, image.php, js/, login.php, logout.php. upload.php and image.php
exist but are behind auth.
Initial Access
Side-channel enumeration (timing attack)
Logging in with random data returns “Invalid username or password”. When the username
exists (for example admin), the response is noticeably slower.
Measure with time (bash; zsh does not support this syntax):
| |
The application computes a bcrypt hash only when the user exists. bcrypt is deliberately
slow (work factor $2y$10$...), so the presence of a user is revealed by a ~1 s
difference: user enumeration via a timing side channel.
Automation:
| |
Result: admin and aaron are valid.
Password: trivially aaron:aaron. Alternatively Hydra:
| |
The threshold depends on your RTT to HTB. Measure a known nonexistent user and a known
existing user (admin), take the midpoint. Too low means false positives; too high means
missed valid users.
Mass assignment / IDOR on role
After login, “Edit Profile” is unlocked. POST /profile_update.php returns a JSON user
state with a hidden parameter:
| |
role is not in the form but is in the response. Inject it into the request:
| |
The server accepts it, role:"1". After refreshing, an Admin panel (avatar upload)
appears.
Mass assignment: the backend binds all POST parameters to the user object without a whitelist. Hiding a field only in HTML/JS is not access control.
LFI (image.php)
In the admin page source:
| |
In js/avatar_uploader.js:
| |
image.php?img=<path> loads files, a candidate for LFI.
| |
Rendering (not source disclosure) reveals that image.php uses include(), which also
gives RCE later.
PHP filter wrapper, read the source:
| |
php://filter/convert.base64-encode returns content as base64 (bypassing the include
that would otherwise execute PHP). Ideal for source audit.
Analysing upload.php, how the filename is built
| |
Key observations:
- A quoting bug:
'$file_hash'is in single quotes, so PHP does not interpolate the variable. The literal string"$file_hash"goes into MD5, not theuniqid()value. One component is constant and known. - The other component is
time(), the Unix timestamp at upload (known to the second). - Name =
md5("$file_hash" . <timestamp>) . "_" . <original_name>. - Only the extension is checked (
jpg); the content is not validated, so PHP inside a.jpgis possible. - The upload goes to
images/uploads/.
The filename is fully predictable, so brute-force by timestamp.
RCE, upload + brute force + LFI
| |
| |
Apache will not execute PHP in .jpg, but image.php uses include():
| |
RCE as www-data.
Lateral Movement
Firewall, no reverse shell
| |
Reverse shell is out; work through the web RCE.
Backup in /opt
| |
Git history
| |
Diff of db_conn.php:
| |
The DB password is in the commit history. Test reuse for SSH:
| |
user.txt is in /home/aaron. Secrets removed in a newer commit are still in history.
Tools: git log -p, gitleaks, truffleHog.
Privilege Escalation
Axel .axelrc (arbitrary file write as root)
| |
netutils.jar is unreadable, so study behaviour, not code.
Identify the underlying tool:
| |
In the listener:
| |
This is Axel, a download accelerator. Root runs axel to download files.
Research the tool:
| |
The FILES section:
| |
Example config in /usr/share/doc/axel/examples/:
| |
Exploit: when downloading a directory / index page (a URL ending in /, with no specific
filename), axel uses default_filename to decide the save path. We control ~/.axelrc
(our own home), and axel runs as root, so this is an arbitrary write as root.
| |
root.txt is in /root.
The server.py snippet in the public writeup omits the imports
(import http.server, socketserver), causing NameError: name 'http' is not defined.
Add the imports.
The URL must end in /: if you supply http://IP/file, axel takes the name from the URL
and ignores default_filename.
Why this works, a methodology
When you can run something as root (sudo -l) and it invokes a known external tool, do
not guess a vulnerability; enumerate the tool’s configuration mechanisms. Every proper
Unix tool has three influence vectors:
- Environment variables (for example
LD_PRELOAD,PATH,HOME, tool-specific). - Config files, usually in two places:
- system-wide:
/etc/<tool>rc - per-user:
~/.<tool>rc(in HOME), which you control
- system-wide:
- Arguments / input you supply.
Step by step:
- How did we know it was axel? Not from a config; we told netutils to connect to our
listener and read
User-Agent: Axel/2.16.1. The first instinct with an unknown wrapper: make it talk to you and see how it identifies itself. - How did we know axel reads
~/.axelrc? Fromman axel, the FILES section. The Unix convention of a per-user rc file in$HOMEis universal (.bashrc,.vimrc,.gitconfig,.wgetrc,.curlrc), so for any tool run as root, check for an rc file in HOME. - Where did
default_filenamecome from? From the example config (/usr/share/doc/axel/examples/), which the man page points to. Read the parameters asking “which parameter controls WHERE the downloaded file lands?”. - Why is that privesc? axel downloads files and writes them to disk, runs as root, and we
control both the destination (
default_filenamein our~/.axelrc) and the content (served by our HTTP). That is arbitrary file write as root. Canonical targets:/root/.ssh/authorized_keys(used here),/etc/passwd//etc/shadow,/etc/sudoersor/etc/sudoers.d/*, cron (/etc/cron.d/*,/etc/crontab), a script/binary run by root.
Why does axel read .axelrc from /home/aaron, not /root, when it runs as root?
Because sudo on this machine (Ubuntu 18.04, env_reset in sudo -l) does not reset
$HOME to root’s home by default; it keeps the calling user’s HOME unless
always_set_home/set_home is enabled. axel does open($HOME/.axelrc) =
/home/aaron/.axelrc even though the process is uid 0. Always check the flags in
sudo -l (env_reset, env_keep, secure_path, always_set_home); they decide which
variables (including HOME, PATH) the root process sees.
Checklist for “sudo wrapper invokes tool X”:
sudo -l: what, as whom, with which flags.file+catthe wrapper: which tool it actually runs.- If you do not know the tool, make it identify itself (listener +
User-Agent,--version,strace,ltrace). man <tool>: FILES, ENVIRONMENT, CONFIGURATION sections.- Check the per-user rc in HOME (
~/.<tool>rc); you control it. - Check environment variables that influence behaviour (do
HOME/PATHpass through sudo?). - Find a parameter that gives arbitrary write / arbitrary command / path hijack, then pick a target (authorized_keys, sudoers, cron, passwd).
Detection and Mitigation
| Stage | Vulnerability | Root cause | Fix |
|---|---|---|---|
| User enum | Timing side channel | bcrypt only computed for existing users | Constant-time response / dummy hash for nonexistent users |
| Privilege bump | Mass assignment (role) | All POST params bound without a whitelist | Field whitelist, server-side checks |
| Source disclosure | LFI + php://filter | User-controlled include() | Path whitelist, basename, no wrappers |
| RCE | Arbitrary upload + predictable name | Only extension validated, md5(time()) | Content validation (magic bytes), CSPRNG names, upload outside the web root, no execution |
| Lateral | Secret in git history | Commit plus “removal” in a newer commit | Rotate secrets, git filter-repo, secret scanning in CI |
| Privesc | Axel .axelrc default_filename | root reads config from the user’s HOME (arbitrary write) | always_set_home in sudoers, no sudo on tools that read user config |
Lessons Learned
- A timing side channel enumerates users when an expensive operation (bcrypt) runs only for valid accounts.
- Hidden form fields are not access control.
php://filterreads source without executing it.- A predictable upload filename is brute-forceable.
- Removed git secrets are still in history.
- For a sudo wrapper around a known tool: enumerate its config mechanisms, do not guess.
Command Reference
| |