Add a facility to sshd(8) to penalise particular problematic client
authordjm <djm@openbsd.org>
Thu, 6 Jun 2024 17:15:25 +0000 (17:15 +0000)
committerdjm <djm@openbsd.org>
Thu, 6 Jun 2024 17:15:25 +0000 (17:15 +0000)
behaviours, controlled by two new sshd_config(5) options:
PerSourcePenalties and PerSourcePenaltyExemptList.

When PerSourcePenalties are enabled, sshd(8) will monitor the exit
status of its child pre-auth session processes. Through the exit
status, it can observe situations where the session did not
authenticate as expected. These conditions include when the client
repeatedly attempted authentication unsucessfully (possibly indicating
an attack against one or more accounts, e.g. password guessing), or
when client behaviour caused sshd to crash (possibly indicating
attempts to exploit sshd).

When such a condition is observed, sshd will record a penalty of some
duration (e.g. 30 seconds) against the client's address. If this time
is above a minimum threshold specified by the PerSourcePenalties, then
connections from the client address will be refused (along with any
others in the same PerSourceNetBlockSize CIDR range).

Repeated offenses by the same client address will accrue greater
penalties, up to a configurable maximum. A PerSourcePenaltyExemptList
option allows certain address ranges to be exempt from all penalties.

We hope these options will make it significantly more difficult for
attackers to find accounts with weak/guessable passwords or exploit
bugs in sshd(8) itself.

PerSourcePenalties is off by default, but we expect to enable it
automatically in the near future.

much feedback markus@ and others, ok markus@

usr.bin/ssh/misc.c
usr.bin/ssh/misc.h
usr.bin/ssh/monitor.c
usr.bin/ssh/monitor_wrap.c
usr.bin/ssh/servconf.c
usr.bin/ssh/servconf.h
usr.bin/ssh/srclimit.c
usr.bin/ssh/srclimit.h
usr.bin/ssh/sshd-session.c
usr.bin/ssh/sshd.c
usr.bin/ssh/sshd_config.5

index 38f9875..33327ad 100644 (file)
@@ -1,4 +1,4 @@
-/* $OpenBSD: misc.c,v 1.195 2024/05/17 06:11:17 deraadt Exp $ */
+/* $OpenBSD: misc.c,v 1.196 2024/06/06 17:15:25 djm Exp $ */
 /*
  * Copyright (c) 2000 Markus Friedl.  All rights reserved.
  * Copyright (c) 2005-2020 Damien Miller.  All rights reserved.
@@ -2947,3 +2947,19 @@ lib_contains_symbol(const char *path, const char *s)
        free(nl[0].n_name);
        return ret;
 }
+
+int
+signal_is_crash(int sig)
+{
+       switch (sig) {
+       case SIGSEGV:
+       case SIGBUS:
+       case SIGTRAP:
+       case SIGSYS:
+       case SIGFPE:
+       case SIGILL:
+       case SIGABRT:
+               return 1;
+       }
+       return 0;
+}
index 00b8bc5..7589d28 100644 (file)
@@ -1,4 +1,4 @@
-/* $OpenBSD: misc.h,v 1.108 2024/05/17 00:30:24 djm Exp $ */
+/* $OpenBSD: misc.h,v 1.109 2024/06/06 17:15:25 djm Exp $ */
 
 /*
  * Author: Tatu Ylonen <ylo@cs.hut.fi>
@@ -250,6 +250,7 @@ void        notify_complete(struct notifier_ctx *, const char *, ...)
 
 typedef void (*sshsig_t)(int);
 sshsig_t ssh_signal(int, sshsig_t);
+int signal_is_crash(int);
 
 /* On OpenBSD time_t is int64_t which is long long. */
 #define SSH_TIME_T_MAX LLONG_MAX
index b5bbdf3..0ada9a5 100644 (file)
@@ -1,4 +1,4 @@
-/* $OpenBSD: monitor.c,v 1.239 2024/05/17 06:42:04 jsg Exp $ */
+/* $OpenBSD: monitor.c,v 1.240 2024/06/06 17:15:25 djm Exp $ */
 /*
  * Copyright 2002 Niels Provos <provos@citi.umich.edu>
  * Copyright 2002 Markus Friedl <markus@openbsd.org>
@@ -132,6 +132,7 @@ static char *auth_submethod = NULL;
 static u_int session_id2_len = 0;
 static u_char *session_id2 = NULL;
 static pid_t monitor_child_pid;
+int auth_attempted = 0;
 
 struct mon_table {
        enum monitor_reqtype type;
@@ -248,6 +249,10 @@ monitor_child_preauth(struct ssh *ssh, struct monitor *pmonitor)
                authenticated = (monitor_read(ssh, pmonitor,
                    mon_dispatch, &ent) == 1);
 
+               /* Record that auth was attempted to set exit status later */
+               if ((ent->flags & MON_AUTH) != 0)
+                       auth_attempted = 1;
+
                /* Special handling for multiple required authentications */
                if (options.num_auth_methods != 0) {
                        if (authenticated &&
@@ -290,6 +295,7 @@ monitor_child_preauth(struct ssh *ssh, struct monitor *pmonitor)
                fatal_f("authentication method name unknown");
 
        debug_f("user %s authenticated by privileged process", authctxt->user);
+       auth_attempted = 0;
        ssh->authctxt = NULL;
        ssh_packet_set_log_preamble(ssh, "user %s", authctxt->user);
 
index 6287a8c..ae254bc 100644 (file)
@@ -1,4 +1,4 @@
-/* $OpenBSD: monitor_wrap.c,v 1.130 2024/05/17 00:30:24 djm Exp $ */
+/* $OpenBSD: monitor_wrap.c,v 1.131 2024/06/06 17:15:25 djm Exp $ */
 /*
  * Copyright 2002 Niels Provos <provos@citi.umich.edu>
  * Copyright 2002 Markus Friedl <markus@openbsd.org>
@@ -28,6 +28,7 @@
 #include <sys/types.h>
 #include <sys/uio.h>
 #include <sys/queue.h>
+#include <sys/wait.h>
 
 #include <errno.h>
 #include <pwd.h>
@@ -69,6 +70,7 @@
 #include "session.h"
 #include "servconf.h"
 #include "monitor_wrap.h"
+#include "srclimit.h"
 
 #include "ssherr.h"
 
@@ -133,6 +135,36 @@ mm_request_send(int sock, enum monitor_reqtype type, struct sshbuf *m)
                fatal_f("write: %s", strerror(errno));
 }
 
+static void
+mm_reap(void)
+{
+       int status = -1;
+
+       if (!mm_is_monitor())
+               return;
+       while (waitpid(pmonitor->m_pid, &status, 0) == -1) {
+               if (errno == EINTR)
+                       continue;
+               pmonitor->m_pid = -1;
+               fatal_f("waitpid: %s", strerror(errno));
+       }
+       if (WIFEXITED(status)) {
+               if (WEXITSTATUS(status) != 0) {
+                       debug_f("preauth child exited with status %d",
+                           WEXITSTATUS(status));
+                       cleanup_exit(255);
+               }
+       } else if (WIFSIGNALED(status)) {
+               error_f("preauth child terminated by signal %d",
+                   WTERMSIG(status));
+               cleanup_exit(signal_is_crash(WTERMSIG(status)) ?
+                   EXIT_CHILD_CRASH : 255);
+       } else {
+               error_f("preauth child terminated abnormally");
+               cleanup_exit(EXIT_CHILD_CRASH);
+       }
+}
+
 void
 mm_request_receive(int sock, struct sshbuf *m)
 {
@@ -145,6 +177,7 @@ mm_request_receive(int sock, struct sshbuf *m)
        if (atomicio(read, sock, buf, sizeof(buf)) != sizeof(buf)) {
                if (errno == EPIPE) {
                        debug3_f("monitor fd closed");
+                       mm_reap();
                        cleanup_exit(255);
                }
                fatal_f("read: %s", strerror(errno));
index 02ec305..51855b8 100644 (file)
@@ -1,4 +1,4 @@
-/* $OpenBSD: servconf.c,v 1.407 2024/05/17 01:17:40 djm Exp $ */
+/* $OpenBSD: servconf.c,v 1.408 2024/06/06 17:15:25 djm Exp $ */
 /*
  * Copyright (c) 1995 Tatu Ylonen <ylo@cs.hut.fi>, Espoo, Finland
  *                    All rights reserved
@@ -145,6 +145,16 @@ initialize_server_options(ServerOptions *options)
        options->per_source_max_startups = -1;
        options->per_source_masklen_ipv4 = -1;
        options->per_source_masklen_ipv6 = -1;
+       options->per_source_penalty_exempt = NULL;
+       options->per_source_penalty.enabled = -1;
+       options->per_source_penalty.max_sources = -1;
+       options->per_source_penalty.overflow_mode = -1;
+       options->per_source_penalty.penalty_crash = -1;
+       options->per_source_penalty.penalty_authfail = -1;
+       options->per_source_penalty.penalty_noauth = -1;
+       options->per_source_penalty.penalty_grace = -1;
+       options->per_source_penalty.penalty_max = -1;
+       options->per_source_penalty.penalty_min = -1;
        options->max_authtries = -1;
        options->max_sessions = -1;
        options->banner = NULL;
@@ -377,6 +387,24 @@ fill_default_server_options(ServerOptions *options)
                options->per_source_masklen_ipv4 = 32;
        if (options->per_source_masklen_ipv6 == -1)
                options->per_source_masklen_ipv6 = 128;
+       if (options->per_source_penalty.enabled == -1)
+               options->per_source_penalty.enabled = 0;
+       if (options->per_source_penalty.max_sources == -1)
+               options->per_source_penalty.max_sources = 65536;
+       if (options->per_source_penalty.overflow_mode == -1)
+               options->per_source_penalty.overflow_mode = PER_SOURCE_PENALTY_OVERFLOW_PERMISSIVE;
+       if (options->per_source_penalty.penalty_crash == -1)
+               options->per_source_penalty.penalty_crash = 90;
+       if (options->per_source_penalty.penalty_grace == -1)
+               options->per_source_penalty.penalty_grace = 20;
+       if (options->per_source_penalty.penalty_authfail == -1)
+               options->per_source_penalty.penalty_authfail = 5;
+       if (options->per_source_penalty.penalty_noauth == -1)
+               options->per_source_penalty.penalty_noauth = 1;
+       if (options->per_source_penalty.penalty_min == -1)
+               options->per_source_penalty.penalty_min = 15;
+       if (options->per_source_penalty.penalty_max == -1)
+               options->per_source_penalty.penalty_max = 600;
        if (options->max_authtries == -1)
                options->max_authtries = DEFAULT_AUTH_FAIL_MAX;
        if (options->max_sessions == -1)
@@ -454,6 +482,7 @@ fill_default_server_options(ServerOptions *options)
        CLEAR_ON_NONE(options->chroot_directory);
        CLEAR_ON_NONE(options->routing_domain);
        CLEAR_ON_NONE(options->host_key_agent);
+       CLEAR_ON_NONE(options->per_source_penalty_exempt);
 
        for (i = 0; i < options->num_host_key_files; i++)
                CLEAR_ON_NONE(options->host_key_files[i]);
@@ -485,6 +514,7 @@ typedef enum {
        sBanner, sUseDNS, sHostbasedAuthentication,
        sHostbasedUsesNameFromPacketOnly, sHostbasedAcceptedAlgorithms,
        sHostKeyAlgorithms, sPerSourceMaxStartups, sPerSourceNetBlockSize,
+       sPerSourcePenalties, sPerSourcePenaltyExemptList,
        sClientAliveInterval, sClientAliveCountMax, sAuthorizedKeysFile,
        sGssAuthentication, sGssCleanupCreds, sGssStrictAcceptor,
        sAcceptEnv, sSetEnv, sPermitTunnel,
@@ -601,6 +631,8 @@ static struct {
        { "maxstartups", sMaxStartups, SSHCFG_GLOBAL },
        { "persourcemaxstartups", sPerSourceMaxStartups, SSHCFG_GLOBAL },
        { "persourcenetblocksize", sPerSourceNetBlockSize, SSHCFG_GLOBAL },
+       { "persourcepenalties", sPerSourcePenalties, SSHCFG_GLOBAL },
+       { "persourcepenaltyexemptlist", sPerSourcePenaltyExemptList, SSHCFG_GLOBAL },
        { "maxauthtries", sMaxAuthTries, SSHCFG_ALL },
        { "maxsessions", sMaxSessions, SSHCFG_ALL },
        { "banner", sBanner, SSHCFG_ALL },
@@ -1888,6 +1920,89 @@ process_server_config_line_depth(ServerOptions *options, char *line,
                        options->per_source_max_startups = value;
                break;
 
+       case sPerSourcePenaltyExemptList:
+               charptr = &options->per_source_penalty_exempt;
+               arg = argv_next(&ac, &av);
+               if (!arg || *arg == '\0')
+                       fatal("%s line %d: missing file name.",
+                           filename, linenum);
+               if (addr_match_list(NULL, arg) != 0) {
+                       fatal("%s line %d: keyword %s "
+                           "invalid address argument.",
+                           filename, linenum, keyword);
+               }
+               if (*activep && *charptr == NULL)
+                       *charptr = xstrdup(arg);
+               break;
+
+       case sPerSourcePenalties:
+               while ((arg = argv_next(&ac, &av)) != NULL) {
+                       found = 1;
+                       value = -1;
+                       value2 = 0;
+                       p = NULL;
+                       /* Allow no/yes only in first position */
+                       if (strcasecmp(arg, "no") == 0 ||
+                           (value2 = (strcasecmp(arg, "yes") == 0))) {
+                               if (ac > 0) {
+                                       fatal("%s line %d: keyword %s \"%s\" "
+                                           "argument must appear alone.",
+                                           filename, linenum, keyword, arg);
+                               }
+                               if (*activep &&
+                                   options->per_source_penalty.enabled == -1)
+                                       options->per_source_penalty.enabled = value2;
+                               continue;
+                       } else if (strncmp(arg, "crash:", 6) == 0) {
+                               p = arg + 6;
+                               intptr = &options->per_source_penalty.penalty_crash;
+                       } else if (strncmp(arg, "authfail:", 9) == 0) {
+                               p = arg + 9;
+                               intptr = &options->per_source_penalty.penalty_authfail;
+                       } else if (strncmp(arg, "noauth:", 7) == 0) {
+                               p = arg + 7;
+                               intptr = &options->per_source_penalty.penalty_noauth;
+                       } else if (strncmp(arg, "grace-exceeded:", 15) == 0) {
+                               p = arg + 15;
+                               intptr = &options->per_source_penalty.penalty_grace;
+                       } else if (strncmp(arg, "max:", 4) == 0) {
+                               p = arg + 4;
+                               intptr = &options->per_source_penalty.penalty_max;
+                       } else if (strncmp(arg, "min:", 4) == 0) {
+                               p = arg + 4;
+                               intptr = &options->per_source_penalty.penalty_min;
+                       } else if (strncmp(arg, "max-sources:", 12) == 0) {
+                               intptr = &options->per_source_penalty.max_sources;
+                               if ((errstr = atoi_err(arg+12, &value)) != NULL)
+                                       fatal("%s line %d: %s value %s.",
+                                           filename, linenum, keyword, errstr);
+                       } else if (strcmp(arg, "overflow:deny-all") == 0) {
+                               intptr = &options->per_source_penalty.overflow_mode;
+                               value = PER_SOURCE_PENALTY_OVERFLOW_DENY_ALL;
+                       } else if (strcmp(arg, "overflow:permissive") == 0) {
+                               intptr = &options->per_source_penalty.overflow_mode;
+                               value = PER_SOURCE_PENALTY_OVERFLOW_PERMISSIVE;
+                       } else {
+                               fatal("%s line %d: unsupported %s keyword %s",
+                                   filename, linenum, keyword, arg);
+                       }
+                       /* If no value was parsed above, assume it's a time */
+                       if (value == -1 && (value = convtime(p)) == -1) {
+                               fatal("%s line %d: invalid %s time value.",
+                                   filename, linenum, keyword);
+                       }
+                       if (*activep && *intptr == -1) {
+                               *intptr = value;
+                               /* any option implicitly enables penalties */
+                               options->per_source_penalty.enabled = 1;
+                       }
+               }
+               if (!found) {
+                       fatal("%s line %d: no %s specified",
+                           filename, linenum, keyword);
+               }
+               break;
+
        case sMaxAuthTries:
                intptr = &options->max_authtries;
                goto parse_int;
@@ -3012,6 +3127,7 @@ dump_config(ServerOptions *o)
        dump_cfg_string(sPubkeyAcceptedAlgorithms, o->pubkey_accepted_algos);
        dump_cfg_string(sRDomain, o->routing_domain);
        dump_cfg_string(sSshdSessionPath, o->sshd_session_path);
+       dump_cfg_string(sPerSourcePenaltyExemptList, o->per_source_penalty_exempt);
 
        /* string arguments requiring a lookup */
        dump_cfg_string(sLogLevel, log_level_name(o->log_level));
@@ -3099,4 +3215,20 @@ dump_config(ServerOptions *o)
        if (o->pubkey_auth_options & PUBKEYAUTH_VERIFY_REQUIRED)
                printf(" verify-required");
        printf("\n");
+
+       if (o->per_source_penalty.enabled) {
+               printf("persourcepenalties crash:%d authfail:%d noauth:%d "
+                   "grace-exceeded:%d max:%d min:%d max-sources:%d "
+                   "overflow:%s\n", o->per_source_penalty.penalty_crash,
+                   o->per_source_penalty.penalty_authfail,
+                   o->per_source_penalty.penalty_noauth,
+                   o->per_source_penalty.penalty_grace,
+                   o->per_source_penalty.penalty_max,
+                   o->per_source_penalty.penalty_min,
+                   o->per_source_penalty.max_sources,
+                   o->per_source_penalty.overflow_mode ==
+                   PER_SOURCE_PENALTY_OVERFLOW_DENY_ALL ?
+                   "deny-all" : "permissive");
+       } else
+               printf("persourcepenalties no\n");
 }
index 8ebdca5..4a4ac1c 100644 (file)
@@ -1,4 +1,4 @@
-/* $OpenBSD: servconf.h,v 1.163 2024/05/23 23:47:16 jsg Exp $ */
+/* $OpenBSD: servconf.h,v 1.164 2024/06/06 17:15:25 djm Exp $ */
 
 /*
  * Author: Tatu Ylonen <ylo@cs.hut.fi>
@@ -65,6 +65,20 @@ struct listenaddr {
        struct addrinfo *addrs;
 };
 
+#define PER_SOURCE_PENALTY_OVERFLOW_DENY_ALL   1
+#define PER_SOURCE_PENALTY_OVERFLOW_PERMISSIVE 2
+struct per_source_penalty {
+       int     enabled;
+       int     max_sources;
+       int     overflow_mode;
+       int     penalty_crash;
+       int     penalty_grace;
+       int     penalty_authfail;
+       int     penalty_noauth;
+       int     penalty_max;
+       int     penalty_min;
+};
+
 typedef struct {
        u_int   num_ports;
        u_int   ports_from_cmdline;
@@ -172,6 +186,8 @@ typedef struct {
        int     per_source_max_startups;
        int     per_source_masklen_ipv4;
        int     per_source_masklen_ipv6;
+       char    *per_source_penalty_exempt;
+       struct per_source_penalty per_source_penalty;
        int     max_authtries;
        int     max_sessions;
        char   *banner;                 /* SSH-2 banner message */
index 853a0ee..2a8dffd 100644 (file)
@@ -1,5 +1,6 @@
 /*
  * Copyright (c) 2020 Darren Tucker <dtucker@openbsd.org>
+ * Copyright (c) 2024 Damien Miller <djm@mindrot.org>
  *
  * Permission to use, copy, modify, and distribute this software for any
  * purpose with or without fee is hereby granted, provided that the above
 
 #include <sys/socket.h>
 #include <sys/types.h>
+#include <sys/tree.h>
 
 #include <limits.h>
 #include <netdb.h>
 #include <stdio.h>
 #include <string.h>
+#include <stdlib.h>
 
 #include "addr.h"
 #include "canohost.h"
 #include "misc.h"
 #include "srclimit.h"
 #include "xmalloc.h"
+#include "servconf.h"
+#include "match.h"
 
 static int max_children, max_persource, ipv4_masklen, ipv6_masklen;
+static struct per_source_penalty penalty_cfg;
+static char *penalty_exempt;
 
 /* Per connection state, used to enforce unauthenticated connection limit. */
 static struct child_info {
@@ -37,8 +44,58 @@ static struct child_info {
        struct xaddr addr;
 } *child;
 
+/*
+ * Penalised addresses, active entries here prohibit connections until expired.
+ * Entries become active when more than penalty_min seconds of penalty are
+ * outstanding.
+ */
+struct penalty {
+       struct xaddr addr;
+       time_t expiry;
+       int active;
+       const char *reason;
+       RB_ENTRY(penalty) by_addr;
+       RB_ENTRY(penalty) by_expiry;
+};
+static int penalty_addr_cmp(struct penalty *a, struct penalty *b);
+static int penalty_expiry_cmp(struct penalty *a, struct penalty *b);
+RB_HEAD(penalties_by_addr, penalty) penalties_by_addr;
+RB_HEAD(penalties_by_expiry, penalty) penalties_by_expiry;
+RB_GENERATE_STATIC(penalties_by_addr, penalty, by_addr, penalty_addr_cmp)
+RB_GENERATE_STATIC(penalties_by_expiry, penalty, by_expiry, penalty_expiry_cmp)
+static size_t npenalties;
+
+static int
+srclimit_mask_addr(const struct xaddr *addr, int bits, struct xaddr *masked)
+{
+       struct xaddr xmask;
+
+       /* Mask address off address to desired size. */
+       if (addr_netmask(addr->af, bits, &xmask) != 0 ||
+           addr_and(masked, addr, &xmask) != 0) {
+               debug3_f("%s: invalid mask %d bits", __func__, bits);
+               return -1;
+       }
+       return 0;
+}
+
+static int
+srclimit_peer_addr(int sock, struct xaddr *addr)
+{
+       struct sockaddr_storage storage;
+       socklen_t addrlen = sizeof(storage);
+       struct sockaddr *sa = (struct sockaddr *)&storage;
+
+       if (getpeername(sock, sa, &addrlen) != 0)
+               return 1;       /* not remote socket? */
+       if (addr_sa_to_xaddr(sa, addrlen, addr) != 0)
+               return 1;       /* unknown address family? */
+       return 0;
+}
+
 void
-srclimit_init(int max, int persource, int ipv4len, int ipv6len)
+srclimit_init(int max, int persource, int ipv4len, int ipv6len,
+    struct per_source_penalty *penalty_conf, const char *penalty_exempt_conf)
 {
        int i;
 
@@ -46,6 +103,9 @@ srclimit_init(int max, int persource, int ipv4len, int ipv6len)
        ipv4_masklen = ipv4len;
        ipv6_masklen = ipv6len;
        max_persource = persource;
+       penalty_cfg = *penalty_conf;
+       penalty_exempt = penalty_exempt_conf == NULL ?
+           NULL : xstrdup(penalty_exempt_conf);
        if (max_persource == INT_MAX)   /* no limit */
                return;
        debug("%s: max connections %d, per source %d, masks %d,%d", __func__,
@@ -55,16 +115,15 @@ srclimit_init(int max, int persource, int ipv4len, int ipv6len)
        child = xcalloc(max_children, sizeof(*child));
        for (i = 0; i < max_children; i++)
                child[i].id = -1;
+       RB_INIT(&penalties_by_addr);
+       RB_INIT(&penalties_by_expiry);
 }
 
 /* returns 1 if connection allowed, 0 if not allowed. */
 int
 srclimit_check_allow(int sock, int id)
 {
-       struct xaddr xa, xb, xmask;
-       struct sockaddr_storage addr;
-       socklen_t addrlen = sizeof(addr);
-       struct sockaddr *sa = (struct sockaddr *)&addr;
+       struct xaddr xa, xb;
        int i, bits, first_unused, count = 0;
        char xas[NI_MAXHOST];
 
@@ -72,18 +131,11 @@ srclimit_check_allow(int sock, int id)
                return 1;
 
        debug("%s: sock %d id %d limit %d", __func__, sock, id, max_persource);
-       if (getpeername(sock, sa, &addrlen) != 0)
-               return 1;       /* not remote socket? */
-       if (addr_sa_to_xaddr(sa, addrlen, &xa) != 0)
-               return 1;       /* unknown address family? */
-
-       /* Mask address off address to desired size. */
+       if (srclimit_peer_addr(sock, &xa) != 0)
+               return 1;
        bits = xa.af == AF_INET ? ipv4_masklen : ipv6_masklen;
-       if (addr_netmask(xa.af, bits, &xmask) != 0 ||
-           addr_and(&xb, &xa, &xmask) != 0) {
-               debug3("%s: invalid mask %d bits", __func__, bits);
+       if (srclimit_mask_addr(&xa, bits, &xb) != 0)
                return 1;
-       }
 
        first_unused = max_children;
        /* Count matching entries and find first unused one. */
@@ -136,3 +188,243 @@ srclimit_done(int id)
                }
        }
 }
+
+static int
+penalty_addr_cmp(struct penalty *a, struct penalty *b)
+{
+       return addr_cmp(&a->addr, &b->addr);
+       /* Addresses must be unique in by_addr, so no need to tiebreak */
+}
+
+static int
+penalty_expiry_cmp(struct penalty *a, struct penalty *b)
+{
+       if (a->expiry != b->expiry)
+               return a->expiry < b->expiry ? -1 : 1;
+       /* Tiebreak on addresses */
+       return addr_cmp(&a->addr, &b->addr);
+}
+
+static void
+expire_penalties(time_t now)
+{
+       struct penalty *penalty, *tmp;
+
+       /* XXX avoid full scan of tree, e.g. min-heap */
+       RB_FOREACH_SAFE(penalty, penalties_by_expiry,
+           &penalties_by_expiry, tmp) {
+               if (penalty->expiry >= now)
+                       break;
+               if (RB_REMOVE(penalties_by_expiry, &penalties_by_expiry,
+                   penalty) != penalty ||
+                   RB_REMOVE(penalties_by_addr, &penalties_by_addr,
+                   penalty) != penalty)
+                       fatal_f("internal error: penalty tables corrupt");
+               free(penalty);
+               if (npenalties-- == 0)
+                       fatal_f("internal error: npenalties underflow");
+       }
+}
+
+static void
+addr_masklen_ntop(struct xaddr *addr, int masklen, char *s, size_t slen)
+{
+       size_t o;
+
+       if (addr_ntop(addr, s, slen) != 0) {
+               strlcpy(s, "UNKNOWN", slen);
+               return;
+       }
+       if ((o = strlen(s)) < slen)
+               snprintf(s + o, slen - o, "/%d", masklen);
+}
+
+int
+srclimit_penalty_check_allow(int sock, const char **reason)
+{
+       struct xaddr addr;
+       struct penalty find, *penalty;
+       time_t now;
+       int bits;
+       char addr_s[NI_MAXHOST];
+
+       if (!penalty_cfg.enabled)
+               return 1;
+       if (srclimit_peer_addr(sock, &addr) != 0)
+               return 1;
+       if (penalty_exempt != NULL) {
+               if (addr_ntop(&addr, addr_s, sizeof(addr_s)) != 0)
+                       return 1; /* shouldn't happen */
+               if (addr_match_list(addr_s, penalty_exempt) == 1) {
+                       return 1;
+               }
+       }
+       if (npenalties > (size_t)penalty_cfg.max_sources &&
+           penalty_cfg.overflow_mode == PER_SOURCE_PENALTY_OVERFLOW_DENY_ALL) {
+               *reason = "too many penalised addresses";
+               return 0;
+       }
+       bits = addr.af == AF_INET ? ipv4_masklen : ipv6_masklen;
+       memset(&find, 0, sizeof(find));
+       if (srclimit_mask_addr(&addr, bits, &find.addr) != 0)
+               return 1;
+       now = monotime();
+       if ((penalty = RB_FIND(penalties_by_addr,
+           &penalties_by_addr, &find)) == NULL)
+               return 1; /* no penalty */
+       if (penalty->expiry < now) {
+               expire_penalties(now);
+               return 1; /* expired penalty */
+       }
+       if (!penalty->active)
+               return 1; /* Penalty hasn't hit activation threshold yet */
+       *reason = penalty->reason;
+       return 0;
+}
+
+static void
+srclimit_remove_expired_penalties(void)
+{
+       struct penalty *p = NULL;
+       int bits;
+       char s[NI_MAXHOST + 4];
+
+       /* Delete the soonest-to-expire penalties. */
+       while (npenalties > (size_t)penalty_cfg.max_sources) {
+               if ((p = RB_MIN(penalties_by_expiry,
+                   &penalties_by_expiry)) == NULL)
+                       break; /* shouldn't happen */
+               bits = p->addr.af == AF_INET ? ipv4_masklen : ipv6_masklen;
+               addr_masklen_ntop(&p->addr, bits, s, sizeof(s));
+               debug3_f("overflow, remove %s", s);
+               if (RB_REMOVE(penalties_by_expiry,
+                   &penalties_by_expiry, p) != p ||
+                   RB_REMOVE(penalties_by_addr, &penalties_by_addr, p) != p)
+                       fatal_f("internal error: penalty tables corrupt");
+               free(p);
+               npenalties--;
+       }
+}
+
+void
+srclimit_penalise(struct xaddr *addr, int penalty_type)
+{
+       struct xaddr masked;
+       struct penalty *penalty, *existing;
+       time_t now;
+       int bits, penalty_secs;
+       char addrnetmask[NI_MAXHOST + 4];
+       const char *reason = NULL;
+
+       if (!penalty_cfg.enabled)
+               return;
+       if (penalty_exempt != NULL) {
+               if (addr_ntop(addr, addrnetmask, sizeof(addrnetmask)) != 0)
+                       return; /* shouldn't happen */
+               if (addr_match_list(addrnetmask, penalty_exempt) == 1) {
+                       debug3_f("address %s is exempt", addrnetmask);
+                       return;
+               }
+       }
+
+       switch (penalty_type) {
+       case SRCLIMIT_PENALTY_NONE:
+               return;
+       case SRCLIMIT_PENALTY_CRASH:
+               penalty_secs = penalty_cfg.penalty_crash;
+               reason = "penalty: caused crash";
+               break;
+       case SRCLIMIT_PENALTY_AUTHFAIL:
+               penalty_secs = penalty_cfg.penalty_authfail;
+               reason = "penalty: failed authentication";
+               break;
+       case SRCLIMIT_PENALTY_NOAUTH:
+               penalty_secs = penalty_cfg.penalty_noauth;
+               reason = "penalty: connections without attempting authentication";
+               break;
+       case SRCLIMIT_PENALTY_GRACE_EXCEEDED:
+               penalty_secs = penalty_cfg.penalty_crash;
+               reason = "penalty: exceeded LoginGraceTime";
+               break;
+       default:
+               fatal_f("internal error: unknown penalty %d", penalty_type);
+       }
+       bits = addr->af == AF_INET ? ipv4_masklen : ipv6_masklen;
+       if (srclimit_mask_addr(addr, bits, &masked) != 0)
+               return;
+       addr_masklen_ntop(addr, bits, addrnetmask, sizeof(addrnetmask));
+
+       now = monotime();
+       expire_penalties(now);
+       if (npenalties > (size_t)penalty_cfg.max_sources &&
+           penalty_cfg.overflow_mode == PER_SOURCE_PENALTY_OVERFLOW_DENY_ALL) {
+               verbose_f("penalty table full, cannot penalise %s for %s",
+                   addrnetmask, reason);
+               return;
+       }
+
+       penalty = xcalloc(1, sizeof(*penalty));
+       penalty->addr = masked;
+       penalty->expiry = now + penalty_secs;
+       penalty->reason = reason;
+       if ((existing = RB_INSERT(penalties_by_addr, &penalties_by_addr,
+           penalty)) == NULL) {
+               /* penalty didn't previously exist */
+               if (penalty_secs > penalty_cfg.penalty_min)
+                       penalty->active = 1;
+               if (RB_INSERT(penalties_by_expiry, &penalties_by_expiry,
+                   penalty) != NULL)
+                       fatal_f("internal error: penalty tables corrupt");
+               verbose_f("%s: new %s penalty of %d seconds for %s",
+                   addrnetmask, penalty->active ? "active" : "deferred",
+                   penalty_secs, reason);
+               if (++npenalties > (size_t)penalty_cfg.max_sources)
+                       srclimit_remove_expired_penalties(); /* permissive */
+               return;
+       }
+       debug_f("%s penalty for %s already exists, %lld seconds remaining",
+           existing->active ? "active" : "inactive",
+           addrnetmask, (long long)(existing->expiry - now));
+       /* Expiry information is about to change, remove from tree */
+       if (RB_REMOVE(penalties_by_expiry, &penalties_by_expiry,
+           existing) != existing)
+               fatal_f("internal error: penalty tables corrupt (remove)");
+       /* An entry already existed. Accumulate penalty up to maximum */
+       existing->expiry += penalty_secs;
+       if (existing->expiry - now > penalty_cfg.penalty_max)
+               existing->expiry = now + penalty_cfg.penalty_max;
+       if (existing->expiry - now > penalty_cfg.penalty_min &&
+           !existing->active) {
+               verbose_f("%s: activating penalty of %lld seconds for %s",
+                   addrnetmask, (long long)(existing->expiry - now), reason);
+               existing->active = 1;
+       }
+       existing->reason = penalty->reason;
+       free(penalty);
+       /* Re-insert into expiry tree */
+       if (RB_INSERT(penalties_by_expiry, &penalties_by_expiry,
+           existing) != NULL)
+               fatal_f("internal error: penalty tables corrupt (insert)");
+}
+
+void
+srclimit_penalty_info(void)
+{
+       struct penalty *p = NULL;
+       int bits;
+       char s[NI_MAXHOST + 4];
+       time_t now;
+
+       now = monotime();
+       logit("%zu active penalties", npenalties);
+       RB_FOREACH(p, penalties_by_expiry, &penalties_by_expiry) {
+               bits = p->addr.af == AF_INET ? ipv4_masklen : ipv6_masklen;
+               addr_masklen_ntop(&p->addr, bits, s, sizeof(s));
+               if (p->expiry < now)
+                       logit("client %s %s (expired)", s, p->reason);
+               else {
+                       logit("client %s %s (%llu secs left)", s, p->reason,
+                          (long long)(p->expiry - now));
+               }
+       }
+}
index 6e04f32..74a6f2b 100644 (file)
  * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
-void   srclimit_init(int, int, int, int);
+struct xaddr;
+
+struct per_source_penalty;
+
+void   srclimit_init(int, int, int, int,
+    struct per_source_penalty *, const char *);
 int    srclimit_check_allow(int, int);
 void   srclimit_done(int);
+
+#define SRCLIMIT_PENALTY_NONE          0
+#define SRCLIMIT_PENALTY_CRASH         1
+#define SRCLIMIT_PENALTY_AUTHFAIL      2
+#define SRCLIMIT_PENALTY_GRACE_EXCEEDED        3
+#define SRCLIMIT_PENALTY_NOAUTH                4
+
+/* meaningful exit values, used by sshd listener for penalties */
+#define EXIT_LOGIN_GRACE       3       /* login grace period exceeded */
+#define EXIT_CHILD_CRASH       4       /* preauth child crashed */
+#define EXIT_AUTH_ATTEMPTED    5       /* at least one auth attempt made */
+
+void   srclimit_penalise(struct xaddr *, int);
+int    srclimit_penalty_check_allow(int, const char **);
+void   srclimit_penalty_info(void);
index b800f2b..e75b4f8 100644 (file)
@@ -1,4 +1,4 @@
-/* $OpenBSD: sshd-session.c,v 1.2 2024/05/17 02:39:11 jsg Exp $ */
+/* $OpenBSD: sshd-session.c,v 1.3 2024/06/06 17:15:25 djm Exp $ */
 /*
  * SSH2 implementation:
  * Privilege Separation:
@@ -188,11 +188,7 @@ grace_alarm_handler(int sig)
                ssh_signal(SIGTERM, SIG_IGN);
                kill(0, SIGTERM);
        }
-
-       /* Log error and exit. */
-       sigdie("Timeout before authentication for %s port %d",
-           ssh_remote_ipaddr(the_active_state),
-           ssh_remote_port(the_active_state));
+       _exit(EXIT_LOGIN_GRACE);
 }
 
 /* Destroy the host and server keys.  They will no longer be needed. */
@@ -1220,6 +1216,8 @@ main(int ac, char **av)
        ssh_signal(SIGALRM, SIG_DFL);
        authctxt->authenticated = 1;
        if (startup_pipe != -1) {
+               /* signal listener that authentication completed successfully */
+               (void)atomicio(vwrite, startup_pipe, "\001", 1);
                close(startup_pipe);
                startup_pipe = -1;
        }
@@ -1338,6 +1336,8 @@ do_ssh2_kex(struct ssh *ssh)
 void
 cleanup_exit(int i)
 {
+       extern int auth_attempted; /* monitor.c */
+
        if (the_active_state != NULL && the_authctxt != NULL) {
                do_cleanup(the_active_state, the_authctxt);
                if (privsep_is_preauth &&
@@ -1350,5 +1350,8 @@ cleanup_exit(int i)
                        }
                }
        }
+       /* Override default fatal exit value when auth was attempted */
+       if (i == 255 && auth_attempted)
+               _exit(EXIT_AUTH_ATTEMPTED);
        _exit(i);
 }
index d310779..33e3528 100644 (file)
@@ -1,4 +1,4 @@
-/* $OpenBSD: sshd.c,v 1.605 2024/06/01 07:03:37 djm Exp $ */
+/* $OpenBSD: sshd.c,v 1.606 2024/06/06 17:15:25 djm Exp $ */
 /*
  * Copyright (c) 2000, 2001, 2002 Markus Friedl.  All rights reserved.
  * Copyright (c) 2002 Niels Provos.  All rights reserved.
@@ -72,6 +72,7 @@
 #include "version.h"
 #include "ssherr.h"
 #include "sk-api.h"
+#include "addr.h"
 #include "srclimit.h"
 
 /* Re-exec fds */
@@ -120,6 +121,8 @@ struct {
 } sensitive_data;
 
 /* This is set to true when a signal is received. */
+static volatile sig_atomic_t received_siginfo = 0;
+static volatile sig_atomic_t received_sigchld = 0;
 static volatile sig_atomic_t received_sighup = 0;
 static volatile sig_atomic_t received_sigterm = 0;
 
@@ -127,8 +130,9 @@ static volatile sig_atomic_t received_sigterm = 0;
 u_int utmp_len = HOST_NAME_MAX+1;
 
 /*
- * startup_pipes/flags are used for tracking children of the listening sshd
- * process early in their lifespans. This tracking is needed for three things:
+ * The early_child/children array below is used for tracking children of the
+ * listening sshd process early in their lifespans, before they have
+ * completed authentication. This tracking is needed for four things:
  *
  * 1) Implementing the MaxStartups limit of concurrent unauthenticated
  *    connections.
@@ -137,14 +141,31 @@ u_int utmp_len = HOST_NAME_MAX+1;
  *    after it restarts.
  * 3) Ensuring that rexec'd sshd processes have received their initial state
  *    from the parent listen process before handling SIGHUP.
+ * 4) Tracking and logging unsuccessful exits from the preauth sshd monitor,
+ *    including and especially those for LoginGraceTime timeouts.
  *
  * Child processes signal that they have completed closure of the listen_socks
  * and (if applicable) received their rexec state by sending a char over their
- * sock. Child processes signal that authentication has completed by closing
- * the sock (or by exiting).
+ * sock.
+ *
+ * Child processes signal that authentication has completed by sending a
+ * second char over the socket before closing it, otherwise the listener will
+ * continue tracking the child (and using up a MaxStartups slot) until the
+ * preauth subprocess exits, whereupon the listener will log its exit status.
+ * preauth processes will exit with a status of EXIT_LOGIN_GRACE to indicate
+ * they did not authenticate before the LoginGraceTime alarm fired.
  */
-static int *startup_pipes = NULL;
-static int *startup_flags = NULL;      /* Indicates child closed listener */
+struct early_child {
+       int pipefd;
+       int early;              /* Indicates child closed listener */
+       char *id;               /* human readable connection identifier */
+       pid_t pid;
+       struct xaddr addr;
+       int have_addr;
+       int status, have_status;
+};
+static struct early_child *children;
+static int children_active;
 static int startup_pipe = -1;          /* in child */
 
 /* sshd_config buffer */
@@ -171,15 +192,257 @@ close_listen_socks(void)
        num_listen_socks = 0;
 }
 
+/* Allocate and initialise the children array */
+static void
+child_alloc(void)
+{
+       int i;
+
+       children = xcalloc(options.max_startups, sizeof(*children));
+       for (i = 0; i < options.max_startups; i++) {
+               children[i].pipefd = -1;
+               children[i].pid = -1;
+       }
+}
+
+/* Register a new connection in the children array; child pid comes later */
+static struct early_child *
+child_register(int pipefd, int sockfd)
+{
+       int i, lport, rport;
+       char *laddr = NULL, *raddr = NULL;
+       struct early_child *child = NULL;
+       struct sockaddr_storage addr;
+       socklen_t addrlen = sizeof(addr);
+       struct sockaddr *sa = (struct sockaddr *)&addr;
+
+       for (i = 0; i < options.max_startups; i++) {
+               if (children[i].pipefd != -1 || children[i].pid > 0)
+                       continue;
+               child = &(children[i]);
+               break;
+       }
+       if (child == NULL) {
+               fatal_f("error: accepted connection when all %d child "
+                   " slots full", options.max_startups);
+       }
+       child->pipefd = pipefd;
+       child->early = 1;
+       /* record peer address, if available */
+       if (getpeername(sockfd, sa, &addrlen) == 0 &&
+          addr_sa_to_xaddr(sa, addrlen, &child->addr) == 0)
+               child->have_addr = 1;
+       /* format peer address string for logs */
+       if ((lport = get_local_port(sockfd)) == 0 ||
+           (rport = get_peer_port(sockfd)) == 0) {
+               /* Not a TCP socket */
+               raddr = get_peer_ipaddr(sockfd);
+               xasprintf(&child->id, "connection from %s", raddr);
+       } else {
+               laddr = get_local_ipaddr(sockfd);
+               raddr = get_peer_ipaddr(sockfd);
+               xasprintf(&child->id, "connection from %s to %s", laddr, raddr);
+       }
+       free(laddr);
+       free(raddr);
+       if (++children_active > options.max_startups)
+               fatal_f("internal error: more children than max_startups");
+
+       return child;
+}
+
+/*
+ * Finally free a child entry. Don't call this directly.
+ */
+static void
+child_finish(struct early_child *child)
+{
+       if (children_active == 0)
+               fatal_f("internal error: children_active underflow");
+       if (child->pipefd != -1)
+               close(child->pipefd);
+       free(child->id);
+       memset(child, '\0', sizeof(*child));
+       child->pipefd = -1;
+       child->pid = -1;
+       children_active--;
+}
+
+/*
+ * Close a child's pipe. This will not stop tracking the child immediately
+ * (it will still be tracked for waitpid()) unless force_final is set, or
+ * child has already exited.
+ */
+static void
+child_close(struct early_child *child, int force_final, int quiet)
+{
+       if (!quiet)
+               debug_f("enter%s", force_final ? " (forcing)" : "");
+       if (child->pipefd != -1) {
+               close(child->pipefd);
+               child->pipefd = -1;
+       }
+       if (child->pid == -1 || force_final)
+               child_finish(child);
+}
+
+/* Record a child exit. Safe to call from signal handlers */
+static void
+child_exit(pid_t pid, int status)
+{
+       int i;
+
+       if (children == NULL || pid <= 0)
+               return;
+       for (i = 0; i < options.max_startups; i++) {
+               if (children[i].pid == pid) {
+                       children[i].have_status = 1;
+                       children[i].status = status;
+                       break;
+               }
+       }
+}
+
+/*
+ * Reap a child entry that has exited, as previously flagged
+ * using child_exit().
+ * Handles logging of exit condition and will finalise the child if its pipe
+ * had already been closed.
+ */
+static void
+child_reap(struct early_child *child)
+{
+       LogLevel level = SYSLOG_LEVEL_DEBUG1;
+       int was_crash, penalty_type = SRCLIMIT_PENALTY_NONE;
+
+       /* Log exit information */
+       if (WIFSIGNALED(child->status)) {
+               /*
+                * Increase logging for signals potentially associated
+                * with serious conditions.
+                */
+               if ((was_crash = signal_is_crash(WTERMSIG(child->status))))
+                       level = SYSLOG_LEVEL_ERROR;
+               do_log2(level, "session process %ld for %s killed by "
+                   "signal %d%s", (long)child->pid, child->id,
+                   WTERMSIG(child->status), child->early ? " (early)" : "");
+               if (was_crash)
+                       penalty_type = SRCLIMIT_PENALTY_CRASH;
+       } else if (!WIFEXITED(child->status)) {
+               penalty_type = SRCLIMIT_PENALTY_CRASH;
+               error("session process %ld for %s terminated abnormally, "
+                   "status=0x%x%s", (long)child->pid, child->id, child->status,
+                   child->early ? " (early)" : "");
+       } else {
+               /* Normal exit. We care about the status */
+               switch (WEXITSTATUS(child->status)) {
+               case 0:
+                       debug3_f("preauth child %ld for %s completed "
+                           "normally %s", (long)child->pid, child->id,
+                           child->early ? " (early)" : "");
+                       break;
+               case EXIT_LOGIN_GRACE:
+                       penalty_type = SRCLIMIT_PENALTY_GRACE_EXCEEDED;
+                       logit("Timeout before authentication for %s, "
+                           "pid = %ld%s", child->id, (long)child->pid,
+                           child->early ? " (early)" : "");
+                       break;
+               case EXIT_CHILD_CRASH:
+                       penalty_type = SRCLIMIT_PENALTY_CRASH;
+                       logit("Session process %ld unpriv child crash for %s%s",
+                           (long)child->pid, child->id,
+                           child->early ? " (early)" : "");
+                       break;
+               case EXIT_AUTH_ATTEMPTED:
+                       penalty_type = SRCLIMIT_PENALTY_AUTHFAIL;
+                       debug_f("preauth child %ld for %s exited "
+                           "after unsuccessful auth attempt %s",
+                           (long)child->pid, child->id,
+                           child->early ? " (early)" : "");
+                       break;
+               default:
+                       penalty_type = SRCLIMIT_PENALTY_NOAUTH;
+                       debug_f("preauth child %ld for %s exited "
+                           "with status %d%s", (long)child->pid, child->id,
+                           WEXITSTATUS(child->status),
+                           child->early ? " (early)" : "");
+                       break;
+               }
+       }
+       /*
+        * XXX would be nice to have more subtlety here.
+        *  - Different penalties
+        *      a) authentication failures without success (e.g. brute force)
+        *      b) login grace exceeded (penalise DoS)
+        *      c) monitor crash (penalise exploit attempt)
+        *      d) unpriv preauth crash (penalise exploit attempt)
+        *  - Unpriv auth exit status/WIFSIGNALLED is not available because
+        *    the "mm_request_receive: monitor fd closed" fatal kills the
+        *    monitor before waitpid() can occur. It would be good to use the
+        *    unpriv exit status to detect crashes.
+        *
+        * For now, just penalise (a), (b) and (c), since that is what we have
+        * readily available. The authentication failures detection cannot
+        * discern between failed authentication and other connection problems
+        * until we have the unpriv exist status plumbed through (and the unpriv
+        * child modified to use a different exit status when auth has been
+        * attempted), but it's a start.
+        */
+       if (child->have_addr)
+               srclimit_penalise(&child->addr, penalty_type);
+
+       child->pid = -1;
+       child->have_status = 0;
+       if (child->pipefd == -1)
+               child_finish(child);
+}
+
+/* Reap all children that have exited; called after SIGCHLD */
+static void
+child_reap_all_exited(void)
+{
+       int i;
+
+       if (children == NULL)
+               return;
+       for (i = 0; i < options.max_startups; i++) {
+               if (!children[i].have_status)
+                       continue;
+               child_reap(&(children[i]));
+       }
+}
+
 static void
 close_startup_pipes(void)
 {
        int i;
 
-       if (startup_pipes)
-               for (i = 0; i < options.max_startups; i++)
-                       if (startup_pipes[i] != -1)
-                               close(startup_pipes[i]);
+       if (children == NULL)
+               return;
+       for (i = 0; i < options.max_startups; i++) {
+               if (children[i].pipefd != -1)
+                       child_close(&(children[i]), 1, 1);
+       }
+}
+
+/* Called after SIGINFO */
+static void
+show_info(void)
+{
+       int i;
+
+       /* XXX print listening sockets here too */
+       if (children == NULL)
+               return;
+       logit("%d active startups", children_active);
+       for (i = 0; i < options.max_startups; i++) {
+               if (children[i].pipefd == -1 && children[i].pid <= 0)
+                       continue;
+               logit("child %d: fd=%d pid=%ld %s%s", i, children[i].pipefd,
+                   (long)children[i].pid, children[i].id,
+                   children[i].early ? " (early)" : "");
+       }
+       srclimit_penalty_info();
 }
 
 /*
@@ -222,6 +485,12 @@ sigterm_handler(int sig)
        received_sigterm = sig;
 }
 
+static void
+siginfo_handler(int sig)
+{
+       received_siginfo = 1;
+}
+
 /*
  * SIGCHLD handler.  This is called whenever a child dies.  This will then
  * reap any zombies left by exited children.
@@ -233,9 +502,17 @@ main_sigchld_handler(int sig)
        pid_t pid;
        int status;
 
-       while ((pid = waitpid(-1, &status, WNOHANG)) > 0 ||
-           (pid == -1 && errno == EINTR))
-               ;
+       for (;;) {
+               if ((pid = waitpid(-1, &status, WNOHANG)) == 0)
+                       break;
+               else if (pid == -1) {
+                       if (errno == EINTR)
+                               continue;
+                       break;
+               }
+               child_exit(pid, status);
+               received_sigchld = 1;
+       }
        errno = save_errno;
 }
 
@@ -268,7 +545,7 @@ should_drop_connection(int startups)
 }
 
 /*
- * Check whether connection should be accepted by MaxStartups.
+ * Check whether connection should be accepted by MaxStartups or for penalty.
  * Returns 0 if the connection is accepted. If the connection is refused,
  * returns 1 and attempts to send notification to client.
  * Logs when the MaxStartups condition is entered or exited, and periodically
@@ -278,12 +555,17 @@ static int
 drop_connection(int sock, int startups, int notify_pipe)
 {
        char *laddr, *raddr;
-       const char msg[] = "Exceeded MaxStartups\r\n";
+       const char *reason = NULL, msg[] = "Not allowed at this time\r\n";
        static time_t last_drop, first_drop;
        static u_int ndropped;
        LogLevel drop_level = SYSLOG_LEVEL_VERBOSE;
        time_t now;
 
+       if (!srclimit_penalty_check_allow(sock, &reason)) {
+               drop_level = SYSLOG_LEVEL_INFO;
+               goto handle;
+       }
+
        now = monotime();
        if (!should_drop_connection(startups) &&
            srclimit_check_allow(sock, notify_pipe) == 1) {
@@ -313,12 +595,16 @@ drop_connection(int sock, int startups, int notify_pipe)
        }
        last_drop = now;
        ndropped++;
+       reason = "past Maxstartups";
 
+ handle:
        laddr = get_local_ipaddr(sock);
        raddr = get_peer_ipaddr(sock);
-       do_log2(drop_level, "drop connection #%d from [%s]:%d on [%s]:%d "
-           "past MaxStartups", startups, raddr, get_peer_port(sock),
-           laddr, get_local_port(sock));
+       do_log2(drop_level, "drop connection #%d from [%s]:%d on [%s]:%d %s",
+           startups,
+           raddr, get_peer_port(sock),
+           laddr, get_local_port(sock),
+           reason);
        free(laddr);
        free(raddr);
        /* best-effort notification to client */
@@ -521,8 +807,12 @@ server_listen(void)
        u_int i;
 
        /* Initialise per-source limit tracking. */
-       srclimit_init(options.max_startups, options.per_source_max_startups,
-           options.per_source_masklen_ipv4, options.per_source_masklen_ipv6);
+       srclimit_init(options.max_startups,
+           options.per_source_max_startups,
+           options.per_source_masklen_ipv4,
+           options.per_source_masklen_ipv6,
+           &options.per_source_penalty,
+           options.per_source_penalty_exempt);
 
        for (i = 0; i < options.num_listen_addrs; i++) {
                listen_on_addrs(&options.listen_addrs[i]);
@@ -548,32 +838,30 @@ server_accept_loop(int *sock_in, int *sock_out, int *newsock, int *config_s,
     int log_stderr)
 {
        struct pollfd *pfd = NULL;
-       int i, j, ret, npfd;
-       int ostartups = -1, startups = 0, listening = 0, lameduck = 0;
+       int i, ret, npfd;
+       int oactive = -1, listening = 0, lameduck = 0;
        int startup_p[2] = { -1 , -1 }, *startup_pollfd;
        char c = 0;
        struct sockaddr_storage from;
+       struct early_child *child;
        socklen_t fromlen;
-       pid_t pid;
        sigset_t nsigset, osigset;
 
        /* setup fd set for accept */
        /* pipes connected to unauthenticated child sshd processes */
-       startup_pipes = xcalloc(options.max_startups, sizeof(int));
-       startup_flags = xcalloc(options.max_startups, sizeof(int));
+       child_alloc();
        startup_pollfd = xcalloc(options.max_startups, sizeof(int));
-       for (i = 0; i < options.max_startups; i++)
-               startup_pipes[i] = -1;
 
        /*
         * Prepare signal mask that we use to block signals that might set
-        * received_sigterm or received_sighup, so that we are guaranteed
+        * received_sigterm/hup/chld/info, so that we are guaranteed
         * to immediately wake up the ppoll if a signal is received after
         * the flag is checked.
         */
        sigemptyset(&nsigset);
        sigaddset(&nsigset, SIGHUP);
        sigaddset(&nsigset, SIGCHLD);
+       sigaddset(&nsigset, SIGINFO);
        sigaddset(&nsigset, SIGTERM);
        sigaddset(&nsigset, SIGQUIT);
 
@@ -595,11 +883,19 @@ server_accept_loop(int *sock_in, int *sock_out, int *newsock, int *config_s,
                                unlink(options.pid_file);
                        exit(received_sigterm == SIGTERM ? 0 : 255);
                }
-               if (ostartups != startups) {
+               if (received_sigchld) {
+                       child_reap_all_exited();
+                       received_sigchld = 0;
+               }
+               if (received_siginfo) {
+                       show_info();
+                       received_siginfo = 0;
+               }
+               if (oactive != children_active) {
                        setproctitle("%s [listener] %d of %d-%d startups",
-                           listener_proctitle, startups,
+                           listener_proctitle, children_active,
                            options.max_startups_begin, options.max_startups);
-                       ostartups = startups;
+                       oactive = children_active;
                }
                if (received_sighup) {
                        if (!lameduck) {
@@ -620,8 +916,8 @@ server_accept_loop(int *sock_in, int *sock_out, int *newsock, int *config_s,
                npfd = num_listen_socks;
                for (i = 0; i < options.max_startups; i++) {
                        startup_pollfd[i] = -1;
-                       if (startup_pipes[i] != -1) {
-                               pfd[npfd].fd = startup_pipes[i];
+                       if (children[i].pipefd != -1) {
+                               pfd[npfd].fd = children[i].pipefd;
                                pfd[npfd].events = POLLIN;
                                startup_pollfd[i] = npfd++;
                        }
@@ -639,34 +935,46 @@ server_accept_loop(int *sock_in, int *sock_out, int *newsock, int *config_s,
                        continue;
 
                for (i = 0; i < options.max_startups; i++) {
-                       if (startup_pipes[i] == -1 ||
+                       if (children[i].pipefd == -1 ||
                            startup_pollfd[i] == -1 ||
                            !(pfd[startup_pollfd[i]].revents & (POLLIN|POLLHUP)))
                                continue;
-                       switch (read(startup_pipes[i], &c, sizeof(c))) {
+                       switch (read(children[i].pipefd, &c, sizeof(c))) {
                        case -1:
                                if (errno == EINTR || errno == EAGAIN)
                                        continue;
                                if (errno != EPIPE) {
                                        error_f("startup pipe %d (fd=%d): "
-                                           "read %s", i, startup_pipes[i],
+                                           "read %s", i, children[i].pipefd,
                                            strerror(errno));
                                }
                                /* FALLTHROUGH */
                        case 0:
-                               /* child exited or completed auth */
-                               close(startup_pipes[i]);
-                               srclimit_done(startup_pipes[i]);
-                               startup_pipes[i] = -1;
-                               startups--;
-                               if (startup_flags[i])
+                               /* child exited preauth */
+                               if (children[i].early)
                                        listening--;
+                               srclimit_done(children[i].pipefd);
+                               child_close(&(children[i]), 0, 0);
                                break;
                        case 1:
-                               /* child has finished preliminaries */
-                               if (startup_flags[i]) {
+                               if (children[i].early && c == '\0') {
+                                       /* child has finished preliminaries */
                                        listening--;
-                                       startup_flags[i] = 0;
+                                       children[i].early = 0;
+                                       debug2_f("child %lu for %s received "
+                                           "config", (long)children[i].pid,
+                                           children[i].id);
+                               } else if (!children[i].early && c == '\001') {
+                                       /* child has completed auth */
+                                       debug2_f("child %lu for %s auth done",
+                                           (long)children[i].pid,
+                                           children[i].id);
+                                       child_close(&(children[i]), 1, 0);
+                               } else {
+                                       error_f("unexpected message 0x%02x "
+                                           "child %ld for %s in state %d",
+                                           (int)c, (long)children[i].pid,
+                                           children[i].id, children[i].early);
                                }
                                break;
                        }
@@ -695,7 +1003,8 @@ server_accept_loop(int *sock_in, int *sock_out, int *newsock, int *config_s,
                                close(*newsock);
                                continue;
                        }
-                       if (drop_connection(*newsock, startups, startup_p[0])) {
+                       if (drop_connection(*newsock,
+                           children_active, startup_p[0])) {
                                close(*newsock);
                                close(startup_p[0]);
                                close(startup_p[1]);
@@ -712,14 +1021,6 @@ server_accept_loop(int *sock_in, int *sock_out, int *newsock, int *config_s,
                                continue;
                        }
 
-                       for (j = 0; j < options.max_startups; j++)
-                               if (startup_pipes[j] == -1) {
-                                       startup_pipes[j] = startup_p[0];
-                                       startups++;
-                                       startup_flags[j] = 1;
-                                       break;
-                               }
-
                        /*
                         * Got connection.  Fork a child to handle it, unless
                         * we are in debugging mode.
@@ -737,7 +1038,6 @@ server_accept_loop(int *sock_in, int *sock_out, int *newsock, int *config_s,
                                close(startup_p[0]);
                                close(startup_p[1]);
                                startup_pipe = -1;
-                               pid = getpid();
                                send_rexec_state(config_s[0], cfg);
                                close(config_s[0]);
                                free(pfd);
@@ -750,7 +1050,8 @@ server_accept_loop(int *sock_in, int *sock_out, int *newsock, int *config_s,
                         * parent continues listening.
                         */
                        listening++;
-                       if ((pid = fork()) == 0) {
+                       child = child_register(startup_p[0], *newsock);
+                       if ((child->pid = fork()) == 0) {
                                /*
                                 * Child.  Close the listening and
                                 * max_startup sockets.  Start using
@@ -774,10 +1075,10 @@ server_accept_loop(int *sock_in, int *sock_out, int *newsock, int *config_s,
                        }
 
                        /* Parent.  Stay in the loop. */
-                       if (pid == -1)
+                       if (child->pid == -1)
                                error("fork: %.100s", strerror(errno));
                        else
-                               debug("Forked child %ld.", (long)pid);
+                               debug("Forked child %ld.", (long)child->pid);
 
                        close(startup_p[1]);
 
@@ -1340,6 +1641,7 @@ main(int ac, char **av)
                ssh_signal(SIGCHLD, main_sigchld_handler);
                ssh_signal(SIGTERM, sigterm_handler);
                ssh_signal(SIGQUIT, sigterm_handler);
+               ssh_signal(SIGINFO, siginfo_handler);
 
                /*
                 * Write out the pid file after the sigterm handler
index 93afc3e..430de76 100644 (file)
@@ -33,8 +33,8 @@
 .\" (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
 .\" THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 .\"
-.\" $OpenBSD: sshd_config.5,v 1.355 2024/02/21 06:17:29 djm Exp $
-.Dd $Mdocdate: February 21 2024 $
+.\" $OpenBSD: sshd_config.5,v 1.356 2024/06/06 17:15:25 djm Exp $
+.Dd $Mdocdate: June 6 2024 $
 .Dt SSHD_CONFIG 5
 .Os
 .Sh NAME
@@ -1558,6 +1558,68 @@ Values for IPv4 and optionally IPv6 may be specified, separated by a colon.
 The default is
 .Cm 32:128 ,
 which means each address is considered individually.
+.It Cm PerSourcePenalties
+Controls penalties for various conditions that may represent attacks on
+.Xr sshd 8 .
+If a penalty is enforced against a client then its source address and any
+others in the
+.Cm PerSourceNetBlockSize
+will be refused connection for a period.
+Multiple penalties from the same source from concurrent connections will
+accumulate up to a maximum.
+Conversely, penalties are not applied until a minimum threshold time has been
+accumulated.
+Penalties are off by default but may be enabled using default settings using the
+.Cm yes
+keyword or by specifying one or more of the keywords below.
+.Pp
+Penalties are controlled using the following keywords, all of which accept
+arguments, e.g.
+.Qq crash:2m .
+.Bl -tag -width Ds
+.It Cm crash:duration
+Specifies how long to refuse clients that cause a crash of
+.Xr sshd 8 .
+.It Cm authfail:duration
+Specifies how long to refuse clients that disconnect after making one or more
+unsuccessful authentication attempts.
+.It Cm noauth:duration
+Specifies how long to refuse clients that disconnect without attempting
+authentication.
+This timeout should be used cautiously otherwise it may penalise legitimate
+scanning tools such as
+.Xr ssh-keyscan 1 .
+.It Cm grace-exceeded:duration
+Specifies how long to refuse clients that fail to authenticate after
+.Cm LoginGraceTime .
+.It Cm max:duration
+Specifies the maximum time a particular source address range will be refused
+access for.
+Repeated penalties will accumulate up to this maximum.
+.It Cm min:duration
+Specifies the minimum penalty that must accrue before enforcement begins.
+.It Cm max-sources:number
+Specifies the maximum number of penalise client address ranges to track.
+.It Cm overflow:mode
+Controls how the server behaves when
+.Cm max-sources
+is exceeded.
+There are two operating modes:
+.Cm deny-all ,
+which denies all incoming connections other than those exempted via
+.Cm PerSourcePenaltyExemptList
+until a penalty expires, and
+.Cm permissive ,
+which allows new connections by removing existing penalties early.
+.El
+.It Cm PerSourcePenaltyExemptList
+Specifies a comma-separated list of addresses to exempt from penalties.
+This list may contain wildcards and CIDR address/masklen ranges.
+Note that the mask length provided must be consistent with the address -
+it is an error to specify a mask length that is too long for the address
+or one with bits set in this host portion of the address.
+For example, 192.0.2.0/33 and 192.0.2.0/8, respectively.
+The default is not to exempt any addresses.
 .It Cm PidFile
 Specifies the file that contains the process ID of the
 SSH daemon, or