Skip to content

Heap corruption from LCGMethodResolver resolver callbacks: the managed byte[] returned by Call_RetOBJECTREF is not GC-tracked across the return (10.0.9) #131267

Description

@gearsetdave

Summary

On release/10.0, several LCGMethodResolver callbacks in src/coreclr/vm/dynamicmethod.cpp fetch a managed object from the resolver as the return value of a MethodDescCallSite::Call_RetOBJECTREF. That reference is not reported as a GC root across the managed→native return, so a gen0 collection triggered by another thread can relocate or reclaim the SOH array before the caller roots it, leaving the native code with a stale OBJECTREF. Under concurrent DynamicMethod compilation with gen0 pressure this corrupts the heap; without the checked-build validation it surfaces later as an intermittent SIGSEGV at some unrelated victim read.

main replaced this convention across the resolver callbacks in two PRs tracked under #123864: #124303 (the MethodDescCallSite callbacks, including GetCodeInfo and GetLocalSig) and its follow-up #124921 (the PREPARE_SIMPLE_VIRTUAL_CALLSITE callbacks, including ResolveSignature). The values now come back through a GCPROTECT'd out-param the callee writes, rather than the return register. That removes the bug. Neither has shipped to release/10.0.

We verified both halves of this on a checked release/10.0 build (details under Verification): the bug reproduces at GetCodeInfo, a backport of only the GetCodeInfo conversion relocates the identical crash to the sibling GetLocalSig, and converting both removes it.

Impact

We hit this as intermittent test-host crashes on .NET 10 in a commercial product's CI (Gearset, a Salesforce DevOps tool): a SIGSEGV reported as "test host process crashed" while every test passes. The suite compiles LINQ Expressions and Moq mocks (LCG DynamicMethod emit) while the test runner executes fixtures in parallel under GC load - the shape the repro models. It's rare per run but frequent enough across a day's builds to force manual re-runs and stall our merge queue, and it can't be worked around from managed code. We're on released 10.0.x, so the main fix only reaches us through a servicing backport.

Fault

The code in 10.0.9, GetCodeInfo:

MethodDescCallSite getCodeInfo(METHOD__RESOLVER__GET_CODE_INFO, m_managedResolver);
OBJECTREF resolver = ObjectFromHandle(m_managedResolver);
...
U1ARRAYREF dataArray = (U1ARRAYREF) getCodeInfo.Call_RetOBJECTREF(args);  // <- stale on return
DWORD codeSize = dataArray->GetNumComponents();

Under a checked build the OBJECTREF validation faults while constructing dataArray, on the return value itself, before any use of it. The MethodTable read from the object is a debug fill pattern (0xcc…), confirming the array was moved or collected:

MethodTable::SanityCheck (this=0xccccccccccccccc8)   methodtable.cpp:6483   <- 0xcc fill
Object::ValidateInner                                object.cpp:548
MethodDescCallSite::Call_RetOBJECTREF                callhelpers.h:449
LCGMethodResolver::GetCodeInfo                       dynamicmethod.cpp:1187

A gen0 GC is concurrently in flight on another thread, triggered by ordinary SOH allocation:

WKS::GCHeap::GarbageCollectGeneration (gen=0, reason=reason_alloc_soh)   gc.cpp:51609
WKS::gc_heap::trigger_gc_for_alloc                                       gc.cpp:19221

Because the fault is on the return value, a GCPROTECT inside GetCodeInfo cannot help: the array has to be produced into a GC-protected slot the callee writes, which is what #124303 does.

The same return-register convention is used by the other resolver callbacks in this file. Two of them hold the returned reference across a subsequent allocation and are therefore vulnerable to the same window:

  • GetCodeInfo - holds dataArray across new BYTE[codeSize], then memcpy from it (dynamicmethod.cpp:1187).
  • GetLocalSig - holds dataArray across new COR_SIGNATURE[localSigSize], then memcpy from it (dynamicmethod.cpp:1235).

ResolveSignature / ResolveSignatureForVarArg hold a U1ARRAYREF across m_jitTempData.New() in the same shape (by code inspection - our repro exercises GetCodeInfo and GetLocalSig, not these). GetJitContext, GetStringLiteral, GetEHInfo and ResolveToken use the convention but consume the reference immediately with no intervening allocation. #124303 converts the MethodDescCallSite callbacks (GetJitContext, GetCodeInfo, GetLocalSig, GetStringLiteral); #124921 converts the PREPARE_SIMPLE_VIRTUAL_CALLSITE ones (ResolveToken, ResolveSignature, ResolveSignatureForVarArg, GetEHInfo).

Verification

We built a checked linux-x64 coreclr from the v10.0.9 tag and looped the attached repro under it - 4 concurrent instances on a 16-core box, workstation GC, HeapVerify=1, small gen0, no GCStress - applying the conversions incrementally:

coreclr build Fault site Result
Unpatched 10.0.9 GetCodeInfo (dynamicmethod.cpp:1187) ~1 fault per 7-14 passes per worker
#124303 GetCodeInfo conversion only GetLocalSig (dynamicmethod.cpp:1235) GetCodeInfo frame gone; identical crash relocates to GetLocalSig, first fault at ~74 passes per worker
GetCodeInfo + GetLocalSig conversions none 239 passes per worker (956 total), zero faults

The relocated crash after the GetCodeInfo-only conversion has the identical signature (Call_RetOBJECTREFValidateInner, 0xcc-filled MethodTable, concurrent reason_alloc_soh gen0 GC), differing only in the faulting frame (GetLocalSig instead of GetCodeInfo). This is direct evidence that the fix must cover the sibling callbacks, not GetCodeInfo alone: a GetCodeInfo-only backport moves the crash rather than removing it.

The same fault showed up in dotnet/runtime CI and @jkotas analyzed it in #119140 (the LCGMethodResolver::GetLocalSigCall_RetOBJECTREF stack). Our repro hits that path on demand with a freed (0xcc-fill) MethodTable, and the conversions stop it - evidence of a real missing GC-root on the register return rather than a racy IsHeapPointer. (This is separate from the runtime-async RuntimeAsyncTask crash later filed on the same issue, which these PRs do not touch.)

Reproduction

Standalone project, no third-party dependencies: https://gist.github.com/gearsetdave/a3bd7d222c4f5cbe6f76135fb25aa301 - two concurrent thread pools:

  • Compile threads each build a fresh Expression.Lambda<Func<int,int,int>> and call .Compile(). That emits an LCG DynamicMethod and calls CreateDelegate, which forces the JIT compile that runs through GetCodeInfo and GetLocalSig; a fresh lambda per iteration defeats caching so emit never stops.
  • Allocator threads each allocate and drop a small SOH array, keeping gen0 collections frequent. Compilation alone does not allocate enough to drive them; the allocator threads are what make it reproduce.

Run under a checked release/10.0 coreclr, workstation GC (DOTNET_gcServer=0 DOTNET_gcConcurrent=0), DOTNET_TieredCompilation=0, small DOTNET_GCgen0size, DOTNET_HeapVerify=1, no DOTNET_GCStress. On 8 cores it faults within minutes. A normal optimized build usually runs to completion; the checked OBJECTREF validation is what surfaces the stale reference at the return site rather than at a later victim-side read.

Request

Please backport both #124303 and its follow-up #124921 to release/10.0 servicing, so the whole resolver-callback surface is converted. It is a silent heap-corruption bug with no application-level workaround: the GC is what relocates the array, so serializing compilation does not close it and raising the gen0 budget only lowers the rate. Backporting GetCodeInfo alone is not enough - the incremental test above shows the crash relocating to GetLocalSig, and ResolveSignature / ResolveSignatureForVarArg (converted only by #124921) share the same held-across-allocation shape.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status
    No status

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions