Skip to content

Vulnerable tmpdir handling (CVE-2025-71176) #13669

Description

@orlitzky

Starting around https://github.com/pytest-dev/pytest/blob/main/src/_pytest/tmpdir.py#L156, there are some heuristics to make sure that the tmpdir is safe:

            temproot = Path(from_env or tempfile.gettempdir()).resolve()
            user = get_user() or "unknown"
            # use a sub-directory in the temproot to speed-up                                                                                                  
            # make_numbered_dir() call                                                                                                                         
            rootdir = temproot.joinpath(f"pytest-of-{user}")
            try:
                rootdir.mkdir(mode=0o700, exist_ok=True)
            except OSError:
                # getuser() likely returned illegal characters for the platform, use unknown back off mechanism                                                
                rootdir = temproot.joinpath("pytest-of-unknown")
                rootdir.mkdir(mode=0o700, exist_ok=True)
            # Because we use exist_ok=True with a predictable name, make sure                                                                                  
            # we are the owners, to prevent any funny business (on unix, where                                                                                 
            # temproot is usually shared).                                                                                                                     
            # Also, to keep things private, fixup any world-readable temp                                                                                      
            # rootdir's permissions. Historically 0o755 was used, so we can't                                                                                  
            # just error out on this, at least for a while.                                                                                                    
            uid = get_user_id()
            if uid is not None:
                rootdir_stat = rootdir.stat()
                if rootdir_stat.st_uid != uid:
                    raise OSError(
                        f"The temporary directory {rootdir} is not owned by the current user. "
                        "Fix this and try again."
                    )

This is vulnerable to symlink attacks, and probably TOCTOU attacks (see e.g. https://owasp.org/www-community/vulnerabilities/Insecure_Temporary_File). The linux kernel has protections for some of these in the fs.protected_hardlinks and fs.protected_symlinks sysctls, but they violate POSIX and are not enabled by default (though most distros enable them). In any case, they are linux-only.

For a proof of concept, create a symlink as some other user from /tmp/pytest-of-youruser to a directory you own (this will require fs.protected_symlinks to be disabled). Run pytest as yourself, and it will start writing junk to the directory that the attacker chose. It will also change the permissions on the directory he chose.

Obviously whoever wrote this was aware of these issues, but maybe not of how hard they can be to fix completely. This can be made a little bit better by telling stat() not to follow symlinks, but I would instead recommend getting a random base directory in a secure way (using the python tempdir functions) rather than using a predictable name.
There are just too many ways it can go wrong.

Activity

  1. RonnyPfannschmidt commented on Aug 24, 2025

    @RonnyPfannschmidt
    Member

    The predictable base is a intentional feature

    Thanks for the heads up on the feature

  2. orlitzky commented on Aug 24, 2025

    @orlitzky
    Author

    If the predictable base is just for convenience (to make them easy to find/identify), it might be acceptable to pass it as a prefix= to the python tempdir functions. That would result in a directory like pytest-for-<user>.XXXXXX, where XXXXXX is a random number.

  3. RonnyPfannschmidt commented on Aug 24, 2025

    @RonnyPfannschmidt
    Member

    we use the pytest-for-$USER as base-tmp for more, have to decide what to do with it as its practically a breaking change in ux

    my initial impression is that id prefer to harden obtaining that directory - but i'm unfamiliar with the attack surface atm

  4. orlitzky commented on Aug 24, 2025

    @orlitzky
    Author

    Looking at the list of weird things that mkstemp() does is a good place to start, but it really depends on how pedantic you want to be. The fundamental issue is that it's not safe to do anything in a directory that is writable by someone you don't trust completely. Usually that's only yourself and root. Right away this raises some questions:

    • Do we assume that if PYTEST_DEBUG_TEMPROOT is taken from the environment, that it's completely safe (i.e. that it meets those writability requirements)?
    • If we use the default TMPDIR on UNIX, what assumptions do we want to make on its permissions? Usually /tmp is world writable, which is already bad, but maybe e.g. some other users are allowed to bind-mount directories there?

    In the "normal" case where we're using /tmp and it's world-writable but there's no other funny business, you have symlinks, hardlinks, and TOCTOU to worry about. Symlinks can be avoided with lstat(), but there's a risk that the path you're lstat'ing will be replaced between the time that you lstat it and the time that you use it. There's also a chance that some directory higher up is a symlink; lstat() only checks the very last path component. Hardlinks to directories aren't allowed on linux, but that's actually not part of the POSIX standard. If you want to be safe on systems where hardlinks to directories are allowed, you have all of the same issues you do with symlinks, but now with hardlinks. Actually it's worse because "is this path a hardlink?" is a meaningless question; you have to do a stat() and count the number of hardlinks, but that number can change behind your back -- there's no way to do it atomically and fail. (Bind mounts are basically hard links to directories in this context.)

    You can make it very hard to exploit by doing a lot of checks, but it will never be 100% safe from every contrived scenario. Even using the python library functions to obtain a directory "securely" is probably vulnerable to edge cases. But the benefit of the latter would be that it is no longer your problem :)

  5. orlitzky commented on Aug 24, 2025

    @orlitzky
    Author

    Oh, and suppose we manage to make the permissions/ownership check bullet-proof. Someone else can create pytest-for-orlitzky when the machine reboots or when /tmp is cleaned. The next time I try to run pytest, it will raise an OSError telling me to fix the ownership issue. But I can't fix it! If I could, the permissions check wouldn't be worth anything.

  6. RonnyPfannschmidt commented on Aug 24, 2025

    @RonnyPfannschmidt
    Member

    Let's keep in mind that pytest is not commonly a service on a multi user system but rather a test tool you run in isolated ci and/or from the primary user

    We don't have the same attack exposure as a unix service on a multi user system

  7. RonnyPfannschmidt commented on Aug 24, 2025

    @RonnyPfannschmidt
    Member

    That being said we should manage the attack surface and/or ensure user are aware of hardening steps to take

  8. added
    status: help wanteddevelopers would like help from experts on this topic
    plugin: tmpdirrelated to the tmpdir builtin plugin
    on Sep 7, 2025
  9. Endertacker commented on Sep 28, 2025

    @Endertacker

    Hi there :)

    Thanks for bringing this up — this is indeed a legitimate security concern.

    The current implementation in tmpdir.py creates a predictable temporary directory name based on the username (pytest-of-{user}), and then verifies ownership with stat(). This design is vulnerable to symlink and TOCTOU (Time-Of-Check to Time-Of-Use) attacks. An attacker could pre-create a symlink /tmp/pytest-of-youruser pointing to a different location they control, and pytest would end up writing to that location. Changing the symlink between the ownership check and actual directory use is a classic TOCTOU race condition.

    Even though Linux provides protections via fs.protected_hardlinks and fs.protected_symlinks, these are not POSIX compliant and not enabled by default. This means such vulnerabilities can still exist in many environments.

    A more secure approach would be:

    • Avoid predictable directory names for temporary files.
    • Use Python’s built-in tempfile.TemporaryDirectory() or tempfile.mkdtemp() to generate secure, random directories.
    • Avoid manual ownership checks and let the OS/tempfile APIs handle security.

    This could significantly improve the security of pytest’s tmpdir handling, and I recommend documenting or addressing this in the upstream code.

    Thanks for considering this security improvement.

    (Hope it helps)

  10. changed the title [-]Vulnerable tmpdir handling[/-] [+]Vulnerable tmpdir handling (CVE-2025-71176)[/+] on Jan 22, 2026
  11. reneleonhardt commented on Jan 28, 2026

    @reneleonhardt

    How is this predictable base being used in UX today?
    Is it not enough to just print the randomly created tmpdir?
    With a standard random suffix like Michael recommended, navigating to it would be extremely easy for users.

    # Navigate to latest pytest results
    cd "$(ls -td1 $TMPDIR/pytest-of-$USER.* 2>/dev/null | head -1)"

    After investigating, the user could optionally clean-up before the next run if he wants to.

    # Remove latest pytest results
    d="$(ls -td1 $TMPDIR/pytest-of-$USER.* 2>/dev/null | head -1)" && [[ -d "$d" ]] && read -p "Delete last '$d'? (y/N): " r && [[ $r =~ ^[Yy]$ ]] && echo rm -r "$d" && echo "Deleted last '$d'" || echo "Aborting, nothing deleted."
  12. BenjaminBrienen-CanonPP commented on Jan 30, 2026

    @BenjaminBrienen-CanonPP

    Is there a patched version available?

  13. webknjaz commented on Jan 30, 2026

    @webknjaz
    Member

    How is this predictable base being used in UX today?
    Is it not enough to just print the randomly created tmpdir?

    Following pytest runs remove older directories based on how many it's set up to preserve. There needs to be a way to figure out which directories are from the last N runs, which wouldn't be possible without predictability.
    FWIW, it doesn't seem reasonable to randomize the directory names since it'd break the fundamental features. I imagine one could set the tmp dir env var to something random/they manage at the cost of confusing the cleanup logic.

  14. orlitzky commented on Jan 30, 2026

    @orlitzky
    Author

    Is there a patched version available?

    The easiest workaround is to create a temporary directory securely yourself, and then use that as the pytest root. For example,

    PYTEST_DEBUG_TEMPROOT=$(mktemp -d) pytest
    

    This works even if you need the "previous N runs" feature. Just store the directory name returned by mktemp -d somewhere safe (environment variable, predictable filename in a directory writable only by you, etc.)... it's not a secret.

    On linux, setting these sysctls eliminates most of the risk:

    fs.protected_fifos = 2
    fs.protected_regular = 2
    fs.protected_symlinks = 1
    fs.protected_hardlinks = 1
    
  15. 25 remaining items

  16. orlitzky commented on Jun 23, 2026

    @orlitzky
    Author

    @bluetech Isn't this fixed by #14343?

    No, it isn't.

  17. nirs commented on Jun 23, 2026

    @nirs

    @orlitzky The CVE mention here: GHSA-6w46-j5rx-g56g link to this issue and claim that the issue is fixed in pytest >= 9.0.3.

    If this is not fixed the CVE should be updated, and this issue should not include the CVE in the title.

  18. orlitzky commented on Jun 23, 2026

    @orlitzky
    Author

    If this is not fixed the CVE should be updated, and this issue should not include the CVE in the title.

    This is the issue that the CVE describes, but some bozo putting "we don't care" in a comment does not make it fixed. I wasted hours explaining all of this, and spent hours reviewing the PR by @laurac8r who took the time to fix everything carefully and correctly, just for it to be thrown away at the last second for a half-assed heuristic that ignores the months of discussion.

    The level of incompetence on display in the handling of this issue has been staggering. I will not be wasting any more time on it.

  19. RonnyPfannschmidt commented on Jun 23, 2026

    @RonnyPfannschmidt
    Member

    I understand the frustration here, and I want to acknowledge that the concerns raised are valid — the work done on PR #14279 by @laurac8r and the thorough review by @orlitzky are genuinely appreciated.

    However, I want to be clear: accusing the maintainers of "incompetence" is unfair and not acceptable. People can reasonably disagree on risk tolerance and tradeoffs without being incompetent. This project is maintained by volunteers making judgment calls under real constraints.

    Here's where things stand:

    Aspect Merged fix (#14343) Proposed fix (#14279)
    Symlink attack Fixed Fixed
    TOCTOU race Partially mitigated Fully eliminated (fd-based)
    DoS via pre-creation Not fixed (acknowledged in code) Fixed
    Complexity ~30 lines changed ~580 additions
    Breaking changes None Yes (path format changes)

    The tradeoff that was made: the simpler fix closes the specific symlink attack vector with no behavioral changes, at the cost of leaving the DoS and a theoretical TOCTOU window open. The comprehensive fix eliminates the vulnerability class entirely, but introduces breaking changes in path naming and significant complexity around cleanup of randomly-named dirs.

    Whether the advisory should say "fixed" is a fair question — the specific PoC no longer works, but the underlying anti-pattern (predictable names in shared /tmp) remains. I think it's reasonable to say the CVE as described is addressed, while acknowledging that a more robust solution exists and may be worth revisiting in a future major release where breaking changes are acceptable.

  20. nirs commented on Jun 23, 2026

    @nirs

    @RonnyPfannschmidt Thanks for the status update.

    • What is the TOCTOU in this case? Regardless of the directory name it can be discovered after we created it, so there is no difference between random name and well known name.
    • What is the Dos? another user creating the director for another user preventing the other user from running pytest?

    The best way to avoid all issues is to switch to XDG_RUNTIME_DIR. It is owned by the current user and we can use a well know name (e.g /run/user//pytest). When XDG_RUNTIME_DIR is not available it can be emulated with similar problems to the current way.

  21. laurac8r commented on Jun 23, 2026

    @laurac8r

    Hey y'all, if there is an interest, I can revisit this issue and look to bridge the gap between @bluetech 's and my solutions (merged vs proposed), incorporating some of the ideas posited since our PRs were finalized.

  22. RonnyPfannschmidt commented on Jun 23, 2026

    @RonnyPfannschmidt
    Member

    I think the topic warrants some discussions

    Lets not jump into implementation right off the bat as i think there's a need for some bikeshedding

    This may be a topic for the pytest sprint

  23. laurac8r commented on Jul 4, 2026

    @laurac8r

    TBH not a cybersecurity expert, so I threw the problem at Claude Fable 5. Here's the synopsis:

    Four approaches:

    • A · Harden in place — fd-based verification (O_NOFOLLOW|O_DIRECTORY + fstat/fchmod), and the bridging idea: on squat detection, warn and fall back to a mkdtemp-random rootdir instead of hard-failing. Converts the DoS into a degraded-but-working run; zero visible change otherwise; shippable in a 9.x minor.
    • B · Flat random per session (tmpdir: prevent symlink attacks and TOCTOU races (CVE-2025-71176) #14279's shape) — strongest single mechanism, but carries exactly the costs that killed it: documented-template break, pytest-current gone, temproot-scan GC (bluetech's objection generate-tests aware tmdir breaks if test id contains the path separator #4).
    • C · Nested random — honestly assessed as low marginal security over A: inside a verified 0700 parent an attacker can't write, and the parent squat remains. It buys concurrency robustness, not attacker resistance.
    • D · Relocate the base — per-platform private dirs (Linux XDG chain; macOS/Windows already private). Dissolves the problem: predictable names become safe, so numbered dirs, retention, and pytest-current all survive.
    • E · --tmpdirs list|gc|latest + registry — the enabling layer, answering Ronny's "list/gc command" condition and webknjaz's retention concern verbatim (go clean -cache is the prior-art pattern).

    Recommendation — stage it: Phase 1 (minor): A + E groundwork, each line answering a named maintainer objection without touching the documented template. Phase 2 (major): D by default with B+E as the no-private-base fallback, plus the deprecation cycle.

  24. laurac8r commented on Jul 4, 2026

    @laurac8r

    TBH not a cyber...

    Four approaches: ...

    Here's a quick visual aid that explores the solution space for this #13669 issue: https://laurac8r.github.io/pytest/

  25. nirs commented on Jul 4, 2026

    @nirs

    Here's a quick visual aid that explores the solution space for this #13669 issue: https://laurac8r.github.io/pytest/

    Nice summary, but it will be more useful here in markdown format.

  26. laurac8r commented on Jul 6, 2026

    @laurac8r

    @nirs which part?

  27. nirs commented on Jul 6, 2026

    @nirs

    @nirs which part?

    All parts seem good content for this issue or other documents in this project.

    Or maybe extract new sub issues for specific problems. Keeping huge issues open for long time is not very useful and makes it hard to understand the status.

  28. laurac8r commented on Jul 7, 2026

    @laurac8r

    +1 for breaking down the problem into sub-issues

  29. RonnyPfannschmidt commented on Sep 13, 2026

    @RonnyPfannschmidt
    Member

    Status note: I'm preparing more comprehensive steps on this and am currently researching the fallout of the different options to pin down the tradeoffs across local development, remote/shared machines and CI runners before splitting it into concrete follow-up issues.

    The research input so far is at https://github.com/RonnyPfannschmidt-Attic/pytest-tmpdir-13669-research: a squat/symlink scenario matrix run against the candidate rootdir strategies with two real unprivileged users, a retention/GC demo driven by pytest's own cleanup code, and planning notes. Those scripts, outputs and notes were generated and run by Claude (Fable 5.1 via Claude Code) at my prompting; I've read them and am posting them as input, not as decisions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    plugin: tmpdirrelated to the tmpdir builtin pluginstatus: help wanteddevelopers would like help from experts on this topic

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions