From 4e6c1836e03b9448d36a8d495e967f1fd31dd290 Mon Sep 17 00:00:00 2001 From: enzok <7831008+enzok@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:20:59 -0400 Subject: [PATCH 1/2] Add loaderlock-settle config option and generic If: conditional action loaderlock-settle: opt-in Sleep(1) yield in LdrGetProcedureAddressForCaller to fix timing race in trojanized sideload DLLs (AxolotlLoader/dui70.dll) where a DllMain bootstrap re-clobbers a dispatch-table slot to -1 mid-resolver. If::[:]:: generic conditional prefix for ActionDispatcher that tests any operand (Src/Dst/register/[mem]/immediate) against unary (ptr/z/nz) or binary (eq/ne/gt/lt/ge/le) predicates before running any wrapped action. --- CAPE/Trace.c | 124 +++++++++++++++++++++++++++++++++++++++++- config.c | 5 ++ config.h | 3 + docs/configuration.md | 1 + hook_misc.c | 8 +++ 5 files changed, 140 insertions(+), 1 deletion(-) diff --git a/CAPE/Trace.c b/CAPE/Trace.c index 62b45602..82d010b4 100644 --- a/CAPE/Trace.c +++ b/CAPE/Trace.c @@ -368,7 +368,7 @@ PVOID GetRegister(PCONTEXT Context, char* RegString) else if (!strnicmp(RegString, "r11", 3)) Register = (PVOID)Context->R11; else if (!strnicmp(RegString, "r12", 3)) - Register = (PVOID)Context->R13; + Register = (PVOID)Context->R12; else if (!strnicmp(RegString, "r13", 3)) Register = (PVOID)Context->R13; else if (!strnicmp(RegString, "r14", 3)) @@ -1132,6 +1132,50 @@ BOOL DoStepOver(PCHAR FunctionName) return FALSE; } +// Resolve one operand token for the 'If:' conditional action to a value. +// Token may be Src/Dst (the breakpointed instruction's operands, dereferenced for memory), +// a register, [reg+off] (dereferenced), or an immediate. $string values are already resolved +// to addresses upstream by ParseOptionLine, so they arrive here as immediates. +PVOID ResolveIfOperand(PCONTEXT Context, _DecodedInst DecodedInstruction, PCHAR Token) +{ + if (!Token || !*Token) + return NULL; + + if (!stricmp(Token, "Src")) + { + PCHAR Comma = strchr(DecodedInstruction.operands.p, ','); + if (Comma) + { + *Comma = 0; + PVOID Value = GetOperand(Context, DecodedInstruction.operands.p); + *Comma = ','; + return Value; + } + return GetOperand(Context, DecodedInstruction.operands.p); + } + if (!stricmp(Token, "Dst")) + { + PCHAR Comma = strchr(DecodedInstruction.operands.p, ','); + if (Comma) + return GetOperand(Context, Comma + 2); + return NULL; + } + if (strchr(Token, '[')) + return GetOperand(Context, Token); + + PVOID Reg = GetRegister(Context, Token); + if (Reg) + return Reg; + + char *endptr; + errno = 0; + unsigned long long Imm = _strtoui64(Token, &endptr, 0); + if (!errno && endptr != Token) + return (PVOID)(DWORD_PTR)Imm; + + return NULL; +} + void ActionDispatcher(struct _EXCEPTION_POINTERS* ExceptionInfo, _DecodedInst DecodedInstruction, PCHAR Action) { // This could be further optimised per action but this is safe at least @@ -1548,6 +1592,84 @@ void ActionDispatcher(struct _EXCEPTION_POINTERS* ExceptionInfo, _DecodedInst De WriteRet(ExceptionInfo->ContextRecord); DebuggerOutput("\nActionDispatcher: ret written.\n"); } + else if (!strnicmp(Action, "If:", 3)) + { + // Generic conditional action: If::[:]: + // lhs/rhs : Src|Dst (this instruction's operands), a register, [reg+off], $string (a VA), or immediate + // op : ptr z nz (unary) | eq ne gt lt ge le (binary) + // action : any existing cape action + its own :param, run only when the condition holds + // If the wrapped action does not itself redirect control flow, the guarded instruction is skipped + // so a flag/register change stands in for it (e.g. a set ZF survives to a following jz). + char Buf[MAX_PATH]; + strncpy(Buf, Action + 3, sizeof(Buf) - 1); + Buf[sizeof(Buf) - 1] = 0; + + PCHAR LhsTok = Buf; + PCHAR OpTok = strchr(LhsTok, ':'); + PCHAR RhsTok = NULL, ActionTok = NULL; + if (OpTok) + { + *OpTok++ = 0; + PCHAR Rest = strchr(OpTok, ':'); + if (Rest) + { + *Rest++ = 0; + BOOL Binary = stricmp(OpTok, "ptr") && stricmp(OpTok, "z") && stricmp(OpTok, "nz"); + if (Binary) + { + PCHAR AfterRhs = strchr(Rest, ':'); + if (AfterRhs) + { + *AfterRhs++ = 0; + RhsTok = Rest; + ActionTok = AfterRhs; + } + } + else + ActionTok = Rest; + } + } + + if (!ActionTok) + DebuggerOutput("ActionDispatcher: If - malformed '%s' (expected If::[:]:).\n", Action); + else + { + ULONG_PTR L = (ULONG_PTR)ResolveIfOperand(ExceptionInfo->ContextRecord, DecodedInstruction, LhsTok); + ULONG_PTR R = RhsTok ? (ULONG_PTR)ResolveIfOperand(ExceptionInfo->ContextRecord, DecodedInstruction, RhsTok) : 0; + BOOL Cond = FALSE; +#ifdef _WIN64 + if (!stricmp(OpTok, "ptr")) Cond = (L > 0x10000 && L < 0x00007FFFFFFFFFFFULL); +#else + if (!stricmp(OpTok, "ptr")) Cond = (L > 0x10000 && L < 0x80000000UL); +#endif + else if (!stricmp(OpTok, "z")) Cond = (L == 0); + else if (!stricmp(OpTok, "nz")) Cond = (L != 0); + else if (!stricmp(OpTok, "eq")) Cond = (L == R); + else if (!stricmp(OpTok, "ne")) Cond = (L != R); + else if (!stricmp(OpTok, "gt")) Cond = (L > R); + else if (!stricmp(OpTok, "lt")) Cond = (L < R); + else if (!stricmp(OpTok, "ge")) Cond = (L >= R); + else if (!stricmp(OpTok, "le")) Cond = (L <= R); + else DebuggerOutput("ActionDispatcher: If - unknown op '%s'.\n", OpTok); + + DebuggerOutput("ActionDispatcher: If %s(0x%p, 0x%p) -> %d, action '%s'.\n", OpTok, (PVOID)L, (PVOID)R, Cond, ActionTok); + if (Cond) + { +#ifdef _WIN64 + QWORD RipBefore = ExceptionInfo->ContextRecord->Rip; +#else + DWORD RipBefore = ExceptionInfo->ContextRecord->Eip; +#endif + ActionDispatcher(ExceptionInfo, DecodedInstruction, ActionTok); +#ifdef _WIN64 + if (ExceptionInfo->ContextRecord->Rip == RipBefore) +#else + if (ExceptionInfo->ContextRecord->Eip == RipBefore) +#endif + SkipInstruction(ExceptionInfo->ContextRecord); + } + } + } else if (!strnicmp(Action, "GoTo", 4)) { if (Target) diff --git a/config.c b/config.c index 4b5ba7bf..7b875917 100644 --- a/config.c +++ b/config.c @@ -1403,6 +1403,11 @@ void parse_config_line(char* line) else DebugOutput("Scans/dumps while loader lock held disabled.\n"); } + else if (!stricmp(key, "loaderlock-settle")) { + g_config.loaderlock_settle = value[0] == '1'; + if (g_config.loaderlock_settle) + DebugOutput("Loader-lock settle (yield in loader hooks) enabled.\n"); + } else if (!stricmp(key, "syscall")) { g_config.syscall = value[0] == '1'; if (g_config.syscall) diff --git a/config.h b/config.h index 2cbf2a54..0cb742b7 100644 --- a/config.h +++ b/config.h @@ -284,6 +284,9 @@ struct _g_config { // Allow scans/dumps with loader lock held int loaderlock_scans; + // Yield in loader hooks while loader lock held (timing fix for trojanized sideload DLLs) + int loaderlock_settle; + // Specify custom trace stepping behavior int stepmode; diff --git a/docs/configuration.md b/docs/configuration.md index 36b7750a..532ebb8e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -124,6 +124,7 @@ They are typically defined in the analysis configuration file (e.g., `config.ini | `base-on-caller` | Boolean | Base breakpoints on new calling regions. | | `file-offsets` | Boolean | Interpret breakpoints as file offsets instead of RVAs. | | `loaderlock` | Boolean | Allow scans/dumps while the Loader Lock is held. | +| `loaderlock-settle` | Boolean | Yield in loader hooks while the Loader Lock is held. Timing fix for trojanized sideload DLLs (e.g. AxolotlLoader) whose DllMain bootstrap races a dispatch-table slot; opt-in per-sample. | | `snaps` | Boolean | Enable Windows Loader Snaps output (LdrSnap). | ## Target Specific diff --git a/hook_misc.c b/hook_misc.c index 3522dd41..011b16a6 100644 --- a/hook_misc.c +++ b/hook_misc.c @@ -288,6 +288,14 @@ HOOKDEF(NTSTATUS, WINAPI, LdrGetProcedureAddressForCaller, ret = 0; } + // Opt-in per-sample via YARA cape_options (loaderlock-settle=1). Trojanized sideload DLLs + // (AxolotlLoader/dui70.dll) crash under monitoring because a reentrant DllMain bootstrap re-clobbers a + // dispatch-table slot back to a -1 sentinel mid-resolver, so the consumer calls slot[0]==-1 (RIP=~0). + // The resolver runs with the loader lock released, so a loader_lock_held() gate never covered it; yield + // on every resolution while active to spread the bootstrap/resolver timing apart and avoid the clobber. + if (g_config.loaderlock_settle) + Sleep(1); + LOQ_ntstatus("system", "opSiP", "ModuleName", get_basename_of_module(ModuleHandle), "ModuleHandle", ModuleHandle, "FunctionName", FunctionName != NULL ? FunctionName->Length : 0, FunctionName != NULL ? FunctionName->Buffer : NULL, "Ordinal", Ordinal, "FunctionAddress", FunctionAddress); From 59929ec254604812393b02da975fdbfa82e566e9 Mon Sep 17 00:00:00 2001 From: enzok <7831008+enzok@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:41:06 -0400 Subject: [PATCH 2/2] Solo: add call stack, thread inspect and batched pointer reads Extend the interactive debugger protocol used by CAPEsolo: - RD reads one pointer from each address in a comma-separated list in a single round trip. Naming indirect calls by their import slot cost one MD per slot (~110ms each), too slow to resolve a window's worth of calls on every break. Unreadable addresses are omitted from the reply, so the caller learns which failed by their absence. - CS walks the call stack from the break context, reporting per frame the return address, frame pointer and the bytes preceding the return address, so the frontend can decode the originating CALL without a round trip per frame. x64 unwinds via RtlLookupFunctionEntry/ RtlVirtualUnwind and falls back to popping a return address off the stack for frames with no unwind data; x86 follows the EBP chain. - TI snapshots another thread's registers, stack window and call stack from a single suspension, so all three views describe the same instant. - Tag requests as ":|" on IP, MD and RD payloads and echo the tag back, so responses are correlated by tag rather than by reply length, which cannot tell a 4-byte pointer read from a 4-byte dump. - SB accepts optional type and size fields for write and read/write data watchpoints; LB decodes R/W and LEN out of DR7 to report each breakpoint's type and width, and now requires a DR7 enable bit as well as a non-zero address so cleared slots and stale addresses are not reported as phantom breakpoints. Also fix InteractiveDebuggerPipe clearing DebuggerCommand before formatting its output: callers pass pointers into that buffer as varargs (the request tag, echoed payloads), so every tagged reply came back with an empty tag and CAPEsolo discarded it as unsolicited. The buffer is now cleared after formatting, immediately before the pipe call. --- CAPE/Solo.c | 447 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 423 insertions(+), 24 deletions(-) diff --git a/CAPE/Solo.c b/CAPE/Solo.c index 1fe47d78..0636f423 100644 --- a/CAPE/Solo.c +++ b/CAPE/Solo.c @@ -36,6 +36,9 @@ along with this program.If not, see . #define MAX_ENTRIES ((BUFFER_SIZE - 16) / sizeof(MBIEntry)) #define PAGE_SIZE 4096 #define OUTPUT_BUFFER_SIZE 2048 +// Addresses served by one RD request. Bounds the reply well inside BUFFER_SIZE; CAPEsolo +// splits a larger set across requests. +#define MAX_READ_ENTRIES 512 #define CHUNKSIZE 16 // Structure for MBI entry @@ -81,6 +84,10 @@ uint32_t GetPageChecksum(HANDLE hProcess, uintptr_t Address); BOOL InteractiveTrace(struct _EXCEPTION_POINTERS* ExceptionInfo); void InitCommands(void); const char* DispatchCommand(struct _EXCEPTION_POINTERS* ExceptionInfo, const char* Command); +const char* HandleCallStack(struct _EXCEPTION_POINTERS* ExceptionInfo, const char* data); +const char* HandleReadPointers(struct _EXCEPTION_POINTERS* ExceptionInfo, const char* data); +const char* HandleThreadInspect(struct _EXCEPTION_POINTERS* ExceptionInfo, const char* data); +const char* FormatRegisters(PCONTEXT Context); void RegisterCommand(const char* name, CmdHandler func); static CONTEXT LastContext; @@ -176,6 +183,59 @@ const char* DispatchCommand(struct _EXCEPTION_POINTERS* ExceptionInfo, const cha } +// Splits a leading ":|" request tag off a command payload, in place, and +// advances *data past it. CAPEsolo correlates responses by this tag instead of guessing +// from the response length, which could not tell a 4-byte pointer read from a 4-byte +// panel dump. An untagged payload yields an empty tag. +static const char* SplitTag(char** data) +{ + char* Cursor = *data; + if (!Cursor || !*Cursor) + return ""; + + char* Rest = strchr(Cursor, '|'); + if (!Rest) + return ""; + + *Rest++ = '\0'; + *data = Rest; + return Cursor; +} + + +// Decodes one debug register's type and length out of DR7. The address in DR0-3 says +// nothing about whether a breakpoint is an execute breakpoint or a data watch, nor how wide +// it is, so listing breakpoints without this cannot tell them apart. +// R/W: 00 execute, 01 write, 10 I/O, 11 read/write. LEN: 00 = 1, 01 = 2, 10 = 8, 11 = 4. +static void DescribeBreakpoint(ULONG_PTR Dr7, int Index, const char** Type, int* Size) +{ + unsigned int Rw = (unsigned int)((Dr7 >> (16 + Index * 4)) & 0x3); + unsigned int Len = (unsigned int)((Dr7 >> (18 + Index * 4)) & 0x3); + + switch (Rw) + { + case 1: *Type = "w"; break; + case 2: *Type = "io"; break; + case 3: *Type = "rw"; break; + default: *Type = "x"; break; + } + + switch (Len) + { + case 1: *Size = 2; break; + case 2: *Size = 8; break; + case 3: *Size = 4; break; + default: *Size = 1; break; + } +} + +// Whether the local or global enable bit for `Index` is set in DR7. +static BOOL BreakpointEnabled(ULONG_PTR Dr7, int Index) +{ + return (Dr7 >> (Index * 2)) & 0x3 ? TRUE : FALSE; +} + + static BOOL ParseHex(const char* input, ULONG_PTR* output) { int base = 16; @@ -211,7 +271,6 @@ char* InteractiveDebuggerPipe(_In_ LPCTSTR lpOutputString, ...) memset(DebuggerLine, 0, sizeof(DebuggerLine)); memset(TempBuffer, 0, sizeof(TempBuffer)); - memset(DebuggerCommand, 0, sizeof(DebuggerCommand)); _vsnprintf_s(TempBuffer, BUFFER_SIZE, _TRUNCATE, lpOutputString, args); _snprintf_s(DebuggerLine, BUFFER_SIZE, _TRUNCATE, "BREAK:%s", TempBuffer); @@ -227,6 +286,13 @@ char* InteractiveDebuggerPipe(_In_ LPCTSTR lpOutputString, ...) int Length = (int)strlen(DebuggerLine); + // Cleared only now, not before the format above: DebuggerCommand still holds the command + // being handled, and callers pass pointers into it as varargs (HandleInstructionPage and + // HandleMemoryDump pass the request tag SplitTag carved out of it, error paths echo the + // payload). Clearing it first blanked those %s arguments, so every tagged reply came back + // with an empty tag and CAPEsolo discarded it as unsolicited. + memset(DebuggerCommand, 0, sizeof(DebuggerCommand)); + BOOL Success = CallNamedPipe(SOLO_PIPE, DebuggerLine, Length, DebuggerCommand, BUFFER_SIZE, (unsigned long*)&BytesRead, NMPWAIT_WAIT_FOREVER); DWORD Error = GetLastError(); @@ -249,7 +315,9 @@ char* InteractiveDebuggerPipe(_In_ LPCTSTR lpOutputString, ...) return DebuggerCommand; } -char* OutputRegisters(PCONTEXT Context) +// Formats into a static buffer without sending, so a caller composing a larger payload +// (thread inspection) can reuse it. OutputRegisters stays the send-it-now wrapper. +const char* FormatRegisters(PCONTEXT Context) { static char OutputBuffer[OUTPUT_BUFFER_SIZE]; memset(OutputBuffer, 0, sizeof(OutputBuffer)); @@ -359,7 +427,12 @@ char* OutputRegisters(PCONTEXT Context) } #endif - return InteractiveDebuggerPipe("%s\n", OutputBuffer); + return OutputBuffer; +} + +char* OutputRegisters(PCONTEXT Context) +{ + return InteractiveDebuggerPipe("%s\n", FormatRegisters(Context)); } @@ -657,16 +730,18 @@ const char* HandleInstructionPage(struct _EXCEPTION_POINTERS* ExceptionInfo, con SIZE_T rd = 0; unsigned char probe = 0; HANDLE DebuggerProcessHandle = GetCurrentProcess(); + char* Payload = (char*)data; + const char* Tag = SplitTag(&Payload); - if (data && *data) { - if (!ParseHex(data, &RequestedAddr)) + if (Payload && *Payload) { + if (!ParseHex(Payload, &RequestedAddr)) { - return InteractiveDebuggerPipe("Failed with invalid instruction address: %s", data); + return InteractiveDebuggerPipe("Failed with invalid instruction address: %s", Payload); } if (!ReadProcessMemory(DebuggerProcessHandle, (LPCVOID)RequestedAddr, &probe, 1, &rd) || rd != 1) { - return InteractiveDebuggerPipe("%p|UNREADABLE", (PVOID)(RequestedAddr & ~((ULONG_PTR)PAGE_SIZE - 1))); + return InteractiveDebuggerPipe("%p|%s|UNREADABLE", (PVOID)(RequestedAddr & ~((ULONG_PTR)PAGE_SIZE - 1)), Tag); } } @@ -674,13 +749,13 @@ const char* HandleInstructionPage(struct _EXCEPTION_POINTERS* ExceptionInfo, con char* InstructionPage = RetrievePage(DebuggerProcessHandle, RequestedAddr, &PageBase); if (InstructionPage) { - const char* Command = InteractiveDebuggerPipe("%p|%s", (PVOID)PageBase, InstructionPage); + const char* Command = InteractiveDebuggerPipe("%p|%s|%s", (PVOID)PageBase, Tag, InstructionPage); free(InstructionPage); return Command; } else { - return InteractiveDebuggerPipe("%p|NODATA", (PVOID)PageBase); + return InteractiveDebuggerPipe("%p|%s|NODATA", (PVOID)PageBase, Tag); } } @@ -816,14 +891,16 @@ const char* HandleMemoryDump(struct _EXCEPTION_POINTERS* ExceptionInfo, const ch SIZE_T BytesRead = 0; unsigned char Probe = 0; HANDLE ProcessHandle = GetCurrentProcess(); + char* Payload = (char*)data; + const char* Tag = SplitTag(&Payload); - if (data && *data) + if (Payload && *Payload) { - char* SizeSep = strchr(data, '|'); + char* SizeSep = strchr(Payload, '|'); if (SizeSep) *SizeSep++ = '\0'; - if (!ParseHex(data, &RequestedAddr)) - return InteractiveDebuggerPipe("Failed with invalid dump address: %s\n", data); + if (!ParseHex(Payload, &RequestedAddr)) + return InteractiveDebuggerPipe("Failed with invalid dump address: %s\n", Payload); if (SizeSep && *SizeSep) { @@ -840,14 +917,14 @@ const char* HandleMemoryDump(struct _EXCEPTION_POINTERS* ExceptionInfo, const ch if (!ReadProcessMemory(ProcessHandle, (LPCVOID)RequestedAddr, Buffer, RequestedSize, &BytesRead) || BytesRead != RequestedSize) { free(Buffer); - return InteractiveDebuggerPipe("0x%p|Failed with unreadable memory\n", (PVOID)RequestedAddr); + return InteractiveDebuggerPipe("0x%p|%s|Failed with unreadable memory\n", (PVOID)RequestedAddr, Tag); } char* HexOutput = (char*)malloc(RequestedSize * 2 + 1); if (!HexOutput) { free(Buffer); - return InteractiveDebuggerPipe("0x%p|Failed with hex formatting.\n", (PVOID)RequestedAddr); + return InteractiveDebuggerPipe("0x%p|%s|Failed with hex formatting.\n", (PVOID)RequestedAddr, Tag); } for (SIZE_T I = 0; I < RequestedSize; ++I) @@ -855,7 +932,7 @@ const char* HandleMemoryDump(struct _EXCEPTION_POINTERS* ExceptionInfo, const ch sprintf(HexOutput + I * 2, "%02X", Buffer[I]); } - const char* Command = InteractiveDebuggerPipe("0x%p|%s\n", (PVOID)RequestedAddr, HexOutput); + const char* Command = InteractiveDebuggerPipe("0x%p|%s|%s\n", (PVOID)RequestedAddr, Tag, HexOutput); free(HexOutput); free(Buffer); return Command; @@ -863,14 +940,14 @@ const char* HandleMemoryDump(struct _EXCEPTION_POINTERS* ExceptionInfo, const ch if (!ReadProcessMemory(ProcessHandle, (LPCVOID)RequestedAddr, &Probe, 1, &BytesRead) || BytesRead != 1) { - return InteractiveDebuggerPipe("0x%p|Failed with unreadable dump address\n", (PVOID)RequestedAddr); + return InteractiveDebuggerPipe("0x%p|%s|Failed with unreadable dump address\n", (PVOID)RequestedAddr, Tag); } } char* MemDump = DumpMemoryView(ProcessHandle, ExceptionInfo->ContextRecord, RequestedAddr, MAX_LINES); if (MemDump) { - const char* Command = InteractiveDebuggerPipe("0x%p|%s\n", (PVOID)RequestedAddr, MemDump); + const char* Command = InteractiveDebuggerPipe("0x%p|%s|%s\n", (PVOID)RequestedAddr, Tag, MemDump); free(MemDump); return Command; } @@ -879,6 +956,66 @@ const char* HandleMemoryDump(struct _EXCEPTION_POINTERS* ExceptionInfo, const ch } +// Reads one pointer from each of a comma-separated list of addresses, in a single round trip. +// +// CAPEsolo names indirect calls in the disassembly view by reading the import slot each one +// goes through. Doing that with one MD per slot cost ~110ms each - the command loop below +// sleeps 100ms between commands - so a window's worth of calls took seconds and could not be +// resolved on every break. Batching makes it one reply. +// +// Addresses that cannot be read are left out of the reply rather than given a sentinel, so +// the caller learns which failed by their absence and nothing has to be parsed to find out. +const char* HandleReadPointers(struct _EXCEPTION_POINTERS* ExceptionInfo, const char* data) +{ + HANDLE ProcessHandle = GetCurrentProcess(); + char* Payload = (char*)data; + const char* Tag = SplitTag(&Payload); + + if (!Payload || !*Payload) + return InteractiveDebuggerPipe("Failed with no addresses to read.\n"); + + // Per entry: two pointers as %p, a comma between them and a separator before the next. + size_t Cap = MAX_READ_ENTRIES * (sizeof(PVOID) * 4 + 4) + 1; + char* Output = (char*)malloc(Cap); + if (!Output) + return InteractiveDebuggerPipe("Failed with memory allocation.\n"); + + *Output = '\0'; + int Count = 0; + int Offset = 0; + char* Cursor = Payload; + + while (Cursor && *Cursor && Count < MAX_READ_ENTRIES) + { + // ParseHex rejects trailing input, so each address has to be terminated in place + // before it is parsed. + char* Next = strchr(Cursor, ','); + if (Next) + *Next++ = '\0'; + + ULONG_PTR Address = 0; + if (ParseHex(Cursor, &Address)) + { + ULONG_PTR Value = 0; + SIZE_T BytesRead = 0; + + if (ReadProcessMemory(ProcessHandle, (LPCVOID)Address, &Value, sizeof(Value), &BytesRead) + && BytesRead == sizeof(Value)) + { + Offset += sprintf(Output + Offset, "%s%p,%p", Count ? "|" : "", (PVOID)Address, (PVOID)Value); + Count++; + } + } + + Cursor = Next; + } + + const char* Command = InteractiveDebuggerPipe("%s|%s\n", Tag, Output); + free(Output); + return Command; +} + + const char* HandleStackView(struct _EXCEPTION_POINTERS* ExceptionInfo, const char* data) { HANDLE ProcessHandle = GetCurrentProcess(); @@ -894,11 +1031,221 @@ const char* HandleStackView(struct _EXCEPTION_POINTERS* ExceptionInfo, const cha return InteractiveDebuggerPipe("Failed to dump stack view.\n"); } +// Walks the call stack from the break context and reports one entry per frame as +// "index,returnAddress,framePointer,callSiteBytes", joined by '|'. +// +// x64 uses the unwind data via RtlLookupFunctionEntry/RtlVirtualUnwind, the same approach as +// our_stackwalk in hooking_64.c, falling back to popping a return address off the stack for +// frames with no unwind info - which is what shellcode and hand-written stubs look like. +// x86 has no unwind tables, so it follows the EBP chain. +// +// callSiteBytes is up to CALLSITE_BYTES of memory ending at the return address, so the +// frontend can decode backwards to find the CALL that made the frame without a round trip +// per frame. Frames are best effort: anything unreadable ends the walk and what was found +// so far is returned, rather than losing the whole stack to one bad frame. +#define MAX_STACK_FRAMES 32 +#define CALLSITE_BYTES 16 + +static BOOL ReadPointer(HANDLE ProcessHandle, ULONG_PTR Address, ULONG_PTR* Value) +{ + SIZE_T BytesRead = 0; + return ReadProcessMemory(ProcessHandle, (LPCVOID)Address, Value, sizeof(*Value), &BytesRead) + && BytesRead == sizeof(*Value); +} + +static int AppendFrame(char* Output, int Offset, int Index, ULONG_PTR ReturnAddress, ULONG_PTR FramePointer) +{ + HANDLE ProcessHandle = GetCurrentProcess(); + unsigned char Bytes[CALLSITE_BYTES]; + SIZE_T BytesRead = 0; + int Written = sprintf(Output + Offset, "%d,%p,%p,", Index, (PVOID)ReturnAddress, (PVOID)FramePointer); + + // The call instruction ends where the frame returns to, so read backwards from there. + if (ReturnAddress > CALLSITE_BYTES + && ReadProcessMemory(ProcessHandle, (LPCVOID)(ReturnAddress - CALLSITE_BYTES), Bytes, CALLSITE_BYTES, &BytesRead) + && BytesRead == CALLSITE_BYTES) + { + for (SIZE_T i = 0; i < CALLSITE_BYTES; ++i) + Written += sprintf(Output + Offset + Written, "%02X", Bytes[i]); + } + + Written += sprintf(Output + Offset + Written, "|"); + return Written; +} + +// Walks frames from an arbitrary context into Output, returning the frame count. Split out +// of HandleCallStack so a suspended thread's captured context can be walked the same way as +// the break context. +static int WalkCallStack(PCONTEXT StartContext, char* Output, size_t BufSize) +{ + HANDLE ProcessHandle = GetCurrentProcess(); + int Frames = 0; + int Offset = 0; + + (void)BufSize; + __try + { +#ifdef _WIN64 + CONTEXT Context = *StartContext; + while (Frames < MAX_STACK_FRAMES && Context.Rip) + { + DWORD64 ImageBase = 0; + PVOID HandlerData = NULL; + ULONG_PTR EstablisherFrame = 0; + KNONVOLATILE_CONTEXT_POINTERS NvContext; + PRUNTIME_FUNCTION RunFunction = RtlLookupFunctionEntry(Context.Rip, &ImageBase, NULL); + + Offset += AppendFrame(Output, Offset, Frames, (ULONG_PTR)Context.Rip, (ULONG_PTR)Context.Rsp); + Frames++; + + memset(&NvContext, 0, sizeof(NvContext)); + if (RunFunction == NULL) + { + // No unwind data: treat the top of the stack as a return address. + ULONG_PTR ReturnAddress = 0; + if (!ReadPointer(ProcessHandle, (ULONG_PTR)Context.Rsp, &ReturnAddress) || !ReturnAddress) + break; + + Context.Rip = ReturnAddress; + Context.Rsp += sizeof(ULONG_PTR); + } + else + { + RtlVirtualUnwind(UNW_FLAG_NHANDLER, ImageBase, Context.Rip, RunFunction, &Context, + &HandlerData, &EstablisherFrame, &NvContext); + } + } +#else + ULONG_PTR Frame = (ULONG_PTR)StartContext->Ebp; + + Offset += AppendFrame(Output, Offset, Frames, (ULONG_PTR)StartContext->Eip, (ULONG_PTR)StartContext->Esp); + Frames++; + + while (Frames < MAX_STACK_FRAMES && Frame) + { + ULONG_PTR ReturnAddress = 0; + ULONG_PTR NextFrame = 0; + if (!ReadPointer(ProcessHandle, Frame + sizeof(ULONG_PTR), &ReturnAddress) || !ReturnAddress) + break; + + Offset += AppendFrame(Output, Offset, Frames, ReturnAddress, Frame); + Frames++; + + // The chain must ascend, or a corrupt or hostile frame pointer loops forever. + if (!ReadPointer(ProcessHandle, Frame, &NextFrame) || NextFrame <= Frame) + break; + + Frame = NextFrame; + } +#endif + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + // Unwinding can fault on non-standard stacks; keep whatever was resolved. + } + + if (Offset > 0 && Output[Offset - 1] == '|') + Output[Offset - 1] = 0; + + return Frames; +} + +const char* HandleCallStack(struct _EXCEPTION_POINTERS* ExceptionInfo, const char* data) +{ + size_t BufSize = MAX_STACK_FRAMES * (48 + CALLSITE_BYTES * 2) + 1; + char* Output = (char*)malloc(BufSize); + if (!Output) + return InteractiveDebuggerPipe("Failed to allocate memory.\n"); + + if (!WalkCallStack(ExceptionInfo->ContextRecord, Output, BufSize)) + { + free(Output); + return InteractiveDebuggerPipe("Failed to walk the call stack.\n"); + } + + const char* Command = InteractiveDebuggerPipe("%s\n", Output); + free(Output); + return Command; +} + +// Snapshots another thread: registers, stack window and call stack from one suspension, so +// the three views describe the same instant. Other threads keep running during a break, so +// reading a live context would give a torn picture. +// +// The thread is resumed before anything is formatted - it is held only for GetThreadContext. +const char* HandleThreadInspect(struct _EXCEPTION_POINTERS* ExceptionInfo, const char* data) +{ + DWORD ThreadId = 0; + CONTEXT Context; + HANDLE ThreadHandle = NULL; + char* Frames = NULL; + char* StackView = NULL; + const char* Command = NULL; + size_t FramesSize = MAX_STACK_FRAMES * (48 + CALLSITE_BYTES * 2) + 1; + + if (!data || !*data) + return InteractiveDebuggerPipe("Failed with missing thread id.\n"); + + ThreadId = (DWORD)strtoul(data, NULL, 0); + if (!ThreadId) + return InteractiveDebuggerPipe("Failed with invalid thread id: %s\n", data); + + if (ThreadId == GetCurrentThreadId()) + return InteractiveDebuggerPipe("Failed: thread %lu is the halted thread.\n", ThreadId); + + ThreadHandle = OpenThread(THREAD_GET_CONTEXT | THREAD_SUSPEND_RESUME | THREAD_QUERY_INFORMATION, FALSE, ThreadId); + if (!ThreadHandle) + return InteractiveDebuggerPipe("Failed to open thread %lu.\n", ThreadId); + + memset(&Context, 0, sizeof(Context)); + Context.ContextFlags = CONTEXT_FULL; + + if (SuspendThread(ThreadHandle) == (DWORD)-1) + { + CloseHandle(ThreadHandle); + return InteractiveDebuggerPipe("Failed to suspend thread %lu.\n", ThreadId); + } + + if (!GetThreadContext(ThreadHandle, &Context)) + { + ResumeThread(ThreadHandle); + CloseHandle(ThreadHandle); + return InteractiveDebuggerPipe("Failed to read the context of thread %lu.\n", ThreadId); + } + + ResumeThread(ThreadHandle); + CloseHandle(ThreadHandle); + + Frames = (char*)malloc(FramesSize); + if (Frames) + { + memset(Frames, 0, FramesSize); + WalkCallStack(&Context, Frames, FramesSize); + } + + StackView = GetStackWindowView(GetCurrentProcess(), &Context, MAX_LINES); + + // Section markers rather than another delimiter: the register dump, the stack view and + // the frame list each already use commas and pipes internally. + Command = InteractiveDebuggerPipe("[TID]\n%lu\n[REGS]\n%s\n[STACK]\n%s\n[FRAMES]\n%s\n", + ThreadId, + FormatRegisters(&Context), + StackView ? StackView : "", + Frames ? Frames : ""); + + if (StackView) + free(StackView); + if (Frames) + free(Frames); + + return Command; +} + const char* HandleListBreakpoints(struct _EXCEPTION_POINTERS* ExceptionInfo, const char* data) { CONTEXT* ctx = ExceptionInfo->ContextRecord; int len = 0; - const int MaxPerLine = 32; + const int MaxPerLine = 48; const int MaxEntries = 4; @@ -917,11 +1264,18 @@ const char* HandleListBreakpoints(struct _EXCEPTION_POINTERS* ExceptionInfo, con (ULONG_PTR)ctx->Dr3 }; + ULONG_PTR Dr7 = (ULONG_PTR)ctx->Dr7; for (int i = 0; i < 4; ++i) { - if (dr[i]) + // Require both: DR7's enable bit, because a cleared breakpoint can leave a stale + // address behind in DR0-3, and a non-zero address, because an enable bit can be + // set on a register that holds none. Either test alone reports phantoms. + if (BreakpointEnabled(Dr7, i) && dr[i]) { - len += sprintf(Output + len, "%d,%p|", i, (PVOID)dr[i]); + const char* Type = "x"; + int Size = 1; + DescribeBreakpoint(Dr7, i, &Type, &Size); + len += sprintf(Output + len, "%d,%p,%s,%d|", i, (PVOID)dr[i], Type, Size); } } @@ -1174,9 +1528,51 @@ const char* HandleSetBreakpoint(struct _EXCEPTION_POINTERS* ExceptionInfo, const *Sep = '\0'; const char* RegStr = Input; - const char* AddrStr = Sep + 1; + char* AddrStr = Sep + 1; int Register = -1; + // Optional trailing fields: |[|[|]]. Absent means an execute + // breakpoint, which is what every caller sent before data watches existed. + char* TypeStr = strchr(AddrStr, '|'); + char* SizeStr = NULL; + if (TypeStr) + { + *TypeStr++ = '\0'; + SizeStr = strchr(TypeStr, '|'); + if (SizeStr) *SizeStr++ = '\0'; + } + + DWORD BpType = BP_EXEC; + int BpSize = 0; + if (TypeStr && *TypeStr) + { + if (!strcmp(TypeStr, "x")) + BpType = BP_EXEC; + else if (!strcmp(TypeStr, "w")) + BpType = BP_WRITE; + else if (!strcmp(TypeStr, "rw")) + BpType = BP_READWRITE; + else + return InteractiveDebuggerPipe("Failed with invalid breakpoint type: %s\n", TypeStr); + } + + if (SizeStr && *SizeStr) + { + char* SizeEnd = NULL; + long ParsedSize = strtol(SizeStr, &SizeEnd, 0); + if (SizeEnd == SizeStr || *SizeEnd != '\0' || + (ParsedSize != 1 && ParsedSize != 2 && ParsedSize != 4 && ParsedSize != 8)) + return InteractiveDebuggerPipe("Failed with invalid breakpoint size: %s\n", SizeStr); + + BpSize = (int)ParsedSize; + } + + // A data watch needs a width; execute breakpoints must keep LEN at 1 byte. + if (BpType != BP_EXEC && BpSize == 0) + BpSize = 1; + else if (BpType == BP_EXEC) + BpSize = 0; + if (strcmp(RegStr, "next") != 0) { char* Endp = NULL; @@ -1195,7 +1591,7 @@ const char* HandleSetBreakpoint(struct _EXCEPTION_POINTERS* ExceptionInfo, const ULONG_PTR BpAddress = (ULONG_PTR)addr; if (Register == -1) { - if (ContextSetNextAvailableBreakpoint(ExceptionInfo->ContextRecord, &StepOverRegister, 0, (BYTE*)BpAddress, BP_EXEC, 0, InteractiveBreakpointCallback)) + if (ContextSetNextAvailableBreakpoint(ExceptionInfo->ContextRecord, &StepOverRegister, BpSize, (BYTE*)BpAddress, BpType, 0, InteractiveBreakpointCallback)) { return InteractiveDebuggerPipe("Breakpoint %d set at 0x%p\n", StepOverRegister, (PVOID)BpAddress); } @@ -1206,7 +1602,7 @@ const char* HandleSetBreakpoint(struct _EXCEPTION_POINTERS* ExceptionInfo, const } else { - if (ContextSetThreadBreakpoint(ExceptionInfo->ContextRecord, Register, 0, (BYTE*)BpAddress, BP_EXEC, 0, InteractiveBreakpointCallback)) + if (ContextSetThreadBreakpoint(ExceptionInfo->ContextRecord, Register, BpSize, (BYTE*)BpAddress, BpType, 0, InteractiveBreakpointCallback)) { return InteractiveDebuggerPipe("Breakpoint %d set at 0x%p\n", Register, (PVOID)BpAddress); } @@ -1571,7 +1967,10 @@ void InitCommands(void) RegisterCommand("OU", HandleStepOut); RegisterCommand("SK", HandleStackView); RegisterCommand("MD", HandleMemoryDump); + RegisterCommand("RD", HandleReadPointers); RegisterCommand("LB", HandleListBreakpoints); + RegisterCommand("CS", HandleCallStack); + RegisterCommand("TI", HandleThreadInspect); RegisterCommand("FL", HandleFlagMod); RegisterCommand("RU", HandleRunUntil); RegisterCommand("TH", HandleListThreads);