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
- Have
Read check the result of FillBufferFromStream() and throw EndOfStreamException when no bytes are available.
- Make
MoreData() account for stream exhaustion, not just buffer state.
- 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.
Summary
Truncated HEIC data causes
HeicImage.Loadto loop forever with no exception and no way to cancel. BecauseCanLoadreturnstruefor such input, the natural guardif (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
lib/net6.0/Openize.Heic.Decoder.dllReproduction
The following is self-contained and uses a publicly available sample file, so no attachment is needed.
Observed results (.NET 8, Windows x64):
ArgumentExceptionDataMisalignedExceptionDataMisalignedExceptionDataMisalignedExceptionCanLoadreturnstrue,Loadreturns aHeicImageCanLoadreturnstrue,Loadreturns aHeicImageCanLoadreturnstrue,Loadreturns aHeicImageCanLoadreturnedtruefor 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:
CanLoaddoes not indicate loadabilityHeicImage.CanLoadparses only the first box and checks theftypbrand, so it returnstrueeven 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 beforeLoad.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.Loaddrives the loopwhile (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 remaintrueafter the stream is exhausted.BitStreamReader.Read(int)callsFillBufferFromStream()but discards the return value. At end of stream that call returns 0, yetReadcontinues 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
Readcheck the result ofFillBufferFromStream()and throwEndOfStreamExceptionwhen no bytes are available.MoreData()account for stream exhaustion, not just buffer state.ParseBox, validate thatsizeis at least the header size and fits within the remaining stream, and guarantee forward progress on each iteration.Question
Since
Loadreads metadata only, is aHeicImageobtained from truncated data (the 512-byte case and above) expected to fail later atGetByteArray, 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-bitlargesize) andsize == 0(box extends to end of file), and reject the data unless the chain lands exactly on the end of the buffer.