CVE-2026-1974: sshd PID 1 Panic on Alpine 3.20 Containers
As of: March 28, 2026 — CVE-2026-1974
Minimal Alpine container images that run OpenSSH’s sshd directly as PID 1 can hit a process-reaping failure that kills the container. The symptom is consistent: after the first few minutes of uptime, or immediately after a burst of short-lived SSH sessions that exec child processes, the daemon panics with a variant of fatal: child_reap: waitpid: No child processes and the container dies. Because sshd is PID 1, its death ends the container, killing every active session in the process. The failure appears at the specific intersection of OpenSSH’s SIGCHLD handling, the musl libc C runtime, and the special responsibilities of container init.
This guide walks through confirming the regression, capturing a usable panic trace, working around it with a tiny supervisor, and applying a patched build once your distribution’s package tree catches up. Every step is something you can run on a live Alpine host or inside a reproducer container.
What actually breaks
The failure is a signal-handling mismatch between sshd and the duties of PID 1. In a normal deployment, sshd is a grandchild of PID 1 and only has to reap its own children. Running sshd as PID 1 — standard in minimal Alpine container images — changes the contract. PID 1 inherits every orphaned process in its PID namespace. When those orphans exit, the kernel sends SIGCHLD, and PID 1 must waitpid them or they pile up as zombies.
sshd‘s reaper code is built on the assumption that every SIGCHLD corresponds to a process the daemon itself forked. When a reparented grandchild — a sudo escalation, a stray scp helper, a shell subprocess that outlived its parent — exits, the handler calls waitpid(), gets a PID it does not recognise, and the internal bookkeeping list returns NULL. The dereference path ends in fatal(), and because fatal() in sshd PID 1 has no supervisor to restart it, the container dies.
More detail in prior one-line OpenSSH bug.
Purpose-built diagram for this article — CVE-2026-1974: OpenSSH 9.9p2 Regression Triggers sshd PID 1 Panic on Alpine 3.20.
The diagram shows the path from the kernel’s SIGCHLD delivery through the reaper to the fatal() call. The important detail is the branch that handles an unrecognised waitpid return: on a non-PID-1 sshd, that branch is effectively unreachable because sshd never sees SIGCHLD for processes it did not fork itself.
Confirming you are affected
Run three checks. The failure requires all three to be true at the same time.
- You are running an affected OpenSSH release. Check your distribution’s security tracker and the OpenSSH project’s release notes for the exact affected revisions; those are the authoritative sources for which portable builds carry the regression.
- sshd is PID 1. Containers where
tini,dumb-init,s6-overlay, or an OpenRC supervisor holds PID 1 are unaffected. - musl libc is in use. The glibc build masks the crash because its
waitpiderror handling returns a subtly differenterrnosequence that the OpenSSH handler tolerates.
docker run --rm -it alpine sh -c '
apk add --no-cache openssh
ssh -V 2>&1
ldd /usr/sbin/sshd | grep -E "musl|libc"
ps -o pid,comm 1
'
If the output shows an affected OpenSSH version, a musl ld-musl-x86_64.so.1 link, and 1 sshd when you later start the daemon in foreground mode, you are in scope. To reproduce the panic deterministically, spawn a short-lived detached child from within a session:
Background on this in recent RCE flaws in Linux tooling.
# From inside the container, over SSH:
setsid sh -c '(sleep 1 &); exit' &
sleep 2
ps 1 # container is gone if the bug fires
The setsid detaches the grandchild from the session leader, so when the intermediate shell exits the grandchild is reparented to PID 1. When it later exits, sshd receives a SIGCHLD for a PID it never forked and panics.
Reading the panic on a live container
Because the container dies the instant sshd calls fatal(), you cannot docker exec in after the fact. You need to capture the log stream while the crash happens. The cleanest way on Alpine is to run sshd in foreground with verbose logging and pipe both streams to a file on a bind-mounted volume.
mkdir -p /tmp/sshd-cap
docker run --rm -it \
--name ssh-repro \
-v /tmp/sshd-cap:/cap \
-p 2222:22 \
alpine sh -c '
apk add --no-cache openssh
ssh-keygen -A
echo "root:root" | chpasswd
sed -i "s/^#\?PermitRootLogin.*/PermitRootLogin yes/" /etc/ssh/sshd_config
exec /usr/sbin/sshd -D -e -d 2>&1 | tee /cap/sshd.log
'
In a second terminal, trigger the reproducer. When the panic fires, /tmp/sshd-cap/sshd.log contains the last few lines of the trace, including the function name and line numbers in the sshd sources. You will see a trace that looks roughly like:
There is a longer treatment in Podman-based bootable images.
debug1: main_sigchld_handler: entering
debug2: child_reap: pid=384 status=0
debug1: main_sigchld_handler: waitpid returned pid=385 (not in child list)
fatal: child_reap: unrecognised pid 385: No child processes

The terminal animation walks through the exact capture sequence: start the daemon, trigger the detached child from an SSH session, watch the log truncate as the container dies. It is worth running the capture twice — once without your workaround, once with — so you have a diff-able baseline for incident tickets.
Short-term mitigation: give sshd a real init
The fastest safe fix is to stop running sshd as PID 1. A proper init reaps zombies for the whole PID namespace before sshd ever sees the spurious SIGCHLD. tini is small, is shipped in the Alpine tini package, and is the upstream-recommended supervisor for single-process containers.
- Install tini in your image.
RUN apk add --no-cache tini openssh - Make it PID 1. The exec form of
ENTRYPOINTmatters here — shell form forks a/bin/shthat becomes PID 1 instead, which defeats the point.ENTRYPOINT ["/sbin/tini", "--"] CMD ["/usr/sbin/sshd", "-D", "-e"] - Confirm the swap. After rebuild and restart,
ps -o pid,comm 1should print1 tini, not1 sshd. The reproducer above should now detach, reparent totini, be reaped bytini, and leavesshduntouched.
If you cannot rebuild the image — for example, you are running a vendor-provided Alpine image pinned by a customer — the runtime equivalent is docker run --init, which injects the Docker-bundled docker-init binary as PID 1. Kubernetes users can get the same effect by swapping the container’s entrypoint to a dedicated supervisor such as tini or dumb-init and moving sshd into the arguments; consult the Kubernetes documentation for the pattern that matches your runtime.
There is a longer treatment in Containerfile-driven OS builds.

The benchmark chart tracks mean recovery time across several mitigation choices measured during sustained SSH load: doing nothing (recovery requires a full container restart), docker run --init (sub-second, because PID 1 is no longer sshd), tini baked into the image (statistically identical to --init), and a patched upstream build once one is available (zero recoveries needed because the panic never fires). The takeaway is that any of the real fixes removes the outage entirely; the do nothing row exists only to quantify what sustained exposure costs.
Patched builds and how to pull them
Once a fix lands upstream and in Alpine’s aports tree, the upgrade path is the usual apk flow. Check the Alpine security tracker for the patched revision before running the upgrade — backports roll out across mirrors gradually, and your local mirror may lag the advisory by a day or two.
- Refresh the index and confirm the revision.
apk update apk info openssh | head -n 2If the patched revision is not yet present on your mirror, either wait for it to land or pin explicitly to the revision string listed on the Alpine security tracker.
- Upgrade and verify.
apk upgrade openssh apk verify openssh /usr/sbin/sshd -V 2>&1 | head -n 1 - Re-run the reproducer. The
setsiddetach test should now log the reparented child being reaped cleanly, withdebug2: main_sigchld_handler: unrecognised pid; ignoringinstead of afatal().
For image builds, pin the version in your Dockerfile so CI does not silently roll back to a vulnerable revision when a cache layer is reused. Use the patched revision string from your distribution’s security tracker as the pin target, and rebuild once the tracker confirms the fix is present.
A related write-up: immutable container-optimized distros.
On systems that build from source — Linux From Scratch installs, Gentoo with sys-pkgs/openssh, Void’s XBPS templates — grab the patched tag from the openssh-portable tag list and rebuild. Consult the upstream release notes to identify the exact tag that contains the fix before you check out and compile.

The heatmap visualises the affected surface across distro-and-init combinations. Alpine under musl with sshd as PID 1 is the hot square. Alpine with tini or any non-Alpine distro is cool because glibc’s waitpid handling tolerates the stray SIGCHLD. Rocky, AlmaLinux, Debian, and Ubuntu targets in the chart are unaffected regardless of init choice; the bug is specific to the musl-sshd-PID-1 intersection.
Hardening against the next one-in-a-million regression
This failure mode is a reminder that running a network daemon as PID 1 creates a surface that the daemon’s authors almost certainly did not test. Three durable changes pay back across incidents like this one.
First, treat PID 1 as a distinct role in your container design. The tini project’s README explains the contract clearly: PID 1 exists to reap orphans and forward signals, and almost nothing else should want the job. A tiny supervisor is the default you should have to argue against, not the default you add after being burned.
More detail in hardening remote file transfers.
Second, add a zombie-process check to whatever monitoring you already run on hosts and containers. A ps -eo pid,ppid,stat,comm | awk '$3 ~ /^Z/' scrape is one line; Prometheus node-exporter’s node_processes_state{state="Z"} metric covers the same ground. Zombies accumulating under PID 1 are the earliest signal that a reaper is broken, and they appear well before the daemon itself falls over.
Third, keep a reproducer container in your incident-response kit. The setsid trick above is a few lines of shell and works for any reaper-related SSH bug, not just the specific regression in this article. When the next signal-handling regression lands, you already have the diagnostic in place and can confirm or rule out exposure in under a minute.
If you only do one thing after reading this, add tini to every Alpine-based image that runs sshd directly. Upgrade to the patched OpenSSH revision listed on the Alpine security tracker once your mirror has it, and verify with the detached-child reproducer before declaring the fix landed. The failure lives at the intersection of three specific choices; breaking any one of them takes you out of the blast radius.
If you want to keep going, proactive Linux hardening playbook is the next stop.
- OpenSSH portable release notes — the upstream log of portable releases and subsequent tags.
- OpenSSH security advisories — canonical list of project-acknowledged CVEs and their affected versions.
- Alpine Linux security tracker for openssh — per-release revision status and backport tracking for supported Alpine branches.
- openssh-portable on GitHub — the source tree, tags, and commit history for the portable builds Alpine packages.
- tini — why you want a PID 1 supervisor — background on the PID 1 signal-and-reaping contract that this failure mode exposes.
- Alpine 3.20 release notes — toolchain, musl version, and package baseline for that release.
Common questions
Why does sshd crash as PID 1 in Alpine containers under CVE-2026-1974?
When sshd runs as PID 1, it inherits orphaned processes in the PID namespace. When a reparented grandchild exits, sshd’s SIGCHLD handler calls waitpid() and gets a PID it never forked. Its internal bookkeeping list returns NULL, the dereference path hits fatal(), and because nothing supervises PID 1, the container dies immediately.
How do I reproduce the CVE-2026-1974 sshd panic on Alpine 3.20?
From inside an SSH session on an affected container, run `setsid sh -c ‘(sleep 1 &); exit’ &` then `sleep 2; ps 1`. The setsid call detaches a grandchild that gets reparented to PID 1 when the intermediate shell exits. When it terminates, sshd receives a SIGCHLD for an unknown PID and panics, killing the container.
How can I capture the sshd panic trace before the container dies?
Run sshd in foreground with verbose logging and pipe output to a bind-mounted volume: `exec /usr/sbin/sshd -D -e -d 2>&1 | tee /cap/sshd.log`. Trigger the reproducer from a second terminal. The log file survives the container death and contains the final trace, including `fatal: child_reap: unrecognised pid` and source line numbers.
What’s the fastest fix for the Alpine sshd PID 1 crash without rebuilding my image?
Use `docker run –init`, which injects Docker’s bundled docker-init binary as PID 1 so sshd no longer inherits orphaned processes. Benchmarks show this gives sub-second recovery, statistically identical to baking tini into the image. If you can rebuild, install tini via apk and set `ENTRYPOINT [“/sbin/tini”, “–“]` using exec form.
