Description
Description
Cross-module MemberRef resolution considers CompilerControlled (privatescope, accessibility 0x00) methods as candidates, contrary to ECMA-335 I.8.5.3.2 which states:
Members that have CompilerControlled accessibility shall not be accessed from outside the defining module.
When a type has two methods with the same name and signature — one CompilerControlled and one public — the CLR may resolve a cross-module MemberRef to the CompilerControlled method and throw MethodAccessException, even though an accessible public method exists.
The behavior depends on MethodDef table order: because MemberLoader::FindMethod iterates backwards (MoveToEnd() then Prev()), it finds the last matching method. If the CompilerControlled method appears after the public method in the table, the CLR picks it and fails.
Root Cause
In src/coreclr/vm/memberload.cpp, MemberLoader::GetDescFromMemberRef calls FindMethod with FM_Default:
MethodDesc * pMD = MemberLoader::FindMethod(pMT,
szMember,
pSig,
cSig,
pModule,
MemberLoader::FM_Default, // <-- no exclusion flags
&sigSubst);
FM_Default (0x0000) does not set FM_ExcludePrivateScope. The flag exists in src/coreclr/vm/memberload.h but is not used in this code path.
In FM_ShouldSkipMethod, the access check is gated on flags & FM_SpecialAccessMask. Since FM_Default has no access bits, the entire access filter is skipped — CompilerControlled methods are treated as valid candidates.
FindMethod iterates the MethodDef table backwards (last-to-first), so it returns the last name+signature match. If a CompilerControlled method appears after a public method in the table, the CLR picks the CompilerControlled one and then fails the cross-module access check.
Context
I discovered this during development of a compiler that emits IL and frequently uses CompilerControlled accessibility. The compiler can assign s multiple methods the same name — which is valid metadata per ECMA-335 when methods have privatescope accessibility. Cross-module MemberRefs target the public methods, but the CLR's backward iteration finds the CompilerControlled method instead when it appears later in the table.
Reproduction Steps
Reproduction
Two assemblies: CollisionLib.dll (defines the type) and CollisionCaller.exe (cross-module caller).
CollisionLib.il
Assemble with: ilasm /dll /output:CollisionLib.dll CollisionLib.il
.assembly extern mscorlib { .ver 4:0:0:0 }
.assembly CollisionLib { }
.module CollisionLib.dll
.class public auto ansi beforefieldinit Target extends [mscorlib]System.Object
{
// Public method — appears FIRST in the MethodDef table
.method public hidebysig static string a() cil managed
{
ldstr "PUBLIC"
ret
}
// CompilerControlled method — appears SECOND in the MethodDef table
.method privatescope hidebysig static string a() cil managed
{
ldstr "PRIVATESCOPE"
ret
}
.method public hidebysig specialname rtspecialname instance void .ctor() cil managed
{
ldarg.0
call instance void [mscorlib]System.Object::.ctor()
ret
}
}
CollisionCaller.il
Assemble with: ilasm /exe /output:CollisionCaller.exe CollisionCaller.il
.assembly extern mscorlib { .ver 4:0:0:0 }
.assembly extern CollisionLib { }
.assembly CollisionCaller { }
.module CollisionCaller.dll
.class public auto ansi beforefieldinit Program extends [mscorlib]System.Object
{
.method public hidebysig static void Main() cil managed
{
.entrypoint
call string [CollisionLib]Target::a()
call void [mscorlib]System.Console::WriteLine(string)
ret
}
.method public hidebysig specialname rtspecialname instance void .ctor() cil managed
{
ldarg.0
call instance void [mscorlib]System.Object::.ctor()
ret
}
}
For .NET 10, add a CollisionCaller.runtimeconfig.json:
{"runtimeOptions":{"tfm":"net10.0","framework":{"name":"Microsoft.NETCore.App","version":"10.0.0"}}}
Expected behavior
Expected Behavior
Per ECMA-335 I.8.5.3.2, CompilerControlled members should not be considered during cross-module MemberRef resolution. The MemberRef [CollisionLib]Target::a() should resolve to the public method regardless of table order.
Suggested Fix
In MemberLoader::GetDescFromMemberRef, pass FM_ExcludePrivateScope when the MemberRef crosses module boundaries:
MethodDesc * pMD = MemberLoader::FindMethod(pMT,
szMember,
pSig,
cSig,
pModule,
MemberLoader::FM_ExcludePrivateScope, // exclude CompilerControlled
&sigSubst);
The FM_ExcludePrivateScope flag already exists in memberload.h and is correctly handled by FM_ShouldSkipMethod. It is simply not passed in this code path.
Actual behavior
Results
> CollisionCaller.exe
Unhandled Exception: System.MethodAccessException:
Attempt by method 'Program.Main()' to access method 'Target.a()' failed.
Control: Swap the table order
If the IL is changed so CompilerControlled appears first and public appears second, the same caller succeeds and prints PUBLIC. The CLR's backward iteration finds the public method (last in table) and resolves correctly. This confirms the issue is table-order-dependent, not a fundamental access check.
Control: Remove the CompilerControlled method
If only the public method exists, the caller succeeds. The mere presence of a CompilerControlled method with the same name+signature corrupts cross-module resolution.
Regression?
Not a regression. This is along-standing latent bug.
Known Workarounds
I sort the MethodDefs for a type such that private scope entries precede all entries with other accessibility.
Configuration
Affected Versions
I reproduced this bug using:
- .NET Framework 4.8 (tested with Framework64 v4.0.30319 ilasm)
- .NET 10.0.201 (tested with
dotnet exec)
Both exhibit identical behavior. I expect the bug is latent in all versions.
Other information
No response
Description
Description
Cross-module MemberRef resolution considers CompilerControlled (privatescope, accessibility 0x00) methods as candidates, contrary to ECMA-335 I.8.5.3.2 which states:
When a type has two methods with the same name and signature — one CompilerControlled and one public — the CLR may resolve a cross-module MemberRef to the CompilerControlled method and throw
MethodAccessException, even though an accessible public method exists.The behavior depends on MethodDef table order: because
MemberLoader::FindMethoditerates backwards (MoveToEnd()thenPrev()), it finds the last matching method. If the CompilerControlled method appears after the public method in the table, the CLR picks it and fails.Root Cause
In
src/coreclr/vm/memberload.cpp,MemberLoader::GetDescFromMemberRefcallsFindMethodwithFM_Default:MethodDesc * pMD = MemberLoader::FindMethod(pMT, szMember, pSig, cSig, pModule, MemberLoader::FM_Default, // <-- no exclusion flags &sigSubst);FM_Default(0x0000) does not setFM_ExcludePrivateScope. The flag exists insrc/coreclr/vm/memberload.hbut is not used in this code path.In
FM_ShouldSkipMethod, the access check is gated onflags & FM_SpecialAccessMask. SinceFM_Defaulthas no access bits, the entire access filter is skipped — CompilerControlled methods are treated as valid candidates.FindMethoditerates the MethodDef table backwards (last-to-first), so it returns the last name+signature match. If a CompilerControlled method appears after a public method in the table, the CLR picks the CompilerControlled one and then fails the cross-module access check.Context
I discovered this during development of a compiler that emits IL and frequently uses CompilerControlled accessibility. The compiler can assign s multiple methods the same name — which is valid metadata per ECMA-335 when methods have privatescope accessibility. Cross-module MemberRefs target the public methods, but the CLR's backward iteration finds the CompilerControlled method instead when it appears later in the table.
Reproduction Steps
Reproduction
Two assemblies:
CollisionLib.dll(defines the type) andCollisionCaller.exe(cross-module caller).CollisionLib.il
Assemble with:
ilasm /dll /output:CollisionLib.dll CollisionLib.ilCollisionCaller.il
Assemble with:
ilasm /exe /output:CollisionCaller.exe CollisionCaller.ilFor .NET 10, add a
CollisionCaller.runtimeconfig.json:{"runtimeOptions":{"tfm":"net10.0","framework":{"name":"Microsoft.NETCore.App","version":"10.0.0"}}}Expected behavior
Expected Behavior
Per ECMA-335 I.8.5.3.2, CompilerControlled members should not be considered during cross-module MemberRef resolution. The MemberRef
[CollisionLib]Target::a()should resolve to the public method regardless of table order.Suggested Fix
In
MemberLoader::GetDescFromMemberRef, passFM_ExcludePrivateScopewhen the MemberRef crosses module boundaries:MethodDesc * pMD = MemberLoader::FindMethod(pMT, szMember, pSig, cSig, pModule, MemberLoader::FM_ExcludePrivateScope, // exclude CompilerControlled &sigSubst);The
FM_ExcludePrivateScopeflag already exists inmemberload.hand is correctly handled byFM_ShouldSkipMethod. It is simply not passed in this code path.Actual behavior
Results
Control: Swap the table order
If the IL is changed so CompilerControlled appears first and public appears second, the same caller succeeds and prints
PUBLIC. The CLR's backward iteration finds the public method (last in table) and resolves correctly. This confirms the issue is table-order-dependent, not a fundamental access check.Control: Remove the CompilerControlled method
If only the public method exists, the caller succeeds. The mere presence of a CompilerControlled method with the same name+signature corrupts cross-module resolution.
Regression?
Not a regression. This is along-standing latent bug.
Known Workarounds
I sort the MethodDefs for a type such that private scope entries precede all entries with other accessibility.
Configuration
Affected Versions
I reproduced this bug using:
dotnet exec)Both exhibit identical behavior. I expect the bug is latent in all versions.
Other information
No response