Skip to content

Truncated HEIC data causes HeicImage.Load to loop forever with no exception and no way to cancel #22

Description

@mhording

Summary

Truncated HEIC data causes HeicImage.Load to loop forever with no exception and no way to cancel. Because CanLoad returns true for such input, the natural guard if (CanLoad(s)) Load(s) does not prevent it. For applications that accept user-uploaded images this is a denial-of-service risk: a single malformed upload permanently consumes a thread.

Environment

  • Openize.HEIC 26.5.0 (NuGet), lib/net6.0/Openize.Heic.Decoder.dll
  • .NET 8, Windows x64

Reproduction

The following is self-contained and uses a publicly available sample file, so no attachment is needed.

using Openize.Heic.Decoder;

var url = "https://convertico.com/samples/heic/heic-apple-circles.heic";
var full = await new HttpClient().GetByteArrayAsync(url); // 366 872 bytes

foreach (var n in new[] { 32, 48, 64, 96, 128, 256, 512 })
{
    var truncated = full.Take(n).ToArray();

    // Run on a background thread: some lengths never return.
    var thread = new Thread(() =>
    {
        using var s1 = new MemoryStream(truncated, false);
        var canLoad = HeicImage.CanLoad(s1);

        using var s2 = new MemoryStream(truncated, false);
        HeicImage.Load(s2);

        Console.WriteLine($"{n}: CanLoad={canLoad}, Load returned");
    }) { IsBackground = true };

    thread.Start();

    if (!thread.Join(TimeSpan.FromSeconds(5)))
        Console.WriteLine($"{n}: HANGS");
}

Observed results (.NET 8, Windows x64):

N (bytes) Result
32 ArgumentException
48 never returns
64 never returns
96 DataMisalignedException
128 DataMisalignedException
256 DataMisalignedException
512 CanLoad returns true, Load returns a HeicImage
1024 CanLoad returns true, Load returns a HeicImage
4096 CanLoad returns true, Load returns a HeicImage

CanLoad returned true for every length tested, including the 32-byte fragment. The 5-second timeouts at 48 and 64 bytes were also retried at 10 seconds with the same result.

The same behaviour reproduces on an unrelated HEIC file, so it is not specific to this sample.

Issue 1: CanLoad does not indicate loadability

HeicImage.CanLoad parses only the first box and checks the ftyp brand, so it returns true even for a 32-byte fragment. The XML doc is accurate ("True if file header contains heic signature"), but the method name suggests a loadability check, and it is the natural guard to place before Load.

Consider documenting the limitation explicitly, or validating that the top-level box lengths fit within the stream.

Issue 2: unbounded read past end of stream (likely cause of the hang)

From reading the source, the non-termination appears to arise from three interacting pieces:

  • HeicImage.Load drives the loop while (bitstream.MoreData()) { Box.ParseBox(bitstream); }.
  • BitStreamReader.MoreData() returns true via its first clause (state.BufferPosition >= 0 && state.BitIndex < 8), which depends only on buffer state and not on stream position, so it can remain true after the stream is exhausted.
  • BitStreamReader.Read(int) calls FillBufferFromStream() but discards the return value. At end of stream that call returns 0, yet Read continues from the stale buffer and returns garbage instead of signalling exhaustion.

The assorted exception types in the table above (DataMisalignedException, ArgumentException) look like further symptoms of the same missing bounds check.

I have not stepped through a debugger to confirm this precise path, so please treat the mechanism as a hypothesis. The hang itself is reproducible.

Suggested fixes

  1. Have Read check the result of FillBufferFromStream() and throw EndOfStreamException when no bytes are available.
  2. Make MoreData() account for stream exhaustion, not just buffer state.
  3. In ParseBox, validate that size is at least the header size and fits within the remaining stream, and guarantee forward progress on each iteration.

Question

Since Load reads metadata only, is a HeicImage obtained from truncated data (the 512-byte case and above) expected to fail later at GetByteArray, or could it silently yield a partial image? A documented error for incomplete input would be preferable to either outcome.

Workaround

For anyone else hitting this: validate the ISO-BMFF top-level box chain before calling into the decoder. Walk the boxes, honour size == 1 (64-bit largesize) and size == 0 (box extends to end of file), and reject the data unless the chain lands exactly on the end of the buffer.

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