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_log → print_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.
Tracking 13 instances of
CLAUDE.mdchecklist item #1 ("TOCTOU on filesystem paths") found in tree on branchdev. Test scaffold for the class will land atzcash_local_net/tests/corrosion_mitigation/fs_path_toctou.rs(this subclass iscorrosion_mitigation::fs_path_toctou).The pattern, from
CLAUDE.md: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 subsequentcp -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
zcash_local_net/src/utils/executable_finder.rs:26→ callerpick_commandpath.exists()thenCommand::new(path)(exec)$TEST_BINARIES_DIR/<name>between check andexecve→ arbitrary code execution. Mitigation: drop the redundant.exists()and letCommand::new(...).spawn()fail withENOENT. The.exists()check also creates the silent-fallback bug observed in test failures ondev(zainod missing → fall back toPATH→ spawn fails with confusing error).zcash_local_net/src/validator.rs:366(Validator::cache_chaindefault impl), used at:374assert!(!chain_cache.exists())thencp -r self.data_dir() chain_cachechain_cachereportsexists() == false(sinceexists()follows symlinks and the target is missing), the assertion passes, andcp -r DSTfollows the symlink to write the data dir contents wherever the attacker pointed.zcash_local_net/src/validator/zebrad.rs:599(Zebrad::load_chain), used at:604assert!(state_dir.exists())thencp -r state_dir validator_data_dirchain_cache/statepointing to an attacker-controlled directory passesexists()andcp -r SRCreads the attacker's files into the validator data dir.zcash_local_net/src/validator/zcashd.rs:425(Zcashd::load_chain), used at:429chain_cache/regtestTier 2 — tempdir-scoped, lower real-world risk but pattern hits
The log filenames
stdout.log,stderr.log,lwd.logare constructed by joining atempfile::TempDir(mode 0700) with a constant filename. Each call site re-resolves the same logical path string and re-issues a syscall:zcash_local_net/src/logs.rs:21–22(File::create stdout.log)launch.rs:34File::open,launch.rs:193fs::read_to_string,logs.rs:13File::open(viaprint_log→print_stdout/print_stderr),tests/integration.rs:436fs::read_to_string,tests/integration.rs:892fs::read_to_stringzcash_local_net/src/logs.rs:26–27(File::create stderr.log)launch.rs:38File::open,launch.rs:195fs::read_to_string,logs.rs:13(viaprint_stderr),tests/integration.rs:436fs::read_to_stringzcash_local_net/src/indexer/lightwalletd.rs:113–114(File::create lwd.log)launch.rs:43File::open(when this is theadditional_log_path),launch.rs:14fs::read_to_string(viasnapshot_additional_logatlaunch.rs:64, 104, 196)Mitigation: keep the
Filehandle returned byFile::createand read through the FD on subsequent calls (or use*atsyscalls relative to an open dir handle on the tempdir).Tier 3 — write-then-read across function boundaries (config files)
config.rs:33write_zcashd_configconfig.rs:443, 462fs::read_to_string(tests)config.rs:132write_zebrad_configzebradchild)config.rs:318write_zainod_configconfig.rs:490fs::read_to_string(test)config.rs:361write_lightwalletd_configconfig.rs:526fs::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)
zcash_local_net/tests/testutils.rs:39–40if !chain_cache_dir.exists() { fs::create_dir_all(...) }—create_dir_allis itself idempotent. Theexists()guard is redundant and introduces the TOCTOU pattern unnecessarily. Drop the guard.zcash_local_net/tests/testutils.rs:53–54generate_zcashd_chain_cache.Mitigations summary (per
CLAUDE.md#1)OpenOptions::create_new(true).open(p)(atomically refuses pre-existing or symlinked targets), and for directoriesDirBuilder::new().recursive(false).create(p).Path::exists()withfs::symlink_metadata(p)(which does not follow symlinks) and reject if it's a symlink, or use*atsyscalls relative to an opened directory FD.cp -rsubprocess invocations: the subprocess re-resolves the path on its own and is the actual TOCTOU vector. Fixing the in-processassert!does not stop the subprocess from following a symlink. Real fix is to dropcp -rfor a Rust-level recursive copy that opens the source as a directory FD once and walks via*atsyscalls (or viawalkdir+ per-file FDs).path.exists()and letCommand::new(...).spawn()fail withENOENT. 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-PATHbug observed today (zainod missing → cryptic spawn failure deep in the test).What I did not find
fs::canonicalize,read_link,symlink_metadata,set_permissions,chmod,rename,hard_link, orread_dircalls anywhere in tree.is_dir/is_file/is_symlink/metadata/try_existspredicates.regtest-launcher/does no filesystem I/O at all.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.