From eadb2a6c79bc219049fa443357f9a18a4c51e8e5 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 27 Jul 2026 13:40:55 +1000 Subject: [PATCH] Handle oversized ANI chunks and partial icon masks --- src/ImageSharp/Formats/Ani/AniConstants.cs | 9 +++ src/ImageSharp/Formats/Ani/AniDecoderCore.cs | 11 +++- src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs | 6 +- .../Formats/Ani/AniDecoderTests.cs | 62 +++++++++++++++++++ .../Formats/Icon/Ico/IcoEncoderTests.cs | 31 ++++++++++ 5 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/ImageSharp/Formats/Ani/AniConstants.cs b/src/ImageSharp/Formats/Ani/AniConstants.cs index 16925cc65..773fe7c26 100644 --- a/src/ImageSharp/Formats/Ani/AniConstants.cs +++ b/src/ImageSharp/Formats/Ani/AniConstants.cs @@ -23,6 +23,15 @@ internal static class AniConstants /// public const int IconDirHeaderSize = 6; + /// + /// The maximum number of bytes retained from an ancillary chunk. + /// + /// + /// Control arrays and information strings come from untrusted input. Bounding them independently of the allocator + /// prevents a physically large RIFF chunk from consuming an unreasonable amount of memory. + /// + public const int MaxAncillaryChunkSize = 8 * 1024 * 1024; + /// /// The list of MIME types that identify ANI data. /// diff --git a/src/ImageSharp/Formats/Ani/AniDecoderCore.cs b/src/ImageSharp/Formats/Ani/AniDecoderCore.cs index 39583cfe4..cdfcf3016 100644 --- a/src/ImageSharp/Formats/Ani/AniDecoderCore.cs +++ b/src/ImageSharp/Formats/Ani/AniDecoderCore.cs @@ -411,6 +411,14 @@ internal sealed class AniDecoderCore : ImageDecoderCore, IDisposable return; } + // MaxFrames controls retained animation steps, but its default is intentionally unbounded. Apply a separate + // byte limit before allocation so an oversized control chunk follows ancillary integrity handling. + if (chunkSize > AniConstants.MaxAncillaryChunkSize) + { + this.ThrowOrIgnoreNonStrictSegmentError($"The ANI {description} chunk is too large."); + return; + } + int count = (int)Math.Min(chunkSize / sizeof(uint), this.Options.MaxFrames); if (count is 0) { @@ -494,7 +502,8 @@ internal sealed class AniDecoderCore : ImageDecoderCore, IDisposable { value = null; - if (chunkSize > int.MaxValue) + // INFO text is optional metadata. Reject or skip oversized values before renting their backing buffer. + if (chunkSize > AniConstants.MaxAncillaryChunkSize) { this.ThrowOrIgnoreNonStrictSegmentError("The ANI information text chunk is too large."); return false; diff --git a/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs b/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs index 111f61fbf..af6582420 100644 --- a/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs +++ b/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs @@ -890,7 +890,7 @@ internal sealed class BmpEncoderCore where TPixel : unmanaged, IPixel { // Each byte represents eight pixels and every scanline is padded to a 4-byte DIB boundary. - int arrayWidth = encodingFrame.Width / 8; + int arrayWidth = (encodingFrame.Width + 7) / 8; int padding = arrayWidth % 4; if (padding is not 0) { @@ -910,7 +910,9 @@ internal sealed class BmpEncoderCore { int x = i * 8; - for (int j = 0; j < 8; j++) + // The final byte can represent fewer than eight pixels when the image width is not byte-aligned. + int pixelCount = Math.Min(8, encodingFrame.Width - x); + for (int j = 0; j < pixelCount; j++) { WriteAlphaMask(row[x + j], ref mask[i], j); } diff --git a/tests/ImageSharp.Tests/Formats/Ani/AniDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Ani/AniDecoderTests.cs index e1ff17fec..93de4d2d9 100644 --- a/tests/ImageSharp.Tests/Formats/Ani/AniDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Ani/AniDecoderTests.cs @@ -145,4 +145,66 @@ public class AniDecoderTests DecoderOptions strict = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; Assert.Throws(() => AniDecoder.Instance.Decode(strict, strictStream)); } + + /// + /// Verifies that oversized control arrays are rejected before allocation and follow ancillary integrity handling. + /// + /// to append a sequence chunk; otherwise, a rate chunk. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AniDecoder_OversizedControlChunk_FollowsIntegrityHandling(bool sequence) + { + byte[] source = TestFile.Create(Help).Bytes.ToArray(); + int chunkOffset = (source.Length + 1) & ~1; + int payloadSize = AniConstants.MaxAncillaryChunkSize + sizeof(uint); + byte[] data = new byte[chunkOffset + AniConstants.ChunkHeaderSize + payloadSize]; + source.CopyTo(data, 0); + + ReadOnlySpan identifier = sequence ? "seq "u8 : "rate"u8; + identifier.CopyTo(data.AsSpan(chunkOffset)); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(chunkOffset + sizeof(uint)), (uint)payloadSize); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(sizeof(uint)), (uint)data.Length - AniConstants.ChunkHeaderSize); + + using MemoryStream defaultStream = new(data, false); + using Image image = AniDecoder.Instance.Decode(DecoderOptions.Default, defaultStream); + + Assert.Equal(4, image.Frames.Count); + + using MemoryStream strictStream = new(data, false); + DecoderOptions strict = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; + Assert.Throws(() => AniDecoder.Instance.Decode(strict, strictStream)); + } + + /// + /// Verifies that oversized information text is rejected before allocation and follows ancillary integrity handling. + /// + [Fact] + public void AniDecoder_OversizedInformationText_FollowsIntegrityHandling() + { + byte[] source = TestFile.Create(Help).Bytes.ToArray(); + int listOffset = (source.Length + 1) & ~1; + int textSize = AniConstants.MaxAncillaryChunkSize + 1; + int paddedTextSize = textSize + (textSize & 1); + int listSize = sizeof(uint) + AniConstants.ChunkHeaderSize + paddedTextSize; + byte[] data = new byte[listOffset + AniConstants.ChunkHeaderSize + listSize]; + source.CopyTo(data, 0); + + "LIST"u8.CopyTo(data.AsSpan(listOffset)); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(listOffset + sizeof(uint)), (uint)listSize); + "INFO"u8.CopyTo(data.AsSpan(listOffset + AniConstants.ChunkHeaderSize)); + int textOffset = listOffset + AniConstants.ChunkHeaderSize + sizeof(uint); + "INAM"u8.CopyTo(data.AsSpan(textOffset)); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(textOffset + sizeof(uint)), (uint)textSize); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(sizeof(uint)), (uint)data.Length - AniConstants.ChunkHeaderSize); + + using MemoryStream defaultStream = new(data, false); + using Image image = AniDecoder.Instance.Decode(DecoderOptions.Default, defaultStream); + + Assert.Equal(4, image.Frames.Count); + + using MemoryStream strictStream = new(data, false); + DecoderOptions strict = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; + Assert.Throws(() => AniDecoder.Instance.Decode(strict, strictStream)); + } } diff --git a/tests/ImageSharp.Tests/Formats/Icon/Ico/IcoEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Icon/Ico/IcoEncoderTests.cs index f637d64bf..08bb537ad 100644 --- a/tests/ImageSharp.Tests/Formats/Icon/Ico/IcoEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Icon/Ico/IcoEncoderTests.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Cur; using SixLabors.ImageSharp.Formats.Ico; using SixLabors.ImageSharp.Formats.Icon; @@ -141,4 +142,34 @@ public class IcoEncoderTests Assert.NotNull(decoded.Metadata.ExifProfile); Assert.Equal(image.Metadata.ExifProfile.Values, decoded.Metadata.ExifProfile.Values); } + + /// + /// Verifies that the final partial AND-mask byte contains every pixel when the bitmap width is not byte-aligned. + /// + /// The bitmap width to encode. + [Theory] + [InlineData(1)] + [InlineData(7)] + [InlineData(9)] + [InlineData(15)] + public void BmpEntry_WritesPartialAlphaMaskByte(int width) + { + using Image image = new(width, 1, Color.Red.ToPixel()); + image[width - 1, 0] = Color.Transparent.ToPixel(); + + IcoFrameMetadata metadata = image.Frames.RootFrame.Metadata.GetIcoMetadata(); + metadata.Compression = IconFrameCompression.Bmp; + metadata.BmpBitsPerPixel = BmpBitsPerPixel.Bit32; + + using MemoryStream stream = new(); + image.Save(stream, Encoder); + + // These widths produce one DWORD-aligned mask row at the end of the bitmap resource. + ReadOnlySpan mask = stream.GetBuffer().AsSpan(checked((int)stream.Length) - sizeof(uint), sizeof(uint)); + int pixelIndex = width - 1; + int byteIndex = pixelIndex / 8; + int bitIndex = pixelIndex % 8; + + Assert.Equal((byte)(0b10000000 >> bitIndex), mask[byteIndex]); + } }