Skip to content

s2: fix LZ4 block conversion writing past dst - #1244

Merged
klauspost merged 4 commits into
masterfrom
s2-lz4-dst-bounds
Sep 30, 2026
Merged

klauspost merged 4 commits into
masterfrom
s2-lz4-dst-bounds

Conversation

@klauspost

@klauspost klauspost commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Alternative to #1243. That change fixes only the pure-Go path, and does it with a check on every sequence. The assembly has the same bug. It doesn't reject these inputs up front: it writes past cap(dst) first and returns ErrDstTooSmall afterwards, so the corruption goes unnoticed.

(1) s2: stop LZ4 block conversion from writing past dst (a3d8c86)

LZ4Converter.ConvertBlock and LZ4sConverter.ConvertBlock (S2 output) could write past cap(dst):

  • The literal bound checks d+ll, but not the 1–5 byte literal header. After a long literal only 10−h bytes (Go) or 9−h bytes (asm) are left, but a copy with a 5-byte repeat needs up to 8. The assembly writes a few bytes past cap(dst); the pure-Go path panics.
  • Matches over ~16.8 MB chain 5-byte repeat chunks with no check, so the overrun grows with the match length (29 bytes for a 100 MB match).

This adds no checks to the common path:

  • The existing literal bound is tightened by a constant so the following copy always fits: len(dst)-13 in Go. In asm the literal limit is computed once before the loop, so the check stays LEAQ/CMPQ/JAE on amd64 and gains no instruction on arm64. dst now needs 3–4 more bytes of spare capacity; buffers sized with MaxEncodedLen are unaffected.
  • The limit is checked only before each extra chunk of a repeat longer than ~16.8 MB. emitRepeat in the generator gets an optional dstLimit; only the converters pass one, so the encoders' generated code is unchanged.

The extra register makes amd64 cvtLZ4BlockAsm/cvtLZ4sBlockAsm use BP (frame $0 → $8), which renames registers through both functions; that is most of the .s diff. The Snappy-output converters already checked every chunk and are unchanged.

An unexported noAsm field lets tests reach the pure-Go path without build tags.

Test: TestLZ4ConvertBlockDstBounds in s2/lz4convert_test.go converts crafted blocks into each dst size near the limit, for all eight converter/path combinations, with guard bytes past cap(dst). It fails before the fix with and without noasm.

(2) s2: fuzz LZ4 conversion with under- and oversized dst (a6b91cf)

FuzzLZ4Block gave the converter 2*len(data)+4096 bytes, which S2 output never comes close to, and an asm write past cap(dst) isn't visible anyway. It now converts again with every variant into need ± crc32(data)%len(data) bytes with 16 guard bytes after the cap, and checks the guard and that the result agrees with the large-dst run. On the unfixed code it found an overrun within minutes: a block that is corrupt as LZ4s, which overran dst before the corruption was detected. That input is added to testdata/fuzz/FuzzLZ4Block.zip. Each exec now does 16 more conversions.

Test: FuzzLZ4Block in s2/lz4convert_test.go.

Testing: go test ./s2/... with default, noasm, nounsafe and nounsafe,noasm; go vet for amd64 and arm64; the regenerated assembly is byte-identical to the committed files. FuzzLZ4Block ran 5 minutes on the default build (which now covers both asm and Go) and 2 minutes with noasm; longer fuzz runs are in progress. Not run on arm64 hardware locally.

Not addressed here: for literals of 8 bytes or less the assembly loads 8 bytes from src, so a short final literal reads up to 7 bytes past the end of src.

Reported by @Raghuvardhan-Reddy78

Summary by CodeRabbit

  • Bug Fixes
    • Improved LZ4 and LZ4s block conversion when output space is limited, helping prevent writes beyond the available destination buffer.
    • Improved handling of repeated match offsets and long repeat sequences during conversion.
    • Corrected handling of certain LZ4s final literal sequences.
    • Improved consistency across conversion paths when checking output capacity and reporting insufficient space.

The literal bound in the S2-output converters ignored the 1-5 byte
literal header, leaving too little room for a following copy with a
5-byte repeat (up to 8 bytes). The assembly wrote a few bytes past
cap(dst) before returning ErrDstTooSmall; the pure-Go path panicked.
Matches over ~16.8 MB chained 5-byte repeat chunks with no check, so the
overrun grew with the match length.

Tighten the existing literal bound so a copy always fits, and check the
limit only before each extra repeat chunk. The Snappy-output converters
already checked every chunk.

Add an unexported noAsm field so tests reach the pure-Go path without
build tags.
FuzzLZ4Block gave the converter 2*len(data)+4096 bytes, which S2 output
never comes close to, and asm writes past cap(dst) are invisible without
a guard. Convert again with every variant into need +/- a deterministic
amount up to len(data), with 16 guard bytes after cap, and check the
guard and that the result agrees with the large-dst run.

On the unfixed code this found an overrun within minutes; the input is
added to the seed corpus.
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: fa86dbf4-b7bf-49bd-b9c5-93fbe94bbbdb

📥 Commits

Reviewing files that changed from the base of the PR and between a6b91cf and 9cc7c14.

⛔ Files ignored due to path filters (1)
  • s2/testdata/fuzz/FuzzLZ4Block.zip is excluded by !**/*.zip
📒 Files selected for processing (3)
  • .github/workflows/go.yml
  • s2/_generate/gen.go
  • s2/lz4convert_test.go

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The LZ4 and LZ4s converters update destination-capacity checks and repeat emission. Converter types can bypass assembly. AMD64 and ARM64 conversion paths receive corresponding updates, and tests cover output equivalence and writes beyond destination capacity.

Changes

LZ4 conversion

Layer / File(s) Summary
Go conversion limits and repeat emission
s2/_generate/gen.go, s2/lz4convert.go, s2/lz4sconvert.go
The generator and Go converters check literal and repeat output against destination limits. The converters can bypass assembly and emit oversized repeats in successive chunks.
Assembly conversion paths
s2/encodeblock_amd64.s, s2/encodeblock_arm64.s
The assembly paths update literal-capacity checks and repeat-offset handling. The AMD64 LZ4s path changes its final-literal nibble check.
Converter bounds validation
s2/lz4convert_test.go, .github/workflows/go.yml
Fuzz and bounds tests cover assembly and no-assembly variants, compare results with large-destination conversions, and check guard bytes beyond destination capacity. The workflow runs the fuzz target on two Ubuntu runners with empty tags.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix

Suggested reviewers: lizthegrey

Security Architecture Review

Security architecture risk: 🔵 Low · up to a6b91

The changes strengthen bounds handling for caller-provided buffers without adding a new entrypoint or privilege boundary. Low-level implementations on two architectures and incomplete security verification leave residual uncertainty.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The independently reachable exposure is a process that invokes these converters with a compressed block and destination buffer. The supplied evidence does not identify a tenant, service, or deployment path that broadens that exposure.

Trust Boundaries and Controls

  • observed — The Go conversion path retains malformed-source checks and returns a capacity error when its tightened destination bound is exceeded. Assembly failures remain translated at the public entrypoint.

Resilience and Maintainability Implications

  • observed — The large-repeat Go helper checks for room before recursively emitting another chunk, while the changed inline path returns to a capacity-checked loop. Tests check that bytes beyond destination capacity remain unchanged.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 4 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: fixing LZ4 block conversion writes beyond the destination buffer.
Full details: Docstring Coverage

Explanation

Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 4 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

FuzzLZ4Block covers the assembly and pure-Go converters in one run, so
add the assembly variants only when hasAsm is set, and run the fuzzer in
CI without the noasm,nounsafe pass, on both amd64 and arm64.
@klauspost

Copy link
Copy Markdown
Owner Author

Additional fuzzing: FuzzLZ4Block ran for 30 minutes on amd64 at a6b91cf with the default build, so all eight asm and pure-Go converter variants were exercised: ~394M execs, no failures.

The later master merge doesn't touch the converters, and on amd64 9cc7c14 fuzzes the same eight variants.

@klauspost
klauspost merged commit 9abcafb into master Sep 30, 2026
92 checks passed
@klauspost
klauspost deleted the s2-lz4-dst-bounds branch September 30, 2026 07:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants