Hello,

I’m Ahmad Elmoursi, a student at KAIST doing security research at the 
NetS&P Lab (https://netsp.kaist.ac.kr/ https://netsp.kaist.ac.kr/).



We found a bug in GNU grep’s --exclude-from handling that can cause 
inconsistent file exclusions and an indefinite hang when using a FIFO as the 
pattern source. We’ve attached the report and reproduction script with the 
details.

Please let us know if this is a known issue or if you need any additional 
information.

(Disclaimer: LLM was used to assist with writing the report and PoC)

Thank you,
Ahmad
# GNU grep `--exclude-from` reads FILE once per pattern table; the two tables filter different name populations from different reads, and a FIFO/producer-backed FILE causes a permanent hang

## Summary

`grep --exclude-from=FILE` opens and reads FILE **once per pattern table** — two reads per invocation (one `add_exclude_file()` call per table, `src/grep.c`). The two tables filter two different name populations: recursively discovered names and command-line operands. Because each table is built from its own independent read:

1. **Correctness**: the two populations can be governed by **different pattern sets** if the file content changes between the reads. The exclusion result then depends on file-state timing: an output impossible under any single consistent read is reachable. The implementation-supported `--exclude-from=-` (stdin) form is also broken: the second read gets an exhausted stdin, so command-line operands are never excluded while recursive ones are.
2. **Conditional permanent hang**: if FILE is a FIFO (or any producer-backed special file) whose writer exits before every open has been satisfied, grep blocks forever inside `open()` (kernel wait channel `wait_for_partner`), producing no output and never exiting.

By contrast, grep's own analogous option `-f PATTERNFILE` opens the file exactly once — the repeated reads are an implementation artifact, not a design decision.

## Affected versions

| Version | Result |
|---|---|
| GNU grep **3.12** (LLVM-instrumented build, Ubuntu 24.04 container) | **Affected** — FIFO serving test hangs (`wchan = wait_for_partner`); divergent-read outcomes confirmed |
| GNU grep **3.7** (Ubuntu 22.04) | **Affected** — strace shows two `openat()` of the exclude-from file; stdin and FIFO divergences reproduced |

The `for (int cmd = 0; cmd < 2; cmd++)` loop is still present in current master (`src/grep.c`, `case EXCLUDE_FROM_OPTION:`), so current releases are affected.

## Root cause

`src/grep.c`, option handling:

```c
case EXCLUDE_FROM_OPTION:
    for (int cmd = 0; cmd < 2; cmd++)
      {
        if (!excluded_patterns[cmd])
          excluded_patterns[cmd] = new_exclude ();
        if (add_exclude_file (add_exclude, excluded_patterns[cmd],
                              optarg, exclude_options (cmd), '\n')
            != 0)
          die (EXIT_TROUBLE, errno, "%s", optarg);    /* blocks inside fopen() here */
      }
    break;
```

The two tables select **two different name populations** (`src/grep.c`):

```c
static int
exclude_options (bool command_line)
{
  return EXCLUDE_WILDCARDS | (command_line ? 0 : EXCLUDE_ANCHORED);
}

static bool
skipped_file (char const *name, bool command_line, bool is_dir)
{
  struct exclude **pats;
  if (! is_dir)
    pats = excluded_patterns;            /* files */
  ...
  return pats[command_line] && excluded_file_name (pats[command_line], name);
}
```

`skipped_file(name, command_line, …)` is called with `command_line = false` for entries discovered by the recursive traversal and with `command_line = true` for command-line operands. The intent of the loop is to populate both tables **from the same file snapshot**; the implementation re-reads the FILE for the second table instead of reusing the first read's content. Deviations:

- the recursion filter and the command-line filter can be built from **two different file states**, so identical names can be treated differently depending on how they were discovered (TOCTOU on the exclusion set);
- each `add_exclude_file` performs its own `fopen()` — for a FIFO or producer-backed FILE, an unsatisfiable open blocks indefinitely.

## Evidence

### E1 — open counts (strace, regular file, grep 3.7)

```
$ strace -f -e trace=openat grep -G -e needle --exclude-from=/wd/ex.file -r /wd/trees/tr
openat(AT_FDCWD, "/tmp/.../ex.file", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/tmp/.../ex.file", O_RDONLY|O_CLOEXEC) = 3      ← second open
```

`-f PATTERNFILE` under identical conditions: 1 open.

### E2 — `--exclude-from=-` : supported stream form (`-`) is broken for command-line operands (grep 3.7; `a.txt` contains `needle`)

```
$ printf '*.txt\n' | grep -e needle --exclude-from=- a.txt
needle one                       ← command-line operand NOT excluded (second read got EOF)
$ printf '*.txt\n' > f.reg
$ grep -e needle --exclude-from=f.reg a.txt
(no output, rc=1)                ← the same patterns from a regular file DO exclude it
```

The first read populates the recursion table; the second read re-reads the exhausted stream and yields an empty command-line table.

### E3 — divergent snapshots: both operands suppressed, an impossible single-snapshot outcome (grep 3.7; recursive `trees/a.txt` and command-line `b.log`, both containing `needle`)

| read #1 → recursion table | read #2 → command-line table | output |
|---|---|---|
| `*.txt` | `*.txt` (consistent) | `b.log:needle two` |
| `*.log` | `*.log` (consistent) | `a.txt:needle one` |
| `*.txt` | `*.log` (divergent) | **`(no output) — both operands suppressed`** |

The divergent row suppresses both operands simultaneously — an outcome neither consistent read can produce, proving the two tables were built from two different snapshots.

### E4 — the conditional permanent hang (grep 3.7 and 3.12)

```
$ mkfifo patterns
$ (printf '*.log\n' > patterns &)            # one-shot producer, exits
$ grep -r -e needle --exclude-from=patterns ./
<no output, no exit — hangs forever; wchan = wait_for_partner>
```

Blocked inside `open()`, not in `read()`: each pass requires a live writer rendezvous, and a producer that exits leaves the next open unsatisfied permanently. The hang is conditional on the producer's lifetime: with a serving peer that answers every open, grep completes normally.

## Impact

- **Correctness**: exclusion behavior depends on operand origin (recursive vs command-line) whenever the two reads differ — including the implementation-supported `--exclude-from=-` form, where command-line operands are never excluded at all. A file rewritten during startup is applied inconsistently across the two populations (TOCTOU on the exclusion set).
- **Availability**: permanent, silent hang for any user whose `--exclude-from` FILE is a FIFO or producer-backed special file with a one-shot producer.
- Not memory-unsafe; no data corruption beyond the exclusion semantics.

## Remediation

1. **Read once, populate both tables**: read FILE into memory (bounded), then run the two `add_exclude` passes over the snapshot — this matches the loop's evident intent and removes the divergence and the extra read.
2. Documentation: state in `grep(1)` how many times FILE is read and that producers must remain open; note the `--exclude-from=-` single-read consequence.

## Known reports

No prior report of this behavior is known: the double-read loop is still present in current master, the git history of `src/grep.c` contains no relevant change, and the issue tracker has no entry about exclude-from file handling, FIFOs, or repeated opens (all outstanding `grep` bugs reviewed as of 2026-10-09). `--exclude-from=-` (stdin) is supported by the implementation and verified working (the manual does not explicitly document `-` for this option), so stream inputs are within the option's intended scope.

## Reproduction

`./reproduce.sh [grep-binary]` — or manually:

```bash
mkfifo patterns
(printf '*.log\n' > patterns &)                 # one-shot producer exits after writing
grep -r -e needle --exclude-from=patterns .     # hangs forever
```

Compare: `strace -e trace=openat grep -G -e needle --exclude-from=<regular file> -r dir` shows two `openat()` calls for the same path; `grep -G -f PATTERNFILE …` shows one.
#!/usr/bin/env bash
# Reproduction: GNU grep --exclude-from reads FILE once per pattern table
#   [1] two opens (strace), vs one open for -f
#   [2] --exclude-from=- : command-line operands never excluded (stream re-read)
#   [3] divergent snapshots: both operands suppressed (impossible single-snapshot outcome)
#   [4] FIFO permanent hang with one-shot producer
#
# Usage: ./reproduce.sh [grep-binary]     (default: system grep)
set -u
GREP_BIN="${1:-/usr/bin/grep}"
WD=$(mktemp -d /tmp/grep-excl-repro.XXXXXX)
mkdir -p "$WD/trees"
printf 'needle one\n' > "$WD/trees/a.txt"
printf 'needle two\n' > "$WD/b.log"
echo "grep binary: $GREP_BIN"
"$GREP_BIN" --version | head -1

echo
echo "== [1] double open (strace; needs strace) =="
printf '*.log\n' > "$WD/ex.file"
if command -v strace >/dev/null; then
    if strace -f -e trace=openat -o "$WD/excl.strace" \
        "$GREP_BIN" -G -e needle --exclude-from="$WD/ex.file" -r "$WD/trees" >/dev/null 2>&1 \
        && [ -s "$WD/excl.strace" ]; then
        echo "  exclude-from openat count: $(grep -c "ex.file" "$WD/excl.strace")  (-f patternfile opens once)"
    else
        echo "  strace unavailable/blocked in this environment — open count not measured"
    fi
else
    echo "  strace not installed — open count not measured"
fi

echo
echo "== [2] --exclude-from=- (stdin): command-line operand exclusion =="
printf '*.txt\n' > "$WD/ak.reg"
out=$(printf '*.txt\n' | "$GREP_BIN" -e needle --exclude-from=- "$WD/trees/a.txt")
echo "  stream (-)        : rc=$? out=${out:-<suppressed>}"
out=$("$GREP_BIN" -e needle --exclude-from="$WD/ak.reg" "$WD/trees/a.txt"); rc=$?
echo "  regular file      : rc=$rc out=${out:-<suppressed>}"
echo "  -> same patterns: stream form leaves the command-line operand searched,"
echo "     regular-file form excludes it. Inconsistent."

echo
echo "== [3] divergent snapshots: recursion vs command-line operands =="
python3 - "$GREP_BIN" "$WD" << 'PYEOF'
import os, subprocess, threading, sys, time
GREP, WD = sys.argv[1], sys.argv[2]
fifo = WD + "/divergent.fifo"

def run(name, first, after):
    """Serving peer: serves `first` on open #1 (recursion table), then `after`
    on every subsequent open (command-line table). Persistent = deterministic.
    Returns the set of files with output, or None if the case hung."""
    if os.path.exists(fifo): os.unlink(fifo)
    os.mkfifo(fifo)
    p = subprocess.Popen([GREP, "-e", "needle", "--exclude-from=" + fifo,
                          "-r", "--", WD + "/trees", WD + "/b.log"],
                         stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stop = threading.Event()
    def peer():
        n = 0
        while not stop.is_set() and p.poll() is None:
            try:
                fd = os.open(fifo, os.O_WRONLY)
                if p.poll() is not None:
                    os.close(fd); return
                os.write(fd, first if n == 0 else after)
                os.close(fd)
            except OSError:
                return
            n += 1
    t = threading.Thread(target=peer, daemon=True); t.start()
    try:
        out, _ = p.communicate(timeout=20)
    except subprocess.TimeoutExpired:
        stop.set(); p.kill(); p.wait()
        print("  [%s] -> HUNG (killed after 20s)" % name)
        t.join(timeout=2)
        return None
    stop.set()
    files = sorted(set(l.split(":")[0].split("/")[-1] for l in out.decode().strip().splitlines() if l))
    print("  [%s] -> output from: %s" % (name, files or "NOTHING"))
    t.join(timeout=2)
    return files

a = run("open1=*.txt / rest=*.log", b"*.txt\n", b"*.log\n")
b = run("open1=*.log / rest=*.log", b"*.log\n", b"*.log\n")

if a is None or b is None:
    print("  DEMONSTRATION INCONCLUSIVE: a case hung — rerun this section")
    sys.exit(0)
print("  consistent read  -> output from: %s" % (b or "NOTHING"))
print("  divergent reads  -> output from: %s" % (a or "NOTHING"))
if a != b:
    print("  same command; only the first read differed -> outputs differ =")
    print("  the recursion and command-line tables were built from different snapshots")
else:
    print("  no divergence observed in this run (racy environment) — rerun")
PYEOF

echo
echo "== [4] FIFO permanent hang with one-shot producer =="
mkfifo "$WD/patterns"
( printf '*.log\n' > "$WD/patterns" ) &
PROD=$!
timeout 8 "$GREP_BIN" -r -e needle --exclude-from="$WD/patterns" "$WD/trees" >/dev/null 2>&1
rc=$?
wait $PROD 2>/dev/null
if [ $rc -eq 124 ]; then
    echo "  HANG reproduced (timeout 8s): grep blocked in open() of the FIFO"
else
    echo "  no hang (rc=$rc)"
fi

echo
echo "workdir: $WD (left in place for inspection; safe to delete)"

Reply via email to