|
| 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<TPixel> 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