Time-of-Check to Time-of-Use (TOCTOU)
Time-of-check to time-of-use is a class of bug in which a program verifies a condition, then acts on the assumption that the condition still holds, while something else changes the state in between. The gap between the check and the use is the vulnerability: whatever was verified is no longer what is being used.
Classic example: files
A privileged program is asked to write to a path supplied by a user. It first checks that the user is allowed to write to the path, then opens the path and writes. Between the two steps the user replaces the path with a symbolic link to a protected file. The check passed on the original target and the write lands on the new one.
The following program reproduces this with two files in a temporary directory:
import os, tempfile
d = tempfile.mkdtemp(); allowed = os.path.join(d, "report.txt"); secret = os.path.join(d, "secret.txt")
open(allowed, "w").write("public"); open(secret, "w").write("private")
link = os.path.join(d, "f"); os.symlink(allowed, link)
checked = os.path.realpath(link).endswith("report.txt") # time of check
os.remove(link); os.symlink(secret, link) # target swapped
print("check passed:", checked, "| use reads:", open(link).read()) # time of use
It prints check passed: True | use reads: private. The usual remedies close the gap rather than shrinking it. Open the object first, compare the opened handle with the identity of the file that is allowed, and read only if they match, so that the check and the use refer to the same thing:
def read_if_allowed(path):
fd = os.open(path, os.O_RDONLY) # bind to one object
try:
if not os.path.samestat(os.fstat(fd), os.stat(allowed)): # compare the opened object with the expected one
return "refused: not the allowed file"
return os.read(fd, 20) # read only after the check passes
finally:
os.close(fd)
os.remove(link); os.symlink(allowed, link)
print(read_if_allowed(link)) # b'public'
os.remove(link); os.symlink(secret, link)
print(read_if_allowed(link)) # refused: not the allowed file
The check now runs on the object that was actually opened, and the read happens only after it passes. When the name points to the allowed file, the function returns its content; when the name has been swapped to point to secret.txt, the opened object does not match and the function refuses without reading it. Swapping the name after the open no longer changes which object is read.
Other remedies are to make check and use one atomic operation (a transaction, a conditional update, an operation that fails on violation rather than a pre-check), to lock the resource for the duration, or to prefer "try the operation and handle the error" over "check, then try".
The same shape in agent workflows
In a workflow with human approval, a reviewer approves an action at one time and the runtime executes it later. Time passes between check and use, and several things can change in that interval: the action's arguments, the state of the target, the approver's authority, the policy in force, or the customer's intent.
Take a hypothetical approval of a 3000-cent refund on an order. If the runtime stores only "approved", then an agent that re-proposes the call with 5000 cents, or a refund issued through another channel in the meantime, is carried out under an approval that never covered it. The fix is to make the approval a statement about one exact action and the facts it was judged on, and to re-check those facts at execution:
import hashlib, json
order = {"version": 1, "refunded": 0}
def digest(call):
data = json.dumps(call, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(data).hexdigest()
def approve_bound(call, basis): return {"digest": digest(call), "basis": basis}
def execute_bound(call, approval):
if digest(call) != approval["digest"]: return "blocked: action differs from what was approved"
if order["version"] != approval["basis"]: return "blocked: order changed since approval"
order["refunded"] += call["cents"]; order["version"] += 1; return "executed"
call = {"tool": "refund", "order_id": "ord_8", "cents": 3000}
approval = approve_bound(call, order["version"])
print("changed amount:", execute_bound(dict(call, cents=5000), approval))
order["version"] += 1
print("changed order:", execute_bound(call, approval))
fresh = approve_bound(call, order["version"])
print("unchanged:", execute_bound(call, fresh))
Run on the example: the naive version executes a refund of 5000 cents. The bound version blocks the changed amount (digest mismatch), blocks execution after another refund incremented the order's version (basis mismatch), and executes the unchanged call against the unchanged order. The version number is the basis, a cheap fingerprint of the state the reviewer saw. Policy version and a validity window extend the same idea to rule changes and long delays.
Conditions and limits
- A re-check at execution only helps if it is atomic with the action. If the version is read and then the refund is written in two steps, another change can slip between them. Use a conditional update at the data store (
UPDATE ... WHERE version = ?) or a lock. - Re-checking cannot detect changes that leave the compared fingerprint unchanged. The basis must include every fact the decision depended on.
- Checks stale by design, such as a cached permission with a time-to-live, trade freshness for cost. Choose the window by what a stale answer costs.
- TOCTOU is a kind of race condition. It is not the same as a general race: the defining feature is a security or correctness check separated from the use of its result.
References
Discover more from Insightful Data Lab
Subscribe to get the latest posts sent to your email.
