Skip to content

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

@BrentRector

Description

@BrentRector

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions