-
Undetected Is Not Invisible: Looking for VEH Debuggers with Rust
Summary: GhostDebug leaves the usual Windows debugger checks untouched. I pulled apart how it works, then wrote a small Rust tool to watch for the state it does change: VEH registration, loaded modules, executable private memory, and patched INT3 bytes.
1. How This Started
I found GhostDebug, an open-source Windows x64 debugger by VollRagm, while reading about VEH-based debugging. It does not attach through the normal Windows debugging API, which made it a good target for a detection experiment.
Instead, GhostDebug injects a native DLL into the target. That DLL registers a Vectored Exception Handler (VEH), places INT3 breakpoints, uses the CPU trap flag for single stepping, and talks to the command-line client through a named pipe.
It is a different model from the debuggers I had used before:
Traditional debugger
VEH debugger
Usually controls the program from another process
Runs the important debugging logic inside the target
Uses APIs such as DebugActiveProcess and WaitForDebugEvent
Handles breakpoint and single-step exceptions through VEH
Creates conventional Windows debugging state
Does not need a debug port or debug object
Can often be found with familiar anti-debugging checks
Can leave those checks looking clean
VollRagm documents the design in a four-part series covering theory, detection, implementation, and evaluation. My question was narrower: if the classic checks stay clean, what changes inside the target?
When I attached GhostDebug, IsDebuggerPresent() still returned false. The PEB BeingDebugged field was not set, and there was no normal debug object.
The process was still being debugged, so I changed the question from:
Is Windows telling me that a debugger is attached?
to:
What changed inside the process when debugging started?
I am not generalizing one sample into a detector for every VEH debugger. I treated the project as a lab notebook: what I checked, what worked on my VM, and where the method breaks down.
What I wanted to learn
Why do the usual checks miss GhostDebug?
Which parts of its design are still visible?
Which observations are generic and which are just GhostDebug fingerprints?
How much can I detect from one scan?
How much stronger does detection become if I have a baseline from before attachment?
Scope
I kept the test setup narrow:
Windows 11 25H2, build 26200.8246;
native 64-bit processes;
a 64-bit detector;
user-mode VEH debuggers;
GhostDebug as the main sample.
Everything below assumes that setup. I would not ship this as an endpoint detector.
2. The Windows Pieces I Needed to Understand
2.1 Traditional Windows debugging
Debuggers such as WinDbg and x64dbg normally use the Windows debugging API. Windows keeps state for this relationship and sends events to the debugger. That gives programs several well-known checks:
IsDebuggerPresent;
CheckRemoteDebuggerPresent;
the PEB BeingDebugged field;
NtQueryInformationProcess(ProcessDebugPort);
NtQueryInformationProcess(ProcessDebugObjectHandle);
exception and timing behavior.
They are useful controls, but they describe the traditional debugger model.
2.2 Vectored Exception Handling
Vectored Exception Handling is a Windows mechanism that lets a process register callbacks for exceptions. The callbacks are not tied to the current stack frame. If a conventional debugger is attached, it receives the first-chance exception notification first. If the exception continues into the process, VEH runs before stack unwinding and frame-based Structured Exception Handling. VEH is not suspicious by itself: crash reporters, runtimes, instrumentation tools, anti-cheat products, and security software can all use it legitimately.
2.3 Turning VEH into a small debugger
A software breakpoint replaces an instruction byte with INT3 (0xCC). When the processor reaches it, Windows raises EXCEPTION_BREAKPOINT.
A VEH callback can then:
recognize the breakpoint address;
restore the original byte;
inspect or change the thread context;
move RIP back to the original instruction;
enable the CPU trap flag;
continue execution;
receive the next EXCEPTION_SINGLE_STEP;
put the breakpoint back.
Figure 1: A VEH debugger restores the original instruction for one step, then reinserts the INT3 breakpoint.
Together, the breakpoint and single-step exceptions provide a debugging loop without a conventional Windows debug object.
3. Looking Through GhostDebug
GhostDebug has two main parts:
a .NET command-line client;
a native DLL that is loaded into the target process.
GhostDebug CLI
|
| OpenProcess + VirtualAllocEx + WriteProcessMemory
| CreateRemoteThread(LoadLibraryA)
v
Target process
|
+-- injected native DLL
+-- head-of-chain VEH callback at registration
+-- INT3 breakpoint manager
+-- trap-flag single stepping
+-- named-pipe communication
3.1 Getting the DLL into the target
The controller opens the target, allocates memory for the DLL path, writes that path, and starts a remote thread at LoadLibraryA.
GhostDebug happens to use familiar LoadLibrary injection. I did not turn that sequence into a signature because another VEH debugger could use a different injector or load with the program from the start.
GhostDebug also creates its pipe, starts a listener thread, and registers the VEH callback during DLL_PROCESS_ATTACH. Doing that work inside DllMain carries loader-lock risk and is not a generic property of VEH debuggers; it mainly helps explain the timing of this sample’s initialization.
3.2 The named pipe
GhostDebug uses a fixed named pipe so the client and injected DLL can exchange JSON commands. Those commands cover actions such as adding a breakpoint, continuing, stepping, and changing registers.
The fixed pipe name and DLL filename are easy to find and just as easy to change. The detector therefore ignores both.
3.3 Registering the handler
The core registers its callback with AddVectoredExceptionHandler:
AddVectoredExceptionHandler(1, exception_handler);
The nonzero first argument places the callback at the head of the VEH chain when it is registered. It remains first only until another handler is registered with the same first-position request.
I also stopped the process on RtlAddVectoredExceptionHandler in WinDbg to confirm it at runtime:
Figure 2: WinDbg stopped while GhostDebug registered its handler. On Windows x64, RCX contains the first argument (1) and RDX points to the callback.
3.4 Breakpoints and stepping
GhostDebug implements the lifecycle described in Section 2.3: it saves the original byte, changes the page protection, writes 0xCC, and restores the old protection. While that breakpoint is armed, the executable code in memory differs from the file on disk, which became the first detector check.
4. Why the Classic Checks Miss It
GhostDebug does not use the APIs that normally create a debug port or debug object. Because of that, these checks are looking for state that GhostDebug never needed to create.
Check
What it looks for
Expected result with GhostDebug
IsDebuggerPresent
PEB BeingDebugged
Not detected
CheckRemoteDebuggerPresent
Conventional debugging state
Not detected
ProcessDebugPort
Attached debug port
Not detected
ProcessDebugObjectHandle
Debug object handle
Not detected
ProcessUsingVEH
Whether the process uses VEH
Observable; purpose unknown
Executable-code comparison
An active INT3 change
Observable while the breakpoint is present
5. What Can I Realistically Detect?
My first detector design treated individual observations too strongly. A VEH flag, new DLL, executable allocation, or patched byte can all come from legitimate software, so the detector combines them. It has two levels of visibility:
Scan mode: inspect what exists right now.
Watch mode: take a baseline first, then report what changes.
A scan shows what exists; a baseline shows what appeared during the test.
5.1 Keep the classic checks as a control
I still collect CheckRemoteDebuggerPresent, ProcessDebugPort, ProcessDebugObjectHandle, and ProcessDebugFlags as controls and for comparison with WinDbg or x64dbg.
If the process cannot be opened or its architecture is unsupported, the tool stops instead of printing a clean result. “I could not read it” and “I read it and found nothing” are not the same outcome.
An unreadable module produces a warning and the detector continues with the others. If that warning appears, No indicators observed should not be read as a complete clean scan.
5.2 Read the PEB ProcessUsingVEH flag
I use NtQueryInformationProcess(ProcessBasicInformation) to obtain the target PEB address and ReadProcessMemory to read CrossProcessFlags.
On the native x64 Windows 11 build I tested, the field is at offset 0x50, and bit 2 is ProcessUsingVEH:
ProcessUsingVEH = (*(u32 *)(PEB + 0x50) & 0x4) != 0
The flag only tells me that the process uses VEH. It says nothing about which handler was registered or why it exists, so the detector treats it as inconclusive on its own.
There is another important catch: the PEB is an internal structure, and Microsoft warns that its layout can change. I record the Windows build and limit this experiment to the x64 Windows 11 layout I tested.
5.3 Record modules without trusting their names
I enumerate modules with CreateToolhelp32Snapshot, Module32FirstW, and Module32NextW. Module snapshots can race with loader changes, so the code retries ERROR_BAD_LENGTH a limited number of times.
I care about whether an image appeared after the baseline. Its path, base, and size go into the report, but its name has no effect on the verdict.
A new module is not suspicious by itself. Programs load DLLs all the time. It becomes more interesting when a newly decoded VEH callback points into it, or when it appears with a new code modification.
5.4 Look for private executable memory
Using VirtualQueryEx, I walk the process address space and record committed MEM_PRIVATE regions that are executable.
The scan can expose manually mapped code or generated stubs, but JIT runtimes and instrumentation tools create the same kind of memory. A new region is more interesting than one that was already present at startup.
5.5 Compare executable code with the file on disk
The byte comparison ended up being the most direct check. The detector parses each loaded PE file, finds executable sections, reads the matching memory from the target, and looks for this difference:
memory byte == 0xCC && original file byte != 0xCC
A completely raw comparison would produce false differences because the Windows loader applies relocations and fills the import table. The parser therefore ignores base-relocation targets and IAT ranges described by the PE format.
Ignoring those ranges removes two common sources of expected differences. It does not cover every legitimate loader or runtime modification, and it assumes the file at the module path is the same image that was originally loaded.
I call the results INT3 candidates, not confirmed breakpoints. My code does not fully disassemble the instruction stream, and hotpatching or security products could also modify executable code. A candidate that appears after the baseline at the same time as VEH becomes active is much more convincing than a candidate from a single scan.
5.6 Enumerate and decode VEH callbacks
Callback enumeration took most of the development time. Windows exposes functions for adding and removing VEH callbacks, but none for listing them. Reading the list from another process meant relying on private ntdll implementation details.
On the Windows 11 25H2 build I tested, the process-wide handler lists are stored behind LdrpVectorHandlerList. Rather than hardcoding its address, the detector resolves it from the detector’s local copy of ntdll, calculates its RVA, and applies that RVA to the target’s ntdll base. The code verifies that the local and target images report the same SizeOfImage, but equal image sizes do not prove that the two ntdll builds are identical.
The resolver starts from RtlRemoveVectoredExceptionHandler, follows its jump to the internal implementation, and looks for the RIP-relative reference to the list:
ntdll!RtlRemoveVectoredExceptionHandler:
xor edx, edx
jmp ntdll!RtlpRemoveVectoredHandler
ntdll!RtlpRemoveVectoredHandler+0x1b:
lea r12, [ntdll!LdrpVectorHandlerList]
For the x64 layout in this build, the exception-handler list head is at LdrpVectorHandlerList + 0x08. Each node is part of a doubly linked list, and its encoded callback is stored at offset 0x20.
The detector walks the list twice and only accepts it if both reads match. It checks the forward and backward links, rejects cycles and invalid user-mode pointers, limits the maximum number of entries, decodes every callback, and verifies that the result points to committed executable memory. Finally, it maps the callback address to a loaded module when possible and records the memory type and protection. An address outside the module list is not necessarily private memory; it can also belong to a mapped region.
The callback address says much more than the ProcessUsingVEH bit because I can map it back to an image or memory region. The method is version-sensitive, so a failed resolver or list validation produces an unknown result instead of an empty list.
5.7 What about timing checks?
I considered timing as well, but ordinary instructions give the VEH debugger nothing to handle.
Timing an intentional exception might show extra delay from the handler and its communication path. The problem is noise from scheduling, virtual machines, power management, logging, and security software. I would treat timing only as supporting evidence, not as the main detector.
5.8 Turning the observations into a verdict
I wanted the output to show why it reached a verdict, so the program reports the observations alongside the label. Scan and watch mode use different rules because one describes a snapshot and the other describes a change.
Scan mode
Current observation
Result
Conventional debugger state
High confidence
ProcessUsingVEH and an active INT3 candidate
High confidence
A decoded callback in private executable memory
Suspicious
An active INT3 candidate
Suspicious
ProcessUsingVEH and private executable memory
Suspicious
ProcessUsingVEH without stronger evidence
Inconclusive
A top-level required collection is unavailable and there is no stronger evidence
Unknown / partial collection
Private executable memory alone
No indicators observed under the current rules
All top-level checks complete and find nothing relevant
No indicators observed
Watch mode
Change from the original baseline
Result
Conventional debugger state appears
High confidence
A new callback points into a newly loaded module
High confidence
A new callback points into private executable memory
High confidence
ProcessUsingVEH changes from false to true with a new INT3 candidate
High confidence
A new module and INT3 candidate appear while ProcessUsingVEH is true
High confidence
A new callback or INT3 candidate without a stronger transition
Suspicious
ProcessUsingVEH changes from false to true with a new private executable region
Suspicious
ProcessUsingVEH changes from false to true without a stronger transition
Inconclusive
A top-level required collection is unavailable and there is no stronger evidence
Unknown / partial collection
A new module or private executable region alone
No indicators observed under the current rules
No relevant change is observed
No indicators observed
The labels rank the evidence the detector collected. High confidence means several changes line up with an attachment; it is not a claim that the callback’s code has been identified as a debugger. A per-module PE warning also does not currently force an unknown verdict.
6. Building It in Rust
The project stayed small enough that I could trace each check from the CLI to the Windows call:
veh-detector/
|-- Cargo.toml
|-- build-windows.sh
|-- src/
| |-- main.rs # Windows entry point and exit codes
| |-- windows.rs # collection, snapshots, CLI, JSON, and verdicts
| `-- pe.rs # PE parsing, code comparison, and parser tests
`-- test-targets/
`-- benign-veh/
|-- benign_veh.c
`-- build.bat
The only direct Rust dependency is windows-sys:
[dependencies]
windows-sys = { version = "0.61.2", features = [
"Win32_Foundation",
"Win32_System_Diagnostics_Debug",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_SystemInformation",
"Win32_System_Threading",
] }
6.1 Opening the target
The tool accepts either a PID or a process name:
.\veh-detector.exe --pid 4242
.\veh-detector.exe scan --pid 4242
.\veh-detector.exe --name TestTarget.exe
It opens the process with PROCESS_QUERY_INFORMATION | PROCESS_VM_READ | PROCESS_VM_WRITE and uses IsWow64Process2 to make sure both sides match the native x64 layout expected by the code.
I tested Windows 11 25H2 build 26200. The code currently tries the same PEB and private-list layout on builds 22000 and newer, which is a wider range than I have verified. The list validation is there to reject an unexpected layout, not to promise compatibility with every Windows 11 build.
The PROCESS_VM_WRITE right looks out of place in a scanner. Remote pointer decoding needs it when Windows queries the target’s process cookie. The detector never calls WriteProcessMemory; the permission is present only because the query fails without it.
I wrapped native handles so Rust closes them automatically:
struct OwnedHandle(HANDLE);
impl Drop for OwnedHandle {
fn drop(&mut self) {
unsafe { CloseHandle(self.0); }
}
}
The remote-read helper rejects partial reads, and I use checked address arithmetic before building pointers.
6.2 Keeping “false” separate from “unknown”
Most collected values are optional:
struct Snapshot {
captured_unix_ms: u128,
process_uses_veh: Option<bool>,
classic: ClassicDebugState,
modules: Option<Vec<ModuleInfo>>,
veh_handlers: Option<Vec<VehHandler>>,
breakpoint_candidates: Option<Vec<BreakpointCandidate>>,
private_executable_regions: Option<Vec<ExecutableRegion>>,
warnings: Vec<String>,
}
Some(false) means the check worked and returned false. None means it failed or was unsupported. The JSON output uses null for the same distinction.
With this representation, a failed collector cannot look like a negative result.
6.3 Reading ProcessUsingVEH
The code first queries the PEB and then reads the flags:
fn query_process_using_veh(process: HANDLE) -> Result<bool, String> {
let basic: ProcessBasicInformation =
nt_query(process, PROCESS_BASIC_INFORMATION_CLASS)?;
let address = (basic.peb_base_address as usize)
.checked_add(PEB_CROSS_PROCESS_FLAGS_OFFSET_X64)
.ok_or("PEB address overflow")?;
let flags: u32 = read_value(process, address)?;
Ok(flags & PROCESS_USING_VEH != 0)
}
6.4 Walking the private VEH list
The callback enumerator does four things:
resolves the private list location from the detector’s local ntdll;
translates that location to the target using an RVA;
walks and validates the remote doubly linked list;
decodes each protected callback and maps it to its owning memory.
I dynamically resolve RtlDecodeRemotePointer from ntdll, with KernelBase and Kernel32 as fallback locations. The import failure that forced dynamic resolution is covered below; the double-read validation rejects a snapshot if registration or removal changes the list during collection.
6.5 The part that kept breaking
The callback reader broke in three different places before it worked. Each failure pointed to an assumption I had made about ntdll or the process handle.
The first version scanned the exported RtlRemoveVectoredExceptionHandler address for a reference to LdrpVectorHandlerList. On Windows 11 build 26200, the export is only a tiny wrapper that jumps backward to RtlpRemoveVectoredHandler. The expected instruction still existed, but it was in the internal function. I confirmed this in WinDbg and changed the resolver to follow the relative jump before scanning.
Figure 3: RtlRemoveVectoredExceptionHandler jumps to the internal removal routine, where LdrpVectorHandlerList is referenced and the exception-handler list head is accessed at offset 0x08.
The int 3 instructions after the unconditional jump are compiler padding in this ntdll build, not runtime breakpoint candidates introduced by GhostDebug.
The next build failed before main() with this message:
The procedure entry point DecodeRemotePointer could not be located
The GNU import library used by windows-sys mapped DecodeRemotePointer to api-ms-win-core-util-l1-1-1.dll, but that API-set DLL does not export it. I removed the static import and resolved RtlDecodeRemotePointer dynamically from ntdll instead.
After that, the program reached the list but decoding failed with STATUS_ACCESS_DENIED. I first tried querying ProcessCookie directly and received the same error. The missing detail was the required access mask: this information class requires PROCESS_VM_WRITE. Adding that right to the process handle fixed decoding, even though the detector never writes to the process.
I kept these failures in the write-up because code built around private ntdll details needs to say exactly where it was tested and fail visibly when an assumption stops holding.
6.6 Comparing PE sections
pe.rs reads the DOS header, PE header, section table, relocation directory, and IAT directory. It does not try to be a complete PE library. It validates the header and section ranges it consumes and uses checked arithmetic for parser offsets. Unit tests cover truncated headers, optional-header directory bounds, IAT exclusions, and DIR64 relocation exclusions.
For each executable section, the detector reads process memory in 64 KiB chunks. If it finds an in-memory 0xCC where the original file contains another byte, it records:
the virtual address;
module name;
section name;
RVA;
original byte.
The result list is capped so a strange or hostile target cannot make the detector grow memory forever.
6.7 Scan mode and watch mode
The command line exposes the scan and watch modes described in Section 5. Scan mode takes one snapshot:
.\veh-detector.exe scan --pid 4242 --json scan.json
Watch mode takes its first snapshot as the baseline and compares later samples with it:
.\veh-detector.exe watch --pid 4242 --interval-ms 1000
.\veh-detector.exe watch --pid 4242 --samples 30 --json watch.json
It looks for generic changes: ProcessUsingVEH changing from false to true, new modules, new decoded callbacks, new private executable regions, new INT3 candidates, or conventional debugger state appearing. There are no GhostDebug filenames or pipe names in the detection rules.
The --json path is overwritten after every sample, so watch.json contains only the latest snapshot. The terminal output preserves the observed transitions, but the current JSON file is not an event history.
7. How I Tested It
I ran the detector in a FLARE-VM Windows 11 25H2 guest using native x64 binaries. Windows reported version 10.0.26200.8246; the detector itself records only the base build, 26200. I used GhostDebug’s TestTarget.exe as the main target.
I started with a clean target, launched watch mode, and only then attached GhostDebug:
.\veh-detector.exe watch --pid 244 --interval-ms 1000 --json watch.json
The detector has to start first because its opening sample becomes the baseline. During development I also used WinDbg to verify the internal ntdll reference and callback offsets. The conventional checks fired while WinDbg was attached and went clean again when only GhostDebug remained.
For a false-positive control, I wrote benign-veh.exe. It prints its PID, waits for Enter, registers a callback that only returns EXCEPTION_CONTINUE_SEARCH, then waits again before removing it. Figure 6 comes from separate scans before and after registration. Watch mode gives the registration a suspicious label because it saw a new callback appear, even though the program does no debugging.
Section 8 reports the results. Still on my test list are a renamed GhostDebug build, deliberately broken collection paths, and more Windows builds.
8. Results
The baseline was clean:
PEB ProcessUsingVEH: false
Decoded VEH callbacks: 0
Active INT3 candidates: 0
Private executable regions: 0
Verdict: NO INDICATORS OBSERVED
During attachment, the detector first saw new modules and a private executable page. VEH became active on the next sample and the callback appeared. I shortened the terminal output below:
Sample 10: HIGH CONFIDENCE
[+] Module: ghostdebug-core.dll at 0x00007FFBD6DE0000
[...] Two dependency-module lines omitted
[+] VEH #1: 0x00007FFBD6DE1B00
-> ghostdebug-core.dll, MEM_IMAGE, protection 0x20
[!] Private executable region:
0x000001EB56A10000-0x000001EB56A11000, protection 0x40
Verdict: HIGH CONFIDENCE
- ProcessUsingVEH changed from false to true
- 3 module(s) appeared
- 1 decoded VEH callback appeared
- 1 private executable region appeared
The other two images were the DLL’s libc++.dll and libunwind.dll dependencies. I suspect the 4 KiB private RWX region was the DLL-path allocation: GhostDebug requests executable, writable memory for the path and does not release it after LoadLibraryA returns. I did not dump that page, so I cannot confirm its contents. The order still fits the source: the DLL loads before it registers the callback.
The callback address is at RVA 0x1B00 inside the newly loaded image:
0x00007FFBD6DE1B00 - 0x00007FFBD6DE0000 = 0x1B00
That matches the ghostdebug_core!debugger::exception_handler location I had already seen with symbols in WinDbg. The detector did not need that symbol or the DLL name to reach its verdict. Its generic rule was that a decoded VEH callback appeared inside a module that was not present in the baseline.
No traditional debugger state appeared, and this run missed the active INT3. The new module and its callback were enough for the high-confidence transition anyway.
I then tested the code-comparison signal directly. TestTarget.exe printed debug_this at 0x7FF797131460, and I armed a GhostDebug breakpoint at that address without executing it. A scan found the same location as TestTarget.exe+0x1460 in .text: the file contained 0x48, while process memory contained 0xCC. ProcessUsingVEH was true, one callback was decoded, and the conventional debugger checks all remained false.
Figure 4: A targeted scan catches the armed breakpoint before execution reaches it. The detector correlates the 0xCC change with VEH state and a decoded callback, producing a high-confidence verdict.
After I cleared the breakpoint with GhostDebug’s cl command, the next scan found zero INT3 candidates. The callback and private RWX allocation remained, so the verdict dropped from high confidence to suspicious. The candidate was following the patched byte, not merely the injected DLL.
Figure 5: Clearing the breakpoint restores the original byte. The remaining VEH callback and private executable region still produce a suspicious result, but the INT3 evidence is gone.
The benign program gave me a useful control. Before registration, the scan was clean. After registration, it found ProcessUsingVEH and one MEM_IMAGE callback inside the existing executable, with no INT3 candidates or private executable regions. A standalone scan labels that combination inconclusive.
Figure 6: The handler is visible, but the scan has nothing connecting it to debugging.
I then ran the same program under watch mode. The baseline had no callback. After I pressed Enter, ProcessUsingVEH changed from false to true and the callback appeared inside the existing executable. That earns a suspicious transition, but there is no INT3, private executable page, or new module tying it to debugging.
Figure 7: Watch mode catches the benign callback appearing after the baseline. The suspicious label comes from the change, not from what the handler does.
9. False Positives
The verdict ranks correlated evidence, not intent. VEH is used by browsers, crash handlers, runtimes, overlays, accessibility software, anti-cheat products, EDR agents, and instrumentation tools. Private executable memory and modified executable bytes also have legitimate uses. The detector therefore avoids these shortcuts:
treating ProcessUsingVEH as proof;
treating every 0xCC byte as a breakpoint;
treating every new DLL as injected;
trusting a filename as an identity;
turning a failed read into a negative result.
Watch mode adds an order and time window to those observations, but does not eliminate false positives.
10. What This Approach Misses
10.1 Private callback-list internals
Because callback enumeration depends on private ntdll code and structure layouts, a Windows update could change the wrapper, list layout, or node offset. The validation should reject an unfamiliar layout and return unknown, but there is no stable API contract here.
Remote pointer decoding also requires PROCESS_VM_WRITE access to the target. A protected process or restrictive security descriptor can deny that access even when basic inspection succeeds.
10.2 No baseline means less confidence
Starting after attachment loses the timeline and leaves only a triage snapshot of the current VEH state, private executable memory, and active INT3 candidates.
10.3 Manual mapping
A debugger could manually map its code instead of calling LoadLibrary, removing the normal module-list transition. The detector may see unusual memory around it, but it cannot identify what that memory does.
10.4 Different exception hooks
A tool could avoid a normal VEH registration and hook an exception-dispatch routine such as KiUserExceptionDispatcher. My detector does not check that path.
10.5 Breakpoint coverage
GhostDebug briefly restores the original instruction while single stepping, so a periodic scan can land in that window and miss the 0xCC. The file comparison also misses hardware breakpoints, page-guard breakpoints, and software breakpoints in private or generated code. It can correlate a callback with a patched byte, but it does not inspect the callback deeply enough to show that one handles the other.
10.6 Snapshot and collection limits
Watch mode compares every sample with the original baseline and reports additions. It does not currently report handler removal, module unloads, or private-region disappearance. Registrations are compared by decoded callback address, so registering the same callback address twice is not represented as a second new handler.
If one module cannot be read, the detector records a warning and checks the rest. That warning does not currently force an Unknown verdict. Failures in process enumeration, module enumeration, or the address-space walk are top-level gaps and can produce Unknown. I still need finer completeness tracking for partially successful scans.
10.7 User-mode limits
A user-mode scanner cannot reliably inspect every protected process or defend itself against a hostile kernel component. Kernel debuggers are also outside the scope of this project.
11. What the Test Showed
GhostDebug kept IsDebuggerPresent, the debug port, and the debug object clean, but it still changed the target by loading a DLL, registering a VEH callback, leaving an executable private allocation, and replacing an instruction byte with 0xCC. Correlating those changes made the attachment visible without treating any single observation as proof.
Callback enumeration provided the strongest evidence and the greatest fragility because it relies on undocumented ntdll behavior. That tradeoff is acceptable for this experiment, but a production detector would need broader Windows-version testing and finer collection-completeness tracking.
Source Material
VEH Debugger Detector source code
GhostDebug
-
Building a Linux Security Module in Rust: Blocking Ptrace with eBPF
Summary: I built a custom LSM in Rust with Aya eBPF to control ptrace. The policy is simple: default deny, allow only trusted debugger binaries by (inode, device), and log every denied attempt.
1. Introduction
Why ptrace matters (and why attackers love it)
ptrace is one of Linux’s most powerful process-control interfaces. Tools like gdb and strace rely on it for legitimate debugging. With ptrace, a process can inspect another process, stop it, read/write memory, and alter execution state.
That same power makes it a high-value abuse path. If an attacker can ptrace a target process, they may be able to:
read secrets from memory (tokens, credentials, session material),
inject code into trusted processes,
tamper with control flow or registers,
hide malicious behavior inside legitimate processes.
So the defensive goal is not “detect it later.” The goal is to make unauthorized ptrace fail immediately.
Approach
Instead of filtering in user space, I enforce policy at the kernel boundary with an eBPF LSM hook (ptrace_access_check). If the caller is not in an allowlist, the hook returns a negative errno and the kernel denies the action.
2. Background Concepts
eBPF + LSM in practice
Modern kernels let eBPF programs attach to LSM hooks. These hooks run during security decisions, before the action is finalized. In this project, the hook checks who is making the ptrace request and decides allow/deny in real time.
Why allowlist over blocklist
For high-risk primitives like ptrace, blocklists are weak: new tools, renamed binaries, and custom malware easily bypass static “known bad” signatures. An allowlist is stronger operationally:
deny by default,
explicitly allow only known debugger binaries,
treat everything else as untrusted.
Why (inode, device) instead of file path
Path checks are fragile in kernel security contexts:
paths can change (rename/move),
symlinks and bind mounts can alter what a path resolves to,
namespaces can present different path views.
The code instead identifies the running executable using:
Inode (i_ino): file object identity within a filesystem,
Device ID (s_dev): which filesystem/device that inode belongs to.
Together they uniquely identify a file object on a mounted filesystem at that time. This is much more robust than path string matching.
3. What Inode and Device ID actually are
Inode: Metadata record for a file object (permissions, ownership, timestamps, block pointers, etc.). File names are directory entries pointing to inodes.
Device ID: Identifier of the filesystem/device superblock. Two files can share inode numbers across different devices, so inode alone is not enough.
Pairing both: (inode, device) gives a stable identity for allowlisting a specific executable object.
Practical note: package upgrades or binary replacement often create a new inode, so allowlists must be refreshed when binaries change.
4. How the code works (logic walkthrough)
Shared structs (LSM-Enforcer-common/src/lib.rs)
User space and eBPF share two key types:
BinaryId { inode, device } for allowlist keys,
PtraceEvent for blocked-attempt telemetry.
#[repr(C)]
#[derive(Copy, Clone, Hash, Eq, PartialEq, Debug)]
pub struct BinaryId {
pub inode: u64,
pub device: u64,
}
PtraceEvent is the ring-buffer event payload that user space reads and logs:
#[repr(C)]
#[derive(Copy, Clone)]
pub struct PtraceEvent {
pub tracer_pid: u32,
pub target_pid: u32,
pub uid: u32,
pub parent_pid: u32,
pub loginuid: u32,
pub comm: [u8; 16],
}
So each denied event captures: who tried (tracer_pid/comm), who was targeted (target_pid), and identity context (uid, parent_pid, loginuid).
Kernel hook (LSM-Enforcer-ebpf/src/main.rs)
The LSM program block_ptrace runs on ptrace_access_check:
Get caller context (uid, current task).
Read target task from hook args.
Walk current task -> mm -> exe_file -> f_inode.
Build BinaryId { inode, device } from i_ino + i_sb->s_dev.
Lookup in ALLOWED_BINARIES map.
If found, return 0 (allow).
If not found, emit PtraceEvent to ring buffer and return -1 (-EPERM).
#[lsm(hook = "ptrace_access_check")]
pub fn block_ptrace(ctx: LsmContext) -> i32 {
let uid = unsafe { bpf_get_current_uid_gid() } as u32;
let target_task: *const task_struct = ctx.arg(0);
let tgid = unsafe { (*target_task).tgid };
let task = unsafe { bpf_get_current_task_btf() as *mut task_struct };
if task.is_null() {
return -1;
}
unsafe {
let mm = (*task).mm;
if mm.is_null() {
return -1;
}
let exe_file = (*mm).__bindgen_anon_1.exe_file;
if !exe_file.is_null() {
let inode_ptr = (*exe_file).f_inode;
if !inode_ptr.is_null() {
let inode_num = (*inode_ptr).i_ino;
let device_id = (*(*inode_ptr).i_sb).s_dev as u64;
let id = BinaryId {
inode: inode_num,
device: device_id,
};
if ALLOWED_BINARIES.get(&id).is_some() {
return 0;
}
}
}
}
report_event(tgid as u32, uid, task);
-1
}
Event reporting
report_event reserves a ring buffer slot, fills:
tracer PID,
target PID,
UID,
parent PID,
login UID,
process comm (bpf_get_current_comm),
then submits the event.
User-space loader (LSM-Enforcer/src/main.rs)
The userspace app:
loads the compiled eBPF object,
loads + attaches the LSM program with BTF,
resolves allowlisted paths with metadata(),
inserts (inode, device) keys into ALLOWED_BINARIES,
polls the EVENTS ring buffer and logs blocked attempts.
let inode = metadata.ino();
let device = metadata.dev() as u64;
let binary_id = BinaryId { inode, device };
allowed_binaries.insert(&binary_id, &1, 0)?;
5. Implementation and expected behavior
Policy behavior
Trusted debugger binary in map -> ptrace succeeds.
Any non-allowlisted executable issuing ptrace -> denied with EPERM.
Denied attempts are logged with context for triage.
Example flow
# Enforcer startup
sudo ./target/debug/LSM-Enforcer
[INFO LSM_Enforcer] Allowed ptrace from: /usr/bin/gdb (inode: 1965616, device: 38)
[INFO LSM_Enforcer] Allowed ptrace from: /usr/bin/strace (inode: 2224918, device: 38)
Waiting for Ctrl-C...
# Unauthorized tracer
./malicious_injector --pid 1024
malicious_injector: attach: ptrace(PTRACE_SEIZE, 1024): Operation not permitted
# Telemetry from enforcer
[INFO LSM_Enforcer] PTRACE BLOCKED - Tracer: malicious_injector (PID: 9876, UID: 1000), Target PID: 1024, Parent PID: 8888, LoginUID: 1000
PID meanings in this log:
Tracer PID: the process trying to call ptrace (the one being blocked).
Target PID: the process that tracer tried to inspect/control.
Parent PID: the parent process of the tracer process.
This gives immediate prevention plus useful audit signals, without relying on post-facto detection.
Conclusion
ptrace is necessary for observability and debugging, but it is also a powerful abuse primitive. Enforcing an allowlist at the LSM layer gives strong control where it matters: inside the kernel security path.
Using Rust + Aya also keeps development ergonomic: shared typed structs, a small eBPF program, and a clean async userspace event loop.
If you deploy this approach, treat allowlist management as a lifecycle task (binary updates, package changes, immutable images, etc.) so trusted tooling stays usable while unauthorized tracing stays blocked.
Source Code
Full source code for the LSM-Enforcer on GitHub
-
SCC 2026 Quals Writeup: Forensics Challenges Walkthrough
Summary: This post provides a technical walkthrough of three forensics challenges I authored for the SCC 2026 Qualifiers. It covers the intended solutions for “The Silent Leak” (DNS exfiltration), “Beeper’s Revenge” (COM hijacking), and “Vault 126” (App-Bound encryption).
1. The Silent Leak
Category: Forensics
Difficulty: Easy
Points: 50
Description
A suspicious PCAP file has been recovered from a compromised system. Something is quietly slipping through the network traffic. Find it and retrieve the flag.
Overview
The challenge provides a PCAP file containing a mix of legitimate network traffic and a large volume of DNS queries. The goal is to identify and reconstruct a data exfiltration stream hidden within these DNS requests.
Identification
Filtering for DNS traffic reveals two primary domains involved in unusual activity:
flag-provider.com: Contains static or decoy fragments.
system-update.internal: Contains the actual encoded payload.
The subdomains for system-update.internal follow a structured format: [data_chunk].[hex_index].system-update.internal. For example, a query might appear as Q1R.00.system-update.internal.
Data Reassembly
The primary technical hurdle is that the packets are not captured in chronological order. To successfully reconstruct the payload, the extracted chunks must be sorted by their hexadecimal index (the second level of the subdomain). Simply concatenating the strings as they appear in the capture will result in an invalid Base64 string.
Solution
tshark -r dump.pcap -Y 'dns.flags.response == 0 && dns.qry.name contains "system-update.internal"' -T fields -e dns.qry.name | sort -t '.' -k2 | cut -d '.' -f1 | tr -d '\n' | base64 -d
The Flag
SCC{pr0mpt_3ngin33r1ng_15_n0t_for3n51c5}
2. Beeper’s Revenge
Category: Forensics
Difficulty: Medium
Points: 50
Description
System administrators are reporting mysterious audible beep signals coming from the Admin workstation, while File Explorer is showing signs of instability.
Although all standard security tools claim the system is clean, a sophisticated “fileless” malware is suspected of manipulating system objects and hiding deep within the system memory.
Your task is to analyze the provided memory dump and disk image, locate the phantom module, and reconstruct the flag, which is split into three fragments (Registry, Environment, and Memory).
Overview
The challenge centers around a COM Hijacking technique. Instead of deploying a standalone executable, the malware resides as a DLL masquerading as a system binary: IconCache_x64.bin. By overriding a legitimate COM Class ID (CLSID) in the Current User registry hive, the malware ensures it is loaded by trusted Windows processes whenever specific folder operations occur.
The flag was fragmented into three distinct “shards” across different layers of the Windows OS:
Registry Layer: A non-standard key within the Explorer advanced settings.
Process Layer: A specific environment variable injected into the surrogate process.
Memory Layer: An encrypted shard within the process VAD (Virtual Address Descriptor) space.
Solution
1. Process Identification
Initial triage begins by identifying the active surrogate process. Given the symptoms of audible beeps and PowerShell activity, we list the active processes.
vol -f dump.raw windows.pslist
PID is 9392
2. Extraction of Shard 1: Registry Analysis
The first fragment is hidden within the User’s Registry hive, specifically under the Explorer folder modes.
vol -f dump.raw windows.registry.printkey --key "Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced\FolderMode"
Result: InternalID = SCC{d34d_
3. Extraction of Shard 2: Environment Variables
The second fragment is stored in the Environment Block of the hijacked process.
vol -f dump.raw windows.environ --pid 9392
Result: COMPLUS_Version = DLLs_t3ll_
4. Memory Analysis: Locating Shard 3
The final shard is stored within a memory-mapped file that does not have a standard .dll extension, making it invisible to basic module listing.
Step A: VAD Enumeration
vol -f dump.raw windows.vadinfo --pid 9392 | grep "IconCache_x64.bin"
Identify the base address of IconCache_x64.bin (e.g., 0x7fff0b830000).
Step B: Memory Region Extraction
vol -f dump.raw -o . windows.memmap --pid 9392 --address 0x7fff0b830000 --dump
The resulting dump contains the encrypted payload.
5. Cryptographic Key Recovery (Machine SID)
Shard 3 is XOR-encrypted using the Machine SID as the cryptographic key. This requires the investigator to recover the SID from the system’s security tokens.
vol -f dump.raw windows.getsids --pid 9392
The key is derived from the base Machine SID (e.g., S-1-5-21-98682186-3360650230-258948293).
6. Final Reconstruction
By applying the recovered XOR key to the bytes extracted from the VAD space, the final fragment is revealed.
Decrypted Shard 3: n0_t4l35}
The Flag
SCC{d34d_DLLs_t3ll_n0_t4l35}
3. Vault 126
Category: Forensics
Points: 432
Difficulty: Medium
Description
Following an internal audit of the workstation Admin-PC, a selective triage of forensic artifacts was performed to investigate a suspected unauthorized session.
Initial analysis suggests that a persistent session state may be preserved within the local environment. However, due to recent security hardening on the host, standard recovery procedures have proven unsuccessful. You are tasked with analyzing the provided filesystem structure to verify the identity of the active session.
Known Data:
Target User: Admin-PC
Known Password: Admin123
Environment: Windows 10
Zip Password: forensics
Overview
This challenge focuses on the modern App-Bound Encryption introduced in recent versions of Google Chrome. Standard DPAPI extraction fails because the encrypted_key in the Local State file is double-encrypted: first at the machine level (S-1-5-18) and then at the user level.
Solution
1. Registry & LSA Secrets
The process begins by extracting the DPAPI_SYSTEM secret from the SYSTEM and SECURITY registry hives. This secret is necessary to unlock the machine-level MasterKey.
mimikatz # lsadump::secrets /system:SYSTEM /security:SECURITY
2. Unlocking the System MasterKey
Using the machine secret, we derive the MasterKey for the S-1-5-18 (System) account located in C:\Windows\System32\Microsoft\Protect\.
mimikatz # dpapi::masterkey /in:"\Windows\System32\Microsoft\Protect\S-1-5-18\User\0f72ee3c-8c78-4522-a588-926ef9f3c512" /system:67e4c0007ac65c240f3278682f97535b835cd55caa4310c984b78b1d5d63640a5fb2bd17ea7f5235
SystemKey: 8e266617067c8d324e19d2e145d5a909fd0b1723e85dbf8509ce274aa5955da7fe66f9d86b63fa0a401c2e5cbe58bb813ea5d7419111d037495d5e5fd98eede0
3. Unlocking the User MasterKey
Unlock the user-level encryption using the known password and SID:
mimikatz # dpapi::masterkey /in:"\Users\Admin-PC\AppData\Roaming\Microsoft\Protect\S-1-5-21-...\a47ee1c4-f671-45d8-b518-83170fa9087b" /sid:S-1-5-21-98682186-3360650230-258948293-1001 /password:Admin123
Result: pbData: a5cd29b02511d808a3ebc2c5fb8f9f8545e54fc31a2a559a9611dc1ddb5a4d99
4. Decrypting the App-Bound Key (Double DPAPI Pivot)
With both keys cached, decrypt the Local State key blob (ensure the APPB header is removed):
5. Database Decryption
Use the extracted AES key to decrypt the SQLite Cookies database:
mimikatz # dpapi::chrome /in:"\AppData\Local\Google\Chrome\User Data\Default\Network\Cookies" /masterkey:a5cd29b02511d808a3ebc2c5fb8f9f8545e54fc31a2a559a9611dc1ddb5a4d99
6. Decode the Flag
Flag is stored in AuthToken Cookie. The decrypted cookie value contains JWT token with the flag embedded in its payload. Decoding the JWT reveals base64-encoded flag fragments, which are then concatenated to reconstruct the final flag.
AuthToken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkbWluLVBDIiwicm9sZSI6InN1cGVydXNlciIsImludGVybmFsX2lkIjoiVTBORGUyNHdYMjB3Y2pOZmJURnNhMTltTUhKZmRHZ3pjek5mTTNod01EVXpaRjlqTURCck1UTTFmUT09IiwiaWF0IjoxNTE2MjM5MDI2LCJleHAiOjE4MDUwOTYzMTZ9.w_1pnqEb50fIE4z6GiERzGJbeF3uQ7HRFAm3o2TdOBg
The Flag
SCC{n0_m0r3_m1lk_f0r_th3s3_3xp053d_c00k135}
-
My Path to the HTB CDSA Certification: How I Passed and What I Learned
Summary: This post details my personal journey of passing the practical HTB Certified Defensive Security Analyst (CDSA) exam. It covers my preparation strategy, a breakdown of the 7-day exam experience, and essential tips for anyone aspiring to earn this blue team certification.
Certification at a Glance
Attribute
Value
Certification Name
HTB Certified Defensive Security Analyst (CDSA)
Provider
Hack The Box
Focus Area
Blue Team, SOC, DFIR
Exam Format
100% Practical, Hands-on
Duration
7 Days
Final Deliverable
Professional Incident Report
Hello everyone, and welcome to my first-ever blog post! The feeling is incredible, not just because I’m launching this blog, but because I have some exciting news to share – I have officially passed the Hack The Box Certified Defensive Security Analyst (HTB CDSA) exam!
The journey was challenging but immensely rewarding. Through this article, I want to share my entire experience, from the moment I decided to pursue it, through the preparation, and all the way to the exam itself. I hope my story will be helpful to all of you who are considering the same path.
Overview of the HTB CDSA
The HTB CDSA is not your typical multiple-choice certification. It is a completely practical, hands-on exam that places you in the role of a Security Analyst in a realistic scenario. Its goal is to test your skills in security analysis, Security Operations Center (SOC) procedures, and Incident Response.
This certification is designed for those who want to validate their Blue Team skills. If you are interested in roles like SOC Analyst, Incident Responder, Threat Hunter, or Digital Forensics Analyst, then this is the right choice for you. It confirms that you possess intermediate-level technical competence and are capable not only of finding traces of an attack but also of writing a professional report about it.
Domains & Pricing
The HTB CDSA teaches and tests you in the following domains:
SIEM Operations (Splunk and the ELK Stack)
Log Analysis from various sources
Threat Hunting (proactively searching for threats)
Network Traffic Analysis (Wireshark, Suricata/Zeek)
Basic Malware Analysis
Digital Forensics and Incident Response (DFIR)
Professional Incident Report Writing
Regarding the price, there are two components: the training and the exam itself.
Training (HTB Academy): To be eligible for the exam, you must complete the entire “SOC Analyst” job-role path on the Hack The Box Academy. The most cost-effective option is the Silver Annual subscription, which costs $490 per year. This plan grants you access to all the necessary modules and includes one exam voucher (for the CDSA, CPTS, or CBBH).
Exam (Voucher): If you purchase the exam voucher separately, its price is $210.
So, for the complete package (training + exam), the most common investment is $490 (Before new VIP update).
My Preparation Strategy
Preparation is the key to everything, and there are no shortcuts. The only real path is to complete the entire “SOC Analyst” job-role path on HTB Academy. This path consists of 28 modules (this number may change) that cover everything from the fundamentals to advanced techniques. It’s worth noting that I was juggling my high school classes at the same time, so my preparation process naturally took more time to ensure I could thoroughly absorb all the material.
My approach to preparation was as follows:
Focused Module Completion: I didn’t just read and click “next.” I carefully studied each module and did the hands-on exercises multiple times until I was certain I fully understood the concepts.
Note-Taking: This is absolutely crucial. I used Obsidian for my notes. For every tool and technique, I wrote down key commands, SIEM queries, and processes. I created my own personal cheat sheet that proved invaluable during the exam.
Understanding the “Why”: It’s not enough to just know a command. I made an effort to understand why I was using a specific Splunk query or why I was looking at a particular log file. Understanding the attacker’s perspective greatly helps in defense.
Extra Practice: Although the Academy is sufficient, If you don’t have experience you should do some HTB boxes before attepmting certification.
The 7-Day Exam Experience
The exam itself lasts for 7 days. When you begin, you are given VPN access and a “Letter of Engagement” that explains your task. You are placed in an environment with multiple machines, and your task is to investigate two separate security incidents. During your investigation, you will find evidence, analyze logs, network traffic, memory dumps, and files.
The key to passing isn’t just the technical analysis. To pass, you must write and submit a professional, detailed incident report. This report must contain all your findings, evidence (screenshots), the attack timeline, the tools and SIEM queries you used, and recommendations for remediation. The report carries a massive portion of the final grade.
My 7-day exam felt like a real job as a SOC Analyst. Here’s a breakdown of how it went for me:
On the very first day, I managed to solve 16 out of 20 flags for the first incident. By the second day, I had already met the minimum passing requirement and started writing the report for Incident 1. On the third day, I began working on Incident 2, which was more difficult. After spending two days on that incident, I started writing the report for it.
The final day was dedicated solely to finishing and polishing the report. Throughout the entire process, I took meticulous notes and screenshots. I spent about 5 hours of real, productive work each day. Thanks to my previous experience, I didn’t find the certification overly difficult, but my thorough preparation on the HTB Academy was also a major contributing factor to that.
Key Tips for Success
If you are planning to take the HTB CDSA, here are a few tips from my firsthand experience:
Trust the Academy Process: Everything you need for the exam is genuinely in the modules. Study them in detail.
Notes, Notes, and More Notes: I cannot stress this enough. Organize them by tools and techniques.
Practice Report Writing: Before the exam, read several publicly available DFIR reports (e.g., from The DFIR Report). Practice writing based on a scenario. Use a tool like SysReptor, which HTB recommends.
Manage Your Time: You have 7 days. Make a plan. You don’t need to be at it 24/7. Rest is important to stay fresh and focused.
Take Lots of Screenshots: Document every step, every command, and every significant result with a screenshot. You will thank yourself later when you’re writing the report.
Don’t Panic: If you get stuck, take a break. Go for a walk. The solution is often obvious, but you can’t see it when you’re fatigued.
I hope this detailed breakdown was useful. The HTB CDSA is more than just a certification—it’s a fantastic learning experience that truly prepares you for real-world cybersecurity challenges.
If you have any questions, feel free to contact me!
Touch background to close