Overview
Altered is a Hard-rated Linux machine built for a UHC (Ultimate Hacking Championship) event. Machine author: ghost-2. Operating system: Ubuntu 20.04 Focal. IP: 10.10.10.x.
A Laravel staff dashboard leaks valid usernames through its login errors. The password
reset issues a four-digit PIN and throttles by client IP, but the app trusts
X-Forwarded-For from any client, so pairing every PIN guess with a fresh spoofed IP
defeats the limiter and recovers the PIN. An authenticated API endpoint guards its
records with an MD5-looking secret compared using PHP’s loose ==, so sending the secret
as the boolean true bypasses the check - and because the id field also accepts a
string, it opens a UNION-based SQL injection. The database account holds FILE, so
INTO OUTFILE writes a PHP webshell into the web root for a shell as www-data. The
kernel is 5.16.0 from January 2022, vulnerable to Dirty Pipe (CVE-2022-0847); a SUID
binary overwrite (or an /etc/passwd edit past a pam_wordle gimmick) gives root.
| |
Three ideas worth keeping:
- A rate limiter that keys on a client-controlled forwarding header is not a limiter.
Test the whole family (
X-Forwarded-For,X-Real-IP,X-Originating-IP,X-Client-IP); usually only one is trusted. - PHP
==against a boolean coerces most non-empty strings totrue. An integrity check written with==instead of===/hash_equals()is no check at all. - A database account with
FILEturns any SQL injection into arbitrary file read and, where the web root is writable, remote code execution - independent of the injection itself.
Reconnaissance
Nmap
| |
| |
Only two ports. The OpenSSH banner (8.2p1 Ubuntu 4ubuntu0.4) fingerprints the host as
Ubuntu 20.04 Focal. With SSH offering no obvious way in, HTTP is the entry point - it
exposes the largest attack surface, and the redirect to /login says the app gates
everything behind authentication.
Web application
Port 80 serves a “UHC Staff Dashboard” login page. Two behaviours stand out immediately.
First, the login form is a username enumeration oracle. A known account returns
Invalid Password, while an unknown one returns a message that the user does not exist.
admin is valid, and player handles taken from the public UHC winners page (for example
big0us) are valid too. Distinct error messages for “wrong password” versus “no such
user” leak account existence - a classic information-disclosure flaw that turns a public
list of names into a set of confirmed usernames.
Second, the stack is clearly Laravel/PHP:
/index.phpredirects to/index.php/login(PHP path-info behaviour), whileindex.html404s.- Responses set
XSRF-TOKENandlaravel_sessioncookies, both signatures of Laravel.
A short directory brute force with a PHP extension confirms the interesting endpoints:
| |
| |
/reset is the lead worth pulling on.
The password-reset flow
Failing a login reveals a “Forgot Password?” link pointing at /reset. Submitting a
valid username there claims a PIN has been emailed. The form placeholder (1234) and the
behaviour on a wrong guess - the message changes and the form is re-rendered - tell us
two things: the PIN is only four digits, and it is tied to the current laravel_session
cookie rather than to the account alone.
A four-digit PIN has only 10,000 possible values. With no per-account lockout, the only thing between us and the reset is whatever server-side throttling exists.
Initial Access
PIN brute force and rate-limit bypass
The reset PIN is validated at /api/resettoken, which takes name and pin parameters.
A naive brute force stalls almost immediately:
| |
After roughly 60 requests the response flips to 429 Too Many Requests (a recognisable
36 L / 125 W body). The --hh value hides the default “wrong PIN” response so that only
anomalies surface.
The stack is nginx acting as a reverse proxy in front of Laravel. Laravel’s throttler
keys requests by client IP. When an application is configured to trust a forwarding
header (via its TrustProxies middleware) without pinning that trust to a known proxy
address, a client can set the header itself and appear to originate from a fresh IP on
every request - defeating any per-IP limit.
Testing header candidates from the usual rate-limit-bypass list, X-Originating-IP is
ignored, but X-Forwarded-For is honoured:
| |
With that header present, the 429 responses stop. The lesson is to test the whole
family of forwarding headers rather than assuming one - the app only trusts a specific
one.
Pairing each PIN with a unique IP
To keep every request looking like a new client, each PIN needs its own spoofed IP. That
means running two wordlists in lockstep, not as a cross-product. In wfuzz this is the
zip mode (-m zip): with lists [a,b] and [c,d] it tries ac, bd rather than every
combination.
The catch is that the paired list must be at least as long as the PIN range, or wfuzz
stops at the shorter list’s length. Generate ~10k values:
| |
Then run the paired fuzz, injecting the second list into X-Forwarded-For:
| |
FUZZ iterates the PIN, FUZ2Z iterates the spoofed IP octet, and --hh hides the
default response so the single valid PIN stands out with a different length:
| |
Entering that PIN at /reset unlocks a password-change form. Setting a known password and
logging in completes access to the dashboard.
Note: the exact winning PIN and cookie-dependent response lengths vary per session, so the value that fires will differ between runs. What matters is the anomalous response size, not the specific number.
Bypassing the secret check with type juggling
The dashboard lists UHC winners with a “View” link per player, wired to an inline AJAX call:
| |
| |
Each id carries a fixed secret that looks like an MD5 hash but is not a plain hash of
the id - most likely md5(salt + id) computed server-side. Tampering with secret
returns a tampered input error, so the value is validated before the record is fetched.
Switching the request to POST returns 405 Method Not Allowed, but the endpoint accepts
a JSON body on a GET request. With the legitimate secret in JSON, the profile comes back.
The interesting question is how the server compares the secret. If it uses PHP’s loose
comparison operator (==) rather than strict (===), the check is vulnerable to type
juggling: comparing a non-empty string against the boolean true coerces to true.
Sending the secret as a boolean bypasses the check entirely:
| |
The profile is returned. Crucially, id also works as a string:
| |
That last detail matters - a string field will happily carry a SQL injection payload, whereas an integer field would reject it. Type juggling did not just skip a check; it opened the door to the injection below.
SQL injection
A single quote breaks the query and returns a 500:
| |
12 alone also errors (no such record), but a boolean-based test restores a valid
response:
| |
This implies a query shaped like:
| |
Incrementing the UNION column count shows the query returns three columns, with the third reflected back:
| |
From there, standard information_schema enumeration maps the custom uhc database:
| |
The users table yields bcrypt ($2y$10$...) hashes. These are a dead end - bcrypt is
deliberately slow to crack, and players can change these passwords anyway, so nothing
useful is expected here. The real value of the injection is the FILE privilege it
exposes.
SQL injection to file read and write
With FILE, load_file() reads arbitrary files:
| |
The nginx config confirms the web root at /srv/altered/public, which matches Laravel’s
documented layout. Knowing a writable, web-served directory is the setup for the next
step.
INTO OUTFILE can then drop files into that web root. The write itself returns a 500,
but the file lands regardless:
| |
| |
A PHP proof follows:
| |
Browsing /info.php renders phpinfo() - confirmed remote code execution via
SQLi-driven file write.
Foothold as www-data
To avoid quoting problems inside the SQL string, the reverse shell is base64-encoded and decoded at runtime:
| |
| |
With a listener ready, visiting /shell.php triggers the callback:
| |
Upgrade to a usable TTY and grab the user flag from the only home directory on the box:
| |
Privilege Escalation
Identifying the vulnerability
| |
Kernel 5.16.0 (January 2022) predates the Dirty Pipe fixes (5.16.11 / 5.15.25 /
5.10.102) and is therefore vulnerable to CVE-2022-0847. Dirty Pipe abuses an
uninitialised PIPE_BUF_FLAG_CAN_MERGE flag to inject data into a file’s page cache and
have it flushed to disk, allowing modification of files the user cannot normally write. It
works within three constraints: the file must be readable, the first byte cannot be
changed, and the file size cannot grow.
No compiler is present on the target, so the PoC is compiled on the attack host and pulled over HTTP:
| |
| |
Variant A - SUID overwrite (clean path)
Rather than touching /etc/passwd, this variant hijacks an existing SUID-root binary,
drops a root shell, and restores the original:
| |
| |
Variant B - /etc/passwd and the PAM-Wordle twist
The more common Dirty Pipe PoC edits /etc/passwd and then calls su. On Altered that
path hits an unusual obstacle - the box ships with a pam_wordle module inserted into
the PAM auth stack, so su prompts a Wordle-style game instead of a password:
| |
The dictionary is non-standard and skewed toward hacking/IT terms; a working sequence is
hacks -> shell -> mkdir -> ngrok. Solving it, then supplying the password the exploit
set (piped), yields root. This is an event-flavoured gimmick rather than a realistic
control, but it is a good reminder that when one primitive path is blocked by an
unexpected mechanism, the same underlying vulnerability usually offers another - here,
overwriting a SUID binary instead of the passwd file.
How the SUID exploit works
The SUID variant is worth reversing because the payload is opaque in the source.
exploit-2 backs up the original bytes of the target binary (from offset 1 onward), uses
the Dirty Pipe primitive hax() to write an embedded ELF into the binary starting at
byte 1 (byte 0 is untouchable by design), executes the now-hijacked binary, then restores
the original bytes.
The injected ELF, extracted and disassembled, is just a handful of syscalls:
| |
It drops a second, SUID-root binary at /tmp/sh. That binary in turn does:
| |
So the chain is: hijack a SUID binary -> have it write a SUID-root helper -> restore the
original binary -> run the helper for a clean root shell. Because the helper self-elevates
via setuid(0) / setgid(0), the final shell is fully root even though the payload never
touched /etc/passwd.
Detection and Mitigation
| Stage | Root cause | Mitigation |
|---|---|---|
| Username enumeration | Distinct login error messages | Return a single neutral “invalid credentials” message |
| Rate-limit bypass | App trusts X-Forwarded-For from any client | Pin TrustProxies to the known proxy IP/CIDR |
| PIN brute force | 4-digit token, no per-account lockout | High-entropy tokens, per-account throttling, short TTL |
| Secret check bypass | PHP loose comparison (==) | Use === / hash_equals() |
| SQL injection | Unparameterised query | Prepared statements |
| SQLi to RCE | DB account has FILE, web root writable by MySQL | Least privilege, restrict secure_file_priv, non-writable web root |
| Root | Outdated kernel (Dirty Pipe) | Patch management |
Detection signals:
- Repeated
POST /api/resettokenwith rotatingX-Forwarded-Forvalues from a single TCP source. 500spikes on/api/getprofilewith non-stringsecretvalues or SQL metacharacters inid.- MySQL writing files under the web root (
INTO OUTFILE), and new.phpfiles appearing in/srv/altered/public. www-dataspawning/bin/bashfrom an nginx/php-fpm worker, and a non-package binary executing from/tmp.- A SUID-root binary changing hash and changing back within seconds (the Dirty Pipe SUID overwrite).
Lessons Learned
- Distinct “wrong password” versus “no such user” responses turn a public name list into confirmed accounts.
- A rate limiter keyed on
X-Forwarded-Foris bypassed by spoofing the header; pair one fresh IP per guess (wfuzz -m zip). - Test the whole forwarding-header family (
X-Forwarded-For,X-Real-IP,X-Originating-IP,X-Client-IP); only one is usually trusted. - PHP
==againsttruecoerces most non-empty strings; integrity checks need===orhash_equals(). - A string-typed parameter that was meant to be an integer is where injection lives.
- The DB
FILEprivilege is the real prize in a SQLi:load_filefor reads,INTO OUTFILEfor a webshell where the web root is writable. - When a known privesc path is blocked by an unexpected control, the same vulnerability
usually offers another primitive - a SUID overwrite instead of
/etc/passwd.
Command Reference
| |
Based on public write-ups of the Hack The Box machine Altered.