LIMB
Вход Регистрация

тема

User-mode anti-debugging через PEB и исключения (SEH)

ghotycal57 · · 0 ответов · 344 просмотров

Статья писалась исключительно для форума LIMB.cc.
Автор: ghotycal57.

Введение
Доброго времени суток.
Анти-дебаг - или, выражаясь точнее, механизмы противодействия отладке и отладчику - применяются с целью затруднить или полностью предотвратить динамический анализ исполняемого кода. В контексте reverse engineering это означает создание условий, при которых поведение программы искажается, прерывается или становится недостоверным при попытке её трассировки.

Большинство широко известных техник анти-отладки опираются на высокоуровневые API, такие как IsDebuggerPresent и CheckRemoteDebuggerPresent. Первая функция возвращает флаг наличия отладчика для текущего процесса, вторая позволяет проверить другой процесс через дескриптор. Однако оба подхода работают на уровне WinAPI и потому легко обходятся - достаточно перехватить вызов или подменить возвращаемое значение.

Следовательно, если наша цель - не формальная проверка, а реальное обнаружение вмешательства, необходимо опускаться на уровень ниже. В данной статье мы рассмотрим два ключевых механизма: доступ к внутренним структурам процесса через PEB, где напрямую хранится информация о состоянии отладки, а также использование механизма исключений Windows (SEH) как инструмента активного обнаружения.

Механизм обработки исключений для меня вызывает больше интереса нежели два варианта выше. В нормальном режиме выполнение программы при возникновении исключения (например, int3, access violation или guard page) передаётся SEH обработчику. Однако при наличии отладчика управление может быть перехвачено раньше, ещё до передачи в обработчики процесса. Это создаёт наблюдаемый эффект: поведение программы при генерации исключения меняется в зависимости от того, присутствует ли отладчик. Вообщем, рассмотрим в дальнейшем.
Начинаем.

Спойлер: Макрос section.inc из кода
C:
macro .code {
    section '.text' code readable executable
}

macro .data {
    section '.data' data readable writeable
}

macro .idata {
    section '.idata' import data readable writeable
}

1. IsDebuggerPresent / CheckRemoteDebuggerPresent
Первый способ реализуется непосредственно через API Windows, логика довольно проста - условно на примере IsDebuggerPresent.
Данная функция возвращает ненулевое значение, если текущий процесс выполняется под управлением отладчика. Внутренне её реализация сводится к чтению соответствующего флага из структуры PEB, а именно поля BeingDebugged. То есть это некая обертка, чтобы не делать вручную работу с PEB самим.

Простейший пример использования выглядит следующим образом:
Не удалось прогрузить медиа

Спойлер: Code
C:
format PE64 console
entry start
include 'win64a.inc'
include 'C:\FLAT\section.inc'

.data
information db "This is test programm for Debug | author: ghotycal57", 10, 0
; ----
dls dq 5
hProcess dq 0
isDbg dq 0

.code
start:
        sub rsp, 28h
     
        lea rcx, [information]
        call[printf]
        call[getch]
        ; ---

        call [GetCurrentProcess]
        mov [hProcess], rax

        call [IsDebuggerPresent]
        mov rcx, rax
        call [ExitProcess]

        mov rcx, [hProcess]
        lea rdx, [isDbg]
        call [CheckRemoteDebuggerPresent]

        add rsp, 28h
        ret

.idata
library kernel32, 'kernel32.dll',\
        msvcrt,   'msvcrt.dll'

import kernel32,\
       ExitProcess, 'ExitProcess',\
       GetCurrentProcess, 'GetCurrentProcess',\
       IsDebuggerPresent, 'IsDebuggerPresent',\
       CheckRemoteDebuggerPresent, 'CheckRemoteDebuggerPresent'

import msvcrt,\
       printf, 'printf',\
       getch, '_getch'
Программа очень проста.
Мы последовательно вызываем функции GetCurrentProcess, IsDebuggerPresent и CheckRemoteDebuggerPresent, чтобы получить информацию о состоянии текущего процесса с точки зрения наличия отладчика. Сначала извлекается псевдодескриптор текущего процесса, который затем может быть использован для дальнейших проверок.
Далее вызывается IsDebuggerPresent, которая возвращает результат напрямую через регистр RAX. Это быстрый и тривиальный способ определить наличие отладчика, основанный на внутреннем флаге в PEB. Полученное значение используется как код завершения процесса, что позволяет сразу увидеть результат работы функции.
После этого, в более корректной реализации, можно использовать CheckRemoteDebuggerPresent, передав ей дескриптор процесса и указатель на переменную, в которую будет записан результат проверки. В отличие от предыдущей функции, здесь используется косвенная передача результата через память.

Давайте же посмотрим что у нас получится в отладчике:
Не удалось прогрузить медиа

После открытия отладчика всё становится на свои места. Как уже упоминалось ранее, функция IsDebuggerPresent не содержит сложной логики и по сути является обёрткой над чтением структуры PEB.
C:
00007FFF368AB6C0 | 6548:8B0425 60000000     | mov rax,qword ptr gs:[60]               |
00007FFF368AB6C9 | 0FB640 02                | movzx eax,byte ptr ds:[rax+2]           |
00007FFF368AB6CD | C3                       | ret                                     |
Инструкция mov rax, qword ptr gs:[60] получает адрес структуры PEB текущего процесса. В x64 Windows сегментный регистр GS используется для доступа к структурам, привязанным к потоку, и по смещению 0x60 как раз находится указатель на PEB.
Далее инструкция movzx eax, byte ptr [rax+2] читает байт по смещению 0x2 от начала PEB. Это и есть поле BeingDebugged. Значение расширяется до регистра EAX, который используется как возвращаемое значение функции.
Если в этом байте находится 1 - процесс запущен под отладчиком. Если 0 - отладчик отсутствует.
Последняя инструкция ret завершает выполнение функции, возвращая управление вызывающему коду.

Что же касается второй функции anti-debug-а, то работает она несколько иначе:
Не удалось прогрузить медиа

В отличие от IsDebuggerPresent, функция CheckRemoteDebuggerPresent не возвращает результат напрямую через регистр, а использует указатель, переданный в качестве аргумента. Это означает, что результат проверки записывается в память, на которую указывает второй параметр функции.

В отладчике можно увидеть начало её выполнения:

C:
00007FFF368F0960 | 48:895C24 10             | mov qword ptr ss:[rsp+10],rbx           |
Данная инструкция сохраняет значение регистра RBX на стек. Это стандартный пролог функции - сохранение невольтильных регистров перед выполнением основной логики.
Дальнейшая логика функции сводится к вызову внутренних механизмов Windows, в частности NtQueryInformationProcess с параметром ProcessDebugPort или ProcessDebugFlags. Через этот системный вызов ядро возвращает информацию о том, привязан ли к процессу отладчик:
Не удалось прогрузить медиа

То есть логика такова:
  • В RCX регистр передается дескриптор процесса;
  • В RDX регистр передается указатель на переменную для результата;
  • Внутри функции выполняется системный вызов;
  • Полученное значение интерпретируется и записываается по адресу и з RDX.
Итог:
Если 1, - то программа под отладчиком.
Если 0, - программа НЕ под отладчиком.

2. Погружаемся в бездну PEB.
А вот теперь мы познаем, как условно та же функция IsDebuggerPresent работает под капотом, как я уже писал выше. Несмотря на кажущуюся простоту, эта функция всего лишь читает один байт по фиксированному смещению в структуре PEB.
Сначала процесс получает указатель на PEB через сегментный регистр GS по смещению 0x60. Именно там Windows хранит внутренние данные о процессе, включая флаг BeingDebugged, который и указывает на наличие отладчика.
Далее байт читается и расширяется до регистра EAX, который возвращается как результат функции. Этот простой приём позволяет API скрыть детали работы с внутренними структурами и предоставить быстрый доступ к информации о состоянии процесса.

А также сразу хотелось бы рассказать про другое смещение, связанное с NtGlobalFlag.
Помимо поля BeingDebugged, в структуре PEB существует ещё один интересный элемент - NtGlobalFlag. Он расположен по смещению 0xBC (для x64) и используется системой для хранения различных флагов режима выполнения процесса.
При запуске программы под отладчиком некоторые биты в NtGlobalFlag автоматически устанавливаются. В частности, это флаги, связанные с отладочной кучей:

1. FLG_HEAP_ENABLE_TAIL_CHECK
2. FLG_HEAP_ENABLE_FREE_CHECK
3. FLG_HEAP_VALIDATE_PARAMETERS
В совокупности они дают значение 0x70, которое часто используется как индикатор наличия отладчика.
Проверка двух этих способов выглядит следующим образом:
Не удалось прогрузить медиа

Спойлер: Code
C:
format PE64 console
entry start
include 'C:\FLAT\INCLUDE\win64a.inc'
include 'C:\FLAT\section.inc'

.data
information db "This is test programm for Debug | author: ghotycal57", 10, 0
; ----
.code
start:
        sub rsp, 28h
     
        lea rcx, [information]
        call[printf]
        call[getch]
        ; ---
        mov rax, [gs:60h] ; peb
        movzx eax, byte [rax+2]
        test eax, eax
        jnz debugger_detect
     
        ; nt
        mov rax, [gs:60h]
        mov eax, [rax+0BCh]
        and eax, 70h
        test eax, eax
        jnz debugger_detect
     
    debugger_detect:
        xor ecx, ecx
        call[ExitProcess]

        add rsp, 28h
        ret

.idata
library kernel32, 'kernel32.dll',\
        msvcrt,   'msvcrt.dll'

import kernel32,\
       ExitProcess, 'ExitProcess'

import msvcrt,\
       printf, 'printf',\
       getch, '_getch'
В первом случае мы обращаемся к полю BeingDebugged и проверяем его напрямую. Если значение ненулевое - процесс находится под отладчиком.
Во втором случае читается NtGlobalFlag, после чего применяется маска>0x70, позволяющая выделить биты, связанные с отладочной кучей. Если хотя бы один из них установлен, это также указывает на возможное присутствие отладчика.
Давайте проверим программу под отладчиком:
Не удалось прогрузить медиа

Как видно на скриншоте, оба метода работают идеально и отоброжаются.
C:
00402017 | 68 60000000         | push bendix.402060
0040201C | 64:FF35 00000000    | push dword ptr fs:[0]
00402023 | 64:8925 00000000    | mov dword ptr fs:[0],esp
0040202A | CD 03               | int 3
0040202C | B8 01000000         | mov eax,1
00402031 | EB 16               | jmp bendix.402049
00402033 | B8 00000000         | mov eax,0
1. mov rax, qword ptr gs:[60h]  - берется адрес PEB текущего потока(в x64 - GS)
2. movzx eax, byte ptr [rax+2] - читается поле BeingDebugged
Двигаемся дальше
Если там 1 - отладчик есть, если 0 - нет.

Про NtGlobalFlag я уже чуть выше объяснял, дублировать думаю нет смысла.
3. Анти-отладка через SEH-исключение
Далее у нас идёт анти-отладка через исключение, метод довольно прост, рассматривать я его буду на x86, так как будет легче.
Суть метода в том, что программа сознательно генерирует исключение, например делением на ноль или инструкцией INT 3. В обычных условиях выполнение передаётся стандартной цепочке обработчиков исключений в самом процессе. Программа может перехватывать это событие через Structured Exception Handling (SEH) или Vectored Exception Handling (VEH) и корректно реагировать на него. Мы рассмотрим SEH.
Однако если процесс запущен под отладчиком, управление часто перехватывается им раньше, чем доходит до обработчиков программы. Это создаёт заметное различие в поведении: в отладчике исключение либо останавливает выполнение, либо вызывает особое поведение, которое можно зарегистрировать.
Пример:
Не удалось прогрузить медиа

Спойлер: Code
C:
format PE console
entry start
include 'C:\FLAT\INCLUDE\win32a.inc'
include 'C:\FLAT\section.inc'

.data
information db "This is test programm for Debug | author: ghotycal57", 10, 0

.code
start:
        push ebp
        mov ebp, esp

        push information
        call [printf]
        add esp, 4

        call [getch]

        push seh_handler
        push dword [fs:0]
        mov [fs:0], esp

        int 3

        mov eax, 1
        jmp exit

seh_handler:
        mov eax, 0
        mov esp, [esp+8]
        mov [fs:0], esp
        add esp, 4
        xor eax, eax
        ret

exit:
        push eax
        call [ExitProcess]

.idata
library kernel32, 'kernel32.dll',\
        msvcrt,   'msvcrt.dll'

import kernel32,\
       ExitProcess, 'ExitProcess'

import msvcrt,\
       printf, 'printf',\
       getch, '_getch'
В коде всё просто, мы вручную регистрируем обработчик исключений через цепочку SEH, после чего искусственно генерируем исключение и анализируем поведение программы.
Сначала в стек помещается адрес нашего обработчика seh_handler, затем сохраняется предыдущий обработчик из fs:[0]. После этого мы обновляем fs:[0], тем самым устанавливая свой обработчик в начало цепочки SEH.
Далее выполняется инструкция int 3, которая генерирует исключение типа breakpoint. В нормальном режиме, без отладчика, управление передаётся нашей функции seh_handler, так как она находится первой в цепочке обработчиков.
Если выполнение действительно попало в seh_handler, значит исключение было обработано внутри процесса. В этом случае мы корректно восстанавливаем цепочку SEH, возвращаем управление и устанавливаем результат, указывающий на отсутствие отладчика.
Также чуть не забыл про CONTEXT. При работе с SEH в x86 важно понимать, что на стеке в момент вызова обработчика лежит не только указатель на предыдущий SEH, но и структуры исключения и контекст потока.

Однако же, если программа запущена под отладчиком, ситуация меняется. Отладчик перехватывает int 3 раньше, чем управление дойдёт до нашего обработчика. В результате выполнение либо останавливается на точке останова, либо идёт по другому сценарию, и наш seh_handler не вызывается.
Откроем отладчик и посмотрим(важно, для x86 - x32dbg.exe):
Не удалось прогрузить медиа

C:
00402000 | 55                       | push ebp                                |
00402001 | 89E5                     | mov ebp,esp                             |
00402003 | 68 00104000              | push bendix.401000                      | 401000:"This is test programm for Debug | author: ghotycal57\n"
00402008 | FF15 84304000            | call dword ptr ds:[<printf>]            |
0040200E | 83C4 04                  | add esp,4                               |
00402011 | FF15 88304000            | call dword ptr ds:[<_getch>]            |
00402017 | 68 33204000              | push bendix.402033                      |
0040201C | 64:FF35 00000000         | push dword ptr fs:[0]                   | [00000000]:&"яяяяЙ“іw"
00402023 | 64:8925 00000000         | mov dword ptr fs:[0],esp                | [00000000]:&"яяяяЙ“іw"
0040202A | CD 03                    | int 3                                   |
0040202C | B8 01000000              | mov eax,1                               | eax:&"яяяяЙ“іw"
00402031 | EB 16                    | jmp bendix.402049                       |
00402033 | B8 00000000              | mov eax,0                               | eax:&"яяяяЙ“іw"
00402038 | 8B6424 08                | mov esp,dword ptr ss:[esp+8]            | [esp+08]:BaseThreadInitThunk
0040203C | 64:8925 00000000         | mov dword ptr fs:[0],esp                | [00000000]:&"яяяяЙ“іw"
00402043 | 83C4 04                  | add esp,4                               |
00402046 | 31C0                     | xor eax,eax                             | eax:&"яяяяЙ“іw"
00402048 | C3                       | ret                                     |
00402049 | 50                       | push eax                                | eax:&"яяяяЙ“іw"
0040204A | FF15 60304000            | call dword ptr ds:[<ExitProcess>]       |
Как видно, все прекрасно работает, в отладчике выполнение останавливается на инструкции int 3, так как она воспринимается как breakpoint. Управление в этот момент перехватывается самим отладчиком, и до нашего обработчика seh_handler выполнение не доходит.
То есть несмотря на то, что мы зарегистрировали свой SEH через fs:[0], отладчик имеет приоритет и обрабатывает исключение раньше. В результате поток выполнения идёт дальше по основной ветке, где устанавливается eax = 1 и происходит переход к завершению программы.
Если же запустить программу вне отладчика, ситуация будет иной. Инструкция int 3 вызовет исключение, которое не будет перехвачено внешним инструментом, и управление перейдёт в наш seh_handler.

Заключение
В заключение хочется отметить, что рассмотренные способы - это далеко не все существующие методы анти-отладки. Их очень много, но в данной статье я показал базовые приёмы, которые должен знать каждый реверсер.
В будущих материалах, если эта статья окажется интересной для читателей, планирую рассмотреть более сложные техники, продвигаясь шаг за шагом и изучая их постепенно.
Всем удачи.
👍25 😮6 💯3

Гостевой просмотр. Чтобы отвечать и ставить реакции, войдите или зарегистрируйтесь.