A focused, cross-language detector for time-of-check / time-of-use races (CWE-367, CWE-362) - the bug class behind a large share of local privilege escalations and remote DoS. It finds the pattern in source and then generates a runnable PoC that verifies the race is real.
Every TOCTOU reduces to one OS-agnostic invariant:
A program checks a resource by name / by shared state, then uses it - instead of operating on a stable handle it already holds under a lock. An attacker mutates what the name/state refers to in the gap, so the use lands on something else (a symlinked secret, a recycled thread id).
Two concrete shapes fall out of that one invariant, and tuktam detects both:
| Family | Check | Use | Attacker swaps | Impact |
|---|---|---|---|---|
| Filesystem | access/stat/exists on a path |
open/chmod/unlink on the same path |
a symlink / junction / rename | privilege escalation, arbitrary read/write |
| Thread / shared state | if (ctx->flag) unlocked |
pthread_join(ctx->tid) |
the shared struct via a concurrent thread | crash / DoS, use-after-free |
| App check-then-act | store.find(token) |
store.create/consume(...) |
the record/token via a concurrent request | token reuse, duplicate accounts, double-spend |
Every engine emits the same Finding (tuktam/model.py). That shared schema
is the "universal" part - every engine speaks it, so one reporter and one PoC
generator serve them all.
- Languages: Python (AST, precise), C/C++, Go, Java, C#/.NET, Rust, TypeScript/JS, Ruby, PHP (heuristic engines).
- 3 rule families: filesystem TOCTOU (
TOCTOU-FS), unsynchronized thread/state TOCTOU (TOCTOU-STATE, C/C++), and application non-atomic check-then-act (TOCTOU-APP, Go/TS/JS/Java/C#/Ruby/PHP). - Taint-lite: follows plain
a = b/a := baliases across the gap. - Confidence tiers:
HIGH/MEDIUM/LOW, based on precision and aggravators (e.g. asleep()widening a race window). - Numbered findings + an interactive
Y<num>/NPoC prompt. - PoC generator: emits a standalone script that verifies each race with a deterministic proof and an empirical win-rate. Faithful symlink mode plus a privilege-free fallback for Windows.
- CI-ready output:
text/json/sarif(GitHub code scanning), plus a--fail-on-findingsgate. - No dependencies, no privileges, no target execution for scanning - pure Python stdlib, runs anywhere including Windows.
- Python 3.8+ (stdlib only;
ast.get_source_segmentneeds >= 3.8). - No third-party runtime dependencies.
Install the tuktam command (isolated, recommended):
pipx install tuktamOr with pip / from a clone:
pip install tuktam # from PyPI
pip install -e . # editable, from a checkoutThen run it as tuktam <target>. No install needed either - the package also
runs in place:
python -m tuktam <target>Development (tests):
pip install -e ".[dev]"
pytestpython -m tuktam TARGET [options]
positional:
TARGET file or directory to scan
options:
-f, --format {text,json,sarif} report format (default: text)
-o, --output FILE write the report to FILE
--fail-on-findings exit 1 if any finding (CI gate)
--poc N non-interactively generate a PoC for finding #N
--poc-dir DIR output dir for PoCs (default: poc/)
--prompt force the interactive PoC prompt (even when piped)
--no-prompt never show the interactive PoC prompt
--no-banner suppress the startup banner
--version show version and exit
Samples
python -m tuktam ./src # scan a tree
python -m tuktam app.py # scan one file
python -m tuktam ./src -f sarif -o tuktam.sarif # SARIF for GitHub code scanning
python -m tuktam ./src --fail-on-findings # CI gate
python -m tuktam app.py --poc 3 # PoC for finding #3, no prompt
python -m tuktam app.py --poc 3 --poc-dir out/ # ...into out/After a text-mode scan on a terminal, findings are numbered and you're asked:
Create a PoC script for a detected vuln? [N / Y<num>, e.g. Y3]: Y3
[+] PoC #3 written: poc/poc_003_python_access_chmod.py
run it: python poc/poc_003_python_access_chmod.py
Y3 -> PoC for finding #3 - N (or empty) -> skip - bad input -> re-asks with the
valid range. Accepts Y3, y 3, y3 (case-insensitive).
text (default)
tests/fixtures/vuln.py
#1 [HIGH ] TOCTOU-FS check exists()@6 -> use open()@7 on `path` (python)
'exists' checks the path, then 'open' re-opens it by name - attacker
can swap the target in between.
fix: Open the path ONCE to a handle/fd, then operate on that fd
(os.open(..., O_NOFOLLOW|O_EXCL) + os.fstat). Never re-resolve the name.
json - the raw Finding list (file, check_line, use_line, path_expr,
check_call, use_call, language, confidence, rule_id, message,
suggestion, index). Ideal for piping into other tooling.
sarif - SARIF 2.1.0 with a tool driver (name/version/organization) and both rule descriptors, so GitHub code scanning renders findings inline on PRs.
A stdlib Tkinter GUI ships alongside the CLI - same engine, still no extra dependencies. Launch it with:
tuktam-gui # installed
python -m tuktam.gui # from a checkoutPick a file or folder, hit Scan, and browse findings grouped by file and colored by severity. Selecting a finding shows its message and fix plus the offending source with the check and use lines highlighted; one click generates a PoC for it, and the whole run exports to JSON or SARIF from the same window.
Findings grouped by file and colored by severity; the selected finding shows its message, fix, and source with the check (amber) and use (red) lines highlighted.
Bundle a standalone, no-Python-required executable with PyInstaller:
pip install pyinstaller
pyinstaller --onefile --windowed --name tuktam-gui packaging/tuktam_gui.py
# -> dist/tuktam-gui.exeScanning is static: the code is parsed/scanned, never executed. Files are
dispatched by extension in tuktam/static/engine.py:
.py->rules_python.scan_python(AST).c/.cpp/.h/...->languagesheuristic +rules_state.scan_state.go/.java/.cs/.rs-> thelanguagesheuristic engine
The invariant: within one function scope, a check-by-name call is followed by a use-by-name call on the same path expression, with no atomic handle in between.
- Python (
rules_python.py) uses the real AST - true scopes, true argument nodes - so it is precise. Direct hits areHIGH. - C, Go, Java, C#, Rust share one engine (
heuristic.py) driven by per-language pattern tables (languages.py). It uses regex for the check/use calls and brace-depth scoping so a check only pairs with a use in the same (or a nested) block. Method-style checks (f.exists(),p.is_file()) capture the receiver as the path; call-style checks capture the argument.
Check/use vocab per language (excerpt):
| Lang | check | use | safe form |
|---|---|---|---|
| C | access stat lstat |
open fopen unlink chmod |
openat(...,O_NOFOLLOW) + fstat(fd) |
| Python | os.path.exists os.access |
open os.chmod shutil.copy |
os.open(...,O_EXCL) + os.fstat |
| Go | os.Stat os.Lstat |
os.Open os.Remove os.WriteFile |
os.OpenFile handle |
| Java | f.exists() Files.exists |
new FileInputStream Files.delete |
NOFOLLOW_LINKS channel |
| C# | File.Exists |
File.OpenRead File.Delete |
FileMode.CreateNew |
| Rust | fs::metadata p.is_file() |
File::open fs::remove_file |
OpenOptions + O_NOFOLLOW |
Not every TOCTOU touches the filesystem. rules_state.py (C/C++) detects the
unsynchronized shared-state race: a thread reads a shared field in a guard,
then acts on a related handle without holding the lock other code uses for it.
Per function (brace-scoped) it looks for:
- Sink -
pthread_join/pthread_cancel/pthread_detach/pthread_killon anobj->field(a thread handle). - Guard - an earlier
if/while (... obj->field ...)on the same base object. - Unsynchronized - the function takes no mutex
(
pthread_mutex_lock,EnterCriticalSection, ...). If it does, the function is skipped (keeps false positives low). - Aggravator - a
sleep()/usleep()/Sleep()between guard and sink -> the window is huge and attacker-triggerable ->HIGH.
This is the exact shape of CVE-2024-11144 (see the case study below).
The dominant modern race class (most 2026 race-condition CVEs). Not a symlink, not a thread - application logic: a shared record/token is checked, then mutated, with no transaction or lock. Two concurrent requests both pass the check before either acts, so a one-time action happens twice (invite-token reuse, duplicate accounts from a single-use token, double-spend).
rules_app.py runs over Go, TypeScript/JS, Java, C#, Ruby, and PHP (brace-scoped
per function) and flags:
- CHECK - a lookup/verify call (
find*,first,exists,verify, ...). - ACT - a later mutation (
create,insert,update,delete,consume,revoke, ...). - Correlated key - the two calls share a meaningful identifier
(
token,email,invite, ...), so unrelated calls aren't paired. - No atomicity guard in scope - if the function uses a transaction, row lock
(
SELECT ... FOR UPDATE),ON CONFLICT,synchronized, mutex, or CAS, it's skipped.
tests/fixtures/app_invite.ts
#1 [MEDIUM] TOCTOU-APP check find()@3 -> use update()@8 on `token` (ts)
Non-atomic check-then-act on shared state: 'find(...)' then 'update(...)'
share key 'token' with no transaction/lock in scope - two concurrent
requests can both pass the check before either acts (CWE-367).
This family is heuristic and cross-language -> MEDIUM confidence. The keyed
correlation and guard suppression are what keep it precise: in the fixtures, the
transaction-wrapped *Safe variants produce zero findings.
Both engines follow plain copies (a = b, a := b, let a = b) across the
gap, so a check on one name pairs with a use of its alias. Alias-linked findings
are reported at lower confidence to signal the indirection. Path-building
(a = join(dir, name)) and reassignment ordering are intentionally not modeled
yet - that's full dataflow, and it's future work.
| Tier | Meaning |
|---|---|
| HIGH | Python AST direct hit, or a state race with a sleep() window |
| MEDIUM | Heuristic direct hit, Python alias-linked hit, or an unslept state race |
| LOW | Heuristic alias-linked hit (indirection increases FP odds) |
tuktam/poc.py turns a Finding into a standalone, runnable Python script.
It reconstructs the detected check -> use window and swaps what the target
resolves to inside that window, then reports two independent results:
- Deterministic proof - the attacker is fired exactly once inside the gap.
If the use lands on the SECRET target, the script prints
RACE WONand the pattern is proven exploitable in the model. - Empirical win-rate - a free-running attacker thread races the victim over thousands of trials; the script reports how often it wins. This shows the race is winnable without an artificially widened window.
For filesystem findings the "use" is mapped to the right attack - read (leak a secret), write (overwrite a protected file through a symlink), or chmod (make a secret world-writable):
- symlink mode (default, faithful): follows a symlink to a protected target - real privilege escalation. Needs symlink privilege (Linux/macOS; Windows with Developer Mode/admin).
--content-swap(privilege-free fallback): swaps file content instead of a link; auto-engages when symlinks aren't permitted. Enough to demonstrate read races on locked-down Windows.--wide: widens the window with a tiny sleep - a deterministic teaching demo.
For state findings the PoC models the thread lifecycle: a shared {valid, tid}
struct, a victim that checks valid then joins tid without a lock, and a
concurrent thread that recycles the context in the gap -> the join hits a stale id
-> modeled SIGSEGV (DoS).
For app findings the PoC models the shared store: two concurrent requests
both pass the check for a single-use key before either acts -> the one-time action
runs twice (RACE WON). --wide drives the empirical rate to 100%.
Short answer: the deterministic proof is 100% conclusive about the logic, not about your production binary.
- When the deterministic proof prints
RACE WON, it has proven that the check->use gap allows the use to land on an attacker-controlled target. There is no false positive on the pattern - the window is real and the logic is exploitable. That's the 100% claim, and it's a claim about the reconstructed model of the detected pattern. - It does not prove that your specific deployed program is exploitable end-to-end. Real exploitation still depends on reachability (can an attacker reach that code path?), timing (how wide is the real window? - see the empirical rate), and privileges (symlink rights, filesystem layout).
- The empirical win-rate is your realism gauge: filesystem races typically win a single-digit-to-low-double-digit percentage of narrow-window trials, which is plenty for a patient attacker retrying in a loop.
So: RACE WON (deterministic) = "this is a genuine TOCTOU, not a false alarm."
Empirical % = "here's how winnable it is in practice." Turning that into a live
exploit against a real target is the last mile you drive by adapting the PoC's
clearly-marked victim() section. The generator is tuktam/poc.py; the CLI glue
(numbering + Y<num> prompt + --poc N) is tuktam/cli.py.
Target: hfiref0x/LightFTP v2.3 -
Advisory: Black Duck CyRC, CVE-2024-11144 (CVSS 7.5, remote DoS) -
Bug: unsynchronized thread-lifecycle TOCTOU in worker_thread_cleanup.
Step 1 - the filesystem engine alone finds nothing (this is not a filesystem race):
$ python -m tuktam LightFTP/Source
No TOCTOU patterns found.
Step 2 - with the TOCTOU-STATE family, the scanner lands on the exact line
the advisory names (ftpserv.c:240):
LightFTP/Source/ftpserv.c
#1 [HIGH] TOCTOU-STATE check context->WorkerThreadValid()@232 -> use pthread_join()@240 on `context->WorkerThreadId` (c)
Unsynchronized thread-state race: 'context->WorkerThreadValid' is checked,
then 'pthread_join(context->WorkerThreadId)' runs with no mutex held and a
sleep() widens the window - a concurrent thread can invalidate the handle
in the gap (CWE-362/367).
#2 [HIGH] TOCTOU-STATE check context->WorkerThreadValid()@232 -> use pthread_cancel()@244 on `context->WorkerThreadId` (c)
The vulnerable code:
void worker_thread_cleanup(PFTPCONTEXT context) {
if ( context->WorkerThreadValid == 0 ) { // TIME OF CHECK (line 232)
context->WorkerThreadAbort = 1;
sleep(2); // 2-second window, attacker-triggerable
err = pthread_join(context->WorkerThreadId, &retv); // TIME OF USE (line 240)
...
}
}Note that worker_thread_start() right below does take pthread_mutex_lock(&context->MTLock)
for the same state - but worker_thread_cleanup() doesn't. That asymmetry is the
bug: an EPRT-command anomaly triggers cleanup while a fresh connection reuses the
context, so WorkerThreadId changes during the sleep(2) and pthread_join
crashes the server.
Step 3 - the patch analysis. On the fixed v2.3.1 the scanner still flags the pattern (now at lines 253/257):
LightFTP/Source/ftpserv.c
#1 [HIGH] TOCTOU-STATE check context->WorkerThreadValid()@232 -> use pthread_join()@253 ...
That's correct: the official fix narrows the window (re-check
WorkerThreadValid == -1 before join, close sockets earlier, split the sleep) but
never adds a mutex to worker_thread_cleanup. The unsynchronized access
remains - the fix reduces the odds rather than eliminating the race. tuktam's
suggestion is exactly the durable fix: hold context->MTLock across both the
check and the join.
Step 4 - the generated PoC verifies the DoS model:
$ python -m tuktam LightFTP/Source/ftpserv.c --poc 1
$ python poc/poc_001_c_context_WorkerThreadValid_pthread_join.py
TOCTOU state PoC -- context->WorkerThreadValid -> pthread_join
================================================================
[*] Deterministic proof:
[+] RACE WON (DoS): pthread_join on invalid tid 62715 -> SIGSEGV
[+] check 'context->WorkerThreadValid' then 'pthread_join' is UNSYNCHRONIZED and exploitable.
[*] Empirical race: modeled cleanup calls hit an invalid join.
The PoC is a faithful model; a live exploit drives the real server (open a
session, trigger the EPRT anomaly while racing a fresh connection). The scanner
found it, explained it, out-analyzed the patch, and produced a verification - the
whole find -> explain -> verify loop on a real CVE.
tuktam/
├── __init__.py version/author/vendor constants + BANNER
├── __main__.py enables `python -m tuktam`
├── cli.py arg parsing, finding numbering, Y<num>/N PoC prompt, --poc
├── model.py Finding dataclass - the one schema every engine emits
├── report.py text / json / sarif renderers
├── poc.py PoC generator (filesystem + state + app templates)
├── static/
│ ├── engine.py walks a path, dispatches files by extension
│ ├── rules_python.py Python AST engine (TOCTOU-FS) + taint-lite -> precise
│ ├── heuristic.py generic regex + brace-scope engine + taint-lite
│ ├── languages.py per-language pattern tables (C/Go/Java/C#/Rust)
│ ├── rules_c.py back-compat shim over the heuristic engine
│ ├── rules_state.py thread/shared-state engine (TOCTOU-STATE, C/C++)
│ └── rules_app.py app check-then-act engine (TOCTOU-APP, Go/TS/JS/Java/C#/...)
tests/fixtures/ vulnerable + safe samples across the supported languages
- Non-Python engines are line/brace heuristics, not full parsers -> occasional false positives/negatives. Python (AST) is precise.
- Taint-lite follows only plain
a = bcopies - not path-building or reassignment ordering. Full dataflow is future work. unlink-style deletes in the filesystem PoC are demonstrated via destructive overwrite-through-symlink; a true directory-swapunlinkrace needs a dedicated harness (noted in the generated PoC).- Static analysis reports potential races. The PoC proves the pattern is exploitable; end-to-end exploitation of a specific binary still depends on reachability, timing, and privileges.
MIT © Zero Science Lab
tuktam v3.42 (build 1700, "Kumanovo").
