diff options
Diffstat (limited to '')
-rwxr-xr-x | tests/rm/r-root.sh | 320 |
1 files changed, 320 insertions, 0 deletions
diff --git a/tests/rm/r-root.sh b/tests/rm/r-root.sh new file mode 100755 index 0000000..7dc618f --- /dev/null +++ b/tests/rm/r-root.sh @@ -0,0 +1,320 @@ +#!/bin/sh +# Try to remove '/' recursively. + +# Copyright (C) 2013-2020 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! + +# Faint-hearted: 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 + # 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 |