Skip to content

[benchmark] advance the decodeOnly result cursor by the decompressed size - #4728

Open
evilgensec wants to merge 1 commit into
facebook:devfrom
evilgensec:fix-benchzstd-decodeonly-result-cursor
Open

[benchmark] advance the decodeOnly result cursor by the decompressed size#4728
evilgensec wants to merge 1 commit into
facebook:devfrom
evilgensec:fix-benchzstd-decodeonly-result-cursor

Conversation

@evilgensec

Copy link
Copy Markdown

One line fix in programs/benchzstd.c. In zstd -b -d mode the result buffer write cursor advances by each input compressed size while the buffer is allocated at the total decompressed size, so multi file decode benchmarks can hand ZSTD_decompressStream a destination that lies outside the allocation.

Detail

BMK_benchMemAdvancedNoAlloc allocates the result buffer at line 480:

*resultBufferPtr = malloc(decodedSize);

where decodedSize is the sum of ZSTD_findDecompressedSize() over the inputs.

The per chunk loop then sets the size correctly but advances the cursor with the wrong quantity:

resPtrs[chunkID]  = resPtr;
resSizes[chunkID] = (adv->mode == BMK_decodeOnly)
                  ? (size_t)ZSTD_findDecompressedSize(srcPtr, chunkSize)
                  : chunkSize;
...
resPtr += chunkSize;      /* compressed size in decodeOnly mode */

In BMK_decodeOnly mode chunkSize is fileSizes[fileNb], the compressed size. So chunk i gets a destination at the sum of the preceding compressed sizes inside a buffer sized by the sum of the decompressed sizes. resPtrs[] and resSizes[] go straight to ZSTD_decompressStream as out.dst and out.size.

Whenever an input compresses to more bytes than it decompresses to, the cursor runs ahead of the allocation. The existing guards, the CONTENTSIZE_UNKNOWN and CONTENTSIZE_ERROR checks at 455 to 466 and the size_t overflow check at 474, only validate the total. They never check the per chunk cursor.

A skippable frame is the simplest way to get compressed larger than decompressed, since it costs compressed bytes and contributes zero to the decompressed size.

Reproduction

Two ordinary files, the first carrying a 4096 byte skippable frame ahead of a frame that decompresses to 16 bytes:

import struct, subprocess
open("p0.bin","wb").write(b"X"*16)
open("p1.bin","wb").write(bytes(range(256))*8)
subprocess.run(["zstd","-f","-19","p0.bin","-o","f0_body.zst"], check=True)
subprocess.run(["zstd","-f","-19","p1.bin","-o","file1.zst"], check=True)
skippable = struct.pack("<II", 0x184D2A50, 4096) + b"S"*4096
open("file0.zst","wb").write(skippable + open("f0_body.zst","rb").read())

That gives compressed 4125 and decompressed 16 for file0.zst, and compressed 280 and decompressed 2048 for file1.zst. The buffer is malloc(16 + 2048) which is 2064, and chunk 1 is handed a 2048 byte destination at offset 4125.

Build with a sanitizer and run the benchmark:

cd programs
make zstd MOREFLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1"
./zstd -b -d file0.zst file1.zst

On dev this reports:

==25121==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x61d00000109d
WRITE of size 2048 at 0x61d00000109d thread T0
    #3 BMK_benchMemAdvancedNoAlloc benchzstd.c:647
0x61d00000109d is located 2061 bytes after 2064-byte region [0x61d000000080,0x61d000000890)
allocated by thread T0 here:
    #1 BMK_benchMemAdvancedNoAlloc benchzstd.c:480
SUMMARY: AddressSanitizer: heap-buffer-overflow benchfn.c:116 in BMK_benchFunction

benchfn.c:116 is the memset(dstBuffers[i], 0xE5, dstCapacities[i]) that prewarms the destination. It faults first on the same out of bounds region that ZSTD_decompressStream would then write.

The fix

Advance by resSizes[chunkID], which is assigned three lines earlier and so is already available.

In non decodeOnly mode resSizes[chunkID] is exactly chunkSize, which makes the change a provable no op there. In decodeOnly mode it makes the sum of the per chunk sizes equal the decodedSize passed to malloc, because line 514 issues the same ZSTD_findDecompressedSize call over the same ranges as the validation loop above it.

Verification

With the change applied, the same command completes with no sanitizer report:

- 2 files : 2064 -> 4405 (x0.469), 0.00 MB/s, 2226.1 MB/s

Regression checked, all clean and all under ASAN:

./zstd -b1 p0.bin p1.bin              non decodeOnly path, 2064 -> 293 (x7.044)
./zstd -b1 -B1024 p0.bin p1.bin       explicit blockSize
./zstd -b -d file1.zst                single file decode
./zstd -b -d file1.zst file1.zst      two honest files, 4096 -> 560 (x7.314)
./zstd -b -d f0_body.zst file1.zst    honest first file
./zstd -b -d file0.zst file1.zst file1.zst   three files

Note

This path has no OSS-Fuzz coverage. Nothing under ossfuzz/ links programs/benchzstd.c, which is why the arithmetic has survived. Reachable from the CLI as zstd -b -d over more than one file, and from BMK_benchFiles or BMK_benchFilesAdvanced with nbFiles > 1 and mode = BMK_decodeOnly.

CLA is already on file from earlier contributions.

…size

In BMK_benchMemAdvancedNoAlloc the result buffer is allocated at the total
decompressed size of all inputs, and resSizes[chunkID] is set per chunk to
ZSTD_findDecompressedSize() in decodeOnly mode. The write cursor, however,
advanced by chunkSize, which in decodeOnly mode is the compressed size of
the file rather than its decompressed size.

Chunk i therefore received a destination pointer at the sum of the preceding
compressed sizes inside a buffer sized by the sum of the decompressed sizes.
Whenever an input compresses to more bytes than it decompresses to, the
cursor runs ahead of the allocation and later chunks are handed a dst that
lies outside it. A skippable frame is the simplest case, since it costs
compressed bytes and contributes nothing to the decompressed size.

Advance by resSizes[chunkID] instead. It is assigned three lines earlier, so
it is already available. In non-decodeOnly mode resSizes[chunkID] is exactly
chunkSize, which makes this a no-op there. In decodeOnly mode it makes the
sum of the per-chunk sizes equal the decodedSize passed to malloc, because
line 514 issues the same ZSTD_findDecompressedSize call over the same ranges
as the validation loop above.
Copilot AI lite review requested due to automatic review settings August 6, 2026 03:18
@meta-cla meta-cla Bot added the CLA Signed label Aug 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an out-of-bounds destination pointer bug in the zstd -b -d (decode-only benchmark) path by advancing the result-buffer cursor using the decompressed chunk size rather than the compressed chunk size. This aligns per-chunk destination offsets with the buffer allocation logic (which is based on total decompressed size) and prevents heap buffer overflows when benchmarking multiple input files.

Changes:

  • Advance resPtr by resSizes[chunkID] (decompressed size in decode-only mode, unchanged behavior in other modes).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants