Международным днём защиты информации по праву считается 30 ноября. Маховик этого праздника раскрутил обычный студент «Роберт Моррис», который в 1988 году создал своего интернет-червя. Какой им был нанесён вред – это отдельная история, зато он заставил весь мир понять, почему кибербезопасности нужно уделять особое внимание. Если коротко, то червь Морриса использовал несколько приёмов стелсирования своей тушки:
- Удаление своего исполняемого файла после запуска.
- Размер аварийного дампа устанавливался в ноль.
- Каждые три минуты порождался дочерний поток, а родительский завершался (при этом постоянно менялся pid, и обнулялось время работы).
- Все текстовые строки были закодированы по
xor 81h
.
просто за 35 лет безопасность систем вышла уже на новый уровень, а потому реализация будет отличаться.
В данном материале я попытаюсь выразить своё мнение на этот счёт. Основной посыл – это шекель в копилку реверс-инженеров, чтобы в своей практике они могли обнаружить уже знакомые участки кода. Краткий список основных моментов выглядит примерно так:
- Борьба с отладчиком – программное удаление точки-входа на ОЕР.
- Борьба с дизассемблером – вызов API-функций по хэшу, печать строк по зашифрованным указателям.
- Создание копии своего исполняемого файла из образа в памяти.
- Запуск ксеро-копии, с последующим удалением себя.
1. Противодействие отладке
Сейчас все уважающие себя отладчики имеют встроенные механизмы «анти-анти-дебаг», что полностью сводит на нет попытки с нашей стороны скрыться от них. Вы только посмотрите на список боеприпасов плагина x64dbg под названием «ScyllaHide» – да это же зверь, который показывает даже то, о чём некоторые из нас и не подозревали. Ну и как теперь выживать в таких условиях?
Таким образом, любой из перечисленных выше вариантов в зародыше обречён на провал, но одна лазейка всё-же осталась.
Откроем в 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 можете без проблем поменять на свои:
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 функции, однако поиск в ней нужной единицы занимает буквально мгновение.
Развивая тему дальше, ничто не мешает вообще избавиться от секции-импорта в программе, ведь хотим мы того или нет, система проецирует Ntdll и Kernel32 во все процессы, включая наш. Прочитав поле Ldr.InMemoryOrderModuleList в структуре РЕВ, можно без WinAPI узнать базу этих библиотек в памяти, но это вариант для хардкорщиков, а не для истинных (т.е. ленивых) программистов как мы.
3. Создание копии своего исполняемого файла из образа в памяти.
Деторождение – типичная практика для малвари. Кто-то тупо копирует свой исходный файл на диске, а некоторые зверки поступают более изящным способом – его и положим на анатомический стол. Плюсом копии уже загруженного в память образа является то, что каждый последующий наш потомок будет отличаться от родителя, т.е. фактически получим генно-модифицированный код, или полиморфизм. Например записали мы в процессе работы нашего приложения что-то новое в секцию кода/данных, а потом раз и сделали из содержимого памяти новый исполняемый файл. В своём примере я буду просто передавать имя своего/текущего файла, чтобы по окончании работы потомок смог найти и удалить его с диска. Реализация копеешная, зато эффект создаёт мощный.
Структура РЕ-файла такова, что всё его содержимое делится на равные страницы по 512-байт. Конкретный размер файловой страницы указывается в поле «FileAlignment» (выравнивание файла), и хранится он в заголовке. Таким образом, размер неупакованного исполняемого файла будет всегда кратен 512-байтам. Но когда файл загружается из диска в память, страницы увеличиваются в размерах до 4096-байт, а разница забивается нулями. Этот размер так-же оговаривается в поле заголовка «SectionAlignment», и как-правило совпадает он с размером виртуальной страницы ОЗУ.
Системный загрузчик образов «ImageLDR» поочерёдно копирует в память буквально все файловые секции, в результате чего мы можем получить точную копию оригинала. Для этого сначала нужно получить базу нашего образа в памяти через CetModuleHandle(0), и сместиться от неё на
0xF8
байт ниже. База есть ничто иное как РЕ-заголовок, а смещение поможет попасть в таблицу-секций.Каждую из секций описывает одинаковая структура размером
0x28
байт, и в ней найдём смещение соответствующей секции в памяти, и (что немаловажно) размер этой секции на диске. Теперь просто переходим по указанному адресу в памяти, и копируем «PhysicalSize» байт в новый файл. Ясно, что если мы хотим получить пропатченную копию своей/чужой программы, осуществлять эту операцию нужно уже после записи всех изменений.
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. Удачных исследований, пока!