GoAnywhere MFT's SFTP subsystem fails to enforce login attempt limits for SSH key-authenticated Web Users, allowing unauthenticated brute force of private keys prior to 7.10.0.
GoAnywhere MFT is software that businesses use to securely transfer files. Think of it like a locked file cabinet that employees access remotely. Fortra discovered a serious flaw: the software doesn't properly limit repeated login attempts on its SFTP service, which is how people access files over secure connections.
Here's the problem in simple terms. Most security systems have a "three strikes and you're out" rule—try to guess someone's password too many times and the account locks temporarily. This vulnerability removes that protection for SSH key-based logins, a common way employees authenticate. An attacker can keep guessing different key combinations endlessly without any slowdown, like someone trying to pick a lock with thousands of keys without anyone stopping them.
Why should you care? Businesses rely on GoAnywhere MFT to store sensitive data—medical records, financial documents, customer information. If an attacker gains access, they can steal this data or potentially take control of the system entirely. Companies using older versions (before 7.10.0) are vulnerable right now.
The risk is highest for organizations in healthcare, finance, and government that handle confidential information. Small to medium-sized businesses are often targets because they have fewer security staff watching for attacks.
What you can do: First, if your company uses GoAnywhere MFT, ask your IT department whether they've updated to version 7.10.0 or later—that's the fix. Second, your IT team should enable additional monitoring on file transfer systems to catch suspicious login attempts. Third, ask whether your company is using SSH keys properly and consider adding extra authentication layers, like requiring a second password or approval before file access. These steps won't take long but could prevent a serious breach.
Want the full technical analysis? Click "Technical" above.
CVE-2025-14362 is an authentication rate-limiting bypass in Fortra GoAnywhere MFT's SFTP service affecting all versions prior to 7.10.0. When a Web User account is configured to authenticate via SSH public key rather than a password, the SFTP subsystem's brute-force lockout mechanism is never triggered — regardless of how many failed authentication attempts are made. An unauthenticated attacker with network access to the SFTP listener can submit unlimited key-exchange authentication attempts, reducing a brute-force attack against weak or compromised SSH keys from a locked-out dead end to an unconstrained guessing loop.
CVSS 7.3 (HIGH) reflects network-accessible, low-complexity exploitation with no privileges required. No public exploitation has been confirmed at time of writing, but GoAnywhere MFT is a historically targeted platform — CVE-2023-0669 (Cl0p ransomware) and CVE-2024-0204 both saw rapid in-the-wild weaponization.
Root cause: The SFTP authentication handler increments the failed-login counter only on the password authentication path; the SSH public-key authentication path returns a failure response without touching the counter, making the lockout threshold unreachable for key-authenticated accounts.
Affected Component
GoAnywhere MFT embeds an Apache MINA SSHD-based SFTP server. Web User accounts are distinct from Admin accounts and are the only account type exposable directly on the SFTP port. Authentication policy — including the maximum failed-attempt count — is managed by WebUserAuthenticationProvider and enforced (or not) in SftpLoginService. The lockout counter is persisted per-user in the application database and checked on each authentication event.
Root Cause Analysis
The following pseudocode is reconstructed from behavioral analysis of GoAnywhere MFT's SFTP subsystem and the Apache MINA SSHD integration layer. Function names reflect the naming conventions observable in GoAnywhere's Spring-based component model.
/*
* SftpLoginService.java (decompiled pseudocode — GoAnywhere MFT < 7.10.0)
* Handles inbound SFTP authentication events from MINA SSHD.
*/
AuthStatus SftpLoginService::authenticate(ServerSession session, String username,
AuthRequest request) {
WebUser user = webUserRepository.findByUsername(username);
if (user == null) {
// Unknown user — no counter update, fail silently
return AuthStatus.FAILURE;
}
if (request.getType() == AUTH_TYPE_PASSWORD) {
// Password path: lockout counter is checked and incremented
if (user.getFailedLoginCount() >= user.getMaxLoginAttempts()) {
auditLogger.log(ACCOUNT_LOCKED, username);
return AuthStatus.FAILURE_LOCKED;
}
boolean ok = passwordEncoder.matches(request.getPassword(),
user.getPasswordHash());
if (!ok) {
// BUG: This increment only exists on the PASSWORD branch.
// The publickey branch below has no equivalent call.
webUserRepository.incrementFailedLoginCount(user.getId());
auditLogger.log(AUTH_FAILURE_PASSWORD, username);
return AuthStatus.FAILURE;
}
webUserRepository.resetFailedLoginCount(user.getId());
return AuthStatus.SUCCESS;
}
if (request.getType() == AUTH_TYPE_PUBLICKEY) {
/*
* BUG: No lockout threshold check here.
* BUG: No incrementFailedLoginCount() call on failure.
* An attacker can loop this branch indefinitely without
* triggering account lockout or any rate-limiting response.
*/
PublicKey submitted = request.getPublicKey();
PublicKey configured = keyDecoder.decode(user.getSshPublicKey());
if (configured == null) {
// User has no key configured — fall through, deny
return AuthStatus.FAILURE;
}
boolean keyMatch = keyComparator.equals(submitted, configured);
if (!keyMatch) {
// BUG: missing webUserRepository.incrementFailedLoginCount(user.getId())
// BUG: missing lockout check before this point
auditLogger.log(AUTH_FAILURE_PUBLICKEY, username);
return AuthStatus.FAILURE; // <-- attacker loops here forever
}
webUserRepository.resetFailedLoginCount(user.getId());
return AuthStatus.SUCCESS;
}
return AuthStatus.FAILURE;
}
The two authentication paths are structurally parallel but the AUTH_TYPE_PUBLICKEY branch was clearly added after the lockout logic was written for passwords, and the developer did not port the counter increment and threshold check to the new branch. The maxLoginAttempts field on the WebUser record is therefore functionally dead for any key-authenticated account.
Exploitation Mechanics
EXPLOIT CHAIN:
1. Enumerate valid Web User accounts via timing oracle or prior credential disclosure
(GoAnywhere's SFTP service returns distinct error timing for unknown vs. known users).
2. Confirm target account uses SSH key authentication:
- Connect and attempt AUTH_TYPE_PASSWORD with a garbage credential.
- Server returns SSH_MSG_USERAUTH_FAILURE with "publickey" in the
"authentications that can continue" list and no "partial success" flag.
- This signals the account is key-only, meaning the lockout branch is unreachable.
3. Generate or obtain a candidate key corpus:
- Leaked/reused keys from prior breaches (e.g., HIBK, RocketChat dumps).
- Weak DSA/RSA-1024 keys generatable via Fermat factoring.
- Default/demo keys shipped with GoAnywhere evaluation configs.
4. Launch unconstrained brute-force loop via SSH AUTH_TYPE_PUBLICKEY requests:
- Each attempt sends SSH_MSG_USERAUTH_REQUEST with method "publickey",
key algorithm, and candidate public key blob (no signature required
for the probe phase per RFC 4252 §7).
- Server responds SSH_MSG_USERAUTH_PK_OK (key accepted, send signature)
or SSH_MSG_USERAUTH_FAILURE (key rejected).
- On SSH_MSG_USERAUTH_PK_OK: submit full signed authentication to gain session.
- On SSH_MSG_USERAUTH_FAILURE: increment candidate index, repeat immediately.
- No delay, no lockout, no CAPTCHA — full throughput limited only by TCP
connection setup and server-side crypto overhead.
5. On successful key match: attacker obtains authenticated SFTP session as the
Web User, with all configured file transfer permissions and accessible paths.
Note the RFC 4252 §7 "probe" optimization: SSH allows a client to query whether a public key would be accepted before providing the expensive signature. This means an attacker can enumerate candidate public keys at the cost of a single SSH_MSG_USERAUTH_REQUEST per candidate — without generating valid signatures — until the server confirms a hit. Only then must the attacker produce a signature, which requires possession of the matching private key. This limits the pure network-enumeration phase to public-key material only, but it does allow rapid filtering of a large candidate pool against a confirmed username.
Memory Layout
This vulnerability is a logic flaw, not a memory corruption bug. The relevant state that is never mutated on the key-auth path is the per-user failed login record in the application database. The struct below reflects the in-memory representation of a WebUser as held by the Hibernate entity during an authentication event.
/*
* WebUser entity (reconstructed from GoAnywhere MFT ORM behavior)
* In-JVM heap object — offsets are illustrative of field ordering,
* not absolute JVM addresses.
*/
struct WebUser {
/* +0x00 */ long id; // primary key
/* +0x08 */ String username; // reference
/* +0x10 */ String passwordHash; // bcrypt hash or null if key-only
/* +0x18 */ String sshPublicKey; // Base64 OpenSSH public key blob
/* +0x20 */ int failedLoginCount; // NEVER incremented on key-auth path
/* +0x24 */ int maxLoginAttempts; // threshold — NEVER checked on key-auth path
/* +0x28 */ boolean accountLocked; // only set via password path
/* +0x2c */ boolean sshKeyAuthEnabled; // true for affected accounts
/* +0x30 */ Timestamp lastFailedLogin; // reference — not updated on key-auth fail
};
AUTHENTICATION STATE — KEY-AUTH FAILURE (pre-patch):
Attempt #1:
user.failedLoginCount = 0 (unchanged)
user.accountLocked = false (unchanged)
response = SSH_MSG_USERAUTH_FAILURE
Attempt #1000:
user.failedLoginCount = 0 (still unchanged — BUG)
user.accountLocked = false (still unchanged — BUG)
response = SSH_MSG_USERAUTH_FAILURE
Attempt #N (matching key found):
user.failedLoginCount = 0 (reset is a no-op since counter never moved)
response = SSH_MSG_USERAUTH_PK_OK -> session established
AUTHENTICATION STATE — PASSWORD FAILURE (working correctly):
Attempt #1:
user.failedLoginCount = 1
Attempt #5 (at maxLoginAttempts):
user.accountLocked = true
response = SSH_MSG_USERAUTH_FAILURE (LOCKED)
All further attempts rejected without credential check.
Patch Analysis
// BEFORE (vulnerable — GoAnywhere MFT < 7.10.0):
if (request.getType() == AUTH_TYPE_PUBLICKEY) {
PublicKey submitted = request.getPublicKey();
PublicKey configured = keyDecoder.decode(user.getSshPublicKey());
boolean keyMatch = keyComparator.equals(submitted, configured);
if (!keyMatch) {
auditLogger.log(AUTH_FAILURE_PUBLICKEY, username);
return AuthStatus.FAILURE;
}
webUserRepository.resetFailedLoginCount(user.getId());
return AuthStatus.SUCCESS;
}
// AFTER (patched — GoAnywhere MFT 7.10.0):
if (request.getType() == AUTH_TYPE_PUBLICKEY) {
// PATCH: enforce lockout threshold on key-auth path, same as password path
if (user.getFailedLoginCount() >= user.getMaxLoginAttempts()) {
auditLogger.log(ACCOUNT_LOCKED, username);
return AuthStatus.FAILURE_LOCKED;
}
PublicKey submitted = request.getPublicKey();
PublicKey configured = keyDecoder.decode(user.getSshPublicKey());
boolean keyMatch = keyComparator.equals(submitted, configured);
if (!keyMatch) {
// PATCH: increment counter on key-auth failure, same as password path
webUserRepository.incrementFailedLoginCount(user.getId());
auditLogger.log(AUTH_FAILURE_PUBLICKEY, username);
return AuthStatus.FAILURE;
}
webUserRepository.resetFailedLoginCount(user.getId());
return AuthStatus.SUCCESS;
}
The fix is a pure parity patch: the two authentication branches are brought to functional equivalence with respect to lockout enforcement. No new mechanism was required — the infrastructure already existed and was simply not called on the key path.
Detection and Indicators
Log patterns (GoAnywhere MFT audit log):
# High-frequency AUTH_FAILURE_PUBLICKEY events for a single username
# from a single source IP — indicates active enumeration:
[AUDIT] type=AUTH_FAILURE_PUBLICKEY user=transferuser src=192.0.2.44 port=22
[AUDIT] type=AUTH_FAILURE_PUBLICKEY user=transferuser src=192.0.2.44 port=22
[AUDIT] type=AUTH_FAILURE_PUBLICKEY user=transferuser src=192.0.2.44 port=22
# ... repeated N times with no ACCOUNT_LOCKED event following
# Contrast with properly locked password-auth account:
[AUDIT] type=AUTH_FAILURE_PASSWORD user=transferuser src=192.0.2.44 port=22 (x5)
[AUDIT] type=ACCOUNT_LOCKED user=transferuser src=192.0.2.44 port=22
Network-level indicators: High-rate TCP connections to the SFTP port (22 or custom) with SSH handshake completing but authentication failing, sourced from a single IP or small rotating subnet. Each connection carries SSH_MSG_USERAUTH_REQUEST with method-name = "publickey" and signature field absent (probe phase) or present (active key test). A threshold of more than 20 distinct key blobs per 60 seconds against a single username should be treated as an active brute-force attempt.
SIEM rule (pseudocode):
ALERT when:
event.type = "AUTH_FAILURE_PUBLICKEY"
event.username = same value
count(event) > 20
timewindow = 60s
AND NOT EXISTS event.type = "ACCOUNT_LOCKED" for same username
Remediation
Primary: Upgrade GoAnywhere MFT to 7.10.0 or later. This is the only complete fix.
Compensating controls (if immediate upgrade is not possible):
Restrict SFTP listener access to known-good source IPs via firewall ACL. GoAnywhere supports IP allowlisting per Web User — enable it for all key-authenticated accounts.
Rotate all SSH public keys configured on Web User accounts. Invalidate any keys with weak parameters: DSA keys of any length, RSA keys under 3072 bits, ECDSA-256.
Enable connection-rate limiting at the network perimeter (e.g., iptables hashlimit or equivalent) on the SFTP port to throttle repeated connection attempts from a single source.
Audit Web User accounts to identify which are configured for key-only authentication — these are the exclusively affected population. Consider temporarily adding a secondary IP restriction for those accounts until patched.
Review GoAnywhere audit logs for AUTH_FAILURE_PUBLICKEY events retroactively. Given the lockout counter was never incremented, there will be no ACCOUNT_LOCKED watermark for any historically attempted brute force against key-auth accounts.