dotnet / dotnet/runtime

EcmaModule::ResolveMemberReference resolves field MemberRefs by name only, ignoring signature, violating ECMA-335 II.22.15

Open
#126,781 2 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

`EcmaModule.ResolveMemberReference` resolves field MemberRefs by **name only**, discarding the field signature carried in the MemberRef blob. When a type defines two fields with the same name and different signatures (which ECMA-335 II.22.15 explicitly permits), every cross-module reference to *any* of them is resolved to the *first* matching field. **ILVerify** reports false-positive verification errors and **ILC** compiles the wrong field reference.

The same code path in the same method handles **method** MemberRefs correctly: it parses the signature, calls `GetMethod(name, sig, substitution)`, walks base types, and applies generic substitution. The field branch was never given the same treatment.

More detail:

When the .NET runtime executes a JIT-compiled method that references a field across modules, it resolves the field MemberRef using the full ECMA-335 II.22.15 triple — `(parent, name, signature)` — and selects the correct field even when the target type declares multiple fields with the same name and different signatures. This is implemented in `coreclr/vm/MemberLoader.cpp::GetDescFromMemberRef` and is well-tested.

The shared TypeSystem layer used by ILC (NativeAOT) and ILVerify, located at `src/coreclr/tools/Common/TypeSystem/Ecma/`, takes a different path. In `EcmaModule.cs::ResolveMemberReference`, the field branch resolves MemberRefs by **name only**, calling `parentTypeDesc.GetField(name)` and discarding the signature blob the parser already extracted. The underlying `EcmaType.GetField(ReadOnlySpan name)` is a name-only lookup with no signature-aware overload to call.

The result is that a field MemberRef whose signature unambiguously identifies one of several same-named fields on a type is silently resolved to whichever field `EcmaType.GetField` happens to return first. ILC compiles the wrong field reference into the AOT image, and ILVerify reports false-positive verification errors — for example, `[StackUnexpected] [found Int32][expected ref 'string']` — at every read site, even though the IL is fully ECMA-compliant and the JIT executes it correctly.

The same `EcmaModule.ResolveMemberReference` method handles **method** MemberRefs correctly in the branch immediately above: it parses the signature, calls `GetMethod(name, sig, substitution)`, walks base types, and applies generic substitution. The field branch was never given the same treatment, so the asymmetry is purely a missing implementation in the field path of the static-analysis tooling — not a runtime bug, and not an ECMA-335 ambiguity.

## Root Cause

[`EcmaModule.cs::ResolveMemberReference`](https://github.com/dotnet/runtime/blob/main/src/coreclr/tools/Common/TypeSystem/Ecma/EcmaModule.cs) — the field branch parses far enough into the signature to know `parser.IsFieldSignature` is true, then discards the rest of the blob and looks the field up by name only:

```csharp
if (parser.IsFieldSignature)
{
FieldDesc field = parentTypeDesc.GetField(name); // signature ignored
if (field != null)
return field;

return ResolutionFailure.GetMissingFieldFailure(parentTypeDesc, ...);
}
else
{
// Method branch — does it correctly:
MethodSignature sig = parser.ParseMethodSignature();
...
do
{
MethodDesc method = typeDescToInspect.GetMethod(name, sig, substitution);
...
} while (typeDescToInspect != null);
}
```

[`EcmaType.cs::GetField(ReadOnlySpan name)`](https://github.com/dotnet/runtime/blob/main/src/coreclr/tools/Common/TypeSystem/Ecma/EcmaType.cs) — the lookup helper iterates the type's `FieldDef` rows and returns the first row whose `Name` column matches:

```csharp
public override EcmaField GetField(ReadOnlySpan name)
{
var metadataReader = this.MetadataReader;
foreach (var handle in _typeDefinition.GetFields())
{
if (metadataReader.StringEquals(metadataReader.GetFieldDefinition(handle).Name, name))
{
var field = _module.GetField(handle, this);
return field; // first match, never reads the signature
}
}
return null;
}
```

Compare with `EcmaType.GetMethod(name, sig, substitution)` in the same file, which is the correct template — same iteration shape, but it additionally compares signatures via `signature.Equals(method.Signature.ApplySubstitution(substitution))` before returning.

## Context

ECMA-335 II.22.15 specifies that the Field table has columns `(Flags, Name, Signature)` and that the **combination of `Owner`, `Name`, and `Signature` is the unique key** for each Field row. A type may legally define multiple fields with identical names provided their signatures differ. Two static fields named `a` on the same type, one of type `int32` and one of type `string`, are two distinct rows and two distinct identifiers from the runtime's perspective. The CLR resolves a MemberRef to a field by matching all three columns at runtime, which is why this works in production today.

This is the field-side counterpart to dotnet/runtime#126741, which I filed yesterday for the analogous (but different) bug in the runtime's `MemberLoader::GetDescFromMemberRef` for *method* MemberRefs into `CompilerControlled` (privatescope) methods. Both bugs surfaced from the same workload: building a .NET obfuscator (Demeanor for .NET) that generates assemblies where the (`name`, `signature`) uniqueness key is exercised in ways the C# compiler never produces.

`Common/TypeSystem` is the shared layer used by both ILVerify (`src/coreclr/tools/ILVerify/`) and ILC / NativeAOT (`src/coreclr/tools/aot/`), so a fix in one place addresses both tools.

### Reproduction Steps

Two `.il` files. `Lib.dll` defines a type with two same-named static fields of different types. `Main.exe` references one of them via a cross-module MemberRef. The bug only fires through cross-module MemberRefs — in-module references go through `ResolveFieldHandle` and the FieldDef token, not through the buggy `ResolveMemberReference` code path.

### Lib.il

```cil
.assembly extern System.Runtime { .ver 10:0:0:0 }
.assembly Lib { }
.module Lib.dll

.class public auto ansi beforefieldinit Holder
extends [System.Runtime]System.Object
{
// Two static fields, identical name 'a', different types.
// Legal per ECMA-335 II.22.15.
.field public static int32 'a'
.field public static string 'a'

.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
.maxstack 1
ldarg.0
call instance void [System.Runtime]System.Object::.ctor()
ret
}
}
```

### Main.il

```cil
.assembly extern System.Runtime { .ver 10:0:0:0 }
.assembly extern System.Console { .ver 10:0:0:0 }
.assembly extern Lib { }
.assembly Main { }
.module Main.dll

.class public auto ansi beforefieldinit Program
extends [System.Runtime]System.Object
{
.method public hidebysig static void Main() cil managed
{
.maxstack 1
.entrypoint
// Cross-module MemberRef. The signature in the blob is 'string'.
ldsfld string [Lib]Holder::'a'
call void [System.Console]System.Console::WriteLine(string)
ret
}

.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
.maxstack 1
ldarg.0
call instance void [System.Runtime]System.Object::.ctor()
ret
}
}
```

### Build & verify

```text
ilasm /dll /output:Lib.dll Lib.il
ilasm /exe /output:Main.exe Main.il

ilverify Main.exe \
-r "%ProgramFiles%\dotnet\packs\Microsoft.NETCore.App.Ref\10.0.5\ref\net10.0\System.Runtime.dll" \
-r "%ProgramFiles%\dotnet\packs\Microsoft.NETCore.App.Ref\10.0.5\ref\net10.0\System.Console.dll" \
-r Lib.dll \
-s System.Runtime
```

### Expected behavior

```text
All Classes and Methods in Main.exe Verified.
```

The `ldsfld` MemberRef carries the signature `string`. The CLR resolves this to the second `Holder::a` (the `string` field) at runtime — `dotnet Main.exe` runs successfully and prints an empty line because the `string` field is default-initialized to `null`. ILVerify should reach the same resolution, observe a `string` reference on the stack, satisfy `Console.WriteLine(string)`, and verify cleanly.

## Suggested Fix

Mirror the method-side path. Two changes are needed:

1. **`EcmaType.GetField`**: add an overload that takes a `FieldSignature` (parallel to the existing `GetMethod(name, sig, substitution)`) and compares signatures inside the loop.

2. **`EcmaModule.ResolveMemberReference` field branch**: parse the field signature via the existing `EcmaSignatureParser`, pass it to the new `GetField(name, sig)`, walk the base type chain (since fields can be inherited and a derived class may shadow a base-class field by name with a different type), and carry generic substitution the same way the method branch does.

Approximate sketch:

```csharp
if (parser.IsFieldSignature)
{
TypeDesc fieldType = parser.ParseType(); // decode the discarded bytes
if (fieldType == null)
return parser.ResolutionFailure;

TypeDesc typeDescToInspect = parentTypeDesc;
Instantiation substitution = default(Instantiation);

do
{
FieldDesc field = typeDescToInspect.GetField(name, fieldType, substitution);
if (field != null)
return field;

var baseType = typeDescToInspect.BaseType;
// ... same generic-substitution carry as the method branch ...
typeDescToInspect = baseType;
} while (typeDescToInspect != null);

return ResolutionFailure.GetMissingFieldFailure(parentTypeDesc, ...);
}
```

The new `GetField(name, type, substitution)` overload can default `type == null` to "any", which preserves existing callers that legitimately want name-only lookup (e.g. enum `value__` resolution).

### Actual behavior

```text
[IL]: Error [StackUnexpected]: E:\tmp\ilverify-fieldbug\Main.exe : Program::Main()
[offset 0x00000005][found Int32][expected ref 'string']
Unexpected type on the stack.
1 Error(s) Verifying E:\tmp\ilverify-fieldbug\Main.exe
```

`EcmaModule.ResolveMemberReference` calls `parentTypeDesc.GetField(name)`, which returns the first field on `Holder` with the name `a` — the `int32` field. ILVerify then tracks `Int32` on the stack and rejects the `Console.WriteLine(string)` call as a stack-type mismatch. The runtime is unaffected because it does not use this code path.

### Control: runtime is correct

```text
$ dotnet Main.exe
$ echo $?
0
```

`dotnet Main.exe` exits 0 and prints an empty line — the runtime correctly resolved the MemberRef to the `string` field (default-initialized to `null`, so `WriteLine` prints empty). The CLR's `MemberLoader` matches all three columns of the Field table; only the `Common/TypeSystem` static analysis layer used by ILVerify and ILC does not.

### Control: name-only field resolves correctly

If `Lib.il` is changed to define only the `string` field (removing the `int32` overload), the same `Main.exe` verifies cleanly:

```text
All Classes and Methods in Main.exe Verified.
```

This confirms that the failure is specifically caused by the presence of two fields sharing a name, not by anything else in the IL or the MemberRef blob.

### Regression?

No. The asymmetric handling between fields and methods in `ResolveMemberReference` appears to date back to the file's original commit. Method MemberRef resolution has always been signature-aware; field MemberRef resolution has always been name-only.

### Known Workarounds

In Demeanor for .NET, the workaround is to never produce assemblies where multiple fields on a type share a name. The cost is real: it weakens field-name obfuscation by forcing every field to have a unique name even when ECMA-335 would allow signature-based collapsing. The workaround is documented inline in the source:

```csharp
// Use unique names per type — don't overload field names by signature.
// While ECMA-335 allows same-named fields with different signatures,
// ILC (NativeAOT) and ILVerify can't resolve them when signatures
// contain generic type parameters. Always use distinct names.
newFieldName = scope.GetNextName();
```

(`src/Demeanor.Core/FieldRenamer.cs:99-103`)

The original symptom that forced the workaround was a `UnionFind` class with two private fields of types `Dictionary` and `Dictionary`. After signature-aware obfuscation gave them the same name, ILVerify reported `StackUnexpected` errors at every read site, and ILC failed AOT compilation. The CLR ran the obfuscated assembly correctly.

There is no general user-side workaround — any consumer of an assembly that legitimately uses signature-distinguished field overloading (an obfuscator output, a hand-written .il file, or a `Reflection.Emit`-generated assembly) cannot run ILVerify or AOT-publish it without modification.

### Configuration

## Affected Versions

- ilverify: 10.0.5 (`dotnet tool install -g dotnet-ilverify`)
- runtime: .NET 10.0.5 (`Microsoft.NETCore.App 10.0.5`)
- ILC: same code path, not separately tested but verified by source inspection of the shared `src/coreclr/tools/Common/TypeSystem/Ecma/EcmaModule.cs`
- ilasm: .NET Framework 4.8 (`C:\Windows\Microsoft.NET\Framework64\v4.0.30319\ilasm.exe`)
- platform: Windows 11 x64
- date: 2026-04-10

The buggy code is in the shared `Common/TypeSystem` directory and is reachable from every consumer of `EcmaModule` — there is no version where this is "fixed in newer ILC but broken in older ILVerify" or vice versa. A fix in one place addresses both tools.

### Other information

Related: dotnet/runtime#126741 — analogous bug in `MemberLoader::GetDescFromMemberRef` (the runtime's own MemberRef resolver) where `CompilerControlled` (privatescope) methods are not excluded from cross-module MemberRef resolution. Different code, different file, different symptom, but the same shape: ECMA-335 specifies a multi-column uniqueness key, the implementation collapses to a single column, and obfuscator-generated assemblies trip the resulting ambiguity.

If both #126741 and this issue are fixed, Demeanor for .NET would be able to ship significantly stronger field- and method-name obfuscation, including a per-method-on-a-type collapse to a single name distinguished only by signature (the metadata-level analogue of what C# compilers already do for method overloads, but pushed further than C# itself can express).

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.