ILVerify does not flag a non-virtual method bound to an interface slot (TypeLoadException at class load)
- 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
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