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.
Where it runs
Section titled “Where it runs”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) | |
|---|---|---|
| Files | auth2.c, auth2-*.c, monitor_wrap.c | monitor.c |
| Sees | Every packet from the client | Only structured requests from its child |
| Can | Parse, decide locally, ask | Read /etc/shadow, sign with the host key, read authorized_keys, run PAM |
| Decides authentication | No — it only thinks it does | Yes |
The protocol loop
Section titled “The protocol loop”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 FAILUREinput_service_request denies every service except ssh-userauth — the
comment /* XXX all other service requests are denied */ is the whole service
layer.
One request, start to finish
Section titled “One request, start to finish”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.
The decision point
Section titled “The decision point”userauth_finish is where a yes becomes a
USERAUTH_SUCCESS, and it is worth reading line by line because several
policies land only here.
| Step | Effect |
|---|---|
| Root check | auth_root_allowed can turn an authenticated root login back into a failure — this is PermitRootLogin, applied after the method succeeded |
| Multi-factor | auth2_update_methods_lists can turn success into partial |
auth_log | Logged before the reply is sent |
| Success | Registers dispatch_protocol_ignore for further USERAUTH_REQUESTs and sets authctxt->success, breaking the loop |
| Failure | Sends 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.
The method table
Section titled “The method table”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:
synonymcarriespublickey-hostbound-v00@openssh.com, so the extension resolves to the same method.userauth_finishnormalises back tom->cfg->namebefore logging, which is why logs saypublickeyregardless.none_enabledis a plain global thatuserauth_noneclears after its single permitted use.
Multi-factor: lists, not counters
Section titled “Multi-factor: lists, not counters”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.
Crossing the privilege boundary
Section titled “Crossing the privilege boundary”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 withtimingsafe_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.
The monitor keeps its own state machine
Section titled “The monitor keeps its own state machine”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 isfatal(); - runs its own
auth2_update_methods_listsfor multi-factor; - applies its own
auth_root_allowedcheck; - disables
MON_ONCErequests after a single use, soPWNAMorKEYVERIFYcannot 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:
| Flag | Meaning |
|---|---|
MON_ISAUTH | Part of an authentication attempt |
MON_AUTHDECIDE | May decide authentication |
MON_ONCE | Permitted exactly once, then disabled |
MON_ALOG | Log the attempt without authenticating |
MON_PERMIT | Currently allowed — cleared and set as the connection progresses |
Defences that are not in the protocol
Section titled “Defences that are not in the protocol”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.
Reading order
Section titled “Reading order”do_authentication2andinput_service_request— six minutes, and the dispatch machinery will look familiar from the client.input_userauth_requestthenuserauth_finish— read these as a pair; together they are the whole policy.- One method end to end:
auth2-passwd.cis 80 lines and crosses the privilege boundary exactly once. monitor_child_preauthand the dispatch table above it.auth2-pubkey.cwithmonitor_valid_userblobopen 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:
/usr/sbin/sshd -ddd -p 2222See also client authentication for what the methods are, and connection workflows for how the processes get created in the first place.