Skip to content

audit: TOCTOU on filesystem paths — 13 sites (corrosion_mitigation::fs_path_toctou) #256

@zancas

Description

@zancas

Tracking 13 instances of CLAUDE.md checklist item #1 ("TOCTOU on filesystem paths") found in tree on branch dev. Test scaffold for the class will land at zcash_local_net/tests/corrosion_mitigation/fs_path_toctou.rs (this subclass is corrosion_mitigation::fs_path_toctou).

The pattern, from CLAUDE.md:

Operating on the same &Path across multiple syscalls lets an attacker swap a symlink between the check and the use, redirecting privileged operations to unintended targets.

Two of the four highest-risk sites have a deterministic exploit (no race-window required): Path::exists() follows symlinks, so a pre-placed dangling or valid symlink fools the check, and the subsequent cp -r (which re-resolves the path on its own) writes to or reads from the symlink's target.

Tier 1 — externally-attacker-influenceable trust boundary

# Site Pattern Exploit
A1 zcash_local_net/src/utils/executable_finder.rs:26 → caller pick_command path.exists() then Command::new(path) (exec) Symlink-swap of the binary at $TEST_BINARIES_DIR/<name> between check and execve → arbitrary code execution. Mitigation: drop the redundant .exists() and let Command::new(...).spawn() fail with ENOENT. The .exists() check also creates the silent-fallback bug observed in test failures on dev (zainod missing → fall back to PATH → spawn fails with confusing error).
A2 zcash_local_net/src/validator.rs:366 (Validator::cache_chain default impl), used at :374 assert!(!chain_cache.exists()) then cp -r self.data_dir() chain_cache Deterministic. A dangling symlink at chain_cache reports exists() == false (since exists() follows symlinks and the target is missing), the assertion passes, and cp -r DST follows the symlink to write the data dir contents wherever the attacker pointed.
A3 zcash_local_net/src/validator/zebrad.rs:599 (Zebrad::load_chain), used at :604 assert!(state_dir.exists()) then cp -r state_dir validator_data_dir Deterministic. A valid symlink at chain_cache/state pointing to an attacker-controlled directory passes exists() and cp -r SRC reads the attacker's files into the validator data dir.
A4 zcash_local_net/src/validator/zcashd.rs:425 (Zcashd::load_chain), used at :429 Same as A3 with chain_cache/regtest Same as A3.

Tier 2 — tempdir-scoped, lower real-world risk but pattern hits

The log filenames stdout.log, stderr.log, lwd.log are constructed by joining a tempfile::TempDir (mode 0700) with a constant filename. Each call site re-resolves the same logical path string and re-issues a syscall:

# Creator Later syscall(s) on same logical path
B7 zcash_local_net/src/logs.rs:21–22 (File::create stdout.log) launch.rs:34 File::open, launch.rs:193 fs::read_to_string, logs.rs:13 File::open (via print_logprint_stdout/print_stderr), tests/integration.rs:436 fs::read_to_string, tests/integration.rs:892 fs::read_to_string
B8 zcash_local_net/src/logs.rs:26–27 (File::create stderr.log) launch.rs:38 File::open, launch.rs:195 fs::read_to_string, logs.rs:13 (via print_stderr), tests/integration.rs:436 fs::read_to_string
B9 zcash_local_net/src/indexer/lightwalletd.rs:113–114 (File::create lwd.log) launch.rs:43 File::open (when this is the additional_log_path), launch.rs:14 fs::read_to_string (via snapshot_additional_log at launch.rs:64, 104, 196)

Mitigation: keep the File handle returned by File::create and read through the FD on subsequent calls (or use *at syscalls relative to an open dir handle on the tempdir).

Tier 3 — write-then-read across function boundaries (config files)

# Writer Same-path re-read
C10 config.rs:33 write_zcashd_config config.rs:443, 462 fs::read_to_string (tests)
C11 config.rs:132 write_zebrad_config (no in-process re-read; consumed by zebrad child)
C12 config.rs:318 write_zainod_config config.rs:490 fs::read_to_string (test)
C13 config.rs:361 write_lightwalletd_config config.rs:526 fs::read_to_string (test)

Each writer itself does only one syscall per path; the pattern emerges across function boundaries when callers re-read by path. Tempdir-scoped, low risk.

Tier 4 — redundant-check smell (test-only)

# Site Why it's a smell
A5 zcash_local_net/tests/testutils.rs:39–40 if !chain_cache_dir.exists() { fs::create_dir_all(...) }create_dir_all is itself idempotent. The exists() guard is redundant and introduces the TOCTOU pattern unnecessarily. Drop the guard.
A6 zcash_local_net/tests/testutils.rs:53–54 Same as A5, second occurrence in generate_zcashd_chain_cache.

Mitigations summary (per CLAUDE.md #1)

  • For "must-not-exist" sites (A2, A5, A6): use OpenOptions::create_new(true).open(p) (atomically refuses pre-existing or symlinked targets), and for directories DirBuilder::new().recursive(false).create(p).
  • For "must-exist" sites (A3, A4): replace Path::exists() with fs::symlink_metadata(p) (which does not follow symlinks) and reject if it's a symlink, or use *at syscalls relative to an opened directory FD.
  • For cp -r subprocess invocations: the subprocess re-resolves the path on its own and is the actual TOCTOU vector. Fixing the in-process assert! does not stop the subprocess from following a symlink. Real fix is to drop cp -r for a Rust-level recursive copy that opens the source as a directory FD once and walks via *at syscalls (or via walkdir + per-file FDs).
  • For A1: drop the redundant path.exists() and let Command::new(...).spawn() fail with ENOENT. The trust boundary is $TEST_BINARIES_DIR (operator-set), so this is a defense-in-depth fix — the operator owning that dir is presumed by the test harness anyway. Same change also fixes the silent-fallback-to-PATH bug observed today (zainod missing → cryptic spawn failure deep in the test).

What I did not find

  • No fs::canonicalize, read_link, symlink_metadata, set_permissions, chmod, rename, hard_link, or read_dir calls anywhere in tree.
  • No is_dir / is_file / is_symlink / metadata / try_exists predicates.
  • regtest-launcher/ does no filesystem I/O at all.
  • No OpenOptions::create_new(true) (which would be the recommended atomic-create pattern) — currently nothing in tree uses it.

Plan

Audit-test scaffold at zcash_local_net/tests/corrosion_mitigation/fs_path_toctou.rs. Tests are written so they FAIL while the pattern is in tree; will be landed alongside the fixes in a single PR so they go green by merge. Top sites (A1–A4) get behavioural exploit tests; lower-tier sites (B/C, A5/A6) are addressed when their fixes ship.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions