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