Security Report: TOCTOU Race Condition in Filesystem Write Path Validation
Component: crates/monty/src/fs/path_security.rs, crates/monty/src/fs/common.rs
Category: Filesystem Security Boundary — Time-of-Check-Time-of-Use (TOCTOU)
Summary
As requested after mail disclosure, here the public issue.
Monty's filesystem security boundary contains a Time-of-Check-Time-of-Use (TOCTOU) gap between path validation and the actual filesystem operation in write paths (write_text, write_bytes, mkdir, rename). When multiple independent processes or sessions share the same host mount directory backing a Monty sandbox, a concurrent process outside the sandbox can create a symbolic link at a validated path after validation completes but before the write executes. This causes the write to follow the symlink and write to an arbitrary location on the host filesystem.
Important: This vulnerability is not exploitable by pure Monty sandbox code alone, because Monty does not expose any API for creating symbolic links (os.symlink, Path.symlink_to, etc.). Exploitation requires a concurrent external actor with write access to the shared mount directory.
Example
This would allow to access resources that are symlinked at the right point in time to not be checked against the "deny list" if the user has a way to dynamicly generate symlinks.
Affected Code
path_security.rs — resolve_creation() (lines 205–227)
fn resolve_creation(request: &ResolutionRequest, mount_host_path: &Path) -> Result<PathBuf, MountError> {
if request.candidate_host.exists() {
return resolve_existing(request, mount_host_path);
}
let parent = request.candidate_host.parent().ok_or_else(|| ...)?;
let file_name = request.final_component()?;
// Phase 1: Validate parent directory boundary
let canonical_parent = fs::canonicalize(parent)
.map_err(|err| MountError::Io(err, request.normalized_virtual.clone()))?;
check_boundary(&canonical_parent, mount_host_path, &request.normalized_virtual)?;
// Phase 2: Validate that target is not a symlink
let resolved_path = canonical_parent.join(file_name);
validate_creation_symlink_target(
&resolved_path,
&canonical_parent,
mount_host_path,
&request.normalized_virtual,
)?;
// Phase 3: Return validated path for use
Ok(resolved_path) // <-- validated, but not atomically locked
}
common.rs — write_text_fs() / write_bytes_fs() (lines 52–68)
pub(super) fn write_text_fs(path: &Path, content: &str, vpath: &str) -> Result<MontyObject, MountError> {
reject_directory(path, vpath)?;
fs::write(path, content).map_err(|err| MountError::Io(err, vpath.to_owned()))?;
Ok(MontyObject::Int(i64::try_from(content.chars().count()).unwrap_or(i64::MAX)))
}
The fs::write() call in step 3 follows symlinks. If a symlink appears at path between validate_creation_symlink_target() (step 2) and fs::write() (step 3), the write escapes the mount boundary.
Attack Prerequisites
This vulnerability has strict prerequisites that limit its exploitability:
- Multiple sessions or processes must share the same host mount directory.
- Example: 150 Monty sessions all backed by
/tmp/shared-mount → /mnt/data.
- An external actor (not sandboxed Monty code) must create a symlink at the target host path.
- Monty does not expose
os.symlink(), Path.symlink_to(), or any symlink creation API.
- The symlink must be created by a separate process or by host-level access.
- The symlink creation must win the race between validation and write.
- The attacker must predict the write target path and time the symlink creation.
Without all three prerequisites, this vulnerability is not exploitable.
Attack Scenario (Multi-Session Shared Mount)
Setup:
- Host directory
/tmp/shared-mount is mounted at /mnt/data for 150 concurrent Monty sessions.
- Session A runs attacker-controlled Python code.
- A separate host process (or another session with host-level access) can write to
/tmp/shared-mount.
Steps:
- Attacker identifies a predictable write path used by another session:
/mnt/data/results.json.
- Attacker's host process runs a high-frequency loop:
while true; do
ln -sf /etc/passwd /tmp/shared-mount/results.json 2>/dev/null
rm -f /tmp/shared-mount/results.json 2>/dev/null
done
- Another session calls
Path('/mnt/data/results.json').write_text('malicious').
- Monty core calls
resolve_creation():
- Validates
/tmp/shared-mount is the mount root.
- Checks
/tmp/shared-mount/results.json does not exist.
- Returns
ResolvedPath { host_path: "/tmp/shared-mount/results.json" }.
- Race window: Attacker's host process creates
results.json -> /etc/passwd.
- Monty core calls
fs::write("/tmp/shared-mount/results.json", "malicious").
- The write follows the symlink and overwrites
/etc/passwd.
Why Existing Symlink Defenses Are Insufficient
Monty already defends against pre-existing symlink escapes:
resolve_existing (for reads): Uses fs::canonicalize(), which resolves symlinks and then checks check_boundary() on the resolved target.
validate_creation_symlink_target (for writes): Checks if the target path is already a symlink and validates its destination stays within the mount.
reject_escaping_symlink: Used by overlay rename to prevent capturing outbound symlinks.
These defenses are static checks at a single point in time. They do not protect against a symlink that is created after the check but before the filesystem operation.
Proposed Fixes
Option A: Atomic Open with O_NOFOLLOW (Recommended)
Open the file with O_NOFOLLOW (Unix) or FILE_FLAG_OPEN_REPARSE_POINT (Windows) so that if a symlink exists at the validated path, the open fails instead of following it. This is the standard defense against symlink races.
Unix:
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
Windows:
Use FILE_FLAG_OPEN_REPARSE_POINT in CreateFileW to prevent following reparse points (symlinks, junctions).
Option B: Re-Validate Immediately Before Write
Add a second validate_creation_symlink_target check immediately before fs::write(). This narrows the race window from milliseconds to microseconds but does not eliminate it.
Option C: Use fs::rename() from a Temporary File
Write to a temporary file in the same directory, then fs::rename() it to the final path. rename() replaces the directory entry atomically without following symlinks. This changes semantics (e.g., write_text truncates differently) but is race-safe.
Option D: Document Defense-in-Depth
Recommend that production deployments use unique mount directories per session rather than sharing a single directory across sessions. This does not fix the race but removes the concurrent access prerequisite.
Recommendations
- Immediate: Implement Option A (
O_NOFOLLOW / FILE_FLAG_OPEN_REPARSE_POINT) for all write operations in crates/monty/src/fs/common.rs.
- Short-term: Add concurrent filesystem security tests that simulate multi-session races.
- Documentation: Update
AGENTS.md to recommend unique mount directories per session as a defense-in-depth measure.
- CI: Add a regression test that validates
write_text/write_bytes fail when the target is a symlink created after validation.
References
--
Date: 2026-05-19
Security Report: TOCTOU Race Condition in Filesystem Write Path Validation
Component:
crates/monty/src/fs/path_security.rs,crates/monty/src/fs/common.rsCategory: Filesystem Security Boundary — Time-of-Check-Time-of-Use (TOCTOU)
Summary
As requested after mail disclosure, here the public issue.
Monty's filesystem security boundary contains a Time-of-Check-Time-of-Use (TOCTOU) gap between path validation and the actual filesystem operation in write paths (
write_text,write_bytes,mkdir,rename). When multiple independent processes or sessions share the same host mount directory backing a Monty sandbox, a concurrent process outside the sandbox can create a symbolic link at a validated path after validation completes but before the write executes. This causes the write to follow the symlink and write to an arbitrary location on the host filesystem.Important: This vulnerability is not exploitable by pure Monty sandbox code alone, because Monty does not expose any API for creating symbolic links (
os.symlink,Path.symlink_to, etc.). Exploitation requires a concurrent external actor with write access to the shared mount directory.Example
This would allow to access resources that are symlinked at the right point in time to not be checked against the "deny list" if the user has a way to dynamicly generate symlinks.
Affected Code
path_security.rs—resolve_creation()(lines 205–227)common.rs—write_text_fs()/write_bytes_fs()(lines 52–68)The
fs::write()call in step 3 follows symlinks. If a symlink appears atpathbetweenvalidate_creation_symlink_target()(step 2) andfs::write()(step 3), the write escapes the mount boundary.Attack Prerequisites
This vulnerability has strict prerequisites that limit its exploitability:
/tmp/shared-mount→/mnt/data.os.symlink(),Path.symlink_to(), or any symlink creation API.Without all three prerequisites, this vulnerability is not exploitable.
Attack Scenario (Multi-Session Shared Mount)
Setup:
/tmp/shared-mountis mounted at/mnt/datafor 150 concurrent Monty sessions./tmp/shared-mount.Steps:
/mnt/data/results.json.Path('/mnt/data/results.json').write_text('malicious').resolve_creation():/tmp/shared-mountis the mount root./tmp/shared-mount/results.jsondoes not exist.ResolvedPath { host_path: "/tmp/shared-mount/results.json" }.results.json -> /etc/passwd.fs::write("/tmp/shared-mount/results.json", "malicious")./etc/passwd.Why Existing Symlink Defenses Are Insufficient
Monty already defends against pre-existing symlink escapes:
resolve_existing(for reads): Usesfs::canonicalize(), which resolves symlinks and then checkscheck_boundary()on the resolved target.validate_creation_symlink_target(for writes): Checks if the target path is already a symlink and validates its destination stays within the mount.reject_escaping_symlink: Used by overlay rename to prevent capturing outbound symlinks.These defenses are static checks at a single point in time. They do not protect against a symlink that is created after the check but before the filesystem operation.
Proposed Fixes
Option A: Atomic Open with
O_NOFOLLOW(Recommended)Open the file with
O_NOFOLLOW(Unix) orFILE_FLAG_OPEN_REPARSE_POINT(Windows) so that if a symlink exists at the validated path, the open fails instead of following it. This is the standard defense against symlink races.Unix:
Windows:
Use
FILE_FLAG_OPEN_REPARSE_POINTinCreateFileWto prevent following reparse points (symlinks, junctions).Option B: Re-Validate Immediately Before Write
Add a second
validate_creation_symlink_targetcheck immediately beforefs::write(). This narrows the race window from milliseconds to microseconds but does not eliminate it.Option C: Use
fs::rename()from a Temporary FileWrite to a temporary file in the same directory, then
fs::rename()it to the final path.rename()replaces the directory entry atomically without following symlinks. This changes semantics (e.g.,write_texttruncates differently) but is race-safe.Option D: Document Defense-in-Depth
Recommend that production deployments use unique mount directories per session rather than sharing a single directory across sessions. This does not fix the race but removes the concurrent access prerequisite.
Recommendations
O_NOFOLLOW/FILE_FLAG_OPEN_REPARSE_POINT) for all write operations incrates/monty/src/fs/common.rs.AGENTS.mdto recommend unique mount directories per session as a defense-in-depth measure.write_text/write_bytesfail when the target is a symlink created after validation.References
open(2)—O_NOFOLLOWCreateFileW—FILE_FLAG_OPEN_REPARSE_POINT--
Date: 2026-05-19