diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8dabf3fcb..67152d99e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -36,6 +36,10 @@ Features: - Support reusing the thread's current CUDA context via a ``current_ctx`` flag on ``CudaContext`` and ``VideoFrame.from_dlpack``, for interop with libraries like PyTorch that initialize CUDA first by :gh-user:`Yozer` (:pr:`2339`). - ``VideoFrame.from_dlpack`` no longer requires restating ``primary_ctx``/``current_ctx`` when passing an explicit ``cuda_context``; the flags are only validated when explicitly given by :gh-user:`WyattBlue`. +Fixes: + +- ``VideoStream.decode`` and ``AudioStream.decode`` now raise ``ValueError`` instead of crashing the process when the stream has no codec context (e.g. a bitstream truncated inside its first coded frame); decoding such input ran FFmpeg 8.1's slice-multithreaded decoder over unvalidated state, which could perform an out-of-bounds read and abort the process with ``SIGSEGV``/``SIGBUS`` by :gh-user:`justinrmiller` in (:pr:`2345`). + v18.0.0 ------- diff --git a/av/audio/stream.py b/av/audio/stream.py index 38009db19..7426cd6dd 100644 --- a/av/audio/stream.py +++ b/av/audio/stream.py @@ -49,5 +49,11 @@ def decode(self, packet: Packet | None = None): .. seealso:: This is a passthrough to :meth:`.CodecContext.decode`. """ - + if self.codec_context is None: + raise ValueError( + "cannot decode: stream has no codec context (the container could " + "not identify a decoder for this stream, e.g. a truncated or " + "corrupt bitstream). Decoding it would run FFmpeg's multithreaded " + "decoder on unvalidated state, which can crash the process." + ) return self.codec_context.decode(packet) diff --git a/av/video/stream.py b/av/video/stream.py index bb8b06fc0..6b5b5020a 100644 --- a/av/video/stream.py +++ b/av/video/stream.py @@ -57,6 +57,13 @@ def decode(self, packet: Packet | None = None): .. seealso:: This is a passthrough to :meth:`.CodecContext.decode`. """ + if self.codec_context is None: + raise ValueError( + "cannot decode: stream has no codec context (the container could " + "not identify a decoder for this stream, e.g. a truncated or " + "corrupt bitstream). Decoding it would run FFmpeg's multithreaded " + "decoder on unvalidated state, which can crash the process." + ) return self.codec_context.decode(packet) @cython.cfunc diff --git a/tests/test_decode.py b/tests/test_decode.py index 814e0fc0e..12efbb77b 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1,4 +1,5 @@ import functools +import io import os import pathlib from fractions import Fraction @@ -190,6 +191,56 @@ def test_decode_video_corrupt(self) -> None: assert packet_count == 1 assert frame_count == 0 + def test_decode_truncated_no_codec_context_raises(self) -> None: + # A bitstream truncated inside its first coded frame can leave the + # container able to open but unable to establish codec parameters + # (`stream.codec_context is None`). Decoding it previously ran FFmpeg's + # multithreaded decoder over unvalidated state and crashed the whole + # process (SIGSEGV/SIGBUS) on a broad class of such inputs; it must now + # raise a catchable error instead. + try: + av.codec.Codec("libx264", "w") + except Exception: + pytest.skip("libx264 not available") + + # Build a short faststart H.264/mp4 (moov up front, so a prefix still + # opens while its first frame is incomplete). faststart rewrites the + # file on close, so encode to a real path rather than a BytesIO. + path = self.sandboxed("truncation_fixture.mp4") + with av.open(path, "w", format="mp4", options={"movflags": "faststart"}) as out: + stream = out.add_stream("libx264", rate=30) + stream.width, stream.height, stream.pix_fmt = 320, 240, "yuv420p" + for _ in range(30): + frame = av.VideoFrame.from_ndarray( + np.zeros((240, 320, 3), dtype=np.uint8), format="rgb24" + ) + for packet in stream.encode(frame): + out.mux(packet) + for packet in stream.encode(): + out.mux(packet) + with open(path, "rb") as fh: + data = fh.read() + + # Find a prefix that opens but yields no codec context. + target = None + for cut in range(200, len(data)): + try: + with av.open(io.BytesIO(data[:cut])) as c: + if c.streams.video and c.streams.video[0].codec_context is None: + target = cut + break + except av.FFmpegError: + continue + assert target is not None, "expected a prefix that opens with no codec context" + + with av.open(io.BytesIO(data[:target])) as container: + stream = container.streams.video[0] + assert stream.codec_context is None + # Must raise (not crash the process) when asked to decode. + with pytest.raises(ValueError): + for packet in container.demux(stream): + packet.decode() + def test_decode_close_then_use(self) -> None: container = av.open(fate_suite("h264/interlaced_crop.mp4")) container.close()