MemberLoader::GetDescFromMemberRef does not exclude CompilerControlled/privatescope methods, violating ECMA-335 I.8.5.3.2
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
### 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`](https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/memberload.cpp), `MemberLoader::GetDescFromMemberRef` calls `FindMethod` with `FM_Default`:
```cpp
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`](https://github.com/dotnet/runtime/blob/main/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`
```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`
```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`:
```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:
```cpp
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_
Contributor guide
Assessment
This issue has not been assessed yet.