session server ssh FEATURE lock an account out after repeated failed password authentication - #640
niklas-moser wants to merge 1 commit into
Conversation
…password authentication Nothing reads ssh_auth_attempts, so password guessing is unlimited both within a connection and across them. auth-timeout bounds how long one authentication may take, not how many may be tried, and pam_faillock only ever sees the keyboard-interactive method. Count consecutive password failures per account across connections and refuse the account for NC_AUTHLOCK_TIME after NC_AUTHLOCK_MAX_FAILS. The hooks sit on the three credential checks in session_server_ssh.c that both auth backends funnel through, so the configured-password, kbdint and PAM methods share one tally and neither dispatch file is touched. The tally is mirrored to a state file and re-read when it changes, so a lockout survives a restart and can be cleared on a running server. A session hitting NC_AUTHLOCK_SESSION_MAX_FAILS is disconnected. Public key auth is deliberately not counted, which keeps a locked out deployment recoverable. The policy is compiled in; ietf-netconf-server has no leaves for it.
20c3063 to
dccb1e7
Compare
Roytak
left a comment
There was a problem hiding this comment.
Thanks for the contribution and sorry for the delay! I think this feature is very useful, but will need a bit more work - mainly making this optional (off by default) and configurable. Also could add at least one test e.g. for locking an account out after N attempts.
| * @param[in] success Whether it succeeded, which clears the tally. | ||
| */ | ||
| static void | ||
| nc_authlock_record(struct nc_session *session, const char *username, int success) |
There was a problem hiding this comment.
The problem I have with this is that the tally is keyed on the username alone, and the username is entirely attacker-controlled. Anyone who can reach the NETCONF port can keep root/admin permanently locked out of password auth by sending 5 bad passwords every 300 s. At minimum the lockout should be keyed by (username, peer address), and the deployment must be able to turn it off. For the peer address I believe you can use session->host, but it may be NULL...
| #define NC_AUTHLOCK_MAX_FAILS 5 /**< consecutive failures that lock an account out */ | ||
| #define NC_AUTHLOCK_TIME 300 /**< how long an account stays locked out, seconds */ | ||
| #define NC_AUTHLOCK_FAIL_INTERVAL 900 /**< failures further apart than this start a new tally, seconds */ |
There was a problem hiding this comment.
Every existing libnetconf2 user gets a behaviour change they cannot configure, disable, or even observe except through a WRN. Add an augment to the libnetconf2-netconf-server YANG with the leaves (e.g. max_fails, lock_time, window probably somewhere under SSH's client-authentication) and default to disabled, so this is opt-in.
| if (authlock.entry_count < NC_AUTHLOCK_MAX_ENTRIES) { | ||
| slot = authlock.entry_count++; | ||
| } else { | ||
| /* full, reuse the entry that failed longest ago. With local users configured only accounts | ||
| * known to the endpoint reach the authentication dispatch, so this bound is about memory | ||
| * rather than about an attacker flooding the tally with invented names. */ | ||
| slot = 0; | ||
| for (i = 1; i < authlock.entry_count; ++i) { | ||
| if (authlock.entries[i].last_fail < authlock.entries[slot].last_fail) { | ||
| slot = i; | ||
| } | ||
| } | ||
| free(authlock.entries[slot].username); | ||
| } |
There was a problem hiding this comment.
You evict the entry with the oldest last_fail when the table is full here, without regard for whether that entry is currently locked. I think you should never evict an entry with locked_until > now, prefer evicting expired/unlocked entries and if none are available, refuse to add rather than dropping a lockout.
| static const char * | ||
| nc_authlock_path(void) | ||
| { | ||
| const char *path = getenv("NC_AUTHLOCK_FILE"); | ||
|
|
||
| return (path && path[0]) ? path : NC_AUTHLOCK_FILE; | ||
| } |
There was a problem hiding this comment.
Nothing in the build creates /var/lib/netconf-authlock/, so on a normal install every open() fails, a single WRN is logged, and the persistence half of the feature is silently absent.
Make the default path a CMake option (off by default), expose a setter in the public API, drop the getenv, and create the directory (or log loudly and disable persistence).
| /* free the password authentication lockout tally, the state file it mirrors is kept */ | ||
| nc_server_ssh_authlock_free(); |
There was a problem hiding this comment.
It sits before nc_server_ch_threads_destroy() so Call Home and accept threads are still authenticating clients. Move the call to the end of the teardown, after all threads are joined.
| ERR(session, "Too many failed authentication attempts (%d) in a single session, disconnecting.", | ||
| session->opts.server.ssh_auth_attempts); | ||
| NC_SESSION_STATUS_SET(session, NC_STATUS_INVALID); | ||
| ATOMIC_STORE_RELAXED(session->term_reason, NC_SESSION_TERM_OTHER); |
There was a problem hiding this comment.
Use NC_SESSION_TERM_REASON_SET.
| WRN(session, "User \"%s\" locked out of password authentication for %d s after %u consecutive " | ||
| "failed attempts.", username, NC_AUTHLOCK_TIME, entry->fails); |
There was a problem hiding this comment.
Use PRIu32 instead of %u (3 occurences I believe).
|
|
||
| /* the username is the rest of the line so that it may contain spaces */ | ||
| while ((authlock.entry_count < NC_AUTHLOCK_MAX_ENTRIES) && fgets(line, sizeof line, f)) { | ||
| if (sscanf(line, "%u %lld %lld %n", &fails, &locked_until, &last_fail, &offset) != 3) { |
There was a problem hiding this comment.
Should use SCNu32 and uint32_t fails.
| struct nc_authlock_entry { | ||
| char *username; /**< account the tally belongs to */ | ||
| uint32_t fails; /**< consecutive failed password authentications */ | ||
| time_t last_fail; /**< when the last one was */ | ||
| time_t locked_until; /**< no password authentication before this, 0 if not locked out */ | ||
| }; | ||
|
|
||
| static struct { | ||
| pthread_mutex_t lock; | ||
| struct nc_authlock_entry entries[NC_AUTHLOCK_MAX_ENTRIES]; | ||
| uint32_t entry_count; | ||
| int loaded; /**< whether the state file was read already */ | ||
| ino_t loaded_ino; /**< inode of the state file revision the tally came from */ | ||
| struct timespec loaded_mtim; /**< mtime of the state file revision the tally came from */ | ||
| int store_failed; /**< whether a failure to write the state file was logged already */ | ||
| } authlock = {.lock = PTHREAD_MUTEX_INITIALIZER}; |
There was a problem hiding this comment.
Should be moved to session_p.h along with any needed macros.
| /** | ||
| * @brief Free the password authentication lockout tally. | ||
| * | ||
| * The state file it mirrors is kept, so a lockout survives the server being restarted. | ||
| */ | ||
| void nc_server_ssh_authlock_free(void); |
There was a problem hiding this comment.
Belongs to session_p.h imo.
Nothing reads ssh_auth_attempts, so password guessing is unlimited both within a connection and across them. auth-timeout bounds how long one authentication may take, not how many may be tried, and pam_faillock only ever sees the
keyboard-interactive method — accounts using hashed-password are verified by libnetconf2 itself with crypt(3).
This counts consecutive password failures per account across connections and refuses the account for NC_AUTHLOCK_TIME after NC_AUTHLOCK_MAX_FAILS. The hooks sit on the three credential checks in session_server_ssh.c that both the message-and callback-based backends funnel through (auth_password_check, kbdint_verify_passwd, pam_authenticate), so all three methods share one tally and neither dispatch file is touched. The tally is mirrored to a state file and re-read when it changes, so a lockout survives a restart and can be cleared on a running server. A session hitting NC_AUTHLOCK_SESSION_MAX_FAILS is disconnected — ssh_auth_attempts finally gets a reader.
Public key auth is deliberately not counted, which keeps a locked out deployment recoverable. TLS is unaffected.
Open question: the policy is compiled in (5 failures, 300 s, 900 s window, 6 per session, 64 accounts). ietf-netconf-server has no leaves for it and your augment carries only auth-timeout, so making it configurable means extending
libnetconf2-netconf-server.yang. If you want to make it configurable, we can change the approach there
Motivated by O-RAN WG11 R004 / 3GPP TS 33.117 4.2.3.4.3.1.