skip to content

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

level: seniorimportance: should knowfreq 22%

answer

  1. Two return values, unequal strength
  2. The descriptor points at the file itself
  3. The name is still a shared namespace
  4. Reopening restarts the original race
  5. A private directory removes the contest

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.

solid answer

~50 s

`tempfile.mkstemp` gives you two things — an open descriptor and a path — and only the descriptor carries the guarantee. It refers to the exact inode the exclusive `os.open` produced, and keeps doing so even if the name is later removed or repointed. The path is an ordinary entry in a directory other accounts may write to, so the moment you stop holding the descriptor you are back in the race the API was meant to close: an attacker can unlink your file and put a symlink at that name before your second open. In preference order: never drop the descriptor, wrapping it with `os.fdopen`; if a path must be shared, create the file inside a `tempfile.mkdtemp` directory, which is mode `0o700`; and if you must reopen, pass `os.O_NOFOLLOW` and compare `os.fstat` against the original.

code

python · 13 lines
python
import os
import tempfile

destination = "/tmp/etl-export.csv"
fd, path = tempfile.mkstemp(dir=os.path.dirname(destination), suffix=".part")
try:
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        f.write("id,amount\n1,83\n")
    os.replace(path, destination)
finally:
    if os.path.exists(path):
        os.unlink(path)
print(open(destination, encoding="utf-8").read())

go deeper

for a junior

Recall that tempfile.mkstemp returns a descriptor and a path, and that the descriptor is the one to keep. Use os.fdopen rather than reopening the path by name.

for a middle

Explain why: the descriptor is bound to the file itself, while the path is just an entry in a directory others can write to. Show os.fdopen for the write and os.replace for the promotion.

for a senior

Demonstrate the hand-off judgement: when a path must reach another process, create it inside a tempfile.mkdtemp directory rather than hardening the reopen; and if a reopen is unavoidable, use os.O_NOFOLLOW plus an os.fstat identity comparison.

for a principal

Own the pattern the estate uses for handing files between processes: a private per-run directory, a descriptor passed directly, or an object store — and be explicit that a shared temp path is not one of the acceptable options.

### Two return values, one guarantee `tempfile.mkstemp()` returns `(fd, path)`. It is tempting to read that as "a file, and where it is", but the two halves have very different strength. The descriptor is a kernel-held reference to a specific open file description, which in turn refers to a specific inode. It was produced by an `os.open` carrying `os.O_CREAT | os.O_EXCL`, so at the moment it came into existence no other object occupied that path and no symlink was followed. That binding is permanent for the life of the descriptor: rename the file, unlink it, replace the name with something else entirely — the descriptor still reads and writes the original inode. The path is a string naming an entry in a directory. If that directory is the shared temp directory, then every local account may create entries in it, and your name is protected only by the fact that something is currently there. The instant you unlink it, or the instant an attacker unlinks it (which the sticky bit prevents for files they do not own — but not for the many real cases where the directory is not sticky, or the file is theirs to begin with because they created a decoy), the name is available again. So the rule is: **closing the descriptor ends the guarantee.** Anything you do afterwards through the path is an ordinary, unprotected filesystem operation. ### Where this actually bites Three patterns produce it. **Close, then reopen.** Code creates the file with `mkstemp`, closes the descriptor because "I just wanted the name", and later does `open(path, "w")`. That is `tempfile.mktemp` with extra steps, and it has exactly the same symlink exposure. **Hand the path to another process.** A child, a helper binary, or a queued job is given the path and opens it itself. Between your creation and its open there is a wide window, and the child may run with different privileges — which is precisely what makes a hijack worth doing. **Reopen a NamedTemporaryFile by its .name.** The object holds a file object; taking `.name` and opening it separately is the same mistake wearing a nicer API. On POSIX the file is unlinked when the object closes, so the reopen may also simply fail, or succeed on a *different* file with the same name. ### The defences, strongest first **Never drop the descriptor.** `os.fdopen(fd, "w", encoding="utf-8")` gives a buffered text file whose close also closes the descriptor. Write everything, then promote with `os.replace(path, destination)` — a rename operates on names, not on your data, and is atomic within one filesystem. **Make the directory private.** `tempfile.mkdtemp()` creates a directory with mode `0o700`. Inside a directory no other account may even traverse, names are not contested, so passing a path to a child is safe again. `tempfile.TemporaryDirectory` is the same thing with cleanup attached. This is the right answer whenever a path genuinely has to leave your process. **If you must reopen, reopen defensively.** `os.open(path, os.O_RDWR | os.O_NOFOLLOW)` refuses to traverse a symlink at the final component. Then `os.fstat` the new descriptor and compare `st_dev` and `st_ino` against the values you recorded from the original — if they differ, the file was swapped and you should abort rather than continue. Note the order: stat the *descriptor* you just obtained, never the path, or you have reintroduced a check-then-use gap. **Keep the handle alive across the hand-off.** From Python 3.12, `tempfile.NamedTemporaryFile(delete_on_close=False)` lets you close the write handle so another reader can open the path on any platform, while the file survives until the context manager exits. That is a deliberate, bounded version of the hand-off rather than an accidental one. ### Why this is a senior question It separates people who learned a rule from people who understand the mechanism. Someone who has only learned "use `mkstemp`" will write `fd, path = tempfile.mkstemp(); os.close(fd)` without hesitating, because the unsafe function name is nowhere in sight. Someone who understands that the descriptor is the object and the path is only a label will notice immediately — and will reach for a private directory as soon as they hear that the path must be shared.

  • How does tempfile.mkdtemp change the picture?
    It creates a directory with mode `0o700`, so no other account can traverse it or create entries inside it. Within such a directory the name contest disappears entirely: an ordinary `open()` on a fixed filename is safe, and a path can be handed to a child process without exposure. `tempfile.TemporaryDirectory` is the same guarantee with automatic cleanup on exit.
  • What does os.O_NOFOLLOW buy you when reopening, and what does it not?
    It makes the open fail if the final path component is a symbolic link, which blocks the classic hijack. It does not protect intermediate directories in the path, and it does not tell you whether the file is still the same file — an attacker who can unlink and recreate a regular file defeats it. Pair it with an `os.fstat` comparison of `st_dev` and `st_ino` against the original.
  • Why compare os.fstat on the descriptor rather than os.stat on the path?
    Because `os.stat` on a path resolves the name again, so what you inspected and what you later use may be two different files — the check-then-use gap once more. `os.fstat` interrogates the object you are already holding, which is the only thing that cannot change underneath you.

saying these in an interview costs you the question

  • Believes mkstemp makes the path safe forever
  • Closes the descriptor immediately and keeps only the path
  • Reopens a NamedTemporaryFile by its .name attribute
  • Uses os.stat on the path to verify what it just opened
  • Hands a shared-directory temp path to a child process
  • Thinks os.replace across filesystems is still atomic

context