Skip to content

Commit 8324129

Browse files
Bound .rsrc resource-tree parsing against crafted input
ReadWin32Resources walked the PE resource directory tree with raw native pointer arithmetic over attacker-controlled offsets, counts and sizes, with no bounds checks, no recursion-depth limit and no cycle detection. The root section pointer came from GetSectionData, whose length was read and then discarded, leaving every dereference unbounded. A crafted assembly could therefore turn merely opening it (the Save as project feature reads these resources unconditionally) into an uncatchable process kill or an out-of-bounds native read: a subdirectory entry pointing back at itself recursed until the stack overflowed; an inflated entry count walked off the section end; and a data entry whose Size was up to 4 GB made Buffer.MemoryCopy read far past the section, faulting on an unmapped page or copying adjacent process memory into the byte[] later written to app.ico/app.manifest on disk. None of this is containable, since a StackOverflowException cannot be caught and the repo has no corrupted-state exception handling. This is the sibling of the bundle signature fix in a154a7b. Carry the section length alongside the root pointer and bounds-check every offset, entry count, name-string length and data Size against it, cap recursion depth and track visited directory offsets to break cycles. A hostile or truncated file now yields a bounded, partial tree instead of a crash; well-formed resources parse exactly as before. The parser no longer needs the whole PEReader, only a delegate that resolves a data RVA to a bounded pointer, which is the seam the new tests drive over a pinned buffer. Assisted-by: Claude:claude-opus-4-8:Claude Code
1 parent a154a7b commit 8324129

2 files changed

Lines changed: 364 additions & 27 deletions

File tree

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
// Copyright (c) 2026 Siegfried Pammer
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
4+
// software and associated documentation files (the "Software"), to deal in the Software
5+
// without restriction, including without limitation the rights to use, copy, modify, merge,
6+
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
7+
// to whom the Software is furnished to do so, subject to the following conditions:
8+
//
9+
// The above copyright notice and this permission notice shall be included in all copies or
10+
// substantial portions of the Software.
11+
//
12+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
13+
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
14+
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
15+
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16+
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
17+
// DEALINGS IN THE SOFTWARE.
18+
19+
using System;
20+
using System.Runtime.InteropServices;
21+
22+
using ICSharpCode.Decompiler.Util;
23+
24+
using NUnit.Framework;
25+
26+
namespace ICSharpCode.Decompiler.Tests.Util
27+
{
28+
// Exercises the bounds, recursion-depth and cycle guards in Win32Resources against crafted
29+
// .rsrc section bytes. Each test hands a hand-built directory tree to the parser through a
30+
// resolver that maps an RVA to an offset inside the same pinned buffer (a single-section PE).
31+
[TestFixture]
32+
public unsafe class Win32ResourcesTests
33+
{
34+
const int DirectorySize = 16; // IMAGE_RESOURCE_DIRECTORY
35+
const int EntrySize = 8; // IMAGE_RESOURCE_DIRECTORY_ENTRY
36+
const int DataEntrySize = 16; // IMAGE_RESOURCE_DATA_ENTRY
37+
const uint SubdirectoryFlag = 0x80000000;
38+
39+
// Pins the buffer, parses it as a resource section, and runs the assertions while the data
40+
// pointers captured during parsing still point into the pinned buffer.
41+
static void Parse(byte[] buffer, Action<Win32ResourceDirectory> assert)
42+
{
43+
var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
44+
try
45+
{
46+
byte* pRoot = (byte*)handle.AddrOfPinnedObject();
47+
var resolver = new BufferResolver(pRoot, buffer.Length);
48+
var root = Win32ResourceDirectory.ReadDirectoryTree(pRoot, buffer.Length, resolver.Resolve);
49+
assert(root);
50+
}
51+
finally
52+
{
53+
handle.Free();
54+
}
55+
}
56+
57+
// Resolves a data RVA to a pointer inside the buffer, returning the bytes that remain from
58+
// that offset to the end - the same "length to end of section" contract PEReader.GetSectionData
59+
// provides, so a crafted Size larger than the data can be bounded.
60+
sealed class BufferResolver
61+
{
62+
readonly byte* pRoot;
63+
readonly int length;
64+
65+
public BufferResolver(byte* pRoot, int length)
66+
{
67+
this.pRoot = pRoot;
68+
this.length = length;
69+
}
70+
71+
public byte* Resolve(int rva, out int dataLength)
72+
{
73+
if (rva < 0 || rva > length)
74+
{
75+
dataLength = 0;
76+
return null;
77+
}
78+
dataLength = length - rva;
79+
return pRoot + rva;
80+
}
81+
}
82+
83+
static void WriteDirectory(byte[] buffer, int offset, ushort namedEntries, ushort idEntries)
84+
{
85+
BitConverter.GetBytes(namedEntries).CopyTo(buffer, offset + 12);
86+
BitConverter.GetBytes(idEntries).CopyTo(buffer, offset + 14);
87+
}
88+
89+
static void WriteEntry(byte[] buffer, int offset, uint name, uint offsetToData)
90+
{
91+
BitConverter.GetBytes(name).CopyTo(buffer, offset);
92+
BitConverter.GetBytes(offsetToData).CopyTo(buffer, offset + 4);
93+
}
94+
95+
static void WriteDataEntry(byte[] buffer, int offset, uint rva, uint size)
96+
{
97+
BitConverter.GetBytes(rva).CopyTo(buffer, offset);
98+
BitConverter.GetBytes(size).CopyTo(buffer, offset + 4);
99+
}
100+
101+
[Test]
102+
public void SelfReferentialSubdirectory_DoesNotRecurseInfinitely()
103+
{
104+
// One directory with a single subdirectory entry that points back at itself (offset 0).
105+
// The unfixed parser follows it forever, yielding an uncatchable StackOverflowException.
106+
byte[] buffer = new byte[DirectorySize + EntrySize];
107+
WriteDirectory(buffer, 0, namedEntries: 0, idEntries: 1);
108+
WriteEntry(buffer, DirectorySize, name: 1, offsetToData: SubdirectoryFlag /* offset 0 */);
109+
110+
Parse(buffer, root => {
111+
Assert.That(root.Directories.Count, Is.EqualTo(1));
112+
var child = root.Directories[0];
113+
Assert.That(child.Directories.Count, Is.EqualTo(0), "the cycle back to the root must be cut");
114+
Assert.That(child.Datas.Count, Is.EqualTo(0));
115+
});
116+
}
117+
118+
[Test]
119+
public void DeeplyNestedDirectories_AreBoundedByDepthLimit()
120+
{
121+
// A long chain of distinct nested directories. Even without a cycle this would recurse
122+
// as deep as the chain; the depth cap must stop it well before that.
123+
const int chainLength = 40;
124+
byte[] buffer = new byte[chainLength * (DirectorySize + EntrySize)];
125+
for (int k = 0; k < chainLength; k++)
126+
{
127+
int dirOffset = k * (DirectorySize + EntrySize);
128+
bool hasChild = k < chainLength - 1;
129+
WriteDirectory(buffer, dirOffset, namedEntries: 0, idEntries: (ushort)(hasChild ? 1 : 0));
130+
if (hasChild)
131+
{
132+
uint childOffset = (uint)((k + 1) * (DirectorySize + EntrySize));
133+
WriteEntry(buffer, dirOffset + DirectorySize, name: (uint)(k + 1), offsetToData: SubdirectoryFlag | childOffset);
134+
}
135+
}
136+
137+
Parse(buffer, root => {
138+
int depth = 0;
139+
var current = root;
140+
while (current != null && current.Directories.Count > 0)
141+
{
142+
current = current.Directories[0];
143+
depth++;
144+
}
145+
// The parser caps nesting at a small constant (well above any real resource tree),
146+
// so the measured depth must be far below the crafted chain length.
147+
Assert.That(depth, Is.LessThanOrEqualTo(17));
148+
});
149+
}
150+
151+
[Test]
152+
public void EntryCountBeyondSection_IsClamped()
153+
{
154+
// A directory header that claims far more entries than the section can hold. The unfixed
155+
// parser walks the declared count straight off the end of the section.
156+
byte[] buffer = new byte[DirectorySize];
157+
WriteDirectory(buffer, 0, namedEntries: 0, idEntries: 0xFFFF);
158+
159+
Parse(buffer, root => {
160+
Assert.That(root.Directories.Count, Is.EqualTo(0));
161+
Assert.That(root.Datas.Count, Is.EqualTo(0));
162+
});
163+
}
164+
165+
[Test]
166+
public void DataSizeBeyondSection_IsClampedToAvailable()
167+
{
168+
// A data leaf whose declared Size dwarfs the bytes actually present. The unfixed Data
169+
// getter copies the full Size, reading gigabytes past the section base.
170+
const int dataBytes = 8;
171+
int dataEntryOffset = DirectorySize + EntrySize;
172+
int dataOffset = dataEntryOffset + DataEntrySize;
173+
byte[] buffer = new byte[dataOffset + dataBytes];
174+
WriteDirectory(buffer, 0, namedEntries: 0, idEntries: 1);
175+
WriteEntry(buffer, DirectorySize, name: 1, offsetToData: (uint)dataEntryOffset /* data leaf */);
176+
WriteDataEntry(buffer, dataEntryOffset, rva: (uint)dataOffset, size: 0xFFFFFFF0);
177+
178+
Parse(buffer, root => {
179+
Assert.That(root.Datas.Count, Is.EqualTo(1));
180+
var data = root.Datas[0];
181+
Assert.That(data.Size, Is.EqualTo(0xFFFFFFF0));
182+
Assert.That(data.Data.Length, Is.EqualTo(dataBytes), "the copy must be bounded to the bytes that exist");
183+
});
184+
}
185+
186+
[Test]
187+
public void NegativeDataRva_YieldsEmptyDataWithoutThrowing()
188+
{
189+
// The data entry's RVA is a file uint; with the high bit set it casts to a negative int.
190+
// The resolver must reject it (PEReader.GetSectionData throws on a negative RVA) so the
191+
// leaf yields empty data rather than aborting the parse.
192+
int dataEntryOffset = DirectorySize + EntrySize;
193+
byte[] buffer = new byte[dataEntryOffset + DataEntrySize];
194+
WriteDirectory(buffer, 0, namedEntries: 0, idEntries: 1);
195+
WriteEntry(buffer, DirectorySize, name: 1, offsetToData: (uint)dataEntryOffset /* data leaf */);
196+
WriteDataEntry(buffer, dataEntryOffset, rva: 0xFFFFFFFF /* negative as int */, size: 0x100);
197+
198+
Parse(buffer, root => {
199+
Assert.That(root.Datas.Count, Is.EqualTo(1));
200+
Assert.That(root.Datas[0].Data, Is.Empty);
201+
});
202+
}
203+
204+
[Test]
205+
public void OutOfRangeStringName_DoesNotReadOutOfBounds()
206+
{
207+
// A named entry whose name-string offset lies past the section end. The unfixed parser
208+
// dereferences it directly, reading the length prefix and characters out of bounds.
209+
int dataEntryOffset = DirectorySize + EntrySize;
210+
byte[] buffer = new byte[dataEntryOffset + DataEntrySize];
211+
WriteDirectory(buffer, 0, namedEntries: 1, idEntries: 0);
212+
WriteEntry(buffer, DirectorySize, name: SubdirectoryFlag | 0x100 /* string offset past the buffer */, offsetToData: (uint)dataEntryOffset);
213+
WriteDataEntry(buffer, dataEntryOffset, rva: 0, size: 0);
214+
215+
Parse(buffer, root => {
216+
Assert.That(root.Datas.Count, Is.EqualTo(1));
217+
var name = root.Datas[0].Name;
218+
Assert.That(name.HasName, Is.True);
219+
Assert.That(name.Name, Is.Empty, "an out-of-range string name must resolve to empty, not an OOB read");
220+
});
221+
}
222+
223+
[Test]
224+
public void ValidResourceTree_ParsesAndReadsData()
225+
{
226+
// A well-formed Type -> Name -> (language data leaf) tree, mirroring how a manifest is
227+
// laid out, to prove the bounds checks do not break normal parsing.
228+
const int RT_MANIFEST = 24;
229+
int typeDir = 0;
230+
int rootEntry = typeDir + DirectorySize; // 16
231+
int nameDir = rootEntry + EntrySize; // 24
232+
int typeEntry = nameDir + DirectorySize; // 40
233+
int leafDir = typeEntry + EntrySize; // 48
234+
int nameEntry = leafDir + DirectorySize; // 64
235+
int dataEntry = nameEntry + EntrySize; // 72
236+
int dataOffset = dataEntry + DataEntrySize; // 88
237+
byte[] payload = { 0xDE, 0xAD, 0xBE, 0xEF };
238+
byte[] buffer = new byte[dataOffset + payload.Length];
239+
240+
WriteDirectory(buffer, typeDir, namedEntries: 0, idEntries: 1);
241+
WriteEntry(buffer, rootEntry, name: RT_MANIFEST, offsetToData: SubdirectoryFlag | (uint)nameDir);
242+
WriteDirectory(buffer, nameDir, namedEntries: 0, idEntries: 1);
243+
WriteEntry(buffer, typeEntry, name: 1, offsetToData: SubdirectoryFlag | (uint)leafDir);
244+
WriteDirectory(buffer, leafDir, namedEntries: 0, idEntries: 1);
245+
WriteEntry(buffer, nameEntry, name: 1033, offsetToData: (uint)dataEntry /* data leaf */);
246+
WriteDataEntry(buffer, dataEntry, rva: (uint)dataOffset, size: (uint)payload.Length);
247+
payload.CopyTo(buffer, dataOffset);
248+
249+
Parse(buffer, root => {
250+
var manifest = root.Find(new Win32ResourceName(RT_MANIFEST))?.FirstDirectory()?.FirstData()?.Data;
251+
Assert.That(manifest, Is.Not.Null);
252+
Assert.That(manifest, Is.EqualTo(payload));
253+
});
254+
}
255+
}
256+
}

0 commit comments

Comments
 (0)