Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tuktam

license python version dependencies CWE CI

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.


The idea

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.


Feature summary

  • 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 := b aliases across the gap.
  • Confidence tiers: HIGH / MEDIUM / LOW, based on precision and aggravators (e.g. a sleep() widening a race window).
  • Numbered findings + an interactive Y<num> / N PoC 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-findings gate.
  • No dependencies, no privileges, no target execution for scanning - pure Python stdlib, runs anywhere including Windows.

Install & requirements

  • Python 3.8+ (stdlib only; ast.get_source_segment needs >= 3.8).
  • No third-party runtime dependencies.

Install the tuktam command (isolated, recommended):

pipx install tuktam

Or with pip / from a clone:

pip install tuktam           # from PyPI
pip install -e .             # editable, from a checkout

Then 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]"
pytest

Usage

python -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).


Output formats

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.


Desktop GUI

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 checkout

Pick 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.

tuktam GUI

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.

Windows .exe

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.exe

How detection works

Scanning 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/... -> languages heuristic + rules_state.scan_state
  • .go/.java/.cs/.rs -> the languages heuristic engine

Family 1 - filesystem TOCTOU (TOCTOU-FS)

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 are HIGH.
  • 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

Family 2 - thread/state TOCTOU (TOCTOU-STATE)

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:

  1. Sink - pthread_join / pthread_cancel / pthread_detach / pthread_kill on an obj->field (a thread handle).
  2. Guard - an earlier if/while (... obj->field ...) on the same base object.
  3. Unsynchronized - the function takes no mutex (pthread_mutex_lock, EnterCriticalSection, ...). If it does, the function is skipped (keeps false positives low).
  4. 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).

Family 3 - app check-then-act (TOCTOU-APP)

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:

  1. CHECK - a lookup/verify call (find*, first, exists, verify, ...).
  2. ACT - a later mutation (create, insert, update, delete, consume, revoke, ...).
  3. Correlated key - the two calls share a meaningful identifier (token, email, invite, ...), so unrelated calls aren't paired.
  4. 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.

Taint-lite

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.

Confidence tiers

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)

How PoC verification works

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:

  1. Deterministic proof - the attacker is fired exactly once inside the gap. If the use lands on the SECRET target, the script prints RACE WON and the pattern is proven exploitable in the model.
  2. 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%.

"Is it 100% when the race is won?"

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.


Real-world case study: LightFTP CVE-2024-11144

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.


Module reference

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

Limitations

  • Non-Python engines are line/brace heuristics, not full parsers -> occasional false positives/negatives. Python (AST) is precise.
  • Taint-lite follows only plain a = b copies - 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-swap unlink race 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.

License

MIT © Zero Science Lab


tuktam v3.42 (build 1700, "Kumanovo").

About

TOCTOU Race Detector

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages