Skip to content

crc32_clmul: only feed full aligned blocks to the folding code - #10151

Merged
ThomasWaldmann merged 1 commit into
borgbackup:1.4-maintfrom
ThomasWaldmann:fix-crc32-clmul-overread-14maint
Aug 19, 2026
Merged

crc32_clmul: only feed full aligned blocks to the folding code#10151
ThomasWaldmann merged 1 commit into
borgbackup:1.4-maintfrom
ThomasWaldmann:fix-crc32-clmul-overread-14maint

Conversation

@ThomasWaldmann

@ThomasWaldmann ThomasWaldmann commented Aug 18, 2026

Copy link
Copy Markdown
Member

Fixes #10149 and #10150. #10149 is the root cause of the intermittent NetBSD test suite segfaults tracked in #5922.

The problem

The folding implementation loads full 16 byte vectors from 16 byte aligned addresses. But it was also used for the bytes in front of the first alignment boundary, and for inputs shorter than 16 bytes. Two defects follow from that.

#10149, over-read / SIGSEGV. For inputs of 4..15 bytes:

xmm_crc_part = _mm_loadu_si128((__m128i *)src);   /* reads 16 bytes */

reads up to 12 bytes past the end of the buffer. partial_fold() discards them afterwards so the result is right, but the load already happened, and if those bytes are in an unmapped page it is SIGSEGV. repository.py _read() calls crc32(memoryview(header)[4:]), a 5 byte view, so this ran for every segment entry read.

#10150, wrong checksum. In the alignment prologue, XOR_INITIAL() xors the 32 bit initial value into the low 4 bytes of the vector, and partial_fold() then keeps only algn_diff bytes of it. For algn_diff of 1..3 the upper bytes of the initial value are silently dropped and the checksum is simply wrong.

Both have been there since 9afebea (2016), i.e. in every release since 1.1.

The fix

Hand short inputs and the alignment prefix to the table driven implementation, so the folding code only ever sees full aligned blocks:

  • no wide load can reach a short buffer any more, so the over-read is gone
  • the initial value is always folded into a complete 16 byte vector by the existing ONCE mechanism, so it can no longer be truncated

This is the same structure zlib-ng arrived at - see crc32_copy_small() in their arch/x86/crc32_pclmulqdq_tpl.h. Vendoring their implementation verbatim is not practical (it is a 737 line template wired into zbuild.h, crc32_braid_tbl.h, crc32_p.h, x86_intrins.h, the chorba variants and a fold-state API), but the structural decision transfers in ~20 lines. Note their header credits Copyright (C) 2016 Marian Beermann (support for initial value) - the same extension borg carries; upstream restructured around it, our copy never did.

Net effect on the C file: 22 insertions, 40 deletions.

Performance

Faster than the code it replaces at every size borg uses. amd64 Zen 3, ns per call, interleaved runs, min of 5x20M iterations:

len before after
5 9.45 5.00
9 9.60 7.90
15 9.60 13.80
16 7.42 6.70
41 11.68 9.28
256 20.03 19.62
4096 248.88 246.30

Only 12..15 byte inputs regress. Borg's call sites pass 5 and 37 byte headers, and bulk data is unchanged.

Tests

test_crc32 swept start offsets range(0, 4). CPython's bytes payloads are 16 byte aligned, so those only ever produce alignments 0, 15, 14 and 13 - never the broken 1..3. That is why #10150 stayed hidden for nine years. Widened to range(0, 16).

Three tests added, all comparing against zlib.crc32 with initial values 0, 0x12345678 and 0xffffffff:

  • test_crc32_random_slices - 200 memoryview slices of a 1 MiB buffer at random, unaligned start and end offsets. Two uniform offsets average a third of the buffer, so these are large slices with an unaligned head, at every 16 byte alignment.
  • test_crc32_short_slices - 10 random starts, then every end offset from start to start+142, with 142 = 15 (max unaligned prefix) + 64 (one full fold_4 block) + 48 (largest epilogue fold) + 15 (max partial tail). Walks the empty input, the too-short-to-fold inputs and the folding path through every main loop / epilogue fold / partial tail combination, at arbitrary alignments.
  • test_crc32_clmul_no_overread - puts the buffer flush against a PROT_NONE guard page, which no slice of a normal bytes object can do. Runs in a subprocess so a regression fails the test instead of taking the test runner down with a SIGSEGV.

The slice tests deliberately use memoryview slices: slicing a bytes object returns a copy at a fresh 16 byte aligned address, which would silently destroy the alignment coverage these tests exist to provide.

All four fail against the unpatched implementation and pass with this change. They add ~0.4s to the suite.

Verification

On a NetBSD 11.0 amd64 KVM guest (Python 3.11.15), which reproduces #5922 reliably:

check result
alignment x length sweep with non-zero initial value (#10150) 192 -> 0 mismatches
guard-page test (#10149) passes, was subprocess rc=-11
vs zlib.crc32 and crc32_slice_by_8, lengths 0..199 x 20 inputs x 3 initial values 0 mismatches
all four new/widened tests against unpatched code all fail, as intended
randomised fuzz, 450000 comparisons over 50000 random slices, 5 seeds 0 mismatches
same fuzz against unpatched code, every mismatch classified 14972 mismatches, 100% explained by the #10150 signature, 0 unexplained
full suite before segfaulted in 4 of 5 runs
full suite after clean, 1360 passed, 378 skipped

@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.11%. Comparing base (7a50b4a) to head (38b6dce).
⚠️ Report is 3 commits behind head on 1.4-maint.

Additional details and impacted files
@@              Coverage Diff              @@
##           1.4-maint   #10151      +/-   ##
=============================================
- Coverage      82.24%   82.11%   -0.14%     
=============================================
  Files             38       38              
  Lines          11435    11435              
  Branches        1802     1802              
=============================================
- Hits            9405     9390      -15     
- Misses          1449     1461      +12     
- Partials         581      584       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@ThomasWaldmann
ThomasWaldmann force-pushed the fix-crc32-clmul-overread-14maint branch from a4b5817 to 34864ab Compare August 18, 2026 19:01
@ThomasWaldmann ThomasWaldmann changed the title fix crc32_clmul reading past the end of short buffers crc32_clmul: only feed full aligned blocks to the folding code Aug 18, 2026
@ThomasWaldmann
ThomasWaldmann force-pushed the fix-crc32-clmul-overread-14maint branch from 34864ab to 892f52e Compare August 18, 2026 20:02
The folding implementation loads full 16 byte vectors from 16 byte
aligned addresses, but it was also used for the bytes in front of the
first alignment boundary and for inputs shorter than 16 bytes. That
caused two defects:

- For inputs of 4..15 bytes it loaded 16 bytes starting at src, reading
  up to 12 bytes past the end of the buffer. Where those bytes fall into
  an unmapped page, this segfaults. repository.py _read() calls
  crc32(memoryview(header)[4:]), which is a 5 byte view, so this was on
  the hot path for every segment entry read and is the cause of the
  intermittent NetBSD test suite segfaults tracked in borgbackup#5922.
  fixes borgbackup#10149

- In the alignment prologue XOR_INITIAL() xored the 32 bit initial value
  into the low 4 bytes of the vector, but partial_fold() then kept only
  algn_diff bytes of it. For algn_diff of 1..3 the upper bytes of the
  initial value were silently discarded, giving a wrong checksum.
  fixes borgbackup#10150

Hand both cases to the table driven implementation instead, so the
folding code only ever sees full aligned blocks and the initial value is
always folded into a complete 16 byte vector. This is the structure
zlib-ng uses as well, see crc32_copy_small() in their
arch/x86/crc32_pclmulqdq_tpl.h.

It is also faster for the sizes borg actually uses. Measured on an amd64
Zen 3 machine, ns per call:

    len      before   after
      5        9.45    5.00
      9        9.60    7.90
     16        7.42    6.70
     41       11.68    9.28
   4096      248.88  246.30

Only 12..15 byte inputs get slower (9.6 -> 13.8 ns), and borg passes 5
and 37 byte headers.

Tests: test_crc32 only swept start offsets 0..3. CPython's bytes payloads
are 16 byte aligned, so those cover alignments 0, 15, 14 and 13 and can
never reach the broken 1..3 - widen the sweep to 0..15.

Add three more tests, all comparing against zlib.crc32:

- test_crc32_random_slices takes memoryview slices of a 1 MiB buffer at
  random, unaligned start and end offsets, which gives large slices with
  an unaligned head at every 16 byte alignment.
- test_crc32_short_slices walks every end offset from start to start+50
  for 10 random starts, covering the empty input, the scalar path, the
  short path and the folding path at arbitrary alignments.
- test_crc32_clmul_no_overread puts the buffer flush against a PROT_NONE
  guard page. It runs in a subprocess, so a regression fails the test
  rather than killing the test runner with a SIGSEGV.

All four fail against the unpatched implementation and pass with this
change; they add about 0.4s to the test suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ThomasWaldmann
ThomasWaldmann force-pushed the fix-crc32-clmul-overread-14maint branch from 892f52e to 38b6dce Compare August 19, 2026 12:11
@ThomasWaldmann
ThomasWaldmann merged commit f8ba648 into borgbackup:1.4-maint Aug 19, 2026
12 of 14 checks passed
@ThomasWaldmann
ThomasWaldmann deleted the fix-crc32-clmul-overread-14maint branch August 19, 2026 12:38
@github-actions

Copy link
Copy Markdown

Successfully created backport PR for 1.2-maint:

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.

1 participant