Repository navigation
TIFF decoder rejects valid files: missing or large RowsPerStrip, strips above the buffer limit, backwards IFD chains, and "0x0" in allocation errors #3243
Description
Prerequisites
- I have written a descriptive issue title
- I have verified that I am running the latest version of ImageSharp
- I have verified if the problem exist in both
DEBUGandRELEASEmode - I have searched open and closed issues to ensure it has not already been reported
ImageSharp version
main @ a73c43d (also present in v4.1.3, same code)
Other ImageSharp packages and versions
None
Environment (Operating system, version and so on)
Windows 11 Pro 10.0.22631, x64
.NET Framework version
.NET 10 (net10.0)
Description
The TIFF decoder rejects several valid uncompressed files. In other cases, it fails with a misleading message. These four defects are in the strip and directory handling of TiffDecoderCore and DirectoryReader.
A fix is proposed in #3231.
Possibly related: #2923 (closed without a sample file) reported the "circular directory offsets" error. #2959 (closed) added the row-by-row read for strips larger than Int32.MaxValue that this extends.
1. RowsPerStrip missing, or larger than the image height
TIFF 6.0 gives RowsPerStrip a default of 2^32 - 1. Any value that is at least the image height means the whole image is one strip.
- A missing RowsPerStrip is stored as
int.MaxValue.DecodeStripsChunkyreplaces that value with the image height, butDecodeStripsPlanardoes not. A planar file without RowsPerStrip therefore fails withNotSupportedException: Strips larger than Int32.MaxValue bytes are not supported for compressed images., even though the file is uncompressed and only 4x2 pixels. - A RowsPerStrip larger than the height is used as given. A 4000x2 RGB file with RowsPerStrip = 100000 makes the decoder ask for a 1.2 GB strip buffer. That is over the default 1 GB single buffer limit, so the decode fails.
Expected: both files decode. The strip buffer is sized from the rows that exist.
2. Uncompressed strips larger than the single buffer limit
For uncompressed chunky strips, DecodeStripsChunky falls back to reading one row at a time, but only when the strip is larger than int.MaxValue bytes. A strip that is between MemoryAllocator.SingleBufferAllocationLimitBytes (1 GB by default) and 2 GB fails, even when the decoded image fits in memory. DecodeStripsPlanar has no row fallback at all.
Expected: an uncompressed strip that exceeds the single buffer limit is read in rows, for chunky and planar data. The result is the same as a decode with no limit.
3. Allocation failures report "0x0"
TiffDecoderCore.Decode sets Dimensions only after every frame has been decoded. When an allocation fails during decoding, the error therefore reads Failed to allocate buffers for possibly degenerate dimensions: 0x0. instead of the real image size. Also, DecodeFrame does not dispose a frame that throws while it decodes, so the frame's pixel buffer is not returned to the allocator.
Expected: the message contains the real dimensions (for example 20000x15000), and a frame that fails to decode is disposed.
4. Directories stored out of file order
DirectoryReader.ReadIfds treats any next-IFD offset that is lower than the current offset as a loop. TIFF 6.0 allows an IFD at any offset. A valid file whose second IFD comes before the first IFD in the file therefore fails with ImageFormatException: TIFF image contains circular directory offsets.
Expected: the file decodes with all frames. A loop is reported only when the chain returns to an IFD that has already been read.
Steps to Reproduce
The snippet writes small uncompressed little-endian TIFF files in memory. The strip data comes first, and one IFD for each page follows it.
using System.Buffers.Binary;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.PixelFormats;
static void Try(string name, Action action)
{
try { action(); Console.WriteLine($"{name}: OK"); }
catch (Exception ex) { Console.WriteLine($"{name}: {ex.GetType().FullName}: {ex.Message}"); }
}
static DecoderOptions WithAllocator(MemoryAllocatorOptions options)
{
Configuration configuration = Configuration.Default.Clone();
configuration.MemoryAllocator = MemoryAllocator.Create(options);
return new DecoderOptions { Configuration = configuration };
}
// 1. RowsPerStrip missing (planar) or larger than the height (chunky).
Try("1a planar RGB 4x2, no RowsPerStrip", () =>
Image.Load<Rgb24>(BuildTiff(false, Page(4, 2, 3, 8, planar: true, rowsPerStrip: null, rowsPerStripData: 2))).Dispose());
Try("1b chunky RGB 4000x2, RowsPerStrip = 100000", () =>
Image.Load<Rgb24>(BuildTiff(false, Page(4000, 2, 3, 8, planar: false, rowsPerStrip: 100000, rowsPerStripData: 2))).Dispose());
// 2. One 4.3 MB uncompressed strip (1024x700, 3 x 16-bit) with a 1 MB single buffer limit.
// The decoded L8 image needs only 0.7 MB.
DecoderOptions limited = WithAllocator(new MemoryAllocatorOptions { SingleBufferAllocationLimitMegabytes = 1 });
foreach (bool planar in new[] { false, true })
{
byte[] tiff = BuildTiff(false, Page(1024, 700, 3, 16, planar, rowsPerStrip: 700, rowsPerStripData: 700));
Try($"2 planar={planar}, no limit", () => Image.Load<L8>(tiff).Dispose());
Try($"2 planar={planar}, 1 MB single buffer limit", () => Image.Load<L8>(limited, tiff).Dispose());
}
// 3. A 20000x15000 RGBA 16-bit image with a 100 MB allocation limit.
Try("3 20000x15000 over the limit", () =>
Image.Load<L8>(
WithAllocator(new MemoryAllocatorOptions { AllocationLimitMegabytes = 100 }),
BuildTiff(false, (20000, 15000, 4, 16, false, null, [new byte[4096]]))).Dispose());
// 4. Two 2x2 gray pages. The second IFD is stored before the first IFD.
Try("4 IFDs in reverse file order", () =>
Image.Load<L8>(BuildTiff(true, (2, 2, 1, 8, false, 2, [[10, 11, 12, 13]]), (2, 2, 1, 8, false, 2, [[20, 21, 22, 23]]))).Dispose());
// Creates a page whose strips are filled with a byte pattern. Planar strips are ordered by plane.
static (int W, int H, int Spp, int Bits, bool Planar, uint? Rps, byte[][] Strips) Page(
int w, int h, int spp, int bits, bool planar, uint? rowsPerStrip, int rowsPerStripData)
{
int stripsPerPlane = (h + rowsPerStripData - 1) / rowsPerStripData;
int planes = planar ? spp : 1;
byte[][] strips = new byte[planes * stripsPerPlane][];
for (int s = 0; s < strips.Length; s++)
{
int rows = Math.Min(rowsPerStripData, h - (s % stripsPerPlane * rowsPerStripData));
strips[s] = new byte[w * rows * (planar ? 1 : spp) * bits / 8];
for (int i = 0; i < strips[s].Length; i++)
{
strips[s][i] = (byte)(i * 7);
}
}
return (w, h, spp, bits, planar, rowsPerStrip, strips);
}
// Writes an uncompressed little-endian TIFF: all strip data, then one IFD per page
// (in reverse page order when reverseIfds is set), chained in page order.
static byte[] BuildTiff(bool reverseIfds, params (int W, int H, int Spp, int Bits, bool Planar, uint? Rps, byte[][] Strips)[] pages)
{
using MemoryStream ms = new();
using BinaryWriter bw = new(ms);
bw.Write("II*\0"u8);
bw.Write(0u);
List<uint[]> offsets = [];
foreach (var p in pages)
{
uint[] o = new uint[p.Strips.Length];
for (int s = 0; s < o.Length; s++)
{
o[s] = (uint)ms.Position;
bw.Write(p.Strips[s]);
}
offsets.Add(o);
}
uint[] ifdOffsets = new uint[pages.Length];
long[] nextIfdPositions = new long[pages.Length];
for (int n = 0; n < pages.Length; n++)
{
int i = reverseIfds ? pages.Length - 1 - n : n;
var p = pages[i];
// (tag, type, values), sorted by tag. Type 3 = SHORT, 4 = LONG.
List<(ushort Tag, ushort Type, uint[] Values)> entries =
[
(256, 4, [(uint)p.W]), // ImageWidth
(257, 4, [(uint)p.H]), // ImageLength
(258, 3, Enumerable.Repeat((uint)p.Bits, p.Spp).ToArray()), // BitsPerSample
(259, 3, [1]), // Compression = none
(262, 3, [(uint)(p.Spp >= 3 ? 2 : 1)]), // Photometric = RGB or BlackIsZero
(273, 4, offsets[i]), // StripOffsets
(277, 3, [(uint)p.Spp]), // SamplesPerPixel
];
if (p.Rps.HasValue)
{
entries.Add((278, 4, [p.Rps.Value])); // RowsPerStrip
}
entries.Add((279, 4, p.Strips.Select(s => (uint)s.Length).ToArray())); // StripByteCounts
if (p.Planar)
{
entries.Add((284, 3, [2])); // PlanarConfiguration = planar
}
if ((ms.Position & 1) != 0)
{
bw.Write((byte)0);
}
ifdOffsets[i] = (uint)ms.Position;
long valuesStart = ms.Position + 2 + (entries.Count * 12) + 4;
using MemoryStream values = new();
using BinaryWriter vw = new(values);
bw.Write((ushort)entries.Count);
foreach ((ushort tag, ushort type, uint[] v) in entries)
{
bw.Write(tag);
bw.Write(type);
bw.Write((uint)v.Length);
int size = v.Length * (type == 3 ? 2 : 4);
BinaryWriter target = size > 4 ? vw : bw;
if (size > 4)
{
bw.Write((uint)(valuesStart + values.Position));
}
foreach (uint x in v)
{
if (type == 3) { target.Write((ushort)x); } else { target.Write(x); }
}
for (int pad = size; pad < 4; pad++)
{
bw.Write((byte)0);
}
}
nextIfdPositions[i] = ms.Position;
bw.Write(0u);
bw.Write(values.ToArray());
}
byte[] tiff = ms.ToArray();
BinaryPrimitives.WriteUInt32LittleEndian(tiff.AsSpan(4), ifdOffsets[0]);
for (int i = 0; i + 1 < pages.Length; i++)
{
BinaryPrimitives.WriteUInt32LittleEndian(tiff.AsSpan((int)nextIfdPositions[i]), ifdOffsets[i + 1]);
}
return tiff;
}Actual output:
1a planar RGB 4x2, no RowsPerStrip: System.NotSupportedException: Strips larger than Int32.MaxValue bytes are not supported for compressed images.
1b chunky RGB 4000x2, RowsPerStrip = 100000: SixLabors.ImageSharp.InvalidImageContentException: Cannot decode image. Failed to allocate buffers for possibly degenerate dimensions: 0x0.
2 planar=False, no limit: OK
2 planar=False, 1 MB single buffer limit: SixLabors.ImageSharp.InvalidImageContentException: Cannot decode image. Failed to allocate buffers for possibly degenerate dimensions: 0x0.
2 planar=True, no limit: OK
2 planar=True, 1 MB single buffer limit: SixLabors.ImageSharp.InvalidImageContentException: Cannot decode image. Failed to allocate buffers for possibly degenerate dimensions: 0x0.
3 20000x15000 over the limit: SixLabors.ImageSharp.InvalidImageContentException: Cannot decode image. Failed to allocate buffers for possibly degenerate dimensions: 0x0.
4 IFDs in reverse file order: SixLabors.ImageSharp.ImageFormatException: TIFF image contains circular directory offsets
Stack traces:
System.NotSupportedException: Strips larger than Int32.MaxValue bytes are not supported for compressed images.
at SixLabors.ImageSharp.Formats.Tiff.TiffThrowHelper.ThrowNotSupported(String message)
at SixLabors.ImageSharp.Formats.Tiff.TiffDecoderCore.DecodeStripsPlanar[TPixel](ImageFrame`1 frame, Int32 width, Int32 height, Int32 rowsPerStrip, ReadOnlySpan`1 stripOffsets, ReadOnlySpan`1 stripByteCounts, CancellationToken cancellationToken)
at SixLabors.ImageSharp.Formats.Tiff.TiffDecoderCore.DecodeImageWithStrips[TPixel](ExifProfile tags, ImageFrame`1 frame, Int32 width, Int32 height, CancellationToken cancellationToken)
at SixLabors.ImageSharp.Formats.Tiff.TiffDecoderCore.DecodeFrame[TPixel](ExifProfile tags, Nullable`1 size, CancellationToken cancellationToken)
at SixLabors.ImageSharp.Formats.Tiff.TiffDecoderCore.Decode[TPixel](BufferedReadStream stream, CancellationToken cancellationToken)
at SixLabors.ImageSharp.Formats.ImageDecoderCore.Decode[TPixel](Configuration configuration, Stream stream, CancellationToken cancellationToken)
SixLabors.ImageSharp.ImageFormatException: TIFF image contains circular directory offsets
at SixLabors.ImageSharp.Formats.Tiff.TiffThrowHelper.ThrowImageFormatException(String errorMessage)
at SixLabors.ImageSharp.Formats.Tiff.DirectoryReader.ReadIfds(Boolean isBigTiff)
at SixLabors.ImageSharp.Formats.Tiff.DirectoryReader.Read()
at SixLabors.ImageSharp.Formats.Tiff.TiffDecoderCore.Decode[TPixel](BufferedReadStream stream, CancellationToken cancellationToken)
at SixLabors.ImageSharp.Formats.ImageDecoderCore.Decode[TPixel](Configuration configuration, Stream stream, CancellationToken cancellationToken)
Expected:
- 1a, 1b: both files decode.
- 2: both layouts decode with the 1 MB single buffer limit, with the same pixels as the decode with no limit.
- 3: the decode still fails, but the message contains
20000x15000. - 4: the file decodes with 2 frames.
A real loop (an IFD chain that links back to an IFD it has already read) must still fail with "circular directory offsets".
Images
No images needed; the repro builds the input in memory.
Activity