#1783·lz4

[Bug]Input-side heap OOB read in LZ4_decompress_fast family (deprecated unsafe API) - CWE-125

Author: 1820893135-pixelCreated Aug 9, 2026Updated Aug 9, 2026

Describe the bug

LZ4_decompress_fast / LZ4_decompress_fast_usingDict are the deprecated, unsafe decompression APIs (marked deprecated and unsafe in lib/lz4.h). They internally dispatch to LZ4_decompress_unsafe_generic (lib/lz4.c:1879), which does not know the input size and assumes the input is well-formed. The decode loop reads past the end of the input buffer in several places with no bound check against the source end:

  • lib/lz4.c:1899 — token byte read
  • lib/lz4.c:1908 — bulk literal copy via LZ4_memmove(op, ip, ll)
  • lib/lz4.c:1861/1905/1926read_long_length_no_check, a do { ... } while (ip[0] == 255) loop that accumulates 255s and never checks ip against the input end
  • lib/lz4.c:1921 — match-offset read LZ4_readLE16(ip)

Feeding a malformed compressed block to LZ4_decompress_fast results in an input-side heap out-of-bounds read (ASan reports use-after-poison / heap-buffer-overflow).

Affected versions: LZ4 v1.10.0 (HEAD 0774d05 "Fix read oob")


Expected behavior

The deprecated LZ4_decompress_fast family is documented as unsafe and assumes well-formed input. However, an application that passes untrusted compressed data to these APIs (even accidentally, e.g. by using a legacy wrapper) currently triggers an ASan-detectable out-of-bounds read. At minimum, the unsafe decompressor should not silently read arbitrary heap bytes past the input; callers should be steered to LZ4_decompress_safe, which does perform input-side bounds checking via read_variable_length with an ilimit guard.


To Reproduce

  1. Clone LZ4 and check out the affected commit:

    bash
    git clone https://github.com/lz4/lz4.git
    cd lz4
    git checkout 0774d05
  2. Build liblz4.a with ASan + UBSan (clang):

    bash
    make clean
    make CC=clang CFLAGS="-O1 -g -fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer" liblz4.a
  3. Save this harness as harness.c (feeds bytes [8..] of the input to LZ4_decompress_fast; the first 8 bytes are little-endian seed fields that derive originalSize):

    c
    #include <stddef.h>
    #include <stdint.h>
    #include <stdlib.h>
    #include "lz4.h"
    
    static uint32_t FUZZ_readLE32(const uint8_t* p) {
      return (uint32_t)p[0] | ((uint32_t)p[1] << 8)
           | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
    }
    
    int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
      if (size < 8) return 0;
      uint32_t seed0 = FUZZ_readLE32(data);
      uint32_t originalSize = (uint32_t)(((uint64_t)seed0 * (uint64_t)(4 * size + 1)) >> 32);
      const char* src = (const char*)(data + 8);
      size_t srcSize = size - 8;
      char* dst = (char*)malloc((size_t)originalSize + 64);
      if (!dst) return 0;
      LZ4_decompress_fast(src, dst, (int)originalSize);
      free(dst);
      return 0;
    }
  4. Compile the reproducer (links liblz4 + a minimal driver):

    bash
    clang -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer \
      -I lib harness.c lib/liblz4.a -o lz4_decompress_fast_repro -fsanitize=fuzzer,address,undefined -lm
  5. Save the POC (53 bytes) and run:

    bash
    printf '\xbb\x0c\x61\xdb\x57\xe9\xff\xff\xff\x22\x22\x4d\x18\x64\x40\x49\x0c\x02\x00\x00\x00\x00\x50\x2a\x4d\x18\x04\x00\x00\x00\xde\xad\xbe\xef\x04\x22\x4d\x18\x40\x40\xc0\x00\x00\x00\x00\x50\x2a\x4d\x18\x00\x00\x00\x00' > poc.bin
    ASAN_OPTIONS=detect_leaks=0 ./lz4_decompress_fast_repro -runs=1 poc.bin

    The POC layout: [0..3] = 0xbb0c61db (LE) derives originalSize; [4..7] = reserved; [8..] = the malformed compressed block whose first token 0xff (high nibble = 15 → long literal) is followed by 0xff 0xff 0x22..., so the extended literal length makes LZ4_memmove read ~49 bytes past the end of src.

  6. See error:

    ==ERROR: AddressSanitizer: use-after-poison on address ... at pc ...
    READ of size 14 at ... thread T0
        #0 __asan_memmove (asan_interceptors_memintrinsics.cpp:71)
        #1 LZ4_decompress_unsafe_generic /src/lz4/lib/lz4.c:1908:13
        #2 LZ4_decompress_fast_extDict /src/lz4/lib/lz4.c:2567:12
        #3 LLVMFuzzerTestOneInput harness.c:141
    • Replay exit code: 1 (ASan abort)
    • Deterministic: yes — the 53-byte input reproduces reliably (30+ distinct crash inputs confirmed)

System

  • OS: Linux (also reproducible on macOS/Windows with clang)
  • Version: LZ4 v1.10.0, HEAD 0774d05 ("Fix read oob")
  • Compiler: clang (ASan + UBSan, -fno-sanitize-recover=all)
  • Build System: Makefile (make liblz4.a)
  • Other hardware specs: n/a (any x86-64)

Additional context

  • The same root cause is reachable via multiple read sites in LZ4_decompress_unsafe_generic (token read at lz4.c:1899, literal memmove at lz4.c:1908, long-length extension at lz4.c:1861/1905/1926, match-offset LZ4_readLE16 at lz4.c:1921). Over 30 distinct AFL++ crash inputs were confirmed, all mapping to this one unsafe decompressor.
  • The safe entry LZ4_decompress_safe is not affected — it uses read_variable_length with an ilimit bound check. This confirms the flaw is specific to the deprecated LZ4_decompress_fast* family.
  • Suggested fix: either give LZ4_decompress_unsafe_generic an input-end (ilimit) awareness so every read/copy returns safely when ip crosses the source boundary, or, per the maintainers' v2.0 plan, remove the LZ4_decompress_fast family. Callers should migrate to LZ4_decompress_safe / LZ4_decompress_safe_partial for untrusted input.
Image