dotnet / dotnet/runtime

MemberLoader::GetDescFromMemberRef does not exclude CompilerControlled/privatescope methods, violating ECMA-335 I.8.5.3.2

Open
#126,741 0 comments 0 reactions 0 assignees View on GitHub
area-TypeSystem-coreclr
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.