1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325
|
#!/bin/sh
# Try to remove '/' recursively.
# Copyright (C) 2013-2025 Free Software Foundation, Inc.
# This program 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.
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
. "${srcdir=.}/tests/init.sh"; path_prepend_ ./src
print_ver_ rm
# POSIX mandates rm(1) to skip '/' arguments. This test verifies this mandated
# behavior as well as the --preserve-root and --no-preserve-root options.
# Especially the latter case is a live fire exercise as rm(1) is supposed to
# enter the unlinkat() system call. Therefore, limit the risk as much
# as possible -- if there's a bug this test would wipe the system out!
# Fainthearted: skip this test for the 'root' user.
skip_if_root_
# Pull the teeth from rm(1) by intercepting the unlinkat() system call via the
# LD_PRELOAD environment variable. This requires shared libraries to work.
require_gcc_shared_
# Ensure this variable is unset as it's
# used later in the unlinkat() wrapper.
unset CU_TEST_SKIP_EXIT
# Set this to 0 if you don't have a working gdb but would
# still like to run the test
USE_GDB=1
if test $USE_GDB = 1; then
case $host_triplet in
*darwin*) skip_ 'avoiding due to potentially non functioning gdb' ;;
*) ;;
esac
# Use gdb to provide further protection by limiting calls to unlinkat().
( timeout 10s gdb --version ) > gdb.out 2>&1
case $(cat gdb.out) in
*'GNU gdb'*) ;;
*) skip_ "can't run gdb";;
esac
fi
# Break on a line rather than a symbol, to cater for inline functions
break_src="$abs_top_srcdir/src/remove.c"
break_line=$(grep -n ^excise "$break_src") || framework_failure_
break_line=$(echo "$break_line" | cut -d: -f1) || framework_failure_
break_line="$break_src:$break_line"
cat > k.c <<'EOF' || framework_failure_
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int unlinkat (int dirfd, const char *pathname, int flags)
{
/* Prove that LD_PRELOAD works: create the evidence file "x". */
fclose (fopen ("x", "w"));
/* Immediately terminate, unless indicated otherwise. */
if (! getenv("CU_TEST_SKIP_EXIT"))
_exit (0);
/* Pretend success. */
return 0;
}
EOF
# Then compile/link it:
gcc_shared_ k.c k.so \
|| framework_failure_ 'failed to build shared library'
# Note breakpoint commands don't work in batch mode
# https://sourceware.org/bugzilla/show_bug.cgi?id=10079
# So we use python to script behavior upon hitting the breakpoint
cat > bp.py <<'EOF.py' || framework_failure_
def breakpoint_handler (event):
if not isinstance(event, gdb.BreakpointEvent):
return
hit_count = event.breakpoints[0].hit_count
if hit_count == 1:
gdb.execute('shell touch excise.break')
gdb.execute('continue')
elif hit_count > 2:
gdb.write('breakpoint hit twice already')
gdb.execute('quit 1')
else:
gdb.execute('continue')
gdb.events.stop.connect(breakpoint_handler)
EOF.py
# In order of the sed expressions below, this cleans:
#
# 1. gdb uses the full path when running rm, so remove the leading dirs.
# 2. For some of the "/" synonyms, the error diagnostic slightly differs from
# that of the basic "/" case (see gnulib's fts_open' and ROOT_DEV_INO_WARN):
# rm: it is dangerous to operate recursively on 'FILE' (same as '/')
# Strip that part off for the following comparison.
clean_rm_err_()
{
sed "s/.*rm: /rm: /; \
s/\(rm: it is dangerous to operate recursively on\).*$/\1 '\/'/"
}
#-------------------------------------------------------------------------------
# exercise_rm_r_root: shell function to test "rm -r '/'"
# The caller must provide the FILE to remove as well as any options
# which should be passed to 'rm'.
# Paranoia mode on:
# For the worst case where both rm(1) would fail to refuse to process the "/"
# argument (in the cases without the --no-preserve-root option), and
# intercepting the unlinkat(1) system call would fail (which actually already
# has been proven to work above), and the current non root user has
# write access to "/", limit the damage to the current file system via
# the --one-file-system option.
# Furthermore, run rm(1) via gdb that limits the number of unlinkat() calls.
exercise_rm_r_root ()
{
# Remove the evidence files; verify that.
rm -f x excise.break || framework_failure_
test -f x && framework_failure_
test -f excise.break && framework_failure_
local skip_exit=
if [ "$CU_TEST_SKIP_EXIT" = 1 ]; then
# Pass on this variable into 'rm's environment.
skip_exit='CU_TEST_SKIP_EXIT=1'
fi
if test $USE_GDB = 1; then
gdb -nx --batch-silent -return-child-result \
--eval-command="set exec-wrapper \
env 'LD_PRELOAD=$LD_PRELOAD:./k.so' $skip_exit" \
--eval-command="break '$break_line'" \
--eval-command='source bp.py' \
--eval-command="run -rv --one-file-system $*" \
--eval-command='quit' \
rm < /dev/null > out 2> err.t
else
touch excise.break
env LD_PRELOAD=$LD_PRELOAD:./k.so $skip_exit \
rm -rv --one-file-system $* < /dev/null > out 2> err.t
fi
ret=$?
clean_rm_err_ < err.t > err || ret=$?
return $ret
}
# Verify that "rm -r dir" basically works.
mkdir dir || framework_failure_
rm -r dir || framework_failure_
test -d dir && framework_failure_
# Now verify that intercepting unlinkat() works:
# rm(1) must succeed as before, but this time both the evidence file "x"
# and the test file / directory must still exist afterward.
mkdir dir || framework_failure_
> file || framework_failure_
skip=
for file in dir file ; do
exercise_rm_r_root "$file" || skip=1
test -e "$file" || skip=1
test -f x || skip=1
test -f excise.break || skip=1 # gdb works and breakpoint hit
compare /dev/null err || skip=1
test "$skip" = 1 \
&& { cat out; cat err; \
skip_ "internal test failure: maybe LD_PRELOAD or gdb doesn't work?"; }
done
# "rm -r /" without --no-preserve-root should output the following
# diagnostic error message.
cat <<EOD > exp || framework_failure_
rm: it is dangerous to operate recursively on '/'
rm: use --no-preserve-root to override this failsafe
EOD
#-------------------------------------------------------------------------------
# Exercise "rm -r /" without and with the --preserve-root option.
# Exercise various synonyms of "/" including symlinks to it.
# Expect a non-Zero exit status.
# Prepare a few symlinks to "/".
ln -s / rootlink || framework_failure_
ln -s rootlink rootlink2 || framework_failure_
ln -sr / rootlink3 || framework_failure_
for opts in \
'/' \
'--preserve-root /' \
'//' \
'///' \
'////' \
'rootlink/' \
'rootlink2/' \
'rootlink3/' ; do
returns_ 1 exercise_rm_r_root $opts || fail=1
# Expect nothing in 'out' and the above error diagnostic in 'err'.
# As rm(1) should have skipped the "/" argument, it does not call unlinkat().
# Therefore, the evidence file "x" should not exist.
compare /dev/null out || fail=1
compare exp err || fail=1
test -f x && fail=1
# Do nothing more if this test failed.
test $fail = 1 && { cat out; cat err; Exit $fail; }
done
#-------------------------------------------------------------------------------
# Exercise with --no-preserve to ensure shortened equivalent is not allowed.
cat <<EOD > exp_opt || framework_failure_
rm: you may not abbreviate the --no-preserve-root option
EOD
returns_ 1 exercise_rm_r_root --no-preserve / || fail=1
compare exp_opt err || fail=1
test -f x && fail=1
#-------------------------------------------------------------------------------
# Exercise "rm -r file1 / file2".
# Expect a non-Zero exit status representing failure to remove "/",
# yet 'file1' and 'file2' should be removed.
> file1 || framework_failure_
> file2 || framework_failure_
# Now that we know that 'rm' won't call the unlinkat() system function for "/",
# we could probably execute it without the LD_PRELOAD'ed safety net.
# Nevertheless, it's still better to use it for this test.
# Tell the unlinkat() replacement function to not _exit(0) immediately
# by setting the following variable.
CU_TEST_SKIP_EXIT=1
returns_ 1 exercise_rm_r_root --preserve-root file1 '/' file2 || fail=1
unset CU_TEST_SKIP_EXIT
cat <<EOD > out_removed
removed 'file1'
removed 'file2'
EOD
# The above error diagnostic should appear in 'err'.
# Both 'file1' and 'file2' should be removed. Simply verify that in the
# "out" file, as the replacement unlinkat() dummy did not remove them.
# Expect the evidence file "x" to exist.
compare out_removed out || fail=1
compare exp err || fail=1
test -f x || fail=1
# Do nothing more if this test failed.
test $fail = 1 && { cat out; cat err; Exit $fail; }
#-------------------------------------------------------------------------------
# Exercise various synonyms of "/" having a trailing "." or ".." in the name.
# This triggers another check in the code first and therefore leads to a
# different diagnostic. However, we want to test anyway to protect against
# future reordering of the checks in the code.
# Expect that other error diagnostic in 'err' and nothing in 'out'.
# Expect a non-Zero exit status. The evidence file "x" should not exist.
for file in \
'//.' \
'/./' \
'/.//' \
'/../' \
'/.././' \
'/etc/..' \
'rootlink/..' \
'rootlink2/.' \
'rootlink3/./' ; do
test -d "$file" || continue # if e.g. /etc does not exist.
returns_ 1 exercise_rm_r_root --preserve-root "$file" || fail=1
grep "rm: refusing to remove '\.' or '\.\.' directory: skipping" err \
|| fail=1
compare /dev/null out || fail=1
test -f x && fail=1
# Do nothing more if this test failed.
test $fail = 1 && { cat out; cat err; Exit $fail; }
done
#-------------------------------------------------------------------------------
# Until now, it was all just fun.
# Now exercise the --no-preserve-root option with which rm(1) should enter
# the intercepted unlinkat() system call.
# As the interception code terminates the process immediately via _exit(0),
# the exit status should be 0.
# Use the option --interactive=never to bypass the following prompt:
# "rm: descend into write-protected directory '/'?"
exercise_rm_r_root --interactive=never --no-preserve-root '/' \
|| fail=1
# The 'err' file should not contain the above error diagnostic.
grep "rm: it is dangerous to operate recursively on '/'" err && fail=1
# Instead, rm(1) should have called the intercepted unlinkat() function,
# i.e., the evidence file "x" should exist.
test -f x || fail=1
test $fail = 1 && { cat out; cat err; }
Exit $fail
|