A Denial-of-Service Bug in Go's Standard Library
One of the more persistent assumptions in application security is that standard library functions are safe. Developers reasonably expect that os.RemoveAll in Go will delete a directory tree and return, either successfully or with an error. During an investigation into CI/CD pipeline failures, we discovered a condition under which this function enters an infinite loop, consuming a goroutine indefinitely. The bug, which we reported as Go issue #29921, demonstrates how trust in standard library correctness can create denial-of-service vulnerabilities in production systems.
The mechanism is straightforward. Go's os.RemoveAll reads directory entries in batches of 1024. After each batch, it attempts to delete every entry. If the batch returns fewer than 1024 entries, the function assumes the directory has been fully enumerated and breaks out of its read loop. The problem arises when deletion fails for every entry in a batch: the directory still contains the same entries, the next read returns another full batch of 1024, and the function never reaches the termination condition. The loop continues indefinitely, re-reading and failing to delete the same entries in perpetuity.
The Docker UID Mismatch Trigger
The specific trigger we encountered was a Docker UID mismatch in a CI/CD pipeline. A build step running as root inside a container created a node_modules directory tree. A subsequent cleanup step, running as a non-root user, attempted to remove this tree. The directory @angular/common/locales contained approximately 1,050 entries — just above the 1,024 batch threshold. Because the files were owned by root and the cleanup process ran as a different UID, every deletion attempt failed with a permission error. The function returned neither success nor failure; it simply never returned.
Since Go's goroutines are cooperatively scheduled, a goroutine stuck in an infinite loop does not yield control to the scheduler unless it performs a blocking operation. In practice, the filesystem reads provided enough scheduling points to prevent complete starvation, but the goroutine consumed CPU and held open file descriptors for the lifetime of the process. In a server context, an attacker who could cause the application to call os.RemoveAll on a crafted directory — for example, a temporary upload directory with controlled contents — could trigger this condition intentionally. Each invocation would permanently consume a goroutine, providing a clean denial-of-service vector with no crash, no error log, and no obvious symptom beyond gradually increasing resource consumption.
Standard Library Trust as a Security Risk
This finding reinforces a principle we apply in all security assessments: standard library functions define a contract, and that contract must be verified under adversarial conditions. The Go standard library is well-engineered, but it was designed with cooperative inputs in mind. When inputs become adversarial — directory structures crafted to exploit batch-size boundaries, file permissions that prevent deletion — the assumptions embedded in the implementation break down. The fix, which was eventually applied to the Go standard library, tracks whether any progress was made during each iteration and breaks the loop when the directory is non-empty but no entries can be removed.
For security practitioners, the broader lesson is that any function that loops over external state (filesystem, network, database) must be evaluated for termination guarantees. We recommend wrapping calls to os.RemoveAll and similar functions with explicit timeouts in security-sensitive contexts, and we routinely flag Docker UID mismatches in CI/CD pipeline reviews as a precondition for this and related classes of defect. The intersection of container isolation boundaries and host filesystem permissions remains one of the most fertile sources of unexpected behavior in modern deployment architectures.