From 494d1855f4d3bec54c6dade0b8d84db19b52fe8d Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Mon, 10 Aug 2026 12:21:32 -0700 Subject: [PATCH] tests: add OOB repro for T283341343 DDict hashset probe Bug: ZSTD_DDictHashSet_getDDict and emplaceDDict do idx &= mask; idx++ which allows idx to become tableSize (64) OOB when probing collides on last slot. IDs 3 and 47 both XXH64 hash to slot 63. Repro (mode 0 from P2447503582): register dictID 3 (slot 63), compress, patch frame header dictID to 47, decompress with refMultipleDDicts. With ASAN, buggy code triggers heap-buffer-overflow at slot 64. Without ASAN it returns dictionary mismatch and hides the bug, so CI must run with -fsanitize=address to catch it. This is the first commit on the fix branch - vulnerability still present so test passes without ASAN but would abort under ASAN, proving the bug. Fix will be second commit. Test: test217 DDict hashset OOB T283341343 (ASAN) Ref: T283341343 --- tests/fuzzer.c | 111 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 7b7c9d666..265168bf3 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -3758,6 +3758,117 @@ static int basicUnitTests(U32 const seed, double compressibility) } DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : DDict hashset OOB T283341343 (ASAN) : ", testNb++); + { + /* Simplified repro for T283341343 - relies on ASAN to catch OOB read. + * Original bug: probe does idx &= mask; idx++ -> idx can become 64 OOB. + * IDs 3 and 47 both hash XXH64(id) & 63 == 63 (last slot). + * Setup: register dict 3 (occupies slot 63), then decompress frame + * whose header dictID is patched to 47 (collides to same slot). + * Buggy getDDict reads slot 64 OOB. With ASAN=1 this is reported as + * heap-buffer-overflow even though glibc would see 0 and return + * "dictionary mismatch". No custom allocator needed. + * See P2447503582 mode 0. + */ + U32 victimID; + U32 attackerID; + size_t victimIdx; + size_t attackerIdx; + ZSTD_DCtx* dctx2; + ZSTD_CCtx* cctx2; + char* dictBufVictim; + ZSTD_DDict* victimDDict; + size_t srcSize; + BYTE* frame; + BYTE fhd; + int dictIDSizeCode; + int nb; + int singleSegment; + int pos; + int i; + + victimID = 3; + attackerID = 47; + victimIdx = XXH64(&victimID, sizeof(victimID), 0) & 63; + attackerIdx = XXH64(&attackerID, sizeof(attackerID), 0) & 63; + if (victimIdx != 63 || attackerIdx != 63) { + DISPLAY("hash assumption broken\n"); + goto _output_error; + } + + dctx2 = ZSTD_createDCtx(); + cctx2 = ZSTD_createCCtx(); + dictBufVictim = (char*)malloc(dictBufferFixedSize); + victimDDict = NULL; + + if (!dctx2 || !cctx2 || !dictBufVictim) { + DISPLAY("alloc failed\n"); + goto _oob_error2; + } + + ZSTD_memcpy(dictBufVictim, dictBufferFixed, dictBufferFixedSize); + MEM_writeLE32(dictBufVictim + ZSTD_FRAMEIDSIZE, victimID); + victimDDict = ZSTD_createDDict(dictBufVictim, dictBufferFixedSize); + if (!victimDDict) { + DISPLAY("DDict creation failed\n"); + goto _oob_error2; + } + + CHECK_Z( ZSTD_DCtx_setParameter(dctx2, ZSTD_d_refMultipleDDicts, ZSTD_rmd_refMultipleDDicts) ); + CHECK_Z( ZSTD_DCtx_refDDict(dctx2, victimDDict) ); + + ZSTD_CCtx_reset(cctx2, ZSTD_reset_session_and_parameters); + srcSize = MIN(CNBuffSize, 1 KB); + cSize = ZSTD_compress_usingDict(cctx2, compressedBuffer, compressedBufferSize, + CNBuffer, srcSize, + dictBufVictim, dictBufferFixedSize, 3); + if (ZSTD_isError(cSize)) { + DISPLAY("compress_usingDict failed %s\n", ZSTD_getErrorName(cSize)); + goto _oob_error2; + } + + /* Patch dictID in frame header to attackerID (47) */ + frame = (BYTE*)compressedBuffer; + fhd = frame[4]; + dictIDSizeCode = fhd & 3; + nb = (dictIDSizeCode == 3) ? 4 : dictIDSizeCode; + singleSegment = (fhd >> 5) & 1; + pos = 4 + 1 + (singleSegment ? 0 : 1); + if (nb == 0) { + DISPLAY("frame has no dictID field\n"); + goto _oob_error2; + } + for (i = 0; i < nb; i++) frame[pos + i] = (BYTE)(attackerID >> (8 * i)); + + /* With ASAN=1 buggy code triggers heap-buffer-overflow here. + * Without ASAN it just returns dictionary_wrong, which is OK for fixed code. + * So this test always passes without ASAN, but fails under ASAN if bug present. + */ + { + size_t const ret = ZSTD_decompressDCtx(dctx2, decodedBuffer, CNBuffSize, compressedBuffer, cSize); + if (!ZSTD_isError(ret)) { + DISPLAY("unexpected success %zu (should be dict mismatch)\n", ret); + goto _oob_error2; + } + /* Expected: dictionary_wrong / mismatch -> OK means OOB was not taken as success, + * but ASAN would have already aborted if OOB read happened. */ + } + + ZSTD_freeDDict(victimDDict); + ZSTD_freeDCtx(dctx2); + ZSTD_freeCCtx(cctx2); + free(dictBufVictim); + goto _oob_skip2; + _oob_error2: + if (victimDDict) ZSTD_freeDDict(victimDDict); + if (dctx2) ZSTD_freeDCtx(dctx2); + if (cctx2) ZSTD_freeCCtx(cctx2); + if (dictBufVictim) free(dictBufVictim); + goto _output_error; + _oob_skip2: ; + } + DISPLAYLEVEL(3, "OK (ASAN would report OOB if vulnerable)\n"); + ZSTD_freeCCtx(cctx); free(dictBuffer); free(samplesSizes);