diff --git a/.gitignore b/.gitignore index 7bc2eaf72..359e01cf5 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 47ea02274..d66590c77 100755 --- a/apps/wolfsshd/test/run_all_sshd_tests.sh +++ b/apps/wolfsshd/test/run_all_sshd_tests.sh @@ -46,6 +46,8 @@ extra_test_cases=( "sshd_login_grace_test.sh" "sshd_privdrop_fail_test.sh" "sshd_chroot_fail_test.sh" + "sshd_fork_fail_test.sh" + "sshd_exit_status_test.sh" "sshd_x509_test.sh" "sshd_x509_upn_fail.sh" "sshd_mldsa_composite_test.sh" @@ -126,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 @@ -164,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 @@ -764,6 +770,8 @@ local_sshd_tests=( "sshd_login_grace_test.sh" "sshd_privdrop_fail_test.sh" "sshd_chroot_fail_test.sh" + "sshd_fork_fail_test.sh" + "sshd_exit_status_test.sh" ) if [ "$USING_LOCAL_HOST" == 1 ]; then for test in "${local_sshd_tests[@]}"; do diff --git a/apps/wolfsshd/test/sshd_exit_status_test.sh b/apps/wolfsshd/test/sshd_exit_status_test.sh new file mode 100755 index 000000000..2ff036c92 --- /dev/null +++ b/apps/wolfsshd/test/sshd_exit_status_test.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# Exit-status regression: the POSIX daemon used to end StartSSHD() with an +# unconditional "return 0", so a config check (-t) of a bad config, or any +# other startup failure, exited 0. Every case runs in -D -t mode, so nothing +# daemonizes or binds a port. + +TESTDIR=`pwd` +ROOT="$TESTDIR/../../.." + +SSHD_BIN="../wolfsshd" +if [ ! -x "$SSHD_BIN" ]; then + echo "SKIP: $SSHD_BIN not built" + exit 77 +fi + +cleanup() { + rm -f sshd_config_test_exit exit_status_hostkey.pem + return 0 +} +trap cleanup EXIT + +# The host key gate wants an owner-only file; the checked-in key is 0644. +cp "$ROOT/keys/server-key.pem" exit_status_hostkey.pem +chmod 600 exit_status_hostkey.pem + +# $1: host key path +write_config() { + cat < sshd_config_test_exit +Protocol 2 +UsePrivilegeSeparation no +UseDNS no +HostKey $1 +EOF +} + +# $1: expected status, "zero" or "nonzero"; $2: description; rest: arguments +expect_status() { + local want="$1" what="$2" ret + shift 2 + "$SSHD_BIN" -D -t -E /dev/null "$@" < /dev/null > /dev/null 2>&1 + ret=$? + if [ "$want" = "zero" ] && [ "$ret" -ne 0 ]; then + echo "FAIL: $what exited $ret, expected 0" + exit 1 + fi + if [ "$want" = "nonzero" ] && [ "$ret" -eq 0 ]; then + echo "FAIL: $what exited 0" + exit 1 + fi + echo "ok: $what exited $ret" +} + +write_config "$TESTDIR/exit_status_hostkey.pem" +expect_status zero "valid config" -f sshd_config_test_exit +expect_status nonzero "missing config file" -f "$TESTDIR/no_such_sshd_config" +expect_status nonzero "invalid -p" -f sshd_config_test_exit -p 70000 + +write_config "$TESTDIR/no_such_hostkey.pem" +expect_status nonzero "missing host key" -f sshd_config_test_exit + +echo "PASS: wolfsshd exit status reflects startup failures" +exit 0 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 000000000..050295ee2 --- /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 000000000..9e5f21883 --- /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 55ad3507b..992d33488 100644 --- a/apps/wolfsshd/wolfsshd.c +++ b/apps/wolfsshd/wolfsshd.c @@ -4202,7 +4202,8 @@ static int NewConnection(WOLFSSHD_CONNECTION* conn) } -/* return non zero value for a pending connection */ +/* return positive for a pending connection, 0 for none, negative on a + * select() failure the accept loop cannot recover from */ static int PendingConnection(WS_SOCKET_T fd) { int ret; @@ -4224,7 +4225,7 @@ static int PendingConnection(WS_SOCKET_T fd) /* a socket level issue happened, could just be a system call int. */ if (errno != EINTR) { wolfSSH_Log(WS_LOG_ERROR, "[SSHD] TCP socket error on select()"); - quit = 1; + return WS_FATAL_ERROR; } ret = 0; } @@ -4716,6 +4717,7 @@ static int StartSSHD(int argc, char** argv) /* wait for incoming connections and fork them off */ while (ret == WS_SUCCESS && quit == 0) { WOLFSSHD_CONNECTION* conn; + int pending; #ifdef WOLFSSL_NUCLEUS struct addr_struct clientAddr; #else @@ -4727,6 +4729,7 @@ static int StartSSHD(int argc, char** argv) if (conn == NULL) { wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Failed to malloc memory for connection"); + ret = WS_MEMORY_E; break; } WMEMSET(conn, 0, sizeof(WOLFSSHD_CONNECTION)); @@ -4740,7 +4743,8 @@ static int StartSSHD(int argc, char** argv) #endif /* wait for a connection */ - if (PendingConnection(listenFd)) { + pending = PendingConnection(listenFd); + if (pending > 0) { conn->ctx = ctx; #ifdef WOLFSSL_NUCLEUS conn->fd = NU_Accept(listenFd, &clientAddr, 0); @@ -4809,8 +4813,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); @@ -4821,6 +4827,9 @@ static int StartSSHD(int argc, char** argv) } } else { + if (pending < 0) { + ret = WS_FATAL_ERROR; + } WFREE(conn, NULL, DYNTYPE_SSHD); } #ifdef _WIN32 @@ -4874,7 +4883,9 @@ static int StartSSHD(int argc, char** argv) * failure, and argc tracks argv's length once it is allocated. */ _freeWinArgs(argv, argc, cmdArgs); #else - return 0; + /* main() returns this, so a failed startup, config check (-t) or + * listen loop must not look like a clean exit to the caller. */ + return (ret == WS_SUCCESS) ? EXIT_SUCCESS : EXIT_FAILURE; #endif }