You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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):
.assemblyextern System.Runtime
{
.publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
.ver10:0:0:0
}
.assembly Test
{
.ver1:0:0:0
}
.module Test.dll
// ---- Control: correctly emitted explicit interface implementation ----.classpublicautoansisealedbeforefieldinit GoodEnumerable
extends[System.Runtime]System.Object
implements[System.Runtime]System.Collections.IEnumerable
{
.methodpublichidebysigspecialnamertspecialnameinstancevoid.ctor() cilmanaged
{
.maxstack8ldarg.0callinstancevoid[System.Runtime]System.Object::.ctor()
ret
}
// Correct: newslot virtual final, bound to the interface slot via .override..methodpublichidebysignewslotvirtualfinalinstanceclass[System.Runtime]System.Collections.IEnumerator
GetEnumerator() cilmanaged
{
.override[System.Runtime]System.Collections.IEnumerable::GetEnumerator
.maxstack8ldnullret
}
}
// ---- Defect: interface-satisfying method missing `virtual` ----.classpublicautoansisealedbeforefieldinit BadEnumerable
extends[System.Runtime]System.Object
implements[System.Runtime]System.Collections.IEnumerable
{
.methodpublichidebysigspecialnamertspecialnameinstancevoid.ctor() cilmanaged
{
.maxstack8ldarg.0callinstancevoid[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..methodpublichidebysiginstanceclass[System.Runtime]System.Collections.IEnumerator
GetEnumerator() cilmanaged
{
.override[System.Runtime]System.Collections.IEnumerable::GetEnumerator
.maxstack8ldnullret
}
}
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:
usingSystem;usingSystem.Collections;usingSystem.Reflection;varasm=Assembly.LoadFrom("Test.dll");vargood=(IEnumerable)Activator.CreateInstance(asm.GetType("GoodEnumerable"))!;Console.WriteLine("GoodEnumerable: "+good.GetEnumerator());// succeedstry{vart=asm.GetType("BadEnumerable");Console.WriteLine("BadEnumerable: Type object obtained: "+t);// reflection alone is finevarbad=(IEnumerable)Activator.CreateInstance(t)!;// class loads on first real usebad.GetEnumerator();}catch(Exceptionex){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.methodpublichidebysignewslotvirtualfinal// <-- virtual presentinstanceclass[System.Runtime]System.Collections.IEnumerator
GetEnumerator() cilmanaged
{
.override[System.Runtime]System.Collections.IEnumerable::GetEnumerator
IL_0000: ldnullIL_0001: ret
}
// BadEnumerable — defect under test.methodpublichidebysig// <-- virtual MISSINGinstanceclass[System.Runtime]System.Collections.IEnumerator
GetEnumerator() cilmanaged
{
.override[System.Runtime]System.Collections.IEnumerable::GetEnumerator // still bound to the slotIL_0000: ldnullIL_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:
.methodpublichidebysiginstanceclass[System.Runtime]System.Collections.IEnumerator
GetEnumerator() cilmanaged
{
.override[System.Runtime]System.Collections.IEnumerable::GetEnumerator
IL_0000: ldarg.0IL_0001: ldfldclass ... GSharp.Repro.Bag::items
IL_0006: callvirtinstancevaluetype ...List`1/Enumerator<!0> ...List`1<int32>::GetEnumerator()
IL_000b: boxvaluetype ...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":
foreach(InterfaceMetadataObjectsimplementedInterfaceinimplementedInterfaces){if(!type.IsAbstract){// Look for missing method implementationforeach(MethodDescmethodinimplementedInterface.InterfaceType.GetAllMethods()){if(!method.IsAbstract){continue;}if(type.ResolveInterfaceMethodTarget(method)is not MethodDescresolvedMethod){type.ResolveInterfaceMethodToDefaultImplementationOnType(method,outresolvedMethod);}if(resolvedMethodisnull){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:
publicstaticMethodDescFindSlotDefiningMethodForVirtualMethod(MethodDescmethod){if(method==null)returnmethod;Debug.Assert(method.GetMethodDefinition()==method);DefTypecurrentType=method.OwningType.BaseType;// Loop until a newslot method is foundwhile((currentType!=null)&&!method.IsNewSlot){MethodDescfoundMethod=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.returnmethod;}
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.
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 MethodDescresolvedMethod){type.ResolveInterfaceMethodToDefaultImplementationOnType(method,outresolvedMethod);}if(resolvedMethodisnull||!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.
Description
ILVerify does not detect a method that is bound to an interface slot via a
MethodImplrow (an.overridein IL) but is not markedvirtualin its ownMethodAttributes. 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:ilasmwill happily assemble it,ILVerifyreports the assembly fully verified, but the CLR type loader throwsTypeLoadExceptionthe first time the type is loaded:This is the same class of gap as #119536 (ilverify accepts an invalid
extendsclause) 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, soilverify'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 withilasm, verified withilverify, and exercised at runtime with a small C# harness. No dependency on any third-party compiler or project.Tool:
dotnet-ilverify10.0.8. Assembler:Microsoft.NETCore.ILAsm10.0.12 (ilasm, obtained from theruntime.osx-arm64.Microsoft.NETCore.ILAsmNuGet package — no in-boxilasm/ildasmship with the SDK anymore). Target:net10.0/System.Runtime, Version=10.0.0.0.Test.il(two contrasting types, mirroring #119536'sFirstObj/SecondObjconvention —GoodEnumerableis the correct control,BadEnumerableis identical except for the missingvirtualkeyword):Assemble and verify:
ilverifyreports full success — no diagnostic onBadEnumerableat all.A minimal C# harness that loads the assembly and drives both types through their
IEnumerableinterface:Actual output:
ilverifysaid 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
GetEnumeratormethods (ildasm), side by side — the only difference between the working and broken method is the presence ofvirtualin the.methodattribute list; everything else (hidebysig, the.overrideMethodImpl binding, the method body) is identical:BadEnumerable::GetEnumeratorstill carries aMethodImplrow (the.override) pointing atIEnumerable::GetEnumerator, and the type'sInterfaceImplrow forIEnumerableis present and otherwise well-formed — the only thing wrong is the missingvirtualbit 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 requiresIEnumerable<T>and non-genericIEnumerable) declares an explicit, non-genericIEnumerable.GetEnumerator()bridge alongside the public genericGetEnumerator(). gsc's emitter has a code path (MethodInfoHelpers.RequiresVirtualOnValueType) that decides whether a value-type instance method needs thevirtualflag 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 aMethodImplrow (correct) but withoutvirtual(the bug). The resultingGSharp.Core.Tests.dllpasses the project'sdotnet-ilverify 10.0.8pipeline stage cleanly, then throws at test-run time:I reduced gsc's own emission to a minimal case (
struct Bag : IEnumerable[int32] { ... two GetEnumerator overloads ... }) and disassembled the real gsc-emitted method:Same
ilverify 10.0.8"Verified" result, sameTypeLoadExceptionshape, 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
ILVerifydecides an interface is "implemented":src/coreclr/tools/ILVerification/TypeVerifier.cs,VerifyInterfaces(), lines 119–142: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 — thatresolvedMethod.IsVirtualis true.The
nullcheck would still save us ifResolveInterfaceMethodTargetrefused to resolve to a non-virtual method, so I followed that call chain:src/coreclr/tools/Common/TypeSystem/Common/TypeSystemHelpers.cs#L290-L301—ResolveInterfaceMethodTargetwalks the type and its bases callingResolveInterfaceMethodToVirtualMethodOnType(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 explicitMethodImpl(our.overridecase). It scans the type'sMethodImplRecords for one whoseDeclmatches the interface method, and returns:.Bodyis theMethodDescof the method theMethodImplrow points at — taken directly off the metadata, with no check that it is virtual.FindSlotDefiningMethodForVirtualMethod, lines 439–461: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— onlyIsNewSlot. ForBadEnumerable::GetEnumerator,IsNewSlotis false (it was never set, since the method isn't virtual), so the loop tries to walk toBadEnumerable's base (System.Object) looking for a same-name/sig virtual match, finds none, and falls off the loop — returning the original, still-non-virtualmethodunchanged, non-null.That
MethodDesc.IsVirtualgenuinely reflects the raw metadata bit (no inference or defaulting) is confirmed atsrc/coreclr/tools/Common/TypeSystem/Ecma/EcmaMethod.cs#L229-L234:Net effect: a
MethodImpl-bound, non-virtual method is returned byResolveInterfaceMethodTargetas a valid resolution.TypeVerifier.VerifyInterfaces()'sresolvedMethod is nullcheck never fires, so noVerifierErroris 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
VerifyInterfacesentry point down to the metadata-levelIsVirtualgetter, and it fully explains the observed "Verified" result onBadEnumerable.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 checkingIsVirtualon the resolved method, treating a non-virtual resolution the same as no resolution:This reuses the existing
InterfaceMethodNotImplementederror/message ("Class implements interface but not method"), which is a reasonable fit — a non-virtualMethodImpltarget 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 distinctVerifierError(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.csside — that code is shared with the AOT/crossgen compilers' own virtual-dispatch resolution, so changing what it returns for a non-virtualMethodImplbody is a larger, riskier change than adding the one extra check where ILVerify already decides pass/fail.Related issues
TypeLoadExceptionat CLR class-load time. There the defect was an invalidextendsclause; the fix added, per the maintainer's own description, "a more general invalid base type validation" (which is exactlyTypeVerifier.VerifyBaseType(), the sibling method toVerifyInterfaces()quoted above). This issue is the interface-implementation counterpart of that same validation gap.Environment
dotnet-ilverify: 10.0.8ilasm/ildasm:Microsoft.NETCore.ILAsm/Microsoft.NETCore.ILDAsm10.0.12 (runtime.osx-arm64.*NuGet packages)net10.0