CWE-367: Time-of-check Time-of-use Race Condition - Go
Overview
Go makes concurrency cheap, so shared state is reached from many goroutines by default rather than by exception. A check and the action it guards run as separate steps, and the scheduler can place another goroutine between them at any point - there is no equivalent of a global interpreter lock to blur the question.
Two shapes account for most findings: a read-modify-write on a shared variable where the read and write are separate operations, and a filesystem check followed by an operation on the same path.
Common Vulnerable Patterns
Check-then-act on a shared counter
// VULNERABLE - two goroutines can both observe remaining > 0
type Inventory struct {
remaining int64
}
func (inv *Inventory) Reserve() error {
if atomic.LoadInt64(&inv.remaining) <= 0 {
return errors.New("out of stock")
}
// Another goroutine reserves the last unit here
atomic.AddInt64(&inv.remaining, -1)
return nil
}
Why this is vulnerable: Both operations are individually atomic, which is what makes this look correct. Atomicity of each step says nothing about the pair: the load and the add are two operations, and the value can change in between. The result is a negative count and more reservations than stock.
Existence check before file creation
// VULNERABLE - the path is resolved twice
func saveUpload(path string, data []byte) error {
if _, err := os.Stat(path); err == nil {
return errors.New("file already exists")
}
// Another process creates the path, or replaces it with a symlink, here
return os.WriteFile(path, data, 0o600)
}
Why this is vulnerable: os.Stat reports on the path at that moment and
os.WriteFile resolves it again, following symlinks. In a shared directory the
write lands wherever the attacker pointed the name, and the permission argument
does not apply to a file that already exists.
Secure Patterns
Compare-and-swap in a retry loop
// SECURE - the update only lands if the value is still what the check saw
func (inv *Inventory) Reserve() error {
for {
current := atomic.LoadInt64(&inv.remaining)
if current <= 0 {
return errors.New("out of stock")
}
if atomic.CompareAndSwapInt64(&inv.remaining, current, current-1) {
return nil
}
// Another goroutine won; re-read and decide again
}
}
Why this works: CompareAndSwapInt64 writes only if the variable still
holds the value the check was based on, so a goroutine that lost the race
observes the failure and re-evaluates rather than applying a decision made on a
stale read. The loop is the mechanism, not a workaround. In Go 1.19 and later,
atomic.Int64 with its CompareAndSwap method gives the same behaviour without
raw pointer arguments.
Use a sync.Mutex instead when the critical section covers more than one
variable - CAS protects a single word, and two coordinated CAS operations are
not a transaction.
Bind file operations to a descriptor
//go:build unix
// SECURE - creation is atomic; O_EXCL fails rather than overwriting
func saveUpload(path string, data []byte) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0o600)
if err != nil {
return err // os.ErrExist if another process got there first
}
defer f.Close()
if _, err := f.Write(data); err != nil {
return err
}
return f.Sync()
}
// SECURE - unpredictable name, created exclusively
func saveTemp(dir string, data []byte) (string, error) {
f, err := os.CreateTemp(dir, "upload-*")
if err != nil {
return "", err
}
defer f.Close()
if _, err := f.Write(data); err != nil {
return "", err
}
return f.Name(), f.Sync()
}
Why this works: O_CREATE|O_EXCL performs the existence check inside the
kernel, under the same lock as the creation, so a pre-existing path produces
os.ErrExist instead of an overwrite. O_NOFOLLOW refuses a symlinked final
component, and os.CreateTemp adds an unpredictable name to the same exclusive
creation, which removes the attacker's ability to pre-place the entry. Once the
file is open, further operations use the handle rather than the path, so a later
rename cannot redirect them.
The //go:build unix line is load-bearing rather than tidiness. O_NOFOLLOW
comes from syscall because os does not export it, and syscall.O_NOFOLLOW
is undefined on Windows - not zero, undefined - so a package containing
saveUpload fails to compile on a Windows builder unless the file is
constrained. Put the line first in the file, above the package clause and
separated from it by a blank line. saveTemp needs no such guard;
os.CreateTemp is portable, so keep it in an unconstrained file if Windows
builds need it.
Push the invariant into the database for shared state
// SECURE - condition and update evaluated together under the row lock
func Withdraw(ctx context.Context, db *sql.DB, accountID string, amount int64) error {
res, err := db.ExecContext(ctx, `
UPDATE accounts
SET balance = balance - $1
WHERE id = $2
AND balance >= $1`, amount, accountID)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return errors.New("insufficient funds")
}
return nil
}
Why this works: A mutex or CAS coordinates goroutines inside one process,
and a Go service is normally deployed as several replicas. Expressing the
condition as part of the UPDATE moves the guarantee to the one place all
replicas share, and RowsAffected reports the outcome at write time rather than
at read time. It does not say which part of the predicate failed: zero means no
row matched id = $2 AND balance >= $1, so either the account does not exist or
its balance was too low. Where the caller has to tell those apart, follow the
failed update with a SELECT for the id - nothing was written, so there is
nothing to undo.
Considerations
- Which boundary the state crosses. In-process state takes
sync.Mutexorsync/atomic; state shared between replicas takes the database. A finding "fixed" with a mutex on a multi-replica service is not fixed, and it will pass every single-instance test. - Atomic operations are not an atomic section.
atomic.LoadInt64followed byatomic.AddInt64is the vulnerable pattern above. If the decision spans two operations, it needs CAS or a mutex - the word "atomic" in the function name applies to each call, not the sequence. - Whether the race is reachable. State confined to one goroutine, or guarded by the channel that owns it, is not this weakness. Go's convention of communicating over channels rather than sharing memory means some findings are reports on code that is already single-owner - record those as false positives with the ownership reason.
- CAS loops need a bound where fairness matters. An unbounded retry under heavy contention can starve a goroutine indefinitely. A mutex is the better choice when the critical section is long or fairness matters more than throughput.
Testing
Go's race detector is the tool that makes this testable, and it is not enough on its own: it reports unsynchronised memory access, not a logically wrong interleaving between two correctly-atomic operations.
- Run the concurrency tests with
go test -race. Assert clean output, and treat any report as a defect rather than as noise. - Start N goroutines from a
sync.WaitGroupagainst stock that admits exactly one reservation, and assert exactly one success and a count that never goes below zero. Release them together so they contend; a sequential loop passes against the vulnerable version. - Use
t.Parallel()and-count=100so the scheduler explores different interleavings across runs. A race test that runs once has told you very little. - Pre-create the upload path and assert
errors.Is(err, os.ErrExist)and that the existing content is unchanged; point it at a symlink and assert the open fails. - Run the database test against two processes concurrently, which is the case a mutex-based fix cannot survive.