skip to content

Temp File Race Conditions

Why a predictable name in a shared temp directory is a symlink attack, what atomic creation gives you that name-guessing never did, the restrictive mode you get free, and which directory you trust.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

Why is tempfile.mktemp unsafe where tempfile.mkstemp is not?

level: middleimportance: must knowfreq 52%

answer

  1. One returns a name, one returns a file
  2. The gap between check and use
  3. A symlink planted in that gap
  4. Two flags passed to a single open
  5. Fails loudly instead of reusing a path

basics

~20 s

tempfile.mktemp only returns a name. Between that return and your own open() an attacker can create that path, usually as a symlink. tempfile.mkstemp creates and opens the file itself in one exclusive step, so there is no window to exploit.

solid answer

~50 s

`tempfile.mktemp` is a name generator, deprecated since Python 2.3. It picks a path that does not currently exist and hands you the string; everything after that is your problem. Between the check it performed and the `open()` you perform there is a schedulable gap — a time-of-check-to-time-of-use race. Any local account that can write to the temp directory can create that exact path in the gap, typically as a symlink to a file your process may write, and your open then truncates and overwrites the attacker's chosen target. `tempfile.mkstemp` closes the gap by never returning an unclaimed name: it calls `os.open` with `os.O_CREAT | os.O_EXCL` and mode `0o600`, so the kernel performs the existence check and the creation atomically, fails with `FileExistsError` rather than reusing anything, refuses to follow a symlink, and returns an already-open descriptor plus the path.

code

python · 11 lines
python
import os
import stat
import tempfile

fd, path = tempfile.mkstemp(prefix="extract-", suffix=".csv")
try:
    print(stat.filemode(os.fstat(fd).st_mode))
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        f.write("id,amount\n1,83\n")
finally:
    os.unlink(path)

go deeper

for a junior

Recall the one-line rule: never use tempfile.mktemp, use tempfile.mkstemp or tempfile.NamedTemporaryFile. Be able to say that one hands you a name and the other hands you a file that already exists.

for a middle

Explain the race in order: mktemp checks, returns, you open, and anything can happen in between. Name os.O_CREAT with os.O_EXCL as the single atomic operation that removes the gap, and mode 0o600 as the second half of the fix.

for a senior

Demonstrate the impact analysis an incident needs: which files the process could write, therefore which symlink targets were reachable, and whether the staging directory was shared at all. Argue for private directories from tempfile.mkdtemp over hardening individual names.

for a principal

Own the policy rather than the call site: which classes of data may be staged on local disk, whether shared temp directories are permitted for services at all, and how the ban on the unsafe API is enforced automatically rather than by reviewer memory.

### The shape of the bug `tempfile.mktemp` returns a *name*: it composes the temp directory with a random component, confirms nothing is at that path right now, and gives you the string. `tempfile.mkstemp` returns a *file*: an integer file descriptor for a file it has already created, plus the absolute path it created it at. That difference is the whole security story, and it has a standard name — TOCTOU, time-of-check to time-of-use. `mktemp` checks at time T. You open at time T+d. The operating system is free to run any other process in between, and on a loaded machine d can be milliseconds or seconds. An attacker who can write to the temp directory only has to win that gap once, and they can retry as often as they like because your job runs on a schedule. The exploit is not subtle: ```python # UNSAFE - do not ship this import tempfile path = tempfile.mktemp(suffix=".csv") # a name, nothing exists yet # ... attacker creates a symlink at `path` here ... with open(path, "w") as f: # follows the symlink, truncates the target f.write("id,amount\n") ``` If the attacker points `path` at a file your process is permitted to write — a config file, a cron fragment, a state file another service trusts — your process destroys and rewrites it on their behalf. Privilege escalation through a temp file is a borrowed-privilege attack: they never needed access, they needed *you* to have it. A gentler but still real variant needs no attacker at all. Consider an export job whose workers each stage a warehouse extract under a name derived from the wall clock. Clock skew between workers means two hosts can produce the same second, both call `mktemp`, both see the path free, and both open it — one truncating the other's half-written extract, so the file that lands in the warehouse is a clean-looking blend of two runs. That is the same race with a scheduler rather than an adversary driving it. ### Why O_EXCL is a different kind of promise `tempfile.mkstemp` builds a name the same way, but then calls roughly: ```python fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600) ``` `O_CREAT` says create it if absent. `O_EXCL` says fail if it is present. Together they are a single system call, and the kernel performs the test and the creation without any other process being able to interleave. There is no interval between check and use, because there is no separate check. Three consequences follow. The call fails with `FileExistsError` if anything is already there — and critically, `O_EXCL` treats an existing *symlink* as existing, even a dangling one, so the classic attack turns into an error instead of a hijack. The module catches that error and retries with a fresh name, up to a bounded number of attempts. And the mode `0o600` is requested at creation, so the file is never briefly world-readable. `mkstemp` returns `(fd, path)` — an integer descriptor, not a file object — precisely because the descriptor is the guarantee. Wrap it with `os.fdopen(fd, "w")` to get buffered writes without ever going back through the path. ### What does *not* fix it - **A longer random suffix.** Unpredictability makes the attacker guess; it does not remove the window. Against an attacker who can watch the directory — with an inotify-style watch, or just a tight polling loop — the name is known the instant it appears. - **Checking first.** `if not os.path.exists(path): open(path, "w")` is the race written out longhand. - **The sticky bit.** It stops other users deleting your files; it does not stop them creating names. - **Deleting afterwards.** Cleanup is hygiene, not a control. ### The remaining caveats worth knowing `O_EXCL` is a kernel guarantee about a local filesystem. On old NFS versions it was documented as unreliable, which is one more reason not to place security-sensitive staging files on a shared network mount. And the guarantee covers *creation only*: once you close the descriptor and go back to the path, you are back in the shared namespace. If you need many temp files, `tempfile.mkdtemp` gives you a directory with mode `0o700`, and inside a directory nobody else may enter, plain names are safe again. `tempfile.mktemp` still exists in Python 3.14 and still works, which is why it still shows up in code review. Treat every appearance as a finding.

  • What concretely happens if the attacker wins the race with a symlink?
    Your `open(path, 'w')` follows the link and truncates its target, then writes your data into it. The attacker needed no write access to that file — they used your process's credentials. Typical targets are files a service can write but a normal user cannot: state files, config fragments, or anything another component reads and trusts. The temp file itself is never the prize.
  • Why does tempfile.mkstemp return an integer descriptor rather than an open file object?
    Because the descriptor *is* the guarantee. It refers to the exact object the exclusive create produced, and it keeps referring to it no matter what happens to the name afterwards. Handing back a path-reopened file object would quietly discard that. Wrap it with `os.fdopen` when you want buffered text writes, and let the resulting object's close handle the descriptor.
  • Is the O_EXCL guarantee absolute?
    It is a guarantee the local filesystem's kernel code makes. On older NFS implementations exclusive create was documented as unreliable, so security-sensitive staging on a shared network mount deserves suspicion. It is also a guarantee about creation only — after you drop the descriptor the path is an ordinary name in a shared directory again.

mktemp is being told a hotel room looks empty and walking there with your bags; mkstemp is being handed the key at the desk, with the desk refusing outright if someone already has it.

saying these in an interview costs you the question

  • Says mktemp is fine if you open the file immediately
  • Thinks a longer random suffix closes the race
  • Confuses the shell mktemp command with tempfile.mktemp
  • Believes os.path.exists() before open() prevents the race
  • Cannot say what time-of-check-to-time-of-use means
  • Thinks the danger is name collision rather than symlink following

context

open as a page

What does tempfile.NamedTemporaryFile give you that open('/tmp/report.csv','w') does not?

level: juniorimportance: should knowfreq 38%

basics

~20 s

tempfile.NamedTemporaryFile creates a uniquely named file atomically, with owner-only 0o600 permissions, and removes it when closed. A fixed /tmp path is guessable, may already exist as somebody else's symlink, and inherits a looser mode from the umask.

open as a page

tempfile.gettempdir() picks a directory from the environment — how do you harden a job that trusts it?

level: seniorimportance: should knowfreq 30%

basics

~20 s

tempfile.gettempdir() honours TMPDIR, TEMP and TMP from the inherited environment before falling back to /tmp. For sensitive staging, pass an explicit dir= you created yourself with mode 0o700 rather than trusting whatever the caller exported.

open as a page

Your job closes the fd from tempfile.mkstemp, then reopens the file by its path — what protection is lost?

level: seniorimportance: should knowfreq 22%

basics

~10 s

The exclusive-creation guarantee covers creation only. Once the descriptor is closed the path is just a name in a shared directory, and can be unlinked or replaced by a symlink before you reopen it.

open as a page