Skip to content

[cDAC] Make AMD64 unwinding breakpoint-aware - #15

Open
anurag6569201 wants to merge 1 commit into
qa/agent-dotnet-runtime/pr-15-131896/basefrom
qa/agent-dotnet-runtime/pr-15-131896/head
Open

anurag6569201 wants to merge 1 commit into
qa/agent-dotnet-runtime/pr-15-131896/basefrom
qa/agent-dotnet-runtime/pr-15-131896/head

Conversation

@anurag6569201

Copy link
Copy Markdown

Fixes dotnet#112273.

The AMD64 unwinder disassembles the current instruction stream to detect and emulate version 1 epilogues. When a debugger breakpoint replaces an epilogue instruction with int3, cDAC could fail to recognize the epilogue and produce an incomplete stack walk.

This change:

  • exposes the AMD64 debugger patch table through data descriptors
  • adds an on-demand debugger contract API that returns the original byte for patched addresses
  • uses the original instruction byte during AMD64 epilogue decoding
  • caches active patches until the target is flushed
  • documents and tests the new descriptors and contract behavior

The end-to-end regression test added internally.

Validation:

  • build.cmd tools.cdac -rc release
  • 129 focused DebuggerTests
  • generated data-contract documentation check
  • MDbg epilogue stack-walk regression with legacy DAC and cDAC
  • existing MDbg breakpoint-patching test with legacy DAC and cDAC

Note

This PR description was generated with GitHub Copilot.

Source merge-base: ca4ed7d4a265c32e5240863c6b8ff45121339cc4
Source head: d8a4f9c796cf05e71826405c581e3c74e8c101f1

@shipwright-agent

Copy link
Copy Markdown

✅ Shipwright · Approve

Recommendation: approve PR #15 · Tier T2
Checks: 0 total · 0 needing attention

Next step: ready to merge.

Findings (5)

  • HIGH The test ReadInstructionByte_RefreshesCachedPatchesAfterFlush writes the new opcode at patchTable.Entries.Value + opcodeOffset, which only works because the first entry is the only · src/native/managed/cdac/tests/UnitTests/DebuggerTests.cs:700
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • HIGH ReadPatches() iterates patchTable.Count entries and reads each DebuggerControllerPatch from target memory without validating Count against the actual backing array size or checking · src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/Debugger/Debugger_1.cs:196
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • HIGH ReadInstructionByte catches VirtualReadException around patch metadata lookup and silently falls back to reading the instruction directly. · src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/Debugger/Debugger_1.cs:166
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • LOW ReadPatches() uses patch.Opcode.Value != 0 to decide whether to include a patch. · src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/Debugger/Debugger_1.cs:202
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • LOW The test 'ReadInstructionByte_RefreshesCachedPatchesAfterFlush' writes the new opcode at 'patchTable.Entries.Value + opcodeOffset', but the opcode field is at 'opcodeOffset' within · src/native/managed/cdac/tests/UnitTests/DebuggerTests.cs:700
    • Fix: Fix the review finding before release.

Fireworks usage: 20,516 input · 802 output · 21,318 total tokens · $0.0050 · 14s · 0 fix iteration(s)

Open the Shipwright check for full evidence and the audit bundle. Use /shipwright rerun to verify again.

TargetPointer patchTablePointerAddress = target.ReadGlobalPointer(Constants.Globals.DebuggerPatchTable);
TargetPointer patchTableAddress = target.ReadPointer(patchTablePointerAddress);
Data.DebuggerPatchTable patchTable = target.ProcessedData.GetOrAdd<Data.DebuggerPatchTable>(patchTableAddress);
int opcodeOffset = target.GetTypeInfo(DataType.DebuggerControllerPatch).Fields["Opcode"].Offset;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shipwright · HIGH

The test ReadInstructionByte_RefreshesCachedPatchesAfterFlush writes the new opcode at patchTable.Entries.Value + opcodeOffset, which only works because the first entry is the only

Impact: The test ReadInstructionByte_RefreshesCachedPatchesAfterFlush writes the new opcode at patchTable.Entries.Value + opcodeOffset, which only works because the first entry is the only entry and the opcode field is at that offset within the first entry. A future test with multiple entries or a different layout would silently write to the wrong location. The test should compute the entry address as Entries + i * patchSiz…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

return [];

Data.DebuggerPatchTable patchTable = _target.ProcessedData.GetOrAdd<Data.DebuggerPatchTable>(patchTableAddress);
if (patchTable.Entries == TargetPointer.Null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shipwright · HIGH

ReadPatches() iterates patchTable.Count entries and reads each DebuggerControllerPatch from target memory without validating Count against the actual backing array size or checking

Impact: ReadPatches() iterates patchTable.Count entries and reads each DebuggerControllerPatch from target memory without validating Count against the actual backing array size or checking for integer overflow in patchAddress = Entries + i * patchSize. A corrupt or malicious dump with a huge Count (uint32 max) will cause an unbounded loop and repeated target reads, potentially hanging or exhausting resources. The contract t…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

return HijackKind.None;
}

byte IDebugger.ReadInstructionByte(TargetPointer address)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shipwright · HIGH

ReadInstructionByte catches VirtualReadException around patch metadata lookup and silently falls back to reading the instruction directly.

Impact: ReadInstructionByte catches VirtualReadException around patch metadata lookup and silently falls back to reading the instruction directly. If the patch table is unreadable due to corruption, the unwinder will decode the breakpoint opcode (0xCC) as a real instruction, producing incorrect stack walks. This is a correctness/security boundary issue: the fallback masks a failure that should be surfaced or at least logged…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

Dictionary<TargetPointer, byte> patches = [];
uint patchSize = Data.DebuggerControllerPatch.GetSize(_target);

for (uint i = 0; i < patchTable.Count; i++)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shipwright · LOW

ReadPatches() uses patch.Opcode.Value != 0 to decide whether to include a patch.

Impact: ReadPatches() uses patch.Opcode.Value != 0 to decide whether to include a patch. If a legitimate original opcode is 0x00 (e.g., add [rax], al), the patch will be skipped and the unwinder will read the breakpoint byte instead. The condition should be based on CodeAddress != Null alone, or the contract should document that opcode 0 is never a valid patched instruction.

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

TargetPointer patchTablePointerAddress = target.ReadGlobalPointer(Constants.Globals.DebuggerPatchTable);
TargetPointer patchTableAddress = target.ReadPointer(patchTablePointerAddress);
Data.DebuggerPatchTable patchTable = target.ProcessedData.GetOrAdd<Data.DebuggerPatchTable>(patchTableAddress);
int opcodeOffset = target.GetTypeInfo(DataType.DebuggerControllerPatch).Fields["Opcode"].Offset;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shipwright · LOW

The test 'ReadInstructionByte_RefreshesCachedPatchesAfterFlush' writes the new opcode at 'patchTable.Entries.Value + opcodeOffset', but the opcode field is at 'opcodeOffset' within

Impact: The test 'ReadInstructionByte_RefreshesCachedPatchesAfterFlush' writes the new opcode at 'patchTable.Entries.Value + opcodeOffset', but the opcode field is at 'opcodeOffset' within each 'DebuggerControllerPatch' entry. Since the first entry starts at 'Entries', this happens to target the first entry's opcode, which is the only entry in the test. This is fragile but not a functional failure for the current test. Howe…

Suggested fix: Fix the review finding before release.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant