
Non-destructive patch-state checker for SolarWinds ARM CVE-2026-28326 that probes the gRPC listener on TCP 55555 to report VULNERABLE, PATCHED, or INCONCLUSIVE.
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.
# 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.
| 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) |
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": ""
}
]
}
]
The check is intended for use against production systems:
Remoting/Send message body. Both probes send a gRPC message with a zero-length body,
and the interceptor rejects the call before any message body is dispatched.GetCertificate probe 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.| # | 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.
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 |