Статья Как усложнить анализ приложения

Logo.jpg

Международным днём защиты информации по праву считается 30 ноября. Маховик этого праздника раскрутил обычный студент «Роберт Моррис», который в 1988 году создал своего интернет-червя. Какой им был нанесён вред – это отдельная история, зато он заставил весь мир понять, почему кибербезопасности нужно уделять особое внимание. Если коротко, то червь Морриса использовал несколько приёмов стелсирования своей тушки:
  1. Удаление своего исполняемого файла после запуска.
  2. Размер аварийного дампа устанавливался в ноль.
  3. Каждые три минуты порождался дочерний поток, а родительский завершался (при этом постоянно менялся pid, и обнулялось время работы).
  4. Все текстовые строки были закодированы по xor 81h.
Справедливости ради нужно отметить, что все перечисленные приёмы актуальные и по сей день,
просто за 35 лет безопасность систем вышла уже на новый уровень, а потому реализация будет отличаться.

В данном материале я попытаюсь выразить своё мнение на этот счёт. Основной посыл – это шекель в копилку реверс-инженеров, чтобы в своей практике они могли обнаружить уже знакомые участки кода. Краткий список основных моментов выглядит примерно так:
  1. Борьба с отладчиком – программное удаление точки-входа на ОЕР.
  2. Борьба с дизассемблером – вызов API-функций по хэшу, печать строк по зашифрованным указателям.
  3. Создание копии своего исполняемого файла из образа в памяти.
  4. Запуск ксеро-копии, с последующим удалением себя.

1. Противодействие отладке

Сейчас все уважающие себя отладчики имеют встроенные механизмы «анти-анти-дебаг», что полностью сводит на нет попытки с нашей стороны скрыться от них. Вы только посмотрите на список боеприпасов плагина x64dbg под названием «ScyllaHide» – да это же зверь, который показывает даже то, о чём некоторые из нас и не подозревали. Ну и как теперь выживать в таких условиях?

ScyllaHide.png

Таким образом, любой из перечисленных выше вариантов в зародыше обречён на провал, но одна лазейка всё-же осталась.
Откроем в x64dbg любой файл, и сразу перейдём на вкладку «Журнал», где будет лог процесса первичной его инициализации:

Код:
Инициализация отладчика...
. . . .
Инициализация успешно завершена!
Загрузка плагинов...
Обработка командной строки...
Процесс запущен: 00400000 F:\Install\DEBUG\ASM\CODE\test.exe

Точка останова по адресу 00403000 (останов в точке входа) установлена!

DLL загружена: 77810000 C:\Windows\SysWOW64\ntdll.dll
DLL загружена: 75D20000 C:\Windows\SysWOW64\kernel32.dll
DLL загружена: 75B30000 C:\Windows\SysWOW64\KernelBase.dll
DLL загружена: 75440000 C:\Windows\SysWOW64\user32.dll
DLL загружена: 770B0000 C:\Windows\SysWOW64\msvcrt.dll

Достигнута системная точка останова!
INT3 "останов в точке входа" на <test.EntryPoint> (00403000)!

Здесь видно, что движок отладки ставит точку на ОЕР ещё до загрузки системных библиотек. В практической реализации это выглядит как запись опкода инструкции int3=0xCC по адресу 0х00403000, в результате чего отладчик останавливается при достижении данного адреса. Суть в том, что мы можем обратно восстановить оригинальную инструкцию на точке-входа в программу, тогда процесс отладки останется для юзера уже за бортом, по типу клавиши F9 «Выполнить всё». Ясно, что секция-кода для этого должна быть открыта на запись, а во-вторых осуществлять подмену нужно исключительно посредством колбэков TLS. Например у меня код программы специально начинается с инструкции NOP, и я успешно борюсь с отладчиком так:

C-подобный:
proc  AntiDebug       ;<---------- Вызывается из секции TLS
      invoke  GetModuleHandle,0  ;
      mov     ebx,eax            ; наш базовый адрес в памяти: «ImageBase»
      mov     ecx,[eax+3ch]      ; RVA на РЕ-заголовок
      add     ebx,ecx            ; Base + RVA = вирт.адрес заголовка
      mov     [peHeader],ebx     ; запомнить..
      mov     ebx,[eax+28h]      ; RVA точки-входа из заголовка: «ОЕР»
      add     ebx,eax            ; делаем из него вирт.адрес
      mov     byte[ebx],90h      ; затереть брейк отладчика на ОЕР: (90h = опкод инструкции NOP)
      ret
endp

Буквально всех аверов абсолютно не беспокоит открытый на запись код, иначе светлая жизнь легальных анпакеров типа UPX, ASpack оказалась-бы под большим вопросом. В купе с пересчётом своей контрольной суммы, данный вариант анти-дебага даёт не плохие результаты на практике.


2. Противодействие дизассемблерам

Аналогичную ситуацию наблюдаем и с дизассемблерами – какой бы план мы не придумали, такие гиганты как IDA, Ghidra, Radare и прочие, всё-равно разорвут его как тузик грелку. Но складывать оружие без боя тоже глупо, особенно если учесть, что умные их анализаторы частенько проседают. Шифрование и обфускация всей тушки очень накладно, а вот рассмотреть простые (и в тоже время эффективные) методы не помешает.

В первую очередь конечно-же текстовые строки, ухватившись за которые аналитик сможет раскрутить буквально весь клубок защиты. Как в отладчиках, так и в дизассемблерах имеется крутая фишка под названием «String/Cross-References», что подразумевает «поиск перекрёстных ссылок на строки». Кстати авторы x64dbg и IDA-Pro почему-то не назначили ей хоткей, хотя в IDA можно быстро организовать это дело комбинацией Ctrl+1.

2.1. Печать строк по указателям

Идея в том, чтобы хранить строки пусть даже и в открытом виде, а вот обращаться к ним уже через проксоренный указатель. В результате инструменты анализа увидят строку, но кто и от куда к ней обращается, останется в тайне.

C-подобный:
Message  db  '            _/-----===^^^===-----\_',10,10
         db  '~.~.~.~.~ Windows Stealth technology ~.~.~.~.~',10
         db  '~.~.~.~.~.~. Find me, if you can! ~.~.~.~.~.~',10,10,0

msgAddr  dd  Message

Посмотрим на фрагмент выше.. По адресу скажем 0х00402104 имеем переменную «Message», которая указывает на начало строки с текстом. Однако из своего кода я обращаюсь к этой строке через вторую переменную «msgAddr», а не напрямую. Первая вообще нигде не фигурирует в коде, а нужна лишь для того, чтобы получить адрес строки исключительно на этапе разработки приложения. Если в дефолте значение второй переменной равно адресу строки 0х00402104, то для пущей надёжности имеет смысл проксорить это значение например ключом 0xDEAD9876, тогда получим указывающий в космос адрес 0xDEEDB972. Операция XOR удобна здесь тем, что является обратимой, а потому перед обращением к данной строке достаточно повторно применить ксор с таким-же ключом.


2.2. Вызов функций Win-API по хэшу

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

Чтобы реализовать «Hash-API» на практике, во-первых нужно определиться с самим алгоритмом хэширования. Широкое распространение получил здесь самый простой и быстрый «Djb2A». Для тех кто в танке напомним, что хэш (в отличии от шифрования) – это одностороняя функция, т.е. вычислив один раз хэш какой-либо строки, вы не сможете получить эту строку обратно. Вот исходник вспомогательной утилиты, которой просто передаём имена функций, а из выхлопной трубы получаем готовые к употреблению хэши для основной программы. Значения Seed и Salt можете без проблем поменять на свои:

Hash.png

C-подобный:
format   pe console
entry    start
include 'win32ax.inc'
;//-------------------
.data
vAlloc     db  'VirtualAlloc',0
kExec      db  'WinExec',0
kSleep     db  'Sleep',0
kCmdLine   db  'GetCommandLineA',0
kDelete    db  'DeleteFileA',0
kCreate    db  '_lcreat',0
kWrite     db  '_lwrite',0
kClose     db  '_lclose',0

Table      dd  vAlloc,  kExec,  kSleep, kCmdLine
           dd  kDelete, kCreate,kWrite, kClose
Count      =   8
Hash       dd  3731   ;//<--------------- начальное значение хэша (Seed)

;//----------
.code
start:  mov     edi,Table
        mov     ecx,Count

GetHashList:            
        push    edi ecx
        mov     esi,[edi]
        push    esi
        xor     eax,eax
        xor     ebx,ebx
;//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@:     lodsb                ;//  AL = очередной символ имени
        or      al,al        ;//  выйти, если конец строки
        je      @f           ;//
        mov     ebx,[Hash]   ;//  EBX = текущее хэш-значение
        shl     ebx,3        ;//  умножить его на 8  <------------(Salt)
        add     ebx,[Hash]   ;//  + текущее хэш-значение
        add     ebx,eax      ;//  + код очередного символа из AL
        mov     [Hash],ebx   ;//  запомнить!
        jmp     @b           ;//  следующий символ имени..
;//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@:     pop     esi
       cinvoke  printf,<10,' %08x   %s',0>,[Hash],esi

        pop     ecx edi
        add     edi,4
        mov     [Hash],3731  ;//  восстановить для следующего API
        loop    GetHashList  ;//  повторить для всех имён из списка

@exit: cinvoke  _getch
       cinvoke  exit,0
 
;//----------
section  '.idata' import data readable
library   msvcrt,'msvcrt.dll'
include  'api\msvcrt.inc'

Получив таким образом хэши, остаётся пропарсить таблицу-экспорта той библиотеки, из которой мы собираемся импортировать функции – в данном случае я ограничился всего одной Kernel32.dll. Указатель на экспорт прописывается в каталоге «DATA_DIRECTORY» РЕ-заголовка, на системах х32 он лежит по смещению 0x78 от начала заголовка, а на х64 оффсет его 0x88.

В таблице экспорта для нас в приоритете всего 3 поля (выделены красным): общее число именованных функций по смещению(24), линк на таблицу адресов(28), и линк на таблицу указателей на имена функций(32). Все поля в этих таблицах хранят относительные RVA-адреса, а потому нужно добавлять к ним полученную через GetModuleHandle() базу DLL в памяти. Размеры полей 4-байта dword, и одинаковы они как для х32, так и для х64.

Чтобы обойти весь экспорт, нужно на каждой итерации цикла делать шаг сразу в обоих таблицах. То-есть берём первый указатель на имя из средней таблицы, вычисляем хэш этого имени и если совпало, значит адресом функции будет первое значение из верхней таблицы. Иначе смещаемся в обоих таблицах на 4-байта вперёд, и повторяем поиск. Это именно то, чем за кулисами занимается готовая функция GetProcAddress(). Отметим, что либа Kernel32.dll всего выдаёт на экспорт аж 1324 функции, однако поиск в ней нужной единицы занимает буквально мгновение.

Export.png

Развивая тему дальше, ничто не мешает вообще избавиться от секции-импорта в программе, ведь хотим мы того или нет, система проецирует Ntdll и Kernel32 во все процессы, включая наш. Прочитав поле Ldr.InMemoryOrderModuleList в структуре РЕВ, можно без WinAPI узнать базу этих библиотек в памяти, но это вариант для хардкорщиков, а не для истинных (т.е. ленивых) программистов как мы.

pebLdr.png


3. Создание копии своего исполняемого файла из образа в памяти.

Деторождение – типичная практика для малвари. Кто-то тупо копирует свой исходный файл на диске, а некоторые зверки поступают более изящным способом – его и положим на анатомический стол. Плюсом копии уже загруженного в память образа является то, что каждый последующий наш потомок будет отличаться от родителя, т.е. фактически получим генно-модифицированный код, или полиморфизм. Например записали мы в процессе работы нашего приложения что-то новое в секцию кода/данных, а потом раз и сделали из содержимого памяти новый исполняемый файл. В своём примере я буду просто передавать имя своего/текущего файла, чтобы по окончании работы потомок смог найти и удалить его с диска. Реализация копеешная, зато эффект создаёт мощный.

PeFile.png

Структура РЕ-файла такова, что всё его содержимое делится на равные страницы по 512-байт. Конкретный размер файловой страницы указывается в поле «FileAlignment» (выравнивание файла), и хранится он в заголовке. Таким образом, размер неупакованного исполняемого файла будет всегда кратен 512-байтам. Но когда файл загружается из диска в память, страницы увеличиваются в размерах до 4096-байт, а разница забивается нулями. Этот размер так-же оговаривается в поле заголовка «SectionAlignment», и как-правило совпадает он с размером виртуальной страницы ОЗУ.

Системный загрузчик образов «ImageLDR» поочерёдно копирует в память буквально все файловые секции, в результате чего мы можем получить точную копию оригинала. Для этого сначала нужно получить базу нашего образа в памяти через CetModuleHandle(0), и сместиться от неё на 0xF8 байт ниже. База есть ничто иное как РЕ-заголовок, а смещение поможет попасть в таблицу-секций.

Каждую из секций описывает одинаковая структура размером 0x28 байт, и в ней найдём смещение соответствующей секции в памяти, и (что немаловажно) размер этой секции на диске. Теперь просто переходим по указанному адресу в памяти, и копируем «PhysicalSize» байт в новый файл. Ясно, что если мы хотим получить пропатченную копию своей/чужой программы, осуществлять эту операцию нужно уже после записи всех изменений.

Section.png

C-подобный:
;// Выделяем память через VirtualAlloc()
       stdcall  GetFuncAddress,[vAlloc] ;// EAX = адрес функции
        mov     esi,[peHeader]
        mov     ebx,[esi + 52]          ;// EBX = размер нашей тушки в памяти
       stdcall  eax,0,ebx,MEM_RESERVE + MEM_COMMIT,PAGE_READWRITE
        mov     [fileBuff],eax

;// Копируем весь РЕ-заголовок файла = 1024 байта
        xchg    edi,eax
        mov     esi,[imageBase]
        mov     ecx,256
        rep     movsd

;// В цикле копируем все остальные секции,
;// динамически вычисляя их (1)адрес в памяти, и (2)размер на диске
        mov     esi,[peHeader]
        movzx   ebx,word[esi + 6] ;//<--- всего секций
        add     esi,0xF8          ;//<--- указатель на таблицу-секций

@@:     push    esi ebx
        mov     ecx,[esi + 16]    ;// размер на диске
        shr     ecx,2             ;// ...(в двордах)
        mov     esi,[esi + 12]    ;// адрес в памяти
        add     esi,[imageBase]
        rep     movsd             ;// скопировать в файловый буфер!
        pop     ebx esi      
        add     esi,0x28          ;// сл.секция в таблице
        dec     ebx               ;// всё скопировали?
        jnz     @b                ;// нет - повторить..

;//*********************************************
;// Создать копию файла из памяти!
;//*********************************************
       stdcall  GetFuncAddress,[kCreate]
       stdcall  eax,newFile,0
        push    eax eax

       stdcall  GetFuncAddress,[kWrite]
        pop     ebx
       stdcall  eax,ebx,[fileBuff],0xE00

       stdcall  GetFuncAddress,[kClose]
        pop     ebx
       stdcall  eax,ebx

Чтобы каждый из потомков рождался с уникальным именем, нужно предусмотреть генератор рандома. Для этого подходит инструкция XLATB, которая ожидает в регистре EBX указатель на строку с алфавитом, а в регистре AL рандомное значение, чтобы выбрать из алфавита произвольный символ. Выглядит это примерно так:

C-подобный:
symTable:
   times 26  db  % +('a'-1)  ;// abcdefghijklmnopqrstuvwxyz
   times 10  db  % +('0'-1)  ;// 0123456789
;//--------------

        rdtsc                 ;// EAX = счётчик тактов CPU
        mov     ebx,symTable  ;// EBX = адрес алфавита
        mov     edi,newFile   ;// куда сохранять рандомную строку
        mov     ecx,4         ;// длина строки
@@:     cmp     al,35         ;// проверить рандом на выход за границы алфавита
        jbe     @01           ;// ок, если меньше/равно
        sub     al,35         ;// иначе: пытаемся загнать в рамки
        jmp     @b            ;//
@01:    xlatb                 ;// AL = рандомный символ из алфавита
        stosb                 ;// сохранить его в буфер EDI
        shr     eax,8         ;// сл.рандомный байт из EAX
        loop    @b            ;//

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

По понятным причинам я не буду выкладывать здесь готовый исходник, а вот уже скомпилированный файл для анализа в отладчике или дизассемблере положу в скрепку. Ничего левого в нём нет, а счётчик жизни выставлен всего на 5 итераций (не на бесконечность). При запуске выводит на консоль телетайпом строку, после чего создаёт свою копию в той-же директории, и убивает предыдущего. При этом если параллельно запустить диспетчер задач Windows, то можно заметить, что процесс c именем «banzai_xx.exe» существует в единственном экземпляре, и по истечении 2-сек меняется его PID. Удачных исследований, пока!

Test.gif
 

Вложения

  • WinStealth.zip
    1,3 КБ · Просмотры: 76

trulala01

New member
28.08.2023
1
0
BIT
59
Привет, хорошая получилась статья, только хотел узнать, IDA вроде как не ставит бряк на OEP, можно просто выбрать адрес, и по нему поставить бряк, вопрос: можно ли как то все таки запатчить в любом случае измененный опкод по адресу, если отлаживают
 

Marylin

Mod.Assembler
Red Team
05.06.2019
326
1 451
BIT
696
IDA вроде как не ставит бряк на OEP
OEP указывается в заголовке файла, и IDA берёт его от туда, запускаясь сразу в режиме дизассемблера, а не отладчика. У неё отладчик активируется в ручную, а пример рассчитан на автоматику именно дебага. На счёт пропатчить не знаю, ..если только занопить/вырезать TLS, ну или в каталоге Data_Directory удалить запись под номером(9).
 
  • Нравится
Реакции: trulala01

Rogvert

New member
01.07.2024
1
0
BIT
17
Попытался воспроизвести у себя анти-отладчик, но он работает не совсем так, как ожидалось (см. приложения). Права на запись секции .text выставил, CRT и прочий шлак был отключён. Что не так?
 

Вложения

  • Screenshot_4.png
    Screenshot_4.png
    10 КБ · Просмотры: 49
  • Screenshot_6.png
    Screenshot_6.png
    40,4 КБ · Просмотры: 44
Мы в соцсетях:

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