Skip to content

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

Description

@DavidObando

Description

ILVerify 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):

.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:

$ 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:

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:

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:

// 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 I work on hit the identical defect independently, in real production code, not a synthetic test: DavidObando/gsharp#4157. A struct implementing IReadOnlyCollection<T> (which transitively requires IEnumerable<T> 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:

.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<!0> ...List`1<int32>::GetEnumerator()
  IL_000b: box        valuetype ...List`1/Enumerator<int32>
  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:

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-L301ResolveInterfaceMethodTarget 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 — this is the branch that fires when the interface method is bound via an explicit MethodImpl (our .override case). It scans the type's MethodImplRecords for one whose Decl matches the interface method, and returns:

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:

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:

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:

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

  • ilverify fails to warn about invalid class declaration #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.
  • ILVerify does not detect disallowed boxing of byref-like types #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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-Tools-ILVerificationIssues related to ilverify tool and IL verification in generaluntriagedNew issue has not been triaged by the area owner

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions