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 Peek – Patch Diff’ing
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
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
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
Figure 4. GetFileUploadInfo() function differences
A Path to Exploitation
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.
The Path to Unclean Input
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 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
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 SQL Injection
The path to the vulnerable
UserGetUsersWithEmailAddress() function we took was via an unauthenticated call to guestaccess.aspx when the passed
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
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
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
The Path to Administrator Session
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:
Figure 9. Example RS256 JWT
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 Path to Remote Code Execution
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/<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
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
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 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:
- Retrieve the user’s FolderID by requesting
- Retrieve a FileID by starting a file upload by requesting
/api/v1/folders/<folder_id>/files?uploadType=resumable and providing our payload as the
- Use SQL injection to copy the
Comment to the
- Resume the file upload triggering loading of
uploadState and calling
The full exploit chain in action to write a file to
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.
Indicators of Compromise
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
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
<InstallDir>/Logs/DMZ_WEB.log when requests are made to
/guestaccess.aspx and relayed messages to
<InstallDir>/Logs/DMZ_ISAPI.log when requests are made to
The post MOVEit Transfer CVE-2023-34362 Deep Dive and Indicators of Compromise appeared first on Horizon3.ai.