Anti-Debug Technique Recognition
Overview and Learning Objectives
Anti-debugging is one of the most common self-defense mechanisms found in modern malware. By the end of this lesson, you will be able to:
- Identify all major categories of anti-debug techniques in disassembly
- Understand why each technique works at the OS level
- Recognize the assembly patterns in both Ghidra and x64dbg
- Select the correct bypass strategy for each technique category
- Map anti-debug findings to MITRE ATT&CK T1622 (Debugger Evasion)
ATT&CK Reference: T1622 - Debugger Evasion. Adversaries may employ various means to detect and avoid debuggers. Used by APT groups including APT28 (Fancy Bear), Lazarus Group, and APT41.
Why Malware Detects Debuggers
Debuggers let analysts step through code instruction-by-instruction, inspect memory, and modify program state. Malware authors embed checks that detect debugger presence and alter behavior -- typically by exiting cleanly, crashing, executing decoy code, or corrupting analysis results.
Common malware responses to debugger detection:
| Response | Description | Example |
|---|---|---|
| Silent exit | Calls ExitProcess(0) cleanly | Most commodity malware |
| Crash | Triggers unhandled exception | Packed/protected samples |
| Decoy path | Executes benign-looking code | Sophisticated APT malware |
| Data corruption | Destroys or garbles payload | Ransomware families |
| Delayed execution | Sleeps for extended period | Sandbox-aware samples |
| Self-deletion | Removes itself from disk | Anti-forensics aware |
Your job as an analyst is to recognize these checks and bypass them.
Category 1: Windows API-Based Detection
IsDebuggerPresent
The simplest and most common check. Internally, this API simply reads the BeingDebugged byte from the Process Environment Block (PEB):
// What the API does internally:
// return NtCurrentPeb()->BeingDebugged;
if (IsDebuggerPresent()) {
ExitProcess(0); // Bail out
}
What it looks like in a disassembler (x64):
call qword ptr ds:[<&IsDebuggerPresent>]
test eax, eax
jne getdown.140001216 ; Jump to exit if debugger detected
In Ghidra, look for:
- Import of
IsDebuggerPresentfrom kernel32.dll - A
TEST EAX, EAXimmediately after the CALL - A conditional jump (
JNE/JNZ) to an exit or cleanup block
Bypass in x64dbg:
- Set breakpoint on the
TEST EAX, EAXinstruction after the call - When hit, double-click the RAX/EAX register and set it to 0
- Or more permanently: select the JNE instruction, right-click > Assemble, type
NOP, enable "Fill with NOPs"
Pro Tip: If the malware calls IsDebuggerPresent frequently (e.g., in a loop or timer callback), manual register patching becomes impractical. Use ScyllaHide or NOP the entire check instead.
CheckRemoteDebuggerPresent
Detects debuggers attached to the process, including remote debuggers:
BOOL isDebugged = FALSE;
CheckRemoteDebuggerPresent(GetCurrentProcess(), &isDebugged);
if (isDebugged) ExitProcess(0);
Bypass: Hook the API to always write FALSE to the output parameter, or NOP the conditional jump.
NtQueryInformationProcess -- ProcessDebugPort (Class 0x07)
More advanced -- queries the kernel for the debug port associated with the process. A non-zero return value indicates a debugger is attached:
DWORD debugPort = 0;
NtQueryInformationProcess(
GetCurrentProcess(),
ProcessDebugPort, // Information class 7
&debugPort,
sizeof(debugPort),
NULL
);
if (debugPort != 0) ExitProcess(0);
NtQueryInformationProcess -- ProcessDebugObjectHandle (Class 0x1E)
HANDLE debugObject;
NTSTATUS status = NtQueryInformationProcess(
GetCurrentProcess(), 0x1E,
&debugObject, sizeof(debugObject), NULL
);
if (status == STATUS_SUCCESS) ExitProcess(0); // Debug object exists
NtQueryInformationProcess -- ProcessDebugFlags (Class 0x1F)
DWORD noDebugInherit;
NtQueryInformationProcess(
GetCurrentProcess(), 0x1F,
&noDebugInherit, sizeof(noDebugInherit), NULL
);
if (noDebugInherit == 0) ExitProcess(0); // 0 means debugger present
Complete NtQueryInformationProcess reference:
| Info Class | Value | What It Checks | Debugged Result |
|---|---|---|---|
| ProcessDebugPort | 0x07 | Debug port number | Non-zero |
| ProcessDebugObjectHandle | 0x1E | Debug object existence | STATUS_SUCCESS |
| ProcessDebugFlags | 0x1F | NoDebugInherit flag | 0 |
Bypass: Hook NtQueryInformationProcess via ScyllaHide, or patch the conditional jumps after each call.
NtSetInformationThread -- ThreadHideFromDebugger
NtSetInformationThread(
GetCurrentThread(),
ThreadHideFromDebugger, // Class 0x11
NULL, 0
);
// After this call, breakpoints in this thread cause crashes
// instead of debug breaks -- the thread becomes invisible to the debugger
Bypass: Hook NtSetInformationThread to ignore class 0x11 (ScyllaHide handles this automatically).
Category 2: PEB Field Checks
The Process Environment Block is a user-mode structure accessible without any API calls, making these checks harder to hook.
PEB anti-debug field reference:
| PEB Field | Offset (x86) | Offset (x64) | Normal Value | Debugged Value |
|---|---|---|---|---|
| BeingDebugged | 0x02 | 0x02 | 0 | 1 |
| NtGlobalFlag | 0x68 | 0xBC | 0 | 0x70 |
| Heap.Flags | varies | varies | 0x02 | 0x50000062 |
| Heap.ForceFlags | varies | varies | 0x00 | 0x40000060 |
BeingDebugged Flag (PEB+0x02)
; x86 - Access PEB via FS segment
mov eax, dword ptr fs:[0x30] ; PEB address
movzx eax, byte ptr [eax+0x02] ; BeingDebugged flag
test eax, eax
jnz debugger_detected
; x64 - Access PEB via GS segment
mov rax, qword ptr gs:[0x60] ; PEB address
movzx eax, byte ptr [rax+0x02] ; BeingDebugged flag
test eax, eax
jnz debugger_detected
Key Insight: If the program directly checks the BeingDebugged bit without calling IsDebuggerPresent, then setting a breakpoint on IsDebuggerPresent will not help you. Instead, you need to look for
FS:[0x30]orGS:[0x60]access patterns and patch the PEB directly.
NtGlobalFlag (PEB+0x68 / PEB+0xBC)
When a debugger launches a process, it sets three heap debugging flags that combine to 0x70:
- FLG_HEAP_ENABLE_TAIL_CHECK (0x10)
- FLG_HEAP_ENABLE_FREE_CHECK (0x20)
- FLG_HEAP_VALIDATE_PARAMETERS (0x40)
mov eax, dword ptr fs:[0x30]
test byte ptr [eax+0x68], 0x70
jnz debugger_detected
Bypass: Zero out the NtGlobalFlag field in x64dbg's dump window, or use ScyllaHide which patches it automatically.
Heap Flags
The process heap has flags that differ under a debugger. Malware reads the heap base and checks:
// Get the process heap
PVOID heap = (PVOID)(*(PDWORD)((PBYTE)peb + 0x18)); // PEB->ProcessHeap
DWORD flags = *(PDWORD)((PBYTE)heap + 0x0C); // Heap.Flags
DWORD forceFlags = *(PDWORD)((PBYTE)heap + 0x10); // Heap.ForceFlags
if (flags != 0x02 || forceFlags != 0x00) {
// Debugger detected
}
Category 3: Timing-Based Detection
Single-stepping through code in a debugger dramatically slows execution. Malware exploits this by measuring elapsed time around code blocks.
RDTSC (Read Time-Stamp Counter)
RDTSC ; Read TSC into EDX:EAX
MOV ECX, EAX ; Save low 32 bits of first timestamp
; ... some code block ...
RDTSC ; Read TSC again
SUB EAX, ECX ; Compute elapsed cycles
CMP EAX, 0x1000 ; If too many cycles elapsed
JA debugger_detected ; ... someone is stepping through
GetTickCount / QueryPerformanceCounter
DWORD t1 = GetTickCount();
// ... suspicious code ...
DWORD t2 = GetTickCount();
if ((t2 - t1) > 5000) {
// More than 5 seconds = probably being single-stepped
ExitProcess(0);
}
Other timing APIs: GetLocalTime, GetSystemTime, NtQuerySystemTime, QueryPerformanceCounter, timeGetTime
Bypass strategies:
- Hook timing functions via ScyllaHide to return normalized values
- Patch the comparison: change the threshold to a very large value
- NOP the conditional jump: skip the check entirely
- Use hardware breakpoints: avoid the slowdown of software breakpoints in timing-sensitive code
Category 4: Exception-Based Detection
INT 2D Technique
INT 2D raises a breakpoint exception. Under a debugger, the debugger typically catches it and the instruction after INT 2D executes. Without a debugger, the SEH handler runs:
PUSH handler ; Install SEH handler
PUSH DWORD PTR FS:[0]
MOV FS:[0], ESP
INT 2D ; Trigger exception
; If we reach here, debugger swallowed the exception
JMP debugger_detected
handler:
; Normal execution continues here (no debugger)
Single-Step (Trap Flag) Detection
Setting the trap flag (TF) in EFLAGS causes a single-step exception after the next instruction:
PUSHF
OR DWORD PTR [ESP], 0x100 ; Set TF (bit 8)
POPF
NOP ; Should trigger SINGLE_STEP exception here
; If we reach here without exception, debugger consumed it
OutputDebugString Technique
SetLastError(0);
OutputDebugStringA("test");
if (GetLastError() == 0) {
// Debugger is present (it consumed the debug string without error)
}
Category 5: Breakpoint Detection
Software Breakpoint Scanning (0xCC / INT 3)
Debuggers insert 0xCC (INT 3) bytes for software breakpoints. Malware can scan its own code:
unsigned char *code = (unsigned char *)FunctionAddress;
for (int i = 0; i < 100; i++) {
if (code[i] == 0xCC) {
ExitProcess(0); // Software breakpoint detected
}
}
Bypass: Use hardware breakpoints instead (limited to 4 via DR0-DR3 registers).
Hardware Breakpoint Detection
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(GetCurrentThread(), &ctx);
if (ctx.Dr0 || ctx.Dr1 || ctx.Dr2 || ctx.Dr3) {
ExitProcess(0); // Hardware breakpoints are set
}
Bypass: Hook GetThreadContext to zero out debug registers (ScyllaHide DRx Protection option).
Recognizing Anti-Debug in Practice
Quick Scan Workflow
- Run CAPA first -- it automatically identifies anti-debug techniques:
capa sample.exe | grep -i "debug"
- Check imports in PEStudio or Ghidra for:
IsDebuggerPresent,CheckRemoteDebuggerPresent,NtQueryInformationProcess,GetTickCount,QueryPerformanceCounter - Search for PEB access patterns:
FS:[0x30](x86) orGS:[0x60](x64) - Search for exception instructions:
INT 2D,INT 3,INT 1 - Look for timing pairs: two calls to the same timing API with subtraction between them
Using x64dbg Intermodular Calls
In x64dbg, right-click in the Disassembler area of the CPU tab, select Search for > Current Region > Intermodular calls. This lists all external API calls referenced by the specimen, making it easy to spot anti-debug APIs.
Anti-Debug Technique Summary Table
| Category | Technique | Detection Pattern | Bypass |
|---|---|---|---|
| API | IsDebuggerPresent | CALL + TEST + JNZ | NOP jump or set EAX=0 |
| API | NtQueryInformationProcess | PUSH 7/0x1E/0x1F + CALL | ScyllaHide hook |
| API | NtSetInformationThread | PUSH 0x11 + CALL | ScyllaHide hook |
| PEB | BeingDebugged | FS:[30]+2 read | Patch PEB byte to 0 |
| PEB | NtGlobalFlag | FS:[30]+68 read | Patch PEB field to 0 |
| PEB | Heap Flags | Heap base + offset read | Patch heap flags |
| Timing | RDTSC pair | RDTSC + code + RDTSC + CMP | Patch threshold or NOP |
| Timing | GetTickCount pair | Two calls + SUB + CMP | ScyllaHide timing hooks |
| Exception | INT 2D | SEH install + INT 2D | Pass exception to program |
| Exception | Trap Flag | PUSHF + OR 0x100 + POPF | Configure exception handling |
| Breakpoint | 0xCC scan | Loop reading own code bytes | Use hardware breakpoints |
| Breakpoint | DR register check | GetThreadContext + DR check | ScyllaHide DRx protection |
Practical Exercise
- Load a malware sample (e.g., getdown.exe) in x64dbg
- Use Search for > Intermodular calls to list API references
- Identify all anti-debug checks in the code and classify by category
- For each check found:
- Document the address and technique type
- Determine what happens on detection (exit, crash, decoy)
- Apply the appropriate bypass (NOP, register edit, or ScyllaHide)
- Verify the bypass worked by running the patched sample
- Map all findings to ATT&CK T1622 (Debugger Evasion)
- Save your patches via File > Patch file > Patch File for reproducibility
