When Hospitality Software is Too Hospitable: an XSS Filter Bypass and a Curious SSRF in Oracle Hospitality OPERA (CVE-2026-21966, CVE-2026-21967)

Last autumn, while a typhoon hammered against the hotel windows, our offensive specialist found themselves locked into a different kind of storm – a pentest that refused to stay routine. What began as a run-of-the-mill exercise quickly spiralled into yet another thrilling adventure of vulnerability disclosure.

This writeup walks through DarkLab’s discovery of a Cross-Site Scripting (XSS) sanitization bypass and a powerful Server-Side Request Forgery (SSRF) vulnerability in Oracle’s OPERA product.[1]

Overview

CVE-2026-21966 – Reflected XSS in Oracle OPERA
CVSS v4.0: 5.1 /  MEDIUM  / CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N
Description: A reflected cross-site scripting (XSS) vulnerability has been identified in Oracle Hospitality OPERA 5, versions at and below 5.6.19.23, 5.6.25.17, 5.6.26.10, 5.6.27.4, 5.6.28.0. Attackers can leverage the vulnerability to deliver social engineering attacks and execute client-side code in the victim’s browser.
CVE-2026-21967 – SSRF and Credential Disclosure in Oracle OPERA
CVSS v4.0: 8.7 / HIGH  / CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:L/SI:L/SA:L
Description: A server-side request forgery (SSRF) vulnerability has been identified in Oracle Hospitality OPERA 5, versions at and below 5.6.19.23, 5.6.25.17, 5.6.26.10, 5.6.27.4, 5.6.28.0. Attackers can leverage the vulnerability to disclose database credentials, invoke POST requests on arbitrary URLs, and enumerate internal networks. The compromised database accounts are used by the OPERA system for business operations and are thus configured with read/write privileges. This may lead to further disclosure of personally-identifiable information (PII) or disruption of business operations if the attacker has access to the database port.

Globally, we observed over 500 Internet-facing Oracle OPERA instances:

Background

Oracle Hospitality OPERA 5 is a Property Management System (PMS) for hotels and resorts, managing core operations like check-ins, reservations, and room allocation — while also offering tools for sales, catering, revenue management, and guest personalization. As such, you would not be surprised to see hotel receptionists and customer support at large chains using this software to handle their everyday operations.

Our testing workstation was a registered OPERA Terminal accessed through a browser. Once login is completed and a tool is selected from the menu, a Java applet pops up.

Figure 1: Sample OPERA login interface

CVE-2026-21966: Reflected XSS and Sanitization Bypass

The Road to XSS

In OPERA, HTTP requests are handled by Java servlets, which are classes with doGet and/or doPost methods. Inside OperaLogin.war, we discovered the OperaPrint servlet which accepts GET requests via a doGet method.

public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// [...]
try {
String execute = Utility.sanitizeParameter(request.getParameter("ex"));
// [...]
if (execute.equals("INIT")) {
initPrint(request, response, /* ... */);
}
} catch (Exception e) {
logException(e, replog, appServerStr);
}
}

Listing 2: This Java servlet handles a GET request and accepts an ex parameter.

By following the taint trail, we see the data is concatenated with other HTML strings and embedded in the HTTP response, wrapped with single quotes.

private void initPrint(
HttpServletRequest request,
HttpServletResponse response, /* ... */)
throws IOException {
// [...]
String newquery = setParam(
Utility.sanitizeParameterString(request.getQueryString()),
"ex",
"START");
// [...]
String newurl = "/OperaLogin/OperaPrint?" + newquery;
// [...]
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter();
out.println("<!DOCTYPE html ...>");
out.println("<html>");
out.println("<head>");
out.println("...");
out.println("</head>");
// [...]
out.println("<body onload=\"InitPrint( '" + newurl + "' , '" + winname + "' )\"/>");
out.println("</html>");
out.close();
}

Listing 3: The URL query is reused and concatenated into HTML output.

This provides a trail for reflected XSS. However, the astute would notice that the code sanitizes user input using Utility.sanitizeParameterString. Some people would probably stop here, but let’s Try Harder™. What does this function actually do? Can you spot the flaw? (Please say yes.)

public static String sanitizeParameterString(String ret) {
if (JavaUtils.isNullOrEmpty(new String[] { ret }))
return ret;
String openTag = "=";
String closeTag = "&";
boolean flagProcessing = true;
int currentTagPosition = 0;
if (ret != null && ret.length() > 0) {
while (flagProcessing) {
int openTagPosition = ret.toLowerCase().indexOf("=", currentTagPosition);
int closeTagPosition = ret.toLowerCase()
.indexOf("&", openTagPosition + "=".length());
if (openTagPosition != -1) {
String param;
SanitationMessage<String> sMessage = new SanitationMessage<String>("");
if (closeTagPosition != -1) {
param = _sanitizeParameter( // <-- Line 901
ret.substring(openTagPosition + "=".length(), closeTagPosition),
sMessage
);
} else {
param = _sanitizeParameter( // <-- Line 906
ret.substring(openTagPosition + "=".length()),
sMessage
);
}
currentTagPosition = openTagPosition + "=".length() + param.length();
if (closeTagPosition != -1) {
ret = ret.substring(0, openTagPosition + "=".length())
+ param
+ ret.substring(closeTagPosition);
continue;
}
ret = ret.substring(0, openTagPosition + "=".length()) + param;
continue;
}
flagProcessing = false;
}
}
return ret;
}

Notice in lines 901 and 906 that _sanitizeParameter is called on a substring. However, the string is extracted starting from the equal sign (=), up to the next ampersand (&; closeTagPosition) or until the end of the string (if closeTagPosition is not found). In other words, only the parameter value is extracted for sanitization. The parameter name is not sanitized.

This means the sanitization function could be bypassed using a query parameter such as /path?'name=value.

(Un)Fortunately, while such a payload may succeed on older or lesser-known browsers, it fails to bypass modern browser filters, which will automatically URL-encode the single quote ' to %27 before firing the HTTP request.

Despite this roadblock, we were able to bypass the sanitization function and browser protection with an alternative method.

A More Robust Sanitization Bypass

We used another trick, which is to use a HTML-entity &apos; instead of the single-quote literal. Normally, this trick would not work if the payload was reflected inside <script> tags. But in the context of a HTML attribute such as onload="...", the &apos; entity is treated as a literal quote, allowing us to escape the string context and run arbitrary JavaScript from the browser.

CVE-2026-21967: SSRF and Credential Disclosure

An SSRF is a vulnerability where an attacker tricks a web application into making unauthorized, unintended, or forged requests to internal or external resources. Attackers may exploit SSRFs to call privileged endpoints or exfiltrate sensitive data placed in cookies, headers, and parameters.

By reviewing yet another Java servlet (OperaServlet), we discovered a parameter named urladdress. This by itself is a huge code smell.

public void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException, UnsupportedEncodingException {
PrintWriter out = response.getWriter();
// [...]
cmd = Utility.sanitizeParameter(request.getParameter("cmd"));
urladdr = Utility.sanitizeParameter(request.getParameter("urladdress"));
// [...]
userid = StringValue[0];
// [...]
if (cmd.equalsIgnoreCase("runreport")) {
callreport(userid, /* ... */, urladdr, out);
}
}

Listing 4: Servlet contains a urladdress parameter.

Following the taint trail, we arrived at the callreport function. This function opens a URL connection to an attacker-controlled address and returns any data received.

private void callreport(
String userid, /* ... */
String urladdress, PrintWriter out) {
DataOutputStream outstr = null;
BufferedReader in = null;
try {
urlparams = "userid=" + userid + /* ... */;
String myAddress = urladdress;
URL myUrl = new URL(myAddress);
URLConnection con = myUrl.openConnection();
con.setDoInput(true);
con.setDoOutput(true);
con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
outstr = new DataOutputStream(con.getOutputStream());
outstr.writeBytes(urlparams);
outstr.flush();
outstr.close();
in = new BufferedReader(new InputStreamReader(con.getInputStream()));
String inputLine;
while ((inputLine = in.readLine()) != null)
out.println(inputLine);
in.close();
} catch (Exception e) {
e.printStackTrace();
} finally {
// [...]
}
}

We were able to confirm the SSRF vulnerability by testing on a local port with netcat listening, then an online webhook service. Notably, we found that plaintext database credentials could be disclosed when a specific parameter is provided.

Figure 5: Left- Attacker-controlled server which receives the SSRF request. Credentials are disclosed in the dbuser/dbpswd@dbschema format. Such hospitality! Right- The crafted request was sent through curl.
Figure 6: Same demonstration but using an online webhook site to demonstrate the remote nature and exploitability.
Figure 7: We were able to enumerate and login to the database server using sqlplus.

In addition to demonstrating remote exploitability, Figure 6 also shows that the HTTP response from the target server (in this case, webhook[.]site) is reflected. An attacker can abuse this by disclosing information on subsequent systems.

We verified these credentials by enumerating the OPERA database host and connecting with sqlplus, an SQL client for Oracle Database. A few seconds later, we’re in!

Inside the database, it was possible to view room allocations, customer names, and emails, among other details.

Impact

CVE-2026-21966: Reflected XSS

Attackers can induce victims to run arbitrary client-side JavaScript, compromising confidentiality and integrity of the victims’ browser session. Attackers can exploit this to proxy through the victim’s browser and potentially perform authenticated requests to Oracle OPERA or other systems on behalf of the victim. This may allow attackers to establish a foothold on the internal network through a social engineering attack.

CVE-2026-21967: SSRF and Credential Disclosure

We have identified multiple impacts for CVE-2026-21967:

  1. Credential Disclosure; Potential Database Access and Customer Information Disclosure. Most concerningly, successful exploitation could lead to the disclosure of database credentials which are used by OPERA for business operations, enabling unauthorized read/write access to the database and password spraying of the corporate network.
  2. POST Request SSRF. By convention, POST requests are used to modify, create, or delete data. In general, they are used to perform more complex tasks compared to GET requests. An SSRF with the capability to send POST requests tends to be more dangerous as it may trigger these complex behaviors, which potentially include disrupting subsequent systems, modifying application data, or exploiting other vulnerabilities.
  3. Social-Engineering Attacks. Since the HTTP output is attacker controllable, it is possible to deliver arbitrary HTML. Attackers can abuse the trust of an OPERA domain to deliver malicious payloads which run in a victim’s browser.
  4. Enumerate Internal Network. An attacker can enumerate the internal network by port scanning or observing the HTTP response, which is reflected from the subsequent system. (This is your typical SSRF impact.) Figure 8 shows the enumeration of common Windows ports (135, 3389) in addition to various Oracle ports.
Figure 8: Sample impact: enumerate ports on the localhost machine.

Proof of Concept

During our discovery of CVE-2026-21966 and CVE-2026-21967, we successfully developed a Proof-of-Concept (POC) for both flaws. However, given the sensitivity of CVE‑2026‑21967 in particular, we have chosen not to release these POCs publicly.

Are you susceptible?

You can follow these steps to verify your Oracle Hospitality OPERA version:

  1. Login to OPERA.
  2. Select any tool. The version should be displayed in the window title.
  3. For detailed version information, select the rightmost “Help” tab.

If you believe you are susceptible to CVE-2026-21966 and/or CVE-2026-21967 and are seeking additional details, please do not hesitate to contact us directly for further guidance.

Remediations

  1. Upgrade to the latest versions of Oracle Hospitality OPERA.
  2. Apply network segmentation between the internal network and Oracle Hospitality OPERA services.
  3. Limit outbound traffic to suspicious sites and domains. Ideally, whitelist allowed destinations.
  4. Do not expose Oracle Hospitality OPERA to the public internet.

Detection Opportunities

In addition, we strongly recommend continuous monitoring of Oracle Hospitality OPERA instances for potential indicators of attack, such as unusual incoming HTTP requests containing Cross-Site Scripting (XSS)  payloads or unauthorized data modification. Further, we advise Web Application Firewall (WAF) coverage for Oracle Hospitality OPERA 5 deployments exposed to the internet to detect/block common XSS payloads.

YARA Rule:

rule CVE_2026_21966
{
meta:
description = "Detection of CVE-2026-21966"
author = "PwC DarkLab"
date = "2026-02"
reference = "CVE-2026-21966"
severity = "medium"
strings:
$path = "OperaPrint" ascii nocase
$apos_entity = "&apos" ascii nocase
condition:
$path and $apos_entity
}
rule CVE_2026_21967
{
meta:
description = "Detection of CVE-2026-21967"
author = "PwC DarkLab"
date = "2026-02"
reference = "CVE-2026-21967"
severity = "high"
strings:
$path = "OperaServlet" ascii nocase
$status = " 200 " ascii
$a = /o.*?p.*?e.*?r.*?a.*?d.*?s/i
$b = /urladdress\s*?=.*?h.*?t.*?t.*?p.*?.*?:/i
condition:
$path and
$status and
$a and $b
}

Save the above file as rules.yar and run the following on your Oracle Application Server. By default, the logs are stored in D:\ORA\user_projects\domains\OperaOHSDomain\servers\ohs1\logs.

Get-ChildItem -Path "D:\ORA\user_projects\domains\OperaOHSDomain\servers\ohs1\logs\access_log*" | ForEach-Object { .\yara64.exe rules.yar $_.FullName }

Conclusion

We hope you enjoyed this walk through of our team’s discovery of CVE-2026-21966 and CVE-2026-21967 in Oracle Hospitality OPERA 5 – a widely deployed property management system essential to hotel operations. As earlier mentioned, to protect our clients and the broader hospitality industry, we intentionally omitted the Proof-of-Concept and highly technical information that could otherwise be abused by malicious actors to weaponize these vulnerabilities.

The most severe, a Server-Side Request Forgery (SSRF) with a high CVSS v4.0 score of 8.7, allows attackers to disclose sensitive database credentials, potentially leading to unauthorized access to vast amounts of guest Personally Identifiable Information (PII) like names, emails, and room allocations. This could also enable internal network enumeration and operational disruption.

This risk is profoundly amplified in the hospitality sector, where open Wi-Fi networks potentially introduce avenues to infiltrate the internal network; therefore, merely restricting public internet access for OPERA systems is insufficient. Robust network segmentation, immediate patching, and comprehensive security controls are absolutely critical to safeguard customer data, maintain business continuity, and protect brand reputation.

General Best Practices

To effectively safeguard critical systems like Oracle Hospitality OPERA and the sensitive data they manage, organizations must adopt a comprehensive, multi-layered security strategy. Beyond specific vulnerability patches and remediation advice, the following best practices are crucial for maintaining a strong security posture:

Robust Patch and Vulnerability Management:

  • Timely Updates: Establish and enforce a rigorous process for promptly applying security patches and updates to all operating systems, applications (including Property Management Systems), databases, and network devices.
  • Active Attack Surface Management (ASM): Continuously discover, inventory, and assess all internet-facing assets and their potential vulnerabilities. This should include regular penetration testing and security audits by independent third parties to identify weaknesses before attackers do.

Network Segmentation and Isolation:

  • Isolate Critical Systems: Implement strict network segmentation to logically separate critical systems (e.g., OPERA servers, database servers) from less trusted networks, such as guest Wi-Fi, corporate office networks, and other non-essential segments.
  • Zero Trust Principles: Apply Zero Trust principles, ensuring that no user, device, or application is inherently trusted, regardless of its location. All access requests must be authenticated, authorized, and continuously validated.
  • Strict Egress Filtering: Implement outbound firewall rules to limit critical systems’ ability to connect to arbitrary external or internal destinations. Whitelist only absolutely necessary connections to prevent data exfiltration and command-and-control communications.

Enhanced Security Monitoring and Incident Response:

  • 24×7 Security Operations Centre (SOC): Leverage a 24×7 Security Operations Center (SOC) for continuous monitoring of security logs, network traffic, and system behaviour. This enables rapid detection of anomalous behaviour and indicators of compromise (IOCs).
  • Advanced Threat Detection: Deploy Intrusion Detection/Prevention Systems (IDS/IPS), Security Information and Event Management (SIEM) systems, and Endpoint Detection and Response (EDR) solutions to provide deep visibility and automated threat response capabilities.
  • Incident Response Plan: Develop, regularly test through tabletop exercises and simulations, and refine a comprehensive incident response plan to ensure rapid detection, containment, eradication, and recovery from security breaches.

Principle of Least Privilege and Strong Authentication:

  • Role-Based Access Control (RBAC): Implement granular Role-Based Access Control (RBAC) to ensure that users and system accounts only have the minimum necessary permissions to perform their assigned functions.
  • Multi-Factor Authentication (MFA): Enforce Multi-Factor Authentication (MFA) for all administrative access, remote access, and privileged user accounts across all critical systems to significantly reduce the risk of credential compromise.
  • Credential Management: Implement strong password policies, regularly rotate credentials, and securely manage secrets, avoiding hardcoded or easily discoverable credentials within code or configuration files.

Secure Development and Configuration:

  • Secure Coding Practices: For custom applications or integrations, ensure developers adhere to secure coding guidelines, including robust input validation and output encoding to prevent common web vulnerabilities like Cross-Site Scripting (XSS) and Server-Side Request Forgery (SSRF).
  • Hardening Baselines: Apply secure configuration baselines to all operating systems, databases, and applications, disabling unnecessary services, features, and default accounts.

Data Protection and Resilience:

  • Encryption: Encrypt sensitive data at rest (e.g., database encryption, disk encryption) and in transit (e.g., TLS for all communications) to protect it from unauthorized access.
  • Regular Backups: Implement a robust, tested, and isolated backup and recovery strategy for all critical data and system configurations to ensure business continuity and data availability in the event of a compromise or disaster.

Timeline

  • Sept. 19, 2025. Discovered first issue (Reflected XSS, now tracked as CVE-2026-21966).
  • Oct. 12, 2025. Discovered second issue (SSRF and Credential Disclosure, now tracked as CVE-2026-21967).
  • Oct. 20, 2025. Vulnerability report sent to Oracle Security Alerts.
  • Jan. 15, 2026. Pre-release announcement by Oracle.
  • Jan. 20, 2026. Public disclosure by Oracle.
  • Feb. 13, 2026. Technical writeup and disclosure by PwC DarkLab HK.

Acknowledgements

Special thanks to the Oracle Security Alerts team for coordinated disclosure. For more information about recent vulnerabilities affecting Oracle Hospitality, please read the advisory published by Oracle: https://www.oracle.com/security-alerts/cpujan2026.html.

Further Information

We are committed to protecting our clients and the wider community against the latest threats through our dedicated research and the integrated efforts of our red team, blue team, incident response, and threat intelligence capabilities. Feel free to contact us at [darklab dot cti at hk dot pwc dot com] for any further information.

Reverse Engineering a Siemens Programmable Logic Controller for Funs and Vulns (CVE-2024-54089, CVE-2024-54090, & CVE-2025-40757)

Under the sweltering heat of the Hong Kong summer, we entered a looming building and kicked off what was supposed to be a simple penetration test. Little did we know, this ordeal would lead to panic-stricken emails, extra reports, and a few new CVEs.

This is a tale of the unexpected discovery of three CVEs in a Siemens logic controller, reverse engineering a bespoke architecture, and an authentication bypass obscured by proprietary file formats.

  • CVE-2024-54089 – Weak Encryption Mechanism Vulnerability in Apogee PXC and Talon TC Devices[1]
  • CVE-2024-54090 – Out-of-Bounds Read Vulnerability in Apogee PXC and Talon TC Devices[2]
  • CVE-2025-40757 – Information Disclosure Vulnerability in Apogee PXC and Talon TC Devices[3]

Background

Our story begins with a simple network penetration test. The objective was to test our client’s internal network for potential vulnerabilities which could allow an attacker to take over systems from the perimeter, affect internal systems, and/or pivot to other networks. After a bit of mundane scanning and spreadsheet wrestling, we came across a few devices marked as Operational Technology (OT).

Nessus detected BACnet devices on the network

Meet the Siemens Apogee/Talon PXC Modular – a programmable logic controller (PLC) designed to automate building controls, monitoring, and energy management. These devices are primarily used in HVAC (heating, ventilation, air conditioning) systems which may have complex requirements depending on the weather, season, and time of day.

PLCs are like the managers of a building automation system. Just as managers oversee teams, allocate resources, and report to higher ups, PLCs monitor sensor inputs, execute logic, and send alerts and telemetry back to a central system or workstation.

Corporate analogies aside, quick scans of the device revealed interesting ports: telnet, HTTP, and BACnet (UDP/47808).

Hidden in HTTP

Analysis of the HTTP server quickly revealed the presence of a path traversal bug in the HTTP server – which we later validated to be CVE-2017-9947. This 7-year-old vulnerability enables remote attackers with network access to the integrated HTTP server to obtain information on the structure of the FAT file system.

Exploitation of CVE-2017-9947 led to enumeration of the following files stored within the directory:

A few files piqued our interest, and we downloaded these with a special parameter. Upon opening 7002[.]db, we uncovered what appeared to be proprietary hex – mostly appearing unhelpful upon first glance – though housing some default credentials which may render themselves useful later…

python custom_decode_script.py 7002.db | xxd

Toying with Telnet

The telnet service was password-protected, but that would not stop us. With a bit of enumeration, we identified a user manual[4] specifying three (3) default credentials: HIGH:HIGH, MED:MED, and LOW:LOW. The first set of default credentials (HIGH:HIGH) rendered itself useless (for now), though the subsequent two default credentials enabled successful login to the Telnet service.

We’re in!

After a bit of exploration, we found we had permission to dump memory as MED!  

And here comes our first finding: what happens if we dump memory at a higher address?

Oh no! Connection lost.

Immediately, we double checked BACnet objects. Originally, over 900 objects were observed – now, only 17 remain. Needless to say, availability has gone out the window.

Current state of BACnet objects; only displaying hardware debug information.

Out of curiosity, we also tried logging into telnet as HIGH with the default password. This time, it worked!

We are HIGH! Oh no.

Let’s recap. We were initially unable to login as HIGH, but could login as MED. When we inputted a large address into the Dump Memory function, we lost the telnet connection. Further enumeration showed 99% of BACnet objects were missing, and the password for HIGH was reset.

Fast forward several months, and our discovery was formally recognized as CVE-2024-54090[5]:

A Peek at Memory and Some Déjà vu

After taking all the necessary screenshots for the first finding, we proceeded to double down on the Dump Memory function. What else could we uncover?1

We determined the memory range to be 0 to 0x03FF'FFFF, which precisely correlates with the 64MB SDRAM listed in the Technical Spec.[6] After obtaining the full dump, it’s time to see what’s inside! A simple strings (or od) operation revealed some familiar faces…

Output from od -A x -S 4 dump.bin | less. The od command will attach the memory location too; quite useful when reversing alongside another tool.

Huh, curious! These are similar to the strings we previously saw in the .db file. On a whim, we tried changing our MED password and dumping the 0xc10f99 region. And sure enough, instead of #kjD., new values appeared. Not only that, but other values around the region remain unchanged, which suggests this particular memory location is tied to the password we just changed.

At this point, we hypothesised these values to be encrypted passwords. If kjD. is our password for MED, then perhaps 1237 and f}W are the passwords for HIGH and LOW respectively? After a quick test, we confirmed f}W is likely the encrypted password for LOW. So where does that leave us with 1237?

On another whim, we tried logging in as HIGH with the password 1234, and…we’re in?! (again)

WHAT?!

In utter disbelief, we toyed around with other passwords, and well – you can see the results for yourself.

Sample plaintext/ciphertext pairs. Notice how passwords comprised solely of digits are easily guessable.

This leads us to our second finding, CVE-2024-54089[7]; a weak password encryption mechanism. At this point, it was confirmed an attacker could guess certain passwords.

In the next few sections, we will show how we discovered how to decrypt any password. We initially attempted to reverse the encryption with a black-box approach and tried our hands at differential cryptanalysis. After much deliberation and regret at not having played more cryptography-style CTF challenges, we decided it was time for a different approach.

Taking a Trip Down Memory Lane

To solidify the impact of our finding (and to properly crack the xor-based slop), we proceeded to reverse-engineer the memory dump.

Loading Memory

While strings and od can provide clues, they do so without much context. We loaded the entire 64MB memory dump into Ghidra and were greeted with this marvelous junk:

Oops, wrong endian. Let’s try loading the same file with Big Endian instead.

That’s more like it!

PowerPC supports both big and little endian, which determine the order of bytes being interpreted. If we specify the wrong endian, the disassembler cannot correctly parse instructions. Evidently, this particular PLC uses big endian.

From here, we can hunt for more vulnerabilities or dig deeper into our previous findings. For now, we’ll stick to reverse engineering the encryption algorithm. But where do we start?

libc: An Exercise in Reverse Engineering

Without symbols, standard C functions are expressed as mumbo jumbo. While these are tedious to reverse, it does help stretch our reversing brains a bit. For instance, the following function has over 600 cross-references (XREFs). If we can identify this function, we’ll have an easier time reversing other parts of code. What do you think this function is?

void FUN_008ba294(undefined *param_1, undefined *param_2, uint param_3)
{
  param_1 = param_1 + -1;
  param_2 = param_2 + -1;
  for (; param_3 != 0; param_3 = param_3 - 1) {
    param_2 = param_2 + 1;
    param_1 = param_1 + 1;
    *param_1 = *param_2;
  }
  return;
}

This is indeed memcpy. This copies param3-bytes from the memory at param2 to the memory at param1. The actual decompiled function is slightly more complicated with optimisations for copying the buffer by words (4 bytes) instead of byte-by-byte. To make our lives easier, we’ll edit the function signature with the appropriate names and types.

Here’s another function (over 1200 XREFs). What could this be?

char * FUN_008ba680(char *param_1)
{
  char cVar1;
  uint *puVar2;
  char *pcVar3;
  uint *puVar4;
  uint uVar5;
 
  uVar5 = 4U - (int)param_1 & 3;
  pcVar3 = param_1;
  while( true ) {
     if (uVar5 == 0) {
       puVar2 = (uint *)(pcVar3 + -4);
       do {
          puVar4 = puVar2;
          puVar2 = puVar4 + 1;
          uVar5 = *puVar2;
       } while ((uVar5 + 0xfefefeff & ~uVar5 & 0x80808080) == 0);
       pcVar3 = (char *)((int)puVar4 + 3);
       do {
          pcVar3 = pcVar3 + 1;
       } while (*pcVar3 != '\0');
       return pcVar3 + -(int)param_1;
     }
     cVar1 = *pcVar3;
     pcVar3 = pcVar3 + 1;
     if (cVar1 == '\0') break;
     uVar5 = uVar5 - 1;
  }
  return pcVar3 + (-1 - (int)param_1);
}

Hint: What is being returned and how is it computed?

Some lines might seem scary, but let’s work with what we observe and know:

  • param_1 operates on a char* and the null byte \0 is checked, so this is likely a string operation.
  • The return statement pcVar3 - 1 - param_1 is a good clue that the function is doing some kind of index-of or counting operation since param_1 is the start of the string. Analysing the operations, no other special operation is performed aside from incrementing pcVar3/pcVar4.
  • Hence, ignoring the weird constants in the nested while-loop, we can conclude with relative certainty this is our good friend strlen.
  • For the curious, the uVar5 + 0xfefefeff & ~uVar5 & 0x80808080 magic is some bitwise trickery to check for null bytes in a word.[8]

We continued hacking away at familiar functions before slowly, moving on to complex higher level functions.

A Note on RTOS

We tried finding the encryption function through different approaches. While noodling around, we came across the thread function running the telnet server (think: the main function / entrypoint of the thread). We were unable to drill down to our target (shakes fist – curse you indirection!), but this still posed a good opportunity to observe how the PLC works at the embedded level, and to revisit concepts of embedded software.

By correlating strings and the number 23 (the default telnet port), we determined functions relevant to socket programming.

For a bare metal system to handle multithreading, a common approach is to use an RTOS (Real-Time Operating System). The RTOS is often provided as a library/API containing threading and synchronisation primitives. It is also common to allocate space for a stack then call some create_task function with a function pointer to the entrypoint of the thread.

Once in a while, we come across interesting bits and pieces. As a side quest, we took a peek at other thread functions and uncovered the code for a debug server with a peculiar choice of port.

Our nmap scans did not reveal this port, so it is likely an artifact from internal testing. Still an interesting find though!

Uncovering the Encryption

After much trial and error, we were able to find the encryption function.

We started by again, searching for common phrases associated with login. This time we tried searching for one of the default users: HIGH. In one instance, the string was embedded among other strings such as “newPassword“, “oldPassword“, and “UserAccountPasswordReset” which suggests some kind of parsing/logging/error-handling related to password reset.

We followed XREFs to a relevant function and got our hands dirty.

Inside the reset_password function, we identified two similar code flows which operate on the old password and new password. In the screenshot below, the old password is converted to bytes and validated before being copied into the buffer at param_1 + 0x291. Later on, the same process is applied to the new password which is copied to param_1 + 0x17a.

As suspected, the code eventually calls a function on each buffer, which we confirmed performs in-place encryption – the very thing we were looking for!

The actual encryption process is rather straightforward to reverse. First, the password is converted to UPPERCASE.

It then performs multiple xor operations on the string, looping through each character. Each byte is xored with a variety of numbers. And interestingly, byte 0x2a (42) shows up multiple times. Coincidence?

Once we know the encryption process, it was trivial to reverse the decryption due to the use of xor.

For security reasons, we will not disclose the full algorithm here, but the gist is that we confirmed the algorithm is xor-based with a hard-coded key. An attacker with knowledge of the algorithm can decrypt any encrypted password on any affected device.

BAC(net) to the Future

This writeup would not be complete without a juicy OT attack. After reporting the first two vulnerabilities, we realised there was an obscure information leak hiding in plain sight…

BACnet is a building automation protocol which exposes an API to read/modify settings of a device. It typically runs on udp/47808 (0xBAC0) and is designed for lightweight and flexible communication between building controllers. We used JoelBender’s bacpypes Python library to interface with BACnet.[9] (Check out this resource to learn more about BACnet basics[10])

BACnet objects found by querying with bacpypes.

We followed this process when enumerating BACnet:

  1. Gather the Device ID. (nmap, Nessus, port scan)
  2. List objects on the device: ./samples/ReadObjectList.py
  3. Select objects and list properties: ./samples/ReadAllProperties.py

From here, we tested individual properties for read/write capability. Suffice it to say, BACnet network security hardly meets modern standards, but that is not our focus for this post.

Instead, we turn our attention to a few interesting BACnet objects specific to our targets:

Look familiar? Using a modified version of ReadWriteFile.py[11], we downloaded the files over BACnet. And surprise surprise – it holds the same contents as the .db found earlier in the HTTP server. But as we realised before, these files actually contain encrypted passwords; and since BACnet by nature does not have authentication, this means devices are susceptible to unauthenticated information disclosure. Anybody on the network can slurp out encrypted passwords. Alas, meet CVE-2025-40757:

The implications are huge! An attacker on the network could perform an authentication bypass by chaining the above issues: read the encrypted password from BACnet, decrypt/guess the plaintext, and login to telnet as a HIGH (admin) user. Even if telnet were disabled, the passwords could be used for spraying across other systems.

Once an attacker can login as HIGH, they could (conceptually) execute arbitrary assembly instructions with the built-in Write Memory feature or modify the embedded HTML to include an XSS snippet! More concerningly, they could compromise availability by tampering with device settings.

If anything, this goes to show how security by obscurity is insufficient.

Let’s Recap

TLDR;

  • We discovered MED/HIGH users can cause the affected device to enter a cold-start state, severely impacting availability. (CVE-2024-54090)
  • We reverse engineered the encryption mechanism for telnet passwords and confirmed it was xor-based. No encrypted passwords are safe. Moreover, if a password contains only digits, it is easily guessable in its encrypted form. Decryption works across affected devices due to a hard-coded key. (CVE-2024-54089)
  • We discovered an Information Disclosure vulnerability where encrypted passwords are disclosed over a BACnet file object. (CVE-2025-40757)
  • By chaining CVE-2025-40757 and CVE-2024-54089, we could perform an authentication bypass, allowing one to login as any user and tamper with the device.

Mitigations

Affected Devices:

  • Siemens APOGEE PXC Series (all versions)
  • Siemens TALON TC Series (all versions)

As of writing, no fix is planned by Siemens. The following mitigations and temporary workarounds can be applied instead:

  1. Disable telnet. According to Siemens, telnet should be disabled by default, but in our experience, it is not uncommon for site administrators to enable it for convenience. We recommend disabling telnet to mitigate these vulnerabilities.
  2. Change the default password for all accounts (HIGH, MED, LOW) even if unused. Choose strong passwords containing a mix of letters and digits.
    • Do not choose passwords comprised solely of digits.
    • Note that this does not prevent attackers with knowledge of the encryption algorithm from decrypting the passwords.
  3. Apply detective controls such as network monitoring to identify suspicious traffic.

Acknowledgements

Special thanks to Siemens ProductCERT team for coordinated disclosure. For more information on these vulnerabilities, please refer to the official advisories published by Siemens:

  • CVE-2024-54089 – Weak Encryption Mechanism Vulnerability in Apogee PXC and Talon TC Devices[12]
  • CVE-2024-54090 – Out-of-Bounds Read Vulnerability in Apogee PXC and Talon TC Devices[13]
  • CVE-2025-40757 – Information Disclosure Vulnerability in Apogee PXC and Talon TC Devices[14]

Further information

Feel free to contact us at [darklab dot cti at hk dot pwc dot com] for any further information.

  1. It is important to caveat that we did not have the firmware for this controller, nor was Ghidra MCP available at the time, so our testing was very much black box. Further, we faced several roadblocks: 1) All symbols, libc or otherwise, needed to be reversed manually. 2) Ghidra flow detection is a bit buggy with PowerPC. 3) Code may be incomplete, since it may be overwritten with data or loaded dynamically.

    Any function/variable names you see in our reversing process are a best guess based on limited information and patterns. In hindsight, our testers recognized there could have been alternative approaches taken, such as grabbing an appropriate libc.so and applying offsets, or reading up on prior research on Nucleus RTOS (which seems to be the underlying RTOS). ↩︎