dotnet / dotnet/runtime

ILVerify does not flag a non-virtual method bound to an interface slot (TypeLoadException at class load)

Open
#133,500 2 comments 0 reactions 0 assignees View on GitHub
area-Tools-ILVerification untriaged
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

### Description

[ILVerify](https://github.com/dotnet/runtime/tree/main/src/coreclr/tools/ILVerification) does not detect a method that is bound to an interface slot via a `MethodImpl` row (an `.override` in IL) but is **not** marked `virtual` in its own `MethodAttributes`. Per ECMA-335 (§II.15.2, "MethodImpls"; the CLR's class-loading rules build on this — a method occupying or overriding a vtable/interface slot must be virtual), this is invalid: `ilasm` will happily assemble it, `ILVerify` reports the assembly fully verified, but the CLR type loader throws `TypeLoadException` the first time the type is loaded:

```
System.TypeLoadException: Method 'GetEnumerator' on type 'BadEnumerable' from assembly '...'
must be virtual to implement a method on an interface or super type.
```

This is the same *class* of gap as #119536 (ilverify accepts an invalid `extends` clause) and the still-open #132954 (ilverify misses disallowed byref-like boxing): a construct that ILVerify passes as "verified" but that unconditionally fails at CLR class-load time, so `ilverify`'s "Verified" result gives false confidence to any tool or pipeline that treats it as a load-safety gate.

### Reproduction

This repro is hand-authored IL (a normal C# compiler will never emit a non-virtual interface-implementing method — Roslyn always sets `virtual`), assembled with `ilasm`, verified with `ilverify`, and exercised at runtime with a small C# harness. No dependency on any third-party compiler or project.

Tool: `dotnet-ilverify` 10.0.8. Assembler: `Microsoft.NETCore.ILAsm` 10.0.12 (`ilasm`, obtained from the `runtime.osx-arm64.Microsoft.NETCore.ILAsm` NuGet package — no in-box `ilasm`/`ildasm` ship with the SDK anymore). Target: `net10.0` / `System.Runtime, Version=10.0.0.0`.

`Test.il` (two contrasting types, mirroring #119536's `FirstObj`/`SecondObj` convention — `GoodEnumerable` is the correct control, `BadEnumerable` is identical except for the missing `virtual` keyword):

```il
.assembly extern System.Runtime
{
.publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
.ver 10:0:0:0
}

.assembly Test
{
.ver 1:0:0:0
}
.module Test.dll

// ---- Control: correctly emitted explicit interface implementation ----
.class public auto ansi sealed beforefieldinit GoodEnumerable
extends [System.Runtime]System.Object
implements [System.Runtime]System.Collections.IEnumerable
{
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
.maxstack 8
ldarg.0
call instance void [System.Runtime]System.Object::.ctor()
ret
}

// Correct: newslot virtual final, bound to the interface slot via .override.
.method public hidebysig newslot virtual final
instance class [System.Runtime]System.Collections.IEnumerator
GetEnumerator() cil managed
{
.override [System.Runtime]System.Collections.IEnumerable::GetEnumerator
.maxstack 8
ldnull
ret
}
}

// ---- Defect: interface-satisfying method missing `virtual` ----
.class public auto ansi sealed beforefieldinit BadEnumerable
extends [System.Runtime]System.Object
implements [System.Runtime]System.Collections.IEnumerable
{
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
.maxstack 8
ldarg.0
call instance void [System.Runtime]System.Object::.ctor()
ret
}

// BUG UNDER TEST: identical shape to GoodEnumerable::GetEnumerator, minus
// `virtual`. Still hidebysig, still bound to IEnumerable.GetEnumerator via
// .override below.
.method public hidebysig
instance class [System.Runtime]System.Collections.IEnumerator
GetEnumerator() cil managed
{
.override [System.Runtime]System.Collections.IEnumerable::GetEnumerator
.maxstack 8
ldnull
ret
}
}
```

Assemble and verify:

```text
$ ilasm -dll -output=Test.dll Test.il
.NET IL Assembler. Version 10.0.12
...
Assembled method GoodEnumerable::.ctor
Assembled method GoodEnumerable::GetEnumerator
Assembled method BadEnumerable::.ctor
Assembled method BadEnumerable::GetEnumerator
Creating PE file
...
Operation completed successfully

$ ilverify Test.dll -s System.Private.CoreLib \
-r /usr/local/share/dotnet/shared/Microsoft.NETCore.App/10.0.11/System.Private.CoreLib.dll \
-r /usr/local/share/dotnet/shared/Microsoft.NETCore.App/10.0.11/System.Runtime.dll \
-r /usr/local/share/dotnet/shared/Microsoft.NETCore.App/10.0.11/mscorlib.dll \
-r /usr/local/share/dotnet/shared/Microsoft.NETCore.App/10.0.11/netstandard.dll
All Classes and Methods in Test.dll Verified.
```

`ilverify` reports full success — no diagnostic on `BadEnumerable` at all.

A minimal C# harness that loads the assembly and drives both types through their `IEnumerable` interface:

```csharp
using System;
using System.Collections;
using System.Reflection;

var asm = Assembly.LoadFrom("Test.dll");

var good = (IEnumerable)Activator.CreateInstance(asm.GetType("GoodEnumerable"))!;
Console.WriteLine("GoodEnumerable: " + good.GetEnumerator()); // succeeds

try
{
var t = asm.GetType("BadEnumerable");
Console.WriteLine("BadEnumerable: Type object obtained: " + t); // reflection alone is fine
var bad = (IEnumerable)Activator.CreateInstance(t)!; // class loads on first real use
bad.GetEnumerator();
}
catch (Exception ex)
{
Console.WriteLine(ex.GetType().FullName);
Console.WriteLine(ex.Message);
}
```

Actual output:

```text
GoodEnumerable: GetEnumerator() succeeded, as expected:
BadEnumerable: Type object obtained: BadEnumerable
System.TypeLoadException
Method 'GetEnumerator' on type 'BadEnumerable' from assembly 'Test, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' must be virtual to implement a method on an interface or super type.
```

`ilverify` said the assembly was fully verified; the CLR type loader disagrees the moment the type is actually loaded.

### The emitted IL, annotated

Disassembly of the two `GetEnumerator` methods (`ildasm`), side by side — the *only* difference between the working and broken method is the presence of `virtual` in the `.method` attribute list; everything else (`hidebysig`, the `.override` MethodImpl binding, the method body) is identical:

```il
// GoodEnumerable — correct
.method public hidebysig newslot virtual final // <-- virtual present
instance class [System.Runtime]System.Collections.IEnumerator
GetEnumerator() cil managed
{
.override [System.Runtime]System.Collections.IEnumerable::GetEnumerator
IL_0000: ldnull
IL_0001: ret
}

// BadEnumerable — defect under test
.method public hidebysig // <-- virtual MISSING
instance class [System.Runtime]System.Collections.IEnumerator
GetEnumerator() cil managed
{
.override [System.Runtime]System.Collections.IEnumerable::GetEnumerator // still bound to the slot
IL_0000: ldnull
IL_0001: ret
}
```

`BadEnumerable::GetEnumerator` still carries a `MethodImpl` row (the `.override`) pointing at `IEnumerable::GetEnumerator`, and the type's `InterfaceImpl` row for `IEnumerable` is present and otherwise well-formed — the *only* thing wrong is the missing `virtual` bit on the method that fills the slot.

### Minimal repro from a second language

The [G# compiler](https://github.com/DavidObando/gsharp) I work on hit the identical defect independently, in real production code, not a synthetic test: [DavidObando/gsharp#4157](https://github.com/DavidObando/gsharp/issues/4157). A struct implementing `IReadOnlyCollection` (which transitively requires `IEnumerable` and non-generic `IEnumerable`) declares an explicit, non-generic `IEnumerable.GetEnumerator()` bridge alongside the public generic `GetEnumerator()`. gsc's emitter has a code path (`MethodInfoHelpers.RequiresVirtualOnValueType`) that decides whether a value-type instance method needs the `virtual` flag by checking overrides and *implicit* interface-signature matches, but never consults the method's explicit-interface-slot binding — so the bridge method is emitted with a `MethodImpl` row (correct) but without `virtual` (the bug). The resulting `GSharp.Core.Tests.dll` passes the project's `dotnet-ilverify 10.0.8` pipeline stage cleanly, then throws at test-run time:

```
System.TypeLoadException: Method 'System.Collections.IEnumerable.GetEnumerator' on type
'ImmutableArrayOfDiagnostic' from assembly 'GSharp.Core.Tests, Version=0.4.0.0, ...' must be
virtual to implement a method on an interface or super type.
```

I reduced gsc's own emission to a minimal case (`struct Bag : IEnumerable[int32] { ... two GetEnumerator overloads ... }`) and disassembled the real gsc-emitted method:

```il
.method public hidebysig instance class [System.Runtime]System.Collections.IEnumerator
GetEnumerator() cil managed
{
.override [System.Runtime]System.Collections.IEnumerable::GetEnumerator
IL_0000: ldarg.0
IL_0001: ldfld class ... GSharp.Repro.Bag::items
IL_0006: callvirt instance valuetype ...List`1/Enumerator ...List`1::GetEnumerator()
IL_000b: box valuetype ...List`1/Enumerator
IL_0010: ret
} // no `virtual` — identical shape to BadEnumerable above
```

Same `ilverify 10.0.8` "Verified" result, same `TypeLoadException` shape, from a completely independent front-end compiler. That two unrelated compilers converge on the identical undetected-by-ILVerify shape is strong evidence the gap is in ILVerify's interface-implementation check, not a quirk of either compiler. (This section is corroborating evidence only — the standalone repro above is self-contained and does not depend on G#, gsc, or the GSharp repository.)

### Root cause analysis

Tracing how `ILVerify` decides an interface is "implemented":

**[`src/coreclr/tools/ILVerification/TypeVerifier.cs`, `VerifyInterfaces()`, lines 119–142](https://github.com/dotnet/runtime/blob/main/src/coreclr/tools/ILVerification/TypeVerifier.cs#L119-L142):**

```csharp
foreach (InterfaceMetadataObjects implementedInterface in implementedInterfaces)
{
if (!type.IsAbstract)
{
// Look for missing method implementation
foreach (MethodDesc method in implementedInterface.InterfaceType.GetAllMethods())
{
if (!method.IsAbstract)
{
continue;
}

if (type.ResolveInterfaceMethodTarget(method) is not MethodDesc resolvedMethod)
{
type.ResolveInterfaceMethodToDefaultImplementationOnType(method, out resolvedMethod);
}

if (resolvedMethod is null)
{
VerificationError(VerifierError.InterfaceMethodNotImplemented, Format(type), Format(implementedInterface.InterfaceType, _module, implementedInterface.InterfaceImplementation), Format(method));
}
}
}
}
```

The only check performed is `resolvedMethod is null`. There is no check anywhere in this method — or, as far as I can find, anywhere else in ILVerify — that `resolvedMethod.IsVirtual` is true.

The `null` check would still save us if `ResolveInterfaceMethodTarget` refused to resolve to a non-virtual method, so I followed that call chain:

**[`src/coreclr/tools/Common/TypeSystem/Common/TypeSystemHelpers.cs#L290-L301`](https://github.com/dotnet/runtime/blob/main/src/coreclr/tools/Common/TypeSystem/Common/TypeSystemHelpers.cs#L290-L301)** — `ResolveInterfaceMethodTarget` walks the type and its bases calling `ResolveInterfaceMethodToVirtualMethodOnType` (name notwithstanding, see below) until it gets a non-null result.

**[`src/coreclr/tools/Common/TypeSystem/Common/MetadataVirtualMethodAlgorithm.cs`, `FindInterfaceImplFromDeclFromMethodImpls`, lines 309–357](https://github.com/dotnet/runtime/blob/main/src/coreclr/tools/Common/TypeSystem/Common/MetadataVirtualMethodAlgorithm.cs#L309-L357)** — this is the branch that fires when the interface method is bound via an explicit `MethodImpl` (our `.override` case). It scans the type's `MethodImplRecord`s for one whose `Decl` matches the interface method, and returns:

```csharp
return FindSlotDefiningMethodForVirtualMethod(foundMethodImpls[resultIndex].Body);
```

`.Body` is the `MethodDesc` of the method the `MethodImpl` row points at — taken directly off the metadata, with no check that it is virtual.

**[`FindSlotDefiningMethodForVirtualMethod`, lines 439–461](https://github.com/dotnet/runtime/blob/main/src/coreclr/tools/Common/TypeSystem/Common/MetadataVirtualMethodAlgorithm.cs#L439-L461):**

```csharp
public static MethodDesc FindSlotDefiningMethodForVirtualMethod(MethodDesc method)
{
if (method == null)
return method;

Debug.Assert(method.GetMethodDefinition() == method);

DefType currentType = method.OwningType.BaseType;

// Loop until a newslot method is found
while ((currentType != null) && !method.IsNewSlot)
{
MethodDesc foundMethod = FindMatchingVirtualMethodOnTypeByNameAndSig(method, currentType, reverseMethodSearch: true, nameSigMatchMethodIsValidCandidate: null);
if (foundMethod != null)
{
method = foundMethod;
}

currentType = currentType.BaseType;
}

// Newslot method found, or if not the least derived method that matches by name and
// sig is to be returned.
return method;
}
```

This function assumes its input is already a virtual method (its whole job is finding which ancestor *defines* the slot) and never itself checks `IsVirtual` — only `IsNewSlot`. For `BadEnumerable::GetEnumerator`, `IsNewSlot` is false (it was never set, since the method isn't virtual), so the loop tries to walk to `BadEnumerable`'s base (`System.Object`) looking for a same-name/sig virtual match, finds none, and falls off the loop — returning the original, still-non-virtual `method` unchanged, non-null.

That `MethodDesc.IsVirtual` genuinely reflects the raw metadata bit (no inference or defaulting) is confirmed at [`src/coreclr/tools/Common/TypeSystem/Ecma/EcmaMethod.cs#L229-L234`](https://github.com/dotnet/runtime/blob/main/src/coreclr/tools/Common/TypeSystem/Ecma/EcmaMethod.cs#L229-L234):

```csharp
public override bool IsVirtual
{
get
{
return (GetMethodFlags(MethodFlags.BasicMetadataCache | MethodFlags.Virtual) & MethodFlags.Virtual) != 0;
}
}
```

Net effect: a `MethodImpl`-bound, non-virtual method is returned by `ResolveInterfaceMethodTarget` as a valid resolution. `TypeVerifier.VerifyInterfaces()`'s `resolvedMethod is null` check never fires, so no `VerifierError` is ever raised — exactly matching the "All Classes and Methods ... Verified" result observed above.

I want to flag the confidence level here honestly: this is derived from reading the call chain rather than from stepping through it in a debugger, but it is a complete, non-speculative trace from the `VerifyInterfaces` entry point down to the metadata-level `IsVirtual` getter, and it fully explains the observed "Verified" result on `BadEnumerable`.

### Suggested fix

The most targeted fix is at the point that already exists to catch exactly this class of "interface not really implemented" case — `TypeVerifier.VerifyInterfaces()` — by additionally checking `IsVirtual` on the resolved method, treating a non-virtual resolution the same as no resolution:

```csharp
if (type.ResolveInterfaceMethodTarget(method) is not MethodDesc resolvedMethod)
{
type.ResolveInterfaceMethodToDefaultImplementationOnType(method, out resolvedMethod);
}

if (resolvedMethod is null || !resolvedMethod.IsVirtual)
{
VerificationError(VerifierError.InterfaceMethodNotImplemented, Format(type), Format(implementedInterface.InterfaceType, _module, implementedInterface.InterfaceImplementation), Format(method));
}
```

This reuses the existing `InterfaceMethodNotImplemented` error/message ("Class implements interface but not method"), which is a reasonable fit — a non-virtual `MethodImpl` target cannot actually serve as the interface's virtual-dispatch implementation, so from the CLR's perspective the interface member genuinely isn't implemented. A maintainer may prefer a distinct `VerifierError` (e.g. `InterfaceMethodNotVirtual`) with a more specific message; I'm happy to send a PR for whichever direction is preferred.

I did not find an equivalently narrow fix on the `MetadataVirtualMethodAlgorithm.cs` side — that code is shared with the AOT/crossgen compilers' own virtual-dispatch resolution, so changing what it returns for a non-virtual `MethodImpl` body is a larger, riskier change than adding the one extra check where ILVerify already decides pass/fail.

### Related issues

- #119536 — *"ilverify fails to warn about invalid class declaration"* (closed, fixed): the same class of gap — ilverify reports "Verified" on IL that unconditionally throws `TypeLoadException` at CLR class-load time. There the defect was an invalid `extends` clause; the fix added, per the maintainer's own description, "a more general invalid base type validation" (which is exactly `TypeVerifier.VerifyBaseType()`, the sibling method to `VerifyInterfaces()` quoted above). This issue is the interface-implementation counterpart of that same validation gap.
- #132954 — *"ILVerify does not detect disallowed boxing of byref-like types"* (open): another live example of the same pattern — a real CLR-enforced rule that ILVerify does not check, so "Verified" assemblies can still fail at runtime.

### Environment

- OS: macOS (Darwin) 26.6, arm64
- .NET SDK: 10.0.400
- `dotnet-ilverify`: 10.0.8
- `ilasm`/`ildasm`: `Microsoft.NETCore.ILAsm` / `Microsoft.NETCore.ILDAsm` 10.0.12 (`runtime.osx-arm64.*` NuGet packages)
- Target framework: `net10.0`

Contributor guide

Open the contributing guide

Research direction

Start with src/coreclr/tools/ILVerification/TypeVerifier.cs and VerifyInterfaces(), then trace ResolveInterfaceMethodTarget in TypeSystemHelpers.cs and FindInterfaceImplFromDeclFromMethodImpls in MetadataVirtualMethodAlgorithm.cs. Use the Test.il reproduction with ilasm and ilverify; done means the non-virtual MethodImpl/interface-slot case is no longer reported as verified and the valid control remains accepted.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
compilers, devtools, testing-qa
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
65/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.