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
pull/1352/head
John Safranek 2026-10-19 13:49:08 -07:00 committed by Paul Adelsbach
parent 9281b03898
commit 8c3640db44
5 changed files with 256 additions and 3 deletions

2
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -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 <<EOF > 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

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
/* 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 <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>
#include <dlfcn.h>
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();
}

View File

@ -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);