
This blog post covers CVE-2026-28325 and is the third in a three-part series (read the first post and the second post). This is the one I most wanted to write, because I got it wrong twice before getting it right.
The short version of the story is that a single crafted WebSocket frame to TCP 17778 runs code as NT AUTHORITY\SYSTEM on the Orion core. No credential, no client certificate, no module, and no user interaction.
To readers who have followed and read the previous two blog posts of the series, they were good findings but we wanted to challenge ourselves to see whether we could find another chain in SolarWinds Orion/Self-Observability with zero add-on modules and none of the “non-default” configurations. So we started digging deeper.
The Orion core itself is the heart of a SolarWinds monitoring infrastructure. Every monitored node runs an agent that phones home to the Agent Management Service on the core, over a WebSocket on TCP 17778 to the endpoint /msg. The service host is SolarWinds.BusinessLayerHostx64.exe, and it runs as LocalSystem.
Before we start, it is also worth being clear about what SYSTEM on that Orion box without any additional modules or non-default configurations means. The core stores credentials for the whole managed estate and legitimately tasks every agent in it. This is, functionally, the full compromise of the deployment.
By default, agent connections are gated by a per-agent, DB-thumbprint-pinned client certificate. That makes 17778 a lateral movement surface: compromise an enrolled agent first, then talk to the core. However, we followed a three-defect chain to get us all the way to unauthenticated RCE landing as SYSTEM on the Orion core with zero non-default configurations:
clientId is treated as a “connection test,” and the certificate check is guarded by a boolean that this sets to false.RecipientId field.RecipientId reaches a Json.NET deserializer at TypeNameHandling.Auto with no SerializationBinder.
UpgradeToWebSocketsImpl from SolarWinds.AgentManagement.Messaging.Core handles the WebSocket upgrade. A connection to /msg carrying an empty clientId is treated as the client’s “connection test,” and that sets an internal flag to false.
Now look at what that flag guards. The single authentication in the entire inbound path is ValidateConnectionSecurity—the per-agent client-certificate and DB-thumbprint gate, and it is invoked only under ... && flag. So:
flag = false, ValidateConnectionSecurity is short-circuited away;channel.Handle() still runs unconditionally;clientId are RegisterClient and its ChannelClosed → UnregisterConnection hook, which are outbound-routing bookkeeping and are never consulted on the receive path.// clientId comes straight off the query string
bool flag = !string.IsNullOrEmpty(clientId);
if (<cert checking enabled> && flag)
ValidateConnectionSecurity(context); // per-agent cert / DB thumbprin
channel.Handle(); // runs regardless of fla
if (flag) {
RegisterClient(clientId, channel); // outbound routing bookkeeping
channel.ChannelClosed += UnregisterConnection;
}
So this request connects and processes inbound frames with no credential and no client certificate:
wss://<core>:17778/msg?clientId=&clientType=WebSocketsExchangeClientThe rest of the hunt was about answering two questions: is there a second gate deeper in, and is there a RecipientId that reaches something dangerous?
After further analysis, we found all reassembled frames become a Message, enqueued by EnqueueIncomingExchange function.
// reduced: everything this does with an inbound Exchange
void EnqueueIncomingExchange(Exchange exchange) {
var msg = new Message {
DeviceId = exchange.DeviceId, // attacker-controlled, never validated
Manifest = exchange.Manifest, // attacker-controlled (layer 1)
Data = exchange.Data, // attacker-controlled (layer 2)
};
_incomingQueue.Enqueue(msg); // one global FIFO, keyed to nothing
}
// no connection lookup, no registration check, no identity resolutionThat function inserted a user-supplied input to the queue, which is a global FIFO keyed to surprisingly nothing. However, DeviceId, Manifest, and Data inputs come straight from the Exchange protobuf under our control, and i_session / connectionId is freshly generated, so no prior handshake is required by any layer.
// AMS.Proxy (native) -- the only check on the messaging branch
bool AgentMessage_t::IsValidMessage(AgentMessage_t *msg, bool strict) {
if (msg->magic != EXPECTED_MAGIC) return false; // shape
if (msg->length > MAX_LENGTH) return false; // shape
return true; // never identity
}Underneath, the native messaging hub (AMS.Proxy) ignores the client certificate on the messaging branch—the certificate is consumed only by the provisioning hub—and applies only a structural IsValidMessage check on magic bytes and length. A full-module grep for approved|registered|unauthorized returns zero hits anywhere on this path.
Delivery is then decided purely by message.RecipientId: the function IncomingMessageProcessor.ProcessIncomingMessage looks up _subscribers[RecipientId] and calls ReceiveMessages on it. We traced down the RecipentId and found that it’s an attacker-controlled string, with no authorization check between it and the handler.
// IncomingMessageProcessor -- the entire routing decision
void ProcessIncomingMessage(Message message) {
// no sender identity, no registration state, no certificate consulted
if (_subscribers.TryGetValue(message.RecipientId, out var subscriber))
subscriber.ReceiveMessages(message);
}That raised the obvious question: which RecipientId do you send to reach the potentially dangerous handler?
The obvious first guess is the service’s own name. RecipientId="AMS" routes to ConnectionManagementService.ProcessIncomingMessage in AgentManagement.ServiceCore.Common, and that turns out to be a fixed switch rather than a dispatcher:
CollectDiagnosticsFinished, Plugins* and PluginStart*—all gated on value != null, meaning a connected agent looked up in _connectedAgents[SourceEndpointId], which we do not have because we never registered;infraPing;AMS_UnregisterClient, which goes to DisconnectClient and is just Guid.TryParse(MessageData).Anything else, including all the interesting-sounding verbs, falls through to default → "Unexpected message received" and is dropped.
After some digging, we found an interesting constant returned by ServiceHostManagerAgentConnectionDetails.GetRecipientId() , which yields ServiceHostManager. More importantly, with a crafted message, we could reach the code path GetRemoteServiceAsync that hosts a vulnerable .NET deserialization route:
HandleMessageInternal dispatches on the attacker-supplied MessageType string with no per-sender authorization;MessageType="GetRemoteServiceAsync" reaches DeserializeJson<ServiceDirectoryItemRequest>(MessageData).// nothing in this method consults the sender's identity
switch (message.MessageType) {
case "GetRemoteServiceAsync": {
var req = DeserializeJson<ServiceDirectoryItemRequest>(message.MessageData);
...
}
...
}The payload is the long-public Json.NET $type gadget shape: a $type naming a type whose construction invokes a method the attacker chooses, with the method and its arguments carried as ordinary JSON properties.
This is where most of our reversing time actually went. One binary WebSocket message is a protobuf WebSocketMessage, schema SolarWinds.AgentManagement.ExchangeProtocol. Here are the fully reversed schema:
{
field 2 Exchange {
field 1 DeviceId (string) = any GUID (not validated)
field 2 Manifest (string) = ewAsyncRpc <rpc> transport envelope (XML)
field 3 Data (bytes) = inner <str_map> property-bag XML (the Message)
}
}Figure 2. The Two Nested Layers
It is small enough to hand-encode with a handful of variant helpers:
Layer 1, the Manifest, is a pure ewAsyncRpc transport wrapper. It is parsed by a boost property_tree via CMapXml::LoadXml, which means case-insensitive element names, children sorted alphabetically, and TAB indentation. <session> carries i_end=1, i_fname=TaskProperties, i_mem=1, i_pos=0 and i_session=<fresh GUID>. <data> carries AllSent=1, compressed=none, taskCreate_tm_=1, taskHandler_tm_=amsh_Agent2AMSHandler and taskID_tm_=<fresh GUID>. The combination i_pos=0 with i_end=1 is what declares this a single self-contained frame rather than a chunk of a larger one.
Layer 2, the Data, is the actual AMS Message property bag—a <str_map> with one XML element per field: creation time, message and endpoint IDs, a timeout, the RecipientId that does the routing, and MessageData carrying the payload.
This chain rests on three internal defects: the certificate-validation short circuit on an empty clientId, the lack of sender authorization before RecipientId-based dispatch, and the unsafe Json.NET deserialization reached through GetRemoteServiceAsync. Closing any of them requires a code change inside the Agent Management Service itself, so none of them is something an operator can apply directly. Patching is the only way to close them. Treat the controls below as defense in depth alongside the patch, not a substitute for it.
Patch promptly: Upgrade SolarWinds Observability Self-Hosted to 2026.2.3 or later to address CVE-2026-28325. Versions 2026.2.2 and below are affected.
Restrict access to TCP 17778: The Agent Management Service listens on TCP 17778 at /msg, and the only legitimate callers are the core’s own enrolled, monitored agents. Apply a default-deny inbound policy on the Orion core and allowlist 17778 to the monitored agent population alone. Segment the core so the port is unreachable from user subnets, the general server estate, and any internet-facing path. This narrows who can reach the port, but it does not close the chain by itself: the certificate bypass needs no enrollment, so any host already inside the allowed population can still reach the vulnerable path without a valid agent certificate.
Contain the Orion core host: SolarWinds.BusinessLayerHostx64.exe runs as LocalSystem, and the core also stores credentials for the whole managed estate, so a successful chain compromises the monitored environment, not just the core box. Apply a default-deny outbound policy on the core and allowlist only the destinations monitoring legitimately requires. Treat the Orion core’s own computer account with the same restraint as a privileged credential, since a LocalSystem process reaches the network as the host’s computer account rather than as a service account of its own. Where feasible, enforce application control: the deserialization gadget in this chain reaches Process.Start with an attacker-chosen target, so a policy that baselines and blocks unexpected child processes under SolarWinds.BusinessLayerHostx64.exe, cmd.exe and powershell.exe among them, narrows the paths for post-exploitation execution. It does not close the underlying primitive, which can invoke any executable the attacker names, not only a shell.
Send Orion core logs to a SIEM and configure alerts on the specific actions this chain requires, including:
:17778/msg with an empty clientId query parameter (clientId=&clientType=WebSocketsExchangeClient) and no client certificate. This needs visibility inside the TLS session, at the Agent Management Service itself or a TLS-terminating proxy. The empty-clientId path is also a legitimate connection test, so tune for false positives.RecipientId=ServiceHostManager and MessageType=GetRemoteServiceAsync. The service does no sender-identity check here, so correlate with the certificate/clientId visibility above to identify an enrolled agent from an attacker.$type, ObjectDataProvider, or PresentationFramework strings inside MessageData. Treat this as a starting signature, not a complete one. A plain string match is easy to evade with an alternate gadget type, Unicode escapes, or XML encoding inside the <str_map> payload.Unexpected message received entries in AgentManagement.Service.log. Confirmed only for probes against RecipientId="AMS": treat it as an early warning signal of dispatcher reconnaissance, not coverage of the exploit itself.On the host, alert on cmd.exe or powershell.exe spawned by SolarWinds.BusinessLayerHostx64.exe, and any other unexpected child process, since the deserialization gadget can invoke any executable the attacker names. Validate the baseline in your own environment before alerting broadly.
We have arrived at the end of this blog post series—ARM over gRPC 55555, WPM’s PerformUpdate on 17781, and this AMS bug on 17778. Three products, three transports, and three bug classes. Looking at them together, the same few habits keep showing up:
ARM and Orion AMS both end in a polymorphic deserializer that will happily instantiate ObjectDataProvider and call Process.Start. Neither needed to accept arbitrary types over the network, and the safe pattern—an allowlist binder—already exists elsewhere in each codebase. It is just not applied on the reachable path. ARM’s denylist is the worse case of the two, because it implies protection while one unlisted gadget walks straight through.ARM’s certificate check is satisfied by a public constant. Orion AMS is guarded by a flag that an empty query parameter sets. WPM’s token check is guarded by an agent mode that defaults to the wrong value. In all three the happy path is authenticated, and was clearly tested. However, the unauthenticated edge—the connection test, the passive mode, and the certless probe—is the one that reaches the sink. If you are auditing something like this, the guard on the check is more interesting than the check.The outcome of SYSTEM and Domain Admin everywhere magnifies all of it. These services run as the most privileged accounts on the box, and for ARM in the domain. There is no low-severity version of any of these bugs.
Thanks for reading this blog post. If you find any mistakes, please reach out to us or find me on LinkedIn. I will correct them, especially on this post, given how much of it is a record of me being wrong in public. Also let us know if you enjoyed reading this series and how we can improve in the future.
Thank you to Brett Hawkins, Craig Wright, Tyler Casey, and the rest of the Armadin team’s support for making this blog post series possible.
AI-driven threats require a new approach and the right platform to keep your environment secure. Discover the value of a safe Hyperattack™ from Armadin. Request a demo.
Learn more about Hyperattacks.