MOVEIt Transfer RCE Part Two (CVE-2023-34362)
Where We Left Off
In our last post we detailed our initial work reversing the recent Progress MOVEit Transfer remote code execution vulnerability as well as our proof-of-concept demonstrating the exploit. We implemented checks in our Attack Surface Management platform providing our customers with assurance on whether or not they are affected. However, we declined to post the full exploit chain as it was being actively exploited at the time. Since then, a public proof-of-concept has been posted and so we will now detail the steps we took to reverse the vulnerability.
At this stage in the process, we had a working instance of MOVEit Transfer and were pretty confident that the initial entrypoint was a request to <span class="code_single-line">/MOVEitISAPI/MOVEitISAPI.dll?action=m2</span> which would then pass through to <span class="code_single-line">/machine2.aspx</span>. We also knew from the logs posted online that subsequent requests in the chain included the following endpoints:
- POST <span class="code_single-line">/guestaccess.aspx</span>
- POST <span class="code_single-line">/api/v1/token</span>
- GET <span class="code_single-line">/api/v1/folders</span>
- POST <span class="code_single-line">/api/v1/folders/{id}/files?uploadType=resumable</span>
- PUT <span class="code_single-line">/api/v1/folders/{id}/files?uploadType=resumable&fileId={id}</span>
Reversing the DLL with Ghidra
We continued our investigation by decompiling <span class="code_single-line">MOVEitISAPI.dll</span> with Ghidra. Using BinDiff we compared both the patched and unpatched versions and found there were no discernible differences between the two.
From this we concluded that the SQL injection was most likely not in <span class="code_single-line">MOVEitISAPI.dll</span>, however we did still have to analyze the code a little to determine how to correctly call the <span class="code_single-line">/MOVEitISAPI/MOVEitISAPI.dll?action=m2</span> endpoint. We started our search by looking for “machine2” and found the string “Passing along user’s request to machine2”, which was promising.
Looking through the enclosing function we saw what looks like a header <span class="code_single-line">X-siLock-Transaction</span> and a potential value <span class="code_single-line">folder_add_by_path</span>. These will become important later. The check for <span class="code_single-line">folder_add_by_path</span> was also the only way to hit the “Passing along user’s request to machine2” message. Otherwise, we were presented with an “Illegal transaction” message.
Finding SQL Injection
We were confident we knew what information would be needed in our request. As such, we moved onto the <span class="code_single-line">/machine2.aspx</span> endpoint to search for the SQL injection location. We loaded the page handler for <span class="code_single-line">/machine2.aspx</span> into dnSpy and found that it just called <span class="code_single-line">Machine2Main</span> on <span class="code_single-line">SILMachine2</span>.
Moving into <span class="code_single-line">Machine2Main</span>, we found where all those <span class="code_single-line">X-siLock-*</span> headers were being handled. There was a <span class="code_single-line">CrackInput</span> method which extracted the data from headers. Inside <span class="code_single-line">CrackInput</span> we found a long list of headers that were extracted and sanitised.
We spent some time investigating the two sanitisation methods used, <span class="code_single-line">GetHeaderValForSQL</span> and <span class="code_single-line">SILUtility.XHTMLClean</span>. But, both were sufficient at preventing injection. The only value that was not sanitised, <span class="code_single-line">X-siLock-Transaction</span>, was not used in any SQL queries.
Progressing through <span class="code_single-line">Machine2Main</span>, the next method that caught our attention was <span class="code_single-line">DoTransaction</span>. This method was near the end of <span class="code_single-line">Machine2Main</span> and we could reach it if we had a valid session cookie, even if it was a guest session. <span class="code_single-line">DoTransaction</span> effectively performed a switch statement on the provided transaction name and executed the corresponding functionality. However, because of restrictions in <span class="code_single-line">MOVEitISAPI.dll</span>, the only transaction we could execute was <span class="code_single-line">folder_add_by_path</span>.
We explored the <span class="code_single-line">FolderAddBypath</span> method, but could not see anything that would lead to SQL injection. All inputs were either sanitised while building the query or came from headers that were sanitised by <span class="code_single-line">CrackInput</span>.
As noted in part one, a piece of functionality that was removed by the patch was the <span class="code_single-line">SetAllSessionVarsFromHeaders</span> method. This method was called when processing the <span class="code_single-line">session_setvars</span> transaction. <span class="code_single-line">SetAllSessionVarsFromHeaders</span> looked interesting as it took the raw input like <span class="code_single-line">CrackInput</span>, but did not do any sanitisation. The values were extracted from the headers and set as session variables. Any header that began with <span class="code_single-line">X-siLock-SessVar</span> and contained a value in the form <span class="code_single-line">KEY: VALUE</span> would be set as a corresponding session variable.
Determined to reach this code path and after some fiddling, we were able to smuggle a header through. By sending two <span class="code_single-line">X-siLock-Transaction</span> headers one with a prefix and one without we were able to specify any transaction we needed. Our request now looked like the following.
The <span class="code_single-line">XX-siLock-Transaction: folder_add_by_path</span> header would match appropriately for <span class="code_single-line">MOVEitISAPI.dll</span> to pass the request through to <span class="code_single-line">machine2.aspx</span>, but only <span class="code_single-line">X-siLock-Transaction: session_setvars</span> would get copied to the downstream request. Meaning when <span class="code_single-line">machine2.aspx</span> went to extract the transaction, all the handler would see is <span class="code_single-line">session-setvars</span>.
At this stage we could inject malicious SQL payloads into the session variables, all we had to do was find a location where one of those variables was used in an unsanitised SQL query. To do this we used dnSpy to analyze all the locations the various database query methods were used. We then went through each of these calls looking for any that could be used to perform a SQL injection attack.
We focused on calls in locations that appeared to have a high chance of being accessible such as those in the session handling and <span class="code_single-line">machine2.aspx</span> code paths. Eventually, we made our way to the handler for <span class="code_single-line">/guestaccess.aspx</span>, which was a page we knew was accessed as part of the attack chain and one we had already touched on briefly as that was how we setup our initial session. In this handler we found the following query that looked very injectable:
We traced <span class="code_single-line">this.m_accesscode</span> back to find where it was set. This lead us to the following method call in <span class="code_single-line">SILGuestPackageInfo</span>:
<span class="code_single-line">AccessCode</span> is the value that would be propagated down to <span class="code_single-line">m_accesscode</span>.
However, there was a problem. <span class="code_single-line">LoadFromSession</span> was called at the start of the handler and the SQL injection point was several hundred lines later. To get through this we attached a debugger and set a breakpoint just after <span class="code_single-line">LoadFromSession</span>, set the <span class="code_single-line">MyPkgAccessCode</span> variable in our request and started stepping through the code.
Each time we hit a check or something missing that would halt the request or send it down the wrong branch, we tweaked our request and tried again. A lot of what we were missing were simple variables needing to be set, but there were several larger pieces which required a bit more work.
The first of these was the CSRF token. Searching through our request history, we didn’t find any requests that had a token in the response. We looked through all the calls to generate a CSRF token (<span class="code_single-line">GetCT</span>) and found one on the <span class="code_single-line">guestaccess.aspx</span> page. It was a bit fiddly, but we tracked it down to needing a specific value for <span class="code_single-line">m_nextform</span> to be set, which we found was possible as follows:
<span class="code_single-line">this.siGlobs.Arg12</span> is set straight from the form parameters. All we had to do was make a request with <span class="code_single-line">arg12=promptaccesscode</span> in the POST body and extract the CSRF token from the response.
The next notable hurdle was right before the SQL injection point in the following call:
<span class="code_single-line">MsgPostForGuest</span> constituted over a thousand lines of code to assemble and send a notification about the accessed package. Some of the checks were trivial, like setting a subject and using a “from” address which resembled a valid email address.
The big problem was the session variable <span class="code_single-line">MyPkgSelfProvisionedRecips</span>, it had to contain a comma separated list of email addresses. The addresses specified were used to query the database and find a list of usernames. If no valid users were found, no notification would be sent and we couldn’t hit our SQL injection.
Below is an example of the SQL query used to lookup users based on their email address (we’ve used <span class="code_single-line">x@example.com</span> as our address).
Seeing the <span class="code_single-line">LIKE</span> operator we thought we were in luck, <span class="code_single-line">%.com%</span> would just match all addresses and we would be fine. Unfortunately, every <span class="code_single-line">%</span> is either preceded by or followed by a comma. Presumably the application lets users specify multiple email addresses and stores them as a comma separated list.
Whatever the reason, it meant we couldn’t use that technique to bypass this check. We continued looking at how the query was built and found the following:
Although the <span class="code_single-line">LIKE</span> operators were sanitised, <span class="code_single-line">"Email='{2}' OR "</span> was not. This meant we could achieve SQL injection with stacked queries right here. But, since the recipient list is split on commas we would be a bit constrained. Instead, we used this vulnerability to skip needing to know a user’s email by constructing a query which returned the first of all non-deleted users.
We were now able to execute our first stacked query with the following payload that would update the innocuous “notes” field of the “sysadmin” user.
After executing our payload we checked the database and were very happy to see that it had worked.
Gaining Admin API Access
With our SQL injection in hand we could now move onto the next step in the chain, which was figuring out how to pivot this into remote code execution.
We knew the attackers were calling API endpoints as part of the exploit and so our next step was determining how to get an API token. We read the documentation for <span class="code_single-line">/api/v1/token</span> and found five possible grant types were available: password, refresh_token, otp, code and external_token. However, we also looked at the implementation and found an unlisted option that was a lot more exciting.
<span class="code_single-line">GrantSessionToken</span> seemed like something that would work much better with our SQL injection. Digging through the call stack, we found that this code path ran the following SQL query to determine whether a session was valid or not.
The query would search the <span class="code_single-line">activesessions</span> table for an entry that matched the current session ID and had not expired. This seemed easy enough to exploit, we created a new session and then ran the following query via SQL injection.
Since <span class="code_single-line">username</span> is randomly generated, we would not know what the correct value for the sysadmin user. So we had to run a subquery instead.
<span class="code_single-line">InterfaceCode</span> and <span class="code_single-line">IPAddress</span> are both needed for additional checks later on to determine whether or not the session is valid for this request. These values were discovered via the same process as was used for the notification recipients. We attached a debugger and stepped through while tweaking the request until we got into the code branch we wanted.
After running our SQL injection, we requested an API token with the following request. Username and password are required, but ignored.
RCE, But Not What We Expected
We were pretty close. We had full control of the database and full access to the API. But, we still didn’t have the full attack chain.
From the released logs we were confident the next step involved the following endpoints.
- GET <span class="code_single-line">/api/v1/folders</span>
- POST <span class="code_single-line">/api/v1/folders/{id}/files?uploadType=resumable</span>
- PUT <span class="code_single-line">/api/v1/folders/{id}/files?uploadType=resumable&fileId={id}</span>
Our theory was an attacker creates a “resumable” file upload, uses SQL injection to modify the upload’s destination and then continues with the upload. To begin, we looked at the request handlers for resumable uploads. Below is the handler for uploading the file content.
The interesting line was <span class="code_single-line">this._resumableUploadFilePartHandler.GetUploadStream(request)</span>. If we could control the output file connected to this <span class="code_single-line">Stream</span>, we could write a payload into the webroot and get remote code execution. Unfortunately, after tracking down where the stream was created we found the following:
Any files saved as part of the resumable file upload were always encrypted on disk. This meant that even if we could control where the files were saved, they would not be executed.
However, there was a silver lining. While tracking down the creation of the upload stream, we came across <span class="code_single-line">DeserializeFileUploadStream</span>. A new stream would only be created if <span class="code_single-line">_uploadState</span> was empty. Otherwise, one was deserialised. If we could control the <span class="code_single-line">_uploadState</span> variable, we could could achieve remote code execution via unsafe deserialisation.
Again, we used dnSpy’s analyzer to determine where <span class="code_single-line">_uploadState</span> was assigned and found only one location.
We looked into <span class="code_single-line">GetFileUploadInfo</span> we found the following.
The <span class="code_single-line">_uploadState</span> was set from the database. This meant we could use SQL injection to set it. But, there was a catch, the <span class="code_single-line">State</span> field was encrypted.
After some investigation, it seemed unlikely that we would find an easy way to bypass the encryption. Instead, we looked for locations where the application would encrypt data for us and save it to the database. That way, all we had to do was use our SQL injection to copy the value over.
We looked through all the calls to <span class="code_single-line">DBFieldEncrypt</span> and found one that should have been obvious.
The <span class="code_single-line">Comment</span> field of the table we were already looking at was also encrypted. We didn’t even need to call a new endpoint, all we had to do was set the comment to our payload when we created the resumable file upload.
So we generated a payload with ysoserial.net.
Sent the first request which created the resumable upload.
Ran the following query via SQL injection. This copied our payload into the <span class="code_single-line">state</span> field.
We then triggered the exploit with the final request.
And lastly, we then checked to see if it worked and were delighted by what we saw.
Conclusion
We are still seeing companies hit by this vulnerability. And with the release of public proof-of-concepts this could increase. If you’re concerned you are affected, see Progress’ bulletin with version and patch information here.
We have shown several techniques here that we hope will aid you on your own security research projects. When it’s an option, attaching a debugger to step through the code has proven to be invaluable in trying to understand a complex application. Additionally, when faced with encrypted values as we were with our deserialisation payload, finding a way to get the application to do the encryption for you is a technique we have used here and in the past to great effect.
As always, customers of our Attack Surface Management platform were the first to know when this vulnerability affected them. We continue to perform original security research in an effort to inform our customers about zero-day vulnerabilities in their attack surface.
More Like This
Ready to get started?
Get on a call with our team and learn how Assetnote can change the way you secure your attack surface. We'll set you up with a trial instance so you can see the impact for yourself.