PR #24817 opened by Jake (jakefineman)
URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24817
Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24817.patch

**Follow-up, not depending on any open PR.** This is one commit on unmodified 
upstream master (ff2059afd9, "tests/fate/dashenc: add a test for a large init 
segment"), branch `dnn-proc-sws-dst-array`. It fixes a stack-buffer-overflow 
found while verifying evidence for an unrelated follow-up 
(`dnn-probe-task-zeroinit`, now #24800); it reproduces identically on that 
commit and on plain unmodified master, so it is not a regression from that or 
any other pending series. The evidence below was produced on commit c0ed9f25fc; 
the commit in this PR, b908f06b70, is the same change re-signed, with an 
identical tree (ea8b39a8b9).

**Problem.** `libavfilter/dnn/dnn_io_proc.c` has two structurally identical 
functions that convert between an `AVFrame` and a `DNNData` tensor, 
`ff_proc_from_dnn_to_frame()` (tensor -> frame, used for a model's output) and 
`ff_proc_from_frame_to_dnn()` (frame -> tensor, used for a model's input). Both 
declare a single `void *middle_data` local to hold a temporary NCHW-layout 
buffer, and both, on the `DL_NCHW` branch, take that one variable's address and 
hand it to `sws_scale()` as the destination or source plane array:

    void *middle_data = NULL;
    ...
    if (output->layout == DL_NCHW) {
        middle_data = av_malloc(plane_size * output->dims[1]);
        ...
        dst_data = &middle_data;          // dnn_io_proc.c:79
        linesize[0] = frame->width * 3;
    }
    ...
    sws_scale(sws_ctx, ..., (uint8_t * const*)dst_data, linesize);

(and the mirror image at line 218, `src_data = &middle_data;`, in 
`ff_proc_from_frame_to_dnn()`.)

`sws_scale()`'s `dst`/`src` parameters are a 4-element array, `uint8_t *const 
dst[4]` / `src[4]`; its internal dispatch reads `dst[1]`, `dst[2]`, `dst[3]` 
(or the `src` equivalents) to decide how many planes it is dealing with, 
regardless of how many planes this particular conversion actually uses. 
`&middle_data` is the address of one 8-byte stack slot, not a 4-element array, 
so those reads walk 8/16/24 bytes past it into whatever the compiler placed 
next on the stack (in practice the `planar_data[4]` array declared right after 
it). This is a genuine out-of-bounds stack read on every `dnn_processing` call 
whose model uses `DL_NCHW` output or input layout together with an RGB24 or 
BGR24 frame -- which is the normal case for ONNX, OpenVINO, and NCHW-layout 
TensorFlow models. It doesn't depend on the model's numerical content, a 
malformed model, or fault injection.

**Fix.** Give `dst_data`/`src_data` real 4-element backing storage instead of 
aliasing the single pointer, with the allocated buffer in slot 0 and the rest 
zeroed:

    -    dst_data = &middle_data;
    +    dst_data_buf[0] = middle_data;
    +    dst_data = dst_data_buf;

and the same shape of change for `src_data`/`src_data_buf`. `sws_scale()` still 
only ever writes/reads slot 0 for these packed-destination conversions; the fix 
just makes the array it's handed actually have 4 real elements to read past 
slot 0 into, instead of running off the end of a single stack variable. The 
`av_free(middle_data)` cleanup at the end of both functions is unchanged -- 
`dst_data_buf[0]`/`src_data_buf[0]` hold a copy of the same pointer, not a 
second allocation, so there is no double free and no new leak.

I checked every other `sws_scale()` call in this file and in 
`vf_dnn_processing.c` for the same aliasing shape (a scalar pointer's address 
cast to a plane array). The `bbox_data` call in `dnn_io_proc.c`'s bounding-box 
crop path takes `&bbox_data` where `bbox_data` is declared `uint8_t 
*bbox_data[4]` -- a real 4-element array, so `&bbox_data` and `bbox_data` are 
the same address with different pointer-to-array vs pointer-to-first-element 
types; that one is not an instance of this bug. Every remaining `sws_scale()` 
call in both files passes either `frame->data` (AVFrame's own multi-element 
array) or a locally declared 4-element array. `dst_data`/`src_data` are the 
only two aliased single-pointer instances in the codebase searched.

**Reproducer.**

    ffmpeg -threads 1 -i puppies.ogv -an \
      -vf 
"fps=2,format=rgb24,dnn_processing=dnn_backend=openvino:model=proc_valid_720.onnx:input=input:output=output"
 \
      -f framemd5 out.md5

built with `--toolchain=clang-asan --enable-libopenvino`, aborts under ASan on 
the very first frame with a stack-buffer-overflow read inside 
`ff_proc_from_dnn_to_frame`, offset 72 into the frame (the byte immediately 
after `middle_data`'s 8-byte slot, running into where `planar_data` begins). 
With this commit applied, the same command is clean of that report. 
[ATTACH:results/before-ov-asan.log] [ATTACH:results/after-ov-asan.log]

**Testing.** Darwin 27.0.0 arm64 (Apple Silicon). OpenVINO 2026.4.0, ONNX 
Runtime 1.30.0, and TensorFlow (C API) all enabled in one configure. Two source 
trees built four ways each (ASan and release): "before" = unmodified master 
ff2059afd9, "after" = this commit on the same base. All of the below was 
independently re-run and re-checked by a second pass after the original 
evidence run, using the same binaries and pinned model/clip hashes; every 
number matched. [ATTACH:MODELS.txt]

*ASan, both aliasing instances independently confirmed live, not just 
structurally identical:*

- `ff_proc_from_dnn_to_frame()` (the `dst_data` instance, line 79): the 
reproducer above, using the synthetic identity model `proc_valid_720.onnx` via 
`dnn_backend=openvino`. Aborts on "before" with the stack-buffer-overflow shown 
above, from the OpenVINO async inference callback thread. Clean on "after". 
[ATTACH:results/before-ov-asan.log] [ATTACH:results/after-ov-asan.log] 
Independently re-run: [ATTACH:results/verify-before-ov-asan.log] 
[ATTACH:results/verify-after-ov-asan.log]
- `ff_proc_from_frame_to_dnn()` (the `src_data` instance, line 218): the same 
`dnn_processing` filter, this time with two real NCHW ONNX Model Zoo 
fast-neural-style models via `dnn_backend=onnx` -- `candy-9.onnx` and 
`mosaic-9.onnx` (sha256s in the attached model manifest), both fixed-shape 
`1x3x224x224` NCHW in/out, run at `scale=224:224,format=rgb24`. Both abort on 
"before" with the overflow located directly inside 
`ff_proc_from_frame_to_dnn`'s `middle_data` frame at offset 72 -- confirming 
this sibling instance is independently exploitable, not merely a structural 
lookalike of the first. Both clean on "after". 
[ATTACH:results/before-candy-asan.log] [ATTACH:results/after-candy-asan.log] 
[ATTACH:results/before-mosaic-asan.log] [ATTACH:results/after-mosaic-asan.log]
- BGR24 (only RGB24 was covered by the runs above): `candy-9.onnx` at 
`scale=224:224,format=bgr24`, five frames. Aborts on "before" with the same 
`ff_proc_from_frame_to_dnn` stack-buffer-overflow. Clean on "after" (only the 
unrelated at-exit SEGV below remains, and the five frames of framemd5 output 
are written successfully before it). 
[ATTACH:results/verify-before-candy-bgr24-asan.log] 
[ATTACH:results/verify-after-candy-bgr24-asan.log]

An unrelated, pre-existing OpenVINO/TBB at-exit SEGV under ASan 
(`tbb::detail::r1::__TBB_InitOnce::~__TBB_InitOnce()`, on process `exit()`) 
remains on this machine/toolchain/OpenVINO version in every "after" ASan run 
above; it reproduces identically on plain `ffmpeg -version` with no DNN filter 
at all, on both the before and after trees, so it is independent of this bug 
and not addressed by this commit. [ATTACH:results/before-version-control.log] 
[ATTACH:results/after-version-control.log]

*Release build, whole clip (puppies.ogv, 589 frames, 1280x720 at 2fps 
sampling), framemd5 output byte-for-byte identical before/after:*

- the OpenVINO/`proc_valid_720.onnx` NCHW path above 
[ATTACH:results/before-ov-rel-whole.md5] [ATTACH:results/after-ov-rel-whole.md5]
- the ONNX/`candy-9.onnx` path [ATTACH:results/before-candy-rel-whole.md5] 
[ATTACH:results/after-candy-rel-whole.md5], independently re-run: 
[ATTACH:results/verify-before-candy-rel-whole.md5] 
[ATTACH:results/verify-after-candy-rel-whole.md5]
- the ONNX/`mosaic-9.onnx` path [ATTACH:results/before-mosaic-rel-whole.md5] 
[ATTACH:results/after-mosaic-rel-whole.md5]
- the BGR24/`candy-9.onnx` path (gap closed) 
[ATTACH:results/verify-before-candy-bgr24-rel-whole.md5] 
[ATTACH:results/verify-after-candy-bgr24-rel-whole.md5]

This is the expected outcome: the aliasing read pulls garbage bytes off the 
stack that `sws_scale()` never writes anywhere for these packed-destination 
conversions, so the fix changes what ASan's redzone sees but not the actual 
visible output.

*The two real models actually transform the frame, so "identical before/after" 
isn't a degenerate no-op check*: the after-fix `candy-9.onnx` and 
`mosaic-9.onnx` whole-clip framemd5 output each differs from a no-filter 
baseline run over the same clip at the same scale/format 
(`scale=224:224,format=rgb24`, no `dnn_processing`), and the after-fix BGR24 
`candy-9.onnx` output differs from the equivalent BGR24 no-filter baseline. 
[ATTACH:results/verify-baseline-nofilter-rgb24.md5] 
[ATTACH:results/verify-baseline-nofilter-bgr24.md5]

*Unchanged paths, also byte-for-byte identical before/after (these never 
allocate `middle_data` or touch the aliased pointer):*

- NHWC TensorFlow: `tf_rgb_valid.pb` via `dnn_backend=tensorflow` into rgb24 
(its output layout is NHWC/packed, so it never takes the `DL_NCHW` branch) 
[ATTACH:results/before-tf-rel-whole.md5] [ATTACH:results/after-tf-rel-whole.md5]
- planar/gray output: `tf_y_valid.pb` via `dnn_backend=tensorflow` into 
`format=gray`, which uses `frame->data` directly in the `AV_PIX_FMT_GRAY8` 
switch case, never the RGB24/BGR24 case that allocates `middle_data` 
[ATTACH:results/before-gray-rel-whole.md5] 
[ATTACH:results/after-gray-rel-whole.md5]

*Second clip (supplementary, not required to reproduce the numbers above)*: 
`mosaic-9.onnx` against a second, unrelated 1280x534 clip (Blender Foundation's 
"Tears of Steel," sha256 in the attached model manifest) reproduces the same 
pattern -- stack-buffer-overflow on "before," clean on "after," and the first 
100 frames of release framemd5 output are byte-for-byte identical before/after. 
[ATTACH:results/verify-before-mosaic-tos-asan.log] 
[ATTACH:results/verify-after-mosaic-tos-asan.log] 
[ATTACH:results/verify-before-mosaic-tos-rel.md5] 
[ATTACH:results/verify-after-mosaic-tos-rel.md5]

`make fate-source` passes on the "after" tree. This tree has no `fate-dnn*` 
tests to run. [ATTACH:results/fate-source-after.log]

Evidence kit (build script, evidence-generation script, model/clip manifest 
with pinned sha256s, and raw logs for every result above, including the 
independent re-verification pass) is attached: [ATTACH:build.sh], 
[ATTACH:run-evidence.sh], [ATTACH:gen-identity-models.py], 
[ATTACH:gen-identity-tf-model.py] and [ATTACH:MODELS.txt]. `run-evidence.sh` 
regenerates every number in this description from a clean checkout given the 
pinned model sha256s and the test clip.



>From b908f06b70f62c4c3295a3bdf9d4f6c5330ca0b7 Mon Sep 17 00:00:00 2001
From: Jake Fineman <[email protected]>
Date: Wed, 30 Sep 2026 10:08:02 -0400
Subject: [PATCH] avfilter/dnn_io_proc: fix stack overflow in NCHW<->frame
 sws_scale calls

ff_proc_from_dnn_to_frame() and ff_proc_from_frame_to_dnn() each declare a
single `void *middle_data` local and, on the DL_NCHW layout branch, take its
address (`dst_data = &middle_data;` / `src_data = &middle_data;`) and pass
that as sws_scale()'s dst/src plane-array argument after casting to
`uint8_t *const *`. sws_scale()'s dst/src parameters are a 4-element array
(uint8_t *const dst[4]/src[4]); its internal dispatch reads dst[1..3] or
src[1..3] regardless of how many planes the actual conversion uses. Since
`&middle_data` only ever points at one 8-byte stack slot, those reads walk
past it into whatever is next on the stack: an out-of-bounds stack read on
every dnn_processing call whose model uses NCHW layout output or input into
an RGB24/BGR24 frame.

Fix both by giving dst_data/src_data real 4-element backing storage
(dst_data_buf/src_data_buf, zero-initialized) with the allocated buffer in
slot 0, instead of aliasing the single pointer.

Tested on arm64 macOS 27.0 (M1 Ultra), clang-asan toolchain, with
--enable-libopenvino (2026.4.0), --enable-libonnxruntime (1.30.0) and
--enable-libtensorflow, against unmodified upstream master (ff2059afd9) and
against this commit applied on top of the same base:

- Reproduced the stack-buffer-overflow on unmodified master two ways:
  - ff_proc_from_dnn_to_frame(): `ffmpeg -threads 1 -i puppies.ogv -an -vf
    "fps=2,format=rgb24,dnn_processing=dnn_backend=openvino:
    model=proc_valid_720.onnx:input=input:output=output" -f framemd5 out.md5`
    aborts under ASan on the first frame (offset-72 read into the
    `middle_data`/`planar_data` stack slots, from the OpenVINO async
    inference callback thread).
  - ff_proc_from_frame_to_dnn(): the same dnn_processing filter with two real
    ONNX Model Zoo fast-neural-style models (candy-9.onnx,
    sha256 9d11a3529d1e547da6ae07201d93484dbab2ec0a3614535752c8f40f0fe2968a;
    mosaic-9.onnx,
    sha256 fa646dedade881243f8d5a2ceb7de2b93675b21fc24f7482894ac4851a9a0a47;
    both NCHW 1x3x224x224 in/out) via dnn_backend=onnx aborts under ASan the
    same way, this time with the overflow located directly in
    ff_proc_from_frame_to_dnn's `middle_data` frame (offset 72), confirming
    the sibling instance flagged at review is independently live, not just
    structurally identical.
  With this commit applied, all three of the above runs are clean of the
  stack-buffer-overflow report (an unrelated, pre-existing OpenVINO/TBB
  at-exit SEGV under ASan remains on this machine/toolchain/OpenVINO version;
  it reproduces identically before and after this commit, and even on plain
  `ffmpeg -version` with no DNN filter at all, so it is unrelated to this fix
  and not addressed here).
- Whole-clip (puppies.ogv, 589 frames) framemd5 output from a release
  (non-ASan) build is byte-for-byte identical before/after this commit for:
  the NCHW OpenVINO identity model (proc_valid_720.onnx) into rgb24, and for
  both candy-9.onnx and mosaic-9.onnx into rgb24 via dnn_backend=onnx. This
  is expected: the aliasing read pulls garbage bytes that sws_scale never
  writes to a packed destination/never reads for these plane counts, so the
  visible output is unaffected; only ASan's redzone instrumentation makes the
  read observable.
- Unchanged paths are also byte-for-byte identical before/after: the NHWC
  TensorFlow path (tf_rgb_valid.pb into rgb24, which never takes the
  DL_NCHW/middle_data branch) and the planar/gray output path (tf_y_valid.pb
  into gray8, which uses frame->data directly and never allocates
  middle_data).
- `make fate-source` passes with this commit. This tree has no fate-dnn*
  tests to run.

Signed-off-by: yakimoto <[email protected]>
---
 libavfilter/dnn/dnn_io_proc.c | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/libavfilter/dnn/dnn_io_proc.c b/libavfilter/dnn/dnn_io_proc.c
index 0a18c56b84..ec4d012319 100644
--- a/libavfilter/dnn/dnn_io_proc.c
+++ b/libavfilter/dnn/dnn_io_proc.c
@@ -46,6 +46,7 @@ int ff_proc_from_dnn_to_frame(AVFrame *frame, DNNData 
*output, void *log_ctx)
     int linesize[4] = { 0 };
     void **dst_data = NULL;
     void *middle_data = NULL;
+    void *dst_data_buf[4] = { NULL, NULL, NULL, NULL };
     uint8_t *planar_data[4] = { 0 };
     int plane_size = frame->width * frame->height * sizeof(uint8_t);
     enum AVPixelFormat src_fmt = AV_PIX_FMT_NONE;
@@ -76,7 +77,8 @@ int ff_proc_from_dnn_to_frame(AVFrame *frame, DNNData 
*output, void *log_ctx)
             ret = AVERROR(ENOMEM);
             goto err;
         }
-        dst_data = &middle_data;
+        dst_data_buf[0] = middle_data;
+        dst_data = dst_data_buf;
         linesize[0] = frame->width * 3;
     }
 
@@ -186,6 +188,7 @@ int ff_proc_from_frame_to_dnn(AVFrame *frame, DNNData 
*input, void *log_ctx)
     int linesize[4] = { 0 };
     void **src_data = NULL;
     void *middle_data = NULL;
+    void *src_data_buf[4] = { NULL, NULL, NULL, NULL };
     uint8_t *planar_data[4] = { 0 };
     int plane_size = frame->width * frame->height * sizeof(uint8_t);
     enum AVPixelFormat dst_fmt = AV_PIX_FMT_NONE;
@@ -215,7 +218,8 @@ int ff_proc_from_frame_to_dnn(AVFrame *frame, DNNData 
*input, void *log_ctx)
             ret = AVERROR(ENOMEM);
             goto err;
         }
-        src_data = &middle_data;
+        src_data_buf[0] = middle_data;
+        src_data = src_data_buf;
         linesize[0] = frame->width * 3;
     }
 
-- 
2.52.0

_______________________________________________
ffmpeg-devel mailing list -- [email protected]
To unsubscribe send an email to [email protected]

Reply via email to