ethereum-optimism / ethereum-optimism/optimism
op-node,kona: zlib channel decompression conformance — Go compress/zlib and miniz_oxide disagree on validity and on partial output
- Dominant language
- Go
- Stars
- 6.5k
- Forks
- 4k
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 145
Description
## Summary
op-node and kona decompress zlib channels with two independent implementations — Go's `compress/zlib` and [`miniz_oxide`](https://github.com/Frommi/miniz_oxide) 0.9.1 — and the two disagree both on **whether a stream is valid** and on **how much output a failing stream yields**. Under today's "accept partially decompressed channel data" rule, both disagreements change the derived batch set.
This is the zlib counterpart of #21793 (the same problem in the brotli decoders), and it lands in the same place: the residual conformance gap behind [protocol-team#84 (strict channel decompression)](https://github.com/ethereum-optimism/protocol-team/issues/84).
Both instances below are confirmed by running the real decoders on both sides. Both are malicious-batcher-reachable only; an honest batcher produces streams that decompress byte-identically on both clients.
## Instance 1 — kona adopts miniz_oxide's untruncated output buffer on error (kona lenient)
[`BatchReader::decompress_zlib`, `rust/kona/crates/protocol/protocol/src/batch/reader.rs#L98-L116`](https://github.com/ethereum-optimism/optimism/blob/85f7c13d7470a66254081f0177fc297201c07459/rust/kona/crates/protocol/protocol/src/batch/reader.rs#L98-L116):
```rust
Err(e) if (e.status == TINFLStatus::HasMoreOutput || !e.output.is_empty()) => {
self.decompressed = e.output;
}
```
The intent is to mirror op-node's streaming decode, which keeps the batches decoded before the error ([`BatchReader`, `op-node/rollup/derive/channel.go#L174-L219`](https://github.com/ethereum-optimism/optimism/blob/85f7c13d7470a66254081f0177fc297201c07459/op-node/rollup/derive/channel.go#L174-L219)). It does not achieve that, for two compounding reasons:
1. **`decompress_to_vec_zlib_with_limit` only truncates its output buffer to the real byte count on the success path.** Every error path returns the whole allocation — `vec![0; min(2·input_len, limit)]`, grown by doubling — so `e.output` is *real bytes followed by a zero tail*.
2. Even setting that aside, the two inflaters need not stop at the same byte. Go's emits only fully decoded bytes; miniz_oxide sometimes decodes further before failing.
So kona's `decompressed` is a **strict superset** of what op-node's reader yields, essentially always. Measured over 28,482 malformed streams (every truncation length plus single-byte corruptions of valid streams, 3 compression levels):
| | count |
|---|---|
| kona partial output strictly longer than op-node's | **28,440 (99.85%)** |
| op-node partial output longer | **0** |
| partial output differing other than by a suffix | 0 |
| kona's extra tail is all `0x00` | 27,543 |
| kona output length exactly `2 · input length` | 9,463 |
The extra bytes change the batch set whenever they complete an RLP item. Over a channel carrying 6 valid RLP-framed singular batches, 3,135 malformed variants:
```
kona derives MORE batches = 535 (17.1%) always exactly +1
op-node derives more batches = 0
```
So this is not a knife-edge — roughly one in six arbitrary malformed zlib channels already diverges, uniformly in the kona-lenient direction. A batcher that *chooses* the truncation point can aim for it deliberately.
Note this arm also swallows outright header rejections: for an invalid header miniz returns an error whose `output` is a non-empty all-zero buffer, and kona adopts it rather than reaching the `Err(_) => ZlibError` arm.
Tracked with the exploit analysis in [protocol-team#306](https://github.com/ethereum-optimism/protocol-team/issues/306).
## Instance 2 — FDICT zlib header: op-node accepts, kona rejects (op-node lenient)
Go's `zlib.Reset` rejects only `CM != 8`, `CINFO > 7` and a bad FCHECK. For the FDICT bit it reads a 4-byte `DICTID` and compares it against `adler32.Checksum(dict)` — and `NewReader` passes `dict == nil`, for which `adler32.Checksum(nil) == 1`. A stream with FDICT set and `DICTID = 0x00000001` therefore **passes**, decoding via `flate.NewReaderDict(r, nil)`. `miniz_oxide`'s `validate_zlib_header` rejects the FDICT bit unconditionally.
Confirmed on a channel carrying one valid singular batch, header `78 20 00 00 00 01`:
```
op-node fdict(DICTID=1) : decoded 1 batch
op-node fdict(DICTID=2) : zlib: invalid dictionary (control — rejected)
kona fdict(DICTID=1) : 0 batches
```
A whole channel op-node derives is dropped by kona. Reachable by a batcher setting FDICT and `DICTID = 1` while compressing without a real preset dictionary — from-scratch deflate decodes fine against an empty dictionary.
Rejecting FDICT in op-node is the tighter option and matches kona.
## Ruled out — please don't re-chase these
Two nearby claims have circulated; both are **not** divergences, verified by running the exact minimized inputs through both clients:
- **CM = 15 ("reserved" compression method) header, e.g. `7fda010000ffff00000001`.** Both clients route a low nibble of `0x0F` to zlib and both then reject: Go with `zlib: invalid header`, and `miniz_oxide` with `Err(status=Failed)`. The `22` bytes visible in kona's `decompressed` are the untruncated all-zero buffer from Instance 1 (`2 × 11` input bytes, **0** of them non-zero), not decompressed content. Both derive 0 batches. (That first byte also fails Go's FCHECK, `0x7fda % 31 == 25`.)
- **"Valid zlib stream with the trailing 4-byte Adler-32 removed."** Go's reader delivers the complete payload before reporting `unexpected EOF`, so there is no partial-output gap at all and both clients derive the same batches. kona never verifies the Adler-32 checksum where op-node does, but for a *complete* deflate stream both still emit every batch present, so it only matters in combination with truncation — i.e. Instance 1.
## Relationship to strict decompression
There is no sound wrapper-level fix for Instance 1. Byte-exact partial-output parity between two independent inflaters is not reachable through `miniz_oxide`'s API, and tightening kona alone makes things worse — restricting the arm to `HasMoreOutput` only, on the same corpus, has kona derive **fewer** batches than op-node in 77.6% of cases, a new divergence in the opposite direction.
Only two-sided strict semantics collapse both columns to "reject the channel". Same conclusion #21793 reached for brotli, and the same #84 gating applies to every "make one decoder stricter" step here.
## Proposed work
- [ ] Land [protocol-team#84](https://github.com/ethereum-optimism/protocol-team/issues/84) (strict decompression) as the foundational rule change — it subsumes Instance 1.
- [ ] Decide the FDICT rule (Instance 2) and align both clients; it is a validity boundary, not a partial-output question, so it can be specified independently of #84.
- [ ] Gate a Go↔Rust differential fuzzer in CI comparing the **derived batch set** (not decoder return status) for a shared corpus of malformed channels. Comparing return status is what makes this class easy to misdiagnose in both directions.
## Related
- #21793 — the same conformance problem in the brotli decoders (`brotli-decompressor` vs `andybalholm/brotli`).
- #19333 — earlier kona decompression divergences (partial output accepted as success; brotli Fjord gating).
- Companion issue on the Holocene frame-queue prune divergence, filed alongside this one.
---
🤖 *Co-created with Claude Opus 5 (1M context)*
Contributor guide
Research direction
Start with BatchReader::decompress_zlib in rust/kona/crates/protocol/protocol/src/batch/reader.rs and the corresponding streaming logic in op-node/rollup/derive/channel.go. Read protocol-team#84 and the linked exploit analysis before deciding whether to address strict decompression, FDICT handling, or differential testing. Done means the clients follow an agreed rule and malformed-channel tests compare derived batch sets without a conformance gap.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, rust
- Domain
- backend, blockchain
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100