SolarWinds Access Rights Manager Unauthenticated RCE — Vulnerability Detection Script
An unauthenticated patch-state check for CVE-2026-28326, the unauthenticated remote code
execution vulnerability in SolarWinds Access Rights Manager (ARM), fixed in
ARM 2026.2.1
on 2026-09-17. CWE-798, CVSS 8.8 High
(CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). Reported to SolarWinds by Kai Huang of Armadin.
The AV:A in that vector assumes TCP 55555 is reachable only from an adjacent network, which is the
posture ARM's own firewall tooling is built for; where the port has been opened more broadly the
score understates the exposure for that environment.
ARM's gRPC remoting listener on TCP 55555 authenticates peers with a client certificate. Unpatched
builds also accept a fallback credential derived from a hardcoded key that is identical on every
installation, which bypasses that authentication and exposes an unsafe deserialization path. The
result is code execution as the ARM service account, which runs as LocalSystem by default.
The script reports whether the fix is present on each target. It reads the status codes returned by
the authentication interceptor and does not use the key, authenticate, or send data to the
deserialization path. A result other than VULNERABLE does not by itself indicate that a target is
patched; see Interpreting non-vulnerable results.
Usage
# single target (port defaults to 55555)
./cve_2026_28326_check.py arm.example.com
# explicit port
./cve_2026_28326_check.py arm.example.com:55555
# several targets
./cve_2026_28326_check.py arm-a.example.com arm-b.example.com
# scan a list, one target per line ('#' comments allowed), compact output
./cve_2026_28326_check.py -f targets.txt --brief
# machine-readable output for pipelines
./cve_2026_28326_check.py -f targets.txt --json > results.json
Python 3.8+, standard library only — no third-party packages.
Run the tool against the ARM server rather than a collector. Both roles listen on 55555, but a
collector refuses external requests the same way before and after the fix, so the tool reports it as
INCONCLUSIVE.
Options
| Flag | Description |
|---|---|
TARGET |
One or more HOST[:PORT] targets; the port defaults to 55555 |
-f, --targets-file FILE |
Read targets from a file (one per line; # comments) |
-p, --port PORT |
Default port when a target omits one (default: 55555) |
--timeout SECS |
Per-probe timeout (default: 12) |
--workers N |
Concurrent targets (default: 16); output stays in input order |
-b, --brief |
Single aligned line per target, for scanning many hosts |
--json |
Emit structured JSON, including both probes per target |
--no-color |
Disable coloured output (also honours NO_COLOR and non-TTY) |
Examples
Vulnerable server (default output; the [!] marker and VULNERABLE are shown in red on a TTY):
$ ./cve_2026_28326_check.py arm.example.com
[!] arm.example.com:55555: VULNERABLE [unavailable-on-server-role]
Remoting/Send returned 14 UNAVAILABLE on a listener answering as the server role, the pre-patch rejection path (<= 2026.2.0.42)
Patched server. The fixed build returns a grpc-message, which the tool prints alongside the
verdict:
$ ./cve_2026_28326_check.py arm-dr.example.com
[+] arm-dr.example.com:55555: PATCHED [unauthenticated-gate]
Remoting/Send returned 16 UNAUTHENTICATED, the localhost and per-process-token gate introduced by the fix (>= 2026.2.1.7)
grpc-message: Client certificate not found. Please register your application first.
Listener that cannot be classified. A collector, or a server that refuses external requests, answers both probes the same way on either build:
$ ./cve_2026_28326_check.py collector.example.com
[?] collector.example.com:55555: INCONCLUSIVE [external-requests-refused]
the listener refuses external requests, which a collector role and a restricted server both do identically on either build; scan the ARM server itself
Multiple targets with --brief:
$ ./cve_2026_28326_check.py -f targets.txt --brief; echo "exit: $?"
VULNERABLE arm.example.com:55555 unavailable-on-server-role
PATCHED arm-dr.example.com:55555 unauthenticated-gate
INCONCLUSIVE collector.example.com:55555 external-requests-refused
UNAFFECTED db.example.com:55555 not-arm
ERROR offline.example.com:55555 unreachable
exit: 1
JSON output with --json. Both probe results are included so the verdict can be checked against
the raw responses:
$ ./cve_2026_28326_check.py arm.example.com --json
[
{
"target": "arm.example.com:55555",
"verdict": "VULNERABLE",
"reason": "unavailable-on-server-role",
"detail": "Remoting/Send returned 14 UNAVAILABLE on a listener answering as the server role, the pre-patch rejection path (<= 2026.2.0.42)",
"grpc_message": "",
"probes": [
{
"path": "/SolarWinds_ARM_GRPC_ConnectionAuthrization.ConnectionAuthorization/GetCertificate",
"status": 0,
"message": "",
"alpn": "h2",
"note": ""
},
{
"path": "/SolarWinds_ARM_Remoting.Remoting/Send",
"status": 14,
"message": "",
"alpn": "h2",
"note": ""
}
]
}
]
Is it Safe to Run?
The check is intended for use against production systems:
- It does not use the hardcoded key or present any credential. It observes how the server rejects an anonymous peer, which depends on the installed build.
- It does not send data to the deserialization path. The vulnerability is triggered by a serialized
object in a
Remoting/Sendmessage body. Both probes send a gRPC message with a zero-length body, and the interceptor rejects the call before any message body is dispatched. - It does not change server state. No client certificate, credential, or session is presented. The
GetCertificateprobe sends an empty enrolment code, which the server rejects before it looks up or removes any pending codes, so the probe does not consume an enrolment code for a client that is mid-registration. - It opens two TCP connections per target, one per probe, and closes each after reading the status.
Request and log footprint
| # | Call | Purpose |
|---|---|---|
| 1 | ConnectionAuthorization/GetCertificate |
Confirms the endpoint is ARM and is in the server role |
| 2 | Remoting/Send |
The patch-state probe |
On an unpatched server a rejected anonymous request logs at debug level
(Unable to validate remote request token) and an informational line naming the peer. Patched
builds log a rejection naming the peer and the registration hint quoted in the grpc-message above.
Neither build records a session.
To look for exploitation rather than exposure, review ARM's NetworkConnectionEntry records. An
entry with IsAuthenticated=false and a populated ClientCertThumbPrint is consistent with the
authentication bypass and does not occur in normal traffic.
How it Works
The fix changed which status the authentication interceptor returns to a peer presenting no client
certificate. Pre-patch, every rejection path in the client-certificate check set Unavailable.
Post-patch, the localhost-plus-token check sets Unauthenticated with a distinctive message, and
nothing else on that path returns 16. Two anonymous calls distinguish the builds:
Remoting/Send |
GetCertificate |
Build | Verdict |
|---|---|---|---|
14 UNAVAILABLE |
0 OK |
<= 2026.2.0.42 |
VULNERABLE |
16 UNAUTHENTICATED |
any | >= 2026.2.1.7 |
PATCHED |
14 UNAVAILABLE |
14 UNAVAILABLE |
either | INCONCLUSIVE |
The verdict is read from Remoting/Send because only the duplex-streaming and unary handlers
propagate the interceptor's status; the server- and client-streaming handlers overwrite it on both
builds.
It reports patch state, not a version
The check observes the behaviour of the patched code path and does not rely on a version string. It does not report which build a patched server is running.
ALPN is required
The ARM listener resets TLS connections that do not negotiate ALPN h2, so the tool sets it
explicitly. A scanner that connects over TLS without ALPN receives no data from the port.
Interpreting non-vulnerable results
VULNERABLE and PATCHED are each based on an observed server response. INCONCLUSIVE and
ERROR mean the probes did not classify the target, so its patch state is unknown. For this reason
INCONCLUSIVE is reported separately from PATCHED.
Verdicts
Every verdict carries a short reason tag. --brief prints it as the third column and --json
carries it as reason.
| Verdict | Reason tag | Meaning |
|---|---|---|
VULNERABLE |
unavailable-on-server-role |
Remoting/Send returned 14 on a listener answering GetCertificate as the server role. The fix is not present. |
PATCHED |
unauthenticated-gate |
Remoting/Send returned 16, the status introduced by the fix. Applies to this CVE only. |
INCONCLUSIVE |
external-requests-refused |
Both probes returned 14. A collector role and a restricted server behave identically here on both builds. Scan the ARM server itself. |
INCONCLUSIVE |
partial-response |
Only one probe returned a status, so the pair cannot be compared. Retry. |
INCONCLUSIVE |
unrecognized-status |
The endpoint answered with a status combination outside the recognized set. |
UNAFFECTED |
not-arm |
No ARM gRPC services answered here. |
ERROR |
unreachable |
The TCP connection, TLS handshake, or HTTP/2 exchange failed. |
For all three INCONCLUSIVE reasons the patch state is unknown. The reason tag indicates what to
address before running the check again.
Exit codes
| Code | Meaning |
|---|---|
0 |
No target was VULNERABLE |
1 |
At least one target is VULNERABLE |
2 |
Usage error (bad arguments, invalid target, or unreadable targets file) |
130 |
Interrupted (Ctrl-C) |
Exit code 0 covers PATCHED, UNAFFECTED, and all three INCONCLUSIVE reasons. To distinguish
a patched target from one that could not be classified, read the verdict (the first column of
--brief or the verdict field of --json) rather than the exit code.
Limitations
- Collector role: a collector answers both probes with 14 on either build and is reported as
INCONCLUSIVE. This case was tested against a mock that reproduces the response, not against a deployed collector. - Header parsing: the tool scans for
grpc-statusas a literal HPACK field and does not implement Huffman or dynamic-table decoding. gRPC's C core, which ARM uses, sends these trailers uncompressed. If a future build Huffman-codes them, the tool reportsINCONCLUSIVEwithpartial-response. - Scope:
PATCHEDapplies to this CVE only and does not indicate the status of other ARM vulnerabilities. - Exploitation and compromise:
VULNERABLEindicates that the fix is not present on the path the probe reached. The tool does not execute code on the target and does not detect prior compromise; see theNetworkConnectionEntryindicator above. - Reachability: results reflect what the server exposes to the network location the tool runs from. On a default install TCP 55555 listens on all interfaces, including IPv6.
Remediation
Upgrade to ARM 2026.2.1.7 or later, per the ARM 2026.2.1 release notes. Builds 2026.2.0.42 and earlier are affected.
Additional notes:
- Restrict TCP 55555 to ARM's own collectors and clients. This limits exposure on systems that cannot be patched immediately.
- The fix removes the fallback authentication path; it does not rotate the key. Because the key was the same on all installations, treat any period before patching as one in which the listener's authentication could be bypassed by anyone able to reach it.
- A version inventory shows what should be installed; this check shows whether the fix is in effect on the listener.
License
This code is distributed under an MIT license.
Legal Disclaimer
Usage of this tool for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state, and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program.