反调试

Open: PEB,TEB结构关系图.png

由 BeingDebugged 引发的蝴蝶效应

BeingDebugged

Win 32 API IsDebuggerPresent 函数用来判断自己是否处于调试状态。

//debug.c
BOOL APIENTRY IsDebuggerPresent(VOID)
{
    return NtCurrentPeb()->BeingDebugged;
}

这个函数读取了当前进程 PEB(Process Environment Block,进程环境块) 中的 BeingDebugged 标志。

0:018> dt ntdll!_PEB
   +0x000 InheritedAddressSpace : UChar
   +0x001 ReadImageFileExecOptions : UChar
   +0x002 BeingDebugged    : UChar           <---------
   +0x003 BitField         : UChar
   +0x003 ImageUsesLargePages : Pos 0, 1 Bit
   +0x003 IsProtectedProcess : Pos 1, 1 Bit
   +0x003 IsImageDynamicallyRelocated : Pos 2, 1 Bit
   +0x003 SkipPatchingUser32Forwarders : Pos 3, 1 Bit
   +0x003 IsPackagedProcess : Pos 4, 1 Bit
   +0x003 IsAppContainer   : Pos 5, 1 Bit
   +0x003 IsProtectedProcessLight : Pos 6, 1 Bit
   +0x003 IsLongPathAwareProcess : Pos 7, 1 Bit
   +0x004 Mutant           : Ptr32 Void
   +0x008 ImageBaseAddress : Ptr32 Void
   +0x00c Ldr              : Ptr32 _PEB_LDR_DATA
   +0x010 ProcessParameters : Ptr32 _RTL_USER_PROCESS_PARAMETERS
   +0x014 SubSystemData    : Ptr32 Void
   +0x018 ProcessHeap      : Ptr32 Void
   +0x01c FastPebLock      : Ptr32 _RTL_CRITICAL_SECTION
   +0x020 AtlThunkSListPtr : Ptr32 _SLIST_HEADER
   +0x024 IFEOKey          : Ptr32 Void
   +0x028 CrossProcessFlags : Uint4B
   +0x028 ProcessInJob     : Pos 0, 1 Bit
   +0x028 ProcessInitializing : Pos 1, 1 Bit
   +0x028 ProcessUsingVEH  : Pos 2, 1 Bit
   +0x028 ProcessUsingVCH  : Pos 3, 1 Bit
   +0x028 ProcessUsingFTH  : Pos 4, 1 Bit
   +0x028 ProcessPreviouslyThrottled : Pos 5, 1 Bit
   +0x028 ProcessCurrentlyThrottled : Pos 6, 1 Bit
   +0x028 ProcessImagesHotPatched : Pos 7, 1 Bit
   +0x028 ReservedBits0    : Pos 8, 24 Bits
   +0x02c KernelCallbackTable : Ptr32 Void
   +0x02c UserSharedInfoPtr : Ptr32 Void
   +0x030 SystemReserved   : Uint4B
   +0x034 AtlThunkSListPtr32 : Ptr32 _SLIST_HEADER
   +0x038 ApiSetMap        : Ptr32 Void
   +0x03c TlsExpansionCounter : Uint4B
   +0x040 TlsBitmap        : Ptr32 _RTL_BITMAP
   +0x044 TlsBitmapBits    : [2] Uint4B
   +0x04c ReadOnlySharedMemoryBase : Ptr32 Void
   +0x050 SharedData       : Ptr32 Void
   +0x054 ReadOnlyStaticServerData : Ptr32 Ptr32 Void
   +0x058 AnsiCodePageData : Ptr32 Void
   +0x05c OemCodePageData  : Ptr32 Void
   +0x060 UnicodeCaseTableData : Ptr32 Void
   +0x064 NumberOfProcessors : Uint4B
   +0x068 NtGlobalFlag     : Uint4B
   +0x070 CriticalSectionTimeout : _LARGE_INTEGER
   +0x078 HeapSegmentReserve : Uint4B
  ...

我们知道了 BeingDebugged 存储在 PEB 中,接下来就要获取到 PEB 的地址。PEB 的地址存储在另一个名为线程环境块(Thread Environment Block,TEB)的结构中。而 windows 在调入线程、创建线程时,操作系统会为每个线程分配 TEB,且 fs:[0] 总是指向当前线程的 TEB。

0:018> dt ntdll!_TEB
   +0x000 NtTib            : _NT_TIB              <-------
   +0x01c EnvironmentPointer : Ptr32 Void
   +0x020 ClientId         : _CLIENT_ID
   +0x028 ActiveRpcHandle  : Ptr32 Void
   +0x02c ThreadLocalStoragePointer : Ptr32 Void
   +0x030 ProcessEnvironmentBlock : Ptr32 _PEB    <-------
   +0x034 LastErrorValue   : Uint4B
   +0x038 CountOfOwnedCriticalSections : Uint4B
   +0x03c CsrClientThread  : Ptr32 Void
   +0x040 Win32ThreadInfo  : Ptr32 Void
   +0x044 User32Reserved   : [26] Uint4B
   +0x0ac UserReserved     : [5] Uint4B
   +0x0c0 WOW32Reserved    : Ptr32 Void
   +0x0c4 CurrentLocale    : Uint4B
 ...

内联汇编代码的简化版 IsDebuggerPresent:

BOOL MyIsDebuggerPresent(VOID)
{
    __asm{
        mov eax, fs:[0x30]            //在TEB偏移30h处获得PEB地址
        movzx eax, byte ptr [eax+2]   //获得PEB偏移2h处BeingDebugged的值
    }
}

根据这个原理,调试器可以用插件清除 BeingDebugged 以隐藏调试器。不过我们需要修改的是调试进程中的 BeingDebugged 的值。虽然在 windows 2000/NT 操作系统中,PEB 本身在绝大多数情况下被映射到 7FFDF000h 处,不过从 windows XP SP2 以后系统引入了 PEB 地址随机化的特性,每个进程的 PEB 地址不固定,有 14 种可能。

系统在创建进程时设置 PEB 的地址,调用 NtCreatProcess/NtCreateProcessEx 函数,依次转向 PspCreateProcess、MmCreatePeb、MiCreatePebOrTeb 函数,在 MiCreatePebOrTeb 中根据当前时间计算随机值。

PVOID HighestVadAddress;
LARGE_INTEGER CurrentTime;
HighestVadAddress = (PVOID) ((PCHAR)MM_HIGHEST_VAD_ADDRESS + 1);
KeQueryTickCount (&CurrentTime);
CurrentTime.LowPart &= ((X64K >> PAGE_SHIFT) - 1);
if (CurrentTime.LowPart <= 1) {
    CurrentTime.LowPart = 2;
}
//
// Select a varying PEB address without fragmenting the address space.
//
HighestVadAddress = (PVOID) ((PCHAR)HighestVadAddress - (CurrentTime.LowPart << PAGE_SHIFT));

所以,PEB 的地址是不固定的,不同进程,其 PEB 地址不一样。正确的方法是用 GetThreadSelectorEntry 函数取得某个线程段所选择的子线程的地址。

BOOL GetThreadSelectorEntry(
  [in]  HANDLE      hThread,
  [in]  DWORD       dwSelector,
  [out] LPLDT_ENTRY lpSelectorEntry   //<----
);
typedef struct _LDT_ENTRY {
  WORD  LimitLow;
  WORD  BaseLow;       //<----The low-order part of the base address of the segment.
  union {
    struct {
      BYTE BaseMid;    //<----Middle bits (16–23) of the base address of the segment.
      BYTE Flags1;
      BYTE Flags2;
      BYTE BaseHi;     //<----High bits (24–31) of the base address of the segment.
    } Bytes;
    struct {
      DWORD BaseMid : 8;
      DWORD Type : 5;
      DWORD Dpl : 2;
      DWORD Pres : 1;
      DWORD LimitHi : 4;
      DWORD Sys : 1;
      DWORD Reserved_0 : 1;
      DWORD Default_Big : 1;
      DWORD Granularity : 1;
      DWORD BaseHi : 8;
    } Bits;
  } HighWord;
} LDT_ENTRY, *PLDT_ENTRY;

如果喜欢用 Native API,也可以通过 NtQueryInformationProcess 函数获得 PEB。

__kernel_entry NTSTATUS NtQueryInformationProcess(
  [in]            HANDLE           ProcessHandle,
  [in]            PROCESSINFOCLASS ProcessInformationClass,
  [out]           PVOID            ProcessInformation,      //<----
  [in]            ULONG            ProcessInformationLength,
  [out, optional] PULONG           ReturnLength
);
typedef struct _PROCESS_BASIC_INFORMATION {
    NTSTATUS ExitStatus;
    PPEB PebBaseAddress;                   //<----
    ULONG_PTR AffinityMask;
    KPRIORITY BasePriority;
    ULONG_PTR UniqueProcessId;
    ULONG_PTR InheritedFromUniqueProcessId;
} PROCESS_BASIC_INFORMATION;

NtGlobalFlag

我们通过 windows 2000 源码了解一下 BeingDebugged 被清除前具体发生了什么。

VOID
    LdrpInitialize (
       IN PCONTEXT Context,
       IN PVOID SystemArgument1,
       IN PVOID SystemArgument2
    )
    //Routine Description:
    //This function is called as a User-Mode APC routine as the first
    //user-mode code executed by a new thread. It's function is to initialize
    //loader context, perform module initialization callouts…
//……

//#if DBG
if (TRUE)
//#else
//        if (Peb->BeingDebugged || Peb->ReadImageFileExecOptions)
//#endif
{
    PWSTR pw;
    pw = (PWSTR)Peb->ProcessParameters->ImagePathName.Buffer;
    if (!(Peb->ProcessParameters->Flags & RTL_USER_PROC_PARAMS_NORMALIZED)) {
        pw = (PWSTR)((PCHAR)pw + (ULONG_PTR)(Peb->ProcessParameters));
        }
    UnicodeImageName.Buffer = pw;
    UnicodeImageName.Length = Peb->ProcessParameters->ImagePathName.Length;
    UnicodeImageName.MaximumLength = UnicodeImageName.Length;
    //
    //  Hack for NT4 SP4.  So we don't overload another GlobalFlag
    //  bit that we have to be "compatible" with for NT5, look for
    //  another value named "DisableHeapLookaside".
    //
    LdrQueryImageFileExecutionOptions( &UnicodeImageName,
                                       L"DisableHeapLookaside",
                                       REG_DWORD,
                                       &RtlpDisableHeapLookaside,
                                       sizeof( RtlpDisableHeapLookaside ),
                                       NULL
                                     );
    st = LdrQueryImageFileExecutionOptions( &UnicodeImageName,
                                            L"GlobalFlag",
                                            REG_DWORD,
                                            &Peb->NtGlobalFlag,
                                            sizeof( Peb->NtGlobalFlag ),
                                            NULL
                                          );
    if (!NT_SUCCESS( st )) {
        if (Peb->BeingDebugged) {
//
// 这里改写了 NtGlobalFlag
//
            Peb->NtGlobalFlag |= FLG_HEAP_ENABLE_FREE_CHECK |
                                 FLG_HEAP_ENABLE_TAIL_CHECK |
                                 FLG_HEAP_VALIDATE_PARAMETERS;
            }
        }

在 BeingDebugged 被设为"TRUE"时,NtGlobalFlag 中会因此设置一些特殊的标志,代码如下。

FLG_HEAP_ENABLE_TAIL_CHECK (0x10) 
FLG_HEAP_ENABLE_FREE_CHECK (0x20) 
FLG_HEAP_VALIDATE_PARAMETERS (0x40)
NtGlobalFlag值0000 0000 0111 0000
...

回顾一下 PEB 的结构,+0x068 处就是 NtGlobalFlag。用 WinHex 比较内存,可以发现在调试时程序的 NtGlobalFlag 为 70h,在正常情况下却不是该值。因此,得到一个改进的 IsDebuggerPresent 函数,具体如下。

BOOL MyIsDebuggerPresentEx(VOID)
{
    __asm{
        mov eax, fs:[0x30]
        mov eax, [eax+0x68]
        and eax, 0x70
    }
}

清除 BeingDebugged 代码中的 LdrQueryImageFileExecutionOption 函数,如果执行成功,就不会改写 NtGlobalFalg 了。这个函数事实上读取了注册表的内容,具体如下。

HKLM\Software\Microsoft\Windows Nt\CurrentVersion\Image File Execution Options

如果在这里新建一个名为进程名、值为空的子键,那么 NtGlobalFlag(及其引发的)的检测就变得无效了。 ## HeapMagic 上面 ntGlobalFlag 设置了标志 FLG_HEAP_VALIDATE_PARAMETERS。在 WRK 中搜索 FLG_HEAP_VALIDATE_PARAMETERS,相关代码如下。

PVOID
RtlCreateHeap (
    IN ULONG Flags,
    IN PVOID HeapBase OPTIONAL,
    IN SIZE_T ReserveSize OPTIONAL,
    IN SIZE_T CommitSize OPTIONAL,
    IN PVOID Lock OPTIONAL,
    IN PRTL_HEAP_PARAMETERS Parameters OPTIONAL
    )
...
if (NtGlobalFlag & FLG_HEAP_ENABLE_TAIL_CHECK){
    Flags |= HEAP_TAIL_CHECKING_ENABLED;
}
if (NtGlobalFlag & FLG_HEAP_ENABLE_FREE_CHECK){
    Flags |= HEAP_FREE_CHECKING_ENABLED;
}
if (NtGlobalFlag & FLG_HEAP_DISABLE_COALESCING){
    Flags |= HEAP_DISABLE_COALESCE_ON_FREE;
}
peb = NtCurrentPeb();
if (NtGlobalFlag & FLG_HEAP_VALIDATE_PARAMETERS){
    Flags |= HEAP_VALIDATE_PARAMETERS_ENABLED;
}
if (NtGlobalFlag & FLG_HEAP_VALIDATE_ALL){
    Flags |= HEAP_VALIDATE_ALL_ENABLED;
}
if (NtGlobalFlag & FLG_USER_STACK_TRACE_DB){
    Flags |= HEAP_CAPTURE_STACK_BACKtRACES;
}
...
#ifndef NTOS_KERNEL_RUNTIME
//
//   In the non kernel case check if we are creating a debug heap
//   the test checks that skip validation checks is false.
if (DEBUG_HEAP( Flags )){
    return RtlDebugCreateHeap( Flags,
                                HeapBase,
                                ReserveSize,
                                CommitSize,
                                Lock,
                                Parameters);
}
#endif // NTOS_KERNEL_RUNTIME
//----宏 DEBUG_HEAP 代码
//heappriv.h
#define HEAP_DEBUG_FLAGS   (HEAP_VALIDATE_PARAMETERS_ENABLED | \
                            HEAP_VALIDATE_ALL_ENABLED        | \
                            HEAP_CAPTURE_STACK_BACKTRACES    | \
                            HEAP_CREATE_ENABLE_TRACING       | \
                            HEAP_FLAG_PAGE_ALLOCS)
#define DEBUG_HEAP(F)      ((F & HEAP_DEBUG_FLAGS) && !(F & HEAP_SKIP_VALIDATION_CHECKS))

从代码中看出 RtlCreateHeap 函数用 RtlDebugCreateHeap 创建调试堆。再去看看 RtlDebugCreateHeap 函数内容。

PVOID
RtlDebugCreateHeap (
    IN ULONG Flags,
    IN PVOID HeapBase OPTIONAL,
    IN SIZE_T ReserveSize OPTIONAL,
    IN SIZE_T CommitSize OPTIONAL,
    IN PVOID Lock OPTIONAL,
    IN PRTL_HEAP_PARAMETERS Parameters
    )
...
    Heap = RtlCreateHeap( Flags |
                            HEAP_SKIP_VALIDATION_CHECKS |
                            HEAP_TAIL_CHECKING_ENABLED  |
                            HEAP_FREE_CHECKING_ENABLED,
                          HeapBase,
                          ReserveSize,
                          CommitSize,
                          Lock,
                          Parameters );

还是调用了 RtlCreateHeap 函数,起关键作用的是下面这三个标志。

HEAP_SKIP_VALIDATION_CHECKS
HEAP_TAIL_CHECKING_ENABLED
HEAP_FREE_CHECKING_ENABLED

我们再回到 RtlCreateHeap 函数搜索这三个标记。HEAP_SKIP_VALIDATION_CHECKS 是为了防止从 RtlCreateHeap 到 RtlDebugCreateHeap 再到 RtlCreateHeap 的重复工作。

    if (!(Flags & HEAP_SKIP_VALIDATION_CHECKS)) {
        if (Flags & ~HEAP_CREATE_VALID_MASK) {
            HeapDebugPrint(( "Invalid flags (%08x) specified to RtlCreateHeap\n", Flags ));
            HeapDebugBreak( NULL );
            Flags &= HEAP_CREATE_VALID_MASK;
        }
    }
...
    //
    //  If the flags indicate that we should zero memory then
    //  remember how much to zero.  We'll do the zeroing later
    //
    if (Flags & HEAP_ZERO_MEMORY) {
        ZeroSize = Size;
    //
    //  Otherwise if the flags indicate that we should fill heap then
    //  it it now.
    //
    } else if (Heap->Flags & HEAP_FREE_CHECKING_ENABLED) {
        RtlFillMemoryUlong( (PCHAR)(BusyBlock + 1), Size & ~0x3, ALLOC_HEAP_FILL );
    }
    //
    //  If the flags indicate that we should do tail checking then copy
    //  the fill pattern right after the heap block.
    //
    if (Heap->Flags & HEAP_TAIL_CHECKING_ENABLED) {
        RtlFillMemory( (PCHAR)ReturnValue + Size,
                       CHECK_HEAP_TAIL_SIZE,
                       CHECK_HEAP_TAIL_FILL );
        BusyBlock->Flags |= HEAP_ENTRY_FILL_PATTERN;
    }
...
#define CHECK_HEAP_TAIL_SIZE HEAP_GRANULARITY
#define CHECK_HEAP_TAIL_FILL 0xAB
#define FREE_HEAP_FILL 0xFEEEFEEE
#define ALLOC_HEAP_FILL 0xBAADF00D

调试堆中会填充一些奇怪的内容,而正常堆中不会有这个 HeapMagic。 如果 0xBAADF00D 在堆中出现很多次(10 次以上),说明程序被调试了。

LPVOID GetHeap(SIZE_T nsize)
{
    return HeapAlloc(GetProcessHeap(),NULL,nSize);
}
BOOL IsDebugHeap(VOID)
{
    LPVOID HeapPtr;
    PDWORD ScanPtr;
    ULONG nMagic = 0;
    Heap Ptr = GetHeap(0x100);
    ScanPtr = (PDWORD)HeapPtr;
    try{
        for(;;){
            switch (*ScanPtr++) {
                case 0xABABABAB:
                case 0xBAADF00D:
                case 0xFEEEFEEE:
                    nMagic++;
                    break;
            }
        }
    }
    catch(...){
        return (nMagic > 10) ? TRUE : FALSE;
    }
}

除了自己申请内存,还能重被调试程序 PEB 的 LDR_MODULE 中找到哪些用来 " 填坑 " 的标记。

; MASM32 antiRing3Debugger example 
; coded by ap0x
; Reversing Labs: http://ap0x.headcoders.net

ASSUME FS:NOTHING
PUSH offset _SehExit
PUSH DWORD PTR FS:[0]
MOV FS:[0],ESP

; Get NtGlobalFlag<- 这里ap0x出现bug了,这里获取的是PEB
MOV EAX,DWORD PTR FS:[30h]

; Get LDR_MODULE
MOV EAX,DWORD PTR[EAX+12]

; The trick is here ;) If ring3 debugger is present memory will be allocated
; and it will contain 0xFEEEFEEE bytes at the end of alloc. This will only
; happen if ring3 debugger is present!
; If there is no debugger SEH will fire and take control.

; Note: This code works only on NT systems!

_loop:
INC EAX
CMP DWORD PTR[EAX],0FEEEFEEEh
JNE _loop
DEC [Tries]
JNE _loop

PUSH 30h
PUSH offset DbgFoundTitle
PUSH offset DbgFoundText
PUSH 0
CALL MessageBox
PUSH 0
CALL ExitProcess
RET
_Exit:
PUSH 40h
PUSH offset DbgNotFoundTitle
PUSH offset DbgNotFoundText
PUSH 0
CALL MessageBox
PUSH 0
CALL ExitProcess
RET

_SehExit:
POP FS:[0]
ADD ESP,4
JMP _Exit

继续看看 RtlCreateHeap 函数,后面还有一些填充 heap header 的操作。

//
//  Fill in the heap header fields
//
Heap->Entry.Size = (USHORT)(SizeOfHeapHeader >> HEAP_GRANULARITY_SHIFT);
Heap->Entry.Flags = HEAP_ENTRY_BUSY;
Heap->Signature = HEAP_SIGNATURE;
Heap->Flags = Flags;
Heap->ForceFlags = (Flags & (HEAP_NO_SERIALIZE |
                             HEAP_GENERATE_EXCEPTIONS |
                             HEAP_ZERO_MEMORY |
                             HEAP_REALLOC_IN_PLACE_ONLY |
                             HEAP_VALIDATE_PARAMETERS_ENABLED |
                             HEAP_VALIDATE_ALL_ENABLED |
                             HEAP_TAIL_CHECKING_ENABLED |
                             HEAP_CREATE_ALIGN_16 |
                             HEAP_FREE_CHECKING_ENABLED));

这里的 Flags 在前面已经被 NtGlobalFlag 影响了,看来进程堆的 Flags 和 ForceFlags 也会被 “感染” 哪些标记。 在正常情况下,系统为进程创建第 1 个堆时,会将它的 Flags 和 ForceFlags 分别设为 2(HEAP_GROWABLE)和 0,而在调试状态下,这两个标志分别会被设为 50000062h (取决于 NtGlobalFlag)和 40000060hwindows 2000 HEAP 结构如下:

   +0x000 Entry            : _HEAP_ENTRY
   +0x008 Signature        : Uint4B
   +0x00c Flags            : Uint4B
   +0x010 ForceFlags       : Uint4B

在 PEB 的 +0x018h 处的 ProcessHeap 就存储了加载器为进程分配的第一个堆的位置。

BOOL CheckHeapFlags(VOID)
{
    __asm{
        mov eax,fs:[0x30]           ;获取PEB
        mov eax,[eax+0x18]          ;获取ProcessHeap  
        cmp dword ptr [eax+0x0C],2          ;获取Flags
        jne __debugger_detected
        cmp dword ptr [eax+0x10],0          ;获取ForceFlags
        jne __debugger_detected
    }
}

当然,上面的代码只能在 windows 2000~windows XP SP 1 上使用,具体原因,可以参考 Windows 堆管理机制 [1] 堆基础 - 修竹 Kirakira 大佬的这篇文章 1.2 节。

win11 上,Heap 结构就变成下面这样,这里我只列出我关心的参数,具体结构读者可自行用 windbg 查看。

0:022> dt _HEAP
ntdll!_HEAP
   +0x000 Segment          : _HEAP_SEGMENT
   +0x000 Entry            : _HEAP_ENTRY
   +0x040 Flags            : Uint4B
   +0x044 ForceFlags       : Uint4B
   +0x060 Signature        : Uint4B

所以现在,检测 ProcessHeap 的代码就变成下面这样:

BOOL CheckHeapFlags(VOID)
{
    __asm{
        mov eax,fs:[0x30]           ;获取PEB
        mov eax,[eax+0x18]          ;获取ProcessHeap  
        cmp dword ptr [eax+0x40],2          ;获取Flags
        jne __debugger_detected
        cmp dword ptr [eax+0x44],0          ;获取ForceFlags
        jne __debugger_detected
    }
}

从源头消灭 BeingDebugged

系统会在创建进程的时候设置 “BeingDebugged=TRUE”,NtGlobalFlag 会根据这个标志设置 FLG_HEAP_VALIDATE_PARAMETERS 等标记。在为进程创建堆的时候,由于 NtGlobalFlag 的作用,堆的 Flags 也被设置了一些标记。这个 Flags 又立即被填充到 ProcessHeap 的 Flags 和 ForceFlags 中,堆的内存也被填充了很多 BA AD F0 0D 之类的内容。这样,调试器就被检测出来了。

总结完这个流程我们发现,就是因为在创建进程时的蝴蝶 BeingDebugged,导致了后面的一系列飓风。只要我们从源头制止这一切,后面的影响都能消除。

我们也确实能找到改写 BeingDebugged 的时机,即编写调试器(或者调试器插件)时,创建进程并调用 WaitForDebugEvent 函数后,在第 1 次 LOAD_DLL_DEBUG_EVENT 发生时设置 “BeingDebugged = FALSE”。但是,这样就无法中断在系统断点处了。所以,在第 2 次 LOAD_DLL_DEBUG_EVENT 发生的时候,要将 BeingDebugged 设置为"TRUE"。此后就会停在系统断点处了,进而安全的清除 BeingDebugged 了。

这样,我们就能一次性解决了 BeingDebugged、NtGlobalFlag、HeapFlags、HeapForceFlags、HeapMagic。

回归 Native: 用户态的梦魇

CheckRemoteDebuggerPresent

用户态下,用于检测调试器的函数,除了 IsDebuggerPresent,还有一个 CheckRemoteDebuggerPresent。

BOOL CheckRemoteDebuggerPresent(
  [in]      HANDLE hProcess,
  [in, out] PBOOL  pbDebuggerPresent
);

CheckRemoteDebuggerPresent 不仅可以探测进程自身是否被调试,也可以探测系统其他进程是否被调试。并且这个函数不依赖于 BeingDebugged 标志。

typedef BOOL (WINAPI *CHECK_REMOTE_DEBUGGER_PRESENT)(HANDLE, PBOOL);
BOOL CheckDebugger(VOID)
{
    HANDLE      hProcess;
    BOOL        bDebuggerPresent = FALSE;
    hProcess = GetCurrentProcess();
    return CheckRemoteDebuggerPresent(
                hProcess, 
                &bDebuggerPresent) ? bDebuggerPresent : FALSE;
}

Open: VS2022 F5调试.png

Open: x32dbg调试,开启插件base反调试.png 可以发现,即使使用了 ScyllaHide 的 Basic 反调试,CheckRemoteDebuggerPresent 依然能检测出来。显然,这个 api 没有用 BeingDebugged 作为判断依据。 ## ProcessDebugPort 直接 Ctrl+G 跳转到 CheckRemoteDebuggerPresent。

75C958C0 | 8BFF                     | mov     edi,edi
75C958C2 | 55                       | push    ebp
75C958C3 | 8BEC                     | mov     ebp,esp
75C958C5 | 51                       | push    ecx    
75C958C6 | 837D 08 00               | cmp     dword ptr ss:[ebp+0x8],0x0
75C958CA | 56                       | push    esi                       
75C958CB | 74 36                    | je      kernelbase.75C95903       
75C958CD | 8B75 0C                  | mov     esi,dword ptr ss:[ebp+0xC]
75C958D0 | 85F6                     | test    esi,esi                   
75C958D2 | 74 2F                    | je      kernelbase.75C95903       
75C958D4 | 6A 00                    | push    0x0                       
75C958D6 | 6A 04                    | push    0x4                       
75C958D8 | 8D45 FC                  | lea     eax,dword ptr ss:[ebp-0x4]
75C958DB | 50                       | push    eax                       
75C958DC | 6A 07                    | push    0x7                       
75C958DE | FF75 08                  | push    dword ptr ss:[ebp+0x8]    
75C958E1 | FF15 4443CC75            | call    dword ptr ds:[<&NtQueryInformationProcess |
75C958E7 | 85C0                     | test    eax,eax                   
75C958E9 | 79 09                    | jns     kernelbase.75C958F4       
75C958EB | 8BC8                     | mov     ecx,eax                   
75C958ED | E8 D5B3F1FF              | call    kernelbase.75BB0CC7       
75C958F2 | EB 17                    | jmp     kernelbase.75C9590B       
75C958F4 | 33C0                     | xor     eax,eax                   
75C958F6 | 3945 FC                  | cmp     dword ptr ss:[ebp-0x4],eax
75C958F9 | 0F95C0                   | setne   al                        
75C958FC | 8906                     | mov     dword ptr ds:[esi],eax                    | [esi]:__enc$textbss$end+23
75C958FE | 33C0                     | xor     eax,eax                   
75C95900 | 40                       | inc     eax                       
75C95901 | EB 0A                    | jmp     kernelbase.75C9590D       
75C95903 | 6A 57                    | push    0x57                      
75C95905 | FF15 D040CC75            | call    dword ptr ds:[<&RtlRestoreLastWin32Error> |
75C9590B | 33C0                     | xor     eax,eax                            
75C9590D | 5E                       | pop     esi                                
75C9590E | C9                       | leave                                      
75C9590F | C2 0800                  | ret     0x8                                

把汇编代码翻译成 C 语言代码:

BOOL CheckRemoteDebuggerPresent(HANDLE hProcess, PBOOL pbDebuggerPresent)
{
    DWORD rv;
    if (hProcess & pbDebuggerPresent){
        rv = NtQueryInformationProcess(hProcess, 7, &hProcess, 4, 0);
        if (rv < 0){
            BaseSetLastNTError(rv);
            return TRUE;
        } else{
            pbDebuggerPresent = hProcess;
            return TRUE;
        }       
    } else {
        RtlRestoreLastWin32Error();
        return FALSE;
    }   
}

我们发现,核心就是 NtQueryInformationProcess 函数,它是 Native API。

__kernel_entry NTSTATUS NtQueryInformationProcess(
  [in]            HANDLE           ProcessHandle,
  [in]            PROCESSINFOCLASS ProcessInformationClass,
  [out]           PVOID            ProcessInformation,
  [in]            ULONG            ProcessInformationLength,
  [out, optional] PULONG           ReturnLength
);

NtQueryInformationProcess 根据不同的 ProcessInformationClass 检索有关指定进程的信息。上面查询了 7 号信息,根据 Windows NT 2000 Native API Reference:

含义
ProcessDebugPort
7
When querying this information class, the value is interpreted as a boolean indicating whether a debug port has been set or not. The debug port can be set only if it was previously zero (in Windows NT 4.0, once set the port can also be reset to zero). The handle which is set must be a handle to a port object. (Zero is also allowed in Windows NT 4.0.)

CheckRemoteDebuggerPresent 函数实际上调用了 NtQueryInformationProcess 来查找进程的 ProcessDebugPort,这个值是进程的调试器端口号。NtCurrentPeb()->Being Debugged 可以被随意清除而不影响调试。但若将调试端口设为 0,系统就不会向用户态调试器发送调试事件通知,调试器当然无法工作了。

这时,是不是想到把 ProcessDebugPort 设为 0,就万事大吉了?注意上面的一段说明"The debug port can be set only if it was previously zero" 。由于程序被调试时 DebugPort 已经是非零值,不能再进行设置了,这个想法就无法实现了。

ThreadHideFromDebugger

既然 NtQueryInformationProcess 这条路走不通,那就继续找找其他函数。Windows NT 2000 Native API Reference 中还有一个函数 ZwSetInformationThread。

NTSYSAPI NTSTATUS NTAPI
ZwSetInformationThread(
    IN HANDLE ThreadHandle,
    IN THREADINFOCLASS ThreadInformationClass,
    IN PVOID ThreadInformation,
    IN ULONG ThreadInformationLength
    );

这个函数可以设置一个与线程相关的信息,看看 ThreadInformationClass 列表:

typedef enum _THREADINFOCLASS { //Query Set
    ThreadBasicInformation, // 0 Y N 
    ThreadTimes, // 1 Y N 
    ThreadPriority, // 2 N Y 
    ThreadBasePriority, // 3 N Y 
    ThreadAffinityMask, // 4 N Y 
    ThreadImpersonationToken, // 5 N Y 
    ThreadDescriptorTableEntry, // 6 Y N 
    ThreadEnableAlignmentFaultFixup, // 7 N Y 
    ThreadEventPair, // 8 N Y 
    ThreadQuerySetWin32StartAddress, // 9 Y Y 
    ThreadZeroTlsCell, // 10 N Y 
    ThreadPerformanceCount, // 11 Y N 
    ThreadAmILastThread, // 12 Y N 
    ThreadIdealProcessor, // 13 N Y 
    ThreadPriorityBoost, // 14 Y Y 
    ThreadSetTlsArrayAddress, // 15 N Y 
    ThreadIsIoPending, // 16 Y N 
    ThreadHideFromDebugger // 17 N Y      <----
} THREADINFOCLASS;

ThreadHideFromDebugger: This information class can only be set. It disables the generation of debug events for the thread. This information class requires no data, and so ThreadInformation may be a null pointer .ThreadInformationLength should be zero.

通过为线程设置 ThreadHideFromDebugger,可以禁止某个线程产生调试事件。测试程序:

#include <stdio.h> 
#include <windows.h>
#include <tchar.h>
typedef DWORD(WINAPI* ZW_SET_INFORMATION_THREAD)(HANDLE, DWORD, PVOID, ULONG);
#define ThreadHideFromDebugger 17
VOID DisableDebugEvent(VOID)
{
    HINSTANCE hModule;
    ZW_SET_INFORMATION_THREAD ZwSetInformationThread;

    hModule = GetModuleHandleA("Ntdll");
    ZwSetInformationThread =
        (ZW_SET_INFORMATION_THREAD)GetProcAddress(hModule, "ZwSetInformationThread");
    ZwSetInformationThread(GetCurrentThread(), ThreadHideFromDebugger, NULL, NULL);
}

int main()
{
    printf("test start----\n\n");
    DisableDebugEvent();
    printf("end----\n");
    system("pause");
    return 0;
}

vs 2022 调试发现下的断点未停住,会自动退出,并且后续的 end---- 也未打印。这是由于程序已经退出,调试器打开的进程句柄不再有效造成的。我们来看看 ZwSetInformationThread 是如何处理 ThreadHideFromDebugger 的:

case ThreadHideFromDebugger:
    if ( ThreadInformationLength != 0 ) {
        return STATUS_INFO_LENGTH_MISMATCH;
    }
    st = ObReferenceObjectByHandle(
            ThreadHandle,
            THREAD_SET_INFORMATION,
            PsThreadType,
            PreviousMode,
            (PVOID *)&Thread,
            NULL
            );
    if ( !NT_SUCCESS(st) ) {
        return st;
    }
    Thread->HideFromDebugger = TRUE;
    ObDereferenceObject(Thread);
    return st;
    break;

可以看到,这里直接将 Thread 对象的 HideFromDebugger 成员设置为 “TRUE” 了。在搜索一下 HideFromDebugger 的引用。

VOID
DbgkMapViewOfSection(
    IN HANDLE SectionHandle,
    IN PVOID BaseAddress,
    IN ULONG SectionOffset,
    IN ULONG_PTR ViewSize
    )
/*++
Routine Description:
    This function is called when the current process successfully
    maps a view of an image section. If the process has an associated
    debug port, then a load dll message is sent.
...
--*/
{
    PVOID Port;
    DBGKM_APIMSG m;
    PDBGKM_LOAD_DLL LoadDllArgs;
    PEPROCESS Process;
    PIMAGE_NT_HEADERS NtHeaders;

    PAGED_CODE();

    Process = PsGetCurrentProcess();

    Port = PsGetCurrentThread()->HideFromDebugger ? NULL : Process->DebugPort;

    if ( !Port || KeGetPreviousMode() == KernelMode ) {
        return;
    }

    LoadDllArgs = &m.u.LoadDll;
    LoadDllArgs->FileHandle = DbgkpSectionHandleToFileHandle(SectionHandle);
    LoadDllArgs->BaseOfDll = BaseAddress;
    LoadDllArgs->DebugInfoFileOffset = 0;
    LoadDllArgs->DebugInfoSize = 0;

    try {
        NtHeaders = RtlImageNtHeader(BaseAddress);
        if ( NtHeaders ) {
            LoadDllArgs->DebugInfoFileOffset = NtHeaders->FileHeader.PointerToSymbolTable;
            LoadDllArgs->DebugInfoSize = NtHeaders->FileHeader.NumberOfSymbols;
            }
        }
    except(EXCEPTION_EXECUTE_HANDLER) {
        LoadDllArgs->DebugInfoFileOffset = 0;
        LoadDllArgs->DebugInfoSize = 0;
    }

    DBGKM_FORMAT_API_MSG(m,DbgKmLoadDllApi,sizeof(*LoadDllArgs));

    DbgkpSendApiMessage(&m,Port,TRUE);
    ZwClose(LoadDllArgs->FileHandle);
}

以上代码注释里说,如果当前进程成功映射了一个映象,就会调用这个函数。如果将进程和一个调试端口关联起来,就会通知调试器发生了 LOAD_DLL_DEBUG_EVENT 事件。如果线程的 HideFromDebugger 为 “TRUE”,那么代码在中间就已经返回,调试器对后面的事情一无所知。

ThreadHideFromDebugger 与直接将 DebugPort 清理异曲同工。

DebugObject

调试器与被调试程序建立关系有两种途径:

  1. 在创建进程时设置 DEBUG_PROCESS;
  2. 调用 DebugActiveProcess 附加到某个已经运行的进程上。 由于前者在建立线程时有太多与调试无关的操作,我们将后者作为研究对象。

参考大佬的文章 [原创]Windows 内核学习笔记之调试(上)-软件逆向|kanxue. com ,调试对象: > 当调试器与调试子系统建立连接时,调试子系统会为其创建一个 DEBUG_OBJECT 调试对象,并将其保存在调试器当前线程的线程环境块(TEB)偏移 0x0F24DbgSsReserved[1] 字段中。该字段中保存的调试对象是这个调试器线程区别于其他普通线程的重要标志。

也就是说,我们可以根据是否有 DebugObject 句柄就能区分线程是否被调试。根据大佬所述,DebugObject 事实上是用内核函数 ObCreateObject 来创建对象。因此可以用 ZwQueryObject 查询所有对象的类型,若发现 DebugObject 的数目不为 0,就说明系统中存在调试器。

NTSYSAPI NTSTATUS ZwQueryObject(
  [in, optional]  HANDLE                   Handle,
  [in]            OBJECT_INFORMATION_CLASS ObjectInformationClass,
  [out, optional] PVOID                    ObjectInformation,
  [in]            ULONG                    ObjectInformationLength,
  [out, optional] PULONG                   ReturnLength
);

关于 OBJECT_INFORMATION_CLASS 看这里: NTAPI Undocumented Functions (ntinternals.net)

typedef enum _OBJECT_INFORMATION_CLASS {
    ObjectBasicInformation,
    ObjectNameInformation,
    ObjectTypeInformation,
    ObjectAllInformation,
    ObjectDataInformation
} OBJECT_INFORMATION_CLASS, *POBJECT_INFORMATION_CLASS;

设置 ObjectAllInformation 就可以获取全部对象信息了。

typedef struct _OBJECT_ALL_INFORMATION {
  ULONG                   NumberOfObjectsTypes;
  OBJECT_TYPE_INFORMATION ObjectTypeInformation[1];
} OBJECT_ALL_INFORMATION, *POBJECT_ALL_INFORMATION;
typedef struct _OBJECT_TYPE_INFORMATION {
  UNICODE_STRING          TypeName;
  ULONG                   TotalNumberOfHandles;
  ULONG                   TotalNumberOfObjects;
  WCHAR                   Unused1[8];
  ULONG                   HighWaterNumberOfHandles;
  ULONG                   HighWaterNumberOfObjects;
  WCHAR                   Unused2[8];
  ACCESS_MASK             InvalidAttributes;
  GENERIC_MAPPING         GenericMapping;
  ACCESS_MASK             ValidAttributes;
  BOOLEAN                 SecurityRequired;
  BOOLEAN                 MaintainHandleCount;
  USHORT                  MaintainTypeList;
  POOL_TYPE               PoolType;
  ULONG                   DefaultPagedPoolCharge;
  ULONG                   DefaultNonPagedPoolCharge;
} OBJECT_TYPE_INFORMATION, *POBJECT_TYPE_INFORMATION;

然后通过 _OBJECT_ALL_INFORMATION.NumberOfObjectsTypes 就能判断出系统中是否有调试器在运行了。 > PS:不过我在 win11 中运行时,发现这个方法并不起作用,而且 win11 下我也没查到有 _OBJECT_ALL_INFORMATION 结构体。不过相应的 api 我都能从 ntdll.dll 中查到。也没想到什么好办法。

SystemKernelDebuggerInformation

NTSTATUS WINAPI ZwQuerySystemInformation(
  _In_      SYSTEM_INFORMATION_CLASS SystemInformationClass,
  _Inout_   PVOID                    SystemInformation,
  _In_      ULONG                    SystemInformationLength,
  _Out_opt_ PULONG                   ReturnLength
);

这个原理就是查询 SystemKernelDebuggerInformation 的信息,以检测当前系统是否正在调试状态,这个函数可以判断当前这个系统是否被内核调试器给附加或者是给调试状态、比如双机调试。

#include <windows.h>
#include <stdio.h>

#define SystemKernelDebuggerInformation 35
#pragma pack(4)
typedef struct _SYSTEM_KERNEL_DEBUGGER_INFORMATION
{
    BOOLEAN DebuggerEnabled;
    BOOLEAN DebuggerNotPresent;
} SYSTEM_KERNEL_DEBUGGER_INFORMATION, * PSYSTEM_KERNEL_DEBUGGER_INFORMATION;
typedef DWORD(WINAPI* ZW_QUERY_SYSTEM_INFORMATION)(DWORD, PVOID, ULONG, PULONG);
BOOL
CheckKernelDbgr(
    VOID)
{
    HINSTANCE hModule = GetModuleHandleA("Ntdll");
    ZW_QUERY_SYSTEM_INFORMATION ZwQuerySystemInformation =
        (ZW_QUERY_SYSTEM_INFORMATION)GetProcAddress(hModule, "ZwQuerySystemInformation");
    SYSTEM_KERNEL_DEBUGGER_INFORMATION Info = { 0 };

    ZwQuerySystemInformation(
        SystemKernelDebuggerInformation,
        &Info,
        sizeof(Info),
        NULL);
    return (Info.DebuggerEnabled && !Info.DebuggerNotPresent);
}

Hook 与 AntiHook

Hook

本节介绍的反调试技巧都是用 Native API 进行操作的,如果调用的 Native API 被人 Hook 了,那一切的检测就没用了。 Open: 没有开启x64dbg的ScyllaHide的反调试.png

Open: 开启x64dbg的ScyllaHide的反调试.png 这种简单的 R3 级 hook 就能反掉我们设置 ThreadHideFromDebugger 的反调试操作。

AntiHook/Splicing

针对 Hook,俄罗斯程序员 PSI_H 的《对付 API-Splicing 的一种简单方法》中介绍了"Splicing" 技巧。 “Splicing” 就是把一段代码抽走,放到壳动态申请的内存中,然后生成一个跳转指令并跳到该指令处执行,这样,在抓取内存镜像时会丢失掉这部分,从而使脱壳变得麻烦。 下面介绍一下为击败 Splicing 而实现的 Hook,具体如下:

// 将NTDLL.DLL文件拷入TEMP文件夹
char szTemp[MAX_PATH];
GetTempPath(MAX_PATH, szTemp);
strcat(szTemp, "ntdll2.dll");
CopyFile("C:\\Windows\\System32\\ntdll.dll", szTemp, TRUE);

// 取得指向原始函数的指针
HMODULE hMod = LoadLibrary(szTemp);
void* ptr_orig = GetProcAddress(hMod, "ZwWriteVirtualMemory"); 
// 取得指向当前函数的指针
void* ptr_new = GetProcAddress (LoadLibrary("ntdll.dll"), "ZwWriteVirtualMemory");

// 设置内存访问权限
DWORD dwOldProtect;
VirtualProtect(ptr_new, 10, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// 替换函数的前10个(为保险起见)字节
memcpy(ptr_new, ptr_orig, 10);
FreeLibrary(hMod);
DeleteFile(szTemp);

上面这段代码恢复了 R3 级对 ntdll.dll 中 ZwWriteVirtualMemory 函数的 Hook。使用 LoadLibrary/GetProcAddress 函数来获取被拦截函数的原始代码,之后用它在内存里替换掉以前的代码,这样就摘掉了对函数的 HOOK。因为调用 LoadLibrary 将返回指向已加载模块的指针,所以必须将文件拷贝并加载此拷贝。

尽管这里给出的摘除 HOOK 的方法完全奏效,但需要加载新的 dll 模块,这可能会引起防火墙的暴怒。我所认为的更为优雅的办法就是只需从文件中读取所需要的字节。下面这个函数的代码恢复了 API 的原始的起始部分。

bool RemoveFWHook(char* szDllPath, char* szFuncName) // szDllPath为DLL的完整路径 !
{
// 取得指向函数的指针
HMODULE lpBase = LoadLibrary(szDllPath);
LPVOID lpFunc = GetProcAddress(lpBase, szFuncName);
if(!lpFunc)
return false;
// 取得RVA
DWORD dwRVA = (DWORD)lpFunc-(DWORD)lpBase;

// 将文件映射入内存
HANDLE hFile = CreateFile(szDllPath,GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL, NULL);
if(INVALID_HANDLE_VALUE == hFile)
return false;

DWORD dwSize = GetFileSize(hFile, NULL);
HANDLE hMapFile = CreateFileMapping(hFile, NULL, PAGE_READONLY|SEC_IMAGE, 0, dwSize, NULL);
LPVOID lpBaseMap = MapViewOfFile(hMapFile, FILE_MAP_READ, 0, 0, dwSize);

// 指向当前函数的指针
LPVOID lpRealFunc = (LPVOID)((DWORD)lpBaseMap+dwRVA);

// 修改访问权限并拷贝
DWORD dwOldProtect;
BOOL bRes=true;
if(VirtualProtect(lpFunc, 10, PAGE_EXECUTE_READWRITE, &dwOldProtect))
{
memcpy(lpFunc, lpRealFunc, 10);
}else{
bRes=false;
}
UnmapViewOfFile(lpBaseMap);
CloseHandle(hMapFile);
CloseHandle(hFile);
return bRes;
}

小技巧

检测调试器

int 2d

int 2d 指令本来是供内核 ntoskrnl.exe 运行 DebugServices 用的,也可以在 R3 下使用。如果在一个正常应用程序中使用 int 2d 指令,就会发生异常,而如果这个程序被调试器附加,就不会发生异常,异常被调试器截获了。

int 2d 指令还能用于混淆,附加调试器程序在运行 int 2d 指令后,会跳过此指令后的 1 个字节。

查找特征码

特征检测,原本是用于检测病毒的,通过从病毒中的不同位置提取一系列字节,来构成特征码。同样我们也可以依赖该技术来识别调试器。不过不同版本的调试器,其特征码也不同。 例如,提取 OllyDbg 1.1 版本的特征码,步骤如下。

  1. 地址:401126h;特征吗:83 3D 1B 01
  2. 地址:43AA7Ch;特征吗:8D 8E 83 21 程序在运行时对当前运行的所有进程进行一次枚举,如果发现某进程地址中有这个特征码,就可以认定检测到 OllyDbg 了,代码如下:
while(Process32Next(...) != False)
{
    OpenProcess(...);
    ReadProcessMemory(...,0x401126,&buf1,4,....);
    ReadProcessMemory(...,0x43AA7C,&buf1,4,....);
    if((buf1 == 0x833d1b01)&&(buf2==0x8d8e8321)
    ....    //找到OllyDbg
}

检测 DBGHELP 模块

调试器一般使用微软提供的 DBGHELP 库来装载调试符号,所以如果一个进程加载了 DBGHELP.DLL 那么它很可能就是一个调试器。具体方法:用 CreateToolhelp32Snapshot 创建进程的模块快照,通过 Module32FirstModule32Next 来枚举模块,看看其中是否加载 DBGHELP.DLL。 不过这种检测很脆弱,一旦把 DBGHELP.DLL 改名,检测就失效了。

查找窗口

  • FindWindow:可以通过类名来查找窗口,也可以通过标题来查找窗口。如果要搜索子窗口,需要使用 FindWindowEx 函数。
  • EnumWindow:这个函数枚举了所有顶级窗口,并调用了指定的回调函数。可以在回调函数中使用 GetWindowText 得到窗口的标题,以判断其中是否有 “OllyDbg”。
  • GetForeGroundWindow:这个函数会返回前台窗口(用户当前工作的窗口)。如果程序正在被调试,调用这个函数将获得前台窗口,也就是 OllyDbg 的窗口句柄。

查找进程

枚举进程检测是否有 OllyDbg.exe 进程存在。这个方法也很好跳过,修改 OllyDbg 的进程名和标题。

SeDebugPrivilege 方法

在默认情况下,进程是没有 SeDebugPrivilege 权限的。当调试器加载进程时,进程也具有 SeDebugPrivilege 权限,这是由于调试器本身会调整并启用 SeDebugPrivilege 权限,当被调试进程加载时,SeDebugPrivilege 的权限也被继承了。

检查进程是否具有调试权限的方式很简单,系统启动的时候会启动一个核心进程 csrss.exe,我们可以通过判断能否使用 OpenProcess 打开该进程来检查当前进程是否具有调试权限,因为只有拥有管理员权限 + 调试权限的进程才能打开 csrss.exe 的句柄。严格来说这种检查方法是不太严格的,因为当进程有调试权限无管理员权限的时候也不能打开 csrss.exe 的句柄,幸运的是,大多数调试器都会要求提供管理员权限,所以被调试程序也会同时拥有管理员权限 + 调试权限。

SetUnhandledExceptionFilter

这是利用了调试器会截获进程产生的异常这一特点,来判断进程是否被调试。正常情况下,进程发生异常会首先调用进程自己的异常处理函数,而如果进程被调试,进程自己的异常处理函数就无法获得该异常。这样我们可以特意制造一个异常,并把我们后续操作放在程序的异常处理函数中。

EnableWindow

调用这个 API 可以暂时锁定前台的窗口,让用户休息一下,也让调试器无法工作。

EnbaleWindow(GetForegroundWindow(), FALSE);

BlockInput

调用 BlockInput (TRUE) 可以锁住键盘,完成工作后调用 BlockInput(FALSE)恢复。

防止调试器附加

R3 调试器附加使用 DebugActiveProcess 函数,在附加相关进程时,会先执行 ntdll.dll 下的 ZwContinue 函数,最后留在 ntdll.dll 的 DbgBreakPoint 处。事实上,调试器在这里设置了一个 INT 3 断点,由调试器自己来捕捉。当调试这按 F9 键时,调试器才会护肤这里的代码,是程序继续运行。

只要在这两个调试器的 Attach 过程中设置一点障碍,就能有效阻止程序被附加调试。

    @get_api_addr   "NTDLL.DLL","ZwContinue"
    xchg    ebx,eax
;  得到Ntdll.dll的ZwContinue地址

    call    a1
    dd  0
a1: push    PAGE_READWRITE
    push    5
    push    ebx
    call    VirtualProtect
    @check  0,"Error: cannot deprotect the region!"
;   申请内存读写权限

    lea edi,_ZwContinue_b
    mov ecx,0Fh
    mov esi,ebx
    rep movsb
;   Edi寄存器指向我们自定义的一块大小为0F的内存区域;   这正是Ntdll.ZwContinue函数的大小rep movsb指令把
;   原始ZwContinue函数复制到我们指定的ZwContinue_b处

    lea eax,_ZwContinue
    mov edi,ebx
    call    make_jump
;   _ZwContinue处地址放入eax,原函数地址放入edi,调用
;   make_jump在原函数开头构造一个跳转指令(常用伎俩)。

    @debug  "attach debugger to me now!",
MB_ICONINFORMATION
    exit: mov byte ptr [flag],1
;   正常调用,flag为1

    push    0
    @callx  ExitProcess

make_jump:
    pushad
    mov byte ptr [edi],0E9h
    sub eax,edi
    sub eax,5
    mov dword ptr [edi+1],eax
    popad
    ret
;   保留所有寄存器,构造跳转,使ZwContinue原函数跳入我
;   们的_ZwContinue执行

flag    db  0
;   定义flag,用来判断是否被附加调试

_ZwContinue:    pushad
    cmp byte ptr [flag],0
    jne we_q
    @debug  "Debugger found!",MB_ICONERROR
we_q:   popad
;   判断flag是否为0,如果为0,检测到调试器,否则继续
;   执行下面的代码,正是我们复制的ZwContinue原始代码

_ZwContinue_b:  db  0Fh dup (0)

comment $       
    77F5B638 > B8 20000000      MOV EAX,20
    77F5B63D   BA 0003FE7F      MOV EDX,7FFE0300
    77F5B642   FFD2             CALL EDX
    77F5B644   C2 0800          RETN 8
$
;   复制完成后,这里看起来应该是上边的样子
end start

这段代码来自《加密解密》,主要挂接了 Ntdll.ZwContinue。如果经过这里,则报告发现调试器,如果未经过这里,则说明程序未被附加调试。直接在程序中检测这里是否有 INT 3 指令也可以达到目的。

父进程检测

不考虑进程创建子进程的情况,当一个程序被正常启动时,其父进程应该是 Exploer.exe, cmd.exe, Services.exe 中的一个。如果不是,一般可以认为它被调试了(或者被内存补丁之类的 Loader 程序加载了)。 实现这种检测的方法:

  1. 通过 TEB (TEB. ClientId) 或者使用 GetCurrentProcessId 来检索当前进程的 PID。
  2. 通过 Process32First, Process32Next 得到所有进程的列表,判断 exeplorer.exe 的 PID(通过 PROCESSENTRY32.szExeFile)和通过 PROCESSENTRY32.th32ParentProcessID 获得当前进程的父进程 PID 是否相同。
  3. 如果父进程的 PID 不是 Exploer.exe, cmd.exe, Services.exe 的 PID,那么目标进程很可能被调试了。

时间差

这个很好理解,当程序的运行被断点打断时,CPU 会捕获异常并将其发送给调试器,调试器处理异常后,程序继续运行(这段时间显然比程序直接执行的时间要长得多)。我们可以计算一个操作到结束所花费的世界,如果耗时不合理,就确定被调试了。

PDTSC(Read Time-Stamp Counter) 指令用于获得 CPU 自开机运行起的时钟周期数。执行结果放在 eax 和 edx 中。

rdtsc
mov ecx,eax
mov ebx,edx
;省略部分代码
;计算两个RDTSC指令的偏移量
rdtsc

cmp edx, ebx     ;检测高位
ja __debugger_found
sub eax,ecx      ;检测低位
cmp eax,0x200
ja __debugger_found

当然也可以用 kernel32!GetTickCount() 函数实现对类似功能的检测。

通过 Trap Flag 检测

标志位 TF (Trap Flag),当 TF=1 时,CPU 执行 eip 中的指令后会触发一个单步异常,示例如下

pushfd
push eflags
or dword ptr [esp], 100h  ;TF=1
popfd               ;在这条指令后,TF=1
nop                 ;执行这条指令后会触发异常,故应提前安装SEH,以跳转到其他地方执行
jmp die             ;如果顺序执行下来,说明程序被跟踪了

双进程保护

在 windows 下,R3 调试器与被调试器的关系是一一对应的,一个进程中只能有一个调试器。 双进程保护就是让我们自己来调用 DebugAPI 来创建一个调试器,通过调试器创建我们的进程。

  • 加载一个进程或附加到一个正在运行的进程上。使用 CreateProcess 创建进程时,需要指定 DEBUG_PROCESS 标志来启动被调试进程,或者使用 DebugActiveProcess 函数绑定到某个正在运行的进程上。
  • 获得被调试程序的底层信息,包括进程 ID、映象基址等。使用 WaitForDebugEvent 函数等待调试事件的发生。该函数会阻止调用线程,知道获得调试信息为止。
  • 接收被调试进程发来的调试事件并对其进行处理。如果 WaitForDebugEvent 函数返回,意味着被调试进程中发生了调试事件。响应并处理该调试事件,继续执行被调试程序。

mic101/windows: windows泄露源码 (github.com) 操作系统篇-分段机制与GDT|LDT - 卫卐 - 博客园 (cnblogs.com) 《加密解密》第 4 版 Windows 堆管理机制 [1] 堆基础 - 修竹 Kirakira - 博客园 (cnblogs. com) Debugapi.h 标头 - Win32 apps | Microsoft Learn [原创]Windows 内核学习笔记之调试(上)-软件逆向-看雪-安全社区|安全招聘|kanxue. com Windows环境下病毒逆向分析,常见反调试技术手法梳理 - 先知社区 (aliyun.com) [参考]反调试技术整理 - 二氢茉莉酮酸甲酯 - 博客园 (cnblogs. com) NtGlobalFlag - CTF Wiki (ctf-wiki.org)

其他相关文章

【第贰期 REVERSE 分享会】二十余种反调试源码讲解 & IDA插件介绍_bilibili 从内核角度认识反调试基本原理 - 『软件调试区』 - 吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn 加壳原理笔记06:反调试技术入门 - 『脱壳破解区』 - 吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn vmp 3.8.1反调试分析与手动绕过 - 『脱壳破解区』 - 吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn 【原创】反调试实战系列二 TLS反调试+CheckRemoteDebuggerPresent原理 - 『软件调试区』 - 吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn

最新x32dbg/x64dbg 反反调试插件HyperHide已签名驱动文件(2023年10月9日更新签名) - 『逆向资源区』 - 吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn Windows反调试技术(下) - 怎么可以吃突突 - 博客园 (cnblogs.com)

Peb(Process Environment Block)简单学习及分析 | Lyon’s blog (youngroe.com) PEB及其武器化 - 先知社区 (aliyun.com) Anti-Debug: Debug Flags (checkpoint.com)

PEB 结构:获取模块 kernel 32 基址技术及原理分析-软件逆向-看雪-安全社区|安全招聘|kanxue. com 超详细的 3 环和 0 环断链隐藏分析-软件逆向-看雪-安全社区|安全招聘|kanxue. com 初探双进程保护-软件逆向-看雪-安全社区|安全招聘|kanxue.com