Skip to content

Server-side authentication

The mirror of reading sshconnect2.c, from the other end of the wire. Same protocol, very different shape: where the client is one process making offers, the server is two processes — one that talks to the network and one that holds the privileges — and the interesting part is the line between them.

Everything the network can reach before authentication succeeds runs in sshd-auth: unprivileged, chrooted, and holding no secrets. That includes the version banner and the key exchange, not just authentication (sshd-auth.c:731):

privsep_child_demote(); /* chroot, setuid to the "sshd" user */
do_ssh2_kex(ssh); /* banner exchange + key exchange happen HERE */
do_authentication2(ssh); /* then the userauth protocol */

The privileged half never touches a protocol packet. It sits in a loop reading requests off a socket pair and answering the ones it currently permits.

flowchart LR
    subgraph U["sshd-auth — unprivileged, chrooted"]
        A["do_authentication2()<br/><i>auth2.c:171</i>"]
        B["input_userauth_request()<br/><i>auth2.c:275</i>"]
        C["m->userauth()<br/><i>auth2-pubkey.c, auth2-passwd.c, …</i>"]
        A --> B --> C
    end
    subgraph P["monitor — privileged, in sshd-session"]
        D["monitor_child_preauth()<br/><i>monitor.c:266</i>"]
        E["mm_answer_* handlers<br/><i>the only privileged API</i>"]
        D --> E
    end
    C -->|"mm_* request"| E
    E -->|"answer"| C
    style U fill:#7f2d2d,color:#fff
Unprivileged (sshd-auth)Privileged (monitor, in sshd-session)
Filesauth2.c, auth2-*.c, monitor_wrap.cmonitor.c
SeesEvery packet from the clientOnly structured requests from its child
CanParse, decide locally, askRead /etc/shadow, sign with the host key, read authorized_keys, run PAM
Decides authenticationNo — it only thinks it doesYes

Structurally this is the client’s dispatch machine reflected:

do_authentication2() auth2.c:171
ssh_dispatch_set(SERVICE_REQUEST → input_service_request)
ssh_dispatch_run_fatal(…, &authctxt->success) ← loop until authenticated
input_service_request() auth2.c:184 — accepts only "ssh-userauth"
ssh_dispatch_set(USERAUTH_REQUEST → input_userauth_request)
input_userauth_request() auth2.c:275 — once per client attempt
m->userauth(ssh, method)
userauth_finish() auth2.c:367 — SUCCESS or FAILURE

input_service_request denies every service except ssh-userauth — the comment /* XXX all other service requests are denied */ is the whole service layer.

input_userauth_request handles every SSH2_MSG_USERAUTH_REQUEST. Reading it in order gives you the server’s entire policy:

First attempt only. The username is resolved once (mm_getpwnamallow — a monitor round trip that also applies AllowUsers/DenyUsers), then cached. Subsequent requests that change the username or service are a disconnect, not a failure:

} else if (strcmp(user, authctxt->user) != 0 ||
strcmp(service, authctxt->service) != 0) {
ssh_packet_disconnect(ssh, "Change of username or service not allowed…");
}

Unknown users are not rejected — they are faked. If the user does not exist or is not allowed, authctxt->valid is cleared and authctxt->pw is set to fakepw(). Authentication then proceeds normally and fails normally, so a probe cannot distinguish “no such user” from “wrong password” by watching which requests get answered.

A hard ceiling and a soft one. attempt >= 1024 disconnects outright; failures < options.max_authtries gates whether a method is even tried. They are different counters, which matters — see the none rule below.

Then dispatch, and unconditionally call userauth_finish.

userauth_finish is where a yes becomes a USERAUTH_SUCCESS, and it is worth reading line by line because several policies land only here.

StepEffect
Root checkauth_root_allowed can turn an authenticated root login back into a failure — this is PermitRootLogin, applied after the method succeeded
Multi-factorauth2_update_methods_lists can turn success into partial
auth_logLogged before the reply is sent
SuccessRegisters dispatch_protocol_ignore for further USERAUTH_REQUESTs and sets authctxt->success, breaking the loop
FailureSends the remaining method list plus the partial byte

The failure counter has a deliberate hole in it:

/* Allow initial try of "none" auth without failure penalty */
if (!partial && !authctxt->server_caused_failure &&
(authctxt->attempt > 1 || strcmp(method, "none") != 0))
authctxt->failures++;

Every client opens with none to discover the method list. Counting that against MaxAuthTries would cost everyone one attempt, so the first one is free — but only the first, and only if it is genuinely the opening request.

server_caused_failure is the other exemption: if the failure was the server’s fault, the client is not charged for it.

Same idea as the client’s, split into two structs (auth.h:107):

struct authmethod_cfg {
const char *name;
const char *synonym;
int *enabled;
};
struct Authmethod {
struct authmethod_cfg *cfg;
int (*userauth)(struct ssh *, const char *);
};

The split exists for a specific reason, stated in the comment at the top of auth2-methods.c: the listener process needs to validate AuthenticationMethods at config-check time without linking the entire authentication implementation. Configuration and behaviour therefore live in different translation units.

Two differences from the client side:

  • synonym carries publickey-hostbound-v00@openssh.com, so the extension resolves to the same method. userauth_finish normalises back to m->cfg->name before logging, which is why logs say publickey regardless.
  • none_enabled is a plain global that userauth_none clears after its single permitted use.

AuthenticationMethods is modelled as lists that get consumed, which is more capable than it first looks. auth2_setup_methods_lists copies the configured lists into the connection; each success calls auth2_update_methods_lists, which removes the completed method from every list it appears in:

  • A list that becomes empty means authentication is complete → success.
  • Any non-empty list remaining means more is required → partial = 1.
  • The offered method list is recomputed from what could still come next in some list (authmethods_get), so a client cannot skip ahead or reorder stages.

That is why publickey,keyboard-interactive cannot be satisfied by doing keyboard-interactive first: at stage one, keyboard-interactive is not at the head of any list, so it is never offered.

Public key authentication is the best worked example, because it crosses twice.

sequenceDiagram
    participant C as client
    participant A as sshd-auth<br/>(unprivileged)
    participant M as monitor<br/>(privileged)
    C->>A: USERAUTH_REQUEST publickey, no signature (probe)
    A->>M: MONITOR_REQ_KEYALLOWED
    Note over M: user_key_allowed() reads ~/.ssh/authorized_keys<br/>records the key as "allowed"
    M-->>A: yes / no
    A->>C: USERAUTH_PK_OK
    C->>A: USERAUTH_REQUEST publickey, with signature
    A->>M: MONITOR_REQ_KEYVERIFY (key, signature, signed data)
    Note over M: monitor_valid_userblob() — is the signed data<br/>really a userauth request for THIS session?
    Note over M: then sshkey_verify()
    M-->>A: verified → the monitor now considers the user authenticated

The second step is the one that carries the security argument. mm_answer_keyverify does not simply verify a signature over whatever bytes it was handed. It first calls monitor_valid_userblob, which parses the signed data and checks that:

  • it begins with this connection’s session_id, compared with timingsafe_bcmp;
  • the message type is SSH2_MSG_USERAUTH_REQUEST;
  • the username matches the one already agreed with the monitor;
  • the method is publickey (or the hostbound variant);
  • for hostbound requests, the embedded host key is genuinely one of ours;
  • and there are no trailing bytes.

It also refuses any key that was not previously approved by a KEYALLOWED request (monitor_allowed_key). A compromised sshd-auth therefore cannot ask the monitor to verify an arbitrary signature and have it count as a login: the only signatures the monitor will accept are ones over a well-formed authentication request, for the right user, bound to the current session.

This is the part that surprises people. monitor_child_preauth (monitor.c:266) is not a passive server. It loops until a request handler returns 1, and it independently:

  • enforces MON_AUTHDECIDE — only certain requests are allowed to be the one that authenticates, and anything else claiming success is fatal();
  • runs its own auth2_update_methods_lists for multi-factor;
  • applies its own auth_root_allowed check;
  • disables MON_ONCE requests after a single use, so PWNAM or KEYVERIFY cannot be replayed.

So the same authentication decision is tracked twice, in two processes, and the privileged copy is the one that counts. The unprivileged child’s opinion only determines what it says on the wire.

The permitted request set is the table at monitor.c:182, and the flags on each entry (monitor.c:169) are the state machine:

FlagMeaning
MON_ISAUTHPart of an authentication attempt
MON_AUTHDECIDEMay decide authentication
MON_ONCEPermitted exactly once, then disabled
MON_ALOGLog the attempt without authenticating
MON_PERMITCurrently allowed — cleared and set as the connection progresses

Constant-ish failure timing. Every failed attempt is padded to a minimum duration by auth_failure_delay. The delay is not a constant: user_specific_delay hashes a per-server timing_secret with the username to derive 0–4.2 ms of jitter on top of MIN_FAIL_DELAY_SECONDS. A fixed delay would still leak, because the work done for a valid user differs; a per-username delay that an attacker cannot predict removes the signal instead of just burying it.

Login grace time. sshd-session arms a SIGALRM before forking (sshd-session.c:1211) with up to four seconds of random jitter added, so an unauthenticated connection cannot be held open indefinitely, and the deadline itself is not a clean oracle.

Logging happens in the monitor. auth_log opens with:

if (!mm_is_monitor() && !authctxt->postponed)
return;

The process an attacker might compromise does not write your authentication log. What lands in /var/log/auth.log is the privileged process’s account of events.

  1. do_authentication2 and input_service_request — six minutes, and the dispatch machinery will look familiar from the client.
  2. input_userauth_request then userauth_finish — read these as a pair; together they are the whole policy.
  3. One method end to end: auth2-passwd.c is 80 lines and crosses the privilege boundary exactly once.
  4. monitor_child_preauth and the dispatch table above it.
  5. auth2-pubkey.c with monitor_valid_userblob open beside it.

To watch it happen, run a server in the foreground and connect to it — the trace shows both processes, including the transition into the unprivileged child:

Terminal window
/usr/sbin/sshd -ddd -p 2222

See also client authentication for what the methods are, and connection workflows for how the processes get created in the first place.