Summary
crc32_clmul() returns wrong CRC32 values when all of the following hold:
- the initial value (
value= argument) has any bit set above its low algn_diff bytes, and
- the buffer start address is congruent to 13, 14 or 15 modulo 16 (i.e.
algn_diff is 1, 2 or 3), and
- the length is >= 16.
crc32_slice_by_8() and zlib.crc32() agree with each other; only crc32_clmul() is wrong.
Found while investigating #10149. It is a separate defect from the over-read reported there — it is present with and without that fix, and it is a correctness rather than a memory-safety problem.
Evidence
Sweeping a 4096 byte heap buffer, views at every start offset 0..31 and every length 0..47, comparing against zlib.crc32:
initial_crc=0x00000000 -> 0 mismatches
initial_crc=0x00000001 -> 0 mismatches
initial_crc=0x12345678 -> 192 mismatches
initial_crc=0xffffffff -> 192 mismatches
All 192 mismatches have algn_diff in {1, 2, 3} and length >= 16. crc32_slice_by_8 matches zlib.crc32 for every one of those inputs.
The dependence on the initial value rather than on the data is the tell: initial_crc=0 is always correct, and initial_crc=1 is correct too, because its only set bit lives in byte 0, which survives even when just one byte is folded.
Cause
In the alignment prologue:
algn_diff = (0 - (uintptr_t)src) & 0xF;
if (algn_diff) {
xmm_crc_part = _mm_loadu_si128((__m128i *)src);
XOR_INITIAL(xmm_crc_part);
src += algn_diff;
len -= algn_diff;
partial_fold(algn_diff, &xmm_crc0, &xmm_crc1, &xmm_crc2, &xmm_crc3, &xmm_crc_part);
}
XOR_INITIAL() xors the 32-bit initial CRC into the low 4 bytes of xmm_crc_part. partial_fold() then folds only algn_diff bytes into the accumulator and discards the rest of the register. When algn_diff < 4, the bytes of the initial value above byte algn_diff - 1 are silently thrown away, and the result is wrong.
The initial-value support is borg's own extension to the Intel code (see the Copyright (c) 2016 Marian Beermann (add support for initial value, restructuring) line in the file header). The upstream routine is a deflate fold-and-copy with no initial value at all, so this one is not an upstream bug either — see #10149 for upstream links.
Why the tests do not catch it
test_crc32 slices a single os.urandom(300) bytes object at start in range(0, 4). CPython's bytes payloads are 16-byte aligned, so those four starts produce addresses congruent to 0, 1, 2, 3 mod 16, i.e. algn_diff in {0, 15, 14, 13} — precisely the complement of the broken set {1, 2, 3}. The test can never reach the failing path.
Covering start in range(0, 16) would expose it immediately.
Impact
Borg 1.x itself appears not to be affected in practice. The two hot call sites in repository.py _read() are:
| call |
address |
algn_diff |
crc32(memoryview(header)[4:]) |
bytes payload + 4 |
12 |
crc32(data, ...) |
bytes payload |
0 |
Both are constant across 2000 samples, because CPython bytes payloads are 16-byte aligned, so neither reaches algn_diff 1..3.
But the function is a public, general-purpose checksum primitive, and the result is silently wrong rather than an error. If any call site ever passes a buffer at an odd alignment (a memoryview into an mmap, a slice at an arbitrary offset, a bytearray from a C extension), borg on a PCLMULQDQ CPU would compute a different CRC than borg on a CPU without it — which surfaces as spurious Segment entry checksum mismatch integrity errors when a repository is written on one machine and read on another.
Suggested fix
Fold the initial value in a way that does not depend on algn_diff. Options:
- XOR the initial value into the accumulator after the prologue rather than into
xmm_crc_part before it, or
- handle the whole
algn_diff prologue through the same zero-padded staging buffer used for the short-input path, positioning the initial value where partial_fold() will keep it, or
- simplest and safest: route any input with
algn_diff != 0 through the scalar path for the first algn_diff bytes, as zlib-ng does with crc32_copy_small().
Whichever is chosen, the regression test should sweep start in range(0, 16) with a non-zero initial value.
Not affected: master / borg 2
src/borg/algorithms/ was removed entirely in master, so there is no crc32_clmul there any more.
Summary
crc32_clmul()returns wrong CRC32 values when all of the following hold:value=argument) has any bit set above its lowalgn_diffbytes, andalgn_diffis 1, 2 or 3), andcrc32_slice_by_8()andzlib.crc32()agree with each other; onlycrc32_clmul()is wrong.Found while investigating #10149. It is a separate defect from the over-read reported there — it is present with and without that fix, and it is a correctness rather than a memory-safety problem.
Evidence
Sweeping a 4096 byte heap buffer, views at every start offset 0..31 and every length 0..47, comparing against
zlib.crc32:All 192 mismatches have
algn_diffin{1, 2, 3}andlength >= 16.crc32_slice_by_8matcheszlib.crc32for every one of those inputs.The dependence on the initial value rather than on the data is the tell:
initial_crc=0is always correct, andinitial_crc=1is correct too, because its only set bit lives in byte 0, which survives even when just one byte is folded.Cause
In the alignment prologue:
XOR_INITIAL()xors the 32-bit initial CRC into the low 4 bytes ofxmm_crc_part.partial_fold()then folds onlyalgn_diffbytes into the accumulator and discards the rest of the register. Whenalgn_diff < 4, the bytes of the initial value above bytealgn_diff - 1are silently thrown away, and the result is wrong.The initial-value support is borg's own extension to the Intel code (see the
Copyright (c) 2016 Marian Beermann (add support for initial value, restructuring)line in the file header). The upstream routine is a deflate fold-and-copy with no initial value at all, so this one is not an upstream bug either — see #10149 for upstream links.Why the tests do not catch it
test_crc32slices a singleos.urandom(300)bytes object atstart in range(0, 4). CPython's bytes payloads are 16-byte aligned, so those four starts produce addresses congruent to 0, 1, 2, 3 mod 16, i.e.algn_diffin{0, 15, 14, 13}— precisely the complement of the broken set{1, 2, 3}. The test can never reach the failing path.Covering
start in range(0, 16)would expose it immediately.Impact
Borg 1.x itself appears not to be affected in practice. The two hot call sites in
repository.py_read()are:algn_diffcrc32(memoryview(header)[4:])crc32(data, ...)Both are constant across 2000 samples, because CPython bytes payloads are 16-byte aligned, so neither reaches
algn_diff1..3.But the function is a public, general-purpose checksum primitive, and the result is silently wrong rather than an error. If any call site ever passes a buffer at an odd alignment (a
memoryviewinto an mmap, a slice at an arbitrary offset, abytearrayfrom a C extension), borg on a PCLMULQDQ CPU would compute a different CRC than borg on a CPU without it — which surfaces as spuriousSegment entry checksum mismatchintegrity errors when a repository is written on one machine and read on another.Suggested fix
Fold the initial value in a way that does not depend on
algn_diff. Options:xmm_crc_partbefore it, oralgn_diffprologue through the same zero-padded staging buffer used for the short-input path, positioning the initial value wherepartial_fold()will keep it, oralgn_diff != 0through the scalar path for the firstalgn_diffbytes, as zlib-ng does withcrc32_copy_small().Whichever is chosen, the regression test should sweep
start in range(0, 16)with a non-zero initial value.Not affected: master / borg 2
src/borg/algorithms/was removed entirely in master, so there is nocrc32_clmulthere any more.