From 8c3640db443f9f4d62697bdedba64abc0cc4ccaa Mon Sep 17 00:00:00 2001 From: John Safranek Date: Mon, 19 Oct 2026 13:49:08 -0700 Subject: [PATCH] wolfsshd: keep accepting after a failed fork StartSSHD() no longer stores the NewConnection() result in ret, the accept loop's condition. A failed fork() or CreateThread() now drops that one connection, and the daemon goes on listening. - add sshd_fork_fail_test.sh with an LD_PRELOAD shim that fails the first connection's fork() with EAGAIN - run it from run_all_sshd_tests.sh on leased port +7 Issue: F-16052 --- .gitignore | 2 + apps/wolfsshd/test/run_all_sshd_tests.sh | 8 +- apps/wolfsshd/test/sshd_fork_fail_test.sh | 171 ++++++++++++++++++++++ apps/wolfsshd/test/sshd_fork_preload.c | 72 +++++++++ apps/wolfsshd/wolfsshd.c | 6 +- 5 files changed, 256 insertions(+), 3 deletions(-) create mode 100755 apps/wolfsshd/test/sshd_fork_fail_test.sh create mode 100644 apps/wolfsshd/test/sshd_fork_preload.c diff --git a/.gitignore b/.gitignore index 7bc2eaf7..359e01cf 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ apps/wolfsshd/wolfsshd apps/wolfsshd/test/test_configuration apps/wolfsshd/test/sshd_privdrop_preload.so apps/wolfsshd/test/privdrop_client_*.log +apps/wolfsshd/test/sshd_fork_preload.so +apps/wolfsshd/test/fork_client_*.log apps/wolfsshd/test/log.txt apps/wolfsshd/test/sshd_config_* apps/wolfsshd/test/authorized_keys_test diff --git a/apps/wolfsshd/test/run_all_sshd_tests.sh b/apps/wolfsshd/test/run_all_sshd_tests.sh index af2439d3..b419c276 100755 --- a/apps/wolfsshd/test/run_all_sshd_tests.sh +++ b/apps/wolfsshd/test/run_all_sshd_tests.sh @@ -47,6 +47,7 @@ extra_test_cases=( "sshd_login_grace_test.sh" "sshd_privdrop_fail_test.sh" "sshd_chroot_fail_test.sh" + "sshd_fork_fail_test.sh" "sshd_x509_test.sh" "sshd_x509_upn_fail.sh" "sshd_mldsa_composite_test.sh" @@ -127,7 +128,10 @@ done # +0 shared wolfSSHd (TEST_PORT) +3 host key ownership/symlink gate # +1 StrictModes negative test +4 OpenSSH certificate test # +2 AuthorizedUPNDomains negative +5 privilege-drop test -# +6 chroot skip-after-failure test +# +6 chroot skip-after-failure test +7 fork failure test +# +# That fills the PORT_BLOCK_SIZE (8) ports of a block; another private-daemon +# port needs a larger PORT_BLOCK_SIZE in port_lease.sh. # # The range deliberately starts above everything else in the repo that binds # a port: CI steps bind 22222, 22225 and 22226, and scripts/fwd-bulk.test @@ -165,6 +169,7 @@ HOSTKEY_PERM_PORT=$((PORT_BASE + 3)) export WOLFSSHD_TEST_PORT=$((PORT_BASE + 4)) export WOLFSSHD_PRIVDROP_PORT=$((PORT_BASE + 5)) export WOLFSSHD_CHROOT_PORT=$((PORT_BASE + 6)) +export WOLFSSHD_FORK_PORT=$((PORT_BASE + 7)) # Registry of the daemons started during this run, appended to by # start_wolfsshd in every test script that sources start_sshd.sh. The exit @@ -766,6 +771,7 @@ local_sshd_tests=( "sshd_login_grace_test.sh" "sshd_privdrop_fail_test.sh" "sshd_chroot_fail_test.sh" + "sshd_fork_fail_test.sh" ) if [ "$USING_LOCAL_HOST" == 1 ]; then for test in "${local_sshd_tests[@]}"; do diff --git a/apps/wolfsshd/test/sshd_fork_fail_test.sh b/apps/wolfsshd/test/sshd_fork_fail_test.sh new file mode 100755 index 00000000..050295ee --- /dev/null +++ b/apps/wolfsshd/test/sshd_fork_fail_test.sh @@ -0,0 +1,171 @@ +#!/bin/bash + +# Accept-loop regression: a fork() that fails for one connection must drop that +# connection only. The daemon used to store the failure in its accept loop's +# control variable and shut down, so one EAGAIN at a process limit stopped it +# listening for good. The failure is injected against the stock wolfsshd with +# an LD_PRELOAD interposer (sshd_fork_preload.c) that fails the first +# connection's fork() once. + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "expecting host and the runner's shared port as arguments;" + echo "this test binds WOLFSSHD_FORK_PORT, or that port + 7" + echo "./sshd_fork_fail_test.sh 127.0.0.1 22300" + exit 1 +fi + +# Not PWD: that is bash's own variable and the shell rewrites it on every cd, +# so a saved copy is gone by the time it is read. +TESTDIR=`pwd` +USER=`whoami` +TEST_HOST="$1" + +# Own daemon on a port leased by the runner, so the isolation holds against +# another run of the suite on the same host. Standalone, fall back to an +# offset from the port passed in. +TEST_PORT="${WOLFSSHD_FORK_PORT:-$(( $2 + 7 ))}" + +SSHD_BIN="../wolfsshd" +if [ ! -x "$SSHD_BIN" ]; then + echo "SKIP: $SSHD_BIN not built" + exit 77 +fi + +# macOS strips DYLD_INSERT_LIBRARIES from the sudo-launched daemon, so Linux only. +if [ "`uname -s`" != "Linux" ]; then + echo "SKIP: fork fault injection needs Linux LD_PRELOAD" + exit 77 +fi + +# start_sshd.sh word-splits SSHD_ENV to build "sudo env NAME=VALUE ...", so a +# preload path holding whitespace would be torn in half. +case "$TESTDIR" in + *[[:space:]]*) + echo "SKIP: test directory path contains whitespace" + exit 77 + ;; +esac + +ROOT="$TESTDIR/../../.." +TEST_CLIENT="$ROOT/examples/client/client" +if [ ! -x "$TEST_CLIENT" ]; then + echo "SKIP: $TEST_CLIENT not built" + exit 77 +fi + +# Absolute: the example client calls ChangeToWolfSshRoot() before parsing +# anything, so a relative path would be resolved from the repository root. +PRIVATE_KEY="$ROOT/keys/hansel-key-ecc.der" +PUBLIC_KEY="$ROOT/keys/hansel-key-ecc.pub" + +source ./start_sshd.sh + +# Teardown on every exit path; log.txt and the client logs are kept for +# debugging like the other tests. +cleanup() { + stop_wolfsshd + rm -f sshd_config_test_fork "$PRELOAD_LIB" + return 0 +} +trap cleanup EXIT + +# Build the interposer next to this script; skip if no compiler. Relative "./" +# path so the LD_PRELOAD value has no space (see SSHD_ENV in start_sshd.sh). +PRELOAD_SRC="sshd_fork_preload.c" +PRELOAD_LIB="./sshd_fork_preload.so" +CC_BIN="${CC:-cc}" +if ! "$CC_BIN" -shared -fPIC -o "$PRELOAD_LIB" "$PRELOAD_SRC" -ldl 2>/dev/null; then + echo "SKIP: could not build $PRELOAD_LIB with $CC_BIN" + exit 77 +fi + +# The runner creates this in its setup step; a standalone run does not get that. +if [ ! -f authorized_keys_test ]; then + ./create_authorized_test_file.sh || exit 1 +fi + +touch log.txt 2>/dev/null + +cat < sshd_config_test_fork +Port $TEST_PORT +Protocol 2 +LoginGraceTime 600 +PermitRootLogin yes +PasswordAuthentication yes +PermitEmptyPasswords no +UsePrivilegeSeparation no +UseDNS no +HostKey $ROOT/keys/server-key.pem +AuthorizedKeysFile $TESTDIR/authorized_keys_test +EOF + +# Read through sudo: the daemon runs under sudo, so log.txt may be root-owned. +log_count() { + local n + n=`sudo grep -c "$1" log.txt 2>/dev/null` + echo "${n:-0}" +} + +DEADLINE=30 +TIMEOUT="" +if command -v timeout >/dev/null 2>&1; then + TIMEOUT="timeout $DEADLINE" +fi + +# Runs one exec session; output in $CLIENT_LOG, exit status in $CLIENT_RET. +run_client() { + CLIENT_LOG="$TESTDIR/fork_client_$1.log" + $TIMEOUT "$TEST_CLIENT" -q -c "echo $TOKEN" -u "$USER" -i "$PRIVATE_KEY" \ + -j "$PUBLIC_KEY" -h "$TEST_HOST" -p "$TEST_PORT" \ + > "$CLIENT_LOG" 2>&1 + CLIENT_RET=$? +} + +client_said() { + echo " client said: `tail -n 3 "$CLIENT_LOG" | tr '\n' '|'`" + echo " full client output in $CLIENT_LOG" +} + +# Echoed by the exec session. Present only when a shell actually ran. +TOKEN="wolfsshd_fork_ok_$$" + +BEFORE_FORK_FAIL=`log_count "Issue spawning new process"` + +SSHD_ENV="LD_PRELOAD=$PRELOAD_LIB WOLFSSHD_FAULT_FORK=1" +export SSHD_BIN SSHD_ENV +start_wolfsshd "sshd_config_test_fork" +if [ -z "$PID" ]; then + echo "FAIL: fork-fault daemon did not start" + exit 1 +fi + +# First connection: its fork() is failed, so the daemon drops it. The client's +# own result does not matter, only that the daemon reached the failure. +run_client "faulted" +AFTER_FORK_FAIL=`log_count "Issue spawning new process"` +if [ "$AFTER_FORK_FAIL" -le "$BEFORE_FORK_FAIL" ]; then + echo "FAIL: injected fork() failure never reached the daemon" + client_said + exit 1 +fi + +# The daemon must still be up. Give a daemon that is on its way out the time to +# finish exiting, so this does not pass on a race. +sleep 1 +if ! wolfsshd_alive "$PID"; then + echo "FAIL: daemon exited after one connection's fork() failed" + exit 1 +fi + +# Second connection: the interposer forwards this fork(), so the session must +# run end to end. +run_client "after" +if [ "$CLIENT_RET" -ne 0 ] || ! grep -q "$TOKEN" "$CLIENT_LOG"; then + echo "FAIL: daemon did not serve a connection after a failed fork()" + client_said + exit 1 +fi + +rm -f "$TESTDIR"/fork_client_*.log +echo "PASS: daemon keeps accepting after a failed fork()" +exit 0 diff --git a/apps/wolfsshd/test/sshd_fork_preload.c b/apps/wolfsshd/test/sshd_fork_preload.c new file mode 100644 index 00000000..9e5f2188 --- /dev/null +++ b/apps/wolfsshd/test/sshd_fork_preload.c @@ -0,0 +1,72 @@ +/* sshd_fork_preload.c + * + * Copyright (C) 2014-2026 wolfSSL Inc. + * + * This file is part of wolfSSH. + * + * wolfSSH is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * wolfSSH is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with wolfSSH. If not, see . + */ + +/* LD_PRELOAD interposer for sshd_fork_fail_test.sh only. */ +/* When WOLFSSHD_FAULT_FORK is set, the first fork() after the first accept() + * returns EAGAIN, as it does at a process limit. Keyed on accept() so the two + * forks made while daemonizing are left alone. One shot: every later fork, + * including those in connection children, is forwarded. */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +static int wsshd_accepted = 0; +static int wsshd_failed = 0; + +int accept(int fd, struct sockaddr* addr, socklen_t* addrLen) +{ + int (*real)(int, struct sockaddr*, socklen_t*); + int ret; + + real = (int (*)(int, struct sockaddr*, socklen_t*)) + dlsym(RTLD_NEXT, "accept"); + if (real == NULL) { + errno = ENOSYS; + return -1; + } + ret = real(fd, addr, addrLen); + if (ret >= 0) { + wsshd_accepted++; + } + return ret; +} + +pid_t fork(void) +{ + pid_t (*real)(void); + + if (getenv("WOLFSSHD_FAULT_FORK") != NULL && wsshd_accepted == 1 + && !wsshd_failed) { + wsshd_failed = 1; + errno = EAGAIN; + return -1; + } + real = (pid_t (*)(void))dlsym(RTLD_NEXT, "fork"); + if (real == NULL) { + errno = ENOSYS; + return -1; + } + return real(); +} diff --git a/apps/wolfsshd/wolfsshd.c b/apps/wolfsshd/wolfsshd.c index c2f03368..e86b3924 100644 --- a/apps/wolfsshd/wolfsshd.c +++ b/apps/wolfsshd/wolfsshd.c @@ -4818,8 +4818,10 @@ static int StartSSHD(int argc, char** argv) conn->auth = connAuth; } #endif - ret = NewConnection(conn); - if (ret != WS_SUCCESS) { + /* Not assigned to ret: a failed fork() or CreateThread() + * belongs to this connection only, and ret is the loop + * condition, so it would stop the daemon accepting. */ + if (NewConnection(conn) != WS_SUCCESS) { /* on failure the connection was not handed off, it is still owned here and needs cleaned up */ WCLOSESOCKET(conn->fd);