Skip to content

session server ssh FEATURE lock an account out after repeated failed password authentication - #640

Open
niklas-moser wants to merge 1 commit into
CESNET:develfrom
niklas-moser:ssh-auth-lockout
Open

niklas-moser wants to merge 1 commit into
CESNET:develfrom
niklas-moser:ssh-auth-lockout

Conversation

@niklas-moser

@niklas-moser niklas-moser commented Sep 7, 2026

Copy link
Copy Markdown

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.

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

@Roytak Roytak left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/session_server_ssh.c
* @param[in] success Whether it succeeded, which clears the tally.
*/
static void
nc_authlock_record(struct nc_session *session, const char *username, int success)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/session_server_ssh.c
Comment on lines +64 to +66
#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 */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/session_server_ssh.c
Comment on lines +305 to +318
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/session_server_ssh.c
Comment on lines +107 to +113
static const char *
nc_authlock_path(void)
{
const char *path = getenv("NC_AUTHLOCK_FILE");

return (path && path[0]) ? path : NC_AUTHLOCK_FILE;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/session_server.c
Comment on lines +1623 to +1624
/* free the password authentication lockout tally, the state file it mirrors is kept */
nc_server_ssh_authlock_free();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/session_server_ssh.c
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use NC_SESSION_TERM_REASON_SET.

Comment thread src/session_server_ssh.c
Comment on lines +399 to +400
WRN(session, "User \"%s\" locked out of password authentication for %d s after %u consecutive "
"failed attempts.", username, NC_AUTHLOCK_TIME, entry->fails);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use PRIu32 instead of %u (3 occurences I believe).

Comment thread src/session_server_ssh.c

/* 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should use SCNu32 and uint32_t fails.

Comment thread src/session_server_ssh.c
Comment on lines +71 to +86
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};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be moved to session_p.h along with any needed macros.

Comment on lines +342 to +347
/**
* @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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Belongs to session_p.h imo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants