Skip to content

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.

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.

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
BinaryBuilt fromRuns asDoes
sshdSSHDOBJSrootBinds the port, accepts, re-execs. Nothing else
sshd-sessionSSHD_SESSION_OBJSroot, then the userLoads 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-authSSHD_AUTH_OBJSsshd user, chrootedEverything 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.

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:

RequestHandlerWhy it must be privileged
MONITOR_REQ_SIGNmm_answer_signSigning the exchange hash with the host private key
MONITOR_REQ_PWNAMmm_answer_pwnamallowgetpwnam plus the AllowUsers/DenyUsers decision
MONITOR_REQ_AUTHPASSWORDmm_answer_authpasswordReading the shadow password file
MONITOR_REQ_KEYALLOWEDmm_answer_keyallowedReading the user’s authorized_keys
MONITOR_REQ_KEYVERIFYmm_answer_keyverifyVerifying 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.

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:170 runs the known_hosts decision 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 H covers everything. Both version strings, both KEXINIT payloads, the host key, both public values and the shared secret. A downgrade attempt changes H, so the signature fails.
  • The first H becomes session_id and 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.

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.

Once authenticated, both ends run the same channel multiplexer (channels.c) over one TCP connection:

  1. The client opens a session channel and sends requests on it — pty-req, env, then shell or exec or subsystem sftp.
  2. Forwardings become their own channel types: a -L listener accepts locally and opens a direct-tcpip channel per connection; -R asks the server to listen; -D runs a SOCKS proxy as SSH_CHANNEL_DYNAMIC.
  3. Agent and X11 forwarding are channels too — SSH_CHANNEL_AUTH_SOCKET and SSH_CHANNEL_X11_LISTENER.
  4. 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.

The most efficient way to learn any of this is to watch both ends at once. In one terminal:

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

In another:

Terminal window
ssh -vvv -p 2222 localhost true

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