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

Open
#22,834 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.