Description
#111164 reported that types nested directly inside fail to load on .NET 9+ with BadImageFormatException: Enclosing type(s) not found for type 'X' in assembly 'Y'.
This was fixed by #111435, which adds an early return in ClassLoader::AddAvailableClassHaveLock (src/coreclr/vm/clsload.cpp) whenever a type's own enclosing type is <Module> (COR_GLOBAL_PARENT_TOKEN):
if (SUCCEEDED(pMDImport->GetNestedClassProps(classdef, &enclosing))) {
// nested type
if (enclosing == COR_GLOBAL_PARENT_TOKEN)
{
// Types nested in the <module> class can't be found by lookup.
return;
}
...
That fix skips registering the directly-nested type in the available-class hash, which avoids the crash for that one type. However, if some other type B is nested inside a type A that is itself nested inside <Module> (i.e. <Module> → A → B, two levels deep), loading still throws the same BadImageFormatException, now naming B instead of A. This is presumably because when B's entry is processed, the loader looks up its immediate enclosing type A in the same hash table — but A was deliberately never added to that table by the #111435 fix, so the lookup misses and the original "enclosing type not found" failure path fires one level down.
This pattern (helper types injected as nested-in-nested-in-<Module>) is common in .NET obfuscators/protectors (ConfuserEx and derivatives) that clone runtime-support types into the target module for constant decryption / anti-debug checks, so it's the same class of regression as #111164, just one level deeper and apparently missed by the original fix and its repro.
Reproduction Steps
Minimal repro, built with System.Reflection.Metadata.Ecma335.MetadataBuilder directly (no obfuscator involved), targeting net10.0:
using System;
using System.IO;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using System.Reflection.PortableExecutable;
// mode: "flat" = <Module> -> TypeA ; "deep" = <Module> -> TypeA -> TypeB
string mode = args.Length > 0 ? args[0] : "flat";
string outPath = args.Length > 1 ? args[1] : "out.dll";
var metadata = new MetadataBuilder();
metadata.AddModule(0, metadata.GetOrAddString("ReproModule"),
metadata.GetOrAddGuid(Guid.NewGuid()), default, default);
metadata.AddAssembly(metadata.GetOrAddString("ReproAsm"), new Version(1, 0, 0, 0),
default, default, AssemblyFlags.PublicKey, AssemblyHashAlgorithm.None);
var systemRuntimeRef = metadata.AddAssemblyReference(
metadata.GetOrAddString("System.Runtime"), new Version(10, 0, 0, 0), default,
metadata.GetOrAddBlob(new byte[] { 0xb0, 0x3f, 0x5f, 0x7f, 0x11, 0xd5, 0x0a, 0x3a }),
default, default);
var objectTypeRef = metadata.AddTypeReference(systemRuntimeRef,
metadata.GetOrAddString("System"), metadata.GetOrAddString("Object"));
var moduleType = metadata.AddTypeDefinition(default, default,
metadata.GetOrAddString("<Module>"), default,
MetadataTokens.FieldDefinitionHandle(1), MetadataTokens.MethodDefinitionHandle(1));
var typeA = metadata.AddTypeDefinition(
TypeAttributes.NestedAssembly | TypeAttributes.Sealed, default,
metadata.GetOrAddString("TypeA"), objectTypeRef,
MetadataTokens.FieldDefinitionHandle(1), MetadataTokens.MethodDefinitionHandle(1));
metadata.AddNestedType(typeA, moduleType);
if (mode == "deep")
{
var typeB = metadata.AddTypeDefinition(
TypeAttributes.NestedAssembly | TypeAttributes.Sealed, default,
metadata.GetOrAddString("TypeB"), objectTypeRef,
MetadataTokens.FieldDefinitionHandle(1), MetadataTokens.MethodDefinitionHandle(1));
metadata.AddNestedType(typeB, typeA);
}
var peBuilder = new ManagedPEBuilder(
new PEHeaderBuilder(imageCharacteristics: Characteristics.ExecutableImage | Characteristics.Dll),
new MetadataRootBuilder(metadata), new BlobBuilder(),
entryPoint: default, flags: CorFlags.ILOnly);
var peBlob = new BlobBuilder();
peBuilder.Serialize(peBlob);
using var fsOut = new FileStream(outPath, FileMode.Create, FileAccess.Write);
peBlob.WriteContentTo(fsOut);
Loader (also net10.0):
using System;
using System.IO;
using System.Reflection;
var bytes = File.ReadAllBytes(args[0]);
try
{
var asm = Assembly.Load(bytes);
Console.WriteLine("LOAD OK: " + asm.FullName);
Console.WriteLine("GetTypes OK, count=" + asm.GetTypes().Length);
}
catch (Exception ex)
{
Console.WriteLine("LOAD FAILED: " + ex.GetType().Name + ": " + ex.Message);
}
Steps:
- Generate repro_flat.dll (mode flat) and repro_deep.dll (mode deep).
- Run the loader against each with a net10.0 app.
Expected behavior
Both repro_flat.dll and repro_deep.dll load successfully, consistent with the intent of #111435 that types nested under <Module> are tolerated (even if not discoverable by name lookup).
Actual behavior
$ loader repro_flat.dll
LOAD OK: ReproAsm, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
GetTypes OK, count=1
$ loader repro_deep.dll
LOAD FAILED: BadImageFormatException: Enclosing type(s) not found for type 'TypeB' in assembly 'ReproAsm, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null'.
repro_deep.dll fails at Assembly.Load itself, before any type is even requested.
Regression?
| Runtime |
flat (1 level) |
deep (2 levels) |
| .NET 6.0.36 |
loads, GetTypes() throws ReflectionTypeLoadException |
loads, GetTypes() throws ReflectionTypeLoadException |
| .NET 8.0.30 |
loads fine |
loads fine |
| .NET 10.0.11 (SDK 10.0.111) |
loads fine (fixed by #111435) |
Assembly.Load throws BadImageFormatException |
Known Workarounds
No response
Configuration
No response
Other information
No response
Description
#111164 reported that types nested directly inside fail to load on .NET 9+ with BadImageFormatException: Enclosing type(s) not found for type 'X' in assembly 'Y'.
This was fixed by #111435, which adds an early return in
ClassLoader::AddAvailableClassHaveLock(src/coreclr/vm/clsload.cpp) whenever a type's own enclosing type is<Module> (COR_GLOBAL_PARENT_TOKEN):That fix skips registering the directly-nested type in the available-class hash, which avoids the crash for that one type. However, if some other type B is nested inside a type A that is itself nested inside
<Module>(i.e.<Module> → A → B, two levels deep), loading still throws the same BadImageFormatException, now namingBinstead ofA. This is presumably because whenB's entry is processed, the loader looks up its immediate enclosing typeAin the same hash table — butAwas deliberately never added to that table by the #111435 fix, so the lookup misses and the original "enclosing type not found" failure path fires one level down.This pattern (helper types injected as nested-in-nested-in-
<Module>) is common in .NET obfuscators/protectors (ConfuserEx and derivatives) that clone runtime-support types into the target module for constant decryption / anti-debug checks, so it's the same class of regression as #111164, just one level deeper and apparently missed by the original fix and its repro.Reproduction Steps
Minimal repro, built with System.Reflection.Metadata.Ecma335.MetadataBuilder directly (no obfuscator involved), targeting net10.0:
Steps:
Expected behavior
Both repro_flat.dll and repro_deep.dll load successfully, consistent with the intent of #111435 that types nested under
<Module>are tolerated (even if not discoverable by name lookup).Actual behavior
repro_deep.dll fails at Assembly.Load itself, before any type is even requested.
Regression?
GetTypes()throwsReflectionTypeLoadExceptionGetTypes()throwsReflectionTypeLoadExceptionAssembly.LoadthrowsBadImageFormatExceptionKnown Workarounds
No response
Configuration
No response
Other information
No response