
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.
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:
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.
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.
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.
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.
Every verdict carries a short reason tag. --brief prints it as the third column and --json
carries it as reason.
For all three INCONCLUSIVE reasons the patch state is unknown. The reason tag indicates what to
address before running the check again.
| Code | Meaning |
|---|
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.
INCONCLUSIVE. This case was tested against a mock that reproduces the response, not against a
deployed collector.grpc-status as 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 reports INCONCLUSIVE with
partial-response.PATCHED applies to this CVE only and does not indicate the status of other ARM
vulnerabilities.VULNERABLE indicates 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 the NetworkConnectionEntry indicator above.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:
This code is distributed under an MIT license.
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.
| 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) |
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 |
| 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. |
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) |