diff --git a/Lib/shutil.py b/Lib/shutil.py --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -240,6 +240,16 @@ if errors: raise Error(errors) +def _close(fd): + try: + os.close(fd) + except OSError as inst: + if inst.errno != errno.EBADF: + raise + +_supports_safe_rmdir = hasattr(os, 'openat') and hasattr(os, + 'fdlistdir') and hasattr(os, 'unlinkat') and hasattr(os, 'fstatat') + def rmtree(path, ignore_errors=False, onerror=None): """Recursively delete a directory tree. @@ -257,6 +267,61 @@ elif onerror is None: def onerror(*args): raise + + if _supports_safe_rmdir: + try: + mode1 = os.lstat(path).st_mode + if stat.S_ISLNK(mode1): + raise OSError("Cannot call rmtree on a symbolic link") + except OSError: + onerror(os.lstat, path, sys.exc_info()) + # can't continue even if onerror hook returns + return + fd = os.open(path, os.O_RDONLY) + try: + mode2 = os.fstat(fd).st_mode + if mode1 != mode2: + raise OSError("Target changed") + except OSError: + onerror(os.fstat, fd, sys.exc_info()) + # can't continue if target has changed + return + _rmtree_safe(fd, ignore_errors, onerror) + try: + os.rmdir(path) + except os.error: + onerror(os.rmdir, path, sys.exc_info()) + else: + _rmtree_unsafe(path, ignore_errors, onerror) + +def _rmtree_safe(dirfd, ignore_errors=False, onerror=None): + names = [] + try: + fd = os.dup(dirfd) + names = os.fdlistdir(fd) + except os.error as err: + onerror(os.fdlistdir, fd, sys.exc_info()) + _close(fd) + for name in names: + try: + mode = os.fstatat(dirfd, name, os.AT_SYMLINK_NOFOLLOW).st_mode + except os.error: + mode = 0 + if stat.S_ISDIR(mode): + newfd = os.openat(dirfd, name, os.O_RDONLY) + _rmtree_safe(newfd, ignore_errors, onerror) + try: + os.unlinkat(dirfd, name, os.AT_REMOVEDIR) + except os.error as err: + onerror(os.unlinkat, (dirfd, name), sys.exc_info()) + else: + try: + os.unlinkat(dirfd, name) + except os.error as err: + onerror(os.unlinkat, (dirfd, name), sys.exc_info()) + _close(dirfd) + +def _rmtree_unsafe(path, ignore_errors=False, onerror=None): try: if os.path.islink(path): # symlinks to directories are forbidden, see bug #1669 @@ -277,7 +342,7 @@ except os.error: mode = 0 if stat.S_ISDIR(mode): - rmtree(fullname, ignore_errors, onerror) + _rmtree_unsafe(fullname, ignore_errors, onerror) else: try: os.remove(fullname) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -19,6 +19,12 @@ unregister_unpack_format, get_unpack_formats) import tarfile import warnings +import time +import errno +try: + import threading +except ImportError: + threading = None from test import support from test.support import TESTFN, check_warnings, captured_stdout, requires_zlib @@ -152,6 +158,8 @@ if self.errorState == 0: if func is os.remove: self.assertEqual(arg, self.childpath) + elif func is os.unlinkat: + self.assertEqual(arg, (3, 'a')) else: self.assertIs(func, os.listdir, "func must be either os.remove or os.listdir") @@ -308,6 +316,40 @@ finally: shutil.rmtree(TESTFN, ignore_errors=True) + @unittest.skipIf(threading == None, 'requires threading') + def test_rmtree_4489(self): + # Issue #4489: test a symlink attack on rmtree + + def hack(a, b): + time.sleep(0.1) + os.rename(b, b + 'x') + self.tempdirs.append(b + 'x') + os.symlink(a, b) + + a = self.mkdtemp() + b = self.mkdtemp() + + for i in range(25000): + open(os.path.join(a, str(i)), "w").close() + open(os.path.join(b, str(i)), "w").close() + + l = sorted(os.listdir(a)) + + hackt = threading.Thread(target=hack, args=(a, b)) + hackt.start() + try: + shutil.rmtree(b) + except OSError as inst: + if inst.errno != errno.ENOTDIR: + raise + finally: + support.unlink(b) + self.tempdirs.remove(b) + if hasattr(os, 'openat') and hasattr(os, 'fdlistdir') and hasattr(os, + 'unlinkat') and hasattr(os, 'fstatat'): + self.assertEqual(l, sorted(os.listdir(a))) + + if hasattr(os, "mkfifo"): # Issue #3002: copyfile and copytree block indefinitely on named pipes def test_copyfile_named_pipe(self):