Skip to content

Commit 0f9f1bf

Browse files
svenclaessonclaude
andcommitted
Fix integer overflow and bounds-checking vulnerabilities in EXR decoder
Use ulong arithmetic in CalculateBytesPerRow and block size calculations to prevent integer overflow. Add validation for DataWindow dimensions, block size limits, and row offsets outside stream bounds. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 936a65b commit 0f9f1bf

4 files changed

Lines changed: 269 additions & 16 deletions

File tree

src/ImageSharp/Formats/Exr/ExrDecoderCore.cs

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,14 @@ private void DecodeFloatingPointPixelData<TPixel>(BufferedReadStream stream, Buf
139139
where TPixel : unmanaged, IPixel<TPixel>
140140
{
141141
bool hasAlpha = this.HasAlpha();
142-
uint bytesPerRow = ExrUtils.CalculateBytesPerRow(this.Channels, (uint)this.Width);
142+
ulong bytesPerRow = ExrUtils.CalculateBytesPerRow(this.Channels, (uint)this.Width);
143143
uint rowsPerBlock = ExrUtils.RowsPerBlock(this.Compression);
144-
uint bytesPerBlock = bytesPerRow * rowsPerBlock;
144+
ulong bytesPerBlock = bytesPerRow * rowsPerBlock;
145+
if (bytesPerBlock > int.MaxValue)
146+
{
147+
ExrThrowHelper.ThrowInvalidImageContentException("EXR block size exceeds the maximum allowed size.");
148+
}
149+
145150
int width = this.Width;
146151
int height = this.Height;
147152
int channelCount = this.Channels.Count;
@@ -158,8 +163,8 @@ private void DecodeFloatingPointPixelData<TPixel>(BufferedReadStream stream, Buf
158163
this.Compression,
159164
this.memoryAllocator,
160165
width,
161-
bytesPerBlock,
162-
bytesPerRow,
166+
(uint)bytesPerBlock,
167+
(uint)bytesPerRow,
163168
rowsPerBlock,
164169
channelCount,
165170
this.PixelType);
@@ -170,6 +175,11 @@ private void DecodeFloatingPointPixelData<TPixel>(BufferedReadStream stream, Buf
170175
ulong rowOffset = this.ReadUnsignedLong(stream);
171176
long nextRowOffsetPosition = stream.Position;
172177

178+
if (rowOffset >= (ulong)stream.Length)
179+
{
180+
ExrThrowHelper.ThrowInvalidImageContentException("EXR row offset is outside the bounds of the stream.");
181+
}
182+
173183
stream.Position = (long)rowOffset;
174184
uint rowStartIndex = this.ReadUnsignedInteger(stream);
175185

@@ -212,9 +222,14 @@ private void DecodeUnsignedIntPixelData<TPixel>(BufferedReadStream stream, Buffe
212222
where TPixel : unmanaged, IPixel<TPixel>
213223
{
214224
bool hasAlpha = this.HasAlpha();
215-
uint bytesPerRow = ExrUtils.CalculateBytesPerRow(this.Channels, (uint)this.Width);
225+
ulong bytesPerRow = ExrUtils.CalculateBytesPerRow(this.Channels, (uint)this.Width);
216226
uint rowsPerBlock = ExrUtils.RowsPerBlock(this.Compression);
217-
uint bytesPerBlock = bytesPerRow * rowsPerBlock;
227+
ulong bytesPerBlock = bytesPerRow * rowsPerBlock;
228+
if (bytesPerBlock > int.MaxValue)
229+
{
230+
ExrThrowHelper.ThrowInvalidImageContentException("EXR block size exceeds the maximum allowed size.");
231+
}
232+
218233
int width = this.Width;
219234
int height = this.Height;
220235
int channelCount = this.Channels.Count;
@@ -231,8 +246,8 @@ private void DecodeUnsignedIntPixelData<TPixel>(BufferedReadStream stream, Buffe
231246
this.Compression,
232247
this.memoryAllocator,
233248
width,
234-
bytesPerBlock,
235-
bytesPerRow,
249+
(uint)bytesPerBlock,
250+
(uint)bytesPerRow,
236251
rowsPerBlock,
237252
channelCount,
238253
this.PixelType);
@@ -243,6 +258,11 @@ private void DecodeUnsignedIntPixelData<TPixel>(BufferedReadStream stream, Buffe
243258
ulong rowOffset = this.ReadUnsignedLong(stream);
244259
long nextRowOffsetPosition = stream.Position;
245260

261+
if (rowOffset >= (ulong)stream.Length)
262+
{
263+
ExrThrowHelper.ThrowInvalidImageContentException("EXR row offset is outside the bounds of the stream.");
264+
}
265+
246266
stream.Position = (long)rowOffset;
247267
uint rowStartIndex = this.ReadUnsignedInteger(stream);
248268

@@ -597,8 +617,21 @@ private ExrHeaderAttributes ReadExrHeader(BufferedReadStream stream)
597617

598618
this.HeaderAttributes = this.ParseHeaderAttributes(stream);
599619

600-
this.Width = this.HeaderAttributes.DataWindow.XMax - this.HeaderAttributes.DataWindow.XMin + 1;
601-
this.Height = this.HeaderAttributes.DataWindow.YMax - this.HeaderAttributes.DataWindow.YMin + 1;
620+
ExrBox2i dataWindow = this.HeaderAttributes.DataWindow;
621+
if (dataWindow.XMax < dataWindow.XMin || dataWindow.YMax < dataWindow.YMin)
622+
{
623+
ExrThrowHelper.ThrowInvalidImageContentException("EXR DataWindow max values must be greater than or equal to min values.");
624+
}
625+
626+
long width = (long)dataWindow.XMax - dataWindow.XMin + 1;
627+
long height = (long)dataWindow.YMax - dataWindow.YMin + 1;
628+
if (width > int.MaxValue || height > int.MaxValue)
629+
{
630+
ExrThrowHelper.ThrowInvalidImageContentException("EXR DataWindow dimensions exceed the maximum allowed size.");
631+
}
632+
633+
this.Width = (int)width;
634+
this.Height = (int)height;
602635
this.Channels = this.HeaderAttributes.Channels;
603636
this.Compression = this.HeaderAttributes.Compression;
604637
this.PixelType = this.ValidateChannels();

src/ImageSharp/Formats/Exr/ExrEncoderCore.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ private ulong[] EncodeFloatingPointPixelData<TPixel>(
170170
CancellationToken cancellationToken)
171171
where TPixel : unmanaged, IPixel<TPixel>
172172
{
173-
uint bytesPerRow = ExrUtils.CalculateBytesPerRow(channels, (uint)width);
173+
uint bytesPerRow = (uint)ExrUtils.CalculateBytesPerRow(channels, (uint)width);
174174
uint rowsPerBlock = ExrUtils.RowsPerBlock(compression);
175175
uint bytesPerBlock = bytesPerRow * rowsPerBlock;
176176

@@ -262,7 +262,7 @@ private ulong[] EncodeUnsignedIntPixelData<TPixel>(
262262
CancellationToken cancellationToken)
263263
where TPixel : unmanaged, IPixel<TPixel>
264264
{
265-
uint bytesPerRow = ExrUtils.CalculateBytesPerRow(channels, (uint)width);
265+
uint bytesPerRow = (uint)ExrUtils.CalculateBytesPerRow(channels, (uint)width);
266266
uint rowsPerBlock = ExrUtils.RowsPerBlock(compression);
267267
uint bytesPerBlock = bytesPerRow * rowsPerBlock;
268268

src/ImageSharp/Formats/Exr/ExrUtils.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ internal static class ExrUtils
1313
/// <param name="channels">The image channels array.</param>
1414
/// <param name="width">The width in pixels of a row.</param>
1515
/// <returns>The number of bytes per row.</returns>
16-
public static uint CalculateBytesPerRow(IList<ExrChannelInfo> channels, uint width)
16+
public static ulong CalculateBytesPerRow(IList<ExrChannelInfo> channels, uint width)
1717
{
18-
uint bytesPerRow = 0;
18+
ulong bytesPerRow = 0;
1919
foreach (ExrChannelInfo channelInfo in channels)
2020
{
2121
if (channelInfo.ChannelName.Equals("A", StringComparison.Ordinal)
@@ -26,11 +26,11 @@ public static uint CalculateBytesPerRow(IList<ExrChannelInfo> channels, uint wid
2626
{
2727
if (channelInfo.PixelType == ExrPixelType.Half)
2828
{
29-
bytesPerRow += 2 * width;
29+
bytesPerRow += 2UL * width;
3030
}
3131
else
3232
{
33-
bytesPerRow += 4 * width;
33+
bytesPerRow += 4UL * width;
3434
}
3535
}
3636
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Six Labors Split License.
3+
4+
using System.Buffers.Binary;
5+
using SixLabors.ImageSharp.Formats;
6+
using SixLabors.ImageSharp.Formats.Exr;
7+
using SixLabors.ImageSharp.PixelFormats;
8+
9+
namespace SixLabors.ImageSharp.Tests.Formats.Exr;
10+
11+
/// <summary>
12+
/// Security regression tests for the EXR decoder (Findings EXR-1, EXR-2, EXR-3).
13+
/// The EXR decoder was merged to main but not yet included in a tagged NuGet release.
14+
/// Each test demonstrates a crafted-input crash present in the unfixed code.
15+
/// </summary>
16+
public class ExrDecoderSecurityTests
17+
{
18+
/// <summary>
19+
/// EXR-1 — EXR DataWindow Integer Overflow Produces Negative Image Dimensions (DoS)
20+
///
21+
/// Width and Height are computed from attacker-controlled DataWindow attributes
22+
/// using unchecked int subtraction:
23+
/// this.Width = XMax - XMin + 1 // overflows to -2147483647
24+
///
25+
/// The negative Width is then passed to the Image&lt;TPixel&gt; constructor, which calls
26+
/// Guard.MustBeGreaterThan(width, 0) → ArgumentOutOfRangeException.
27+
///
28+
/// After a fix this should throw InvalidImageContentException instead.
29+
///
30+
/// Affected file:
31+
/// src/ImageSharp/Formats/Exr/ExrDecoderCore.cs lines 600–601
32+
/// </summary>
33+
[Fact]
34+
public void Decode_DataWindowOverflow_NegativeWidth_Throws()
35+
{
36+
// XMin = -1073741825, XMax = 1073741823
37+
// Width = 1073741823 - (-1073741825) + 1 = 2^31 + 1 → wraps to -2147483647
38+
byte[] data = BuildMinimalExr(xMin: -1073741825, yMin: 0, xMax: 1073741823, yMax: 0);
39+
40+
using var stream = new MemoryStream(data);
41+
Assert.Throws<InvalidImageContentException>(
42+
() => ExrDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, stream));
43+
}
44+
45+
/// <summary>
46+
/// EXR-2 — EXR Row Offset Table Unvalidated Seek (DoS / data integrity)
47+
///
48+
/// Row offsets are read from the file and used unconditionally to seek the stream:
49+
/// ulong rowOffset = this.ReadUnsignedLong(stream);
50+
/// stream.Position = (long)rowOffset; // no bounds check
51+
///
52+
/// A crafted offset of 0xFFFFFFFFFFFFFFFF casts to −1 as long, causing
53+
/// an ArgumentOutOfRangeException when setting stream.Position.
54+
///
55+
/// After a fix this should throw InvalidImageContentException instead.
56+
///
57+
/// Affected file:
58+
/// src/ImageSharp/Formats/Exr/ExrDecoderCore.cs lines 170–175 (scanline)
59+
/// lines 243–248 (tile)
60+
/// </summary>
61+
[Fact]
62+
public void Decode_CraftedRowOffsets_OutOfBounds_Throws()
63+
{
64+
// Valid 2×2 image (XMin=0,YMin=0,XMax=1,YMax=1 → Width=2,Height=2).
65+
// Row offset table immediately follows the header null byte:
66+
// 2 rows × 8 bytes each, all set to 0xFFFFFFFFFFFFFFFF.
67+
byte[] invalidOffsets = new byte[16];
68+
BinaryPrimitives.WriteUInt64LittleEndian(invalidOffsets, 0xFFFFFFFFFFFFFFFF);
69+
BinaryPrimitives.WriteUInt64LittleEndian(invalidOffsets.AsSpan(8), 0xFFFFFFFFFFFFFFFF);
70+
71+
byte[] data = BuildMinimalExr(
72+
xMin: 0, yMin: 0, xMax: 1, yMax: 1,
73+
rowOffsetTableAppend: invalidOffsets);
74+
75+
using var stream = new MemoryStream(data);
76+
Assert.Throws<InvalidImageContentException>(
77+
() => ExrDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, stream));
78+
}
79+
80+
/// <summary>
81+
/// EXR-3 — EXR bytesPerBlock uint Overflow Chain (DoS)
82+
///
83+
/// CalculateBytesPerRow is computed in ulong (fixed), and if the result exceeds
84+
/// int.MaxValue the decoder throws InvalidImageContentException. With 4 RGBA HALF
85+
/// channels and Width = 2^29:
86+
/// bytesPerRow = 4 × 2 × 2^29 = 2^32 (> int.MaxValue)
87+
/// → InvalidImageContentException before any allocation
88+
///
89+
/// Affected file:
90+
/// src/ImageSharp/Formats/Exr/ExrDecoderCore.cs lines 142–150, 215–223
91+
/// src/ImageSharp/Formats/Exr/ExrUtils.cs CalculateBytesPerRow
92+
/// </summary>
93+
[Fact]
94+
public void Decode_BytesPerBlockUintOverflow_Throws()
95+
{
96+
// 4 RGBA HALF channels, Width = 2^29:
97+
// bytesPerRow = 4 × 2 × 536870912 = 4294967296 > int.MaxValue
98+
// → InvalidImageContentException from the block-size guard
99+
byte[] data = BuildMinimalRgbaExr(xMin: 0, yMin: 0, xMax: 536870911, yMax: 0);
100+
101+
using var stream = new MemoryStream(data);
102+
Assert.Throws<InvalidImageContentException>(
103+
() => ExrDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, stream));
104+
}
105+
106+
// -------------------------------------------------------------------------
107+
// Helpers: construct minimal valid-enough EXR scanline files.
108+
//
109+
// Required attributes per ParseHeaderAttributes validation:
110+
// channels, compression, dataWindow, displayWindow,
111+
// lineOrder, pixelAspectRatio, screenWindowCenter, screenWindowWidth
112+
// -------------------------------------------------------------------------
113+
114+
private static byte[] BuildMinimalExr(
115+
int xMin, int yMin, int xMax, int yMax,
116+
byte[]? rowOffsetTableAppend = null)
117+
{
118+
// channels: single "R" HALF channel with xSampling=1, ySampling=1
119+
// Layout per ReadChannelInfo: name\0 (2) + pixelType (4) + pLinear+reserved (4)
120+
// + xSampling (4) + ySampling (4) = 18 bytes/channel
121+
// + list-null (1) = 19 total
122+
byte[] channelData =
123+
[
124+
0x52, 0x00, // "R\0"
125+
0x01, 0x00, 0x00, 0x00, // pixelType = Half (1)
126+
0x00, 0x00, 0x00, 0x00, // pLinear + 3 reserved bytes
127+
0x01, 0x00, 0x00, 0x00, // xSampling = 1
128+
0x01, 0x00, 0x00, 0x00, // ySampling = 1
129+
0x00, // channel-list null terminator
130+
];
131+
132+
return BuildExrWithChannels(xMin, yMin, xMax, yMax, channelData, rowOffsetTableAppend);
133+
}
134+
135+
private static byte[] BuildMinimalRgbaExr(int xMin, int yMin, int xMax, int yMax)
136+
{
137+
// 4 HALF channels in alphabetical order (A, B, G, R) per EXR spec.
138+
// 18 bytes per channel × 4 channels + 1 list-null = 73 bytes total.
139+
byte[] channelData =
140+
[
141+
0x41, 0x00, // "A\0"
142+
0x01, 0x00, 0x00, 0x00, // pixelType = Half (1)
143+
0x00, 0x00, 0x00, 0x00, // pLinear + 3 reserved bytes
144+
0x01, 0x00, 0x00, 0x00, // xSampling = 1
145+
0x01, 0x00, 0x00, 0x00, // ySampling = 1
146+
0x42, 0x00, // "B\0"
147+
0x01, 0x00, 0x00, 0x00,
148+
0x00, 0x00, 0x00, 0x00,
149+
0x01, 0x00, 0x00, 0x00,
150+
0x01, 0x00, 0x00, 0x00,
151+
0x47, 0x00, // "G\0"
152+
0x01, 0x00, 0x00, 0x00,
153+
0x00, 0x00, 0x00, 0x00,
154+
0x01, 0x00, 0x00, 0x00,
155+
0x01, 0x00, 0x00, 0x00,
156+
0x52, 0x00, // "R\0"
157+
0x01, 0x00, 0x00, 0x00,
158+
0x00, 0x00, 0x00, 0x00,
159+
0x01, 0x00, 0x00, 0x00,
160+
0x01, 0x00, 0x00, 0x00,
161+
0x00, // channel-list null terminator
162+
];
163+
164+
return BuildExrWithChannels(xMin, yMin, xMax, yMax, channelData);
165+
}
166+
167+
private static byte[] BuildExrWithChannels(
168+
int xMin, int yMin, int xMax, int yMax,
169+
byte[] channelData,
170+
byte[]? rowOffsetTableAppend = null)
171+
{
172+
using var ms = new MemoryStream();
173+
using var bw = new BinaryWriter(ms, System.Text.Encoding.ASCII, leaveOpen: true);
174+
175+
// Magic (0x01312F76 LE) + version 2, scanline (flags = 0x00)
176+
bw.Write(new byte[] { 0x76, 0x2F, 0x31, 0x01, 0x02, 0x00, 0x00, 0x00 });
177+
178+
void WriteAttr(string name, string type, byte[] payload)
179+
{
180+
foreach (char c in name) bw.Write((byte)c);
181+
bw.Write((byte)0);
182+
foreach (char c in type) bw.Write((byte)c);
183+
bw.Write((byte)0);
184+
bw.Write(payload.Length);
185+
bw.Write(payload);
186+
}
187+
188+
WriteAttr("channels", "chlist", channelData);
189+
190+
WriteAttr("compression", "compression", [0x00]); // None
191+
192+
byte[] dw = new byte[16];
193+
BinaryPrimitives.WriteInt32LittleEndian(dw, xMin);
194+
BinaryPrimitives.WriteInt32LittleEndian(dw.AsSpan(4), yMin);
195+
BinaryPrimitives.WriteInt32LittleEndian(dw.AsSpan(8), xMax);
196+
BinaryPrimitives.WriteInt32LittleEndian(dw.AsSpan(12), yMax);
197+
WriteAttr("dataWindow", "box2i", dw);
198+
199+
WriteAttr("displayWindow", "box2i", new byte[16]); // all zeros (0,0,0,0)
200+
201+
WriteAttr("lineOrder", "lineOrder", [0x00]); // IncreasingY
202+
203+
byte[] aspect = new byte[4];
204+
BinaryPrimitives.WriteSingleLittleEndian(aspect, 1.0f);
205+
WriteAttr("pixelAspectRatio", "float", aspect);
206+
207+
WriteAttr("screenWindowCenter", "v2f", new byte[8]); // (0f, 0f)
208+
209+
byte[] sww = new byte[4];
210+
BinaryPrimitives.WriteSingleLittleEndian(sww, 1.0f);
211+
WriteAttr("screenWindowWidth", "float", sww);
212+
213+
bw.Write((byte)0x00); // end-of-header sentinel
214+
215+
if (rowOffsetTableAppend is not null)
216+
bw.Write(rowOffsetTableAppend);
217+
218+
return ms.ToArray();
219+
}
220+
}

0 commit comments

Comments
 (0)