From c8800991ee8db8020a1e7aa4aab95ce5aeefdcef Mon Sep 17 00:00:00 2001 From: Larry Gritz Date: Sun, 26 Jul 2026 15:12:43 -0700 Subject: [PATCH 1/2] fix(ffmpeg): heap reads after the end, bad packet, checks at open This is a security audit of the FFmpeg reader (specs/002-format-security-audit). Gray movies read heap memory after the end of the buffer. The GRAY8 and GRAY16 code asked swscale for a gray frame with one plane, but it left nchannels at 3. Each scanline copy then read three times too many bytes. Now nchannels is 1. This was the intent when we added the gray code (#2349). It also makes 16-bit gray files readable, which they were not before. A decoder can change the frame size in the middle of a stream. But we make the scale context and the RGB buffer one time, from the header. A smaller frame thus caused a read after its end. Now read_frame() refuses a frame whose size or pixel format is different from the ImageSpec. It also scales from the spec. Nothing examined the frame size at open. A file of 6 KB can declare a frame of 1.1 GB, and cause an allocation of 2.4 GB. Now check_open() and check_compression_ratio() refuse such a file. The ratio floor stays at 1 GB, because a correct movie with one frame can reach a ratio of 13000:1. read_frame() gave an uninitialized AVPacket to av_read_frame(), which does not initialize it when it fails. The code then read bad values from the packet, and it could continue forever on a bad stream. Now the code uses av_packet_alloc(), and it stops when the decoder is empty. There are smaller changes. Examine the result of av_frame_alloc(). Correct a bad nb_frames and start_time. Protect time_stamp() against a time base of zero. Do the frame-count arithmetic in double, because two int64 times can overflow. Report a decode failure, and do not give back the last good frame. Remove an unnecessary av_free() after avformat_close_input(). New fixtures come from testsuite/ffmpeg/src/make_malformed_movies.py: the gray8 and gray16 tests, a decompression bomb, a truncated stream, and a change of size in the middle of a stream. Assisted-by: Claude Code / claude-opus-5 Signed-off-by: Larry Gritz --- src/ffmpeg.imageio/ffmpeginput.cpp | 198 +++++++++++++----- testsuite/ffmpeg/ref/out-ffmpeg6.1.txt | 14 ++ testsuite/ffmpeg/ref/out-ffmpeg8.0.txt | 14 ++ testsuite/ffmpeg/ref/out-ffmpeg8.1.txt | 14 ++ testsuite/ffmpeg/run.py | 25 +++ testsuite/ffmpeg/src/bomb-16384x9000.avi | Bin 0 -> 6592 bytes testsuite/ffmpeg/src/gray16.avi | Bin 0 -> 6726 bytes testsuite/ffmpeg/src/gray8.avi | Bin 0 -> 6468 bytes testsuite/ffmpeg/src/make_malformed_movies.py | 82 ++++++++ testsuite/ffmpeg/src/resolution-change.mkv | Bin 0 -> 2154 bytes testsuite/ffmpeg/src/truncated.avi | Bin 0 -> 2156 bytes 11 files changed, 295 insertions(+), 52 deletions(-) create mode 100644 testsuite/ffmpeg/src/bomb-16384x9000.avi create mode 100644 testsuite/ffmpeg/src/gray16.avi create mode 100644 testsuite/ffmpeg/src/gray8.avi create mode 100644 testsuite/ffmpeg/src/make_malformed_movies.py create mode 100644 testsuite/ffmpeg/src/resolution-change.mkv create mode 100644 testsuite/ffmpeg/src/truncated.avi diff --git a/src/ffmpeg.imageio/ffmpeginput.cpp b/src/ffmpeg.imageio/ffmpeginput.cpp index ba6a3a8b12..165d81fb77 100644 --- a/src/ffmpeg.imageio/ffmpeginput.cpp +++ b/src/ffmpeg.imageio/ffmpeginput.cpp @@ -74,13 +74,37 @@ receive_frame(AVCodecContext* avctx, AVFrame* picture, AVPacket* avpkt) #include +#include +#include #include +#include +#include #include #include OIIO_PLUGIN_NAMESPACE_BEGIN +// Timestamps, frame rates and time bases all come from the file, so the +// arithmetic below can produce NaN, infinity, or values outside the integer +// range. Convert through these helpers to keep that out of undefined +// behavior. safe_int64's range is well under the type's limits so that a sum +// of two results can't overflow either. +static int64_t +safe_int64(double v) +{ + return std::isfinite(v) ? int64_t(clamp(v, -4.0e18, 4.0e18)) : 0; +} + + +static int +safe_int(double v) +{ + return std::isfinite(v) ? int(clamp(v, double(INT_MIN), double(INT_MAX))) + : 0; +} + + class FFmpegInput final : public ImageInput { public: FFmpegInput(); @@ -120,6 +144,7 @@ class FFmpegInput final : public ImageInput { AVFrame* m_frame = nullptr; AVFrame* m_rgb_frame = nullptr; size_t m_stride; // scanline width in bytes, a.k.a. scanline stride + AVPixelFormat m_decoded_pix_format; // what the decoder promised at open AVPixelFormat m_dst_pix_format; SwsContext* m_sws_rgb_context = nullptr; AVRational m_frame_rate; @@ -133,19 +158,21 @@ class FFmpegInput final : public ImageInput { bool m_offset_time; bool m_codec_cap_delay; bool m_read_frame; + bool m_frame_valid; // did the last read_frame() actually decode? int64_t m_start_time; // init to initialize state void init(void) { m_filename.clear(); - m_format_context = nullptr; - m_codec_context = nullptr; - m_codec = nullptr; - m_frame = nullptr; - m_rgb_frame = nullptr; - m_sws_rgb_context = nullptr; - m_stride = 0; + m_format_context = nullptr; + m_codec_context = nullptr; + m_codec = nullptr; + m_frame = nullptr; + m_rgb_frame = nullptr; + m_sws_rgb_context = nullptr; + m_stride = 0; + m_decoded_pix_format = AV_PIX_FMT_NONE; m_rgb_buffer.clear(); m_video_indexes.clear(); m_video_stream = -1; @@ -155,6 +182,7 @@ class FFmpegInput final : public ImageInput { m_last_decoded_pos = 0; m_offset_time = true; m_read_frame = false; + m_frame_valid = false; m_codec_cap_delay = false; m_subimage = 0; m_start_time = 0; @@ -315,31 +343,48 @@ FFmpegInput::open(const std::string& name, ImageSpec& spec) AVStream* stream = m_format_context->streams[m_video_stream]; m_frame_rate = av_guess_frame_rate(m_format_context, stream, NULL); - m_frames = stream->nb_frames; + // nb_frames and start_time come from the file. Clamp them to a range the + // rest of this reader can handle: subimage indices are int, and an + // unset start_time (AV_NOPTS_VALUE == INT64_MIN) would blow up the frame + // arithmetic in read_frame(). + m_frames = OIIO::clamp(stream->nb_frames, int64_t(0), int64_t(INT_MAX)); m_start_time = stream->start_time; + if (m_start_time == int64_t(AV_NOPTS_VALUE)) + m_start_time = 0; if (!m_frames) { seek(0); AVPacket pkt; av_init_packet(&pkt); av_read_frame(m_format_context, &pkt); int64_t first_pts = pkt.pts; - int64_t max_pts = 0; + if (first_pts == int64_t(AV_NOPTS_VALUE)) + first_pts = 0; + int64_t max_pts = 0; av_packet_unref(&pkt); //because seek(int) uses m_format_context seek(1 << 29); av_init_packet(&pkt); //Is this needed? while (stream && av_read_frame(m_format_context, &pkt) >= 0) { - int64_t current_pts = static_cast( - av_q2d(stream->time_base) * (pkt.pts - first_pts) * fps()); + // Do the difference in double: both timestamps are untrusted and + // subtracting them as int64 can overflow. + int64_t current_pts = safe_int64( + av_q2d(stream->time_base) + * (double(pkt.pts) - double(first_pts)) * fps()); if (current_pts > max_pts) { max_pts = current_pts + 1; } av_packet_unref(&pkt); //Always free before format_context usage } - m_frames = max_pts; + m_frames = std::min(max_pts, int64_t(INT_MAX)); } m_frame = av_frame_alloc(); m_rgb_frame = av_frame_alloc(); + if (!m_frame || !m_rgb_frame) { + errorfmt("\"{}\" could not allocate FFmpeg frame", file_name); + close(); + return false; + } + m_decoded_pix_format = m_codec_context->pix_fmt; AVPixelFormat src_pix_format; switch (m_codec_context->pix_fmt) { // deprecation warning for YUV formats case AV_PIX_FMT_YUVJ420P: src_pix_format = AV_PIX_FMT_YUV420P; break; @@ -418,6 +463,7 @@ FFmpegInput::open(const std::string& name, ImageSpec& spec) case AV_PIX_FMT_GRAY8: case AV_PIX_FMT_MONOWHITE: case AV_PIX_FMT_MONOBLACK: + nchannels = 1; datatype = TypeUInt8; m_dst_pix_format = AV_PIX_FMT_GRAY8; break; @@ -430,6 +476,7 @@ FFmpegInput::open(const std::string& name, ImageSpec& spec) case AV_PIX_FMT_GRAY12LE: case AV_PIX_FMT_GRAY16BE: case AV_PIX_FMT_GRAY16LE: + nchannels = 1; datatype = TypeUInt16; m_dst_pix_format = AV_PIX_FMT_GRAY16; break; @@ -496,8 +543,16 @@ FFmpegInput::open(const std::string& name, ImageSpec& spec) default: break; } - m_spec = ImageSpec(m_codec_context->width, m_codec_context->height, - nchannels, datatype); + m_spec = ImageSpec(m_codec_context->width, m_codec_context->height, + nchannels, datatype); + // The frame dimensions come from the container/codec headers, which are + // untrusted. FFmpeg caps the pixel count at only about 2.7e8, which still + // leaves room for a tiny file to declare a multi-GB frame. + if (!check_open(m_spec, { 0, 1 << 16, 0, 1 << 16, 0, 1, 0, 4 }) + || !check_compression_ratio(m_spec, Filesystem::file_size(name))) { + close(); + return false; + } m_stride = (size_t)(m_spec.scanline_bytes()); int rgb_buffer_size @@ -592,8 +647,9 @@ FFmpegInput::seek_subimage(int subimage, int miplevel) if (subimage == m_subimage) { return true; } - m_subimage = subimage; - m_read_frame = false; + m_subimage = subimage; + m_read_frame = false; + m_frame_valid = false; return true; } @@ -633,6 +689,14 @@ FFmpegInput::read_native_scanline(int subimage, int miplevel, int y, int /*z*/, if (!m_read_frame) { read_frame(m_subimage); } + if (!m_frame_valid) { + // Nothing was decoded for this subimage. Without this check we would + // hand back whatever the previously decoded frame left in the buffer. + if (!has_error()) + errorfmt("Could not decode frame {} of \"{}\"", m_subimage, + m_filename); + return false; + } if (m_spec.format == TypeUInt8 || m_spec.nchannels == 1) { if (m_rgb_frame->data[0]) { memcpy(data, m_rgb_frame->data[0] + y * m_rgb_frame->linesize[0], @@ -676,8 +740,8 @@ FFmpegInput::close(void) if (m_codec_context) avcodec_free_context(&m_codec_context); if (m_format_context) { + // Frees the context and everything it owns, and nulls the pointer. avformat_close_input(&m_format_context); - av_free(m_format_context); // will free m_codec and m_codec_context } if (m_frame) av_frame_free(&m_frame); // free after close input @@ -697,61 +761,87 @@ FFmpegInput::read_frame(int frame) if (m_last_decoded_pos + 1 != frame) { seek(frame); } - AVPacket pkt; - int finished = 0; - int ret = 0; - while ((ret = av_read_frame(m_format_context, &pkt)) == 0 - || m_codec_cap_delay) { - if (ret == AVERROR_EOF) { - break; + m_read_frame = true; + m_frame_valid = false; + // Allocate the packet rather than using a stack AVPacket: av_read_frame + // is not guaranteed to initialize it on failure, and the flush path below + // would then unref an uninitialized packet. + AVPacket* pkt = av_packet_alloc(); + if (!pkt) { + errorfmt("Could not allocate FFmpeg packet"); + return; + } + bool flushing = false; + while (true) { + int ret = av_read_frame(m_format_context, pkt); + if (ret < 0) { + if (!m_codec_cap_delay || ret == AVERROR_EOF) + break; + // The codec buffers delayed frames, so keep going with flush + // packets (data == null, size == 0), but stop as soon as the + // decoder runs dry -- otherwise a stream that keeps returning + // the same error would spin here forever. + flushing = true; + av_packet_unref(pkt); + pkt->stream_index = m_video_stream; } - if (pkt.stream_index == m_video_stream) { - if (ret < 0 && m_codec_cap_delay) { - pkt.data = NULL; - pkt.size = 0; - } - - finished = receive_frame(m_codec_context, m_frame, &pkt); + if (pkt->stream_index == m_video_stream) { + int finished = receive_frame(m_codec_context, m_frame, pkt); + if (flushing && !finished) + break; double pts = 0; if (static_cast(m_frame->pts) != int64_t(AV_NOPTS_VALUE)) { pts = av_q2d( m_format_context->streams[m_video_stream]->time_base) - * m_frame->pts; + * double(m_frame->pts); } - int current_frame = int((pts - m_start_time) * fps() + 0.5f); //??? + int current_frame = safe_int((pts - double(m_start_time)) * fps() + + 0.5); //current_frame = m_frame->display_picture_number; m_last_search_pos = current_frame; if (current_frame == frame && finished) { + // A decoder may change frame geometry or pixel format + // mid-stream, but the scaling context and RGB buffer were + // sized from the header when we opened the file. + if (m_frame->width != m_spec.width + || m_frame->height != m_spec.height + || m_frame->format != m_decoded_pix_format) { + errorfmt("\"{}\" frame {} does not match the {}x{} format " + "declared by the header", + m_filename, frame, m_spec.width, m_spec.height); + break; + } + // Use the spec dimensions, which are what m_rgb_buffer and + // the scaling context were sized for -- the decoder can move + // m_codec_context->width/height out from under us. int fill_ret = avpicture_fill(m_rgb_frame, &m_rgb_buffer[0], - m_dst_pix_format, - m_codec_context->width, - m_codec_context->height); + m_dst_pix_format, m_spec.width, + m_spec.height); if (fill_ret < 0) { errorfmt("Error filling FFmpeg RGB frame"); - av_packet_unref(&pkt); break; } - int scale_ret = sws_scale( - m_sws_rgb_context, - static_cast(m_frame->data), - m_frame->linesize, 0, m_codec_context->height, - m_rgb_frame->data, m_rgb_frame->linesize); + int scale_ret = sws_scale(m_sws_rgb_context, + static_cast( + m_frame->data), + m_frame->linesize, 0, m_spec.height, + m_rgb_frame->data, + m_rgb_frame->linesize); if (scale_ret <= 0) { errorfmt("Error converting FFmpeg frame"); - av_packet_unref(&pkt); break; } m_last_decoded_pos = current_frame; - av_packet_unref(&pkt); + m_frame_valid = true; break; } } - av_packet_unref(&pkt); + av_packet_unref(pkt); } - m_read_frame = true; + av_packet_free(&pkt); } @@ -791,15 +881,19 @@ FFmpegInput::seek(int frame) int64_t FFmpegInput::time_stamp(int frame) const { - int64_t timestamp = static_cast( - (static_cast(frame) - / (fps() - * av_q2d(m_format_context->streams[m_video_stream]->time_base)))); + // A corrupt header can give us a zero or degenerate time base, which + // would make the divisions below produce inf/NaN. + double time_base = av_q2d( + m_format_context->streams[m_video_stream]->time_base); + double scale = fps() * time_base; + if (!(scale > 0) || !(time_base > 0)) + return 0; + int64_t timestamp = safe_int64(static_cast(frame) / scale); if (static_cast(m_format_context->start_time) != int64_t(AV_NOPTS_VALUE)) { - timestamp += static_cast( + timestamp += safe_int64( static_cast(m_format_context->start_time) * AV_TIME_BASE - / av_q2d(m_format_context->streams[m_video_stream]->time_base)); + / time_base); } return timestamp; } diff --git a/testsuite/ffmpeg/ref/out-ffmpeg6.1.txt b/testsuite/ffmpeg/ref/out-ffmpeg6.1.txt index 1f1258b343..8d09187e79 100644 --- a/testsuite/ffmpeg/ref/out-ffmpeg6.1.txt +++ b/testsuite/ffmpeg/ref/out-ffmpeg6.1.txt @@ -110,3 +110,17 @@ ref/vp9_rec2100_pq.mkv : 384 x 216, 3 channel, uint10 FFmpeg movie oiio:ColorSpace: "pq_rec2020_display" oiio:Movie: 1 oiio:subimages: 2 +oiiotool WARNING : oiiotool produced no output. Did you forget -o? +src/gray8.avi : 128 x 64, 1 channel, uint8 FFmpeg movie +oiiotool WARNING : oiiotool produced no output. Did you forget -o? +src/gray16.avi : 128 x 64, 1 channel, uint16 FFmpeg movie +oiiotool WARNING : oiiotool produced no output. Did you forget -o? +oiiotool ERROR: read : "src/bomb-16384x9000.avi": FFmpeg movie header claims a 1125 MB image from a 6592 byte file; probably a corrupt or malicious header +Full command line was: +> oiiotool --info --hash src/bomb-16384x9000.avi +oiiotool ERROR: read : "src/truncated.avi" could not open input +Full command line was: +> oiiotool --info --hash src/truncated.avi +src/resolution-change.mkv : 320 x 240, 3 channel, uint8 FFmpeg movie (4 subimages) + SHA-1: "src/resolution-change.mkv" frame 0 does not match the 320x240 format declared by the header +iinfo ERROR: "src/resolution-change.mkv" : "src/resolution-change.mkv" frame 0 does not match the 320x240 format declared by the header diff --git a/testsuite/ffmpeg/ref/out-ffmpeg8.0.txt b/testsuite/ffmpeg/ref/out-ffmpeg8.0.txt index 28084a22e3..022f360f6a 100644 --- a/testsuite/ffmpeg/ref/out-ffmpeg8.0.txt +++ b/testsuite/ffmpeg/ref/out-ffmpeg8.0.txt @@ -110,3 +110,17 @@ ref/vp9_rec2100_pq.mkv : 384 x 216, 3 channel, uint10 FFmpeg movie oiio:ColorSpace: "pq_rec2020_display" oiio:Movie: 1 oiio:subimages: 2 +oiiotool WARNING : oiiotool produced no output. Did you forget -o? +src/gray8.avi : 128 x 64, 1 channel, uint8 FFmpeg movie +oiiotool WARNING : oiiotool produced no output. Did you forget -o? +src/gray16.avi : 128 x 64, 1 channel, uint16 FFmpeg movie +oiiotool WARNING : oiiotool produced no output. Did you forget -o? +oiiotool ERROR: read : "src/bomb-16384x9000.avi": FFmpeg movie header claims a 1125 MB image from a 6592 byte file; probably a corrupt or malicious header +Full command line was: +> oiiotool --info --hash src/bomb-16384x9000.avi +oiiotool ERROR: read : "src/truncated.avi" could not open input +Full command line was: +> oiiotool --info --hash src/truncated.avi +src/resolution-change.mkv : 320 x 240, 3 channel, uint8 FFmpeg movie (4 subimages) + SHA-1: "src/resolution-change.mkv" frame 0 does not match the 320x240 format declared by the header +iinfo ERROR: "src/resolution-change.mkv" : "src/resolution-change.mkv" frame 0 does not match the 320x240 format declared by the header diff --git a/testsuite/ffmpeg/ref/out-ffmpeg8.1.txt b/testsuite/ffmpeg/ref/out-ffmpeg8.1.txt index 0a9eef7cb9..e5bcc54445 100644 --- a/testsuite/ffmpeg/ref/out-ffmpeg8.1.txt +++ b/testsuite/ffmpeg/ref/out-ffmpeg8.1.txt @@ -110,3 +110,17 @@ ref/vp9_rec2100_pq.mkv : 384 x 216, 3 channel, uint10 FFmpeg movie oiio:ColorSpace: "pq_rec2020_display" oiio:Movie: 1 oiio:subimages: 2 +oiiotool WARNING : oiiotool produced no output. Did you forget -o? +src/gray8.avi : 128 x 64, 1 channel, uint8 FFmpeg movie +oiiotool WARNING : oiiotool produced no output. Did you forget -o? +src/gray16.avi : 128 x 64, 1 channel, uint16 FFmpeg movie +oiiotool WARNING : oiiotool produced no output. Did you forget -o? +oiiotool ERROR: read : "src/bomb-16384x9000.avi": FFmpeg movie header claims a 1125 MB image from a 6592 byte file; probably a corrupt or malicious header +Full command line was: +> oiiotool --info --hash src/bomb-16384x9000.avi +oiiotool ERROR: read : "src/truncated.avi" could not open input +Full command line was: +> oiiotool --info --hash src/truncated.avi +src/resolution-change.mkv : 320 x 240, 3 channel, uint8 FFmpeg movie (4 subimages) + SHA-1: "src/resolution-change.mkv" frame 0 does not match the 320x240 format declared by the header +iinfo ERROR: "src/resolution-change.mkv" : "src/resolution-change.mkv" frame 0 does not match the 320x240 format declared by the header diff --git a/testsuite/ffmpeg/run.py b/testsuite/ffmpeg/run.py index eca172516c..0522a96e29 100755 --- a/testsuite/ffmpeg/run.py +++ b/testsuite/ffmpeg/run.py @@ -9,6 +9,31 @@ for f in files: command = command + info_command (os.path.join(imagedir, f)) +# Capture stderr too, so the errors from the malformed-input reads below land +# in out.txt and are checked against the ref. +redirect = " >> out.txt 2>&1 " + # Regression test for narrow 10-bit VP9 input that used to make swscale write # past OIIO's destination buffer during pixel reads. command = command + oiiotool ("src/ffmpeg-width1-gbrp16.mkv --hash") + +# Grayscale movies. These used to be described as 3-channel even though the +# frames were requested from swscale as GRAY8/GRAY16, so every scanline copy +# ran past the end of the decoded row. Check the channel count, then read the +# pixels to make sure the copy stays in bounds. (No hash: swscale output is +# not bit-identical across FFmpeg versions.) See src/make_malformed_movies.py. +for f in [ "gray8.avi", "gray16.avi" ]: + command = command + info_command ("src/" + f, verbose=False, hash=False) + command = command + oiiotool ("src/" + f + " --hash") + +# Hostile/truncated input must be rejected cleanly, with no crash and no +# over-allocation ahead of the rejection. +for f in [ "bomb-16384x9000.avi", "truncated.avi" ]: + command = command + info_command ("src/" + f, verbose=False, + failureok=True) + +# A stream whose frames decode smaller than the header claims. swscale used +# to read past the end of those frames; the read must fail instead. (iinfo, +# because oiiotool's --hash exits 0 on a failed read.) +command = command + info_command ("src/resolution-change.mkv", verbose=False, + failureok=True, info_program="iinfo") diff --git a/testsuite/ffmpeg/src/bomb-16384x9000.avi b/testsuite/ffmpeg/src/bomb-16384x9000.avi new file mode 100644 index 0000000000000000000000000000000000000000..654bda29d3f966af97bd6defed64575a8ae2152c GIT binary patch literal 6592 zcmWIYbaUGw$-v+k=BeQ0863hS%)pS5Qk0WemYHF}z`)?(#LuuI5y$`n0Zs-6MhFv1 z3jh@`FlZ=41(7I_S*rvY7>Y}Za)20YR#|3Bv71|%A(Aq%Iml*4Gchpy2LhNH0R{n( z8EKz@0v|zw4iNho86+6MW-)^VQ9&4+oc`Y(Zr2N@8!)ypzI!P1Y_F^DRtxo#8+rn@ zrAJpiwoTBv(boJg+I=Jc(YTM*{~If|)*e(;QrstbEbyiwKgYKEs!b>FoQj-x{O;e! z#_Y%2@4hQ(f0TOU_n#{VWX`HS$?&?)cV8;VbYEEdKZE>z(~c%ES@`IQA2Rl}O(LGmPMBn3EJ0LF~Ku@wXx zAr2Em(gtRN#u`|F2IQyY<^WN~4|D}1hz}aQk^=hI)6dP{GuSPJ56JdOEK4&p(lZ1i z10(Rr%nvMMPa`OhM!h;30wXpAKqW*5aI88vzbq3pDhnF7{>c8}x_4XhYKA)X!z;wD zF)4A?zg{q{boB?%&dUGOA8PukCfCmUo%B2G1?QjEmxrA;r|aEw%-Hsa^Li}T8H49v z)*RH$nC5A0m@J!9U! zzTqwMmXo(4OJu|Q>suym?3)(Hl(b###kaZBw0jyX(q%&bdB$)4tG4&)tCjxt6TkfG z-mvzX(@k^1Y`fpmhu7zvDyZDO`}ipfpWhc+J=p?PU+ez2y7TG(A_j&y2KifGyQZAi zIeK$vmxPV*f}(F`jQ+tlO%#+am8|S9;a)N^LSf-Y<1D|_o-bxc>#pcr1`XQ(|Ff!T zp2)?+MgMgV?5a33*Yb0Y&`rL>cb2bSR~;s~uu_pX{q4b>)~8wb)mXiieEN<>_p8_I zgMR(W+fMN*{gwQ@H%{Z@+3I4$_qXfa>uW;a|NpkZmwSFM$Ne>lTE zcV5nB1C`caf2RGE-OqbjdSz+y`_iLdzHLlCb$fH2cA09mx=pZ&*gm_3uNfE)F~ka` zm`fS$dBesnWc0oIRIz!fn4QG!_?e8wDNiaM=t!r$PHmc3H}_g?V&k&Ei$T8l`0LB- zE7I#fs9&1r9{>1t{I287Pk3)!VEZV*;&DL4_DPNE%`^SA*K-8s9%R(lU^=exo84p1 z%3p$_{c>y9yXAI2VohAn^?RwXw$p_F|BarhY!v2_IkzWj#-E6nae5yaUY9$HFF0?q zXKxxyd9W7qvun%lna^E%>hqt^XRdrX(RokODgQ1{SXkq}Cf&mf3_BTSM~j;?WTsRY Rf@*G1S_9Fb839n50{}dw)hGY} literal 0 HcmV?d00001 diff --git a/testsuite/ffmpeg/src/gray16.avi b/testsuite/ffmpeg/src/gray16.avi new file mode 100644 index 0000000000000000000000000000000000000000..5fd43e5443b3113c08d768d827dd488dbd07d3e2 GIT binary patch literal 6726 zcmWIYbaS(lVqkC#^HlKh3=UxsW?;xjDauJK%gnG~U|?`?;%8Wq2xI_(04DRtGW<+04gG3=IE)0H&q^XgW}T z+6SNj2sZ$+0}wMZNHBoSN&s@uKp2~x{@)#L*9)c_Ft#zidnog4udDA?3-yv4dIGhj zM^`hzAI^elzQa%pDPDs&Z<7i@Vd@-UnXD@2r|1&hxnb%hrI@!fyHUonh!<*Y_2DTeD zziv_$`5*IiTf@IoaZU08EA69g_P_a`@-D^A1CeNui!VgcpwCrtYRKYLa-?5*U zZg_Sy>C@_4pW4?YIQ^5Gu6ub&%Xx>*a%YGZx-F@jA2c)%*YduSwr{_w@4q z(|xbzm~P6PCF$`?c~{@1;G369gB=}Z{$6||Q+4_T<5#cs-hu~D|C_?Zx0I|9|HDlN8PI^Q_78|NjmD zaKBkNUuNGP-;{;NeMKdAzWqDTk2SON$_gIGlJqqf|Fv@;yY;6?!>4e{bMvdMWsAOv zoO@Kf{`uLgonO)y3SM;W<8e3MGc}~bB+w=^I5p04llB5hk%q=X&gPErFwuLK^~np; zqp~9(ozAS@peH_a?lnG*umw|#wJa?KPyJhQW8(jR4~|c7VUSf=%$j*?X1<%N*Gr35 z@s{qNsTTXbIx*XI%`5$Ud4osYVso2nR=?Ccfq`OEwmGP`t$z2g_-a(kl!)`&jx0DE zBo?=H-oAOC=l%cxva{lA>(u#{p34)$`}dt@V3cBr*m2jGAv2}I5I7;h07~;98ZGb24iATlsw0EN~M<`EPmqh1{i zfzc3vhXAOA=mCxe=jNAXf<|Q# z29BHY{}=>~jTj`1=e&FOZdC)5UHJd={|gunGVs_x6>4B+Uvq+i`@i2YrXFVj&Rx?4 zgpy`3xL#e~(7Yy&`F+$b_DTNJ^&FVF!cQMyW??Yc*kE$p=F@2l*OlFSrUgG1=S-Qy zz>v*w!tX%x9GS@=hcob47$i1vI0>{Uaxidoo-3a7RMTq}#NPk^e{?V;G_c8cayl?J zH;4+lvIw~UdtZ63ATU5AfPwi>ZWe<>Puc@U_H4h+YhFhlo3@;RrAL{=O|S(>2u#}l z|C87M|0lIVzkjO#{}+h<|9=fe@Bjag2Mwqb0S+*kq-o0jOg8j*dw`+icT6S&GoRN3 zM#1>cDhC+l)ow+GMlF+MU~Dux#=!k9vx7mrI7fkD=K%(Wdkh^OF75*RZD9c$#~^Y2 zga%{hxuPknR;8t-Feq33|M~y_XYtCE84NrP499lwV30ImP^tqeG^p9(KaHWvlg0Ll z<=SkC?~V748Fnw#zWmaE;#^0?-r3vr8<<=V?>@C=%_<;hjdMnG+Kut}rm%WGGgj_#rc;!VoxF PzyL~*AR07l07{nte94-K literal 0 HcmV?d00001 diff --git a/testsuite/ffmpeg/src/make_malformed_movies.py b/testsuite/ffmpeg/src/make_malformed_movies.py new file mode 100644 index 0000000000..5d04572c78 --- /dev/null +++ b/testsuite/ffmpeg/src/make_malformed_movies.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Generate the small movie fixtures used by the ffmpeg security-audit +# regression tests. Requires the `ffmpeg` command line tool; the generated +# files are committed, so this only needs to be re-run if a fixture changes. +# +# gray8.avi valid 8-bit grayscale movie +# gray16.avi valid 16-bit grayscale movie +# bomb-16384x9000.avi tiny file whose header declares a 1.1 GB frame +# truncated.avi valid header followed by a chopped-off stream +# resolution-change.mkv frames smaller than the size the header declares +# +# The two grayscale movies are the regression case for a heap overread: the +# reader asked FFmpeg for GRAY8/GRAY16 frames but described them in the +# ImageSpec as 3-channel, so each scanline copy ran three times past the end +# of the decoded row. +# +# AVI keeps the frame dimensions in two places, the main header (avih) and +# the stream format header (strf, a BITMAPINFOHEADER). Patching both is +# enough to make a small file claim an arbitrary frame size, since ffv1 takes +# its dimensions from the container. + +import os +import struct +import subprocess +import sys + + +def ffmpeg(pix_fmt, size, out): + subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", + "testsrc=size=%s:rate=1:duration=1" % size, + "-pix_fmt", pix_fmt, "-c:v", "ffv1", "-frames:v", "1", + out], check=True) + + +def patch_dimensions(src, dst, width, height): + d = bytearray(open(src, "rb").read()) + avih = d.find(b"avih") + strf = d.find(b"strf") + if avih < 0 or strf < 0: + sys.exit("could not locate AVI headers in " + src) + # avih: dwWidth/dwHeight are the 9th and 10th uint32 of the structure. + d[avih + 40:avih + 44] = struct.pack("YE71L?lx;WzisH1C+0*na{N>K;j?#Vf>5B z-E+_Ldp_Rh{oV7Xeat*1Ee5wK<(a=qYN(eioFGnk@YFPe{g za^OrYnLb~vhCGF$|E&;i5`nvo~N8R>JwZGVvzMtwn`E=jp<*~yzy54?Dqm(Z*OQs{eyAv&F zKtrpC2D;i~{)~pU^=$-2>sB)Aa?cr}W9vnO1&SvUbn`>I? z8#mQ;zd_T^!fj?8bn!x^wI8>;XY=X%PhNWDR}E!ddo+ZSz|Frl2l$Qd5HdW!c+uS4 zmhatXKmFzOhT+}D@9^n0GqZ84qXL^SFUkZn4m*}@R;v~JY<352=fJVS3;`CMLQ@3> z{vgs%cyQE;QbHgB69JJ`Gm_uq*fEZCy z!(vQvVxIGHJj-C21VH+UPZ0SC%Q#`zn2T{jLLn?2w>jxJ!`N_?uptu1O1lrVN-QY} zNCN4V{VZeFc>;l>;W+Vo^@^Yqugt}Qgk>C<56NPb^Fk_vRhbAvSOKjnUghUiQ24ee zB=`yE-zvrkYh{cKg9DtRdL<tp0hx;u z1+-2Q4u(_-v$r*Z!{bOL*Yq`@a-yFLF>!NoKA^Mln`XdJj=@g9Q6SYKrjY_ zwF85=O<$a)%??l{y-wlH#aYKX02Gx-Yy}QWKq`Dd;2dgmZScKx_6sw(lw{Hi^M9p& z{_W9OEjy&4vOAhgbRWs2sM0^rO}w<+k#&t|s7cRT3CC^O;PJBcFRa$j8yv+2$PcIP z?nwU+BVBoSqzfZ$oFD1S`#F5|i?tKuA0*MzffwJsxbK-i(8;7>TD)>r!%1yxmGD{i zTMfnUY;;li{LJqx--po1x!k(15M_UI==`hS5 u&WT*#F%1pxfd2; Date: Sun, 23 Aug 2026 16:23:54 -0700 Subject: [PATCH 2/2] fix(ffmpeg): correct the frame indexing, seeking and decoder flush Four bugs in how the reader turns timestamps into frame numbers. All predate this branch; the new "could not decode" error made them visible, because before they returned stale or blank pixels instead. read_frame() compared a pts in seconds against m_start_time, which is a count of time base ticks. Any movie whose stream does not start at zero was therefore undecodable, in every frame. Scale the start time to seconds before the subtraction. time_stamp() produced a value in the stream time base, and seek() gave it to av_seek_frame() with a stream index of -1, which wants AV_TIME_BASE units. It also added the format start time, which is already in AV_TIME_BASE units, after multiplying it by AV_TIME_BASE again. Seeking therefore landed at the start of the file, or far past the end. Seek on the video stream instead, and keep the whole calculation in that stream's time base. The frame counting pass treated every demuxed packet as a video packet. An audio track that runs longer than the video thus added frames that no decode will ever produce. Count video packets only. Codecs that buffer frames, h264 among them, need a flush at the end of the stream to give up the last few. The loop broke out on AVERROR_EOF before reaching the flush path, so the tail of every such movie was unreadable. Flush at end of stream, and let receive_frame() keep pulling frames after the decoder stops taking input. Also: open() left the demuxer at the end of the file, so reading frame 1 first found nothing. Seek back, and start m_last_decoded_pos at -1 so the first read always seeks. The frame counting pass now allocates its packet rather than using the deprecated av_init_packet(). Drop m_offset_time and m_last_search_pos, which nothing has read since 2015. New fixtures in testsuite/ffmpeg/src, from make_test_movies.py (renamed from make_malformed_movies.py, which no longer describes all of them): bframes.mp4, audio-track.mkv and start-offset.mkv. Each is ten frames of well-formed video, and each fails without the fixes above. Assisted-by: Claude Code / claude-opus-5 Signed-off-by: Larry Gritz --- src/ffmpeg.imageio/ffmpeginput.cpp | 125 +++++++++--------- testsuite/ffmpeg/ref/out-ffmpeg6.1.txt | 3 + testsuite/ffmpeg/ref/out-ffmpeg8.0.txt | 3 + testsuite/ffmpeg/ref/out-ffmpeg8.1.txt | 3 + testsuite/ffmpeg/run.py | 17 ++- testsuite/ffmpeg/src/audio-track.mkv | Bin 0 -> 9882 bytes testsuite/ffmpeg/src/bframes.mp4 | Bin 0 -> 2021 bytes ...alformed_movies.py => make_test_movies.py} | 37 +++++- testsuite/ffmpeg/src/start-offset.mkv | Bin 0 -> 6512 bytes 9 files changed, 125 insertions(+), 63 deletions(-) create mode 100644 testsuite/ffmpeg/src/audio-track.mkv create mode 100644 testsuite/ffmpeg/src/bframes.mp4 rename testsuite/ffmpeg/src/{make_malformed_movies.py => make_test_movies.py} (65%) create mode 100644 testsuite/ffmpeg/src/start-offset.mkv diff --git a/src/ffmpeg.imageio/ffmpeginput.cpp b/src/ffmpeg.imageio/ffmpeginput.cpp index 165d81fb77..7893ca0e0d 100644 --- a/src/ffmpeg.imageio/ffmpeginput.cpp +++ b/src/ffmpeg.imageio/ffmpeginput.cpp @@ -56,19 +56,15 @@ avpicture_fill(AVFrame* picture, uint8_t* ptr, enum AVPixelFormat pix_fmt, inline int receive_frame(AVCodecContext* avctx, AVFrame* picture, AVPacket* avpkt) { - int ret; - - ret = avcodec_send_packet(avctx, avpkt); + int ret = avcodec_send_packet(avctx, avpkt); - if (ret < 0) + // AVERROR_EOF means the decoder is already draining: it accepts no more + // input, but it may still have buffered frames to hand back, so keep + // going and let avcodec_receive_frame() tell us when it is empty. + if (ret < 0 && ret != AVERROR_EOF) return 0; - ret = avcodec_receive_frame(avctx, picture); - - if (ret < 0) - return 0; - - return 1; + return avcodec_receive_frame(avctx, picture) == 0; } @@ -153,9 +149,7 @@ class FFmpegInput final : public ImageInput { int m_video_stream; int m_data_stream; int64_t m_frames; - int m_last_search_pos; int m_last_decoded_pos; - bool m_offset_time; bool m_codec_cap_delay; bool m_read_frame; bool m_frame_valid; // did the last read_frame() actually decode? @@ -175,12 +169,13 @@ class FFmpegInput final : public ImageInput { m_decoded_pix_format = AV_PIX_FMT_NONE; m_rgb_buffer.clear(); m_video_indexes.clear(); - m_video_stream = -1; - m_data_stream = -1; - m_frames = 0; - m_last_search_pos = 0; - m_last_decoded_pos = 0; - m_offset_time = true; + m_video_stream = -1; + m_data_stream = -1; + m_frames = 0; + // -1, not 0, so that the first read_frame() always seeks: nothing + // has been decoded yet, and open() leaves the demuxer wherever the + // frame-count pass stopped. + m_last_decoded_pos = -1; m_read_frame = false; m_frame_valid = false; m_codec_cap_delay = false; @@ -333,10 +328,6 @@ FFmpegInput::open(const std::string& name, ImageSpec& spec) close(); return false; } - if (!strcmp(m_codec_context->codec->name, "mjpeg") - || !strcmp(m_codec_context->codec->name, "dvvideo")) { - m_offset_time = false; - } m_codec_cap_delay = (bool)(m_codec_context->codec->capabilities & AV_CODEC_CAP_DELAY); @@ -352,28 +343,49 @@ FFmpegInput::open(const std::string& name, ImageSpec& spec) if (m_start_time == int64_t(AV_NOPTS_VALUE)) m_start_time = 0; if (!m_frames) { + // The container did not say how many frames there are, so find the + // timestamp of the first video packet and of the last one, and turn + // the span into a frame count. Only video packets count: audio and + // data streams have their own time base and often run past the end + // of the video, so counting them invents subimages that no frame + // will ever decode into. + AVPacket* pkt = av_packet_alloc(); + if (!pkt) { + errorfmt("\"{}\" could not allocate FFmpeg packet", file_name); + close(); + return false; + } seek(0); - AVPacket pkt; - av_init_packet(&pkt); - av_read_frame(m_format_context, &pkt); - int64_t first_pts = pkt.pts; - if (first_pts == int64_t(AV_NOPTS_VALUE)) - first_pts = 0; + int64_t first_pts = 0; + while (av_read_frame(m_format_context, pkt) >= 0) { + bool is_video = (pkt->stream_index == m_video_stream); + int64_t pts = pkt->pts; + av_packet_unref(pkt); // seek() below reuses m_format_context + if (is_video) { + if (pts != int64_t(AV_NOPTS_VALUE)) + first_pts = pts; + break; + } + } int64_t max_pts = 0; - av_packet_unref(&pkt); //because seek(int) uses m_format_context seek(1 << 29); - av_init_packet(&pkt); //Is this needed? - while (stream && av_read_frame(m_format_context, &pkt) >= 0) { + while (av_read_frame(m_format_context, pkt) >= 0) { + bool is_video = (pkt->stream_index == m_video_stream); + int64_t pts = pkt->pts; + av_packet_unref(pkt); // always free before reusing the context + if (!is_video || pts == int64_t(AV_NOPTS_VALUE)) + continue; // Do the difference in double: both timestamps are untrusted and // subtracting them as int64 can overflow. - int64_t current_pts = safe_int64( - av_q2d(stream->time_base) - * (double(pkt.pts) - double(first_pts)) * fps()); + int64_t current_pts = safe_int64(av_q2d(stream->time_base) + * (double(pts) - double(first_pts)) + * fps()); if (current_pts > max_pts) { max_pts = current_pts + 1; } - av_packet_unref(&pkt); //Always free before format_context usage } + av_packet_free(&pkt); + seek(0); // the pass above ran to the end of the file m_frames = std::min(max_pts, int64_t(INT_MAX)); } m_frame = av_frame_alloc(); @@ -775,12 +787,13 @@ FFmpegInput::read_frame(int frame) while (true) { int ret = av_read_frame(m_format_context, pkt); if (ret < 0) { - if (!m_codec_cap_delay || ret == AVERROR_EOF) + if (!m_codec_cap_delay) break; - // The codec buffers delayed frames, so keep going with flush - // packets (data == null, size == 0), but stop as soon as the - // decoder runs dry -- otherwise a stream that keeps returning - // the same error would spin here forever. + // The codec buffers delayed frames, so at the end of the stream + // keep going with flush packets (data == null, size == 0) to + // collect them. The test below stops us as soon as the decoder + // runs dry -- otherwise a stream that keeps returning the same + // error would spin here forever. flushing = true; av_packet_unref(pkt); pkt->stream_index = m_video_stream; @@ -790,18 +803,17 @@ FFmpegInput::read_frame(int frame) if (flushing && !finished) break; + // m_frame->pts and m_start_time are both counts of time base + // ticks; scale both to seconds before taking the difference. + double time_base = av_q2d( + m_format_context->streams[m_video_stream]->time_base); double pts = 0; if (static_cast(m_frame->pts) != int64_t(AV_NOPTS_VALUE)) { - pts = av_q2d( - m_format_context->streams[m_video_stream]->time_base) - * double(m_frame->pts); + pts = time_base * double(m_frame->pts); } - int current_frame = safe_int((pts - double(m_start_time)) * fps() - + 0.5); - //current_frame = m_frame->display_picture_number; - m_last_search_pos = current_frame; - + int current_frame = safe_int( + (pts - time_base * double(m_start_time)) * fps() + 0.5); if (current_frame == frame && finished) { // A decoder may change frame geometry or pixel format // mid-stream, but the scaling context and RGB buffer were @@ -872,7 +884,7 @@ FFmpegInput::seek(int frame) int64_t offset = time_stamp(frame); int flags = AVSEEK_FLAG_BACKWARD; avcodec_flush_buffers(m_codec_context); - av_seek_frame(m_format_context, -1, offset, flags); + av_seek_frame(m_format_context, m_video_stream, offset, flags); return true; } @@ -881,21 +893,16 @@ FFmpegInput::seek(int frame) int64_t FFmpegInput::time_stamp(int frame) const { + // The result is in the video stream's time base, which is the unit + // av_seek_frame() expects when seek() hands it m_video_stream. // A corrupt header can give us a zero or degenerate time base, which - // would make the divisions below produce inf/NaN. + // would make the division below produce inf/NaN. double time_base = av_q2d( m_format_context->streams[m_video_stream]->time_base); double scale = fps() * time_base; if (!(scale > 0) || !(time_base > 0)) - return 0; - int64_t timestamp = safe_int64(static_cast(frame) / scale); - if (static_cast(m_format_context->start_time) - != int64_t(AV_NOPTS_VALUE)) { - timestamp += safe_int64( - static_cast(m_format_context->start_time) * AV_TIME_BASE - / time_base); - } - return timestamp; + return m_start_time; + return safe_int64(static_cast(frame) / scale) + m_start_time; } diff --git a/testsuite/ffmpeg/ref/out-ffmpeg6.1.txt b/testsuite/ffmpeg/ref/out-ffmpeg6.1.txt index 8d09187e79..572acc3817 100644 --- a/testsuite/ffmpeg/ref/out-ffmpeg6.1.txt +++ b/testsuite/ffmpeg/ref/out-ffmpeg6.1.txt @@ -124,3 +124,6 @@ Full command line was: src/resolution-change.mkv : 320 x 240, 3 channel, uint8 FFmpeg movie (4 subimages) SHA-1: "src/resolution-change.mkv" frame 0 does not match the 320x240 format declared by the header iinfo ERROR: "src/resolution-change.mkv" : "src/resolution-change.mkv" frame 0 does not match the 320x240 format declared by the header +src/bframes.mp4 : 32 x 32, 3 channel, uint8 FFmpeg movie (10 subimages) +src/audio-track.mkv : 32 x 32, 3 channel, uint8 FFmpeg movie (10 subimages) +src/start-offset.mkv : 32 x 32, 3 channel, uint8 FFmpeg movie (10 subimages) diff --git a/testsuite/ffmpeg/ref/out-ffmpeg8.0.txt b/testsuite/ffmpeg/ref/out-ffmpeg8.0.txt index 022f360f6a..afa68adcfe 100644 --- a/testsuite/ffmpeg/ref/out-ffmpeg8.0.txt +++ b/testsuite/ffmpeg/ref/out-ffmpeg8.0.txt @@ -124,3 +124,6 @@ Full command line was: src/resolution-change.mkv : 320 x 240, 3 channel, uint8 FFmpeg movie (4 subimages) SHA-1: "src/resolution-change.mkv" frame 0 does not match the 320x240 format declared by the header iinfo ERROR: "src/resolution-change.mkv" : "src/resolution-change.mkv" frame 0 does not match the 320x240 format declared by the header +src/bframes.mp4 : 32 x 32, 3 channel, uint8 FFmpeg movie (10 subimages) +src/audio-track.mkv : 32 x 32, 3 channel, uint8 FFmpeg movie (10 subimages) +src/start-offset.mkv : 32 x 32, 3 channel, uint8 FFmpeg movie (10 subimages) diff --git a/testsuite/ffmpeg/ref/out-ffmpeg8.1.txt b/testsuite/ffmpeg/ref/out-ffmpeg8.1.txt index e5bcc54445..0c5cab9d39 100644 --- a/testsuite/ffmpeg/ref/out-ffmpeg8.1.txt +++ b/testsuite/ffmpeg/ref/out-ffmpeg8.1.txt @@ -124,3 +124,6 @@ Full command line was: src/resolution-change.mkv : 320 x 240, 3 channel, uint8 FFmpeg movie (4 subimages) SHA-1: "src/resolution-change.mkv" frame 0 does not match the 320x240 format declared by the header iinfo ERROR: "src/resolution-change.mkv" : "src/resolution-change.mkv" frame 0 does not match the 320x240 format declared by the header +src/bframes.mp4 : 32 x 32, 3 channel, uint8 FFmpeg movie (10 subimages) +src/audio-track.mkv : 32 x 32, 3 channel, uint8 FFmpeg movie (10 subimages) +src/start-offset.mkv : 32 x 32, 3 channel, uint8 FFmpeg movie (10 subimages) diff --git a/testsuite/ffmpeg/run.py b/testsuite/ffmpeg/run.py index 0522a96e29..0677e15e39 100755 --- a/testsuite/ffmpeg/run.py +++ b/testsuite/ffmpeg/run.py @@ -21,7 +21,7 @@ # frames were requested from swscale as GRAY8/GRAY16, so every scanline copy # ran past the end of the decoded row. Check the channel count, then read the # pixels to make sure the copy stays in bounds. (No hash: swscale output is -# not bit-identical across FFmpeg versions.) See src/make_malformed_movies.py. +# not bit-identical across FFmpeg versions.) See src/make_test_movies.py. for f in [ "gray8.avi", "gray16.avi" ]: command = command + info_command ("src/" + f, verbose=False, hash=False) command = command + oiiotool ("src/" + f + " --hash") @@ -37,3 +37,18 @@ # because oiiotool's --hash exits 0 on a failed read.) command = command + info_command ("src/resolution-change.mkv", verbose=False, failureok=True, info_program="iinfo") + +# Frame indexing. Each of these is 10 frames of well-formed video: check that +# the reader says so, and that every frame decodes. (No hashes: swscale +# output is not bit-identical across FFmpeg versions, so just read the pixels +# and let a failed decode show up as an error.) +# bframes.mp4 the decoder holds frames back, so the last ones only +# appear once it is flushed at the end of the stream. +# audio-track.mkv the audio track is longer than the video, and used to +# be counted as extra frames. +# start-offset.mkv the first frame is at t = 5 s, and the whole movie used +# to be undecodable. +for f in [ "bframes.mp4", "audio-track.mkv", "start-offset.mkv" ]: + command = command + info_command ("src/" + f, verbose=False, hash=False) + # -o so oiiotool actually reads the pixels; the .exr is not compared. + command = command + oiiotool ("-a src/" + f + " -o " + f + ".exr") diff --git a/testsuite/ffmpeg/src/audio-track.mkv b/testsuite/ffmpeg/src/audio-track.mkv new file mode 100644 index 0000000000000000000000000000000000000000..80757c3ebcef526c98cc9521b5d63e455802e062 GIT binary patch literal 9882 zcmch-c|2788~=Zu8T%57qAXK%M^v;T%vf4)AzP9xDT;)&Ak2&tSKZ*DTOe$ zP?R=Jr9u-^jj~J_j5*);=)OO{&*zWdf8WP<9^=e8XFT4^bv>`wb-mwb=5OfE&9yj5 z$1KLt-w0jAf=idMh)xLbI&wHL#NW#z9^Hzg6OiPvw$YNTy^i^;UA5d`xq-gH4)g!H47qXEMYhamu=?5T zmbJB=AJPnctWG6R3W8>aC7ch1!-yRjNvD?lGin->j)go9& zdT4I~9ZsHf_Sm@5*&v3FX|iHsMMry~C-n}UR7EFUr4yX)BskrrgHt6s{Y8g=%|HO~ zzFz38@@>_CDSyPTF3_@a+aZF_kv99Al@_AdtO6aWO%H0XlfN7`mZ>Nsp89tn4oo@$ z4di0_Hp|rU5Y7J`NSwKcxw)ltVXSx#31a93;c$efz2~y%Sa|}4L7AE0p1pLEa4g=} zLs|L+SzLSFr8_1~HK zwr2cyt7reGRWC2^|G!3&$5$HuX%;jr9~|{Q&4|5r^3yCf9daqd(EzlM@ZW6#!SJU2 z>y5wejio$nx_ErqY^}}bv|{`|94pR8hmuw0!5Oy?WM&k+sH&;5RGTll_ezwcLyGgJU4#VKy;WoXpHl50Smv-(; z{a7Zv_<#4V&b3IRqdD`HXhSe-B@H;rP2%}RG~##8xNgf2MtUej;1uAd->lFp;Xy>L z{uYC1dpA(0!O=#h;QbUSvgBf(*SF>12u1D+HbbL-k->r@~bgt@%N>Hmkn&5jlIMD1U&oBki zf6TejAYs;#i#-^Z2O3+=a!#svodlQ&;o`VSWQ0l@NK(WBTloF{oR?*Q1$YKQ2_{fL zT~C~$iJq4r6RdUHJtI>t*Fgcy+qmil8W!;hJru+!*hsSB*P`mo^JO9UuEEl*T{u2D z_0QA*(?$)$RtXwhqJVJ`$hNBf%$5f;PjCXltsziCvm{brqD3oS6%%ZbfQaJ&c_3=H zSX#S>pKSO7V7cDU`aMnNlezpbR`KpnzSCs#B$YeLRP}fE$89?VS<`|FR`Qw^b9XD! z>-N2TVtCc(pn=zXrX&b`04_ixpBPW>?n)y2v-_cD|mz30bl+Mw{_5dDqzI`e!d zMb(YP38&X8jC_1}Fhe@mf8b!^vbYtk=jwNc=&|>haMRiL7w4zmVrPc29e-pv6o#=g zwq_o+HO|ir>ok6#9>&g$j{aeH>Y3N(jK(2{3_GrE`z>~VR^Q0&v{RGGaim$f7Q5)M z7a}gwhJVqnKiIn zFz`EPD*X0SLtbR~%PEcg_V8Lf)~o;*iLf^a^@!uy#ts28zO2o+Qz+U@EoBzU=r@=p z8#WlaxLC;qx2Og;freZ?zKLJfW?i-!)PGKi$hVR^&IC7ynz{3Lg_@BdI(~?rTXsV3 zuBnrEl!$uO)LsoD`@@u6;PXiF{Ei3)4&VxLlE=5A2#ar0UHAUEr;+O$LRpgSL*|#A zd(Ust($G6`BiV?RtPkTnszBoluXY8<(d3kPD#Rw0zB3s1{sbMc`DKL!il&F#KATHX zy|IrYgD)n`;t(c=u6Y5W0F+ePr5o(qmT|xGZW}o{T_8tB_&r$Twih#(W<++xu#^P)swP{0y_xZ5iFQ6*XpLXI0V2+bD6j+)7 z>J#it2l6JD?hDs;FKM*OmHn#+;3@1n>AVevltxG@ZLtJJoNYv7T4*L}Ur$5|5uu^< z`_j$>mv&N!pBizTKhEme^h_$tY`c5?+z+#~TwXWLBAq!yk6S7hlX*CAt(a2Jx^@}g z>orG^P=M3kl4YuqueT{U>t0|#$Dd4(4n@`D%yDM@lsJ+J9=RUPLOlJ`oj2l_ve!An zf}9w=<%S|GG>MXVQ&R{hOm!XHP_fTpEPlZd6j9Y?(ddlY7;A-JvS&^iP6m(*<8&*NAUdr2F!%8-@L zHuzN<%U=J~9^~S`iHAL3r!dWAvE+vn{C*Apnmai#aDB>uhhy2uC}-1~+RFwXMg{WY zH~9+Rt)F~}TJptA6KS6loH+Ku9>Hz$UvO7c+>k+07nTQdzCC!Z!1`)HULp`XYqO(u z#7EtZ&tFwPI(YM8%u|ri?v|Jzk*WES>J(fXEw?+OOwQo@&j|6$Y}3X@c1;$J1ZWo+ zKlpkC(Vew9^!Qbqf9a+&8&Dqy^3;pM@~-;jU9y8Q9z(F=3*Yua9q}XaW|Y_!?=%o8 z6p`^;7OD&iw{?dcl;0`uDC!_9T=(+!^l~HaX|4lNXV2---!NM*>Zr-kXWI!)YBP0W z5--40@DAqmCO%a_#aRNf0K(XYfXy=3NGrS>MWHmr!~|@3?3)gzqiRQ+o50|rlfJlS z>#fTkR!9V(P4u2i>B9!cyn-xpoYhrUf1%l%S|9qR&usd>U{Bz@p$fvP2UE_n7SO_AGv;eRJ z8Y0((j_J*i+ofD5&2hy727T`_ukS?FnV+fBk7EBsdm8p@8rUb^e!*+Tc#rveKK}~- zjL7}k@w3>?p`(LYtTAWOtwpzmXZf1kld??a^NKG5-7nPuj^GQ#6A0Y(LU83NG~p}d6v2~cPMWt@ng*{-esNDP-pLs|WI~9#Gdie(U;a z?dvoc4aEgy7C-Pz&-xU(p>dVBATr>-VVgEj`JK0Bx6GUloQr4SWE@{ILLJ2MC;Svt$Bd9Q zQGt6RW)Sl3of|z1;Y|t=>O{$N(FtM}j}wWHz0b@i!7|Yz{cPfsVfx`}?3Y;mS$+MrdP>qFl~SV1=Sapy_u3&& zD+r#_q6Bl28wt`}^i+Bj6e|#^TjI$~?mxy>KM z@BJo5g&sV%;wikr zttuHN67YeAEJ^g%)kp_<>jt!Hw) ztUKl^X!J*r4vZX5=0-X-k4cz@be;`K?(lClW*B`@(=hG9`N9IiN{1bVP|bWIfvY^F ztlw%!IG>t~Go!l#9&`n$6&)=qYu78Q18x{uVEdJya<0cXxnsy!3NW{O7q;fiO%++H zdslz|r7Q0PYp1@&(Srl7By1H8IC?G!R-V4?366cw%WDnh38+`dPw+vUdKW1wJ~pMn z!hhm{EIyS7tKYu>Ls0$tM6GV%=}XUE&v}=1vlJr7Y2r^WoAkBOWDZ}}hBX^*`mcQE zWC!6|C9B@;yLSIb=cdMMma~M(wi?PT<=ch4-bcLZY!*MT6*@NFX_OwU9fD!LYUh_0 z)uSz{6yjs1uw-cKaoezA-o)y<bL&=zibi2}iSGAR#Itwibv#zr1^Y6RM#0zKR3rR7x$7D3DYQQoC{T(E6iBM!yG7_a|>E&ewW|5?vM4 zCDChURPXl;OKs`zq>fi(LHq4sppVcXp88&Jef1GDDWS8C-5ua&>o{bz73 zlD#*~pT(|DOeV8f{oXiqUD;@`IhX?8Gv@%dBiAUv!vJy_bRY^%8RT1oEpV*0s2* z7qY@d)GPa24L5Bn|E$`b{%e+P=1;VMgPpK%ZJmw~rBg;`@3&Ic8?_S-r`j->d!~L> zaZ1$mLQ1Lz0v^18kqN56qmN&DvBxMmWys{o>B=QqmQ&_fXb)^GdXZq%W{t0?o%-r? z|IUl{`%fiL=&JO9+vOc{p3lkyJyCiilhJZ2Wf*7UxJC=kzP2fdEqj{AHx0edpq*ujr4`8 zr#^K1y^x}##h_!90 zmLkmyT(I<2G%X1lre8pyh){xZEiCB-ml;kQYkDN7YOob2TKt)=$jMb4HNSx!yLUJ_ zQLt~*(D8Y!k6by#dm6D%ePFl*aoXPXvv{X-R~O>c8FA`=nP*uV*pqzbS>}$uD2Kio zPUi~}wGgL}|oNft=N^2&W>TD)# zPqLvhEvC@;i&cw)iywRpcp#KKh9Iz?-*KhKFxhs9OoPRwy&26{6HY4XdRkF5PC6S8 z)=ov#RaLY$EZj|yLbaeiXiE?Lz2#_|!U!L?of;Qp{l-&R_-jGRFrPX}ekmZQ2(T#P z9V^8pG~yeWRbihE`5n>FX&OAQJ3<~Ja)KW()$h^G-DC%A9v|RjRiP})-sI+cwrx4< zQU&jrSG-X0s$lfI$5kP!YlLx)JXIdi%x5XzlECnF3Ys@^!AQ7-O`FXEEqT5{Kb*#jNMOGqU7F7K!U8xS1 zX6n3p1G1-#54_A~wt`&7TxG+)-##2P^{aX7i?=V@D~vSN;UqyWCz^*$2)8Dt;Te3Lx! z8PmYo4or(bNlxnfu7I?i`;aV5LkmI{ul&VptX?9?|^Kkr3GuRlUFxA+kbg#RXI(E*U-RlI4aJG@JgKQ&Qzr zm>0JxR*tUa#f=>QECMV4=CmJNq%_)d%;hrDm#w7++6(lY+u9x?N#Y<)O%X8gVcwrp zKBfA@(OJ9c|w+Nu?7zU+91<9vqyW`fcT#AFa=hN2uLMa>vGx-T9f)cT+p6hwwH%}+Te=@ zv~I!$INmu*9mfTww@^wLHMJcgE{N{edrN`keuT5J~b^xUy7FSC~s!Bww9)b{4Q(VL7;utZQNIFbB3IDj=#etk+0%L3xxlaUwqtYY&)eJW zQbjSSnhngs2&vNSZ?H1^-Ti<7xXhK2YAmSd@?!1$q3PE*3aS{E-V(aSnb`U+hfjgp5^6~p5)~= z>Vf?N|Na?7_YNsqAVk*+5k;zME<&6%HrA3wapeB!h?s^D(W&ua;koQ5MaTcB>f!Df zW0LxSP*r`-jG;pE7eciBtnZl)|FpFXqo)frOk4jTN?Jaeju6dwG9f?;BOgn`BrL?m zIdPHW-H}Y${&n+qA6nbBAHr%h@J+v?;$I9c*f_|OT-mTE(Z$<1@4OX}-9fkWZkQUZ z2GQt%qrvA6Tns)q|Lt}Q7X4fjwrzDbh#4#|@xaINE?l5{TF{LTyLpV^c;XivLJJj_ z++PUdF%fM@Q^NCYN!C;l5vfTT=t)Z1x_i1p|73SaQ+=N+NPXM#vpfAsRqdwgW zI5_;$$9N7v1zc+@?iE5>{x77o7MIU8$9{H4z^sS$SsR^@c|O;KiV8GP{;u&L)-Lna zop_tRxPbxza}~c6UY(g}AX@vC>94?1^*e}9j(Z?H zIiCU%uf<0hb>gFzAGa$aNDqUYt^_rr5HP3fk53UXj1yUC<*H+1lLsQNXu!-{U_@G{ zuQ_6RU=QoKZ7#z%B5TbBHRrph76IG`iH`xxatEVD&~6h5^M49!bYhs@(uI#w`I9xF z{HSo|;i?H1qr0)<*$30!@@hul_W)D^e8H{dElr8ltqx{E$fx43kf%cqPmh!jGZC&HX=3$r$uX%4x0v} z;%%_1x2a?ebN9suA3)vXg|waJREB_ko3(y8<3#ae-Y!nr>#PUmJAJdV-0vUr{T?#6 zIIDNoa%!}pu~Are#zaT@5P&mOO>C}4$gcTYFGuf>s(uI$-#deB%@Hx-?&%D&=v?-_ z>XHAb>YnbHMf&OiLRIxX^U&0(c>p0>e0J}d6#t|(45Oy`S*8ttkd;p94H6=oXl(kf znWP{@w)rm(C+sM5cff-)57s(&9a_876Cq2+ZGQ_czZ#m~+`*IV6OHXGJKeh7OVpd4 zf<>_q;L4z6Ro11uXyi*5X$9xt?X4#_xa`kCJO4+6&r|VCY8K9xo5H6Xk=KK8kZOVmdg^JSgE7mK4jKl1t&ieK^Qk!A z5pE_vXnlE?XZE*u1vJol981F@Bus}7tVv8oHBo@BC;;b?zs@F6{m=Zyc zE6^I9?LCbPIEdFzXr{H^>(Dg~efI_4}e%I%L9`L{|<`OX{vG(JhiSKzE$ zjVX~4Q6Z;TtnT6G8#x_`gs7iWZ`TcGZ`t;9>Z$#Yz;0pQM>|K|2Ewdwm+zk{Vu1x! zxM?dQF_k`fRuriyQQsP~Lq%$Wm3Z#AXwjMS0__1z$T8YW)ycRVvIUduUcuP0JF<%X zCGc2x<{OyRRrX*2#3fUY|&y>n)UjGf!HChDruZB-5wvG9IWd8j!%*W$5q4mC%T#J@J!R{u(2s8 zzbh1H%{dd)6@3pSx)jq*_FhS_u&~(Ou(f^m?AdQ^e$95&(kl4-dx;+bgETiiC9q!z zG=is8T(mjG3JWT5K{K@g!`vUyVkpdo35lBjdwPODG5p7ZYhJro4^LE$@uwpuCi!d` zP7~qo_I>LH3*⪼n$uz5U!06J_;AsO-X^>)h>&(?R031)axp|r9o)JIZ`KIG)W{9 zyyQhKa!5s@i*$libALvQ{p+m!-~7Plz;1kq%I6jzA7*uUT(9P(PkXE}stIwCW;)+Q zMw{HRJyCX>>(%Vjmx^7>*P!I*TEj`NFMF?^(P~WLkaFgxl`>lAYPShQlm6>oCGB-Hpj7Sg%Ia;U!H)m?s@a zQ<4%zw+LQ#cQ&Ka@Y0`7|8bxBqiJ#qo898hutm^MA1w|U9-H?6>A>L@@je}@U_T$H z-50&aXS_9ECh+)?KdN1qt*orSa>#QJrLKWlNG|Q=1!{LQsBMr4!b1HDm(}q}|8b(kKTeFo9}fNs9mUZDnn-_lGxVG=>f-V4hbd=9-YzlDb|oM~ z+AZGgdbO}~>UtltCo(g#tJB5;zc-uF{c<~biMWQGI&DY=d2mBr){{~#KpNd6J%)rl zP|Y2PmOxGv7TQ#De#_Agv==sl3yj}BWHVOS*OJXXS8=(=cb52LWR1pM{_Ztl&9w@b z?w6lWeZ?$wuL&P*Lx&%0c%vNcxe3{eYAJS2qmS%G3X*XaITVp-TKgO+n%-;?E4qFfq1W@d z${rmBJ=EvVXi4P4naqbmVtOKd3Kz)XS4Uf@>H_!WwG6WptkR8JE>cP#PT*@?%c&;j ziLzUN?6|tzwLENehEmt^1M?Y~#Ty8B6eWEm#OewHWwIr$7R{45sV{0lr=ZmYT4I<} z(Dbo76oFF6dBX0Z@W9dwE#GlI15aYwn2wHeYnL4Fc)PU>_oVQFP4l?PLtNifM^OAqN*BlWcQrryUqH{>86lD~FaYREqd07lZ8*XAwtTHVvwM_xmF4s7nw02uZN(QVF?GslwUq5jl0X~ognCZ`!Np=>Q0oHA z{aXp=6kiZ&x%_D986TdKR}prK+cIk1xxz@yuvyQ2$QpC6KFS%aDI9UEBh-4VI2#dB z>!Ae%zX|lY;;dDh!;$12U$}FKGq1nX(61{V#aS9UyyW9q-0Vg3Bx4(uQOG9{NYb!W zIof=)2tccfrQyDW8&=m*@05mUY2;4hmFJc!en>2OGEL3c=2meoSZi|v4RelYSC0Wnn*lc@yoYKhMZ1~GO0vCnlx zIpwf%d4S>gtWW@T{h@IhU@SQ~Hvp;oCZ+=bCoCJ8o3Y9!av@Mh0J z;n6FroAP8hZT(=j221~VH-*aTS_wpmz#Oty!X8~ zGjC?+5JEWRW-Qe)b%cr$)6hbsQ{r_i!Xt!AQ#Pdt4OyVe#0AdgVMBAdPfyLhxc%IT z#J07y$MK2rSFWZ*(FhJ;$+RivqxIMhhe9Eo4A)1wdIF9_5D0Zo&71v1AcEryn}8`( z2^>wPmCW=O_p2$QoAm2ummD6 zH4Q3+_*$OFDdM=I<+Q05V|WSLEzwM+9O?>z5Ox(CB3L5LG~0**1M#lS(Nqc6QZR7X zu=8_Bs!m+C4%Kj}tq~xBJ*jorBqK_uZV?y85|qfbiE2O!Alig+QZ~`41I0>;RtB&t z3vgBqnxl8j#(Q zs6nL;SBP+Ikv3rkwW*^(uq}yqHp>ESNs#Om){{_Gwka4uSdhc*Y%2m6i~_V+-n=Gh zAvzrlj!P{ef>jHS2(AO1Ak0PD;q=*AqHegq2kFWc=!0Y8^xX^I+W*F_gLAiDPxReZ zUVmWG?=3q&^38AlCHA3R)$u}0+5MNht#=b2x7=(f@sEFO?#X2}b&+Ql&)6c(o5EEN z?OETVb<8s|-iQnTNpZ;}kFm_w?|Ga(I<2m+0#0<5_9WnUNEpEjIV{%zCMKbx$K$E92P}o^^a~S8l@| z`R&6;CN0?r9$!4Wq2SqFdZ5bp*RuOAPLZDn7o^(fxu)dy3YYDF&R+!U=y>+LXAS2L zv@~y8dwlWgzo30OmpC#=FdWp<;Nz*1%0{1Lt#YTYk=x0mS}nZ0HF=20#j2$dku2!M8N;@j9@V zyaI3pG#|7|Xk(#0)Q`u2uIL}b8YB}i6PA~QMml7dfNs`lUIRq_fyWM4#U_@;PUeo( as@~n2u+cSH!fN<`W=U951ocl9>i;kCm=B@= literal 0 HcmV?d00001 diff --git a/testsuite/ffmpeg/src/make_malformed_movies.py b/testsuite/ffmpeg/src/make_test_movies.py similarity index 65% rename from testsuite/ffmpeg/src/make_malformed_movies.py rename to testsuite/ffmpeg/src/make_test_movies.py index 5d04572c78..da4ea02c23 100644 --- a/testsuite/ffmpeg/src/make_malformed_movies.py +++ b/testsuite/ffmpeg/src/make_test_movies.py @@ -3,15 +3,18 @@ # SPDX-License-Identifier: Apache-2.0 # https://github.com/AcademySoftwareFoundation/OpenImageIO -# Generate the small movie fixtures used by the ffmpeg security-audit -# regression tests. Requires the `ffmpeg` command line tool; the generated -# files are committed, so this only needs to be re-run if a fixture changes. +# Generate the small movie fixtures used by the ffmpeg regression tests. +# Requires the `ffmpeg` command line tool; the generated files are committed, +# so this only needs to be re-run if a fixture changes. # # gray8.avi valid 8-bit grayscale movie # gray16.avi valid 16-bit grayscale movie # bomb-16384x9000.avi tiny file whose header declares a 1.1 GB frame # truncated.avi valid header followed by a chopped-off stream # resolution-change.mkv frames smaller than the size the header declares +# bframes.mp4 h264 with B-frames, so the decoder holds frames back +# audio-track.mkv video plus a longer audio track +# start-offset.mkv stream whose first frame is at t = 5 seconds # # The two grayscale movies are the regression case for a heap overread: the # reader asked FFmpeg for GRAY8/GRAY16 frames but described them in the @@ -80,3 +83,31 @@ def patch_dimensions(src, dst, width, height): "resolution-change.mkv"], check=True) for f in ["small.h264", "large.h264", "both.h264"]: os.remove(f) + + +# Well-formed movies that exercise the frame indexing, rather than the +# handling of hostile input. Each is 10 frames of 32x32. + +# libx264 with B-frames gives the decoder a reorder delay, so the last frames +# only come out after the decoder is flushed at the end of the stream. +subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", + "testsrc=size=32x32:rate=10:duration=1", "-c:v", "libx264", + "-pix_fmt", "yuv420p", "-bf", "3", "-g", "30", "-crf", "40", + "bframes.mp4"], check=True) + +# 1 second of video and 3 seconds of audio. Matroska stores no frame count, +# so the reader has to derive one from the packet timestamps, and the audio +# packets must not be allowed to contribute. +subprocess.run(["ffmpeg", "-y", "-loglevel", "error", + "-f", "lavfi", "-i", "testsrc=size=32x32:rate=10:duration=1", + "-f", "lavfi", "-i", + "sine=frequency=440:duration=3:sample_rate=8000", + "-c:v", "ffv1", "-c:a", "aac", "-b:a", "8k", "-ac", "1", + "audio-track.mkv"], check=True) + +# A nonzero stream start time, as an mp4 edit list or a live capture would +# produce. Frame 0 is at t = 5 s, not t = 0. +subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", + "testsrc=size=32x32:rate=10:duration=1", "-c:v", "ffv1", + "-output_ts_offset", "5", "-muxdelay", "0", + "start-offset.mkv"], check=True) diff --git a/testsuite/ffmpeg/src/start-offset.mkv b/testsuite/ffmpeg/src/start-offset.mkv new file mode 100644 index 0000000000000000000000000000000000000000..7e52c7ac39730cd1524062e44c9051a9b4bd2008 GIT binary patch literal 6512 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 literal 0 HcmV?d00001