Skip to content

Commit 5c20517

Browse files
gh-157579: Fix race condition in the cleanup of tempfile.TemporaryDirectory (GH-157580)
Co-authored-by: Petr Viktorin <encukou@gmail.com>
1 parent 42e62d6 commit 5c20517

6 files changed

Lines changed: 235 additions & 19 deletions

File tree

‎Doc/library/tempfile.rst‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,15 @@ The module defines the following user-callable items:
234234
debugging or when you need your cleanup behavior to be conditional based on
235235
other logic.
236236

237+
.. warning::
238+
239+
Cleanup is not robust against the tree being modified while it is removed.
240+
Files outside of the tree may have their permissions and file flags reset.
241+
242+
On systems where :data:`shutil.rmtree.avoids_symlink_attacks` is
243+
false, manipulating symbolic links during cleanup
244+
may cause files outside of the tree to be removed.
245+
237246
.. audit-event:: tempfile.mkdtemp fullpath tempfile.TemporaryDirectory
238247

239248
.. versionadded:: 3.2

‎Lib/shutil.py‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,7 @@ def _rmtree_safe_fd_step(stack, onexc):
761761
# save a call to os.lstat() when walking subdirectories.
762762
func, dirfd, path, orig_entry = stack.pop()
763763
name = path if orig_entry is None else orig_entry.name
764+
parent_fd = None if func is os.close else dirfd
764765
try:
765766
if func is os.close:
766767
os.close(dirfd)
@@ -808,22 +809,23 @@ def _rmtree_safe_fd_step(stack, onexc):
808809
except FileNotFoundError:
809810
continue
810811
except OSError as err:
811-
onexc(os.unlink, fullname, err)
812+
onexc(os.unlink, fullname, err, direntry=entry, dir_fd=topfd)
812813
except FileNotFoundError as err:
813814
if orig_entry is None or func is os.close:
814815
err.filename = path
815-
onexc(func, path, err)
816+
onexc(func, path, err, direntry=orig_entry, dir_fd=parent_fd)
816817
except OSError as err:
817818
err.filename = path
818-
onexc(func, path, err)
819+
onexc(func, path, err, direntry=orig_entry, dir_fd=parent_fd)
819820

820821
_use_fd_functions = ({os.open, os.stat, os.unlink, os.rmdir} <=
821822
os.supports_dir_fd and
822823
os.scandir in os.supports_fd and
823824
os.stat in os.supports_follow_symlinks)
824825
_rmtree_impl = _rmtree_safe_fd if _use_fd_functions else _rmtree_unsafe
825826

826-
def rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None):
827+
def rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None,
828+
_onexc_kwargs=False):
827829
"""Recursively delete a directory tree.
828830
829831
If dir_fd is not None, it should be a file descriptor open to a directory;
@@ -846,24 +848,29 @@ def rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None):
846848

847849
sys.audit("shutil.rmtree", path, dir_fd)
848850
if ignore_errors:
849-
def onexc(*args):
851+
def onexc(*args, **kwargs):
850852
pass
851853
elif onerror is None and onexc is None:
852-
def onexc(*args):
854+
def onexc(*args, **kwargs):
853855
raise
854856
elif onexc is None:
855857
if onerror is None:
856-
def onexc(*args):
858+
def onexc(*args, **kwargs):
857859
raise
858860
else:
859861
# delegate to onerror
860-
def onexc(*args):
862+
def onexc(*args, **kwargs):
861863
func, path, exc = args
862864
if exc is None:
863865
exc_info = None, None, None
864866
else:
865867
exc_info = type(exc), exc, exc.__traceback__
866868
return onerror(func, path, exc_info)
869+
elif not _onexc_kwargs:
870+
# Only the internal caller in tempfile asks for the extra arguments.
871+
_onexc = onexc
872+
def onexc(func, path, err, **kwargs):
873+
return _onexc(func, path, err)
867874

868875
_rmtree_impl(path, dir_fd, onexc)
869876

‎Lib/tempfile.py‎

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import shutil as _shutil
4545
import errno as _errno
4646
from random import Random as _Random
47+
import stat as _stat
4748
import sys as _sys
4849
import types as _types
4950
import weakref as _weakref
@@ -274,15 +275,68 @@ def _dont_follow_symlinks(func, path, *args):
274275
elif not _os.path.islink(path):
275276
func(path, *args)
276277

277-
def _resetperms(path):
278+
def _resetflags(path):
278279
try:
279280
chflags = _os.chflags
280281
except AttributeError:
281282
pass
282283
else:
283284
_dont_follow_symlinks(chflags, path, 0)
285+
286+
def _resetperms(path):
287+
_resetflags(path)
284288
_dont_follow_symlinks(_os.chmod, path, 0o700)
285289

290+
# True if TemporaryDirectory._rmtree() can work relative to open directories
291+
# instead of resolving paths again.
292+
_rmtree_use_dir_fd = (
293+
{_os.chmod, _os.unlink, _os.lstat} <= _os.supports_dir_fd
294+
and _os.chmod in _os.supports_fd
295+
)
296+
297+
def _resetperms_fd(dir_fd, path):
298+
# Same as _resetperms(), but for the directory referred to by dir_fd.
299+
if dir_fd is None:
300+
_resetperms(path)
301+
return
302+
_resetflags(path)
303+
_os.chmod(dir_fd, 0o700)
304+
305+
try:
306+
_nofollow_mode = _os.O_RDONLY | _os.O_NONBLOCK | _os.O_NOFOLLOW
307+
except AttributeError:
308+
_nofollow_mode = None
309+
310+
def _resetperms_at(name, dir_fd, path):
311+
# Same as _resetperms(), but name is resolved relative to the directory
312+
# file descriptor dir_fd. path is only used for os.chflags(), which
313+
# doesn't support dir_fd or file descriptors.
314+
if dir_fd is None:
315+
_resetperms(path)
316+
return
317+
_resetflags(path)
318+
if _os.chmod in _os.supports_follow_symlinks:
319+
_os.chmod(name, 0o700, dir_fd=dir_fd, follow_symlinks=False)
320+
else:
321+
# dir_fd & follow_symlinks is not supported on this platform.
322+
# Try chmod opening the file with O_NOFOLLOW.
323+
if _nofollow_mode is not None:
324+
try:
325+
fd = _os.open(name, _nofollow_mode, dir_fd=dir_fd)
326+
except OSError:
327+
pass
328+
else:
329+
try:
330+
_os.chmod(fd, 0o700)
331+
finally:
332+
_os.close(fd)
333+
return
334+
# If that did not work, we change by name, which is subject to a race
335+
# condition.
336+
stat = _os.lstat(name, dir_fd=dir_fd)
337+
if not _stat.S_ISLNK(stat.st_mode):
338+
_os.chmod(name, 0o700, dir_fd=dir_fd)
339+
286340

287341
# User visible interfaces.
288342

@@ -927,24 +981,43 @@ def __init__(self, suffix=None, prefix=None, dir=None,
927981
ignore_errors=self._ignore_cleanup_errors, delete=self._delete)
928982

929983
@classmethod
930-
def _rmtree(cls, name, ignore_errors=False, repeated=False):
931-
def onexc(func, path, exc):
984+
def _rmtree(cls, name, ignore_errors=False, repeated=False, dir_fd=None,
985+
fullname=None):
986+
if fullname is None:
987+
fullname = name
988+
989+
def onexc(func, path, exc, direntry=None, dir_fd=None):
932990
# On DragonFly BSD, UF_NOUNLINK removal fails with EISDIR, not EPERM.
933991
if isinstance(exc, (PermissionError, IsADirectoryError)):
934992
if repeated and path == name:
935993
if ignore_errors:
936994
return
937995
raise
938996

997+
# fullpath is path as seen from the working directory
998+
fullpath = fullname + path[len(name):]
999+
# base is path relative to dir_fd, the directory rmtree()
1000+
# reached it through, or the whole path when there is none
1001+
if dir_fd is None or not _rmtree_use_dir_fd:
1002+
base, dir_fd = path, None
1003+
elif direntry is None:
1004+
base = path
1005+
else:
1006+
base = direntry.name
1007+
9391008
try:
9401009
if path != name:
941-
_resetperms(_os.path.dirname(path))
942-
_resetperms(path)
1010+
# The parent directory of path is the one referred to
1011+
# by dir_fd.
1012+
_resetperms_fd(dir_fd, _os.path.dirname(fullpath))
1013+
_resetperms_at(base, dir_fd, fullpath)
9431014

9441015
try:
945-
_os.unlink(path)
1016+
_os.unlink(base, dir_fd=dir_fd)
9461017
except IsADirectoryError:
947-
cls._rmtree(path, ignore_errors=ignore_errors)
1018+
cls._rmtree(base, ignore_errors=ignore_errors,
1019+
repeated=(path == name),
1020+
dir_fd=dir_fd, fullname=fullpath)
9481021
except PermissionError:
9491022
# The PermissionError handler was originally added for
9501023
# FreeBSD in directories, but it seems that it is raised
@@ -953,21 +1026,27 @@ def onexc(func, path, exc):
9531026
# raise NotADirectoryError and mask the PermissionError.
9541027
# So we must re-raise the current PermissionError if
9551028
# path is not a directory.
956-
if not _os.path.isdir(path) or _os.path.isjunction(path):
1029+
if (not _os.path.isdir(fullpath)
1030+
or _os.path.isjunction(fullpath)):
9571031
if ignore_errors:
9581032
return
9591033
raise
960-
cls._rmtree(path, ignore_errors=ignore_errors,
961-
repeated=(path == name))
1034+
cls._rmtree(base, ignore_errors=ignore_errors,
1035+
repeated=(path == name),
1036+
dir_fd=dir_fd, fullname=fullpath)
9621037
except FileNotFoundError:
9631038
pass
1039+
except OSError:
1040+
if ignore_errors:
1041+
return
1042+
raise
9641043
elif isinstance(exc, FileNotFoundError):
9651044
pass
9661045
else:
9671046
if not ignore_errors:
9681047
raise
9691048

970-
_shutil.rmtree(name, onexc=onexc)
1049+
_shutil.rmtree(name, onexc=onexc, dir_fd=dir_fd, _onexc_kwargs=True)
9711050

9721051
@classmethod
9731052
def _cleanup(cls, name, warn_message, ignore_errors=False, delete=True):

‎Lib/test/test_shutil.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,37 @@ def check_args_to_onexc(self, func, arg, exc):
495495
self.assertTrue(isinstance(exc, OSError))
496496
self.errorState = 3
497497

498+
@os_helper.skip_if_dac_override
499+
@os_helper.skip_unless_working_chmod
500+
@unittest.skipUnless(shutil.rmtree.avoids_symlink_attacks,
501+
'requires the fd based implementation of rmtree()')
502+
def test_on_exc_kwargs(self):
503+
os.mkdir(TESTFN)
504+
self.addCleanup(shutil.rmtree, TESTFN)
505+
506+
child_dir_path = os.path.join(TESTFN, 'b')
507+
child_file_path = os.path.join(child_dir_path, 'a')
508+
os.mkdir(child_dir_path)
509+
os_helper.create_empty_file(child_file_path)
510+
old_child_dir_mode = os.stat(child_dir_path).st_mode
511+
# Make unwritable.
512+
new_mode = stat.S_IREAD|stat.S_IEXEC
513+
os.chmod(child_dir_path, new_mode)
514+
515+
self.addCleanup(os.chmod, child_dir_path, old_child_dir_mode)
516+
517+
calls = []
518+
def onexc(func, path, err, direntry=None, dir_fd=None):
519+
calls.append((func, path, err))
520+
if func is os.unlink:
521+
self.assertEqual(direntry.name, os.path.basename(path))
522+
self.assertTrue(os.path.samestat(
523+
os.stat(path), os.stat(direntry.name, dir_fd=dir_fd)))
524+
525+
shutil.rmtree(TESTFN, onexc=onexc, _onexc_kwargs=True)
526+
self.assertIn((os.unlink, child_file_path),
527+
[(func, path) for func, path, err in calls])
528+
498529
@unittest.skipIf(sys.platform[:6] == 'cygwin',
499530
"This test can't be run on Cygwin (issue #1071513).")
500531
@os_helper.skip_if_dac_override

‎Lib/test/test_tempfile.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import gc
1515
import shutil
1616
import subprocess
17+
import sysconfig
1718
from unittest import mock
1819

1920
import unittest
@@ -1861,6 +1862,54 @@ def test(target, target_is_directory):
18611862
new_flags = os.stat(dir1).st_flags
18621863
self.assertEqual(new_flags, old_flags)
18631864

1865+
@os_helper.skip_unless_symlink
1866+
@os_helper.skip_unless_working_chmod
1867+
@support.requires_non_root_user
1868+
@unittest.skipIf(support.is_emscripten, 'Fails due to Emscripten bug:'
1869+
'emscripten-core/emscripten#27761')
1870+
@unittest.skipUnless(shutil.rmtree.avoids_symlink_attacks,
1871+
'requires the fd based implementation of rmtree()')
1872+
def test_cleanup_with_symlink_race(self):
1873+
# cleanup() should not operate on files outside of the temporary
1874+
# directory when a directory is replaced with a symlink while it
1875+
# recovers from a PermissionError (CVE-2026-12345).
1876+
with self.do_create(recurse=0) as target:
1877+
target_file = os.path.join(target, 'file1')
1878+
open(target_file, 'wb').close()
1879+
target_mode = os.stat(target_file).st_mode
1880+
1881+
d1 = self.do_create(recurse=0)
1882+
dir1 = os.path.join(d1.name, 'dir1')
1883+
os.mkdir(dir1)
1884+
open(os.path.join(dir1, 'file1'), 'wb').close()
1885+
# Removing contents of dir1 fails with a PermissionError, and
1886+
# dir1 is replaced with a symlink to target at the very moment
1887+
# cleanup() starts to recover from that error.
1888+
os.chmod(dir1, 0o500)
1889+
unlink = os.unlink
1890+
def hook(path, *, dir_fd=None):
1891+
try:
1892+
return unlink(path, dir_fd=dir_fd)
1893+
except PermissionError:
1894+
if not os.path.islink(dir1):
1895+
os.chmod(dir1, 0o700)
1896+
os.rename(dir1, dir1 + '_moved')
1897+
os.symlink(target, dir1)
1898+
raise
1899+
try:
1900+
with mock.patch('os.unlink', hook):
1901+
with contextlib.suppress(OSError):
1902+
d1.cleanup()
1903+
finally:
1904+
if os.path.islink(dir1):
1905+
os.unlink(dir1)
1906+
os.rename(dir1 + '_moved', dir1)
1907+
os.chmod(dir1, 0o700)
1908+
d1.cleanup()
1909+
1910+
self.assertTrue(os.path.exists(target_file))
1911+
self.assertEqual(os.stat(target_file).st_mode, target_mode)
1912+
18641913
@support.cpython_only
18651914
def test_del_on_collection(self):
18661915
# A TemporaryDirectory is deleted when garbage collected
@@ -2033,6 +2082,29 @@ def test_modes(self):
20332082
d.cleanup()
20342083
self.assertFalse(os.path.exists(d.name))
20352084

2085+
@support.subTests('ignore_errors', (True, False))
2086+
def test_parent_mode_preserved(self, ignore_errors):
2087+
# Test that cleanup does not touch the parent directory,
2088+
# even if that prevents removal.
2089+
for mode in range(8):
2090+
mode <<= 6
2091+
with self.subTest(mode=format(mode, '03o')):
2092+
outer = self.do_create()
2093+
with outer:
2094+
d = self.do_create(dir=outer.name, dirs=2, files=2,
2095+
ignore_cleanup_errors=ignore_errors)
2096+
with d:
2097+
os.chmod(outer.name, mode)
2098+
orig_mode = os.stat(outer.name).st_mode
2099+
try:
2100+
d.cleanup()
2101+
except PermissionError:
2102+
if ignore_errors:
2103+
raise
2104+
self.assertEqual(os.stat(outer.name).st_mode, orig_mode)
2105+
outer.cleanup()
2106+
self.assertFalse(os.path.exists(outer.name))
2107+
20362108
def check_flags(self, flags):
20372109
# skip the test if these flags are not supported (ex: FreeBSD 13)
20382110
filename = os_helper.TESTFN
@@ -2070,5 +2142,17 @@ def test_delete_false(self):
20702142
self.assertTrue(os.path.exists(working_dir))
20712143
shutil.rmtree(working_dir)
20722144

2145+
@unittest.skipUnless(
2146+
sysconfig.get_config_var('PY_SUPPORT_TIER')
2147+
and sysconfig.get_config_var('PY_SUPPORT_TIER') <= 3,
2148+
'regression test for supported platforms')
2149+
@unittest.skipIf(support.MS_WINDOWS, 'dirfd not used on Windows')
2150+
@unittest.skipIf(support.is_wasi, 'WASI has no chmod')
2151+
def test_cleanup_safe(self):
2152+
"""Verify that cleanup uses the safer code path"""
2153+
# This is a regression test. Feel free to add exceptions for new
2154+
# platforms, but don't forget to update the docs.
2155+
self.assertTrue(tempfile._rmtree_use_dir_fd)
2156+
20732157
if __name__ == "__main__":
20742158
unittest.main()

0 commit comments

Comments
 (0)