Files
brotli/tests/regression/t01/test_copystat_timestamp.sh
parasol-aser da05ef838c Fix CopyStat TOCTOU in brotli CLI by using fd-based syscalls
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/brotli#1461
2026-04-30 17:19:28 +00:00

68 lines
2.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# Timestamp-copy test for T-01.
#
# PLAN.md §3.2 replaces path-based utime() with fd-based futimens() under
# HAVE_UTIMENSAT. On Linux we always have HAVE_UTIMENSAT, so this test
# validates futimens() is actually invoked with the input's mtime.
#
# Procedure:
# 1. Create an input file and set its mtime to a well-known past date.
# 2. Sleep briefly so the ambient "now" drifts from that mtime.
# 3. Compress. The compressed output should have the same mtime as the
# input (give or take filesystem-timestamp resolution).
# 4. Decompress to a fresh path. The decompressed output should also
# carry the input's mtime (actually: the compressed file's mtime,
# which is the input's mtime from step 3).
#
# This guards against:
# - CopyTimeStat refactor dropping the call entirely.
# - The futimens path using wrong indices or wrong struct fields.
#
# Usage: test_copystat_timestamp.sh /path/to/brotli
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $0 /path/to/brotli" >&2
exit 2
fi
brotli_bin=$(readlink -f -- "$1")
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
workdir=$(mktemp -d)
cleanup() {
rm -rf -- "$workdir"
}
trap cleanup EXIT
umask 0022
cp -- "$script_dir/plain.bin" "$workdir/in.bin"
# Pinned mtime in the past (well before the current CI run).
past="202001020304.05"
touch -m -t "$past" "$workdir/in.bin"
expected_mtime=$(stat -c '%Y' "$workdir/in.bin")
# Small drift to ensure "now" != expected_mtime in the default-create case.
sleep 1
"$brotli_bin" -fk "$workdir/in.bin" -o "$workdir/in.bin.br"
compressed_mtime=$(stat -c '%Y' "$workdir/in.bin.br")
if [[ "$compressed_mtime" != "$expected_mtime" ]]; then
echo "FAIL: compressed mtime ($compressed_mtime) != input mtime" \
"($expected_mtime)" >&2
exit 1
fi
"$brotli_bin" -df "$workdir/in.bin.br" -o "$workdir/out.bin"
decompressed_mtime=$(stat -c '%Y' "$workdir/out.bin")
if [[ "$decompressed_mtime" != "$expected_mtime" ]]; then
echo "FAIL: decompressed mtime ($decompressed_mtime) != input mtime" \
"($expected_mtime)" >&2
exit 1
fi
echo "T-01 TIMESTAMP: futimens copied mtime through compress+decompress"