Skip to content

TIFF decoder rejects valid files: missing or large RowsPerStrip, strips above the buffer limit, backwards IFD chains, and "0x0" in allocation errors #3243

Open

Description

@jafin

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 DEBUG and RELEASE mode
  • 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. DecodeStripsChunky replaces that value with the image height, but DecodeStripsPlanar does not. A planar file without RowsPerStrip therefore fails with NotSupportedException: 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions