Connection workflows
High-level shape of the things OpenSSH actually does, and which process or file does each part. Tree is OpenSSH 10.5p1, where the server is split across three binaries.
Every connection has the same four phases
Section titled “Every connection has the same four phases”flowchart LR
A["1. Version exchange<br/><i>plaintext banners</i>"] --> B["2. Key exchange<br/><i>everything after is encrypted</i>"]
B --> C["3. User authentication<br/><i>who are you?</i>"]
C --> D["4. Channels<br/><i>shell, forwards, sftp</i>"]
style B fill:#2d6a4f,color:#fff
style C fill:#2d6a4f,color:#fff
Phases 1 and 2 authenticate the server to the client and establish keys. Phase 3 authenticates the user to the server. Only phase 4 carries anything you would call payload. Rekeying re-runs phase 2 mid-connection without disturbing 3 or 4.
The client
Section titled “The client”One process, one long linear story:
flowchart TD
A["ssh.c main()<br/><i>parse args, read config, load keys</i>"] --> B["ssh_connect()<br/><i>sshconnect.c — TCP or ProxyCommand</i>"]
B --> C["ssh_login()<br/><i>sshconnect.c:1630</i>"]
C --> D["kex_exchange_identification()<br/><i>phase 1</i>"]
D --> E["ssh_kex2()<br/><i>sshconnect2.c:219 — phase 2</i>"]
E --> F["ssh_userauth2()<br/><i>sshconnect2.c:424 — phase 3</i>"]
F --> G["ssh_session2()<br/><i>ssh.c:2247 — opens the session channel</i>"]
G --> H["client_loop()<br/><i>clientloop.c:1453 — phase 4, poll() forever</i>"]
ssh_session2 is also where multiplexing is decided: it sets up forwardings,
and if ControlMaster is on it calls muxserver_listen so later ssh
invocations can borrow this connection instead of making their own.
client_loop is a plain poll() loop over the network socket plus every
channel’s file descriptors. It runs until the session channel closes.
The server: three binaries, four processes
Section titled “The server: three binaries, four processes”This surprises people who go looking for the connection-handling code in
sshd.c and find a listener.
flowchart TD
L["sshd<br/><i>listener, root, long-lived</i>"]
L -->|"fork + execv<br/>sshd.c:1903"| S["sshd-session<br/><i>root, one per connection</i>"]
S -->|"fork; child execs<br/>sshd-session.c:364"| A["sshd-auth<br/><i>unprivileged, chrooted</i><br/>runs phases 1–3 on the network"]
S -.->|"parent becomes the monitor<br/>monitor_child_preauth()"| M["monitor<br/><i>privileged</i>"]
M -->|"after auth: fork again<br/>privsep_postauth()"| U["session child<br/><i>privileges dropped to the user</i><br/>runs phase 4"]
style A fill:#7f2d2d,color:#fff
style U fill:#7f2d2d,color:#fff
| Binary | Built from | Runs as | Does |
|---|---|---|---|
sshd | SSHDOBJS | root | Binds the port, accepts, re-execs. Nothing else |
sshd-session | SSHD_SESSION_OBJS | root, then the user | Loads config and host keys, arms the login grace timer, then forks: the child becomes sshd-auth, the parent becomes the privileged monitor. After authentication it forks again to run the user’s session |
sshd-auth | SSHD_AUTH_OBJS | sshd user, chrooted | Everything the network touches before authentication succeeds: the version banner, the key exchange, and the authentication protocol |
The red boxes are the processes that touch attacker-controlled data. Neither runs as root.
Note where that boundary falls: sshd-auth does not merely handle
authentication, it does the banner exchange and key exchange too
(sshd-auth.c:735), after dropping privileges and
chrooting. The privileged monitor never parses a protocol packet — it signs the
exchange hash when asked and answers authentication questions. Every byte an
unauthenticated client sends is parsed by an unprivileged, chrooted process.
Privilege separation in practice
Section titled “Privilege separation in practice”The unprivileged pre-auth process cannot read /etc/shadow, cannot touch the
host private key, and cannot run PAM. When it needs one of those it sends a
request over a socket pair to the monitor, which decides whether the request is
allowed right now and does the work.
The permitted requests are a table at monitor.c:182:
| Request | Handler | Why it must be privileged |
|---|---|---|
MONITOR_REQ_SIGN | mm_answer_sign | Signing the exchange hash with the host private key |
MONITOR_REQ_PWNAM | mm_answer_pwnamallow | getpwnam plus the AllowUsers/DenyUsers decision |
MONITOR_REQ_AUTHPASSWORD | mm_answer_authpassword | Reading the shadow password file |
MONITOR_REQ_KEYALLOWED | mm_answer_keyallowed | Reading the user’s authorized_keys |
MONITOR_REQ_KEYVERIFY | mm_answer_keyverify | Verifying the signature that proves key possession |
MONITOR_REQ_PAM_* | mm_answer_pam_* | The whole PAM conversation |
Each entry carries flags — MON_ONCE, MON_AUTH, MON_AUTHDECIDE — that the
monitor enforces as a state machine. A compromised pre-auth process cannot ask
for KEYVERIFY twice, or ask for things out of order. That, not the chroot, is
the real security property: the attacker’s reachable code has a tiny, ordered
API into the privileged side.
After authentication succeeds, privsep_postauth
(sshd-session.c:372) forks once more. The child
drops to the authenticated user and runs the session; the parent stays
privileged only to do things like PTY allocation on request.
Key exchange, in order
Section titled “Key exchange, in order”sequenceDiagram
participant C as ssh
participant S as sshd-auth
C->>S: SSH-2.0-OpenSSH_10.5 (plaintext)
S->>C: SSH-2.0-OpenSSH_10.5 (plaintext)
C->>S: KEXINIT — my algorithm proposal
S->>C: KEXINIT — their proposal
Note over C,S: both sides pick the first mutual match, independently
C->>S: KEX_ECDH_INIT — client public value
S->>C: KEX_ECDH_REPLY — host key, server public value, signature over H
Note over C: kex_verify_host_key() → known_hosts check
Note over C: then sshkey_verify() over the exchange hash H
C->>S: NEWKEYS
S->>C: NEWKEYS
Note over C,S: everything from here is encrypted with keys derived from H
Three things worth holding on to:
- Trust is checked before the signature.
kexgen.c:170runs theknown_hostsdecision the moment the host key is parsed, before verifying the signature over the exchange hash. Verifying a signature from a key you were never going to accept would be wasted work. - The exchange hash
Hcovers everything. Both version strings, both KEXINIT payloads, the host key, both public values and the shared secret. A downgrade attempt changesH, so the signature fails. - The first
Hbecomessession_idand never changes. Authentication signatures are computed over it, binding them to this connection.
Rekeying re-runs the same exchange, driven by RekeyLimit and checked against
kex->initial_hostkey so the server cannot swap identity mid-connection.
Authentication
Section titled “Authentication”The client drives a request/failure loop against the server’s list of
acceptable methods; the server evaluates each attempt across the privilege
boundary described above. The methods themselves are covered in
client authentication, the client’s state machine in
reading sshconnect2.c, and the server’s — including
what crossing that boundary costs — in
server-side authentication.
The session phase
Section titled “The session phase”Once authenticated, both ends run the same channel multiplexer
(channels.c) over one TCP connection:
- The client opens a
sessionchannel and sends requests on it —pty-req,env, thenshellorexecorsubsystem sftp. - Forwardings become their own channel types: a
-Llistener accepts locally and opens adirect-tcpipchannel per connection;-Rasks the server to listen;-Druns a SOCKS proxy asSSH_CHANNEL_DYNAMIC. - Agent and X11 forwarding are channels too —
SSH_CHANNEL_AUTH_SOCKETandSSH_CHANNEL_X11_LISTENER. - Window-adjust messages provide per-channel flow control so one busy forward cannot starve your shell.
Server-side, the session channel’s requests land in
session.c, which is what actually allocates the PTY and
execs the login shell.
Reading a whole connection
Section titled “Reading a whole connection”The most efficient way to learn any of this is to watch both ends at once. In one terminal:
/usr/sbin/sshd -ddd -p 2222In another:
ssh -vvv -p 2222 localhost trueThe two traces interleave the same protocol from both sides, and nearly every
line is a debug() string you can grep for verbatim in the source. The
server’s trace also shows the process transitions — you will see the re-exec
into sshd-session and the fork into the unprivileged child as they happen.