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

**Follow-up, not depending on any open PR.** This is one commit on unmodified 
upstream master (09f879b074, "swresample: keep integer samples if channels are 
only copied"), branch `dnn-probe-task-zeroinit`. It answers Forgejo_Fairy's 
non-blocking follow-up in review 8971 on #24761 ("SPACE-BUNNY-ALPHA — Minor for 
this review, pre-existing memory-safety follow-up: initialize the TensorFlow 
and OpenVINO probe tasks"). Fairy's review states the defect predates #24761's 
series and confirmed it is already present at ancestor 6246f7a4cde6, which is 
why this fix targets master directly rather than #24761 or #24702.

**Problem.** `get_output_tf()` in `libavfilter/dnn/dnn_backend_tf.c` and 
`get_output_ov()` in `libavfilter/dnn/dnn_backend_openvino.c` both declare 
`TaskItem task;` with no initializer, then run:

    ret = ff_dnn_fill_gettingoutput_task(&task, &exec_params, ...);
    if (ret != 0) {
        goto err;
    }
    ...
err:
    av_frame_free(&task.out_frame);
    av_frame_free(&task.in_frame);

`ff_dnn_fill_gettingoutput_task()` in `libavfilter/dnn/dnn_backend_common.c` 
allocates the input frame first:

    in_frame = av_frame_alloc();
    if (!in_frame) {
        av_log(ctx, AV_LOG_ERROR, "Failed to allocate memory for input 
frame\n");
        return AVERROR(ENOMEM);
    }

    out_frame = av_frame_alloc();
    if (!out_frame) {
        av_frame_free(&in_frame);
        av_log(ctx, AV_LOG_ERROR, "Failed to allocate memory for output 
frame\n");
        return AVERROR(ENOMEM);
    }

    in_frame->width = input_width;
    in_frame->height = input_height;
    exec_params->in_frame = in_frame;
    exec_params->out_frame = out_frame;

    return ff_dnn_fill_task(task, exec_params, backend_model, 0, 0);

Both `task->in_frame` and `task->out_frame` are set only in the final 
`ff_dnn_fill_task()` call, by way of `exec_params`. If the first 
`av_frame_alloc()` fails, the function returns `AVERROR(ENOMEM)` immediately, 
before `task` is touched at all. Back in the probe, that failure takes the 
`goto err` branch, and `av_frame_free()` is then called on `task.out_frame` and 
`task.in_frame`, which still hold whatever bytes were on the stack when `task` 
was declared. `av_frame_free()` unconditionally dereferences a non-NULL 
pointer's fields (`av_frame_unref()` walks `side_data`, etc.), so this is a 
read (and potential double-free/free-of-garbage) through an uninitialized 
pointer, not a crash that depends on any specific stack content: in practice it 
can free arbitrary memory or, more commonly, immediately trip on whatever 
garbage looks like a non-NULL pointer.

**Fix.** Exactly what Fairy proposed and verified by source review: 
zero-initialize the declaration in each probe, so cleanup receives NULL 
pointers (which `av_frame_free()` treats as a no-op) until the task genuinely 
owns frames:

    -    TaskItem task;
    +    TaskItem task = { 0 };

This matches the two other backends' probes, which already do this and are not 
affected: `get_output_onnx()` in `dnn_backend_onnx.c` (`TaskItem task = { 0 
};`) and `get_output_th()` in `dnn_backend_torch.cpp` (same). I checked both 
before writing this fix; the OpenVINO and TensorFlow probes were the only two 
with the bare declaration in the whole `libavfilter/dnn` tree.

**Base.** Built directly on upstream master because Fairy's citation (ancestor 
6246f7a4cde6) and the current master tree are identical in the three functions 
this touches: `git diff 6246f7a4cde6 master -- 
libavfilter/dnn/dnn_backend_common.c libavfilter/dnn/dnn_backend_tf.c 
libavfilter/dnn/dnn_backend_openvino.c` shows only unrelated hunks (the 
pthread-cancel/`thread_started` guard work in `dnn_backend_common.c` and a dead 
`#if !HAVE_PTHREAD_CANCEL` block removed from `dnn_backend_tf.c`); 
`get_output_tf()`, `get_output_ov()` and `ff_dnn_fill_gettingoutput_task()` are 
byte-for-byte unchanged. Neither #24702 nor #24761 is a prerequisite for this 
fix, and this branch does not carry any of their commits.

**Testing.** Darwin 27.0.0 arm64 (Apple M1 Ultra). OpenVINO 2026.4.0 and 
TensorFlow 2.18.0 (C API) enabled in one configure, against the test clip and 
models listed in `MODELS.txt` in the evidence kit attached to this PR (589 
frames at 1280x720, 29.97fps, sampled at 2fps). ONNX Runtime was enabled at 
configure time so all four backends build, but the fix does not touch the ONNX 
or Torch probes (neither is exercised here beyond compiling). Two source trees: 
"before" = master 09f879b074 unmodified, "after" = an identically-treed 
pre-sign commit of this fix (tree `c184e2b073368f07e16b9a4edd99768dbea8779a`, 
the same tree this commit, `4be38f85d056d9935899dd491a389d2e0d065c8a`, carries 
-- signing off doesn't change a tree). Each tree was built twice, once with 
`--toolchain=clang-asan` and once without, both with identical 
`--enable-libopenvino --enable-libonnxruntime --enable-libtensorflow` configure 
flags. `build.sh` in the kit is the exact build script used, with the local 
paths replaced by environment variables.

Fault injection. `ff_dnn_fill_gettingoutput_task()`'s first `av_frame_alloc()` 
call was made to fail on demand via a one-line, env-gated, uncommitted 
throwaway edit in the ASan build trees only (`in_frame = 
getenv("FF_DNN_TEST_FAIL_FIRST_ALLOC") ? NULL : av_frame_alloc();`, plus 
`#include <stdlib.h>`); it is a no-op unless that variable is set, so it does 
not change any other result in this report and was not part of the commit 
tested by the other builds. The exact edit is `fault-injection.patch` in the 
kit, verified to apply cleanly to `libavfilter/dnn/dnn_backend_common.c` at 
09f879b074. With the variable set, `FF_DNN_TEST_FAIL_FIRST_ALLOC=1 ffmpeg -i 
puppies.ogv -vf 
format=rgb24,dnn_processing=dnn_backend=<b>:model=<m>:input=<i>:output=<o> -f 
null -` was run under ASan for `<b>` = openvino (`proc_valid_720.onnx`) and 
tensorflow (`tf_rgb_valid.pb`), one job at a time.

| build | backend | ASan result | log |
|---|---|---|---|
| before | openvino | `heap-use-after-free` inside `av_frame_side_data_free` <- 
`av_frame_unref` <- `av_frame_free`, called from `avfilter_graph_config` (the 
probe's `err:` cleanup); process aborts | [ATTACH:before-ov-crashtest.log] |
| before | tensorflow | `AddressSanitizer:DEADLYSIGNAL` (BUS, wild write) 
inside `av_frame_side_data_free`, same call path; process aborts | 
[ATTACH:before-tf-crashtest.log] |
| after | openvino | clean error cascade: `Failed to allocate memory for input 
frame` -> `could not get output from the model` -> `Failed to configure output 
pad` -> `Task finished with error: Cannot allocate memory` -> `Conversion 
failed!`; ffmpeg exits through its normal error path with ENOMEM, no 
use-after-free or wild write in the probe | [ATTACH:after-ov-crashtest.log] |
| after | tensorflow | identical clean ENOMEM cascade | 
[ATTACH:after-tf-crashtest.log] |

In both "after" runs, ASan does still report a `SEGV` — but only after the 
"Conversion failed!" line, at process `exit()`, inside 
`tbb::detail::r1::__TBB_InitOnce::~__TBB_InitOnce()` (OpenVINO's bundled TBB, 
`libtbb.12.19.dylib`). This is a pre-existing, unrelated teardown artifact of 
building `--enable-libopenvino` under ASan: it reproduces identically, with the 
same stack, in a fully successful, non-fault-injected "before" run that decodes 
and processes all 589 frames of the TensorFlow whole-clip test and writes 
correct output before exiting ([ATTACH:before-tf-normal.log]). It fires on 
process exit regardless of the DNN backend actually used in the run (it shows 
up on the TensorFlow-model run because OpenVINO is linked into the same 
`ffmpeg` binary), and it is present in both the before and after trees, so it 
is not a regression from, or a confound for, this fix.

Normal whole-clip runs. Both builds, ASan and non-ASan, ran the unmodified 
589-frame clip through `dnn_processing` with no fault injection, `-vf 
fps=2,format=rgb24,dnn_processing=dnn_backend=<b>:model=<m>:input=<i>:output=<o>
 -f framemd5`. TensorFlow (`tf_rgb_valid.pb`): before and after MD5-of-framemd5 
are both `21fe0c97db5ec71c94ba4b741d7a7894`, byte-identical, 589 frames 
([ATTACH:before-tf.md5], [ATTACH:after-tf.md5]), no ASan reports other than the 
unrelated exit-time TBB SEGV noted above (which does not affect the 
already-written output; see [ATTACH:before-ov-normal.log] / 
[ATTACH:after-ov-normal.log]). OpenVINO with `proc_valid_720.onnx` hit a 
pre-existing, unrelated ASan-only finding — a `stack-buffer-overflow` inside 
`ff_proc_from_dnn_to_frame`'s `sws_scale()` call — identically in both the 
before and after ASan builds (same address pattern, same frame, present with 
the same model regardless of this fix; see the same two normal-run logs), so 
the release (non-ASan) builds (`--toolchain` unset, otherwise identical 
configure flags) were used for the OpenVINO whole-clip comparison instead. 
(That overflow is a separate, pre-existing bug, root-caused to 
`dnn_io_proc.c`'s `dst_data = &middle_data;` aliasing a single pointer as a 
4-element array for `sws_scale()`; it is not fixed in this commit and is being 
tracked separately.) Both release runs are clean, `rc=0`, no sanitizer 
involved: before and after MD5-of-framemd5 are both 
`21fe0c97db5ec71c94ba4b741d7a7894`, byte-identical, 589 frames 
([ATTACH:before-ov-rel.md5], [ATTACH:after-ov-rel.md5]) — the same hash as the 
TensorFlow run above, which is expected since 
`proc_valid_720.onnx`/`tf_rgb_valid.pb` are matched identity-transform models 
(their output equals their input).

Models: `proc_valid_720.onnx` sha256 
`1795ae6d41d123b77c18d471ab24818fd395e5726d4aaf3349e0fd54e75c22b7`, 
`tf_rgb_valid.pb` sha256 
`2d16458a416af730a30df7f23781cb6af547455f65bfe02ed3b6307cc66d1169`; both are 
deterministically generated by scripts included in the kit 
(`gen-identity-models.py`, `gen-identity-tf-model.py`), not downloaded -- see 
`MODELS.txt`.

`proc_valid_720.onnx` and `tf_rgb_valid.pb` are identity-transform models 
(their output equals their input), so the matching before/after hash on its own 
only shows that a degenerate pixel-in/pixel-out path was unaffected. To confirm 
the fix doesn't change real output, the same before/after release builds were 
also run against two real, non-identity object-detection models through the 
OpenVINO backend (whose probe, `get_output_ov()`, is the one this commit 
changes): D-FINE-S and RF-DETR-S (both COCO object detectors), wired the same 
way `dnn_detect` is exercised in PR #24702's evidence: `-threads 1 -vf 
fps=2,dnn_detect=dnn_backend=openvino:model=<m>:model_type=ssd:input=data:output=detection_out:confidence=0.5:labels=<labels>,showinfo
 -f null -`, with the `showinfo` lines (frame checksums plus every detection 
box, label and confidence) hashed. Both produced real, varied detections 
(people, a bench, a chair, confidences from ~0.5 to ~0.93) across the 589-frame 
clip, not a constant or empty result:

| model | before MD5 (showinfo) | after MD5 (showinfo) | lines | logs |
|---|---|---|---|---|
| D-FINE-S (`dfine_s_obj2coco_ffmpeg_bgr.onnx`) | 
`770c6e366825ceed08b005c7cb2eda0d` | `770c6e366825ceed08b005c7cb2eda0d` | 6670 
| [ATTACH:dfine-before.info.gz], [ATTACH:dfine-after.info.gz] |
| RF-DETR-S (`rfdetr_s_ffmpeg_bgr.onnx`) | `7aa5b96828128cf0c0a88666b668b0a0` | 
`7aa5b96828128cf0c0a88666b668b0a0` | 4819 | [ATTACH:rfdetr-before.info.gz], 
[ATTACH:rfdetr-after.info.gz] |

Both are byte-identical before/after. Both also differ from the no-filter 
baseline (`fps=2,showinfo` alone over the same clip, MD5 
`28ff75129334b5c317a13eb165aa5d98`, 1180 lines, [ATTACH:nofilter.info.gz]), 
confirming the detection filters are actually doing something and the identical 
hash isn't a degenerate pass-through artifact. (These two hashes include the 
model's file path as printed by ffmpeg's own `showinfo` detection side-data 
output, so they will only reproduce exactly for a run using the same path 
layout as the kit's `run-real-models.sh`; what should reproduce on any machine 
is before == after, and both differing from the no-filter baseline.)

No real, non-synthetic TensorFlow detection model was available for this check, 
so a real, non-synthetic TensorFlow model was sourced for 
`dnn_processing`/`derain` instead: `derain_RESCAN.pb`, a pretrained CAN 
("Recurrent Squeeze-and-Excitation Context Aggregation Net for Single Image 
Deraining") network -- the exact model `doc/filters.texi`'s `derain` section 
and its `dnn_processing`/`can.pb` example point to. Source, revision and sha256 
are in `MODELS.txt`. Inspecting the frozen `GraphDef` confirms the input/output 
node names the docs assume: a single `Placeholder` named `x` (`[1,-1,-1,3]` 
float, NHWC RGB) and a final op named `y`, 278 nodes total (i.e. an actual 
trained network, not a one-node stub).

Run the same way as the identity TF case -- `-threads 1 -vf 
fps=2,format=rgb24,dnn_processing=dnn_backend=tensorflow:model=derain_RESCAN.pb:input=x:output=y
 -f framemd5`, MD5-of-framemd5, both TF-enabled release builds:

| model | before MD5 (framemd5) | after MD5 (framemd5) | frames | logs |
|---|---|---|---|---|
| CAN derain (`derain_RESCAN.pb`) | `2e76557a27bda5a27bb8d5e7104d49f2` | 
`2e76557a27bda5a27bb8d5e7104d49f2` | 589 | [ATTACH:derain-before.md5], 
[ATTACH:derain-after.md5] |

Byte-identical before/after, and different from the no-filter baseline over the 
same clip and pixel format (`fps=2,format=rgb24` alone, MD5 
`21fe0c97db5ec71c94ba4b741d7a7894`, 589 frames, [ATTACH:nofilter-rgb24.md5]) -- 
which is, reassuringly, the exact same hash reported above for the 
`tf_rgb_valid.pb` identity model, confirming that one really was a no-op and 
this one is a real transform. This exercises `get_output_tf()`, the function 
this commit changes, through a real non-identity TensorFlow inference path, 
closing the TF side of the real-model gap for `dnn_processing`.

Not tested: ONNX Runtime and Torch probes (already zero-initialized, not 
touched by this commit, so out of scope); Linux; `sr`/`dnn_classify` filters 
(the bug is in the two probes' shared helper path, reached identically from 
every DNN filter's `config_props`, so this is expected to generalize, but only 
`dnn_processing` and `dnn_detect` were run); a real, non-synthetic TensorFlow 
*detection* model specifically (the real-model TF check above uses 
`derain`/`dnn_processing`, not `dnn_detect`; no real TF SSD-family frozen graph 
was sourced for this commit).

**Reproduction.** An evidence kit is attached to this PR with everything needed 
to rerun all of the above end to end:

- `fault-injection.patch` -- [ATTACH:fault-injection.patch] -- the throwaway 
env-gated edit to `dnn_backend_common.c`, verified to apply cleanly to 
09f879b074.
- `build.sh` -- [ATTACH:build.sh] -- configures and builds either tree 
(`./build.sh before|after clang-asan` or `./build.sh before|after ""` for a 
release build), with OpenVINO/ONNX Runtime/TensorFlow install paths taken from 
environment variables.
- `run-evidence.sh` -- [ATTACH:run-evidence.sh] -- runs the fault-injection 
crash tests and the identity-model whole-clip comparisons against a built tree.
- `run-real-models.sh` -- [ATTACH:run-real-models.sh] -- runs the 
D-FINE-S/RF-DETR-S/CAN-derain real-model comparisons and the no-filter 
baselines.
- `MODELS.txt` -- [ATTACH:MODELS.txt] -- every model and the test clip, with 
public URL, pinned revision and sha256, plus the exact wrap/generation commands.
- `gen-identity-models.py` -- [ATTACH:gen-identity-models.py] -- generates 
`proc_valid_720.onnx` and, with `--bgr`, the OpenVINO BGR-input variants of the 
real detection models.
- `gen-identity-tf-model.py` -- [ATTACH:gen-identity-tf-model.py] -- generates 
`tf_rgb_valid.pb`.
- `wrap_models.py` -- [ATTACH:wrap_models.py] -- wraps the upstream 
D-FINE/RF-DETR ONNX exports into FFmpeg's `dnn_detect` SSD output layout and 
generates their label files.
- The raw logs and hash files behind every number above, under `logs/` and 
`results/` in the kit (linked inline next to each claim).

To reproduce the core fix: `git clone` this branch on top of `09f879b074`, 
apply `fault-injection.patch` to a scratch copy of `dnn_backend_common.c`, 
configure with `--enable-libopenvino --enable-libtensorflow` (and any sanitizer 
toolchain) via `build.sh`, then run `run-evidence.sh` against both the "before" 
(unmodified master) and "after" (this commit) trees. Before this commit, ASan 
reports a use-after-free or wild write from the probe's cleanup; after it, 
ffmpeg reports a clean `AVERROR(ENOMEM)` and exits normally. Run 
`run-real-models.sh` against both trees' release builds to reproduce the 
real-model comparisons.



From 4be38f85d056d9935899dd491a389d2e0d065c8a Mon Sep 17 00:00:00 2001
From: Jake Fineman <[email protected]>
Date: Tue, 29 Sep 2026 22:05:26 -0400
Subject: [PATCH] avfilter/dnn: zero-initialize the output-probe task

get_output_tf() (dnn_backend_tf.c) and get_output_ov()
(dnn_backend_openvino.c) both declare `TaskItem task;` uninitialized
and then unconditionally free task.in_frame/task.out_frame on their
error path:

    ret = ff_dnn_fill_gettingoutput_task(&task, ...);
    if (ret != 0)
        goto err;
    ...
err:
    av_frame_free(&task.out_frame);
    av_frame_free(&task.in_frame);

ff_dnn_fill_gettingoutput_task() (dnn_backend_common.c) allocates
in_frame first and, only if that succeeds, allocates out_frame and
assigns both pointers into exec_params (and from there into the
task via ff_dnn_fill_task()). If the first av_frame_alloc() fails,
the function returns AVERROR(ENOMEM) immediately, before task.in_frame
or task.out_frame is ever set. The two probes then reach `err:` with
task's frame fields holding whatever garbage was on the stack, and
av_frame_free() dereferences those pointers.

Fix by zero-initializing the declaration, matching get_output_onnx()
(dnn_backend_onnx.c) and get_output_th() (dnn_backend_torch.cpp),
which already use `TaskItem task = { 0 };` and are not affected.

Reported by Forgejo_Fairy, review 8971 on #24761, which also
confirmed the defect predates that series and is already present at
6246f7a4cde6.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Jake Fineman <[email protected]>
---
 libavfilter/dnn/dnn_backend_openvino.c | 2 +-
 libavfilter/dnn/dnn_backend_tf.c       | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/libavfilter/dnn/dnn_backend_openvino.c 
b/libavfilter/dnn/dnn_backend_openvino.c
index 0df4d585e8..08c4faef8f 100644
--- a/libavfilter/dnn/dnn_backend_openvino.c
+++ b/libavfilter/dnn/dnn_backend_openvino.c
@@ -1273,7 +1273,7 @@ static int get_output_ov(DNNModel *model, const char 
*input_name, int input_widt
     int ret;
     OVModel *ov_model = (OVModel *)model;
     DnnContext *ctx = ov_model->ctx;
-    TaskItem task;
+    TaskItem task = { 0 };
     OVRequestItem *request;
     DNNExecBaseParams exec_params = {
         .input_name     = input_name,
diff --git a/libavfilter/dnn/dnn_backend_tf.c b/libavfilter/dnn/dnn_backend_tf.c
index 6c72783f77..110ae2c509 100644
--- a/libavfilter/dnn/dnn_backend_tf.c
+++ b/libavfilter/dnn/dnn_backend_tf.c
@@ -315,7 +315,7 @@ static int get_output_tf(DNNModel *model, const char 
*input_name, int input_widt
     int ret;
     TFModel *tf_model = (TFModel *)model;
     DnnContext *ctx = tf_model->ctx;
-    TaskItem task;
+    TaskItem task = { 0 };
     TFRequestItem *request;
     DNNExecBaseParams exec_params = {
         .input_name     = input_name,
-- 
2.52.0

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

Reply via email to