On May 31, 2023, Progress released a security advisory for their MOVEit Transfer application which detailed a SQL injection leading to remote code execution and urged customers to update to the latest version. The vulnerability, CVE-2023-34362, at the time of release was believed to have been exploited in-the-wild as a 0-day dating back at least 30 days.
Soon after publication, a flurry of threat intelligence by various companies was released which indicated that this vulnerability was exploited further back than initially thought – GreyNoise seeing activity 90 days prior and Kroll reporting similar activity as far back as 2021. The attacks have been attributed to the cl0p ransomware gang, which is attributed to several other recent 0-day ransomware campaigns such as PaperCut, GoAnywhere MFT, SolarWinds Serv-U, and Accellion FTA.
Figure 1. cl0p 0-day activities
Taking a look at the differences between the vulnerable and patched versions we find three interesting areas.
The first difference found in the function UserGetUsersWithEmailAddress()
appears to update a SQL query from a concatenated string of several arguments passed in, to a safer looking SQL builder utility. This helper function is reachable from many code paths, interestingly from several unauthenticated paths via guestaccess.aspx
.
Figure 2. UserGetUserWithEmailAddress() function differences
The second difference found in the function SetAllSessionVarsFromHeaders()
removes the entire function and removes the only caller of that function from the machine2.aspx handler, SILMachine2
, when the received Transaction
is session_setvars
. Unfortunately machine2.aspx requests will only be processed if coming from localhost.
Figure 3. SetAllSessionVarsFromHeaders() function removed
The last difference found in GetFileUploadInfo()
adds a single statement which changes the way the uploadState
is set by first checking if the State
is null before using a new decryption helper DecryptBytesForDatabase
.
Figure 4. GetFileUploadInfo() function differences
Foreword: looking at public threat intelligence about the series of endpoints being hit and the types of indicators of compromise, we aren’t entirely sure the path we’ve found is the exact same abuse of the patched functionality mixed with abuse of intended functionality. There are likely several paths to exploitation – there are many like it, but this one is ours.
Given that the description of the vulnerability was a SQL injection, the path to the apparent patch in UserGetUsersWithEmailAddress()
was pursued first. While paths were discovered to reach this function from an unauthenticated point-of-view, we were unable to discover a way to have the controllable arguments passed to it without being ‘cleaned’ by XHTMLClean()
, which converts the typical unsafe SQL characters to their HTML encoded counterparts.
We shifted our focus to the other removed function SetAllSessionVarsFromHeaders()
. We found that this function had the restriction that only localhost is allowed to route. Threat actors were observed hitting the /moveitisapi/moveitisapi.dll?action=m2
so we were hopeful that we could find a path from moveitisapi.dll
to SetAllSessionVarsFromHeaders()
. moveitisapi.dll
is a compiled C program of which we can analyze with Ghidra. Opening it up, we find that the function at 0x180080920, dubbed action_m2
, is responsible for parsing requests that contain the action=m2
request parameter. The action_m2
function takes requests, and forwards those requests on to the machine2.aspx
endpoint only if the passed in header X-siLock-Transaction
is equal to folder_add_by_path
.
Figure 5. action_m2() function in MOVEitISAPI.dll
Unfortunately, thats not ~exactly~ how it works. The function that extracts the X-siLock-Transaction
header to compare its value to folder_add_by_path
has a bug. It will incorrectly extract headers that end in X-siLock-Transaction
, so an attacker can trick the function to passing the request onto the machine2.aspx by providing a header such as xX-siLock-Transaction=folder_add_by_path
and additionally providing the correctly formatted header with our own arbitrary transaction to be executed by the machine2.aspx endpoint.
Figure 6. Transaction bypass via crafted headers
With entry into machine2.aspx via this backend relay of our request, we can now reach SetAllSessionVarsFromHeaders()
when we pass in a transaction of session_setvars
. Our Cookie header as well as all other X-siLock-
headers will be passed in with our request. Analyzing the functionality of this removed function further, it will parse all headers, and if the header starts with X-siLock-SessVar
it will set the corresponding variable of the session in use to the arbitrary value provided. For example, X-siLock-SessVar0: MyUsername: sysadmin
will set the username of session to the builtin sysadmin. This capability unfortunately does not enable you to just assume the sysadmin role and use the application, but it does provide access to set many variables loaded in code paths which bypass being cleaned by the XHTMLClean()
function from earlier.
The path to the vulnerable UserGetUsersWithEmailAddress()
function we took was via an unauthenticated call to guestaccess.aspx when the passed Transaction
is secmsgpost
. The full call chain of relevant calls is:
guestaccess.aspx -> SILGuestAccess -> SILGuestAccess.PerformAction() -> MsgEngine.MsgPostForGuest() -> UserEngine.UserGetSelfProvisionUserRecipsWithEmailAddress() -> UserEngine.UserGetUsersWithEmailAddress()
While we will not analyze the call chain in depth and all of the variable setting whack-a-mole that was needed to reach the vulnerable function, the crux of what changed with our access to session variable manipulation is in the very beginning of guestaccess.aspx’s handler in SILGuestAccess
. The main function calls this.m_pkginfo.LoadFromSession()
, which sets variables from session variables that we can now influence with session_setvars
.
Figure 7. LoadFromSession() loads variables from the session
Along the call chain, the SelfProvisionedRecips
value is extracted as a list of comma separated email addresses and never cleaned before being passed to our vulnerable function. Inspecting how the SQL query is built in our vulnerable function, we see the InstID
, EscapeLikeForSQL(EmailAddress)
, and finally EmailAddress
are formatted into the query statement. The final query statement looks like:
SELECT Username, Permissions, LoginName, Email FROM users WHERE InstID=9389 AND Deleted=0 AND (Email='<EmailAddress>' OR Email LIKE (%EscapeLikeForSQL(<EmailAddress>)) or Email LIKE (EscapeLikeForSQL(<EmailAddress>));
The part of the query AND Email='<EmailAddress>'
has our uncleaned argument of SelfProvisionedRecips
inserted into the query. The only caveat to this injection, is that just prior to the call the SelfProvisionedRecips
variable is split on comma’s (,). Our injected SQL statement should avoid having commas to continue proper execution. We can work around needing commas by reusing the SQL injection several times to do sequential statements such as INSERT then UPDATE.
All of this information combined, an example request in Python that will set the right session variables via a request to the action=m2
endpoint and then a request to the guestaccess.aspx
endpoint to inject would look like the following:
Figure 8. Python script excerpt to perform SQL injection
With the ability to read and write any data within the MOVEit database, our next goal is to achieve elevated permissions from an unauthenticated session. Threat intelligence showed logs that the attackers would hit the /api/v1/auth/token
endpoint, which is handled by MOVEit.DMZ.WebAPI
. Authentication is handled here, and based on the session_grant
parameter passed in, different authentication paths are taken. Several of these paths were explored, some more than others, but the path we decided to go after is when session_grant=external_token
, which is handled by the function GrantTokenFromExtenralToken()
. This type of authentication flow is used when the MOVEit Transfer application has been configured to use federated logins, specifically from Microsoft Outlook acting as the identity provider.
Assuming the application has been configured to use a federated login flow, users send a payload to the /api/v1/auth/token
endpoint with a payload that contains a RS256 JWT. The decoded JWT should look like the following:
The important information here is that the MOVEit Transfer application will reach out the URL in the amurl
field to retrieve the certificate that matches the given x5t
signature to extract and validate that the JWT was in fact signed by the identity provider. Because we control the content of the JWT, we can point it to our own endpoint that hosts our own matching certificate that will pass validation.
We ultimately use the SQL injection from the previous paths to configure the database to think the application is configured this way, to trust our identity provider URL, and inject an external token for the builtin sysadmin user. We also use the SQL injection to pass several checks along the way to allow the sysadmin user to be able to login from any IP address.
Combining it all together we now obtain an access token for the sysadmin user and use it to list files they have access to.
Figure 10. Chaining issues to obtain sysadmin access token
The last step of this exploit chain is to abuse the sysadmin access token to achieve remote code execution. Threat actors were observed hitting the /api/v1/folders
, /api/v1/folders/<folder_id>/files?uploadType=resumable
, and /api/v1/folders/<folder_id>/files?uploadType=resumable&fileId=<file_id>
endpoints. Pairing that knowledge with the last difference observed in the patch related to file uploads, we begin looking at the file upload handlers in within MOVEit.DMZ.WebApi
.
The only path to the function that was patched, GetFileUploadInfo()
, is when a file upload is resumed that was previous in progress – which matches the call to /api/v1/folders/<folder_id>/files?uploadType=resumable&fileId=<file_id>
. The specific variable they now attempt to protect is this._uploadState
. Examining where that variable is referenced in the .NET DLL, we see that the function DeserializeFileUploadStream()
uses it to create a MemoryStream object and then immediately uses it in a call to BinaryFormatter().Deserialize()
. This is a classic .NET deserialization vulnerability. Normally, the uploadState
variable would not be under attacker influence, but because we have a SQL injection, we can influence the field from which that variable is set.
Figure 11. BinaryFormatter.Deserialize() on input we control
Looking at the state of the database from which the uploadState
variable is set, we find that the State
value is NULL
. We need this State
value to contain our base64 encoded serialized .NET payload.
Figure 12. Database tabe fileuploadinfo
schema
Using a tool like ysoserial.net, we generate a payload for the formatter in use.
ysoserial.exe -g TypeConfuseDelegate -f BinaryFormatter -c "cmd.exe /C echo DIRTY MIKE AND THE BOYS WERE HERE > C:\Windows\Temp\message.txt" -o base64
Figure 13. ysoserial payload generation
The only hurdle to overcome is, that when reading the State
field from the database, it expects the data to be encrypted with the an organization specific encryption key. We spent some time looking at how we could extract and re-implement the encryption, but thankfully theres a simple workaround. When initiating the file upload, you can optionally provide a Comment
. This comment is encrypted with that organization specific key. We can provide our base64 ysoserial payload as the comment when initiating the upload and have it do the heavy lifting for us.
To prepare the application to reach this bit of code requires several interactions:
/api/v1/folders
/api/v1/folders/<folder_id>/files?uploadType=resumable
and providing our payload as the Comment
Comment
to the State
fieldState
into uploadState
and calling BinaryFormatter.Deserialize(uploadState)
The full exploit chain in action to write a file to C:\Windows\Temp\message.txt
.
Figure 14. Executing the proof-of-concept exploit
Figure 15. Remote Code Execution
Our proof of concept can be found on our GitHub.
If you find yourself on a MOVEit Transfer server that was deployed via the Azure Marketplace (and in some other cases), in C:\MOVEitDMZ_Install.INI
you will find cleartext credentials for the provisioned sysadmin account, database credentials, and the service credential. All great targets for lateral movement.
Figure 16. MOVEitDMZ_Install.INI
This file is used for unattended installs, and users are given the optional to preserve it after normal installations as well.
MOVEitDMZ_Install.INI – The parameter input file for the installation. You can create an INI file by performing a standard MOVEit DMZ installation and NOT deleting the file at the end. Once you have the INI file, you can modify it in a text editor to customize the input for use as an unattended install.
Our exploit path may not be similar to paths taken by recent threat actors, but there are several places to look for indicators.
The database tables userexternaltokens
, trustedexternaltokenproviders
, and hostpermits
all had entries inserted to achieve the sysadmin access token. The fileuploadinfo
table was altered to obtain RCE. One should inspect these tables to look for any anomalous entries.
Log entries for endpoint traffic can be found in the following areas:
<InstallDir>/Logs/DMZ_WebApi.log
when requests are made to /api/v1/
endpoints<InstallDir>/Logs/DMZ_WEB.log
when requests are made to /guestaccess.aspx
and relayed messages to /machine2.aspx
<InstallDir>/Logs/DMZ_ISAPI.log
when requests are made to /moveitisapi/moveitisapi.dll?action=m2