serhiy-bzhezytskyy opened a new pull request, #16478:
URL: https://github.com/apache/lucene/pull/16478

   ### Description
   
   `LZ4#decompress` reads a match offset and checks only that it is not zero. 
It does not check it against the number of bytes written so far, so an offset 
larger than `dOff` makes the match reference bytes before `dest[0]` — bytes 
this call never wrote:
   
   ```java
   final int matchDec = compressed.readShort() & 0xFFFF;
   if (matchDec == 0) {
     throw new IOException("offset 0 is invalid");
   }
   ...
   for (int ref = dOff - matchDec, end = dOff + matchLen; dOff < end; ++ref, 
++dOff) {
     dest[dOff] = dest[ref];          // ref can be negative
   }
   ...
   System.arraycopy(dest, dOff - matchDec, dest, dOff, fastLen);   // same
   ```
   
   Corrupt input therefore fails with `ArrayIndexOutOfBoundsException: 
arraycopy: source index -8 out of bounds for byte[18]` rather than a checked 
`IOException`.
   
   The LZ4 block format leaves this to the implementation, and @jpountz 
described the same condition on LUCENE-5267 in 2013 while diagnosing an 
`AIOOBE` from a corrupt `.fdt`:
   
   > all the lines you pasted make no sense since `matchDec` should be lower 
than `dOff`
   
   The compressor already asserts the invariant when writing a match — `assert 
matchDec > 0 && matchDec < 1 << 16` at `LZ4.java:188` — so this only affects 
input that was not produced by `LZ4#compress`, which is to say corrupt or 
hostile input.
   
   ### What this does and does not fix
   
   It makes the failure legible. It does not improve corruption detection, and 
it is worth being explicit about that, because the two are easy to conflate.
   
   Measured on `main`, flipping one byte at a time across the `.fdt` of a 
2,000-document index (codec and mode pinned to `Lucene104`/`BEST_SPEED`, every 
37th byte, all documents read back and compared):
   
   | outcome | `main` | with this change |
   |---|---|---|
   | `ArrayIndexOutOfBoundsException` from LZ4 | 36.1% | **0%** |
   | `IOException` | 0.3% | **35.8%** |
   | incorrect stored-field value returned, no error at all | 48.5% | 47.7% |
   | `CorruptIndexException` | 0.8% | 0.6% |
   | `NullPointerException` (header or index corruption) | 8.8% | 9.1% |
   
   So the `AIOOBE` becomes a checked exception almost one-for-one, and the 
largest outcome — a wrong document returned silently — is untouched. A validity 
check cannot detect a valid-but-wrong LZ4 stream; most flipped bytes still 
decode to something, just not to what was compressed. Only a checksum can catch 
that, which is what LUCENE-5267 proposed and what the frame format specifies as 
an optional per-block checksum.
   
   That sampling is coarse: 2.7% of positions, all eight bits inverted rather 
than one, only the first exception classified, one corpus and one compression 
mode. It is offered as the shape of the change, not as a precise figure.
   
   ### Relationship to #15570
   
   `matchDec == 0` was closed there in January. This is the other unchecked 
path in the same method. `lucene-core` is not affected by CVE-2025-66566 — that 
is a `lz4-java` advisory, and Lucene has its own vendored LZ4 — but the 
advisory describes the general shape as *"an attacker can direct such a copy at 
a region that hasn't been filled with decompressed bytes yet"*, and cites it as 
prior art for why the bound matters. Whether Lucene's stored-fields path reuses 
output buffers such that another document's bytes could surface is not 
something I measured, so this is proposed as format conformance and error 
legibility, not as a security fix.
   
   ### Verification
   
   - Both new tests fail without the change, with `AIOOBE: source index -8` and 
`source index -1`
   - Removing the check and re-running (mutation check) fails; restoring it 
passes
   - The dictionary case is covered separately: with a preset dictionary the 
bound is `dOff` rather than the number of bytes this call decompressed, since a 
match may legitimately reference the dictionary the caller placed in 
`dest[dOff-dictLen:dOff]`
   - `:lucene:core:test` for `*LZ4*`, `*Compressing*` and `*StoredFields*` 
passes with `-Ptests.nightly=true -Ptests.iters=5` (1,375 tests)
   - `:lucene:core:check` and `tidy` pass
   
   The `CHANGES.txt` entry uses `GITHUB#PENDING`; I will replace it with this 
PR's number.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to