Skip to content

3.9.25.2: tempfile.TemporaryDirectory cleanup race fix (CVE-2026-12345) - #90

Merged
icanhasmath merged 5 commits into
3.9.25.xfrom
3.9.25.2-tempfile-race
Oct 28, 2026
Merged

icanhasmath merged 5 commits into
3.9.25.xfrom
3.9.25.2-tempfile-race

Conversation

@icanhasmath

Copy link
Copy Markdown

ActiveState security release 3.9.25.2: fixes CVE-2026-12345 (PSF-2026-42, CVSS 4.0 5.9 Medium), a race condition in tempfile.TemporaryDirectory cleanup.

Stacked on #88 (3.9.25.1-security). Merge #88 first; this PR's base will then need to change to 3.9.25.1's merged base branch.

The vulnerability

When cleanup hits a PermissionError, it resets permissions and retries the removal by resolving the path again. An attacker who can modify the tree during cleanup can swap a directory for a symlink. Cleanup then deletes files, or resets their permissions and flags, outside the temporary directory, with the privileges of the process doing the cleanup. Upstream issue: python#157579. The fix is in main and in 3.12 through 3.15; it was not backported to 3.11.

Commits

  1. bpo-43153 (from 3.11): don't hide a PermissionError behind NotADirectoryError during cleanup. Prerequisite.
  2. TemporaryDirectory clean-up fails with unsearchable directories python/cpython#79325 (from 3.11): stop the endless recursion when the temporary directory itself can't be removed (the repeated guard). Prerequisite.
  3. Race condition in the cleanup of tempfile.TemporaryDirectory python/cpython#157579: the security fix, ported from the 3.12 backport ([3.12] gh-157579: Fix race condition in the cleanup of tempfile.TemporaryDirectory (GH-157580) (GH-158430) python/cpython#159074), plus os.lstat() supports dir_fd but is not in os.supports_dir_fd python/cpython#134993 (os.lstat in os.supports_dir_fd). Files are reset and removed relative to the directory fds that the fd-based shutil.rmtree() walk already holds, instead of by path.
  4. Version bump to 3.9.25.2 and release notes.

How the port differs from 3.12

3.12's fix depends on 3.12's shutil.rmtree() (onexc, a public dir_fd parameter, and the _onexc_kwargs switch). 3.9 only has onerror and a recursive _rmtree_safe_fd(), so:

  • rmtree() gains private keyword arguments _dir_fd and _onerror_kwargs. _rmtree_safe_fd() now passes the parent directory fd and the os.DirEntry along to the error callback.
  • With _onerror_kwargs left false (every existing caller), onerror callbacks still get the same three positional arguments.
  • TemporaryDirectory._rmtree() uses os.lstat(base, dir_fd=...) where 3.12 checks the full path with isdir()/isjunction().
  • 3.9's TemporaryDirectory has no ignore_cleanup_errors (it arrived in 3.10). _rmtree() gets a private ignore_errors parameter that is always False, so the code matches the 3.10/3.11 shape. Tests use test.support instead of os_helper.

The adaptations are described in the commit message bodies.

Testing (Linux x86_64, local build)

  • Full python -m test suite passes.
  • New tests: test_cleanup_with_symlink_race, test_parent_mode_preserved, test_cleanup_safe (tempfile) and test_on_error_kwargs (shutil).
  • test_cleanup_with_symlink_race fails on the base branch: the file outside the temp directory gets deleted. It passes with this change.
  • Windows doesn't use the fd-based path, so only the prerequisite changes apply there. Windows x64 CI is the check.

@ezequielp-activestate ezequielp-activestate left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers. Approving, with one small test nit.

This PR is stacked on the still-open #88 (3.9.25.1-security), so it can only land after that one.

What I checked (CPython built from this branch, Linux x86_64, Debian glibc 2.28, tests run as a non-root user):

  • The fix works. I wrote an independent repro: a subdirectory of a TemporaryDirectory is made unwritable so cleanup takes the PermissionError path, and the subdirectory is swapped for a symlink to an outside directory at the handler's first chmod or at its retry unlink.

    base (3.9.25.1-security) this PR
    swap at the handler's chmod outside file deleted untouched
    swap at the retry unlink outside file deleted untouched (cleanup raises NotADirectoryError for the symlink)
  • Matches upstream. The shutil.py / tempfile.py / os.py changes are the same as the 3.10 port (#89), apart from the private ignore_errors on _rmtree() that 3.9 needs. That port matches the upstream 3.12 backport (c39b52d), with the safer os.lstat(base, dir_fd=…) in place of the path-based isdir()/isjunction(). HAVE_LSTAT is in this branch's posixmodule.c, so the os.py line takes effect.

  • No later fixes missed. I walked upstream Lib/tempfile.py and Lib/shutil.py forward on main, 3.14, 3.13 and 3.12, and nothing later touches it.

  • shutil compatibility. Existing onerror callbacks still get three positional arguments.

  • Tests: test_tempfile, test_shutil and test_os pass.

⚪ Nit: test_cleanup_with_symlink_race leaks a temporary directory on 3.9. After the test, $TMPDIR holds a leftover <random>/dir1 (mode 0700). Run with /tmp as the working directory, regrtest reports test_tempfile failed (env changed). The cause is 3.9's TemporaryDirectory.cleanup(), which only runs _rmtree() if self._finalizer.detach(). The first d1.cleanup() detaches the finalizer even though it raises, so the second d1.cleanup() in the test's finally does nothing. 3.10's cleanup() also retries if … or _os.path.exists(self.name), which is why the same test is clean in #89. Calling d1._rmtree(d1.name) (or shutil.rmtree) in the finally fixes it.

Not assessed: Windows (the fd path is not used there), macOS/BSD chflags, and the full test suite.

@icanhasmath

Copy link
Copy Markdown
Author

Fixed the test leak in 63a77ee. The finally block now removes the tree with d1._rmtree(d1.name), because 3.9's cleanup() does nothing once the finalizer is detached.

  • Before: with TMPDIR set to an empty directory, the test left <random>/dir1 behind.
  • After: the directory stays empty, and test_tempfile, test_shutil and test_os pass with nothing left behind.

@ezequielp-activestate ezequielp-activestate left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 63a77ee: still approved. The only change is the test fix for the temp-directory leak I noted: test_cleanup_with_symlink_race now removes the directory with d1._rmtree(d1.name), since 3.9's cleanup() does nothing once the failed attempt has detached the finalizer. On Python 3.9 with this branch's Lib/, python -m test --fail-env-changed test_tempfile passes, and running the race test from /tmp leaves nothing behind.

miss-islington and others added 5 commits October 28, 2026 14:13
…or` during tempdirectory cleanup (pythonGH-29940) (pythonGH-112754)

(cherry picked from commit 8cdfee1)

Co-authored-by: Ken Jin <kenjin@python.org>
Co-authored-by: andrei kulakov <andrei.avk@gmail.com>
Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
(cherry picked from commit 23920a0)
(cherry picked from commit e3888c0)

3.9.25.x: 3.9's TemporaryDirectory has no ignore_cleanup_errors (added in
3.10), so _rmtree() gains a private ignore_errors parameter that is
always False here; it keeps the code in the 3.10/3.11 shape for the
follow-up fixes. The ignore_cleanup_errors test is not ported.
…nup on Windows (pythonGH-112762) (pythonGH-112848)

(cherry picked from commit b2923a6)
(cherry picked from commit a18201b)
(cherry picked from commit ffb9ae4)

3.9.25.x: test uses support.change_cwd (3.9 has no test.support.os_helper).
…TemporaryDirectory (CVE-2026-12345)

When TemporaryDirectory cleanup recovers from a PermissionError it resets
permissions and retries removal. It did that by resolving paths again,
so an attacker who could modify the tree during cleanup could replace a
directory with a symlink and make cleanup delete files, or reset their
permissions and flags, outside of the temporary directory. Files are now
handled relative to the open directory file descriptors that the
fd-based shutil.rmtree() walk already holds.

Port of the upstream fix (pythonGH-157580, pythonGH-158430), based on the 3.12
backport (python#159074), plus pythongh-134993 (os.lstat in os.supports_dir_fd).

3.10.22.x: the 3.12 fix relies on 3.12's shutil.rmtree() API
(onexc, a public dir_fd parameter, and the _onexc_kwargs switch that
passes direntry/dir_fd to the callback). 3.10 only has onerror and a
recursive _rmtree_safe_fd(), so this adds the private keyword arguments
_dir_fd and _onerror_kwargs to rmtree() instead and threads the parent
directory fd and os.DirEntry through _rmtree_safe_fd(). With
_onerror_kwargs left false, existing onerror callbacks get the same
three positional arguments as before. TemporaryDirectory._rmtree() is
ported onto the 3.11 shape of the code (bpo-43153 and pythongh-79325 are
picked first), using os.lstat(base, dir_fd=...) where 3.12 uses
os.path.isdir()/isjunction() on the full path. Tests are adapted:
onerror instead of onexc, local root and platform skips instead of
support.requires_non_root_user and PY_SUPPORT_TIER.

(cherry picked from commit c39b52d)
(cherry picked from commit 5c20517)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 776b466)

3.9.25.x: tests use test.support instead of test.support.os_helper, and
test_parent_mode_preserved only covers the default (no ignore_errors)
case because 3.9's TemporaryDirectory has no ignore_cleanup_errors.
ActiveState security release of the end-of-life 3.9 line: the
pythongh-157579 tempfile.TemporaryDirectory cleanup race fix (CVE-2026-12345),
ported from 3.12, with its prerequisites bpo-43153 and pythongh-79325.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up.  3.9's TemporaryDirectory.cleanup() only removes the
tree if self._finalizer.detach() succeeds.  The test's first cleanup()
detaches the finalizer even though it raises, so the second cleanup()
in the finally block did nothing and left <tmp>/dir1 behind; regrtest
reported test_tempfile as "env changed".  Remove the tree with
d1._rmtree() instead.  (3.10's cleanup() also retries while the
directory still exists, so #89 is unaffected.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@icanhasmath
icanhasmath force-pushed the 3.9.25.2-tempfile-race branch from 63a77ee to 89e9acb Compare October 28, 2026 19:25
@icanhasmath
icanhasmath changed the base branch from 3.9.25.1-security to 3.9.25.x October 28, 2026 19:25
@icanhasmath

Copy link
Copy Markdown
Author

Rebased onto the release branch and retargeted, after #87/#88 (and #109/#110/#112) merged and v3.10.22.1 / v3.9.25.1 were tagged. The old base branch held the pre-rebase copies of those commits.

  • Conflicts: none. The added and removed lines are identical to the reviewed version; only context and line numbers moved.
  • Tests: rebuilt on the new base. test_tempfile, test_shutil, test_os, test_ensurepip and test_sys pass, and the tests leave nothing in TMPDIR.

@icanhasmath
icanhasmath merged commit bec368b into 3.9.25.x Oct 28, 2026
12 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants