Skip to content

Commit d1cd2b5

Browse files
Merge pull request #3231 from jafin/jf/tiff-decoder-malformed-input
Decode TIFF files with large strips and backwards directory chains
2 parents 826512f + 1bcc8bb commit d1cd2b5

34 files changed

Lines changed: 530 additions & 70 deletions

‎src/ImageSharp/Formats/Tiff/Compression/Decompressors/NoneTiffCompression.cs‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,37 @@ namespace SixLabors.ImageSharp.Formats.Tiff.Compression.Decompressors;
1111
/// </summary>
1212
internal sealed class NoneTiffCompression : TiffBaseDecompressor
1313
{
14+
private readonly SegmentIntegrityHandling integrityHandling;
15+
1416
/// <summary>
1517
/// Initializes a new instance of the <see cref="NoneTiffCompression" /> class.
1618
/// </summary>
1719
/// <param name="memoryAllocator">The memory allocator.</param>
1820
/// <param name="width">The width of the image.</param>
1921
/// <param name="bitsPerPixel">The bits per pixel.</param>
20-
public NoneTiffCompression(MemoryAllocator memoryAllocator, int width, int bitsPerPixel)
22+
/// <param name="integrityHandling">The policy for incomplete image data.</param>
23+
public NoneTiffCompression(MemoryAllocator memoryAllocator, int width, int bitsPerPixel, SegmentIntegrityHandling integrityHandling)
2124
: base(memoryAllocator, width, bitsPerPixel)
2225
{
26+
this.integrityHandling = integrityHandling;
2327
}
2428

2529
/// <inheritdoc/>
2630
protected override void Decompress(BufferedReadStream stream, int byteCount, int stripHeight, Span<byte> buffer, CancellationToken cancellationToken)
27-
=> _ = stream.Read(buffer, 0, Math.Min(buffer.Length, byteCount));
31+
{
32+
// The caller supplies only the rows needed for this read. Respect both the declared byte count and end of file.
33+
int bytesRead = stream.Read(buffer, 0, Math.Min(buffer.Length, byteCount));
34+
if (bytesRead < buffer.Length)
35+
{
36+
if (this.integrityHandling != SegmentIntegrityHandling.IgnoreImageData)
37+
{
38+
throw new InvalidImageContentException("TIFF strip or tile contains incomplete image data.");
39+
}
40+
41+
// The buffer is reused. Missing samples must not retain bytes from a previous strip, row, or pooled allocation.
42+
buffer[bytesRead..].Clear();
43+
}
44+
}
2845

2946
/// <inheritdoc/>
3047
protected override void Dispose(bool disposing)

‎src/ImageSharp/Formats/Tiff/Compression/TiffDecompressorsFactory.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public static TiffBaseDecompressor Create(
4040
case TiffDecoderCompressionType.None:
4141
DebugGuard.IsTrue(predictor == TiffPredictor.None, "Predictor should only be used with lzw or deflate compression");
4242
DebugGuard.IsTrue(faxOptions == FaxCompressionOptions.None, "No fax compression options are expected");
43-
return new NoneTiffCompression(allocator, width, bitsPerPixel);
43+
return new NoneTiffCompression(allocator, width, bitsPerPixel, options.SegmentIntegrityHandling);
4444

4545
case TiffDecoderCompressionType.PackBits:
4646
DebugGuard.IsTrue(predictor == TiffPredictor.None, "Predictor should only be used with lzw or deflate compression");

‎src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,17 @@ private static ByteOrder ReadByteOrder(Stream stream)
7474
private List<ExifProfile> ReadIfds(bool isBigTiff)
7575
{
7676
List<EntryReader> readers = [];
77+
78+
// TIFF allows directories at any offset, so a chain that goes backwards in the file is valid.
79+
// Only a directory offset that has already been read means the chain loops.
80+
HashSet<ulong> visitedOffsets = new(4);
7781
while (this.nextIfdOffset != 0 && this.nextIfdOffset < (ulong)this.stream.Length)
7882
{
83+
if (!visitedOffsets.Add(this.nextIfdOffset))
84+
{
85+
TiffThrowHelper.ThrowImageFormatException("TIFF image contains circular directory offsets");
86+
}
87+
7988
EntryReader reader = new(this.stream, this.ByteOrder, this.allocator);
8089
reader.ReadTags(isBigTiff, this.nextIfdOffset);
8190

@@ -90,11 +99,6 @@ private List<ExifProfile> ReadIfds(bool isBigTiff)
9099
}
91100
}
92101

93-
if (this.nextIfdOffset >= reader.NextIfdOffset && reader.NextIfdOffset != 0)
94-
{
95-
TiffThrowHelper.ThrowImageFormatException("TIFF image contains circular directory offsets");
96-
}
97-
98102
this.nextIfdOffset = reader.NextIfdOffset;
99103
readers.Add(reader);
100104

‎src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs‎

Lines changed: 165 additions & 61 deletions
Large diffs are not rendered by default.

‎tests/ImageSharp.Tests/Formats/Tiff/Compression/NoneTiffCompressionTests.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public void Decompress_ReadsData(byte[] inputData, uint byteCount, byte[] expect
1818
using BufferedReadStream stream = new(Configuration.Default, memoryStream);
1919
byte[] buffer = new byte[expectedResult.Length];
2020

21-
using NoneTiffCompression decompressor = new(default, default, default);
21+
using NoneTiffCompression decompressor = new(default, default, default, default);
2222
decompressor.Decompress(stream, 0, byteCount, 1, buffer, default);
2323

2424
Assert.Equal(expectedResult, buffer);
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Six Labors Split License.
3+
4+
using SixLabors.ImageSharp.Formats;
5+
using SixLabors.ImageSharp.Memory;
6+
using SixLabors.ImageSharp.PixelFormats;
7+
using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison;
8+
using static SixLabors.ImageSharp.Tests.TestImages.Tiff;
9+
10+
namespace SixLabors.ImageSharp.Tests.Formats.Tiff;
11+
12+
/// <summary>
13+
/// Tests strip allocation and directory traversal with stored TIFF inputs.
14+
/// </summary>
15+
[Trait("Format", "Tiff")]
16+
[ValidateDisposedMemoryAllocations]
17+
public class TiffDecoderStripLayoutTests
18+
{
19+
/// <summary>
20+
/// A missing RowsPerStrip or a value greater than the image height describes one strip per plane.
21+
/// </summary>
22+
/// <param name="path">The stored TIFF input.</param>
23+
[Theory]
24+
[InlineData(ChunkyRowsPerStripMissing)]
25+
[InlineData(ChunkyRowsPerStripLarge)]
26+
[InlineData(ChunkyRowsPerStripMax)]
27+
[InlineData(PlanarRowsPerStripMissing)]
28+
[InlineData(PlanarRowsPerStripLarge)]
29+
[InlineData(PlanarRowsPerStripMax)]
30+
public void Decode_RowsPerStripMissingOrAboveHeight_DecodesAllRows(string path)
31+
{
32+
using Image<Rgb24> expected = Image.Load<Rgb24>(TestFile.Create(StripLayoutRgbReference).Bytes);
33+
byte[] data = TestFile.Create(path).Bytes;
34+
35+
foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
36+
{
37+
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
38+
using Image<Rgb24> actual = Image.Load<Rgb24>(options, data);
39+
40+
ImageComparer.Exact.VerifySimilarity(expected, actual);
41+
}
42+
}
43+
44+
/// <summary>
45+
/// Reading an uncompressed strip in rows preserves every 16-bit component.
46+
/// </summary>
47+
/// <param name="path">The stored single-strip or multiple-strip input.</param>
48+
[Theory]
49+
[InlineData(ChunkyRgb16SingleStrip)]
50+
[InlineData(ChunkyRgb16MultipleStrips)]
51+
[InlineData(PlanarRgb16SingleStrip)]
52+
[InlineData(PlanarRgb16MultipleStrips)]
53+
public void Decode_UncompressedStripAboveBufferLimit_DecodesAllRows(string path)
54+
{
55+
byte[] data = TestFile.Create(path).Bytes;
56+
using Image<Rgb48> expected = Image.Load<Rgb48>(data);
57+
Configuration configuration = Configuration.Default.Clone();
58+
configuration.MemoryAllocator = MemoryAllocator.Create(new MemoryAllocatorOptions { SingleBufferAllocationLimitMegabytes = 1 });
59+
60+
// Each full strip exceeds 1 MiB, including each separate plane. A row fits within the limit.
61+
// The final strip in the multiple-strip inputs contains only the remaining 100 rows.
62+
foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
63+
{
64+
DecoderOptions options = new() { Configuration = configuration, SegmentIntegrityHandling = integrity };
65+
using Image<Rgb48> actual = Image.Load<Rgb48>(options, data);
66+
67+
ImageComparer.Exact.VerifySimilarity(expected, actual);
68+
}
69+
}
70+
71+
/// <summary>
72+
/// Subsampled chroma planes require fewer stored samples than their expanded destination buffers.
73+
/// </summary>
74+
[Fact]
75+
public void Decode_SubsampledPlanarYCbCr_DoesNotRequireExpandedChromaInFile()
76+
{
77+
byte[] data = TestFile.Create(PlanarYCbCrSubsampled).Bytes;
78+
79+
foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
80+
{
81+
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
82+
using Image<Rgb24> image = Image.Load<Rgb24>(options, data);
83+
84+
// Y = 64 with neutral Cb and Cr = 128 represents gray 64 in every pixel.
85+
Assert.Equal(new Size(4, 2), image.Size);
86+
for (int y = 0; y < image.Height; y++)
87+
{
88+
for (int x = 0; x < image.Width; x++)
89+
{
90+
Assert.Equal(new Rgb24(64, 64, 64), image[x, y]);
91+
}
92+
}
93+
}
94+
}
95+
96+
/// <summary>
97+
/// A zero strip height is invalid in every integrity mode.
98+
/// </summary>
99+
/// <param name="path">The stored TIFF with RowsPerStrip set to zero.</param>
100+
[Theory]
101+
[InlineData(ChunkyRowsPerStripZero)]
102+
[InlineData(PlanarRowsPerStripZero)]
103+
public void Decode_ZeroRowsPerStrip_ThrowsImageFormatException(string path)
104+
{
105+
byte[] data = TestFile.Create(path).Bytes;
106+
107+
foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
108+
{
109+
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
110+
Assert.Throws<ImageFormatException>(() =>
111+
{
112+
using Image<Rgb24> image = Image.Load<Rgb24>(options, data);
113+
});
114+
}
115+
}
116+
117+
/// <summary>
118+
/// Short image data follows the integrity policy without reading past the declared strip or reusing old samples.
119+
/// </summary>
120+
/// <param name="path">The stored TIFF whose strip ends halfway through its second row.</param>
121+
/// <param name="limitBuffer">Whether to force row-by-row reads.</param>
122+
[Theory]
123+
[InlineData(ChunkyRgb16ShortStrip, false)]
124+
[InlineData(ChunkyRgb16ShortStrip, true)]
125+
[InlineData(PlanarRgb16ShortStrip, false)]
126+
[InlineData(PlanarRgb16ShortStrip, true)]
127+
public void Decode_ShortUncompressedStrip_UsesImageDataIntegrityPolicy(string path, bool limitBuffer)
128+
{
129+
byte[] data = TestFile.Create(path).Bytes;
130+
Configuration configuration = Configuration.Default.Clone();
131+
if (limitBuffer)
132+
{
133+
configuration.MemoryAllocator = MemoryAllocator.Create(new MemoryAllocatorOptions { SingleBufferAllocationLimitMegabytes = 1 });
134+
}
135+
136+
foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
137+
{
138+
DecoderOptions options = new() { Configuration = configuration, SegmentIntegrityHandling = integrity };
139+
if (integrity != SegmentIntegrityHandling.IgnoreImageData)
140+
{
141+
Assert.Throws<InvalidImageContentException>(() =>
142+
{
143+
using Image<Rgb48> image = Image.Load<Rgb48>(options, data);
144+
});
145+
}
146+
else
147+
{
148+
using Image<Rgb48> image = Image.Load<Rgb48>(options, data);
149+
150+
// The fixtures contain more bytes after StripByteCounts. Those bytes must not become image samples.
151+
// Samples before the declared end survive. Missing samples are zero in both read paths.
152+
Assert.Equal(new Rgb48(0, 13621, 27242), image[0, 0]);
153+
Assert.Equal(new Rgb48(25957, 39578, 53199), image[0, 1]);
154+
Assert.Equal(default, image[1023, 1]);
155+
Assert.Equal(default, image[0, 2]);
156+
Assert.Equal(default, image[1023, 699]);
157+
}
158+
}
159+
}
160+
161+
/// <summary>
162+
/// A failed image allocation reports the declared dimensions.
163+
/// </summary>
164+
[Fact]
165+
public void Decode_FrameAboveAllocationLimit_ThrowsWithRealDimensions()
166+
{
167+
byte[] data = TestFile.Create(FrameAboveAllocationLimit).Bytes;
168+
Configuration configuration = Configuration.Default.Clone();
169+
configuration.MemoryAllocator = MemoryAllocator.Create(new MemoryAllocatorOptions { AllocationLimitMegabytes = 100 });
170+
DecoderOptions options = new() { Configuration = configuration };
171+
172+
InvalidImageContentException ex = Assert.Throws<InvalidImageContentException>(() =>
173+
{
174+
using Image<L8> image = Image.Load<L8>(options, data);
175+
});
176+
177+
Assert.Contains("20000x15000", ex.Message, StringComparison.Ordinal);
178+
}
179+
180+
/// <summary>
181+
/// Directory links determine frame order even when their file offsets decrease.
182+
/// </summary>
183+
/// <param name="path">The stored TIFF or BigTIFF with reversed directory offsets.</param>
184+
[Theory]
185+
[InlineData(ReverseDirectories)]
186+
[InlineData(BigTiffReverseDirectories)]
187+
public void Decode_DirectoriesStoredInReverseFileOrder_DecodesAllFrames(string path)
188+
{
189+
byte[] data = TestFile.Create(path).Bytes;
190+
191+
foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
192+
{
193+
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
194+
using Image<L8> image = Image.Load<L8>(options, data);
195+
ImageInfo info = Image.Identify(options, data);
196+
197+
Assert.Equal(2, image.Frames.Count);
198+
Assert.Equal(2, info.FrameMetadataCollection.Count);
199+
Assert.Equal(new Size(2, 2), image.Size);
200+
Assert.Equal(new L8(10), image.Frames[0][0, 0]);
201+
Assert.Equal(new L8(11), image.Frames[0][1, 0]);
202+
Assert.Equal(new L8(12), image.Frames[0][0, 1]);
203+
Assert.Equal(new L8(13), image.Frames[0][1, 1]);
204+
Assert.Equal(new L8(20), image.Frames[1][0, 0]);
205+
Assert.Equal(new L8(21), image.Frames[1][1, 0]);
206+
Assert.Equal(new L8(22), image.Frames[1][0, 1]);
207+
Assert.Equal(new L8(23), image.Frames[1][1, 1]);
208+
}
209+
}
210+
211+
/// <summary>
212+
/// A repeated directory offset is a structural error in every integrity mode.
213+
/// </summary>
214+
/// <param name="path">The stored TIFF or BigTIFF with a circular directory chain.</param>
215+
[Theory]
216+
[InlineData(DirectoryLoopOne)]
217+
[InlineData(DirectoryLoopTwo)]
218+
[InlineData(DirectoryLoopTwoReverse)]
219+
[InlineData(DirectoryLoopThreeReverse)]
220+
[InlineData(BigTiffDirectoryLoopOne)]
221+
[InlineData(BigTiffDirectoryLoopTwo)]
222+
[InlineData(BigTiffDirectoryLoopTwoReverse)]
223+
[InlineData(BigTiffDirectoryLoopThreeReverse)]
224+
public void Decode_DirectoryChainRevisitsDirectory_ThrowsImageFormatException(string path)
225+
{
226+
byte[] data = TestFile.Create(path).Bytes;
227+
228+
foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
229+
{
230+
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
231+
ImageFormatException identifyError = Assert.Throws<ImageFormatException>(() => Image.Identify(options, data));
232+
ImageFormatException decodeError = Assert.Throws<ImageFormatException>(() =>
233+
{
234+
using Image<L8> image = Image.Load<L8>(options, data);
235+
});
236+
237+
Assert.Contains("circular", identifyError.Message, StringComparison.Ordinal);
238+
Assert.Contains("circular", decodeError.Message, StringComparison.Ordinal);
239+
}
240+
}
241+
}

‎tests/ImageSharp.Tests/TestImages.cs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,34 @@ public static class Tiff
10021002
public const string Benchmark_RgbPackbits = "medium_rgb_packbits.tiff";
10031003
public const string Benchmark_RgbUncompressed = "medium_rgb_uncompressed.tiff";
10041004

1005+
public const string StripLayoutRgbReference = "Tiff/strips/rgb-4000x2.png";
1006+
public const string PlanarYCbCrSubsampled = "Tiff/strips/planar-ycbcr-subsampled.tiff";
1007+
public const string ChunkyRowsPerStripMissing = "Tiff/strips/chunky-rowsperstrip-missing.tiff";
1008+
public const string ChunkyRowsPerStripLarge = "Tiff/strips/chunky-rowsperstrip-large.tiff";
1009+
public const string ChunkyRowsPerStripMax = "Tiff/strips/chunky-rowsperstrip-max.tiff";
1010+
public const string ChunkyRowsPerStripZero = "Tiff/strips/chunky-rowsperstrip-zero.tiff";
1011+
public const string PlanarRowsPerStripMissing = "Tiff/strips/planar-rowsperstrip-missing.tiff";
1012+
public const string PlanarRowsPerStripLarge = "Tiff/strips/planar-rowsperstrip-large.tiff";
1013+
public const string PlanarRowsPerStripMax = "Tiff/strips/planar-rowsperstrip-max.tiff";
1014+
public const string PlanarRowsPerStripZero = "Tiff/strips/planar-rowsperstrip-zero.tiff";
1015+
public const string ChunkyRgb16SingleStrip = "Tiff/strips/chunky-rgb16-700-rows.tiff";
1016+
public const string ChunkyRgb16MultipleStrips = "Tiff/strips/chunky-rgb16-600-rows.tiff";
1017+
public const string PlanarRgb16SingleStrip = "Tiff/strips/planar-rgb16-700-rows.tiff";
1018+
public const string PlanarRgb16MultipleStrips = "Tiff/strips/planar-rgb16-600-rows.tiff";
1019+
public const string ChunkyRgb16ShortStrip = "Tiff/strips/chunky-rgb16-short-strip.tiff";
1020+
public const string PlanarRgb16ShortStrip = "Tiff/strips/planar-rgb16-short-strip.tiff";
1021+
public const string FrameAboveAllocationLimit = "Tiff/strips/frame-above-allocation-limit.tiff";
1022+
public const string ReverseDirectories = "Tiff/strips/reverse-directories.tiff";
1023+
public const string BigTiffReverseDirectories = "Tiff/strips/reverse-directories.bigtiff";
1024+
public const string DirectoryLoopOne = "Tiff/strips/loop-1-forward.tiff";
1025+
public const string DirectoryLoopTwo = "Tiff/strips/loop-2-forward.tiff";
1026+
public const string DirectoryLoopTwoReverse = "Tiff/strips/loop-2-reverse.tiff";
1027+
public const string DirectoryLoopThreeReverse = "Tiff/strips/loop-3-reverse.tiff";
1028+
public const string BigTiffDirectoryLoopOne = "Tiff/strips/loop-1-forward.bigtiff";
1029+
public const string BigTiffDirectoryLoopTwo = "Tiff/strips/loop-2-forward.bigtiff";
1030+
public const string BigTiffDirectoryLoopTwoReverse = "Tiff/strips/loop-2-reverse.bigtiff";
1031+
public const string BigTiffDirectoryLoopThreeReverse = "Tiff/strips/loop-3-reverse.bigtiff";
1032+
10051033
public const string BilevelWithoutBitsPerSampleUncompressed = "Tiff/defaults/bilevel-no-bitspersample-none.tiff";
10061034
public const string BilevelWithoutBitsPerSampleFax4 = "Tiff/defaults/bilevel-no-bitspersample-g4.tiff";
10071035
public const string BilevelWithoutSampleTags = "Tiff/defaults/bilevel-no-bitspersample-or-samplesperpixel.tiff";
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:b47a52f17b1f1b56984f46264c5daa4b023ff85195ef5ceedf61bfaeb86d18c8
3+
size 4300944
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:386e773612f8673784d243e251c99657bd5e92204bd8b3fed0c7ec1b3ef16b16
3+
size 4300928
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:240011d8f11a91720343ef6878d9fe1b89018507edcfc48954cd25b89b4bd6b8
3+
size 4300928

0 commit comments

Comments
 (0)