Debian GNU/Linux · Network

Limit SSH connections and close dead sessions

When it fails: The SSH server keeps dead sessions open and accepts many concurrent unauthenticated connections. Unbounded connection and session counts let a single client exhaust the SSH server, and dead sessions stay open for anyone who takes over the network path.

CB-DEB-NET-017lowactiveautomatedautomated fix, lockout riskprofile core

As released in v0.1.1 beta · pre-release. Current state.

Description

These OpenSSH server options limit resources used per client and detect dead connections:

  • ClientAliveInterval (1 to {{ ssh_client_alive_interval }} seconds) and ClientAliveCountMax (1 to {{ ssh_client_alive_count_max }}): the server checks that the client still answers, and closes the session after ClientAliveCountMax unanswered checks;
  • MaxStartups at most 10:30:60: beyond 10 unauthenticated connections, new ones are refused with a probability of 30 %, rising to 100 % at 60;
  • MaxSessions at most {{ ssh_max_sessions }}: shells, commands and subsystems multiplexed on one connection.

OpenSSH's own default for ClientAliveInterval is 0 (no check); the Debian cloud images set 120. Idle shell sessions are closed by TMOUT (CB-DEB-AUTH-020), not by these options, since a live client always answers keepalive checks. The fix only changes the options that are out of bounds, in /etc/ssh/sshd_config.d/00-cirsec.conf.

Rationale

Sessions whose client has gone away (laptop closed, network cut) keep a shell open on the server; closing them limits what a later session hijack can reach. Connection limits keep a brute force tool or a flood of half-open connections from making the server unreachable for administrators.

Impact

Sessions of clients that stop answering for more than {{ ssh_client_alive_interval }} × {{ ssh_client_alive_count_max }} seconds are closed. Automation that opens many parallel connections or multiplexed sessions (Ansible pipelining, ControlMaster) may hit the limits; raise them through tailoring if needed.

Applies when

Detected: when: OpenSSH server is installed A scanner reports the control not applicable when one is false.

Setting

Location/etc/ssh/sshd_config.d/00-cirsec.conf
DefaultClientAliveInterval 0 (120 on cloud images), ClientAliveCountMax 3, MaxStartups 10:30:100, MaxSessions 10
RecommendedClientAliveInterval 1-{{ ssh_client_alive_interval }}, ClientAliveCountMax 1-{{ ssh_client_alive_count_max }}, MaxStartups <= 10:30:60, MaxSessions <= {{ ssh_max_sessions }}

Parameters

ParameterCoreServerServer hardenedDesktopDesktop hardenedBounds
ssh_client_alive_intervalinteger, seconds. Maximum interval between two keepalive checks of the client.3003003003003001 to 900
ssh_client_alive_count_maxinteger. Maximum number of unanswered keepalive checks before the session is closed.333331 to 10
ssh_max_sessionsinteger. Maximum sessions per SSH connection.10101010101 to 10

The value used is your override (tailoring file), else the profile's, else the one of the profile it extends, else the default. Bounds limit what an override may set.

Audit

Run sshd -T | grep -E '^(clientaliveinterval|clientalivecountmax|maxstartups|maxsessions) '. clientaliveinterval must be between 1 and {{ ssh_client_alive_interval }}, clientalivecountmax between 1 and {{ ssh_client_alive_count_max }}, each field of maxstartups at most 10:30:60, and maxsessions at most {{ ssh_max_sessions }}.

Automated: every check must pass. Scanners report each check by its id.

client-alive-interval-set

ClientAliveInterval is not 0 (keepalive off).

key: clientaliveinterval
operator: '>='
value: 1

client-alive-interval

ClientAliveInterval upper bound.

key: clientaliveinterval
operator: <=
value: '{{ ssh_client_alive_interval }}'

client-alive-count-set

ClientAliveCountMax is not 0.

key: clientalivecountmax
operator: '>='
value: 1

client-alive-count

ClientAliveCountMax upper bound.

key: clientalivecountmax
operator: <=
value: '{{ ssh_client_alive_count_max }}'

max-startups

MaxStartups at most 10:30:60.

key: maxstartups
operator: match
value: ^([1-9]|10):([1-9]|[12][0-9]|30):([1-9]|[1-5][0-9]|60)$

max-sessions

MaxSessions upper bound.

key: maxsessions
operator: <=
value: '{{ ssh_max_sessions }}'

Remediation

Add to /etc/ssh/sshd_config.d/00-cirsec.conf:

ClientAliveInterval {{ ssh_client_alive_interval }}
ClientAliveCountMax {{ ssh_client_alive_count_max }}
MaxStartups 10:30:60
MaxSessions {{ ssh_max_sessions }}

Check with sshd -t, then run systemctl reload ssh.

Restartservice: ssh
Disruptionlow
Lockout riskyes: can cut off administrative access. Off by default in generated scripts and playbooks, opt in through a tailoring file.

Rollback

Remove the lines from /etc/ssh/sshd_config.d/00-cirsec.conf and run systemctl reload ssh.

limits

# CB-DEB-NET-017: bring SSH keepalive and connection limits within bounds, changing only what is out of bounds.
PATH="$PATH:/usr/sbin"
f=/etc/ssh/sshd_config.d/00-cirsec.conf
grep -Eqi '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config.d/\*\.conf' /etc/ssh/sshd_config || {
  echo '/etc/ssh/sshd_config does not include sshd_config.d/*.conf' >&2
  exit 1
}
cfg=$(sshd -T 2>/dev/null) || { echo 'sshd -T failed' >&2; exit 1; }
get() { printf '%s\n' "$cfg" | awk -v k="$1" '$1 == k { print $2; exit }'; }
# in_range VALUE MAX: VALUE is an integer between 1 and MAX.
in_range() { case $1 in '' | *[!0-9]*) return 1 ;; esac; [ "$1" -ge 1 ] && [ "$1" -le "$2" ]; }
changes=
in_range "$(get clientaliveinterval)" "$CB_SSH_CLIENT_ALIVE_INTERVAL" || changes="$changes clientaliveinterval=$CB_SSH_CLIENT_ALIVE_INTERVAL"
in_range "$(get clientalivecountmax)" "$CB_SSH_CLIENT_ALIVE_COUNT_MAX" || changes="$changes clientalivecountmax=$CB_SSH_CLIENT_ALIVE_COUNT_MAX"
in_range "$(get maxsessions)" "$CB_SSH_MAX_SESSIONS" || changes="$changes maxsessions=$CB_SSH_MAX_SESSIONS"
get maxstartups | grep -Eq '^([1-9]|10):([1-9]|[12][0-9]|30):([1-9]|[1-5][0-9]|60)$' || changes="$changes maxstartups=10:30:60"
[ -n "$changes" ] || exit 0
mkdir -p /etc/ssh/sshd_config.d
cb_backup "$f"
touch "$f"
chmod 600 "$f"
for c in $changes; do
  k=${c%%=*}
  sed -i "/^[[:space:]]*${k}[[:space:]]/Id" "$f"
  printf '%s %s\n' "$k" "${c#*=}" >>"$f"
done
if ! sshd -t; then
  cb_restore "$f"
  echo 'sshd -t rejected the change, reverted' >&2
  exit 1
fi

Handwritten code reads parameters from CB_<NAME> environment variables. Each release turns these fixes into remediate.sh and an Ansible role per profile: dry run by default, a fix runs only when its check fails.

References

Mappings

FrameworkIDRelation
NIST SP 800-53 Rev. 55.1.1SC-10partial
NIST SP 800-53 Rev. 55.1.1SC-5related

How this control relates to the other framework's: equivalent, stricter, looser, partial or related.

Threats mitigated

People and lifecycle

Authors
Verified2026-10-18 by Sn0wAlice on 12, Proxmox VM 921, Debian 12 genericcloud image, kernel 6.1.0-54-cloud-amd64. fix.sh sets MaxStartups 10:30:60 on stock config, and interval/count when set to 0; SSH by key still accepted.
Verified2026-10-18 by Sn0wAlice on 13, Proxmox VM 931, Debian 13 genericcloud image, kernel 6.12.113+deb13-cloud-amd64. fix.sh sets MaxStartups 10:30:60 on stock config, and interval/count when set to 0; SSH by key still accepted.
Introduced inv0.1.0
Created, updated2026-10-18, 2026-10-18
Translationsfr