From cluster:read to Cluster-Wide Root RCE: Chaining Two Wazuh Coverage-Gap Bugs
July 11, 2026 (1mo ago)
by Sujal Tuladhar

From cluster:read to Cluster-Wide Root RCE: Chaining Two Wazuh Coverage-Gap Bugs

Two Wazuh cluster bugs, each moderate on its own, share one root cause: a security guard applied to every sibling code path except one. Chained, a read-only API user reads the cluster key, authenticates as a cluster peer, and reaches remote code execution as root on every node. Fixed in 4.14.7.

Two vulnerabilities in Wazuh's cluster stack chain into remote code execution as root on every node in a cluster, starting from the lowest-privilege API role the product ships. Neither is exotic. One is a missing decorator. The other is a missing four-line check. Each on its own is a moderate finding: one leaks a secret, the other writes a file. Chained, they turn a read-only API call into a root shell on the whole fleet.

They share a single root cause worth naming early: a security control that exists and works, correctly applied to every sibling code path except one. In both bugs the guard was written, understood, and deployed. It was then skipped on the one path that looked slightly different from the others it was copied onto.

The two were reported as CVE-2026-61802 (the key disclosure, Moderate) and CVE-2026-61800 (the file write, High), both fixed in Wazuh 4.14.7.

The shape of the target

Wazuh runs as a cluster: one master node that holds the source of truth, and one or more workers that receive its configuration and do the heavy lifting. Two facts about how the nodes trust each other are the whole story.

  • They share one key. A single 32-character secret is copied onto every node. Any process that holds it can open the cluster protocol on TCP 1516 and talk to a worker as if it were the master. There is no per-node identity beyond that shared key.
  • Workers trust whatever the master sends. Workers periodically sync files from the master. The master says "here is a bundle of files, and here is where each one belongs," and the worker writes them into place. The worker does not second-guess those instructions.

The first bug leaks the key. The second bug is what the key unlocks.

Bug one: the config reader that skips the mask

Wazuh's API has a family of endpoints that return configuration. Three are relevant, and they do the same job, reading some config and handing it back:

  • GET /manager/configuration maps to manager.py get_config
  • the running-config reader maps to manager.py read_ossec_conf
  • GET /cluster/local/config maps to cluster.py read_config_wrapper

Configuration contains secrets: the agent-registration password, and the cluster key itself. Wazuh has a decorator built for exactly this problem, @mask_sensitive_config. When a caller lacks update permissions, it walks the response and redacts the sensitive fields. Its target list is explicit:

SENSITIVE_FIELD_PATHS = ("authd.pass", "cluster.key")

The intent is unambiguous: the cluster key is a field Wazuh has already decided a read-only caller must never receive in cleartext. The mechanism exists, works, and is deployed. On two of the three siblings:

# manager.py: get_config
@expose_resources(actions=["manager:read"], ...)
@mask_sensitive_config()          # <-- masked
def get_config(...): ...
 
# manager.py: read_ossec_conf
@expose_resources(actions=["manager:read"], ...)
@mask_sensitive_config()          # <-- masked
def read_ossec_conf(...): ...

And on the third:

# cluster.py: read_config_wrapper
@expose_resources(actions=["cluster:read"], ...)
#                                 ^ no @mask_sensitive_config
def read_config_wrapper(...):
    return read_cluster_config()   # returns the key in cleartext

That is the entire bug. read_config_wrapper is the one config reader that does not wear the mask. The two managers that obviously look secret-bearing got the decorator. The cluster reader, whose config file happens to contain the single most dangerous secret in the system, did not.

What makes it exploitable rather than theoretical is who can reach it. The endpoint is gated by cluster:read. Wazuh ships two default roles, readonly and cluster_readonly, whose policy grants cluster:read but explicitly not any update_config permission. That is precisely the combination the masker is designed to redact for. So a stock low-privilege user:

  1. passes the cluster:read gate on /cluster/local/config, and
  2. fails the "has update permissions?" test the masker keys off of, meaning the response should be masked,
  3. but isn't, because the decorator was never applied.

The API's own spec example confirms it. The documented 200 response for the endpoint literally shows key: 9d273b53.... A read-only account calls one endpoint and reads the cluster's shared secret out of the response body. On its own the CVSS is AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N, which is 6.5, Moderate. Pure disclosure, no integrity impact, nothing runs. It just hands over the key.

The fix is one line: give the sibling the same decorator its two brothers already had.

@expose_resources(actions=["cluster:read"], ...)
@mask_sensitive_config()          # the missing line
def read_config_wrapper(...): ...

What the key authenticates

A disclosed key is only as serious as what it protects. This one protects everything between nodes. Holding the cluster key means speaking the cluster protocol to any worker as the master, and the master, as established, is trusted to tell workers which files to write and where.

That is exactly the trust the second bug fails to enforce.

Bug two: the confinement check on three branches of four

Earlier in 2026, Wazuh fixed CVE-2026-30893. The flaw: a malicious master could put ../ sequences in a synced filename and escape /var/ossec entirely, writing files anywhere on a worker's disk. The fix added a confinement check. For each file, confirm its final destination actually sits inside the folder its label (cluster_item_key) claims. Call it the label-versus-shelf check: a file labeled "shared settings" must land on the shared-settings shelf, not wherever the filename points.

The code that moves synced files into place has four sibling branches: the master receiving merged files, the master receiving non-merged files, the worker receiving merged files, and the worker receiving non-merged files. The 4.14.4 fix added the check to three of them. One grep shows the asymmetry cleanly:

$ grep -rn "expected_base = safe_join" framework/wazuh/core/cluster/
master.py:901:   expected_base = safe_join(common.WAZUH_PATH, item_key)
master.py:940:   expected_base = safe_join(common.WAZUH_PATH, item_key)
worker.py:805:   expected_base = safe_join(common.WAZUH_PATH, item_key)   # merged branch
   # worker.py non-merged branch: nothing. that's the hole.

Three hits. The worker's non-merged branch, overwrite_or_create_files in worker.py, the else that handles a plain, unmerged file, has none. Its only surviving guard is safe_join, which keeps the file somewhere under /var/ossec but never checks it against the label:

if data_["merged"]:
    item_key = data_["cluster_item_key"]
    expected_base = safe_join(common.WAZUH_PATH, item_key)
    if not os.path.commonpath([dest, expected_base]).startswith(expected_base):
        raise ...                              # merged branch: label must match shelf ✓
else:
    dest = safe_join(common.WAZUH_PATH, filename)   # keeps it under /var/ossec...
    safe_move(safe_join(zip_path, filename_), dest)  # ...but writes it wherever the label pointed ✗

Both the filename (where it lands) and the label (cluster_item_key) come from the master. The merged branch forces them to agree. The non-merged branch never compares them. So a master, or via bug one anyone holding the cluster key, sends a file description like this:

{
  "missing": {
    "etc/ossec.conf": {
      "merged": false,
      "cluster_item_key": "etc/shared/"
    }
  }
}

The label says etc/shared/. The file lands in etc/, outside the shelf the label named. The worker writes it and asks no questions. The delete path has the same missing check, so the same primitive removes client.keys or certificates to knock nodes offline. On its own this bug is AV:N/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H, which is 8.0, High. CWE-22, an incomplete fix of the original path traversal.

This is where it gets less clean than it sounds. A freshly written file is created without the executable bit, so a brand-new script can't be dropped and run directly. The primitive is overwrite, not create. Overwriting a file that root already runs through an interpreter is what yields execution as uid=0: a shell script invoked as sh script, a command block in etc/ossec.conf, or a Python integration module. Wazuh runs plenty of files under /var/ossec as root. Only one of them needs to become content the caller controls.

The fix mirrors the other three branches exactly: the same label-versus-shelf check, plus the same guard on the delete path.

else:
    item_key = data_["cluster_item_key"]
    expected_base = safe_join(common.WAZUH_PATH, item_key)
    if not os.path.commonpath([dest, expected_base]).startswith(expected_base):
        raise exception.WazuhClusterError(3022, ...)   # the missing check
    # ... existing move logic ...

The chain, end to end

Lined up, the severity stops being additive and starts compounding. Each step supplies the privilege the next one assumes:

  1. cluster:read user to the key. Call GET /cluster/local/config (CVE-2026-61802). The response returns the 32-character cluster key in cleartext, because the endpoint never masks it.
  2. Key to trusted peer. The key is the whole of node-to-node trust, so a caller who holds it can speak the cluster protocol on :1516 as the master. The worker is an outbound client, so this needs the master's network position (or a MITM on the worker-to-master link), which is the AC:H in the score below, not a repeatable inbound push.
  3. Trusted peer to arbitrary write. Sync one non-merged file whose label says etc/shared/ but whose path points at etc/ossec.conf (CVE-2026-61800). The worker writes it wherever it was aimed, into any directory under /var/ossec the wazuh user can write.
  4. Arbitrary write to root RCE. Overwrite a file root already runs through an interpreter, and Wazuh executes those bytes as uid=0 on the worker.

A Moderate disclosure and a High file write are each survivable in isolation. Together they are a straight line from the lowest-privilege API role Wazuh ships to remote code execution as root on every worker in the fleet, because the read-only user obtains the exact secret the file-write bug assumes an attacker already holds. The disclosure bug is the how you get the key that the cluster-peer RCE advisories all presuppose.

Both vulnerabilities were reproduced end to end in a disposable two-node cluster, not just from reading the code. Bug one is visible in the decorator stack and the default-role policy: three functions side by side, one missing an annotation. Bug two is a grep that returns three hits where there should be four; the sync was then driven in the lab and the file landed as uid=0. The source shows the gap, the lab confirms the write actually happens.

One root cause, two symptoms

Bug one is a copy-paste omission: a decorator applied to two of three sibling readers. Bug two is an incomplete fix: a check applied to three of four sibling write paths. Different mechanisms, different files, different CWEs, but the same shape underneath. In each case a security control the authors clearly understood and correctly built was replicated across the sibling paths that looked like they needed it, and skipped on the one sibling that looked a little different. read_config_wrapper reads a cluster config, not a manager config, so it didn't group with "the secret-bearing readers." The non-merged worker branch handles a plain file, not a merged one, so it didn't group with "the branch that needs the traversal check." In both, the odd-one-out is exactly where the guard went missing.

This is a general pattern, not a Wazuh-specific one. Controls in real systems are rarely in a single place. The same guard has to be replicated across several near-identical paths, and replication by analogy tends to miss the case that doesn't quite match the mental template. The practical takeaway is mechanical and it pays off: whenever a guard, decorator, or fix repeats across sibling code paths, enumerate every sibling that does the same job and diff them against each other. The gap is almost never in the path that was hardened. It is in the one that was assumed to be the same and never checked. Two questions would have caught both of these before they shipped: did this check land on every branch that writes a synced file, or only the ones in this diff?, and does every endpoint that can return this secret mask it, or only the ones whose names sound secret?

If you run Wazuh

Upgrade to 4.14.7. It closes both: the missing @mask_sensitive_config() on the cluster config reader, and the missing confinement check on the worker's non-merged sync branch. Until patched, the disclosure half is reachable by any account with the default readonly or cluster_readonly role, so limiting who holds even read-only API access, and rotating the cluster key after upgrading, are both worth doing.

Credit: Sujal Tuladhar (evilgensec / evilgenius01), with @karasu-hakira on the worker file-sync finding. Both disclosed responsibly through the Wazuh security advisory process.