Статья ASM. Борьба с точками останова BreakPoint на WinAPI.

Бытует мнение, что отладчики получили своё название благодаря обычному жуку (англ. Bug), который пролез в первый компьютер эпохи неолита 1940-50х годов, и нарушил его работу, застряв между ферритовыми кольцами . Так появился термин «Debugger», что подразумевает борьба с ошибками. За свой почтенный возраст более чем 80 лет, из гадкого утёнка отладчик превратился в прекрасного лебедя – основной инструмент реверс-инженеров, без которого поиск багов в софте просто невозможен. Такие монстры как дизассемблер IDA-Pro, отладчик ядра ОС WinDbg, и x64Dbg пользовательского уровня, не оставляют никаких шансов на выживание малвари, а немаловажную роль играет здесь способность дебагеров устанавливать точки останова на произвольные участки кода «BreakPoint». В данной статье приводятся несколько способов обхода «бряков» на Win32API в надежде, что вы найдёте им применение на практике.

Оглавление:
  1. Системная поддержка отладки
  2. Программная реализация точек останова
  3. Способы противодействия
  4. Практика
  5. Заключение.


1. Системная поддержка отладки

Процессоры архитектуры х86 имеют аппаратную поддержку отладки, для чего предусмотрен «TrapFlag» TF = бит 8 в регистре флагов EFLAGS (пошаговая трассировка), а так-же 8 регистров спец.назначения DR0-DR7. Более того, начиная с Pentium был добавлен и модельно-специфичный регистр управления MSR.IA32_DEBUGCTL=0x01D9, который позволяет более тонко контролировать процессы ветвления в коде отладки, например заходить внутрь функции клавишей F7, или пропускать циклы по F4.

Поскольку аппаратная поддержка требовательна к ресурсам, имеется и программная реализация пошагового исполнения, которая базируется на том-же флаге трассировке TF. Когда этот флаг взведён, процессор останавливается после исполнения каждой инструкции, передавая управление обработчику прерывания INT-1h. По сути номер этого «инта» можно переназначить на произвольный (путём перепрограммирования контроллёра PIC/APIC), но так повелось, что при инициализации BIOS привязывает к Trap’у именно INT-1h. Чуть позже появилось и прерывание INT-3h под кличкой «BreakpointTrap», которое позволяет взводить флаг TF по значению регистра-указателя команд EIP (Instruction Pointer), что равносильно достижении процессором произвольно взятого адреса в памяти.

Что касается защищённого режима ЦП в Windows, то для установки брейков (точек останова, далее ВР) здесь используется опкод со-значением 0xCC, который перехватывается системой для дальнейшей передачи управления на всё тем-же обработчикам INT-1/3h. Если отладчиком WinDbg заглянуть в таблицу диспетчеризации прерываний командой !idt, то в её логе можно обнаружить указатели на обработчики этих прерываний, и соответственно дизассемблировать их по uf. Поскольку листинги получаются весьма объёмными, я сократил их ограничившись ключом /i (возвращает кол-во инструкций), и ключом (только вызовы процедур call без дизасма). Как видим, на обработку брейков ЦП тратит вдвое больше инструкций =833, чем на обычный трап =492:

Код:
0: kd> !idt     <------ Interrupt Descriptor Table
Dumping IDT:

00:   fffff800028ec900  nt!KiDivideErrorFault
01:   fffff800028ecc00  nt!KiDebugTrapOrFault    <------- Trap = Step | INT-01
02:   fffff800028ed080  nt!KiNmiInterrupt
03:   fffff800028ed480  nt!KiBreakpointTrap      <------- Breakpoint  | INT-03
04:   fffff800028ed780  nt!KiOverflowTrap
05:   fffff800028eda80  nt!KiBoundFault
06:   fffff800028edd80  nt!KiInvalidOpcodeFault
07:   fffff800028ee380  nt!KiNpxNotAvailableFault
08:   fffff800028ee640  nt!KiDoubleFaultAbort
............

0: kd> uf /i /c fffff800028ecc00
nt!KiDebugTrapOrFault (fffff800`028ecc00), 492 instructions  <---------------
    call to nt!KiBugCheckDispatch              (fffff800`028f20c0)
    call to nt!KiCheckForSListAddress          (fffff800`0286ed00)
    call to nt!KiExceptionDispatch             (fffff800`028f2140)
    call to nt!KiFlushBhbDuringTrapEntryOrExit (fffff800`028f2f00)
    call to nt!KiProcessNMI                    (fffff800`02957fa0)
    call to nt!KiRestoreDebugRegisterState     (fffff800`028e2900)
    call to nt!KiSaveDebugRegisterState        (fffff800`028e2970)
    call to nt!KiSaveProcessorState            (fffff800`0293f9a0)
    call to nt!KiSetSpecCtrlNmi                (fffff800`028e6520)
    call to nt!KxDebugTrapOrFault+0x202        (fffff800`028ecec2)
    call to nt!KxNmiInterrupt                  (fffff800`028ed380)


0: kd> uf /i /c fffff800028ed480
nt!KiBreakpointTrap (fffff800`028ed480), 833 instructions  <-----------------
    call to nt!KiBoundFault+0x20b              (fffff800`028edc8b)
    call to nt!KiBreakpointTrap+0x202          (fffff800`028ed682)
    call to nt!KiCopyCounters                  (fffff800`0293cd00)
    call to nt!KiExceptionDispatch             (fffff800`028f2140)
    call to nt!KiFlushBhbDuringTrapEntryOrExit (fffff800`028f2f00)
    call to nt!KiInitiateUserApc               (fffff800`028e7ae0)
    call to nt!KiInvalidOpcodeFault+0x20b      (fffff800`028edf8b)
    call to nt!KiOverflowTrap+0x202            (fffff800`028ed982)
    call to nt!KiPreprocessInvalidOpcodeFault  (fffff800`028cb900)
    call to nt!KiRestoreDebugRegisterState     (fffff800`028e2900)
    call to nt!KiSaveDebugRegisterState        (fffff800`028e2970)
    call to nt!KiUmsExit                       (fffff800`028f2d80)
    call to nt!KiUmsTrapEntry                  (fffff800`028f2900)
0: kd>

На уровне пользовательского режима, отладка поддерживается специальным классом из либы Kernel32.dll. Все отладчики третьего кольца (такие как x64Dbg, OllyDbg, и прочие) используют функции именно из этой библиотеки, а для аппаратных точек на чтение/записи/исполнение памяти и обращения к портам ввода-вывода, обращаются к упомянутым регистрам DR0-DR7. Детали устройства этих регистров уже обсуждались в статье «Скрытый потенциал регистров отладки», так-что не буду здесь повторяться.


2. Программная реализация точек останова

Прежде чем что-то ломать, необходимо чётко представлять себе архитектурные особенности и программную реализацию клиента, а потому проведём небольшие эксперименты на практике, загрузив подопытное приложение в отладчик, и установив точку-останова, например, на повсеместно используемую функцию вывода сообщений в окно MessageBox().

Sample.webp

Теперь запустим отладчик x64Dbg и посмотрим, как он отреагирует на команду установки брейка bp MessageBoxA в ком.строке, и запуске приложения на исполнение по F9. Здесь видим, что ловушка Trap сработала и мы оказались в нёдрах библиотеки User32.dll, однако опкода 0xCC не видно ни в окне кода, ни в окне дампа памяти, которому мы задали адрес функции MessageBox() = 0x77AF1A20 – это продемонстрировано на скрине ниже. По идее опкод ВР должен был заменить первый-же байт функции со-значением (в данном случае) 0x48, играющего роль префикса 64-битной инструкции. Тогда каким-же образом сработал пошаговый механизм отладчика?

Break.webp

Дело в том, что у страниц памяти системных библиотек всегда стоят атрибуты Execute-Read, что потенциально предотвращает возможность их модификации Write (а как иначе записать туда опкод 0xCC). Поэтому отладчики в своих тушках имеют ассоциативные списки учёта точек-останова, где каждая запись имеет формат Address:Type:Status (адрес, тип брейка, и его состояние вкл/выкл), которые и проверяются при исполнении отлаживаемого кода. Например вот лог WinDbg, в котором я открыл это-же приложение, и установив брейк на MessageBox(), после его срабатывания проверил флаги состояния памяти – как видим это PAGE_EXECUTE_READ без доступа на запись:

Код:
0:000> bp @$exentry   <-------- WinDbg встаёт на системной точке, поэтому меняем её на ЕР софта
0:000> bl
  0 e 00000000`00402000     0001 (0001)  0:**** image00000000_00400000+0x2000  <--- Правильная точка входа

0:000> g
  Breakpoint 0 hit
  image00000000_00400000+0x2000:
  00000000`00402000 4883ec08        sub     rsp,8

0:000> bp user32!MessageBoxA   <----- Ставим брейк на WinAPI и проверяем лист (е = Enable)
0:000> bl
  0 e 00000000`00402000     0001 (0001)  0:**** image00000000_00400000+0x2000
  1 e 00000000`77af1a20     0001 (0001)  0:**** user32!MessageBoxA

0:000> g
  Breakpoint 1 hit
  user32!MessageBoxA:
  00000000`77af1a20 4883ec38        sub     rsp,38h

0:000> !address user32!MessageBoxA   <------------- Сбор информации об адресе

Usage:             Image
Allocation Base:   00000000`77a80000
Base Address:      00000000`77a81000
End Address:       00000000`77b02000
Region Size:       00000000`00081000
Type:              01000000  MEM_IMAGE
State:             00001000  MEM_COMMIT
Protect:           00000020  PAGE_EXECUTE_READ   <---- Упс: Запрет на запись!
More info:         lmv m user32
More info:         !lmi user32
More info:         ln 0x77af1a20

Таким образом опкод 0xCC прерывания INT-3 на самом деле существует, просто все инструменты отладки маскируют его в своих списках учёта брейков хотя-бы потому, что иных способов заставить ЦП остановиться на указанном адресе просто нет. Отладчик – это далеко не та сущность, которой следует доверять, поскольку он функционирует только в своих интересах. Разработчики стараются по максимуму приблизить обстановку к реальному положению дел, в результате чего многое приходится скрывать и эмулировать. В этом легко убедиться, если вставить в код небольшую процедуру проверки первого байта API-функции на опкод 0xCC например так:

C-подобный:
format   pe64 console    ;// микс консольного приложения с гуем
include 'win64ax.inc'
entry    start
;//-----------
section '.data' data readable writeable
buff     db      0

;//-----------
section '.text' code readable executable writeable
start:   sub     rsp,8

         mov     rax,[MessageBox]
         push    rax                  ;// запомнить адрес MessageBox()

        cinvoke  wsprintf,buff,<'Адрес MessageBoxA: 0x%016I64X',0>,rax  ;// заполнить буф строкой

         pop     rsi
         cmp     byte[rsi],0xCC       ;//<----------- проверить первый байт на опкод 0xCC = INT-3h
         jnz     @f                   ;// пропустить, если нет

        cinvoke  printf,<10,' Warning! Breakpoint in MessageBoxA',0>  ;// иначе вкл сирену!

@@:      invoke  MessageBox,0,buff,<'Anti-Breakpoint',0>,0        ;// просто форточка с мессагой
@exit:   invoke  ExitProcess, 0
;//-----------
section '.idata' import data readable writeable
library  kernel32,'kernel32.dll',user32,'user32.dll',msvcrt,'msvcrt.dll'
include 'api\kernel32.inc'
include 'api\user32.inc'
include 'api\msvcrt.inc'

Break_1.webp


3. Способы противодействия точкам останова

От сюда следует, что если мы хотим избавиться в своём коде от брейков на WinAPI, то достаточно функцией VirtualProtect() открыть на запись страницу требуемой библиотеки (в данном случае user32.dll), после чего восстановить оригинальный/первый байт точки-входа в брякнутую функцию. Но проблема здесь в том, что для антивирусов подобная тактика неприемлема, и при обращении к системным DLL с попыткой записи, они тут-же поднимают тревогу, незаслуженно отправляя нашу софтину в блэк-лист. Поэтому малварь и прочие хитрые зверки поступают иначе..

А что, если в своём коде мы будет вызывать API-функцию не с первого байта, а например с восьмого? Тогда установленный отладчиком на точке-входа в функцию брейк уже не сработает, поскольку мы тупо «перепрыгнем» его, и он вообще не получит управления. Звучит заманчиво, но здесь мы сталкиваемся с некоторыми проблемами, которые в принципе решить можно, хотя и с некоторыми исключениями. В общем будем преследовать следующий алгоритм:

1. Копируем пролог функции в несколько байт в специально выделенный для этих целей свой буфер. Он обязательно должен иметь атрибуты Execute-Read-Write. Можно создать отдельную секцию в программе, или-же просто открыть на запись уже имеющуюся секцию-кода. В силу того, что у всех упаковщиков типа UPX код всегда открыт на запись, аверы легко пропускают его между ног, что играет нам только на руку.​
2. В архитектуре х86 размер инструкций носит переменный характер, и варьируется в широком диапазоне от 1 до 16-байт. Поэтому нам нужно в качестве копируемого пролога вычислить такое кол-во байт, чтобы не «разорвать» инструкцию пополам. Это основная проблема, к которой нужно отнестись серьёзно. К счастью содержимое большинства функций WinAPI переходит от версии к версии ОС практически без изменений, при условии одинаковой их разрядности х32 или х64.​
3. Определившись с размером пролога, мы должны будем джампом JMP передать управление на следующий за прологом байт, а значит нужно сохранить и его адрес. При этом прыгать обязательно нужно по абсолютному, а не относительному адресу, т.к. последний представляет собой смещение от текущего. Достаточно в качестве аргумента инструкции JMP использовать любой из регистров ЦП.​

На скринах ниже представлены две исследованные мною функции – это наша подопытная MessageBox(), и для разнообразия printf() из либы рантайма msvcrt.dll. Как видим, на системах Win10 и Win7 содержимое первой функции буквально идентично как под копирку, и для реализации задуманного нам будет достаточно скопировать в свой буф первые 7-байт, которые я выделили в блок. Соответственно управление джампом буду передавать на следующий за этим прологом адрес, вычислив его динамически в регистре RSI инструкцией копирования rep movsb. При желании можно прихватить N-нное число и следующих байт, просто в этом нет особого смысла:

MBox.webp

А вот состав инструкций функции printf() уже немного отличается на разных осках, хотя и здесь достаточно идентичных байт в прологе. Аналогичную картину можно наблюдать и во-всех остальных WinAPI, а если нет, то лучше сразу отказаться от рассматриваемой нами техники анти-брейкпоинтов.

Printf.webp


4. Практика

Ну и под занавес напишем приложение, которое соберёт под один капот всё вышеизложенное.
Чтобы суть всплыла наружу, я оставлю только самое необходимое, хотя для маскировки непосредственно факта копирования пролога, имеет смысл озадачить этим секцию TLS – если реверсер «недавно встал с горшка», авось не додумается включить в отладчике опцию «Останов на секции TLS». А в остальном всё описано в комментах:

C-подобный:
format   pe64 console
include 'win64ax.inc'
entry    start
;//-----------
section '.data' data readable writeable
MsgBoxEntry    dq  0
msg            db  'Установи на меня Breakpoint!',0
buff           db  0

;//-----------
section '.text' code readable executable writeable  ;//<--- доступ на запись
;//----- TLS callback --------------------
proc  DeleteBPoint
         mov     rsi,[MessageBox]    ;// взять адрес функции
         mov     edi,MyMBox          ;// приёмный буфер
         mov     ecx,7               ;// байт для копирования
         rep     movsb               ;// перегнать из RSI в RDI
         add     byte[MyMBox+3],8    ;// сбить сигнатуру пролога в буфере
         mov     [MsgBoxEntry],rsi   ;// запомнить указатель для JMP
         ret
endp

align    8
start:   sub     rsp,8   ;//<----------- Точка входа!
frame
         invoke  GetCurrentThread        ;// здесь основной код программы..
         invoke  Wow64GetThreadSelectorEntry,rax,10h,buff
endf
        cinvoke  printf,<10,' **** Hello Hacker! ****',0>  ;// напечатаем что-нибудь

         cmp     byte[MyMBox],0xCC    ;// чекнуть первый байт на INT-3
         jnz     @f                   ;// пропустить, если ложь
         dec     ebp                  ;// иначе: мусор
         mov     edx,ebp              ;//
         mov     byte[MyMBox],0x48    ;// ...и восстановить оригинал!

@@:      push    0 0 0                ;// вызов API через свой буф
         pop     rcx r8 r9            ;// аргументы MessageBox()
         mov     rdx,msg              ;//
         mov     rsi,[MsgBoxEntry]    ;// RSI = адрес после пролога
         call    MyMBox               ;// передать на него управление!!!

@exit:  cinvoke  _getch               ;// ждём клавишу..
        cinvoke  exit, 0              ;// на выход

;//*************** Буфер для копирования пролога API ***************
MyMBox:  rb      7          ;// резерв для первых 7 байт           *
         add     rsp,8      ;// восстановить сигнатуру пролога     *
         jmp     rsi        ;// вызвать API не с первого байта!    *
;//*****************************************************************

section '.idata' import data readable writeable
library  msvcrt,'msvcrt.dll',kernel32,'kernel32.dll',user32,'user32.dll'
include 'api\msvcrt.inc'
include 'api\kernel32.inc'
include 'api\user32.inc'
;//--------------------------- Секция TLS ----------------------------------------
data  9
          dq  @tls,@tls,@tls   ;// линк на начало/конец лок.памяти, и индекс треда
          dq  cback            ;// линк на Callback цепочку
;//******************** Статическая память TLS ***********************************
@tls      dq  0                ;// будет индексом треда (заполняет загрузчик)    *
cback     dq  DeleteBPoint,0   ;// цепочка указателей на Callback (нультерминал) *
;//*******************************************************************************
end  data

Result.webp


5. Заключение

Описанный в данной статье способ противодействия точкам-останова на Win32API отлично справляется со своими обязанностями будучи загруженным на любой отладчик, включая WinDbg, x64Dbg, IDA-Pro, Immunity, и всей их братии. Конечно-же в данном примере все карты раскрыты и обнаружить секрет не составит особого труда. Однако в реальных программах сотни тысяч строк кода, и если запустить его в миксер, то реверсер будет долго недоумевать, почему-же не срабатывает установленная им точка-останова. В общем техника имеет право на существование, а использовать её или нет уже решать вам. В скрепке лежит исполняемый файл для тестов – попробуйте в отладчике установить брейк на MessageBoxA() и протестите фишку. Всем удачи, пока!
 

Вложения

Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!