diff --git a/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs b/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs index 95f02503e9..c853341b94 100644 --- a/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs +++ b/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs @@ -133,6 +133,31 @@ protected override Image Decode(BufferedReadStream stream, Cance int bytesPerColorMapEntry = this.ReadImageHeaders(stream, out bool inverted, out byte[] palette); ushort bitsPerPixel = this.infoHeader.BitsPerPixel; + // Reject impossible RGB and bit-fields dimensions before allocating the image. Ignoring image data errors + // does not make these dimensions safe: a tiny file could otherwise request a large pixel buffer. + // Match ReadBitFields, which uses 32-bit storage for every depth other than 16. + int storedBitsPerPixel = this.infoHeader.Compression switch + { + BmpCompression.RGB => bitsPerPixel, + BmpCompression.BitFields or BmpCompression.BI_ALPHABITFIELDS => bitsPerPixel is 16 ? 16 : 32, + _ => 0 + }; + + if (storedBitsPerPixel > 0) + { + // Stored rows start on four-byte boundaries. Retain support for missing padding after the final row. + // Reserve that row's pixel bytes, then count the complete strides available for the other rows. + // Division avoids multiplying width by height, which can overflow even long for 32-bit BMP dimensions. + long rowBits = (long)this.infoHeader.Width * storedBitsPerPixel; + long stride = (rowBits + 31) / 32 * 4; + long finalRowBytes = (rowBits + 7) / 8; + long remainingLength = stream.Length - stream.Position; + if (remainingLength < finalRowBytes || this.infoHeader.Height - 1 > (remainingLength - finalRowBytes) / stride) + { + BmpThrowHelper.ThrowInvalidImageContentException("The bitmap does not contain enough pixel data for its declared dimensions."); + } + } + image = new Image(this.configuration, this.infoHeader.Width, this.infoHeader.Height, this.metadata); Buffer2D pixels = image.GetRootFramePixelBuffer(); @@ -185,10 +210,6 @@ protected override Image Decode(BufferedReadStream stream, Cance break; - case BmpCompression.RGB when bitsPerPixel is <= 0 or > 32: - BmpThrowHelper.ThrowInvalidImageContentException($"Invalid bits per pixel: {bitsPerPixel}"); - break; - case BmpCompression.RLE24: this.ReadRle24(stream, pixels, this.infoHeader.Width, this.infoHeader.Height, inverted); @@ -1573,6 +1594,13 @@ private int ReadImageHeaders(BufferedReadStream stream, out bool inverted, out b BmpThrowHelper.ThrowInvalidImageContentException("Width and height must be greater than 0."); } + // Uncompressed pixel data is only defined for these depths. Rejecting any other depth here, rather than in + // the pixel readers, also keeps identification from reporting a header that cannot be decoded. + if (this.infoHeader.Compression is BmpCompression.RGB && this.infoHeader.BitsPerPixel is not (1 or 2 or 4 or 8 or 16 or 24 or 32)) + { + BmpThrowHelper.ThrowInvalidImageContentException($"Invalid bits per pixel: {this.infoHeader.BitsPerPixel}"); + } + int bytesPerColorMapEntry = 4; int colorMapSizeBytes = -1; if (this.infoHeader.ClrUsed == 0) diff --git a/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs index 1702657892..2c96f1c347 100644 --- a/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs @@ -8,6 +8,7 @@ using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Tests.Memory; using SixLabors.ImageSharp.Tests.TestUtilities; using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; using SixLabors.ImageSharp.Tests.TestUtilities.ReferenceCodecs; @@ -696,4 +697,80 @@ public void BmpDecoder_ThrowsException_Issue3074() using Image image = Image.Load(stream); }); } + + /// + /// Invalid RGB bit depths cannot define a pixel layout, even when image data errors are ignored. + /// + /// The stored bitmap with an unsupported RGB bit depth. + [Theory] + [InlineData(InvalidRgbDepth0)] + [InlineData(InvalidRgbDepth3)] + [InlineData(InvalidRgbDepth7)] + [InlineData(InvalidRgbDepth12)] + [InlineData(InvalidRgbDepth33)] + public void BmpDecoder_InvalidUncompressedBitsPerPixel_ThrowsInvalidImageContentException(string path) + { + byte[] data = TestFile.Create(path).Bytes; + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + Assert.Throws(() => Image.Identify(options, data)); + Assert.Throws(() => + { + using Image image = Image.Load(options, data); + }); + } + } + + /// + /// Rejects dimensions that require more pixel data than the file contains before requesting pixel storage. + /// + /// The stored bitmap with incomplete RGB or bit-fields pixel data. + [Theory] + [InlineData(TruncatedRgb24LargeImage)] + [InlineData(TruncatedRgb24WideRow)] + [InlineData(TruncatedBitFields16)] + [InlineData(TruncatedBitFields32)] + [InlineData(TruncatedAlphaBitFields32)] + [InlineData(TruncatedRgb32MaximumDimensions)] + public void BmpDecoder_PixelDataSmallerThanDeclared_ThrowsBeforeAllocatingPixelBuffer(string path) + { + byte[] data = TestFile.Create(path).Bytes; + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + // The allocator's group limit prevents a failed maximum-dimension regression from allocating that image. + TestMemoryAllocator allocator = new(); + allocator.EnableNonThreadSafeLogging(); + Configuration configuration = Configuration.Default.Clone(); + configuration.MemoryAllocator = allocator; + DecoderOptions options = new() { Configuration = configuration, SegmentIntegrityHandling = integrity }; + + InvalidImageContentException exception = Assert.Throws(() => + { + using Image image = Image.Load(options, data); + }); + + // An allocation-limit exception is too late. No Rgba32 buffer, including a split buffer, may be requested. + Assert.Null(exception.InnerException); + Assert.DoesNotContain(allocator.AllocationLog, request => request.ElementType == typeof(Rgba32)); + } + } + + /// + /// Accepts a missing padding byte after the final stored row in either row order. + /// + /// The stored one-column bitmap with a red top pixel and a blue bottom pixel. + [Theory] + [InlineData(MissingFinalRowPadding)] + [InlineData(MissingFinalRowPaddingTopDown)] + public void BmpDecoder_MissingLastRowPadding_Decodes(string path) + { + using Image actual = Image.Load(TestFile.Create(path).Bytes); + + Assert.Equal(new Size(1, 2), actual.Size); + Assert.Equal(new Rgb24(255, 0, 0), actual[0, 0]); + Assert.Equal(new Rgb24(0, 0, 255), actual[0, 1]); + } } diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index 31dca8d5a6..49b52625c3 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -459,6 +459,19 @@ public static class Bmp // Note: The inverted images have been generated by altering the BitmapInfoHeader using a hex editor. // As such, the expected pixel output will be the reverse of the unaltered equivalent images. public const string Car = "Bmp/Car.bmp"; + public const string InvalidRgbDepth0 = "Bmp/headers/invalid-rgb-depth-0.bmp"; + public const string InvalidRgbDepth3 = "Bmp/headers/invalid-rgb-depth-3.bmp"; + public const string InvalidRgbDepth7 = "Bmp/headers/invalid-rgb-depth-7.bmp"; + public const string InvalidRgbDepth12 = "Bmp/headers/invalid-rgb-depth-12.bmp"; + public const string InvalidRgbDepth33 = "Bmp/headers/invalid-rgb-depth-33.bmp"; + public const string TruncatedRgb24LargeImage = "Bmp/headers/truncated-rgb24-4000x4000.bmp"; + public const string TruncatedRgb24WideRow = "Bmp/headers/truncated-rgb24-100000x1.bmp"; + public const string TruncatedBitFields16 = "Bmp/headers/truncated-bitfields16.bmp"; + public const string TruncatedBitFields32 = "Bmp/headers/truncated-bitfields32.bmp"; + public const string TruncatedAlphaBitFields32 = "Bmp/headers/truncated-alphabitfields32.bmp"; + public const string TruncatedRgb32MaximumDimensions = "Bmp/headers/truncated-rgb32-max-dimensions.bmp"; + public const string MissingFinalRowPadding = "Bmp/headers/missing-final-padding.bmp"; + public const string MissingFinalRowPaddingTopDown = "Bmp/headers/missing-final-padding-top-down.bmp"; public const string F = "Bmp/F.bmp"; public const string NegHeight = "Bmp/neg_height.bmp"; public const string CoreHeader = "Bmp/BitmapCoreHeaderQR.bmp"; diff --git a/tests/Images/Input/Bmp/headers/invalid-rgb-depth-0.bmp b/tests/Images/Input/Bmp/headers/invalid-rgb-depth-0.bmp new file mode 100644 index 0000000000..a748ec8821 --- /dev/null +++ b/tests/Images/Input/Bmp/headers/invalid-rgb-depth-0.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23be9a3bc12c4818fc0e9aba881d00e4afed47f94e8f7419987dd197f417c05c +size 62 diff --git a/tests/Images/Input/Bmp/headers/invalid-rgb-depth-12.bmp b/tests/Images/Input/Bmp/headers/invalid-rgb-depth-12.bmp new file mode 100644 index 0000000000..ad7b6b9a32 --- /dev/null +++ b/tests/Images/Input/Bmp/headers/invalid-rgb-depth-12.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70b9093ca2422a2509547cf158be6fdb425b729311e299a95d3ac3d1cd35ca2a +size 62 diff --git a/tests/Images/Input/Bmp/headers/invalid-rgb-depth-3.bmp b/tests/Images/Input/Bmp/headers/invalid-rgb-depth-3.bmp new file mode 100644 index 0000000000..48e01afd35 --- /dev/null +++ b/tests/Images/Input/Bmp/headers/invalid-rgb-depth-3.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:abe4d16944660433ee3c472711089b4091056bd206f446dd4a18eaf6aa278e9c +size 62 diff --git a/tests/Images/Input/Bmp/headers/invalid-rgb-depth-33.bmp b/tests/Images/Input/Bmp/headers/invalid-rgb-depth-33.bmp new file mode 100644 index 0000000000..b3c4b0c014 --- /dev/null +++ b/tests/Images/Input/Bmp/headers/invalid-rgb-depth-33.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b71c714a1808915b15ab8583fc464a1b41845629fc67fd54b24d20b1f3f04468 +size 62 diff --git a/tests/Images/Input/Bmp/headers/invalid-rgb-depth-7.bmp b/tests/Images/Input/Bmp/headers/invalid-rgb-depth-7.bmp new file mode 100644 index 0000000000..044382b047 --- /dev/null +++ b/tests/Images/Input/Bmp/headers/invalid-rgb-depth-7.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac5e42f5c0feefc454446153bca6dfe9ba7cfdb579cf63865b7733997650598a +size 62 diff --git a/tests/Images/Input/Bmp/headers/missing-final-padding-top-down.bmp b/tests/Images/Input/Bmp/headers/missing-final-padding-top-down.bmp new file mode 100644 index 0000000000..1cf0309159 --- /dev/null +++ b/tests/Images/Input/Bmp/headers/missing-final-padding-top-down.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edb0c2ce2ecee35d2e3f2dd03745183e9a5c609903b0516b9d096cd33719c0aa +size 61 diff --git a/tests/Images/Input/Bmp/headers/missing-final-padding.bmp b/tests/Images/Input/Bmp/headers/missing-final-padding.bmp new file mode 100644 index 0000000000..3de1ed3989 --- /dev/null +++ b/tests/Images/Input/Bmp/headers/missing-final-padding.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb21ba3d4482e2d8d6b103c57ec57956df67e870a8a8b98bb6ae3687e3600a2a +size 61 diff --git a/tests/Images/Input/Bmp/headers/truncated-alphabitfields32.bmp b/tests/Images/Input/Bmp/headers/truncated-alphabitfields32.bmp new file mode 100644 index 0000000000..e0caf505db --- /dev/null +++ b/tests/Images/Input/Bmp/headers/truncated-alphabitfields32.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8332dd80fb79e689dc0c472e121f7ce117c0add354e8c61ee6c6b627d14a0476 +size 142 diff --git a/tests/Images/Input/Bmp/headers/truncated-bitfields16.bmp b/tests/Images/Input/Bmp/headers/truncated-bitfields16.bmp new file mode 100644 index 0000000000..76d8082241 --- /dev/null +++ b/tests/Images/Input/Bmp/headers/truncated-bitfields16.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72190e5ea3331a466b9263bc404422500c8a52af33986e0f841fd61c447e1f3a +size 138 diff --git a/tests/Images/Input/Bmp/headers/truncated-bitfields32.bmp b/tests/Images/Input/Bmp/headers/truncated-bitfields32.bmp new file mode 100644 index 0000000000..a2b2d282f9 --- /dev/null +++ b/tests/Images/Input/Bmp/headers/truncated-bitfields32.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:568ea10e01df3087ec9cc5f7aa45a660bfa47dd13258ef3851d2a3303f07eb06 +size 138 diff --git a/tests/Images/Input/Bmp/headers/truncated-rgb24-100000x1.bmp b/tests/Images/Input/Bmp/headers/truncated-rgb24-100000x1.bmp new file mode 100644 index 0000000000..666e7d5e2f --- /dev/null +++ b/tests/Images/Input/Bmp/headers/truncated-rgb24-100000x1.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:173208862a7dcb0d1a8ac371792bf5ae58f50d8eb48c70c76cdeac8a5c32e3eb +size 126 diff --git a/tests/Images/Input/Bmp/headers/truncated-rgb24-4000x4000.bmp b/tests/Images/Input/Bmp/headers/truncated-rgb24-4000x4000.bmp new file mode 100644 index 0000000000..86304f25c2 --- /dev/null +++ b/tests/Images/Input/Bmp/headers/truncated-rgb24-4000x4000.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26d418b96e27e422fdeccc81a1c909f74a0b7e8ce09ab87bf4f957d460ba1371 +size 126 diff --git a/tests/Images/Input/Bmp/headers/truncated-rgb32-max-dimensions.bmp b/tests/Images/Input/Bmp/headers/truncated-rgb32-max-dimensions.bmp new file mode 100644 index 0000000000..6391e1d219 --- /dev/null +++ b/tests/Images/Input/Bmp/headers/truncated-rgb32-max-dimensions.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff4d2387a8f9c30c77d61aaf988bc80c98956f89608d55afabbd99da7cd91143 +size 126