Adversarial test harness for the .NET DAC (Data Access Component) on Linux. Systematically exercises every method on ISOSDacInterface through ISOSDacInterface16 with valid, null, garbage, and boundary inputs to find crash-inducing bugs.
648 tests covering all DAC interface methods.
Related issue: dotnet/runtime#124640
The .NET DAC (libmscordaccore.so) is the component that diagnostic tools like
SOS, dotnet-dump, and ClrMD use to inspect crash dumps and live processes. It
exposes a set of COM interfaces (ISOSDacInterface through ISOSDacInterface16)
that return information about the CLR's internal state: threads, GC heaps, method
tables, modules, assemblies, and more.
On Windows, the DAC runs inside a structured exception handling (SEH) context that catches access violations and converts them to error HRESULTs. On Linux, there is no SEH -- an access violation becomes a SIGSEGV that kills the entire diagnostic tool process. This means input validation bugs that are silent on Windows become fatal crashes on Linux.
This test suite was built to find those bugs by calling every DAC method with
inputs designed to trigger missing validation: null pointers, garbage addresses,
boundary values like ulong.MaxValue, misaligned pointers, and cross-type
confusion (passing a GC heap address where a module address is expected).
The test harness is entirely standalone with no ClrMD dependency. It implements its own infrastructure from scratch:
dac-tests/
src/
CoreReader/ # Standalone ELF core file reader
ElfReader.cs # ELF header and program header parsing
ElfStructs.cs # ELF64 structure definitions
CoreDumpReader.cs # PT_NOTE parsing (threads, aux vector, mapped files)
MemoryReader.cs # Virtual address to file offset mapping, ReadMemory API
DacInterop/ # COM interop layer for the DAC
ComHelpers.cs # COM vtable construction, QI/AddRef/Release
DataTarget.cs # ICLRDataTarget/2/3 + IXCLRDataTarget3 backed by CoreReader
DacLoader.cs # Loads libmscordaccore.so, calls CLRDataCreateInstance
Interfaces/ # One wrapper file per interface version
DacStructs.cs # All Dacp*Data structs matching dacprivate.h layouts
ISOSDacInterface.cs through ISOSDacInterface16.cs
IXCLRDataProcess.cs
tests/
DacTests/
DacTestFixture.cs # Shared xUnit fixture: loads dump + DAC once
SmokeTests.cs # End-to-end infrastructure validation
ISOSDacInterfaceTests_Group1.cs through Group6.cs # Base interface tests
ISOSDacInterface2Tests.cs through ISOSDacInterface16Tests.cs
CoreDumpReaderTests.cs # Unit tests for the ELF reader
ElfReaderTests.cs
MemoryReaderTests.cs
ComHelpersTests.cs
DataTargetTests.cs
DacStructSizeTests.cs # Validates struct layouts match native sizes
tools/
CrashApp/ # Minimal .NET app that crashes to produce a coredump
generate-coredump.sh # Runs CrashApp with dump collection env vars
run-tests.sh # Test runner with crash dump collection enabled
A standalone ELF core file reader that parses:
- ELF headers -- identifies the file as an ELF core dump, reads program header table location and count
- Program headers -- iterates PT_LOAD segments to build the virtual memory map, PT_NOTE segments for metadata
- PT_NOTE segments -- extracts NT_PRSTATUS (thread register contexts), NT_PRPSINFO (process info), NT_AUXV (auxiliary vector), and NT_FILE (mapped file list with paths)
- Virtual memory mapping -- given a virtual address, finds the PT_LOAD segment containing it and translates to a file offset for reading
This replaces ClrMD's ElfCoreFile/CoredumpReader for test purposes. It is thread-safe (giant lock) and uses BinaryReader/Span for I/O.
A COM interop layer built with ComWrappers and [UnmanagedCallersOnly] static
methods. The key components:
- ComHelpers -- builds COM vtables manually with function pointers, implements QueryInterface/AddRef/Release, and provides ComInterfaceDispatch helpers for getting back to managed state from native callbacks
- DataTarget -- implements the four callback interfaces the DAC calls into:
ICLRDataTarget(ReadVirtual, GetMachineType, GetPointerSize, etc.),ICLRDataTarget2(AllocVirtual/FreeVirtual),ICLRDataTarget3(GetExceptionRecord/Context/ThreadID), andIXCLRDataTarget3(GetMetaData). All backed by the CoreReader. - DacLoader -- uses
NativeLibrary.Loadto loadlibmscordaccore.so, gets theCLRDataCreateInstanceexport, calls it with our DataTarget to getIXCLRDataProcess, then QueryInterfaces for all ISOSDacInterface versions - Interface wrappers -- one file per interface version (ISOSDacInterface
through ISOSDacInterface16 plus IXCLRDataProcess). Each defines the vtable
layout as a struct of
delegate* unmanaged[Stdcall]function pointers and provides thin managed wrappers. Struct definitions in DacStructs.cs match the native layouts fromdacprivate.hexactly.
- CrashApp -- a minimal .NET console app that allocates various object types (strings, arrays, generic types), creates threads with interesting stacks, sets up static fields, then throws an unhandled exception to crash and produce a coredump
- generate-coredump.sh -- configures .NET dump collection via environment
variables (
DOTNET_DbgEnableMiniDump=1,DOTNET_DbgMiniDumpType=4for full dumps,DOTNET_EnableCrashReport=1) and runs CrashApp against the locally built runtime - DacTestFixture -- a shared xUnit class fixture that opens the test coredump, creates the DataTarget, loads the DAC, and QueryInterfaces for all interface versions. All test classes share this single fixture instance so the DAC is loaded once per test run.
- run-tests.sh -- runs
dotnet testwith dump collection enabled so that if a DAC bug crashes the test process, a dump is produced for analysis
Each DAC method is tested with multiple categories of input:
Call the API the way a real diagnostic tool would, using addresses discovered from prior API calls (e.g., get a MethodTable address from GetMethodTableData, pass it to GetMethodTableName). Validates that the method returns expected results.
For every pointer parameter: test with 0 (null), ulong.MaxValue (0xFFFFFFFFFFFFFFFF).
For every length parameter: test with 0 and -1.
For every output pointer: test combinations of null output pointers.
The API must return an error HRESULT (E_INVALIDARG, E_FAIL, etc.) but NOT crash.
For every target-process-pointer parameter:
- Unallocated address:
0xDEAD_0000_BEEF_0000 - Wild pointer: a valid address pointing to unrelated data (e.g., pass a GC heap address where a module address is expected)
- Offset address: a real valid address + 0x16
- Misaligned address: a real valid address + 0x15
- Small address:
0x1000(mapped but not a valid CLR structure)
The function must return an error HRESULT but NOT crash.
When a test crashes the process (SIGSEGV), a dump is produced in tmp/dac/.
The test is annotated with the crashing input, native call stack, and root cause,
then marked as skipped. When a test hangs (100% CPU), a dump is collected via
dotnet-dump collect, analyzed with lldb+SOS to find the spinning thread, and
similarly annotated and skipped.
- Linux x64
- .NET SDK 10.0 or later
- A locally built .NET runtime (for the DAC and test coredump)
-
Build the .NET runtime:
cd /path/to/runtime ./build.sh -s clr -c Release -
Generate a test coredump:
./generate-coredump.sh /path/to/runtime
This produces a coredump in
tmp/dac/using the locally built runtime. -
Run the tests:
./run-tests.sh # Or directly: dotnet test dac-tests.sln
The test fixture auto-discovers the coredump in tmp/dac/ and the DAC from
the runtime's testhost artifacts directory.
Testing found 13 bugs (12 SIGSEGV crashes + 1 hang) across the DAC interface surface:
| Interface | Method | Input | Bug Type |
|---|---|---|---|
| ISOSDacInterface | GetAppDomainName | addr=0 | Null dereference (SIGSEGV) |
| ISOSDacInterface | TraverseVirtCallStubHeap | garbage addr, null callback | Null dereference (SIGSEGV) |
| ISOSDacInterface | TraverseVirtCallStubHeap | valid addr, null callback | Null callback (SIGSEGV) |
| ISOSDacInterface | TraverseModuleMap | valid module, null callback | Null callback (SIGSEGV) |
| ISOSDacInterface | GetRegisterName | idx=0, bufLen=2 | Buffer overflow (SIGSEGV) |
| ISOSDacInterface3 | GetGCGlobalMechanisms | (no addr params) | Stack corruption causing infinite loop |
| ISOSDacInterface7 | GetPendingReJITID | md=MaxValue | Dereferences 0xFFFF...FFFF (SIGSEGV) |
| ISOSDacInterface7 | GetReJITInformation | md=MaxValue | Dereferences 0xFFFF...FFFF (SIGSEGV) |
| ISOSDacInterface7 | GetProfilerModifiedILInformation | md=MaxValue | Dereferences 0xFFFF...FFFF (SIGSEGV) |
| ISOSDacInterface7 | GetMethodsWithProfilerModifiedIL | module=MaxValue | Dereferences 0xFFFF...FFFF (SIGSEGV) |
| ISOSDacInterface8 | GetFinalizationFillPointersSvr | heap=garbage | Bad gc_heap pointer (SIGSEGV) |
| ISOSDacInterface8 | GetFinalizationFillPointersSvr | heap=MaxValue | Bad gc_heap pointer (SIGSEGV) |
| ISOSDacInterface8 | GetAssemblyLoadContext | mt=MaxValue | Bad MethodTable dereference (SIGSEGV) |
-
Missing address validation (8 crashes) -- the DAC takes a CLRDATA_ADDRESS and dereferences it without checking if it points to valid target-process memory. On Windows, SEH catches the AV. On Linux, SIGSEGV kills the process.
-
Missing null callback validation (3 crashes) -- traversal APIs accept a function pointer callback and call through it without null-checking.
-
Buffer overflow (1 crash) -- GetRegisterName writes the full register name string without checking the caller's buffer size.
-
Stack corruption / infinite loop (1 hang) -- GetGCGlobalMechanisms writes 6 size_t values (48 bytes) into the caller's buffer, but the IDL signature is just
size_t*with no count parameter. If the caller provides a buffer smaller than 48 bytes, the write corrupts the stack frame, causing undefined behavior (infinite loop on Linux).
Every method on these interfaces handled all adversarial inputs correctly with no crashes and no hangs:
- ISOSDacInterface2, ISOSDacInterface4, ISOSDacInterface5, ISOSDacInterface6
- ISOSDacInterface9 through ISOSDacInterface16
During fix development, 5 instances of a shadowed variable bug were found in the DAC source. The pattern:
SOSDacEnter(); // expands to: HRESULT hr = S_OK; EX_TRY {
HRESULT hr = S_OK; // SHADOWS the macro's hr
// ... code that sets hr on error ...
SOSDacLeave(); // expands to: } EX_CATCH { ... } return hr;The return hr after SOSDacLeave uses the outer (macro) hr which is always S_OK,
silently swallowing errors. Found in: GetGenerationTable, GetFinalizationFillPointers,
GetGenerationTableSvr, GetFinalizationFillPointersSvr, GetObjectComWrappersData.
CLRDataCreateInstancerequires anICLRDataTargetinterface pointer (viaMarshal.QueryInterface), not a raw IUnknown. Passing IUnknown causes a SIGSEGV because the DAC calls ICLRDataTarget vtable slots (3+) through the IUnknown pointer which only has 3 valid slots.IXCLRDataTarget3inheritsICLRDataTarget2-- its vtable has 17 slots total, not just IUnknown's 3 plus GetMetaData.GetMetaDatahas 9 parameters withmvidasGuid*(IntPtr), not uint. A signature mismatch here causes subtle memory corruption.GetThreadpoolDatareturnsE_NOTIMPLon modern .NET (managed threadpool). This is expected behavior, not a bug.GetAssemblyList(SystemDomain)returnsE_INVALIDARGbecause SystemDomain does not hold assemblies in .NET Core. Also expected.