fix(install): prevent symlink race condition in install -D (fixes #10013) - #10140
Conversation
|
GNU testsuite comparison: |
fc7eade to
0b91865
Compare
|
GNU testsuite comparison: |
Merging this PR will not alter performance
Comparing Footnotes
|
|
The 3.46% performance regression is an acceptable trade-off for fixing the symlink race condition vulnerability. The safe directory traversal using file descriptors adds unavoidable overhead, but prevents potential security exploits. |
|
GNU testsuite comparison: |
|
sorry but could you please rebase it ? thanks |
agreed |
fe7aec2 to
4fb0779
Compare
|
some conflicts, sorry |
4fb0779 to
f95bed7
Compare
|
There, that should resolve the conflicts :) |
|
GNU testsuite comparison: |
|
The sort regression is benchmark noise - this PR only touches |
|
This is a lot of added complexity and I think you could have a bit more polish before submitting for review :/ |
Yeah that was rough, won't happen again |
|
GNU testsuite comparison: |
b804df6 to
d138468
Compare
|
GNU testsuite comparison: |
|
GNU testsuite comparison: |
|
GNU testsuite comparison: |
…s a file When -t is used with -D and the target exists as a file (not a directory), we should fail immediately without printing verbose directory creation messages. This matches GNU behavior and fixes the failing GNU test tests/install/basic-1.
- Replace std::io::copy() with copy_stream() for consistency and splice optimization - Consolidate DirFd::open() and open_subdir() to take follow_symlinks parameter - Extract finalize_installed_file() helper to reduce code duplication - Add documentation for security behavior (symlink removal) and mode handling - Clean up verbose comments
268e92d to
d694c9e
Compare
d694c9e to
a1ef268
Compare
|
GNU testsuite comparison: |
|
it looks great, i will wait for the ci to finish |
|
@abendrothj Would you please have a look at the issue I'm having? It seems the ancestors_mode_directories_with_file test only passed with the root user on my system, and unsure if my "fix" is correct. |
Something changed in 0.7.0 re. what happens when boulder builds some packages, that makes boulder record directories as part of the package, which can in turn cause conflicts (notably seen with inputplumber). To avoid this becoming an issue, and to give us time to investigate further, downgrade to 0.6.0 for now. Signed-off-by: Rune Morling <ermo@aerynos.com>
## Summary Fixes #11469 `install -D` was replacing pre-existing symlinks in the destination path with real directories instead of following them. This broke any workflow where part of the install prefix is a symlink; including BOSH deployments, Homebrew, Nix, stow, and any `make install` targeting a symlinked prefix. **Reproduction (from the issue):** ```sh mkdir -p /tmp/target ln -s /tmp/target /tmp/link echo hello > /tmp/file.txt install -D -m 644 /tmp/file.txt /tmp/link/subdir/file.txt # GNU coreutils 8.32: /tmp/link stays a symlink, file lands in /tmp/target/subdir/file.txt # uutils 0.7.0: /tmp/link is replaced with a real directory — wrong ``` ## Root cause PR #10140 introduced `create_dir_all_safe()` in `safe_traversal.rs` to prevent TOCTOU symlink race conditions. The fix was correct in intent but too aggressive: `open_or_create_subdir()` unconditionally unlinked and recreated any symlink it encountered, including pre-existing legitimate ones. ## Changes **`src/uucore/src/lib/features/safe_traversal.rs`** - `open_or_create_subdir`: when `stat_at` returns `S_IFLNK`, call `open_subdir(Follow)` instead of `unlink_at + mkdir_at`. The `O_DIRECTORY` flag already in `open_subdir` means dangling or non-directory symlinks still return an error cleanly. - `find_existing_ancestor`: switch from `fs::symlink_metadata` to `fs::metadata` so that a symlink-to-directory is recognised as an existing ancestor rather than a component to recreate (this was already the stated intent in the function's doc comment). **`src/uu/install/src/install.rs`** - Align the `dir_exists` check and the `DirFd::open` call to also follow symlinks, consistent with the above. **`tests/by-util/test_install.rs`** - Update the two tests added by #10140 — they were asserting the buggy behavior (symlink replaced). Flip the assertions to document the correct GNU behavior. - Add `test_install_d_follows_symlink_prefix` as a direct regression test for the issue's reproduction case. ## TOCTOU / security note The true TOCTOU race (a symlink *injected during the operation* into a not-yet-existing path component) is still blocked: `mkdirat` fails with `EEXIST` if an attacker creates a symlink between `stat_at` returning `ENOENT` and our `mkdir_at`. Newly-created directories are still opened with `O_NOFOLLOW`. What changes is that *pre-existing* symlinks are now followed — which is exactly what GNU coreutils 8.32 does. The previous behavior was stricter than GNU in this regard.
## Summary Fixes uutils#11469 `install -D` was replacing pre-existing symlinks in the destination path with real directories instead of following them. This broke any workflow where part of the install prefix is a symlink; including BOSH deployments, Homebrew, Nix, stow, and any `make install` targeting a symlinked prefix. **Reproduction (from the issue):** ```sh mkdir -p /tmp/target ln -s /tmp/target /tmp/link echo hello > /tmp/file.txt install -D -m 644 /tmp/file.txt /tmp/link/subdir/file.txt # GNU coreutils 8.32: /tmp/link stays a symlink, file lands in /tmp/target/subdir/file.txt # uutils 0.7.0: /tmp/link is replaced with a real directory — wrong ``` ## Root cause PR uutils#10140 introduced `create_dir_all_safe()` in `safe_traversal.rs` to prevent TOCTOU symlink race conditions. The fix was correct in intent but too aggressive: `open_or_create_subdir()` unconditionally unlinked and recreated any symlink it encountered, including pre-existing legitimate ones. ## Changes **`src/uucore/src/lib/features/safe_traversal.rs`** - `open_or_create_subdir`: when `stat_at` returns `S_IFLNK`, call `open_subdir(Follow)` instead of `unlink_at + mkdir_at`. The `O_DIRECTORY` flag already in `open_subdir` means dangling or non-directory symlinks still return an error cleanly. - `find_existing_ancestor`: switch from `fs::symlink_metadata` to `fs::metadata` so that a symlink-to-directory is recognised as an existing ancestor rather than a component to recreate (this was already the stated intent in the function's doc comment). **`src/uu/install/src/install.rs`** - Align the `dir_exists` check and the `DirFd::open` call to also follow symlinks, consistent with the above. **`tests/by-util/test_install.rs`** - Update the two tests added by uutils#10140 — they were asserting the buggy behavior (symlink replaced). Flip the assertions to document the correct GNU behavior. - Add `test_install_d_follows_symlink_prefix` as a direct regression test for the issue's reproduction case. ## TOCTOU / security note The true TOCTOU race (a symlink *injected during the operation* into a not-yet-existing path component) is still blocked: `mkdirat` fails with `EEXIST` if an attacker creates a symlink between `stat_at` returning `ENOENT` and our `mkdir_at`. Newly-created directories are still opened with `O_NOFOLLOW`. What changes is that *pre-existing* symlinks are now followed — which is exactly what GNU coreutils 8.32 does. The previous behavior was stricter than GNU in this regard.
This PR fixes a TOCTOU (Time-of-Check-Time-of-Use) race condition in the install -D command where an attacker could replace a directory component with a symlink between directory creation and file installation, redirecting writes to an arbitrary location.
Changes
mkdir_at()andopen_file_at()methods toDirFdfor safe operationsopen_no_follow()to prevent following symlinks when opening directoriescreate_dir_all_safe()function that uses directory file descriptorscopy_file_safe()function for safe file copying using directory fdsHow the Fix Works
The fix prevents the race condition by:
mkdirat/openat) instead of pathnamesThis eliminates the race window by ensuring all critical operations use directory file descriptors that cannot be replaced by symlinks.
Testing
Fixes #10013