Skip to content

Commit 7e41ce2

Browse files
ejohnstownphilljj
authored andcommitted
tests: cover the sshd chroot skip-after-failure
An LD_PRELOAD interposer fails one SetupChroot() step at a time and records each chroot() plus every chdir() made after one, so a step that ran when it should have been skipped shows up in the record. The daemon stops after the first failed step, and nothing asserted that. - three cases: chdir faulted, chroot faulted, and an unfaulted chroot into "/" that must still complete and run its command - keep the fault injection in the interposer, with no fault code in the daemon or the library - register it after the privilege-drop test, the other test that needs its own root-launched daemon - skip when the example client is not built, and set the EXIT trap before building the interposer so no exit path leaves it behind
1 parent bda30b7 commit 7e41ce2

3 files changed

Lines changed: 446 additions & 0 deletions

File tree

‎apps/wolfsshd/test/run_all_sshd_tests.sh‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ done
7777
# +0 shared wolfSSHd (TEST_PORT) +3 host key ownership/symlink gate
7878
# +1 StrictModes negative test +4 OpenSSH certificate test
7979
# +2 AuthorizedUPNDomains negative +5 privilege-drop test
80+
# +6 chroot skip-after-failure test
8081
#
8182
# The range deliberately starts above everything else in the repo that binds
8283
# a port: CI steps bind 22222, 22225 and 22226, and scripts/fwd-bulk.test
@@ -113,6 +114,7 @@ HOSTKEY_PERM_PORT=$((PORT_BASE + 3))
113114
# probed block even when --port moves the shared daemon off it.
114115
export WOLFSSHD_TEST_PORT=$((PORT_BASE + 4))
115116
export WOLFSSHD_PRIVDROP_PORT=$((PORT_BASE + 5))
117+
export WOLFSSHD_CHROOT_PORT=$((PORT_BASE + 6))
116118

117119
# Registry of the daemons started during this run, appended to by
118120
# start_wolfsshd in every test script that sources start_sshd.sh. The exit
@@ -652,6 +654,7 @@ else
652654
run_upn_unenforceable_negative_test
653655
run_test "sshd_login_grace_test.sh"
654656
run_test "sshd_privdrop_fail_test.sh"
657+
run_test "sshd_chroot_fail_test.sh"
655658
else
656659
printf "Skipping tests that need to setup local SSHD\n"
657660
SKIPPED=$((SKIPPED+12))
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
#!/bin/bash
2+
3+
# Fail-closed chroot regression for SetupChroot(): once one step of the chroot
4+
# sequence has failed, the steps after it must not run. chroot() after a failed
5+
# chdir() into the target would leave the session chrooted with a working
6+
# directory outside the new root; chdir("/") after a failed chroot() would move
7+
# the session somewhere it was never meant to be.
8+
#
9+
# The failures are injected against a stock wolfsshd with an LD_PRELOAD
10+
# interposer (sshd_chroot_preload.c), so no fault code lives in the daemon or
11+
# library. The same interposer records each chroot() and each chdir() made
12+
# after one, and that record is what the assertions read: a recorded call is a
13+
# step that ran.
14+
#
15+
# Drives SHELL_Subsystem, whose SetupChroot() failure is the one reported as
16+
# "Error setting chroot". SFTP and SCP reach the same helper, and the guard
17+
# under test is inside it.
18+
19+
if [ -z "$1" ] || [ -z "$2" ]; then
20+
echo "expecting host and port as arguments"
21+
echo "./sshd_chroot_fail_test.sh 127.0.0.1 22222"
22+
exit 1
23+
fi
24+
25+
# Not PWD: that is bash's own variable and the shell rewrites it on every cd,
26+
# so a saved copy is gone by the time it is read.
27+
TESTDIR=`pwd`
28+
USER=`whoami`
29+
TEST_HOST="$1"
30+
31+
# Own daemon on a port leased by the runner, so the isolation holds against
32+
# another run of the suite on the same host. Standalone, fall back to an
33+
# offset from the port passed in.
34+
TEST_PORT="${WOLFSSHD_CHROOT_PORT:-$(( $2 + 6 ))}"
35+
36+
SSHD_BIN="../wolfsshd"
37+
if [ ! -x "$SSHD_BIN" ]; then
38+
echo "SKIP: $SSHD_BIN not built"
39+
exit 77
40+
fi
41+
42+
# macOS strips DYLD_INSERT_LIBRARIES from the sudo-launched daemon, so Linux only.
43+
if [ "`uname -s`" != "Linux" ]; then
44+
echo "SKIP: chroot fault injection needs Linux LD_PRELOAD"
45+
exit 77
46+
fi
47+
48+
# chroot(2) needs root, and start_sshd.sh gets there with sudo. Without a usable
49+
# non-interactive sudo the healthy case could not chroot at all, so the run would
50+
# prove nothing.
51+
if [ "`id -u`" -ne 0 ] && ! sudo -n true 2>/dev/null; then
52+
echo "SKIP: chroot test needs root or non-interactive sudo"
53+
exit 77
54+
fi
55+
56+
# start_sshd.sh word-splits SSHD_ENV to build "sudo env NAME=VALUE ...", so a
57+
# fault path holding whitespace would be torn in half.
58+
case "$TESTDIR" in
59+
*[[:space:]]*)
60+
echo "SKIP: test directory path contains whitespace"
61+
exit 77
62+
;;
63+
esac
64+
65+
ROOT="$TESTDIR/../../.."
66+
TEST_CLIENT="$ROOT/examples/client/client"
67+
if [ ! -x "$TEST_CLIENT" ]; then
68+
echo "SKIP: $TEST_CLIENT not built"
69+
exit 77
70+
fi
71+
72+
# Absolute, and that matters. Unlike most of the sshd tests this one runs from
73+
# its own directory rather than the repository root, and the example clients
74+
# call ChangeToWolfSshRoot() before parsing anything, so every path they are
75+
# handed is resolved from the repository root instead of here.
76+
PRIVATE_KEY="$ROOT/keys/hansel-key-ecc.der"
77+
PUBLIC_KEY="$ROOT/keys/hansel-key-ecc.pub"
78+
79+
source ./start_sshd.sh
80+
81+
# Teardown on every exit path, set before anything below creates a file;
82+
# log.txt is kept for debugging like the other tests.
83+
cleanup() {
84+
# Unconditional: it also removes the temp dir a failed start leaves behind.
85+
stop_wolfsshd
86+
rm -f sshd_config_test_chroot sshd_config_test_chroot_ok "$PRELOAD_LIB"
87+
rm -f "$TESTDIR"/chroot_client_*.log
88+
sudo rm -f "$TRACE_CHDIR_FAIL" "$TRACE_CHROOT_FAIL" "$TRACE_OK"
89+
rmdir "$CHROOT_DIR" 2>/dev/null
90+
return 0
91+
}
92+
trap cleanup EXIT
93+
94+
# Build the interposer next to this script; skip if no compiler. Relative "./"
95+
# path so the LD_PRELOAD value has no space (see SSHD_ENV in start_sshd.sh).
96+
PRELOAD_SRC="sshd_chroot_preload.c"
97+
PRELOAD_LIB="./sshd_chroot_preload.so"
98+
CC_BIN="${CC:-cc}"
99+
if ! "$CC_BIN" -shared -fPIC -o "$PRELOAD_LIB" "$PRELOAD_SRC" -ldl 2>/dev/null; then
100+
echo "SKIP: could not build $PRELOAD_LIB with $CC_BIN"
101+
exit 77
102+
fi
103+
104+
# The runner creates this in its setup step; a standalone run does not get that.
105+
if [ ! -f authorized_keys_test ]; then
106+
./create_authorized_test_file.sh || exit 1
107+
fi
108+
109+
# start_sshd.sh hardcodes "-E ./log.txt", so the daemon appends here. Counts are
110+
# read as before/after deltas rather than wiping the file, which keeps whatever
111+
# the suite logged earlier in the run.
112+
touch log.txt 2>/dev/null
113+
114+
# The chroot target has to exist. The point of faulting one step at a time is
115+
# that the others would have succeeded, so a daemon missing the guard really
116+
# does take them rather than failing on its own.
117+
CHROOT_DIR="$TESTDIR/chroot_target"
118+
mkdir -p "$CHROOT_DIR"
119+
120+
# One record per case, so a case never reads a file left by another.
121+
TRACE_CHDIR_FAIL="$TESTDIR/chroot_trace_chdir_fail.txt"
122+
TRACE_CHROOT_FAIL="$TESTDIR/chroot_trace_chroot_fail.txt"
123+
TRACE_OK="$TESTDIR/chroot_trace_ok.txt"
124+
sudo rm -f "$TRACE_CHDIR_FAIL" "$TRACE_CHROOT_FAIL" "$TRACE_OK"
125+
126+
# Echoed by the exec session. Present only when a shell actually ran.
127+
TOKEN="wolfsshd_chroot_ok_$$"
128+
129+
cat <<EOF > sshd_config_test_chroot
130+
Port $TEST_PORT
131+
Protocol 2
132+
LoginGraceTime 600
133+
PermitRootLogin yes
134+
PasswordAuthentication yes
135+
PermitEmptyPasswords no
136+
UsePrivilegeSeparation no
137+
UseDNS no
138+
HostKey $ROOT/keys/server-key.pem
139+
AuthorizedKeysFile $TESTDIR/authorized_keys_test
140+
ChrootDirectory $CHROOT_DIR
141+
EOF
142+
143+
# Healthy control. "/" is the one chroot the daemon can complete and still find
144+
# a shell in, so that case asserts the guarded code left a working chroot
145+
# session working, not merely that chroot() was reached.
146+
sed "s|^ChrootDirectory .*|ChrootDirectory /|" \
147+
sshd_config_test_chroot > sshd_config_test_chroot_ok
148+
149+
DEADLINE=30
150+
151+
# grep -c prints 0 and exits 1 when there is no match, so the count is usable as
152+
# is. Do not add "|| echo 0": the fallback appends a second 0 and every later
153+
# use of the count becomes a syntax error. Default the empty case instead, which
154+
# is a missing or unreadable log.
155+
# Read through sudo: start_sshd.sh launches the daemon under sudo, so in a
156+
# suite run log.txt already exists owned by root, and the touch above only
157+
# wins when the file is absent. sshd_pubkey_reject_test.sh reads the same
158+
# file the same way.
159+
log_count() {
160+
local n
161+
n=`sudo grep -c "$1" log.txt 2>/dev/null`
162+
echo "${n:-0}"
163+
}
164+
165+
# Starts a daemon with $1 as its config and $2 as its SSHD_ENV, runs one exec
166+
# session through it, and stops it again. Leaves the client output in
167+
# $CLIENT_LOG and the per-case log deltas in NEW_SPAWN / NEW_CHDIR_FAIL /
168+
# NEW_CHROOT_FAIL / NEW_SETUP_FAIL.
169+
run_case() {
170+
CFG="$1"
171+
CASE_ENV="$2"
172+
LABEL="$3"
173+
174+
BEFORE_SPAWN=`log_count "Spawned new process"`
175+
BEFORE_CHDIR_FAIL=`log_count "chdir to chroot path failed"`
176+
BEFORE_CHROOT_FAIL=`log_count "chroot failed to path"`
177+
BEFORE_SETUP_FAIL=`log_count "Error setting chroot"`
178+
179+
SSHD_ENV="$CASE_ENV"
180+
export SSHD_BIN SSHD_ENV
181+
start_wolfsshd "$CFG"
182+
if [ -z "$PID" ]; then
183+
echo "FAIL: $LABEL daemon did not start"
184+
exit 1
185+
fi
186+
187+
CLIENT_LOG="$TESTDIR/chroot_client_$LABEL.log"
188+
TIMEOUT=""
189+
if command -v timeout >/dev/null 2>&1; then
190+
TIMEOUT="timeout $DEADLINE"
191+
fi
192+
# The exit status is not checked: under a fault the session is killed from
193+
# the far side, and what matters is what the daemon did, not how the client
194+
# reported it. The output is kept, because a client that dies before opening
195+
# a socket leaves the daemon-side counts flat and looks like a daemon fault.
196+
$TIMEOUT "$TEST_CLIENT" -c "echo $TOKEN" -u "$USER" -i "$PRIVATE_KEY" \
197+
-j "$PUBLIC_KEY" -h "$TEST_HOST" -p "$TEST_PORT" \
198+
> "$CLIENT_LOG" 2>&1
199+
200+
# The daemon logs from the connection process, so wait for the fork to show
201+
# up rather than assuming it landed before the client returned.
202+
WAITED=0
203+
while [ "$WAITED" -lt "$DEADLINE" ]; do
204+
NEW_SPAWN=`expr \`log_count "Spawned new process"\` - $BEFORE_SPAWN`
205+
if [ "$NEW_SPAWN" -gt 0 ]; then
206+
break
207+
fi
208+
sleep 1
209+
WAITED=`expr $WAITED + 1`
210+
done
211+
212+
stop_wolfsshd
213+
PID=""
214+
215+
NEW_SPAWN=`expr \`log_count "Spawned new process"\` - $BEFORE_SPAWN`
216+
NEW_CHDIR_FAIL=`expr \`log_count "chdir to chroot path failed"\` \
217+
- $BEFORE_CHDIR_FAIL`
218+
NEW_CHROOT_FAIL=`expr \`log_count "chroot failed to path"\` \
219+
- $BEFORE_CHROOT_FAIL`
220+
NEW_SETUP_FAIL=`expr \`log_count "Error setting chroot"\` \
221+
- $BEFORE_SETUP_FAIL`
222+
223+
if [ "$NEW_SPAWN" -le 0 ]; then
224+
echo "FAIL: $LABEL daemon never forked a connection process"
225+
echo " client said: `tail -n 3 "$CLIENT_LOG" | tr '\n' '|'`"
226+
exit 1
227+
fi
228+
}
229+
230+
fail_case() {
231+
echo "FAIL: $1"
232+
shift
233+
for extra in "$@"; do
234+
echo " $extra"
235+
done
236+
exit 1
237+
}
238+
239+
# Case 1: the chdir into the chroot target fails. chroot() must not run.
240+
run_case sshd_config_test_chroot \
241+
"LD_PRELOAD=$PRELOAD_LIB WOLFSSHD_FAULT_CHDIR_PATH=$CHROOT_DIR WOLFSSHD_FAULT_CHROOT_MARKER=$TRACE_CHDIR_FAIL" \
242+
chdir_fail
243+
244+
if [ "$NEW_CHDIR_FAIL" -le 0 ]; then
245+
fail_case "the chdir fault never fired, so nothing was tested" \
246+
"client said: `tail -n 3 "$TESTDIR/chroot_client_chdir_fail.log" | tr '\n' '|'`"
247+
fi
248+
if [ -e "$TRACE_CHDIR_FAIL" ]; then
249+
fail_case "chroot() ran after the chdir into the chroot path failed" \
250+
"recorded: `tr '\n' '|' < "$TRACE_CHDIR_FAIL"`"
251+
fi
252+
if [ "$NEW_SETUP_FAIL" -le 0 ]; then
253+
fail_case "the failed chroot setup was not reported to the caller"
254+
fi
255+
if grep -q "$TOKEN" "$TESTDIR/chroot_client_chdir_fail.log"; then
256+
fail_case "the session ran a command after the chroot setup failed"
257+
fi
258+
printf " chdir_fail: chroot skipped and the session terminated\n"
259+
260+
# Case 2: the chdir succeeds and chroot() fails. The chdir("/") that would have
261+
# followed must not run.
262+
run_case sshd_config_test_chroot \
263+
"LD_PRELOAD=$PRELOAD_LIB WOLFSSHD_FAULT_CHROOT_FAIL=1 WOLFSSHD_FAULT_CHROOT_MARKER=$TRACE_CHROOT_FAIL" \
264+
chroot_fail
265+
266+
if [ "$NEW_CHROOT_FAIL" -le 0 ]; then
267+
fail_case "the chroot fault never fired, so nothing was tested" \
268+
"client said: `tail -n 3 "$TESTDIR/chroot_client_chroot_fail.log" | tr '\n' '|'`"
269+
fi
270+
if [ "$NEW_CHDIR_FAIL" -ne 0 ]; then
271+
fail_case "the chdir into the chroot path failed in the chroot-fault case"
272+
fi
273+
if ! grep -q "^chroot " "$TRACE_CHROOT_FAIL" 2>/dev/null; then
274+
fail_case "chroot() was never reached, so the record proves nothing"
275+
fi
276+
if grep -q "^chdir " "$TRACE_CHROOT_FAIL"; then
277+
fail_case "chdir() ran after chroot() failed" \
278+
"recorded: `tr '\n' '|' < "$TRACE_CHROOT_FAIL"`"
279+
fi
280+
if [ "$NEW_SETUP_FAIL" -le 0 ]; then
281+
fail_case "the failed chroot setup was not reported to the caller"
282+
fi
283+
if grep -q "$TOKEN" "$TESTDIR/chroot_client_chroot_fail.log"; then
284+
fail_case "the session ran a command after the chroot setup failed"
285+
fi
286+
printf " chroot_fail: chdir skipped and the session terminated\n"
287+
288+
# Case 3: no fault. The same guarded code must still chroot, still chdir into
289+
# the new root, and still run the command. The recorded chdir is also what makes
290+
# its absence above meaningful.
291+
run_case sshd_config_test_chroot_ok \
292+
"LD_PRELOAD=$PRELOAD_LIB WOLFSSHD_FAULT_CHROOT_MARKER=$TRACE_OK" \
293+
ok
294+
295+
if [ "$NEW_CHDIR_FAIL" -ne 0 ] || [ "$NEW_CHROOT_FAIL" -ne 0 ] || \
296+
[ "$NEW_SETUP_FAIL" -ne 0 ]; then
297+
fail_case "the unfaulted chroot setup failed"
298+
fi
299+
if ! grep -q "^chroot " "$TRACE_OK" 2>/dev/null; then
300+
fail_case "chroot() never ran on the healthy path"
301+
fi
302+
if ! grep -q "^chdir /$" "$TRACE_OK"; then
303+
fail_case "chdir into the new root never ran on the healthy path" \
304+
"recorded: `tr '\n' '|' < "$TRACE_OK"`"
305+
fi
306+
if ! grep -q "$TOKEN" "$TESTDIR/chroot_client_ok.log"; then
307+
fail_case "the chrooted session did not run its command" \
308+
"client said: `tail -n 3 "$TESTDIR/chroot_client_ok.log" | tr '\n' '|'`"
309+
fi
310+
printf " ok: the whole sequence ran and the chrooted session worked\n"
311+
312+
echo "PASS: each chroot step is skipped after an earlier failure, and none otherwise"
313+
exit 0

0 commit comments

Comments
 (0)