Skip to content

Fix CopyStat TOCTOU in brotli CLI (#1461)#1463

Open
parasol-aser wants to merge 1 commit intogoogle:masterfrom
parasol-aser:fix/copystat-toctou-1461
Open

Fix CopyStat TOCTOU in brotli CLI (#1461)#1463
parasol-aser wants to merge 1 commit intogoogle:masterfrom
parasol-aser:fix/copystat-toctou-1461

Conversation

@parasol-aser
Copy link
Copy Markdown

Summary

Fixes #1461.

CloseFiles() in c/tools/brotli.c previously called fclose(context->fout) before invoking CopyStat() on the output pathname. CopyStat() then used path-based chmod() and chown(), which gave an attacker with write access to the output directory a window between the close and the metadata syscalls to swap the output pathname to a symlink and redirect the metadata writes to an arbitrary target.

Close the race by doing the metadata copy while the output file is still open, and by switching to fd-based syscalls on fileno(fout):

  • CopyStat() now takes FILE* fout and calls fchmod() / fchown() on the underlying fd.
  • CopyTimeStat() uses futimens(fd, ...) in the HAVE_UTIMENSAT branch; the legacy utime() fallback is preserved for platforms without it.
  • CloseFiles() invokes CopyStat() before fclose(), and skips it when current_output_path == NULL (stdout), matching the guidance in the issue.
  • The Windows no-op shims are updated from chmod / chown to fchmod / fchown to keep the file compiling on _WIN32.
  • An fflush(fout) is added before the timestamp copy so stdio-buffered output does not clobber the atime/mtime the helper just applied during fclose()'s write-back.

The old /* TOCTOU violation ... */ comment is deleted — the violation is gone.

No CMake / Bazel / build-system changes are required; the fix uses only POSIX symbols already available in the existing guarded blocks, and stays within the C89 compatibility constraint from CONTRIBUTING.md.

Test plan

Regression assets are checked in under tests/regression/t01/.

  • tests/regression/t01/repro_copystat_swap.sh — deterministic attack reproducer using an LD_PRELOAD hook on fclose() that swaps the just-closed output path to a symlink. Against pre-fix brotli, the script exits 0 and prints T-01 OK: target mode flipped to 644 via post-close CopyStat path. Against the patched binary, the attack no longer lands.
  • tests/regression/t01/test_copystat_swap_fixed.sh / test_copystat_swap_fixed_strict.sh — negative-assertion wrappers over the reproducer. They pass only when the swap fails to flip the target's mode.
  • tests/regression/t01/test_copystat_swap_with_no_copy_stat.sh — confirms -n / --no-copy-stat still disables the metadata copy entirely.
  • tests/regression/t01/test_copystat_positive.sh, test_copystat_various_modes.sh, test_copystat_mode_mask.sh — positive regressions: with no attacker, the output file's mode is copied from the input (including across a range of modes and the S_IRWXU|S_IRWXG|S_IRWXO mask).
  • tests/regression/t01/test_copystat_timestamp.sh — confirms atime/mtime are propagated via futimens on the still-open fd.
  • tests/regression/t01/test_copystat_stdout_skip.sh — exercises the current_output_path == NULL branch (echo ... | brotli -c | brotli -dc); no metadata call is attempted on stdout.
  • tests/regression/t01/test_copystat_stdin_input.sh — confirms stdin input (no input path to stat from) is handled cleanly.
  • tests/regression/t01/test_copystat_roundtrip.sh — plain compress/decompress roundtrip still produces byte-identical output, so the reorder in CloseFiles() has not regressed the happy path.
  • tests/regression/t01/run_all.sh — runs all of the above and summarizes pass / fail / infra-skip counts.
  • Built and re-ran the full set under ASan + UBSan (build-asan/); no sanitizer reports.

To reproduce locally:

cc -fPIC -shared -o tests/regression/t01/libfclose_swap.so \
    tests/regression/t01/fclose_swap.c -ldl
tests/regression/t01/run_all.sh \
    build-asan/brotli \
    tests/regression/t01/libfclose_swap.so

The .so and plain.bin.br artifacts are intentionally not committed (covered by the existing *.so / *.br ignore rules) — they are built on demand from fclose_swap.c and plain.bin.

The companion path-TOCTOU on OpenOutputFile() is tracked separately in #1462 and will land in its own PR, to keep the -f behavior change out of this review.

CloseFiles() previously called fclose(context->fout) before invoking
CopyStat() on the output pathname. CopyStat() then used path-based
chmod() and chown(), which gave an attacker with write access to the
output directory a window to replace the path with a symlink between
the close and the metadata syscalls, redirecting the chmod/chown to an
arbitrary target.

Close the race by doing the metadata copy while the output file is
still open and by switching to fd-based syscalls on fileno(fout):

- CopyStat() now takes FILE* fout and calls fchmod()/fchown() on the
  underlying fd.
- CopyTimeStat() uses futimens(fd, ...) in the HAVE_UTIMENSAT branch;
  the legacy utime() fallback is preserved for platforms without it.
- CloseFiles() invokes CopyStat() before fclose() and skips it when
  current_output_path is NULL (stdout), matching the issue's guidance.
- Windows no-op shims updated from chmod/chown to fchmod/fchown.

Adds deterministic regression coverage under tests/regression/t01/:
an LD_PRELOAD fclose() swap reproduces the pre-fix behavior, and a
negative-assertion wrapper confirms the attack no longer succeeds
after the patch. Additional scripts cover mode/timestamp propagation,
the -n (no-copy-stat) flag, stdin input, stdout output, mode mask, and
roundtrip correctness.

Refs google#1461
@google-cla
Copy link
Copy Markdown

google-cla Bot commented Apr 19, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] High: CLI CopyStat uses path-based chmod/chown after closing output file

1 participant