Bill Prisoner
Июль 2005
Здравствуйте дамы и господа! Название книги не говорит само за себя. Эта книга посвящена современным ныне компьютерным вирусам для ОС семейства Windows. Я не буду рассматривать здесь устаревшие методики получения ring 0, которую мы использовали в Windows 98, а также никаких других специфических особенностей применимых для Windows 9x. Мы вдоль и поперек исследуем с Вами средства, механизмы и способы действия вирусных программ применимых для ОС Windows XP, а также родственные ей, т.е. все NT. Почему Windows XP? Да потому, что она использует все средства NT, плюс по данным некоторых аналитических компаний, в частности авторитетной Gartner (http://www.gartner.com), эта ОС просуществует дольше всех ОС из семейства Windows. Gartner считает, что на следующую ОС (Windows Longhorn) надеяться вообще не имеет смысла, т.к из нее будут убраны те революционные нововведения, которые планировались сначала - файловая система WinFS и другие технологии. По данным аналитиков компании, ОС Windows XP просуществует до 2011 года, в тоже время Windows 2003 Server просуществует до 2010 года. Корпоративным стандартом на начало 2005 года считается Windows XP с Service Pack 2. Вот на упомянутые системы мы и будет ориентироваться в ближайшем будущем. Конечно рассылка аналитических компаний это всего лишь предположение и обстановка может измениться в любой момент. Но не надо волноваться сильно по этому поводу, т.к. концепции ОС Windows остаются еще с 1995 года. Вы видели оглавление и, наверное, удивились как много охватывают вирусы. Не удивляйтесь, это все когда-то использовали компьютерные вирусы под Windows и Вы конечно должны об этом знать, чтобы создавать свои новые средства или использовать существующие. Все примеры из этой книги будут написаны используя компилятор masm32. Не спрашивайте пока меня почему masm. Просто masm и все :). Но в одной из глав мы исследует все известные компиляторы. Там я и объясню Вам что к чему. Естественно все самые быстрые, незаметные и разрушительные вирусы пишутся на ассемблере. Если у Вас есть иное мнение, то сначала научитесь программировать вирусы на ассемблере, а потом делайте все что угодно. С ассемблером приходит настоящие понимание того, что делаешь, а разные HLL (High Level Language) только добавляют сложности и в без того сложные вещи. Почти весь материал ориентирован на операционные системы Windows, но я также буду исследовать ныне очень модные вирусы для мобильных телефонов и автомобилей. И если появятся в ближайшем будущем, то и вирусы для кофеварок и пылесосов :) Данная книга была написана для того, чтобы показать как работают вирусы и, следовательно, как с ними бороться. Материал будет сопровождать много кода, который вынесен в отдельные функции. Эти функции Вы можете использовать, даже не разбираясь в тонкостях функционирования той или иной функции. Автор не несет ответственности за данную информацию, если она будет использоваться в незаконных целях. Вместе с этом я предлагаю методы защиты.
Я не буду философствовать на тему: "Кто такие вирмейкеры и зачем они пишут вирусы-, пусть этим занимаются "философы-. Я же буду Вам объяснять, как работают эти зверушки.
Эта книга содержит в себе очень много материала. Она поможет не только антивирусным работникам, но и простым программистам. Здесь будет содержаться много готового кода. Изложение ведется научно. То есть все положения объясняются исходя из свойств операционной системы.
Если какая-то глава вышла, и у меня появились новые сведения по этой проблеме, то я буду обновлять материал. Поэтому если у Вас возникают какие-то вопросы или непонятки, то отсылайте все мне на почтовый ящик. Я работаю круглосуточно.
Почему книга называется "От зеленого к красному"? Потому что она должна как-то называться. Название вызывает любопытство, не правда ли? Я хотел выдать оглавление прямо во введении, но я передумал. Так Вам будет гораздо интереснее.
Море информации находиться на сайте http://vx.netlux.org. Самый известный российский вирмейкер - z0mbie пока не имеет сайта. Но у него есть ЖЖ: http://www.livejournal.com/users/zasrakomondohuy.
Я принимаю любую критику на своем почтовом ящике. Если Вы нашли неточность, сообщите мне. Просто я уважаю своего читателя и хочу, чтобы Вы знали все. На этом введение я заканчиваю. Всего Вам хорошего.
База - это адрес чего-то, что лежит в адресном пространстве текущего процесса. Для каждой программы в Windows существует свое адресное пространство. Его объем 4Гб. Т.к. на самом деле такого количества памяти нет, и адреса памяти не соответствуют физическим, поэтому его называют виртуальным адресным пространством. Противоположное этому понятие называется - физическое адресное пространство. Откуда берется столько памяти, если на машине установлено, всего лишь 256 Мб? Операционная система использует дисковое пространство. Если какие-либо куски кода или данных не нужны, она сбрасывает их на диск. Шина адреса для 32х разрядного процессора 32-х разрядная, т.е. адрес может быть 32х разрядным. Диапазон значения адреса - 0..4 294 967 269d, а в шестнадцатеричной системе счисления 0..0FFFFFFFFh. Скоро, когда мы будет программировать для 64-х разрядных ОС размер виртуального адресного пространства увеличиться до 16 экзабайт. Этому пространству соответствует диапазон для указателей 0..0FFFFFFFFFFFFFFFFh. Каждый процесс работает в своем адресном пространстве. Это означает что если Вы создали программу и запустили ее, никакая другая программа не сможет читать или изменять данные в Вашей программе. Есть, конечно, много способов изменить такое положение вещей, но для этого надо использовать специальные механизмы. Адресное пространство процесса полностью не принадлежит ему. Более того, если мы обратимся не туда куда надо, то ОС завершит нашу программу сразу же. Почему так? Да потому, что виртуальное адресное пространство разбивается на разделы, которые имеют свое специфическое назначение. Раздел для данных и кода приложения имеет диапазон 00010000H..0BFFEFFFFH. Существует раздел для кода и данных режима ядра. Он находиться в диапазоне 0C0000000H..0FFFFFFFFH. Например, в отладчике режима ядра Вы можете посмотреть в зависимости от адреса, какой код трассируется - код пользовательского режима или режима ядра. Все что Вы должны из этого для себя почерпнуть это то, что все пространство памяти делиться на куски, которые имеют свое назначение. Также есть такие разделы - для выявления нулевых указателей, закрытый раздел. Я не привожу диапазоны, т.к. они обычно не нужны. Диапазоны, которые я привел, справедливы для ОС Windows XP. Вообще, в ОС отличных от Windows XP могут быть другие диапазоны и другие наборы разделов, если Вас это интересует, то Вы можете узнать их точно на сайте производителя этих самых ОС, нашу горячо любимую корпорацию Micro$oft(http://www.microsoft.com). В базовом разделе Platform SDK говорится, что нижние 2 Гб относятся к коду и данным пользовательского режима, а верхние к коду и данным режима ядра. Остальные детали о регионах могут меняться с каждым выпуском обновления.
Для памяти в Windows есть унифицированная единица, которой можно манипулировать напрямую - страница(page). Странице памяти можно присвоить определенный атрибут, т.е. можно ли считывать данные со страницы или записывать и т.д. Размер страницы зависит от типа процессора. Так для процессоров с архитектурой x86 размер страницы равен 4 Кб. Для 64-разрядного процессора размер страницы может отличаться от 32-разрядного. Чтобы получить размер страницы можно использовать функцию GetSystemInfo.
Для того чтобы воспользоваться какой-либо частью виртуального адресного пространства мы должны сначала выделить в нем регион. Регион - эта область памяти (совокупность страниц) произвольного (но кратного) размера с одним и тем же атрибутом страниц. Операция выделения региона называется резервированием. При резервировании ОС выравнивает начало региона с учетом гранулярности выделения памяти(Allocation Granularity). Эта величина зависит от типа процессора, но для процессоров с архитектурой (x86,IA-64) она составляет 64 Кбайта. Чтобы получить значение гранулярности выделения памяти можно использовать функцию GetSystemInfo. Например, если исполняемый файл подгружает какие-нибудь DLL, то сначала он резервирует регион для этой DLL подходящего размера, а потом передает физическую память с диска на зарезервированный регион. Регион резервируется с учетом гранулярности, т.е. он будет кратен величине 64 Кб и значит, сама DLL будет размещаться по адресам кратным 64 Кб, т.к. она размещается с самого начала региона. Т.к. единица памяти - страница, то размер региона кратен размеру страницы, т.е. регион выделяется страницами.
Теперь поговорим о kernel32.dll. Это библиотека динамической компоновки(Dinamic Link Library) которая содержит основные системные подпрограммы(routines) для поддержки подсистемы Win32. Процедуры, которые мы используем в своих программах для Windows, так или иначе содержаться в kernel32.dll. Например, мы завершили выполнение своего кода и хотим корректно завершиться. Надо использовать функцию ExitProcess. Она содержится в kernel32.dll. Если мы хотим использовать функции из других DLL, то в kernel32.dll есть функция GetProcAddress, которая возвращает нам указатель на требуемую функцию. Функции GetProcAddress надо указать описатель(handle) модуля и указатель на строку с именем функции. Описатель модуля можно получить с помощью функции GetModuleHandle, которой передается указатель на строку с именем функции. Вы спросите: "А зачем получать адреса функций, если я и так могу их вызывать из своих программ?-. Дело в том, что проблем с адресами API-функций нет, если у Вас есть самостоятельный исполняемый модуль. При загрузке exe-файла ОС сама находит нужные адреса с помощью функции LoadLibrary. Обычно программисты об этом и не задумываются. Но представьте, что Вы пишете вирус, а он часто не является отдельным exe-файлом, а живет внутри файла-жертвы. Ему, для своего существования приходиться ;) вызывать разные API-функции, но их адреса он не знает. В одной и той же ОС, например Windows XP, база kernel32.dll, т.е. ее (библиотеки) начало, может быть фиксирована и иметь, например, значение 7с800000h. Но в зависимости от ситуации или операционной системы этот базовый адрес может изменяться. Наша задача писать вирусы, которые могут функционировать на, как можно, большем числе платформ. Для этого нам надо сначала найти базу kernel32.dll, а потом получить адреса нужных нам API-функций из этой библиотеки. Вообще сначала нам нужна всего одна функция - GetProcAddress. Если мы используем функции из библиотек отличных от kernel32.dll, то также GetModuleHandle. Мы предполагаем, что процесс-жертва использует функции kernel32.dll. Если нужной нам библиотеки может не оказаться в адресном пространстве процесса-жертвы, то нам понадобиться и функция LoadLibrary.
Если мы используем процедуры из этой библиотеки kernel32.dll, то она должна быть спроецирована в адресное пространство процесса. Проецирование делается при создании объекта ядра "проекция файла-. Точно также, при загрузке exe-файла или его запуске, загрузчик создает его проекцию в адресном пространстве созданного процесса. Потом он просматривает таблицу импорта и проецирует все dll или exe нужные приложению. База kernel32.dll - это адрес в памяти, где начинается спроецированная в память библиотека.
Существует несколько способов получения базы kernel32.dll. Все они, так или иначе, опираются на какие-то тонкости ОС. Вы можете удивиться, но я в этой книге рассмотрю все известные мне способы. Все они будут представлены в виде ассемблерных процедур. (В терминологии языков программирования термины "функция- и "процедура- эквиваленты. Но язык Паскаль внес здесь свою путаницу. Я, естественно, буду руководствоваться традиционной и универсальной терминологией). Отдельные способы используют методы получения адреса в какой-нибудь процедуре из kernel32.dll. Суть метода в том, что мы каким-либо способом находим адрес произвольной процедуры в kernel32.dll. Каким способом, зависит от внутренней реализации ОС и ее особенностей. Другой способ заключается в проверке таблицы импорта.
Вы можете не разбираться даже в деталях реализации процедур и сразу же их использовать. Для подобного удобства около заголовка каждой из процедур будет описание входных и выходных данных. Ни одна из процедур не изменяет регистры за исключением выходного параметра. Например, если Вы вызываете процедуру ValidPE, и перед ней написано что выходной параметр помещается в регистр eax, то изменяется только регистр eax. Остальные регистры остаются с тем же содержимым что и до вызова процедуры. Признаюсь, я тут немного соврал. Не все регистры остаются с такими же значениями. Один регистр, все таки изменяется. Как Вы думаете какой? EIP. Также следите за вложенными процедурами.
Далее я привожу процедуру проверки PE-файла на правильность. Посмотрите на код. В исполняемом файле данные расположены, как "MZ" и "PE", но мы сравниваем их наоборот. Здесь вступает в силу принцип "младший байт по младшему адресу-. Это означает, что в памяти байты данных расположены наоборот. Соль в том что "MZ" и "PE" рассматриваются не как строки, а как слова в памяти. Строки - это массив байтов. Т.е. если мы берем слово, то адрес младшего байта является адресом всего слова. А младший байт это, в случае "PE", естественно "E". Специфика микропроцессора здесь в том, как он работает с памятью и как интерпретирует адреса. Задумайтесь в связи с этим об аппаратной поддержке типов данных. Это очень важно. Вы должны хорошо это усвоить.
Допустим, что мы каким-либо способом получили адрес где-то в kernel32.dll. Способы получения такого адреса приведены в разделе "Способы получения адреса в памяти kernel32.dll". Теперь наша задача получить базу по данному адресу. В нескольких способах мы сначала получаем адрес в памяти внутри kernel32.dll. Мы используем здесь гранулярность выделения памяти, т.е. сначала выравниваем значение адреса до 64 Кб, проверяем не база ли это уже kernel32.dll, если нет, то идем шагами назад по 64 Кб. Чтобы проверить, не база ли это, проверяем правильность формата PE файла.
Теперь вопрос о том, сколько страниц проверять и когда останавливаться. Размер исполняемого образа kernel32.dll в Windows XP SP2 около 1 Мб. Мы не знаем, где находиться сама процедура CreateProcess или UnhandledExceptionFilter. Но она точно содержится в секции кода PE-файла. Можно проанализировать PE-заголовок и выяснить начало секции кода и ее размер. Но это избыточные меры. В каждой ОС семейства Windows, как показывает проведенное тестирование, база находиться без счетчика. Я тестировал свою программу на ОС Windows 95,98,ME,2000,XP. Предлагаю Вам табличку с базами:
| ОС | База kernel32.dll |
|---|---|
| Windows XP SP1 | 77E60000H |
| Windows XP SP2 | 7C000000H |
| Windows 2000 SP4 | 79430000H |
Можно полагаться на результаты тестирования. Но я ввожу счетчик, лишь для того, чтобы сделать процедуру универсальной.
Вот исходный код процедуры для получения базы:
В этом разделе будут рассмотрены способы получения адреса в памяти внутри спроецированной DLL.
Посмотрите такой пример:
Что, по Вашему, поместиться в регистр eax? Как операционная система создает процесс? Правильно, с помощью функции CreateProcess. CreateProcess находиться где-то внутри kernel32.dll. Т.о. в eax мы получаем адрес где-то внутри kernel32.dll. Когда запускается зараженный файл, то управление передается вирусу. Вот тут-то мы и сцапаем нужный адрес. Но это естественно надо cделать сразу при запуске программы, а то стек забьется какими-нибудь данными или адресами возврата. Вот код, который должен выполнить Ваш вирус для получения базы kernel32.dll с помощью данного способа:
Просто, не правда ли?
SEH расшифровывается как Structured Exception Handling. По-русски - Структурная Обработка Исключения (СОИ). Вы узнаете о SEH все в соответствующей части данной книги. Здесь я только приведу способ, как получить адрес в kernel32.dll используя SEH. Кажется навороченно, да? Но на самом деле это достаточно просто. По адресу FS:0 находиться структура, которая называется TIB(Thread Information Block). Перый DWORD TIB'а указывает на структуру которую называют ERR. Вот как она выглядит:
| 1ый dword | Указатель на следующую ERR структуру |
|---|---|
| 2ой dword | Указатель на обработчик исключения |
Т.о. формируется связный список. Как узнать где заканчивается связный список? Если это последний элемент списка, то 1ый DWORD имеет значение -1. Операционная система при создании процесса сама устанавливает обработчик, чтобы, если что, выдать на экран MessageBox с сообщением об ошибке. Если это последний элемент в цепочке структур ERR, то указатель на обработчик исключения будет находиться где-то в kernel32.dll. Важно где именно. Этот адрес не будет совпадать с функцией UnhandledExceptionFilter. Это можно проверить практически. На самом деле это стандартный обработчик ОС Windows. Вот процедура, которая демонстрирует эту технику:
После получения адреса внутри kernel32.dll вызываем функцию GetBase, передавая ей соответствующие параметры для получения базы.
Этот способ отличается от приведенных выше. При загрузке PE-файла в память загрузчик заполняет адреса соответствующих функций из соответствующих DLL, которые нужны программе. Т.е. эти адреса хранятся внутри PE-файла, когда он загружен. Нам необходимо получить адрес любой функции из kernel32.dll.
В таблице импорта есть два массива адресов. Один не изменяется. В нем содержаться сразу адреса импортируемых функций. Это применимо, в частности, для системных DLL. Второй массив заполняется при загрузке PE-файла. Чтобы найти базу kernel32.dll надо найти таблицу импорта. В таблице импорта найти второй массив адресов. Массивы называются IMAGE_THUNK_DATA и описаны в WINNT.H. Первый массив называется OriginalFirstFunk, второй FirstThunk. Точнее так называются указатели на них, определенные в WINNT.H. Вам надо хорошо разбираться в импорте PE-файлов, чтобы понять это. Сначала мы должны найти начало зараженного файла. Потом переходим к PE заголовку. Далее проходим до IMAGE_DATA_DIRECTORY. Переходим к элементу с индексом 1. Элемент с индексом 1 соответствует таблице импорта PE-файла. Сохраняем RVA и складываем его с базой нашего EXE. По найденному адресу находятся структуры IMAGE_IMPORT_DESCRIPTOR. В этой структуре есть элемент - указатель на имя импортируемой DLL. Проверяем не kernel32.dll ли это, если нет, то идет к следующей структуре IMAGE_IMPORT_DESCRIPTOR. Если это kernel32.dll, то идем по указателю FirstThunk. Он указывает на таблицу адресов импорта или по-другому IMAGE_THUNK_DATA. Эта таблица переписывается загрузчиком PE-файла при загрузке. Вы можете подумать, что можно из таблицы импорта сразу взять адрес функции GetProcAddress. Но не факт что она будет там, так как сам EXE-файл может не импортировать функцию. Вот код который выуживает адрес одной из функций библиотеки kernel32.dll:
Здесь были рассмотрены наиболее популярные и известные способы. Если у Вас есть мысли по этому поводу, то присылайте их мне на электронную почту, обсудим вместе.
Вот мы получили базу kernel32.dll в адресном пространстве текущего процесса. Теперь нам надо найти для начала функцию GetProcAddress. C ее помощью мы получим желаемые адреса API-функций, которые мы будем использовать. Чтобы получить адрес функции GetProcAddress будет анализировать таблицу экспорта PE-файла.
Для начала находим таблицу экспорта. Из нее получаем адрес массива AddressOfNames. Это массив двойных слов. Каждое двойное слово - это RVA на ASCIIZ строку с именем экспортируемой функции. Мы проходим по этому массиву и сравниваем имя "GetProcAddress- с именем экспортируемой функции. Номер слова в AddressOfNames будет индексом для массива AddressOfFunctions. Но нельзя забывать о элементе nBase структуры IMAGE_EXPORT_DIRECTORY. Это начальный номер экспорта для экспортируемых функций. После получения индекса функции мы должны нормализовать его значение относительно nBase. Полученный индекс используем для получения адреса функции из массива AddressOfFunctions.
Вот процедура которая все это делает:
После вызова функции GetGetProcAddress в регистре eax у нас есть адрес функции GetProcAddress. Передавая соответствующие параметры функции, получаем адреса других функций. Вызывать функцию можно как call eax. Взляните на код:
После вызова call eax в регистре eax будет лежать адрес функции AddAtomA. При поиске не забывайте, что одна и та же функция может присутствовать в 2-х версиях - ANSI и UNICODE. Функции принимающие ANSI-строки, у них в конце имени стоит буква "A-. Функции принимающие UNICODE-строки, у них в конце имени стоит буква "W-. В примере выше, функция AddAtom принимает указатель на ANSI-строку. Как узнать, что функция существует в двух вариантах? Есть два способа. 1) Подумать :) Если функция принимает какую-нибудь строку, то она точно в двух вариантах.2) В Win32.hlp - справочнике по API-функциям, в описании каждой функции можно посмотреть краткую информацию о функции(кнопка QuickInfo). Там есть строка Unicode. Если там что-нибудь, кроме None, то функция существует в двух вариантах, иначе - в одном. Описание функции GetProcAddress, я думаю, Вы посмотрите сами.
Нам может быть полезна функция LoadLibrary, которая загружает PE-файл в адресное пространство процесса. Если модуль уже загружен, то эта функция вернет нам базовый адрес данного модуля. Она будет нужна, если наш зверь требует функции, которые могут не быть в KERNEL32.DLL. Единственный параметр, который передается LoadLibrary, это адрес строки с именем требуемой DLL или EXE-файла. Теперь я опишу, как действуют большинство вирусов при получении адресов API функций.
Вирус хранит в своем теле имена API-функций чтобы потом найти их адреса. Он может также хранить контрольные суммы для строк, содержащих имена. Но я пока не буду затрагивать теорию контрольных сумм. Все что известно о хешах и контрольных суммах, стандартные алгоритмы и примеры использования, Вы узнаете в соответствующей главе. А пока потерпите. Здесь мы рассмотрим пока только простые имена.
Где-то внутри тела вируса есть такие строки:
Им соответствуют переменные вида:
Важно, что между ними взаимнооднозначное соответствие (привет Соломатину О.Д.!). Порядок, тоже сохраняется. Этими свойствами мы и пользуемся при получении адресов. Можно, конечно, обойтись без циклов и соответсвий, но в ассемблере халявы нет, в вирмейкинге тем более.
Ниже приведен код процедуры, которая заполняет соответствующую область адресами нужных API-функций:
Далее я привожу пример программы которая демонстрирует использование данных методик. Программа просто создает файл с именем "c:\2.txt", а потом завершается. Естественно, что адреса API функций мы получаем сами. Никаких библиотек импорта, как Вы поняли, не требуется. В файле Part1.inc находятся требуемые функции, листинги которых приведены выше. Файл Part1.inc можно скачать отсюда.
Кстати у меня к Вам маленький вопрос уважаемый читатель. Что будет, если мы получим базу не с помощью функции GetKernelSEH, а с помощью функции GetKernelImport? Ответ: программа глюканет. Вы заметили, что наша программа не пользуется никакими прототипами? Из-за этого у нее нет экспортируемых функций. Но, если Вы будете внедрять код, то этот метод отлично подойдет, т.к. практически все Windows приложения импортируют функции из библиотеки kernel32.dll. Кроме такой, листинг которой, приведен выше.
Это последняя вещь, о которой я хотел Вам рассказать в той главе. Представьте, что Вы заразили файл. Теперь код вируса или его часть находиться в другом exe-файле. Например, возьмем переменную _CreateFileA. Она имеет определенное смещение. Смещение это фиксировано. И если код, приведенный выше запишется в другой exe-файл, то это смещение будет уже некорректным. Наша задача сделать так, чтобы смещение не зависело от местоположения кода. Для этого, нам надо узнать по какому смещению находиться наш код. И относительно этого смещения вычислить смещение нашей переменной. Это же относиться и к функциям нашего кода. Дельта смещение - это значение, показывающее на сколько байт сместилось положение нашего кода. Проще говоря дельта-смещение - это адрес где находиться код которые сейчас выполняется. Дельта-смещение вычисляют обычно вначале старта кода вируса.
Вот пример получения дельта-смещения:
После выполнения этого кода в регистре ebp находиться дельта смещение. Вот еще несколько способов получения дельта смещения:
Еще один, по типу предыдущего:
Вот этот прием от Billy Belcebu:
И Еще:
На самом деле существует бесконечное число способов получить дельта смещение. Это зависит только от Вашей фантазии и знания языка ассемблер.
Теперь, как пользоваться переменными или функциями. Пусть у нас есть две переменные X и Y. Пусть дельта смещение находиться в регистре EBP, тогда обращение к переменным в Вашем коде будет выглядить следующим образом:
Т.к. адреса функций помещаются в переменные, то этот способ также можно использовать для вызова функций:
В данной главе приводились методы, которые используют очень много вирусов. Этот код типичен. Эврестик просто должен распознавать что-то подобное.
В этом разделе я хочу выразить благодарности людям, которые помогли мне:
В этой главе мы исследуем формат исполняемых файлов в операционной системе Windows. Все факты, которые будут касаться этого изложения подходят для ОС Windows XP c установленным SP2. Но большинство фактов распространяются на всю платформу Win32. Я буду рассматривать все поля PE-формата полностью. Я привожу здесь описания используемых структур, для того чтобы Вы могли использовать этот документ и как справочник.
Формат PE (Portable Executable) - это переносимый исполняемый формат файлов. Переносимым он является потому, что он единственный для всех операционных систем Windows(9x,NT). Есть форматы и другие, но для платформы Win32 этот формат является единственным.
PE-формат впервые был использован в ОС Windows 3.1. Он был стандартизирован в 1993 году и базируется на формате COFF (Common Object File Format), который использовался в нескольких UNIX и VMS. Приступим, сначала рассмотрим общий вид PE-файла, чтобы Вы имели представление о нем.
PE-файл в самом своем начале содержит программу для ОС DOS. Эта программа называется stub и нужна для совместимости со старыми ОС. Если мы запускаем PE-файл под ОС DOS или OS/2 она выводит на экран консоли текстовую строку, которая информирует пользователя, что данная программа не совместима с данной версией ОС. Программист при линковке может указать любую программу DOS, любого размера. После этой DOS-программы идет структура, которая называется IMAGE_NT_HEADERS. Эта структура определена так:
Почти все определения структур PE-файла Вы можете узнать из заголовочного файла WINNT.H, который поставляется вместе с какой-нибудь средой программирования.
Первый элемент IMAGE_NT_HEADERS - сигнатура PE-файла. Для PE-файлов она должна иметь значение IMAGE_NT_SIGNATURE. Далее идет структура, которая называется файловым заголовком и определенная как IMAGE_FILE_HEADER. Файловый заголовок содержит наиболее общие свойства для данного PE-файла. Мы рассмотрим файловый заголовок в соответствующем разделе. После файлового заголовка идет опциональный заголовок - IMAGE_OPTIONAL_HEADER32. Он содержит специфические параметры данного PE-файла. В конце опционального заголовка содержится массив элементов DataDirectory. Он служит для доступа к некоторым сущностям, которые могут быть секциями (о секциях далее), а могут и не быть. В общем случае эти сущности называются - директориями. После опционального заголовка начинается таблица секций. В ней содержится информация о каждой секции. После таблицы секций идут исходные данные для секций. В конец PE-файла можно записать любую информацию и от этого функционирование программы не измениться (если там не присутствует проверка контрольной суммы etc.). Вы можете посмотреть, как выглядит PE-файл на рисунке, тогда Вы поймете, о чем я говорил в этом разделе:
"A section is the basic unit of code or data within a PE/COFF file. In an object file, for example, all code can be combined within a single section, or (depending on compiler behavior) each function can occupy its own section. With more sections, there is more file overhead, but the linker is able to link in code more selectively. A section is vaguely similar to a segment in Intel 8086 architecture. All the raw data in a section must be loaded contiguously. In addition, an image file can contain a number of sections, such as .tls or .reloc, that have special purposes-
Прочтите внимательно, Microsoft - звери хитрые, просто так писать ничего не будут, да и НЕ писать тоже. Хотя, время текет и все устаревает.
RVA = VA - IMAGE_OPTIONAL_HEADER.ImageBase (1)
Иногда возникает необходимость посчитать файловое смещение соответствующее VA или RVA. Если требуется смещение внутри секции, используется следующая формула:
offset = RVA - IMAGE_SECTION_HEADER.VirtualAddress + IMAGE_SECTION_HEADER.PointerRawData (2)
Значения IMAGE_SECTION_HEADER.VirtualAddress и IMAGE_SECTION_HEADER.PointerRawData берутся из таблицы секций, которая соответствует секции RVA секции.
Если смещение находится вне секции, т.е. в заголовке, таблице секций или еще где-нибудь, то естественно файловое смещение равно RVA. Вот код функции, которая возвращает файловое смещение в зависимости от RVA:
Макрос ALING_UP определен при описании параметра SectionAlignment в опциональном заголовке. Кстати, с помощью CreateFileMapping можно спроецировать файл как PE, т.е. кусками по секциям, а не как сплошной файл. Это делается так:
Параметр SEC_IMAGE указывает, что проецировать файл надо как исполняемый. Естественно мы будем только так проецировать файлы при заражении, чтобы не высчитывать соответствий смещения в файле и RVA.
В начале файла располагается DOS-MZ заголовок. Он определен следующим образом:
Все что нас интересует здесь - это только одно значение - e_lfanew. Это двойное слово является RVA и указывает на структуру IMAGE_NT_HEADERS. Размер DOS-MZ заголовка составляет 80 байт.
Файловый заголовок находиться в PE-файле сразу же после сигнатуры IMAGE_NT_SIGNATURE. В файле WINNT.H она определена как 00004550H. Файловый заголовок содержит наиболее общую информацию о данном файле. В файле WINNT.H файловый заголовок определен следующим образом:
Давайте рассмотрим по порядку данные поля.
ОС Windows поддерживает только две архитектуры и все они - процессоров Intel - IA-32, IA-64. Исходя из этого, только два значения считаются корректными в PE-файле IMAGE_FILE_MACHINE_IA64 и IMAGE_FILE_MACHINE_I386. Если Вы подставите чего-либо другое, загрузчик откажется загружать данный файл. Да и то для 32х разрядных операционных систем (т.е. работающих с 32х разрядными процессорами) - значение единственное - IMAGE_FILE_MACHINE_I386. Очень интересно еще и то, что в официальной спецификации о некоторых значениях просто умалчивается, просто умалчивается и все!
Чтобы узнать какой дате это число соответствует, используйте следующую функцию
X - значение поля TimeDateStamp. Чтобы использовать данную функцию необходимо подключить заголовочный файл time.h.
Определены следующие значения:
В файле отсутствует информация о базовых поправках. Этот флаг не используется в исполняемых файлах. Вместо этого информация о базовых поправках храниться в каталоге, на который указывает элемент в массиве DataDirectory с индексом IMAGE_DIRECTORY_ENTRY_BASERELOC.
Файл является исполняемым (т.е. не содержит нераспознанных внешних ссылок). Если файл является исполняемым, то он не является объектным файлом или библиотекой.
В файле отсутствуют номера строк. Это значение не используется в исполняемых файлах.
Локальные символы отсутствуют в файле. Это значение не используется в исполняемых файлах.
Этот флаг установлен, если операционная система ограничивает программу памятью, агрессивно сбрасывая данные приложения в страничный файл. Этот флаг устанавливается для приложений, которые большую часть своего времени ждут, лишь очень редко пробуждаясь.
Флаг, чтобы приложение могла работать с объемом памяти больше 2 или 3 Гб (в зависимости от загрузочного параметра).
Эти флаги устанавливаются если порядок байт в конце файла, отличен от порядка байт для текущей архитектуры. Т.к. порядок байт в процессорах Intel одинаковый, то этот параметр в данное время не используется.
Этот флаг установлен, если предполагается, что машина 32- разрядная. Вероятно, если файл будет собран при помощи 64-разраного линкера, то этот флаг не будет установлен.
Отладочная информация отсутствует в файле. Этот параметр не используется для исполняемых файлов.
Этот флаг установлен, если приложение может не запуститься с переносного носителя, дискеты или CD-ROM. В этом случае ОС переносит данные исполняемый файл в файл подкачки и считывает его оттуда. Но этот флаг в данный момент избыточен, т.к. ОС сама переносит исполняемый файл в файл подкачки, если он находиться на подобном съемном носителе.
Флаг установлен, если приложение может не запуститься по сети. Но этот флаг в данный момент избыточен, т.к. ОС сама переносит исполняемый файл в файл подкачки, если он находиться на общем сетевом ресурсе.
Этот флаг установлен, если данный файл является системным, подобно драйверу. В настоящее время не используется.
Данный файл - это динамически подключаемая библиотека(Dinamic Link Library). Каждая DLL обязана иметь этот флаг, иначе она не загрузиться. Этот флаг может использоваться EXE, и при этом быть корректным исполняемым файлом.
Этот флаг установлен, если приложение не предназначено для многопроцессорных платформ.
Главные поля в файловом заголовке - это количество секций и размер опционального заголовка. Остальные нужны очень редко или не нужны вовсе.
В опциональном заголовке храниться более специфическая информация о приложении и его потребностях. Я не хочу утомлять Вас, но если Вы это читаете, то будьте добры читать все. Здесь я опишу все поля опционального заголовка. В любом случае тонкости PE-формата нам пригодятся. А где пригодятся, Вы узнаете в этой главе. Следите внимательно.
В WINNT. H опциональный заголовок - это структура IMAGE_OPTIONAL_HEADER. Она определена следующим образом:
Как Вы уже, наверное, заметили, опциональный заголовок абстрактно делится на две части: стандартные поля и дополнительные поля NT. Естественно на реализации это деление не отражается. Рассмотрим поля по порядку. Кстати, опциональный заголовок так называется, потому что, если рассматривать в общем стандарт PE/COFF файлов, то для объектных файлов COFF-формата он отсутствует. Для исполняемых файлов этот заголовок является обязательным. А то некоторые авторитетные товарищи удивляются, почему этот заголовок называется опциональным. А это написано черным по белому в спецификации Microsoft PE-формата. Размер опционального заголовка не является фиксированным и чтобы узнать его надо обратиться к файловому заголовку.
Для спецификации PE32
Для спецификации PE64
Если исполняемый файл после проекции его загрузчиком будет только для чтения. Не используется в настоящее время.
Для 32х разрядных ОС есть одно возможное значение - IMAGE_NT_OPTIONAL_HDR32_MAGIC
,
где x - выравниваемое значение, y - выравнивающий фактор.
Посмотрите на пример функции, которое выравнивает вверх нужное значение:
Вот процедура, которая выравнивает вниз нужное значение:
(4),
где VirtualSize и VirtualAddress значения соответствующие последней секции

Кратно значению FileAlignment. Должно быть корректным.
Подсистема может быть только одна. Если подсистема CUI, то Windows создает консольное окно при старте программы. Когда мы будет заражать файлы, то будем выбирать только с подсистемами IMAGE_SUBSYSTEM_WINDOWS_GUI и IMAGE_SUBSYSTEM_WINDOWS_CUI
Вообще каждый элемент массива указывает на какую-либо структуру, например на таблицу импорта. Т.е. каждый элемент это информация о директории, каждая из которых несет собой определенную смысловую нагрузку. Определенный индекс в массиве соответствует определенной директории. Директория может быть секцией, а может и не быть секцией, т.е. быть ее частью. Если нам надо найти, например таблицу экспорта, то обращаемся к элементу 0 этого массива. Вот полный перечень всех индексов:
Структура IMAGE_DATA_DIRECTORY содержит в себе RVA директории. Если файл спроецирован не как SEC_IMAGE, то сразу найти смещение в файле данной директории не удастся. Для этой операции используйте функцию RVAtoOffset листинг которой приведен выше.
Таблица секций - это база данных, для всех секций используемых в PE-файле. Сразу после окончания опционального заголовка следует таблица секций. В PE-файле теоретически может быть сколько угодно секций. Все они могут иметь одинаковые атрибуты и даже одинаковые имена(!), кроме секции ресурсов :). Но обычно секции делят либо по их логическому предназначению, либо по атрибутам. Имена секций вообще никого не волнуют и нигде не проверяются (почти). Загрузчик ориентируется на массив DataDirectory в опциональном заголовке, для того чтобы найти нужные данные. Это сделано в целях оптимизации, чтобы не сравнивать строки, а просто перейти сразу же к нужной директории с помощью соответствующих индексов. Но некоторые особо "талантливые- программисты все равно используют имя секции, так что будьте с этим аккуратнее. В приложениях Windows NT могут использоваться много стандартных секций - .text(.CODE) - код программы, .bss - для неинициализированных данных, .rdata - данные только для чтения, .data - глобальные переменные, .rsrc - ресурсы, .edata - экспорт, .idata - импорт, .debug - отладочная информация и т.д. Такие секции создают линкеры, опираясь на спецификацию Microsoft. Таблица секций это массив элементов типа IMAGE_SECTION_HEADER. Этот тип определен следующим образом:
Опишем по порядку эти поля:
Флаги IMAGE_SCN_MEM_EXECUTE и IMAGE_SCN_MEM_READ эквивалентны. Флаги могут быть использованы одновременно, если применять к ним побитовою операцию "или-. Например, нам нужно чтобы в секцию можно было записывать, читать из нее, а также для пущей надежности указывает, что секция содержит код. Т.о. итоговой значение поля Characteristics будет выглядить следующим образом:
80000000H + 40000000H + 00000020H = A0000020H
Это значение мы будем использовать при внедрении в последнюю секцию, чтобы использовать переменные внутри нее и выполнять код. Мы указываем, что секция содержит код, т.к. антивирус может обращать на это внимание, если точка входа установлена на данную секцию.
Данная процедура проходит по таблице секций и выводит ее на экран. На вход процедуре передается адрес по которому спроецирован PE-файл.
Вот вроде разобрались со всеми заголовками, теперь нужно рассмотреть важные директории. Они понадобятся в нашем деле.
Экспорт - механизм PE-файлов, предоставляющий доступ к переменным или функциям из другого исполняемого модуля. Обычно EXE-файлы ничего не экспортируют. А DLL обычно экспортируют функции. Таблица секций может быть отдельной секцией, которая называется .edata. Но обычно таблицу секций ищут исходя из каталога данных. Она имеет индекс 0 в массиве DataDirectory. В таблице экспорта содержится массив, в котором находятся адреса функций. Ординал - это индекс в этом массиве адресов функций. Функции могут экcпортироваться либо по имени, либо по ординалу. Если функция экспортируется по ординалу, то загрузчик почти ничего не делает, а просто обращается сразу к таблице адресов функций. Но обычно функции экспортируются по именам. Чтобы экспортировать функции по именам, необходимо произвести некоторые действия. Какие, узнаете чуть ниже.
В начале таблицы экспорта расположена структура IMAGE_EXPORT_DIRECTORY. После этой структуры должны идти данные, на которые указывают элементы этой структуры. Но практически данные могут быть расположены где угодно. Вот вид структуры IMAGE_EXPORT_DIRECTORY:
Самое важное поле в таблице экспорта - это AddressOfFunctions, потому что оно и содержит адреса экспортируемых функций. Можно по разному экспортировать функции - по имени или по ординалу. Чтобы экспортировать функцию по ординалу достаточно использовать ординал, как индекс в массиве адресов функций, но, не забывая, о начальном номере экспорта. Чтобы экспортировать функцию по имени надо использовать информацию из двух дополнительных массивов, точнее указателей на них - AddressOfNameOrdinals и AddressOfNames. Массив AddressOfNames содержит RVA строк с именами функций. Нам дано имя функции, надо найти это имя в данном массиве. Если мы нашли имя, то получаем индекс в массиве имен, которому соответствует данная строка. Используя этот индекс применительно к массиву AddressOfNameOrdinals, находим индекс в массиве AddressOfFunctios, но без учета начального номера экспорта или начального ординала. Полученное значение нормализуем и получаем нужный ординал, который и используем для получения адреса функции по данному имени. Посмотрите рисунок ниже, чтобы понять это объяснение:
Не забывайте, что имена функций могут быть представлены в двух версиях - ANSI и UNICODE, если функция каким-либо образом обрабатывает строки. И имя функции различаться в зависимости от версии функции. Для ANSI версии в конце имени функции используется буква A, для UNICODE - W.
В таблице адресов функций могут быть разрывы, т.е. элементы, которые указывают в никуда. Т.е. если библиотека экспортирует функции с ординалами 5,8 и все, а начальный номер экспорта 5, то в массиве адресов функций будет 4 элемента. Первый нормальный, т.е. указывающий на функцию, два следующих пустые, 4-ый нормальный. Эти разрывы надо учитывать при разборе, а не обрабатывать все подряд.
Иногда в одной DLL содержится только имя функции, а сам код содержится в другой DLL, но экспортируем мы функцию из первой DLL. Этот механизм называется передача экспорта. Например, возьмем библиотеку KERNEL32.DLL. Возьмем из нее функцию HeapAlloc, она в действительности вызывает функцию RtlAllocateHeap из NTDLL.DLL.
Чтобы узнать, является ли функция переданной, нужно проверить не указывает ли адрес функции на таблицу экспорта данного файла (в данном случае KERNEL32.LL). Тогда этот "адрес функции- является RVA-строки вида имя_библиотеки.имя_функции (например NTDLL.RtlAllocateHeap). Для проверки является ли данная функция переданной, нужен адрес таблицы экспорта и ее размер. В примере ниже показано как определить, что функция является переданной.
Работая с таблицей экспорта будьте внимательны. Не забывайте, что можно экспортировать функцию как по имени и ординалу, как просто по ординалу. Не забывайте о переданных функциях. Существуют две важные операции при работе с таблицей экспорта:
Посмотрите на пример работы с директорией экспорта. Процедура выводит на экран таблицу экспорта. Параметром ей передается адрес спроецированного в память файла.
Импорт в PE-файлах - это механизм позволяющий использовать функции или переменные из модулей отличных от данного. Если наша программа вызывает функцию GetMessage, которая находиться в библиотеке KERNEL32. DLL, то вместо инструкции CALL используется инструкция JMP DWORD PTR [XXXXXXXX]. Адрес указанный как XXXXXXXX находиться где-то в таблице импорта. Посмотрите на рисунок, и Вы все поймете:
Это очень удачное решение - хранить адрес функции в одном месте. Если DLL загрузиться по определенному адресу, то загрузчику необходимо изменить только адрес функции в таблице импорта, а не каждый вызов данной функции.
Директория импорта и таблица импорта есть понятия эквивалентные, так что имейте это ввиду при чтении других авторов.
Импорт PE-файлов может происходить четырьмя различными способами. Повеселимся над этими механизмами и терминами, используемыми при импорте функций PE-файла. Импорт файлов - это первая вещь, которая действительно интересна.
Когда загружается исполняемый файл, то загрузчик использует таблицу импорта, чтобы узнать какие функции импортирует данный модуль. Потом загрузчик загружает библиотеки содержащие данные функции, если они не загружены, с помощью функции LoadLibrary. LoadLibrary возвращает адрес библиотеки в адресном пространстве текущего процесса. Чтобы получить адрес функции надо использовать функцию GetProcAddress. Ей передается имя функции и базовый адрес библиотеки. Т.о. в таблицу импорта добавляются адреса нужных функций при загрузке, а потом используются после загрузки. В некоторых библиотеках адреса функций уже имеются, это сделано в целях оптимизации, но об этом немного позже (в разделе "Биндинг-).
Таблица импорта начинается с массива элементов типа IMAGE_IMPORT_DESCRIPTOR. Количество элементов массива нигде не указывается, но вместо этого первый элемент последнего члена массива - нулевой. Каждый элемент соответствует DLL, из которой импортируют функции. Каждый элемент выглядит следующим образом:
Опишем поля этой структуры по порядку.
В структуре IMAGE_IMPORT_DESCRIPTOR содержатся указатели на массивы элементов типа IMAGE_THUNK_DATA. Эти массивы называются таблицами адресов импорта (IAT - import address table). Вообще, т.к. массив OriginalFirstThunk не патчится загрузчиком, то только FirstThunk считается настоящей таблицей адресов импорта - IAT.
Теперь необходимо описать двойное слово IMAGE_THUNK_DATA. Он определена следующим образом:
Это двойное слово соответствует одной импортируемой функции. Это двойное слово отличается, если файл был загружен в память или была ли функция импортирована по имени или по номеру. Если функция импортируется по номеру (ординалу), старший бит двойного слова устанавливается в 1. Импорт по ординалу производиться очень редко. Мы должны убрать эту единицу в последнем разряде и использовать полученное значение как ординал.
Если происходит импорт по имени, то двойное слово содержит RVA структуры IMAGE_IMPORT_BY_NAME. Эта структура определена следующим образом:
В таблице импорта вначале идет массив из элементов типа IMAGE_IMPORT_DESCRIPTOR. Каждый элемент соответствует одной DLL из которой импортируются функции. Самыми главными частями IMAGE_IMPORT_DESCRIPTOR являются имя DLL и два массива элементов типа IMAGE_THUNK_DATA32. В принципе они эквивалентны и идут параллельно. Но есть определенная логическая нагрузка на один и второй массивы. Конец массива IMAGE_THUNK_DATA32 определяется нулевым DWORD'ом. Первый массив - OriginalFirstThunk, остается неизменным при загрузке. Второй массив - FirstThunk правиться при запуске программы, загрузчиком. Вот он содержит адреса всех импортируемых функций. Вообще поле OriginalFirstThunk может быть любым и не используется загрузчиком. Для системных DLL массив OriginalFirstThunk сразу содержит адреса импортируемых функций. Т.е. для таких DLL, массив OriginalFirstThunk содержит не элемент IMAGE_THUNK_DATA32, а уже адрес для импортируемой функции данным модулем. Второй массив содержит, если функция импортируется по имени, RVA на структуру IMAGE_IMPORT_BY_NAME. Эта структура, содержит имя нужной функции. Сначала загрузчик просматривает массив IMAGE_IMPORT_DESCRIPTOR и проецирует в адресное пространство текущего процесса нужные модули, содержащие импортируемые функции. Далее загрузчик просматривает массив из IMAGE_THUNK_DATA32 и вызывает для каждого имени GetProcAddress. После вызова GetProcAddress возвращает адрес точки входа в функцию. Этот адрес записывается на место, где был RVA IMAGE_IMPORT_BY_NAME. Точно также происходит импорт по ординалу, только GetProcAddress передается не указатель на имя функции, а ординал. Если импортируется переданная функция, то в DWORD'е массива FirstThunk содержиться указатель на строку форвардной функции. Все эти действия ведутся с массивом имен FirstThunk. Массив OriginalFirstThunk остается прежним. Линкеры фирмы Borland делают массив OriginalFirstThunk нулевым, что можно считать ошибкой, но мы должны с ней считаться.
Посмотрите код, который выводит на экран всю таблицу импорта. Вы должны спроецировать PE-файл с помощью CreateFile->CreateFileMapping->MapViewOfFile. В hMap передайте значение возвращенное MapViewOfFile.
Компанией Microsoft была создана утилита, которая называется BIND. Ей на вход подается PE-файл, а она записывает в массив OriginalFirstThunk, таблицы импорта данного файла, адреса функций которые данный PE-файл использует. Такая операция называется биндингом (binding) и служит в целях оптимизации процесса загрузки исполняемого файла. Есть два вида биндинга - OLD STYLE BINDING и NEW STYLE BINDING.
Вначале об OLD STYLE BINDING. Адреса функций таблицы импорта уже известны до загрузки программы. Загрузчик файла смотрит на поле TimeDateStamp структуры IMAGE_IMPORT_DESCRIPTOR. Если это поле равно полю TimeDateStamp той DLL, из которой импортируются функции, то адреса импортированных функций не изменяются и загрузчик ничего не делает, т.к. правильные адреса уже находятся в модуле. Если поля TimeDateStamp в DLL и в таблице импорта не равны, то загрузчик патчит адреса импортированных функций с помощью стандартного механизма. Поле TimeDateStamp требуемой DLL может иметь значение 0, что происходит, если для данной функции не было биндинга. В этом случае загрузчик пропатчит все адреса импортируемых функций, для которых поле TimeDateStamp равно нулю. Если DLL была загружена не по своему предпочтительному адресу, то также происходит патч соответствующих адресов функций.
Если DLL экспортирует функцию, код которой находиться в другой DLL, т.е. при передаче экспорта, то используется поле ForwarderChain структуры IMAGE_IMPORT_DESCRIPTOR. Поле ForwarderChain содержит индекс в массиве FirstThunk первого импортируемого форварда. Если переданная функция - последняя, то это значение элемента соответствующего данному индексу равно -1. Если это не последний форвард в цепочке, то элемент содержит следующий индекс в этом же массиве. Т.о. происходит проход по цепочке переданных функций и заполнение адресами соответствующих двойных слов массива FirstThunk. Т.к. у нас есть параллельный массив - OriginalFirstThunk, то мы используем информацию из него об именах форвардных функций. Обратите внимание, то массив OriginalFirstThunk обязан быть не нулевым, чтобы использовать биндинг форвардных функций. Если утилите BIND передается PE-файл в котором нулевой массив OriginalFirstThunk, то она отказывается обрабатывать такой файл.
Теперь о NEW STYLE BINDING. Перед загрузкой файла в массиве элементов типа IMAGE_THUNK_DATA уже также содержатся адреса импортируемых функций. Изменится механизм импорта переданных функций. При NEW STYLE BINDING поля TimeDateStamp и ForwarderChain для DLL, из которых происходит экспорт форвардов, равны -1. Загрузчик ориентируется на эти значения -1, и использует директорию bound-импорта, где содержится информация о форвардных функциях.
Bound-импорт называют также - привязанный импорт. В массиве DataDirectory элемент с индексом 11 соответствует директории отложенного импорта. Отложенный импорт используется при NEW STYLE BINDING. Используя bound-импорт можно также оптимизировать процесс загрузки, т.к. есть возможность не пропатчивать, даже адреса, переданных функций. С этой директорией связан массив структур IMAGE_BOUND_IMPORT_DESCRIPTOR, каждая из которых определена следующим образом:
Delay-импорт, называется также, - отложенный импорт. Delay-импорт - это промежуточный подход между неявным импортом и явным импортом с помощью LoadLibrary/GetProcAddress. Механизм отложенного импорта - это не свойство операционной системы, это дополнительный код в Вашей программе, с помощью которого оптимизируется импорт API-функций. Этот дополнительный код называется - Delay Helper. Если Ваша программа запускает впервые API-функцию, то код Delay-импорта вызывает LoadLibrary и GetProcAddress. Адрес впервые вызванной функции будет сохранен в таблице импортированных функций отложенного импорта. На данные имеющие отношение к отложенному импорту указывает запись номер IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT в таблице директорий. RVA в DataDirectory указывает на массив структур ImgDelayDescr. Эта структура определена в заголовочном файле DELAYIMP.H. Вот ее вид:
Каждая структура соответствует одной DLL импортированной с помощью отложенного импорта. В данном массиве присутствует указатель на массив IAT, идентичный массиву, используемому в стандартном механизме импорта, а также массив таблицы импортируемых имен INT (Import Name Table). В IAT помещаются адреса при первом вызове соответствующей функции. Рассмотрим все поле структуры по порядку:
Представляю Вашему вниманию, пример процедуры - дампера таблицы отложенного импорта:
В разных ОС импорт может быть реализован по-разному. Механизмов - целых 3! И загрузчик вправе выбирать, какой из них, и в каком порядке, в случае провала, будет использован. Загрузчик, например, может сразу просмотреть цепочку директорий и сразу перейти к bound-импорту. Если он валиден, то использовать его для импорта. Если он не корректный, то перейти к стандартному механизму импорта. Т.о., в зависимости от ОС, загрузчик в праве выбирать какой механизм импорта ему использовать. В любом случае Вы можете узнать, как происходит импорт, исследуя поведение загрузчика с помощью дизассемблирования. Но если мы хотим сделать переносимый вирус, то на эти особенности полагаться ни в коем случае нельзя.
Если PE-файл не загружается по ImageBase, то применяются базовые поправки. Для данной секции применим особый термин - дельта. Дельта - это разница по модулю между базовым адресом для PE-файла и значением ImageBase в опциональном заголовке. Если файл загрузился по базовому адресу, то базовые поправки не нужны. Чаще EXE файл грузится по своему базовому адресу, но DLL обычно - нет. Базовые поправки - это набор смещений, по которым нужно прибавить дельту. Для базовых поправок часто выделяется отдельная секция .reloc, но они также могут не иметь отдельной секции, а быть частью какой-либо секции. Поправки упаковываются сериями смежных кусков различной длины. Каждый кусок описывает поправки для одной четырехкилобайтовой страницы. Секция базовых поправок начинается с массива структур IMAGE_BASE_RELOCATION, которая выглядит следующим образом:
X = (SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION))/2 (6)
Процедура предполагает, что все поправки типа IMAGE_REL_BASED_HIGHLOW.
В рамках данной главы я также выкладываю пример работы с PE-файлами консольной программы PE Inside. Просто посмотрите, как это работает и все. Все построено на функциях и макросах и не должно вызвать у Вас проблем. Исходный код находиться в архиве к статье.
Данная программа демонстрирует работу с PE-файлами. Она была сделана в рамках написания данной главы и имеет открытый исходный код, который Вы можете использовать в своих целях. При первом запуске программа добавляет себя в контекстное меню для PE-файлов, чтобы быстро просмотреть или отредактировать поля PE-файла. Скачать программу и ее исходник можно скачать в архиве прилагаемом к статье. Это только версия 0.5alfa и она мало чего умеет, но далее ее возможности будут расширяться.
PE64 - это расширение PE32 на случай 64-разрядной платформы. Не бойтесь, изменения между этими форматами минимальны, т.к. все что изменяется - это адреса в памяти. Поэтому все 32-разрядные поля превращаюся в 64-разрядные. В Си для адресации используется тип __int64. Но не забывайте, что в 32-х разрядных процессорах все регистры 32-разрядные по определению. Так что для работы с таким типом используются два регистра. Сами структуры в PE-файле остались прежними. Естественно изменились смещения. Все что Вам понадобиться для работы с этим форматом, так это спецификация Microsoft. А в теории Вы можете опираться на имеющиеся здесь выкладки.
Здесь я предлагаю оторваться от чтения и попробовать все прочтенное самому. Единственным способом понять все тонкости PE-формата - это трогать ручками все структуры. Попробуйте написать дамперы соответствующих структур. Откройте hex-редактор и найдите все структуры, попробуйте изменить чего-нибудь etc. Соберите все нужные структуры в один файл. Распечатайте этот документ и повесьте у себя рядом с кроватью. Это приблизит Вас к истинному пониманию структуры PE-файлов. Очень желательно знать все смещения соответствующих структур наизусть, дабы не отстать от Мыша ;)
Вот мы и добрались до самого главного, т.е. к чему стремились. Все смещения структур PE-файла мы знаем наизусть, знаем как загрузчик работаем с PE-файлами, значит можно заражать файлы. Школьники ходят в школу, учатся, кушают в столовой. Студенты с папочками и с очочками занимаются, а мы пишем вирусы, а все остальное mustdie. Здесь будут рассмотрены более или менее стандартные способы и наиболее простые.
Мы отвлеклись. Вот стандартные действия Windows-вируса:
Исходя из этих действий выдвигается новая тема. Итак...
Когда наш детеныш запускается, то он начинает поиск файлов и соответственно заражение. Обычно вирусы не заражают сразу все файлы, чтобы быть не замеченными. Сейчас мы напишем процедуру, которая ищет файлы. Если находиться директория, то для этого директории рекурсивно вызывается эта же процедура. Рекурсия - это очень интересная вещь в программировании. Мы еще будем обращаться к этому понятию. Т.к. мы программируем в 3 кольце защиты, то в этом кольце для поиска файлов используются три API-функции: FindFirstFile, FindNextFile, FindClose - соответственно начало поиска, продолжение поиска и завершение поиска. Эта процедура похожа на "Танго мастдайное-. Кому надо тот понял. Процедура требует два параметра. Процедура универсальна, сохраняет все регистры. В этом примере я не стал ее оптимизировать. Все что нужно об оптимизации Вы узнаете в соответствующей главе. Но процедура не до конца доделана. Точнее говоря, файлы она ищет все, но ничего не делает с ними. Вы должны добавить всего лишь, что делать с найденными файлами. Чтобы получить имя найденного файла используйте член структуры WIN32_FIND_DATA - cFileName. Чтобы получить путь для этого файла используйте локальную переменную Path. Она следующего вида: <Путь к файлу>0F3h,0F3h,0F3h,0. Где 0F3h и 0 - это байты. Чтобы получить нормальный путь к файлу надо убрать 3 0F3h байта и слить эту строку со строкой содержащей имя файла. В примере немного позже Вы увидите, как это делается. Я добавляю эти лишние байты, для того, чтобы для следующих папок в данной, путь формировался правильно. Эти байты играют роль маски в конце, которая потом удаляется.
Как проверить, что PE-файл является вилидным я рассказывал в главе 1. Просто, используйте процедуру ValidPE, передавая ей правильные параметры.
У нас в распоряжении есть исполняемый файл, мы должны заразить его. Давайте рассмотрим первый способ. Как Вы уже знаете, в начале PE-файла идtn PE-заголовок. Между окончанием таблицы секции и первой секцией есть промежуток. Этот промежуток появляется из-за файлового выравнивания выравнивания (значение FileAlignment в файловом заголовке). Туда мы можем впихнуть исполняемый вредоносный код. Плохо, что места мало, значит либо наш вирус будет очень маленьким или очень оптимизированным, либо в это место мы внедрим только часть вируса. Хорошо то, что размер файла не изменяется. Запись в данную область возможна, если изменить атрибуты соответствующих страниц. Рассмотрим алгоритм внедрения кода, используя запись в заголовок:
Есть шаги, которые необходимо будет выполнять при любом способе заражения. Я опишу их в каждом разделе.
При работе с PE-файлом мы будем постоянно обращаться к некоторым областям, важными для нас. Необходимо получить указатели на них, чтобы постоянно не вычислять эти значения. Нам будут нужны следующие значения: PE-заголовок, таблица секций, таблица директорий, файловый заголовок, опциональный заголовок. В этом примере кода, предполагается что в hMap находиться проекция EXE-файла-жертвы.
Когда мы внедряем код, то мы изменяем точку входа на нашу. Чтобы управление вернулось программе необходимо прыгнуть на инструкции, с которых первоначально планировалось выполнение. Ниже приведен отрывок кода, который добавляет инструкции после внедренного кода для перехода на оригинальную точку входа. Предполагается, что в pOptionalHeader находиться указатель на опциональный заголовок. Так же предполагается, что в регистре EDI находиться место, куда мы хотим записать команды перехода. Проекция EXE файла создается не как SEC_IMAGE, а как обычная, потому что при SEC_IMAGE запись на диск не производиться :(, даже если мы изменяем атрибуты страниц с помощью VirtualProtect
Сначала мы получаем все важные части отображения. После проецирования файла проверяем корректен ли он. Если он корректен, то проверяем, не заражен ли он уже. Чтобы это проверить, надо знать некоторые отличительные особенности зараженности данного файла. При самом заражении в поле Win32VersionValue добавляются байты - 00BADF11Eh. Если в данном поле такие байты, то файл заражен. Посмотрите на пример:
Для индикатора зараженности подойдет любое поле, которое не используется загрузчиком. Я описывал ранее, какие это поля. Если посмотреть внимательно на какой-нибудь PE-файл с Bound-импортом, то обычно Bound-импорт помещается как раз в это свободное пространство нужное нам. Bound-импорт - средство оптимизации загрузки. Но если его удалить, то файл будет все равно нормально загружаться.
Теперь надо найти начало свободного пространства в заголовке. Это пространство будет начинаться сразу после таблицы секций. Посмотрите на код и мои комментарии:
Чтобы получить начало первой секции в файле надо пройтись по всем секциям и сохранить минимальное физическое смещение. Мы делаем это для того, что в таблице секций, первая запись не обязательно соответствует первой секции в файле. Иначе можно было бы взять информацию из первой записи в таблице секций. Чаще, в конечном итоге, так и получается. Вот исходный делающий эти операции:
После проекции, проверки EXE-файла и получения информации о промежутке в заголовке проецируем файл, откуда берутся данные, которые надо внедрять. Потом проверяем размер промежутка и размер файла. Если размер промежутка достаточен для кода, то можно внедрять. Код внедряем обычными цепочечными командами ассемблера:
После данных из файла необходимо поставить переход на нормальную точку входа. Я делаю это следующими инструкциями:
Далее программа модифицирует точку входа. Параметр SizeOfHeaders очень важен для нас. Он должен быть равен физическому смещению последней секции. Иначе загрузчик не спроецирует код, а забьет пространство нулями. К сожалению, этот способ внедрения отлавливают все антивирусы, просто проверяя, что точка входа указывает на заголовок. Можно, например, записать код в заголовок и потом использовать его. При этом обязательно, чтобы AddressOfEntryPoint не указывал на заголовок. Т.е. можно использовать это место для хранения т.н. загрузочной процедуры, которая передает управление на соответствующие инструкции.
Этот способ более предпочтителен для внедрения потустороннего кода в PE-файл. Можно внедрять сколько угодно кода. Но, используя данный способ изменяется размер файла. Что в этом плохого догадайтесь сами. Способ заключается в простом добавлении кода в конец последней секции с изменением параметров для данной секции. Вот алгоритм внедрения, используя расширение последней секции:
Ну как? По-моему ничего сложного. Надо просто знать, какие поля есть в PE-заголовке, и помнить о них. Здесь нам пригодиться и вычисление выравнивания секций. Как вы помните из главы 1, есть формула для вычисления, выровненного вверх или вниз, значения. Был также приведен код процедур для этих расчетов. Сейчас, я приведу код и Вам мигом все станет понятно.
Первая проблема, которая возникла - это каким делать размер файла. Ведь его нужно знать до заражения. Его нужно знать, чтобы соответствующим образом спроецировать файл и чтобы хватило места в проекции для внедряемого кода. Для нового размера файла используется такая формула:
Y=X+AlignUp(размер_кода+7,FileAlignment),
где X - исходный размер файла, Y - новый размер файла, FileAlignment - файловое выравнивание для файла-жертвы.
Для удобства я сделал процедуру для получения файлового выравнивания. Учтите что данная процедура не сохраняет регистры. Взгляните на эту процедуру:
В начале работы программы она высчитывает значение размера нового файла. Потом это значение используется при проекции EXE-файла жертвы. После этого как обычно программа проходит по EXE-файла и вылавливает нужные указатели. После получения нужных данных проходим по таблице секций и выясняем, какая все-таки секция последняя. Важно, что мы смотрим не только на физическое смещение в файле, но и на виртуальное. А то может оказаться, что физически секция последняя, а виртуально нет. В этом случае если мы все-таки внедрим код, то он перепишем данные секции, которая виртуально идет после последней физически. Так что, это надо иметь ввиду. Код:
Далее проверяем, что найденная секция имеет не нулевой размер. Если бы секция имела бы нулевой физический размер, то это секция с неинициализированными данными. В коде приложения содержатся ссылки на эту секцию. Если мы в начало запишем наш код, то в итоге по некоторым адресам будут записываться данные. Т.о. часть нашего кода перепишется. А это нам естественно не нужно. Вот пример проверки, что найденная секция ненулевая:
После этих действий записываем код и правим некоторые значения. Какие значения править было описано в алгоритме выше.
При внедрении заметьте, что мы добавляем данные в конец последней секции. Т.е. мы не используем место оставшееся в результате файлового выравнивания. Учитывая этот факт, новая точка входа будет равна RVA секции + SizeOfRawData до заражения. Также как и в прошлом примере в код добавляется переход на старую точку входа. Правка точки входа достигается следующим кодом:
Загрузчик проверяет выполнение равенства ImageSize=VirtualSize+VirtualAddress. Из-за этого мы должны изменить ImageSize:
В результате заражения размер файла увеличивается. Это может вызвать подозрения. Используя данный метод заражения можно внедрить код любого размера. Также можно заразить файл бесконечное количество раз и он будет работать. У меня был notepad.exe, который занимал 30 Мб. Он был просто заражен много раз. Полезная нагрузка (внедряемый код) занимала ~3Мб. Но notepad.exe запускался после повторения некоторых действий.
Теперь давайте сами добавим новую секцию в PE-файл. Алгоритм добавления новой секции выглядит так:
Размер нового файла вычисляется по такой же формуле что и в предыдущем способе. Первым делом в программе как раз вычисляется новый размер файла. После этого опять ищем Bound-импорты, которые могут находится сразу после оригинальной таблицы секций. Затираем запись о Bound-импортах в таблице директорий. После окончания оригинальной таблицы секций забиваем нулями 40 байт - это будет наше место для новой записи в таблице секций. Хорошо, место есть. Теперь надо создать запись о новой секции и внести туда правильные данные. Чтобы выяснить какие данные нужны, посмотрите на структуру IMAGE_SECTION_HEADER. Имя секции выбираем любое. Главное чтобы оно укладывалось в 8 байт. Я назвал свою секцию .new. Еще один способ проверки не заражен ли уже файл - это проверка названия последней секции. VirtualSize - это размер нашего вредного кода. Чтобы посчитать виртуальный адрес новой секции надо взять виртуальный адрес последней секции. Потом взять размер в файле этой секции. Сложить полученные данные и выровнять их по SectionAlignment. Для получения значения SectionAlignment используется процедура GetSectionAlignment. Код:
SizeOfRawData - берем значение виртуального размера и выравниванием на FileAlignment. Для получения значения FileAlignment используется процедура GetFileAlignment. PointerToRawData будет соответствовать старому размеру файла, т.е. данные для секции добавляются в хвост. Далее все оставляем, кроме характеристик. Как выставлять характеристики нам известно. После создания записи о новой секции внедряем код в конец файла. Потом правим AddressOfEntryPoint, ImageSize. И не забудьте подправить NumberOfSections, а то лоадер начнет ругаться что-то там про win32. Вот как я делаю это:
Я не проверяю ошибки, так что сделайте так чтобы файлы, которые Вы открываете, были валидны. В этом инфекторе не также проверки на зараженность, чтобы показать что файл можно заражать несколько раз. В результате заражения размер файла увеличивается. Можно заражать несколько раз, но не бесконечное число. Количество зависит от места конца таблицы секций до данных первой физической секции. Если вдруг антивирус обращет внимание, что точка входа стоит на последней секции, то создайте две секции. На первую из них будет указывать AddressOfEntryPoint. Тогда подозрение по данному признаку исчезнут.
В некоторых PE-файлах присутствуют базовые поправки. Вы уже знаете, что это такое, если читали с начала главу. Так вот они в большинстве случаев для EXE-файла не обязательны. Линкеры по умолчанию не создают базовых поправок в PE-файле в целях оптимизации. Мы можем использовать место, отведенное для базовых поправок, для внедрения кода. Чаще всего для базовых поправок отведена отдельная секция, которая называется .reloc. Но эти данные могут и не иметь отдельной секции. Чтобы узнать, где действительно распологается базовые поправки необходимо обратиться к таблице директорий. При заражении мы должны вынудить заргрузчик не использовать базовые поправки для данного EXE-файла. Для этого требутся всего лишь обнулить запись о базовых поправках в таблице директорий. Алгоритм замены секции базовых поправок выглядит так:
Это все! Никаких ImageSize и т.д. не нужно править т.к. мы не изменяем размер файла.
Теперь вы знаете, как внедряться в исполняемый файл. Код, который будет внедрен должен быть базонезависимым. Что обеспечить это условие необходимо использовать дельта смещение и связанные с ним техники. О дельта смещении вы должны были узнать в 1 главе. Код, который здесь приводился базозависим. Это сделано для большего понимания приводимого материала. Но если вы читали главу 1, то для Вас не составит труда сделать код базонезависимым. Также можно использовать термин - код в шел-код стиле.
Один из продвинутых приемов при заражении файлов является модификация кодапрограммы. Это довольно сложно. Неоходимо анализировать код программы и выискивать оттуда пустые места или инструкции, которые можно заменить. Если мы просто заразили файл и точку входа изменили на наш код, то это сразу вызвет подозрения, даже визульно. Хороший инфектор должен быть практически невидим, т.е. не отличаться от кода программы. Вы можете размазывать весь код вируса по всему PE-файлу. Куда его засовывать? Да очень просто. У каждой секции есть файловое выравнивание. Следовательно остается свободное место в конце каждой физической секции. Когда в одной секции место закончилось ставьте jmp на следующий кусок кода и так далее. При модификации кода необходимо сохранять старые байты команд, т.е. например не переписать случайно половину команды. В этом случае помогает дизассемблер в вирусе специально написанный Вами. О дизассемлере в вирусах и его использовании я буду говорить в соответствующей главе. Эта тема требут отдельного разговора и называется EPO (EPO: Entry Point Obscuring). При модификации кода, часто необходимо учитывать базовые поправки. Если вдруг Вы попытались заразить DLL, а ей базовые поправки нужны очень часто, то Вы должны позаботиться при модификации кода о прапатчивании модифицированных элементов. Так или иначе Microsoft приподнесла нам подарок в виде файлового выравнивания. Мы можем как угодно использовать это свободное место. Еще один способ для получения свободного места - сжатие оригинального кода. На его место можно записать наш код. При запуске файла код распаковывается, а вирус попадает на какой-то виртуальный адрес. Можно сделать заражение не использую код в шел-код стиле. Есть исполняемый файл, который является вирусом. Есть жертва. Мы берем исполняемый файл, добавляем все данные файла жертвы в файл вируса. Модифицируем с учетом новых данных вирусный PE-файл. Далее заменяем файл жертву на новый файл. При запуске зараженного файла некоторый код вируса, используя сохраненные данные, создает временный оригинальный файл и запускает его. В итоге запускается оригинальный файл. Сразу же исчезают многие проблему. Но у этого способа есть недостатки. Например, решение о том где хранить оригинальный файл. Если мы будем хранить его в той же папке это сразу можно заметить. Еще один способ заключается в следующем. Мы внедряем код запуска некоторого файла в жертву. При запуске жертвы запускается вредоносный файл, и жертва продолжает работу. Ну, это слишком просто. Тем более будет отображеться новый процесс. Это просто новый способ автозагрузки. Например, если заразить explorer. exe. Можно заразить любой файл из папки Windows. Например, notepad.exe. Это можно осуществить, т.к. WFP(Windows File Protection) побеждена. Я расскажу Вам об этом скоро. Вообще можно придумать куча вещей, неоходимо немного фантазии и знание PE-формата. Кое-что Вы можете почитать из той литературы, которую я Вам предложу ниже.
В этой главе мы рассмотрели формат исполняемых файлов win32. Рассмотрели каждое поле в отдельности и в общем весь формат. Были приведены примеры работы с PE-форматом на С и ассемблере. Мы узнали как заражать PE-файлы. Цель данной статьи - рассказать Вам как устроен PE-формат, расписать некоторые трудности при записи своего кода в посторонний файл. Также Вы должны приобрести гибкость при анализе любого исполняемого файла и создании своих способов внедрения. К статье прилагется исходные коды 3-х инфекторов и дампера PE-формата в 2-х версиях.
Этот раздел является своеобразным обобщением первых двух глав. Прочтя его, Вы сможете уже без особых трудностей писать простые Win32-вирусы. Код в shell-код стиле или как он еще называется - базово-независимый код требует определенных условий при его написании. Основное условие - чтобы код не зависел от адреса загрузки его в адресное пространство процесса-жертвы и от структур данных загрузчика. Надо определить адрес какой-нибудь команды, где она находилась первоначально (т.е. в первом поколении). Это значение будет константой, зашитой в коде. Далее код должен определить, где он находиться в данный момент. Для этого есть несколько способов, которые описывались в 1 главе. Вот это и называется дельта-смещением.
Также мы должны знать адреса функций API, чтобы вирус был мульти-платформенным относительно Windows, т.е. работал во всех ОС Windows, т.к. известно, что адреса API-функций меняются в зависимости от ОС, а также могут поменяться в той же ОС в какой-то конфликтной ситуации, например при конфликте разделов виртуальной памяти. Для получения адресов, нужных нам функций ОС, существует много способов. Основы получения адресов мы рассмотрели. При получении адресов ОС Windows мы выполняем часть работы загрузчика. При загрузке исполняемого файла (PE, DLL, SYS, SCR) в адресное пространство процесса загрузчик заполняет таблицу адресов импорта (Import Address Table) и таблицу адресов экспорта (Export Address Table). При выполнении кода этого исполняемого файла IAT используется, чтобы хранить адреса всех API-функций, которые использует приложение. Таким образом, мы касаемся неявного связывания (implicit linking). Адрес API-функции может и не быть в IAT, его можно получить с помощью функции KERNEL32.DLL!GetProcAddress. Этой функции на вход передается описатель модуля, в котором экспортируется нужная функция и имя нужной функции. KERNEL32.DLL!GetProcAddress просматривает EAT модуля, описатель которого передается ей параметром (а описателем модуля(module handle), как известно является его базовый адрес(base address) в адресном пространстве процесса, в котором он загружен). Даже при неявном связывании ОС вызывает GetProcAddress для заполнения IAT. Мы своим кодом эмулируем процедуру GetProcAddress - не больше не меньше!
В исполняемом файле есть несколько секций, которые имеют свои атрибуты. Например, секция кода не предназначена для записи. Есть секция неинициализированных данных, которая имеет нулевой размер физически, но при загрузке данного исполняемого модуля в память эта секция приобретает материальный характер. Чтобы это было именно так, загрузчик просматривает таблицу секций и если он видит, что данная секция - секция неинициализированных данных, то он выделяет память в адресном пространстве процесса с помощью функций выделения памяти. Наш код находится всегда в одной секции. Чтобы таким же образом использовать виртуальное адресное пространство для своих целей приходиться использовать функции резервирования и выделения памяти в куче или, напрямую, - в виртуальной памяти.
Более того, есть проблема - если ЮЗВЕРЬ (классное слово :) ) посмотрит файл, зараженный нашим кодом, то он визуально сможет найти там чего-нибудь подозрительное. Чтобы этого не случилось приходиться шифровать наш код или строки текста, создавая соответственно, и расшифровщик. Но это естественно не единственное применение шифрования в коде.
Представьте, что у нас есть код обычного приложения подсистемы Win32 на ассемблере. Задача: превратить его в код в Shell-код стиле. Сначала надо все переменные переместить в секцию с кодом и соответственно поставить прыжок на нормальный код, чтобы эти данные не начали выполняться как код. Потом вычислить дельта-смещение. Далее получить адреса всех API-функций. После этого можно превращать обычный код в код в Shell-код стиле, т.е. заменять все смещения - смещениями с учетом дельта-смещения.
Пример:
Первоначальный код:
Во-первых, переменные offset Text1, offset Title1 должны находиться в секции кода т.е. там, где находиться код вируса. Из-за этого секцию с таким кодом нужно делать доступным для записи. Во-вторых, offset Text1 - это абсолютный адрес. Допустим, что мы вычислили дельта-смещение и поместили его в регистр EBP. С учетом вычисленного дельта-смещения мы должны его исправить т.о.
Теперь в EDI находиться реальный адрес строки Text1. Также делаем и со всеми остальными переменными. Допустим, что адрес функции MessageBox, находиться в переменной _MessageBox. Тогда вызываем функцию так:
Две строки
можно заменить одной
Пример Закончен.
Как известно система команд современных 32-х разрядных процессоров не содержит в себе дальнего условного перехода. Но у нас код и данные расположены в одном большом сегменте, т.о. мы можем переходить на любые расстояния, используя модель памяти FLAT. Но нет команды, которая осуществляет косвенный переход. Т.е., если у нас адрес хранится в каком-нибудь регистре, то мы не можем использовать команду условного перехода, например так - jne EDI. Вот как можно реализовать косвенный переход
Пример:
Пример Закончен.
Этот код означает следующее - если значение в регистре EAX равно нулю, то делается дальний переход на адрес, который находиться в EDI.
При программировании в shell-код стиле полезно пользоваться процедурами, т.к. в них можно использовать локальные переменные и они базово-независимы в принципе, т.к. используют стек. Но здесь возникает небольшой вопрос - где хранить дельта-смещение? Вопрос возникает потому, что мы обычно храним дельта-смещение в регистре EBP. В процедурах, регистр EBP используется для своего первоначального предназначения - хранить базу кадра стека. Здесь можно пофантазировать. Я использовал локальную переменную для хранения дельта-смещения.
Директивы компилятора .IF,.WHILE и т.д. Вы можете применять без особых проблем, т.к. у нас всего один сегмент. В случае этих директив компилятор генерирует код, в который входят только относительные адреса.
API-функции мы будем вызывать по абсолютным адресам, для чего мы и получили их адреса. В итоге, первоначальный код, который мы решили перевести в код в Shell-код стиле превращается в такой:
Пример:
Пример Закончен.
В команде jmp error также используется относительный переход. По умолчанию в JMP в MASM'е трактуется как прямой внутрисегментный переход.
При программировании удобно использовать макросы. Посмотрите пример
Пример:
А вот так это можно использовать:
Пример Закончен.
В этом разделе я хотел привести нормальную программу, а потом эту же программу, но в Shell-код стиле. Но потом я передумал :) Код той и другой программы находятся в архиве, который прилагается к статье. Итак, программа рекурсивного поиска. Программа выводит на экран с помощью MessageBox'а количество найденных файлов с расширением EXE в указанной директории и всех ее поддиректориях. Файлы ищутся в директории, имя которой находиться по адресу Buffer. В архиве есть папка, которая называется ShellCoded. В ней нормальная программа называется - normal.asm, в Shell-код стиле - shellcode.asm. Внимательно рассмотрите эти программы и попробуйте их сравнить. Также потренируйтесь переводить свои программы таким же образом.
Т.о. Вы можете переводить обычное Win32-приложение в приложение в shell-код стиле. Во вложении к статье я также предлагаю Вам шаблон файла, где Вам не придется получать дельта смещение и адреса API-функций. Там уже все есть как в сказке! Почти всё ;) Файл называется VXTemplateWin32.asm.
Structured Exception Handling (SEH) - структурная обработка исключений, механизм, который поддерживается операционной системой и позволяющий обрабатывать ошибки в программах. В этом разделе я расскажу Вам, что такое SEH, как работает данный механизм и как его использовать в своих вирусах.
SEH - это системный механизм. Представьте, что Ваша программа попытается выполнить следующий код:
Пример:
Пример Закончен.
Любое обращение к адресам от 0 до 0FFFFh ведет к исключению нарушения доступа к памяти. Конечно, ошибка нарушения доступа к памяти появляется не только для этих адресов, но и для всех адресов выше 2х Гб в виртуальном адресном пространстве, а также если мы пытаемся обратиться к не переданным страницам или например, произвести запись к странице к которой мы не имеем право на запись.
Исключение - это событие, которое происходит в результате какой-либо ошибки. Каждое исключение имеет свой код. Например, код неправомерного доступа к памяти - 0C0000005h. Коды исключений определены в файле WINBASE.H. Допустим, выполняется пример кода, когда мы записываем 1 по адресу 0, тогда возникает исключение. ОС должна реагировать на исключение. Обычно при возникновении исключения ОС вызывает функцию, которая называется обработчиком исключений (exception handler). Эта функция - обычная CALLBACK-функция принимающая несколько параметров. Если мы обрабатываем это исключение, то мы пишем обработчик и в определенном месте указываем его адрес, чтобы, если произошло исключение, ОС смогла вызвать наш обработчик. Если обработчик выполнился, ОС решает, что дальше делать исходя из возвращаемого значения, которой вернул обработчик. Исходя из этих соображений, программа может продолжить работу, программа может завершиться или ОС вызывает следующий обработчик в цепочке (если таковой имеется). Т.е. можно устанавливать несколько обработчиков. Если мы сами не установили обработчик, то в любом приложении установлен обработчик по умолчанию и если случиться исключение, то ОС выведет сообщение о завершении программы.
Если на участок кода приведенном в примере установлен обработчик, то мы можем обработать эту ошибку с помощью специально написанного обработчика. Существует два типа обработчиков исключений - конечные и внутри-поточные. Итак...
Если программа вызвала исключение, то, если внутри-поточные обработчики не установлены или не обрабатывают исключение, вызывается конечный обработчик. Конечный обработчик глобален для процесса, в котором он установлен, в отличии от внутри-поточного. Конечный обработчик устанавливается с помощью API-функции KERNEL32.DLL!SetUnhandledExceptionFilter. Как Вы заметили :) она экспортируется из kernel32.dll. С помщью этой функции можно установить конечный обработчик. Если в Вашей программе произошло исключение и его не обрабатывают никакие внутри-поточные обработчики, то вызывается конечный обработчик. Конечный обработчик вызывается как раз перед тем, когда ОС решила закрыть приложение. Смещение конечного обработчика передается как параметр функции KERNEL32.DLL!SetUnhandledExceptionFilter.
Пример:
Пример Закончен.
Функция-обработчик такой прототип прототип:
Прототип этой функции я взял из SDK. Также там описаны и возвращаемые значения этой функции. А возвращаемые значения могут быть такие:
Прототип конечного обработчика отличается от прототипа внутри-поточного обработчика.
Если что-то произошло в коде вируса, то надо просто перепрыгнуть на нормальный код программы, если этот код внедрен в программу и выполняется до ее старта. Если код вируса выполняется в потоке, то мы завершаем поток. Конечно, можно попробовать исправить ошибку, и продолжить выполнение.
Если мы хотим обрабатывать ошибки для каждого потока, т.е. устанавливать свой обработчик для каждого вида ошибок в потоке, то мы должны установить внутри-поточный обработчик. Например, ошибка нарушения доступа к памяти в одном потоке будет обрабатываться по-своему, а в другом потоке та же ошибка, уже по-другому, в зависимости от обработчика. Из внутри-поточных обработчиков можно делать цепочки. Т.е. если один обработчик не обрабатывает исключение, то исключение может обработать следующий обработчик в цепочке.
По адресу FS:[0] находиться указатель на структуру SEH, ее называют SEH-фрейм.
Вот описание этой структуры:
Когда мы устанавливаем обработчик исключения вручную, то мы заполняем структуру SEH и передаем указатель на нее в FS:[0]. Структура SEH должна состоять как минимум из 2-х первых двойных слов. Эта новая созданная структура должна обязательно находиться в стеке, иначе наш обработчик не будет вызван. Более того, очередная новая созданная структура должна находиться в стеке выше, чем предыдущие установленные структуры.
Вот как можно установить внутри-поточный обработчик:
Пример:
Пример Закончен.
Когда поток начинает только выполняться, у него уже установлен один обработчик, обработчик по умолчанию, который выводит сообщение о завершении программы.
Если присмотреться внимательно, то можно понять, что вышеприведенным кодом добавляется очередной элемент в связный список. По адресу FS:[0] содержится указатель на структуру SEH, в которой имеется адрес предыдущей структуры SEH в стеке. Этот связный список называется SEH-цепочка (SEH-chain). Так формируется цепочка из обработчиков исключений. Сцепление в цепочку обработчиков делается, например для того, чтобы каждый обработчик в цепочке обрабатывал свои типы исключений. Если первый обработчик не обработал исключение, то он возвращает eax=1 и управление передается следующему обработчику в цепочке. Т.е. если обработчик возвращает 1, то ОС переходит к следующему элементу в цепочке. Также для каждого куска кода может быть свой обработчик. Если данный обработчик - последний в цепочке, то у него указатель на предыдущий обработчик (поле PrevLink) будет равен -1. Чтобы точно понять, что же такое цепочка из внутри-поточных обработчиков посмотрите на рисунок:
При вызове внутри-поточного обработчика ОС использует Си-договоренность о передаче параметров, вместо стандартной договоренности, т.е. стек после вызова, вызывающий код, должен сам уравнивать, что ОС и делает.
Прототип внутри-поточного обработчика имеет вид
Обработчик имеет доступ к структуре EXCEPTION_RECORD, которая содержит подробную информацию о исключении. С помощью адреса структуры SEH можно получить доступ к локальным переменным, т.к. структура SEH находится в стеке. Из структуры CONTEXT можно получить значения всех регистров, которые они имели во время возникновения исключения. Структуру CONTEXT также можно редактировать, чтобы исправить ошибку и продолжить выполнение программы. Параметр DispatcherContext обычно не используется.
В заключение этого раздела приведу значения, которые могут возвращать конечный обработчик:
Внутри-поточный обработчик
Когда мы просто прыгаем на безопасное место из обработчика, мы не сохраняем никакие регистры, кроме регистра EIP. Например, регистры ESP, EBP не сохраняются. Именно поэтому такой способ - "грязный". Есть техника позволяющая сохранять регистры, а также иметь доступ к локальным данным. Для этого нужно написать соответствующий обработчик. Используя эту технику можно исправить ошибку и продолжить выполнение с безопасного места. Вот маленькая программа, где используется техника продолжения выполнения с безопасного места:
Пример:
Пример Закончен.
В начале программы, в стеке создается SEH-фрейм. По адресу FS:[0] передается указатель на этот SEH-фрейм. Помимо смещения обработчика и адреса предыдущего SEH-фрейма мы передаем смещение безопасного места, значение ESP и EBP. Т.о. мы заполняем все поля структуры SEH. Если происходит исключение, то управление передается обработчику исключений SEHHandler. Обработчик исключений, используя переданную ему структуру SEH заполняет некоторые поля структуры CONTEXT, а именно регистры ESP(для сохранения вершины стека), EBP(для доступа к локальным данным), EIP(для перехода на безопасное место). Обработчик возвращает 1 или константу ExceptionContinueExecution, чтобы сообщить операционной системе, что обработчик обработал исключение и необходимо продолжить выполнение программы в контексте указанной в структуре CONTEXT.
Финальный обработчик
В финальном обработчике также можно перезагружать контекст таким образом, чтобы выполнение продолжалось с безопасного места. Но если мы хотим продолжить выполнение программы возвращать обработчик должен уже не 1, а -1. Финальному обработчику в отличие от внутри-поточного передается только структуры CONTEXT, EXCEPTION_RECORD, а структура SEH не передается, поэтому значения регистров EIP, EBP, ESP надо хранить в статической памяти или что-либо подобное, например в куче.
SEH также используют для переполнения стека или переполнения кучи, с помощью подмены обработчика. Это уже штучки создателей эксплойтов - отдельное сообщество компьютерного андеграунда, так же как и вирмейкеры. Очень хорошо, когда сообщества объединяются или комбинируются. Остальную информация о SEH - такую как - "раскрутка стека", "информация, которая передается обработчику", и т.д. можно прочитать в статье Джереми Гордона.
VEH - или векторная обработка исключений - относительно новый механизм обработки исключений. Он появился впервые в операционной системе Windows XP. Вы, наверное, испугались названия, но не бойтесь, использовать VEH очень просто.
VEH это тоже самое, что и SEH - также устанавливаются обработчики исключений. Но в этих механизмах есть несколько различий. Во-первых, никаких служебных слов типа try, except, finally для С++, как раньше, нет. Т.е. это не надстройка компилятора. Во-вторых, и это очень важно - VEH это не stack-frame based механизм. Т.е. раньше все SEH-фреймы были в стеке. Теперь же узлы VEH'а находятся в куче. В-третьих, VEH обработчики глобальны для процесса. Из VEH обработчиков можно делать цепочки.
Можно сравнить VEH с финальными обработчиками UnhandledExceptionFilter из которых можно делать цепочки. Различие с финальным обработчиком и в том, что векторный обработчик вызывается в первую очередь(т.е. до SEH), а финальный в последнюю.
Чтобы установить векторный обработчик мы вызываем функцию AddVectoredExceptionFilter. Вот ее прототип:
FirstHandler - если этот параметр не ноль, то обработчик устанавливается, как следующий элемент в цепочке. Т.е. при возникновении исключения именно он вызовется ОС. Если этот параметр ноль, то обработчик устанавливается в начало цепочки и вызывается в том случае, если все остальные обработчики в цепочке не обрабатывают исключение, т.е. возвращают EXCEPTION_CONTINUE_SEARCH.
Огромным преимуществом VEH'а над SEH'ом в том, что он отлавливает абсолютно все исключения для всех потоков. А вот у SEH'а с этим проблемы.
Пример использования VEH'а:
Пример:
Пример Закончен.
Я попытался исследовать VEH изнутри. Что из этого получилось, описано в этом разделе.
В модуле NTDLL.DLL есть статическая глобальная переменная. Назовем её CurrentVEHFrame. В этой переменной содержится адрес текущего VEH-фрейма. При вызове функции AddVectoredExceptionHandler в куче создается новый VEH-фрейм и заполняется соответствующими значениями. VEH-фреймом я называю структуру, которая определена следующим образом
Prev - адрес в куче предыдущего VEH-фрейма. Если это самый последний фрейм, то его значение равно значению адреса переменной CurrentVEHFrame.
pСurrentVEHFrame - адрес переменной CurrentVEHFrame
EncodeVEHHandler - закодированный адрес обработчика. Чтобы получить виртуальный адрес обработчика необходимо вызвать функцию RtlDecodePointer библиотеки NTDLL(можно написать так: NTDLL!RtlDecodePointer).
Т.о. при вызове функции AddVectoredExceptionHandler в цепочку векторных обработчиков добавляется новый элемент. Цепочка представляет связанный список. Вот рисунок, который иллюстрирует сказанное:
Здесь при возникновении исключения будет вызван обработчик Handler1. Если он не обрабатывает исключение, то управление передается обработчику, следующему в цепочке. Еще раз повторю, что ОС определяет, что обработчик является последним в цепочке, если pCurrentVEHFrame==Prev. Это показано на рисунке.
Перехват вызовов функций называется также "Per-process residency" техника, применяемая в операционных системах Windows. С ОС Windows поставляются файлы с расширением DLL - Dinamic Link Library. Это библиотеки динамической компоновки. Они экспортируют функции, чтобы их могли вызвать другие приложения или DLL. Чтобы приложение могло использовать какие-то сервисы ОС, оно должно вызвать одну из функций, которая экспортируются системной DLL. Все функции ОС хранятся в системных DLL. Функции, которые являются посредниками между ОС и приложением называются API (Application Programming Interface)-функциями. Соль механизма перехвата функций состоит в следующем. Когда приложение вызывает API-функцию мы можем вместо оригинальной функции вызвать свою функцию, которая может изменить результат вызова для приложения-жертвы (для того приложения, в котором мы перехватываем функции). Т.о. мы можем изменять логику работы любого приложения. Т.е. любое обращение программы к ОС мы можем контролировать, изменять или просто наблюдать за работой какого-то приложения. Мы можем понять, как работает та или иная программа по функциям, которая она вызывает. И этот способ контроля будет значительно проще для анализа, чем простая отладка. Тем более некоторые программы используют анти-отладочные механизмы. Некоторые операции в ОС Windows вообще нельзя осуществить без помощи перехвата API-функций. Перехватывать можно не только API-функции, но и любые экспортируемые функции.
В вирусологии техника перехвата особенно полезна. Она используется для продвинутого заражения файлов, полезной нагрузки, получения информации нужной вирусу (например, путь к файлу для заражения), скрытия присутствия, уничтожения или нарушения работы ненужных нам программ (антивирусов и брандмауэров).
В адресное пространство любого процесса загружена библиотека NTDLL.DLL. При вызове функций из kernel32.dll, например, OpenProcess в конечном итоге вызывается функция ZwOpenProcess, которая находиться в NTDLL.DLL. Низкоуровневые функции, которые находятся в NTDLL.DLL называются NativeAPI функции. Лучше перехватывать именно их, чтобы процесс жертва не смог отделаться от перехвата даже с помощью вызова Native API. Можно и просто исправить перехват. Но чтобы и этого не случилось, необходим перехват в нулевом кольце. Здесь мы будем заниматься только третьим кольцом.
Перехват вызовов функций делается при помощи некоторого механизма. Этот механизм применим для одного конкретного процесса. Если мы хотим глобализировать наш перехват, то мы должны применить технику перехвата для всех процессов в системе. Но по умолчанию даже пользователь с привилегиями администратора не имеет возможности получить доступ к системным процессам (например, winlogon.exe). Чтобы перехватывать функции и в системных процессах необходим доступ к этим системным процессам. Вообще, для внедрения кода в удаленный процесс (а это один из важных шагов механизма перехвата) необходимы следующие привилегии:
Чтобы открыть системный процесс с такими привилегиями, вызывающий функцию KERNEL32.DLL!OpenProcess должен иметь привилегию SeDebugPrivilegies. Ниже представлена процедура на ассемблере получения данной привилегии:
Пример:
Пример Закончен.
Здесь Priv - это строка определенная так:
После вызова данной функции вызывающий ее процесс может открывать системные процессы.
Пример:
Пример Закончен.
GetLastError вернет ERROR_SUCCESS. Если открыть системный процесс без вызова функции EnableDebugPrivilege, то OpenProcess вернет ноль, а GetLastError вернет ERROR_ACCESSDENIED.
Чтобы перехватить функцию в каком-нибудь процессе необходимо выполнить код в этом процессе. Изначально этот код не содержится в этом процессе. Т.е. его необходимо туда поместить. Для этого есть два способа: 1) Внедрение кода с помощью DLL. 2) Простое копирование кода в шел-код стиле. Большинство методов перехвата API функций используют внедрение кода с помощью DLL, т.к. при этом нет требования базовой независимости и зависимости от адресов API-функций. В случае вируса нам желательно не создавать никаких DLL, хотя нет никаких проблем, если мы создадим ее. При этом есть ограничение - это размер кода, который будет внедрен в жертву при заражении. Как создавать код в шел-код стиле мы уже знаем, теперь рассмотрим как создать DLL.
DLL - это обычный PE-файл, в котором есть соответствующий флаг поля Characteristics файлового заголовка. В EXE-файле не может быть этого флага. Если в EXE файле стоит флаг DLL, то он считается некорректным. DLL - это обычно набор функций, которые экспортируются другими модулями. У DLL, как и у любого EXE файла есть точка входа. Для DLL точка входа указывает на функцию, которую условно можно назвать DLLMain. Вот её прототип:
hInstDLL - описатель данной DLL
Эта функция вызывается при определенных событиях. В результате какого события была вызвана функция DLL указано в параметре reason.
Вот его возможные значения и их описание:
Создание DLL мало отличается от создания EXE. Вот код самой простой DLL:
Пример:
Пример Закончен.
Также необходимо создать файл с расширением DEF, который должен быть примерно такого вида:
Пример:
;---------------------------------------------------------------------------- ; DLL.def ;---------------------------------------------------------------------------- LIBRARY DLL EXPORTS TestFunction ;----------------------------------------------------------------------------
Пример Закончен.
Где LIBRARY - имя библиотеки, EXPORTS - имя функции, которая экспортируется из DLL (EXPORTS может быть несколько). Необходимо при вызове DLLMain сохранять регистры esi,edi,ebx,ebp и восстанавливать их при выходе из DllMain.
Для компиляции DLL нужно создать как обычно объектный файл, а для линковки используйте следующую строку:
link /DLL /SUBSYSTEM:WINDOWS /DEF:DLL.def DLLSkeleton.obj
Видите, ключ /DLL указывает на установку флага DLL в файловом заголовке.
Внедрить DLL в адресное пространство постороннего процесса можно несколькими способами. А именно: с помощью реестра, с помощью хуков, с помощью удаленных потоков, с помощью замены оригинальной DLL, а также DLL можно внедрить как отладчик или через функцию KERNEL32.DLL!CreateProcess. Все эти способы описаны в книгe Джеффри Рихтера "Windows для профессионалов". Можно также и даже проще внедрить просто посторонний код в чужой процесс. Хотя в этом случая потребуется время на его создание. Но мы-то с Вами знаем теперь как делать такой код.
Я буду использовать метод внедрения DLL с помощью удаленных потоков, т.к. он является самым гибким. Но вы можете использовать любой другой. Это совершенно не принципиально, главное чтобы внедрение происходило правильно и в нужные приложения. Методы внедрения, конечно, отличаются друг от друга и налагают некоторые ограничения.
Windows предоставляет функцию, которая называется KERNEL32.DLL!CreateRemoteThread. Она позволяет создать новый поток внутри удаленного процесса. Мы заставляем вызвать функцию KERNEL32.DLL!LoadLibrary потоком целевого процесса для загрузки нужной DLL. Одним из параметров функции KERNEL32.DLL!CreateRemoteThread является lpStartAddress, который означает адрес процедуры потока. Процедура потока принимает один параметр. KERNEL32.DLL!LoadLibrary принимает также один параметр. Т.е. как стартовый адрес удаленного потока мы можем указать адрес функции KERNEL32.DLL!LoadLibrary. При этом мы пользуемся тем, что KERNEL32.DLL проецируется во всех виртуальных адресных пространствах по одному и тому же адресу и из этого соображения предполагаем, что в удаленном процессе функция KERNEL32.DLL!LoadLibrary тоже находиться по тому же адресу что и в нашем процессе.
Еще один важный момент заключается в параметре, который передается потоку и соответственно функции LoadLibrary. Мы должны передать адрес строки с именем функции. Адрес этот должен обязательно находиться в адресном пространстве целевого процесса, т.е. мы должны скопировать эту строку туда. Выделения виртуальной памяти в удаленном процессе производиться c помощью функции KERNEL32.DLL!VirtualAllocEx. Осуществлять запись и чтение памяти чужого процесса можно с помощью функций KERNEL32.DLL!WriteProcessMemory и KERNEL32.DLL!ReadProcessMemory соответственно. Освободить выделенный регион можно с помощью функции KERNEL32.DLL!VirtualFreeEx.
Вот код программы с помощью, которой внедряется DLL:
Пример:
Пример Закончен.
После внедрения DLL вызывается DllMain с параметром DLL_PROCESS_ATTACH. Именно при обработке этого параметра мы устанавливаем перехватчик.
При вызове Win32-приложением функции экспортируемой из другого модуля, например
CALL MessageBoxA,0
компилятор генерирует код следующего вида:
CALL X, где X - адрес переходника вида jmp dword ptr [Y], где Y - адрес адреса функции в IAT(Import Address Table), которую заполняет при загрузке модуля загрузчик. При особой настройке компилятора вызов может быть таким CALL DWORD PTR [Y]. Суть метода перехвата заключается в том, чтобы править значения, которые находятся по адресу Y, т.е. правка значений в таблице адресов импорта. Сначала мы сохраняем реальный адрес перехватываемой функции. Потом проходимся по IAT и правим этот реальный адрес на адрес нашего обработчика. Но править придется IAT всех модулей в данный момент загруженный в АП процесса, а также всех динамически подгружаемых. В первом случае необходимо решить задачу получения списка всех модулей загруженных в АП процесса. Во втором случае мы должны перехватывать функции LoadLibraryA, ; LoadLibraryW, LoadLibraryExA, LoadLibraryExW. Также необходимо сделать так, чтобы функция GetProcAddress возвращала адрес нашего перехватчика, если вдруг жертва захочет получить реальный адрес функции, которую мы перехватываем. Это можно делать двумя способами - перехватом GetProcAddress или правкой таблицы экспорта модуля, где находиться перехватываемая функция. У этого способа есть один очень большой недостаток - функции, которые не содержатся в таблице импорта, перехватываться не будут, если только мы не будем осуществлять перехват прямо при начальной загрузке процесса. Обычно перехват делается для процесса, который уже работает. Например, программа получает адрес функции с помощью GetProcAddress, а потом мы уже делаем перехват. Тогда программа минует наш обработчик и вызовет правильную функцию.
Сначала я опишу процедуру, которая правит IAT указанного одним из параметров модуля. Я назвал эту процедуру EdiIATLocal. Например, мы перехватываем функцию, адрес которой X. Тогда процедура EditIATLocal анализирует таблицу импорта указанного модуля и если она встречает там адрес X, то функция меняет X на адрес нашего обработчика, который также передается как параметр функции.
Пример:
Пример Закончен.
А процедура EditIATGlobal правит IAT всех модулей процесса, в котором она вызывается. Мы вызываем ее в процедуре DllMain DLL, которую мы будет внедрять в адресное пространство процесса-жертвы. Она просто перечисляет все модули в адресном пространстве текущего процесса с помощью ToolHelp-функций, а потом последовательно вызывает для каждого модуля процедуру EditIATLocal, которую я описал чуть выше.
Пример:
Пример Закончен.
В функции DLLMain DLL, которую мы впоследствии будем внедрять во все процессы мы должны обрабатывать reason следующим образом:
Пример:
Пример Закончен.
Я приложил к статье исходный код DLL, которая перехватывает функции USER32.DLL!MessageBoxA и USER32.DLL!MessageBoxW в целевом процессе. Файлы исходного кода этой DLL находиться в папке HookMessBox. Чтобы посмотреть как работает перехват этих функций Вы можете использовать для внедрения мою программу DLL Injector. Например, попробуйте внедрить эту DLL в блокнот, напечатать чего-нибудь и потом нажать на крестик закрытия окна.
Чтобы распространить перехват на новые подгружаемые DLL, необходимо перехватывать KERNEL32.DLL!LoadLibrary. Используя функцию EditIATLocal Вы сможете с легкостью перехватить вызов KERNEL32.DLL!LoadLibrary таким образом, чтобы после загрузки новой DLL она сразу же обрабатывалась.
Сначала определяется адрес функции, которую надо перехватить. Первый несколько байт данной функции заменяются на переход к нашему обработчику. Теперь, если будет вызвана перехватываемая функция, то произойдет переход на наш обработчик. Если нужно вызвать оригинальную функцию, то необходимо восстановить исходные байты. С помощью этого метода перехватываются абсолютно все вызовы из любых модулей, и при этом не надо делать ничего дополнительного. Этот метод хорош во всех отношениях, если бы не одно НО...Люди, которые понимают что-нибудь в многозадачности сразу учуяли что-то не-то. Представьте, что какой-то поток правит начало функции джапмом, но вдруг ОС отнимает у него управление и передает его другому потоку. А тот обращается к недоконца подправленной функции. В итоге произойдет ошибка и приложение, скорее всего, слетит. Есть решение этой проблемы, - останавливать все потоки, когда начало функции правиться и когда вызывается ее перехватчик (ведь перехватчик тоже правит начало функции, чтобы вызывать ее оригинал). Все эти вещи реализуются очень просто. Давайте рассмотрим функции, которые приостанавливают и запускают потоки, соответственно. Нашей задачей опять будет перехват функций USER32.DLL!MessageBoxA.
Пример:
Пример Закончен.
Пример:
Пример Закончен.
В процедуру ResumeThreads не учитывается, что поток можем остановить не мы. Но это допущение для большинства приложений не является критическим.
После того, как мы нашли реальный адрес функции MessageBoxA, мы сохраняет старые 6 байт по некоторому адресу. Далее мы записываем по этому адресу переход на наш обработчик. Код перехода выглядит так:
Пример:
Пример Закончен.
А вот функция, которая как раз делает то, к чему мы стремились - осуществляет перехват:
Пример:
Пример Закончен.
Также нужен код, который позволяет выполнить оригинальную функцию, т.е. временно убрать перехват:
Пример:
Пример Закончен.
А вот и сам перехватчик. Т.е. код на который мы прыгаем, при вызове перехватываемой функции.
Пример:
Пример Закончен.
Когда мы устанавливаем перехват с помощью сплайсинга, мы затираем первые несколько байт оригинальной функции. Если мы используем относительный JMP, то мы затираем первые 5 байт. Перед затиркой мы сохраняем эти 5 байт. Когда нам нужно вызвать оригинальную функцию, мы записываем сохраненные байты по адресу точки входа функции. Вот здесь есть проблеме связанная с реентерабельностью. Мы можем избавиться от этой проблемы. Мы должны всего лишь сохранить первые инструкции, размер которых больше или равно 5 байтам (в случае, если мы затираем начало функции относительным JMP). Тогда если мы хотим вызвать оригинальную функцию, мы вызываем инструкции по адресу, по которому мы сохраняли затертые инструкции. После выполнения этих затертых инструкций мы выполняем инструкцию JMP на адрес в перехватываемой функции, где начинается следующая инструкция. Таким образом, логика работы оригинальной функции совершенно не меняется. При этом мы можем ее вызывать без особых функций. Самая главная здесь сложность - это как определить начало следующей инструкции, т.е. здесь нам необходим дизассемблер длин. Ему на вход подается адрес, а выход - это количество байт, занимаемых инструкцией по входному адресу.
Чтобы понять смысл этого метода рассмотрим простой пример. Во-первых, определим место, куда мы будем копировать инструкции, которые могут быть затерты. Мы сделаем это так:
old_func db 090h, 090h, 090h, 090h, 090h, 090h, 090h, 090h, 090h, \
090h, 090h, 090h, 090h, 090h, 090h, 090h, 0e9h, 000h, \
000h, 000h, 000h
Мы будем сохранять инструкции по адресу old_func. Мы оставляем место для некоторого количества инструкций. Мы заполняем оставшееся место в буфере 090h, т.к. эта инструкция ничего не делает, в результате её выполнения просто инкрементируется регистр EIP. В конце буфера мы ставим относительный JMP, адрес, куда мы будем переходить в этой инструкции, мы потом должны заполнить. При вызове оригинальной функции мы вызываем ее так: CALL old_func
Допустим, мы перехватываем функцию Sleep.
До перехвата она выглядит так:
KERNEL32.Sleep: 77E86779: 6A00 PUSH 0 77E8677B: FF742408 PUSH DWORD PTR [ESP+8] 77E8677F: E803000000 CALL Kernel32.SleepEx 77E86784: C20400 RET 00004H
С помощью дизассемблера длин мы вычисляем последовательно длины команд. Если с начала функции сумма длин команд больше или равно 5, то сохраняем обработанные инструкции по адресу old_func. Для функции Sleep мы сохраняем 6 байт, т.е. два PUSH'а. Также мы запоминаем адрес 77E8677F - после выполнения двух PUSH'ей мы джампим на этот адрес.
После установки перехвата функция Sleep примет следующий вид:
KERNEL32.Sleep: 77E86779: E937A95788 JMP 0004010B5H ; 0004010B5H - адрес обработчика 77E8677E: 08 ? 77E8677F: E803000000 CALL Kernel32.SleepEx 77E86784: C20400 RET 00004H
А код old_func будет таким:
old_func: 00403027: 6A00 PUSH 0 00403029: FF742408 PUSH DOWRD PTR [ESP+8] 0040302D: 90 NOP 0040302E: 90 NOP 0040302F: 90 NOP 00403030: 90 NOP 00403031: 90 NOP 00403032: 90 NOP 00403033: 90 NOP 00403034: 90 NOP 00403035: 90 NOP 00403036: 90 NOP 00403037: E94337A877 JMP KERNEL32.77E8677F
Таким образом, если мы хотим вызывать оригинальную функцию мы вызываем old_func - это и будет оригинальной функцией. old_func называется функцией-трамплином (trampoline function).
Этот метод используется в продукте для перехвата функций, который называется Detours.
Описанный способ не может работать если функция занимает меньше 5 байт. Эту проблему можно решить с помощью перехода не командой JMP, а командой INT 3 (наш перехватчик в итоге будет обработчиком необработанных исключений). Команда INT 3 занимает 1 байт. Но производительность этого способа оставляет желать лучшего.
Можно разделить способы перехвата на перехват до запуска модуля и перехват после запуска модуля. При перехвате до запуска модуля, используется техника правки системных библиотек на жестком диске. Для этого необходимо проделать следующие шаги:
Чтобы осуществить все перечисленные шаги необходимо знать, что такое Windows File Protection и как его отключать без перезагрузки системы.
Windows File Protection - это сервис ОС, который защищает системные файлы ОС от изменения, повреждения или удаления. Впервые WFP появился в ОС Windows Millennium Edition. До появления WFP любая программа могла заменить системную библиотеку, что многие программы и делали при инсталляции. Из-за этого другие программы переставали работать и при этом могли забрать систему с собой в мир иной :) Такое положение вещей назвали "DLL Hell". В Windows Millennium Edition все системные SYS, DLL, EXE, and OCX защищены. В дополнение TrueType шрифты Micross.ttf, Tahoma.ttf, и Tahomabd.ttf также защищены. Если происходит изменение, модификация или удаление защищенного файла, то система восстанавливает его из кэша DLL, который по умолчанию находиться в папке:
%SYSTEMROOT%\system32\dllcache
Этот путь можно изменить, изменив значение параметра реестра:
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\WindowsNT\CurrentVersion\Winlogon\SFCDllCacheDir
Чтобы узнать, что был заменен какой-то из файлов, Windows просматривает каталоги безопасности и сверяет цифровые подписи. Если подпись какого-файла не соответствует подписи в каталоге безопасности, то Windows берет файлы из кэша. Потом Windows ищет эти файлы в сети, если была произведена установка оп сети. Если данный файл отсутствует в кэше и в сети, то Windows требует вставить оригинальный диск ОС. Можно включить принудительную проверку всех файлов ОС Windows с помощью утилиты sfc, которая доступна в стандартной комплектации ОС. Также при обнаружении исправленного или удаленного системного файл WFP записывает событие в лог событий, который можно посмотреть с помощью оснастки Event Log (%windir%\system32\eventvwr.msc). Следующие механизмы позволяют изменять системные файлы, не смотря на Windows File Protection:
Чтобы без шума добраться до системных файлов и отредактировать их мы должны отключить WFP. Есть несколько способов сделать это. Например, с помощью редактирования реестра или с помощью правки файла sfc.dll или sfc_os.dll. Но эти способы теряют свою актуальность, потому что они либо работали с какой-то конкретной ОС, либо требуют перезагрузки и/или входа в безопасный режим ОС. Но есть способ отключения WFP прямо при работе. Давайте его и рассмотрим.
WFP держится на двух DLL - SFC.DLL, SFC_OS.DLL. А код, который использует эти DLL находиться в WINLOGON.EXE. Модуль SFC_OS.DLL экспортирует функцию, которая экспортируется не по имени, а по ординалу и имеет ординал 1. Эта функция запускает систему защиты файлов. Если покопаться в коде этой функции, то можно увидеть, что она вызывает функцию NTDLL.DLL!NtNotifyChangeDirectoryFile. Это недокументированная функция, но на ней основывается другая функция, которая называется KERNEL32.DLL!FindFirstChangeNotification. Эта функция возвращает описатель, который можно использовать в функциях ожидания, например KERNEL32.DLL!WaitForSingleObject. Т.е. WFP устанавливает систему нотификации на системные папки. Если файлы в папке изменяются, то WFP сразу на это реагирует. Все что нам требуется чтобы отключить WFP - это закрыть все описатели, которые были возвращены NTDLL.DLL!NtNotifyChangeDirectoryFile. Эти описатели типа "файл". Если мы захотим отключить WFP, когда система работает, и если мы не хотим писать код, можно просто запустить утилиту Process Explorer или подобную ей, чтобы закрыть хэндлы объектов "файл". Например,
File Object - C:\WINDOWS\SYSTEM32\.
Закрывая этот описатель, мы можем изменять файлы в папке C:\WINDOWS\SYSTEM32 и Windows ничего не скажет. При реализации кода процедуры отключения WFP необходимо знать, как получить хэндлы открытых описателей. Это делается с помощью функции NtQuerySystemInformation. В MSDN она документирована, но не полностью и того, что нам нужно там нет. Приходиться использовать справочник Гарри Нэббета "Windows NT 2000 Native API Reference".
Чтобы отключить таким образом WFP, необходимы отладочные привилегии, т.к. нам приходиться открывать процесс WINLOGON.EXE. А для того чтобы получить отладочные привилегии, необходимы привилегии администратора. Из этого следует, что этот способ будет работать только под учетной записью администратора или используя имперсонацию.
Для начала получаем идентификатор процесса WINLOGON.EXE. Он нужен для того, чтобы отличать хэндлы процесса WINLOGON.EXE от всех остальных. Чтобы получить идентификатор по имени модуля, используем функцию GetPIDbyName:
Пример:
Пример Закончен.
В функции GetPIDbyName используем Toolhelp-функции для перечисления процессов в системе. Мы сравниваем имя полученного модуля со статической строкой "WINLOGON.EXE". Сравнение идет с помощью API-функции lstrcmpi. Эта функция сравнивает строки не учитывая во внимание регистр символов.
Далее нам необходимо получить список всех описателей процесса WINLOGON.EXE. Но в ОС Windows нет функции, которая позволила бы получить описатели для конкретного процесса. Однако, как Вы уже знаете описатели можно получить с помощью Native функции NtQuerySystemInformation. Часть описания этой функции доступно в MSDN, но этого нам не достаточно. Более того там написано неправильно!!! :( Посмотрите на прототип этой функции:
Давайте прочтем описание переменной ReturnLength:
"ReturnLength [out, optional] Optional pointer to a location where the function writes the actual size of the information requested. If that size is less than or equal to the SystemInformationLength parameter, the function copies the information into the SystemInformation buffer; otherwise, it returns an NTSTATUS error code and returns in ReturnLength the size of buffer required to receive the requested information."
Вот здесь и есть ошибка в документации. На самом деле, если размер буфера меньше нужного, то параметр ReturnLength не заполняется. Так как размер буфера не перманентен, то нам приходиться инкрементно перебирать размеры. Если функция возвращает STATUS_INFO_LENGTH_MISMATCH, то размер буфера недостаточен. Вот код который находит нужный размер буфера:
Пример:
Пример Закончен.
После выполнения вышеприведенного кода, в pSystemHandleInfo содержится указатель на буфер. В буфере содержится количество описателей. А потом массив структур типа HandleInfo. Количество структур в этом буфере ровно соответствует первому двойному слову буфера. Эта структура определена следующим образом:
Pid мы используем, чтобы узнать какому процессу принадлежит описатель. Также мы будем использовать параметр HandleValue для дублирования хэндлов.
После того как мы узнали, что данный описатель принадлежит процессу WINLOGON.EXE мы должны узнать имя объекта соответствующего данному описателю. Нас интересует имя \Device\HarddiskVolume1\WINDOWS\system32. А если точнее его часть WINDOWS\SYSTEM32. Закрывая эти описатели, мы отключаем Windows File Protection. Чтобы получить имя объекта по его описателю, мы вызываем функцию NtQueryObject. Эта Native функция полностью недокументированна. По крайней мере в MSDN VisualStudio .NET 2003 ее описание отсутствует. Но я знаю, что ее описание есть в DDK. Как бы то ни было, я взял прототип функции в книге Гарри Нэббета.
Мы вызываем функцию NtQueryObject, чтобы получить имя объекта соответствующее описателю. Далее мы сравниваем UNICODE-строку "WINDOWS\SYSTEM32" или "WINNT\SYSTEM32" с полученным именем объекта. Сравниваем мы с конца, идя в начало. Сравнение идет с помощью функции CompareStringsBackwards. В ней используются цепочечные операции пересылки слов. Длина сравнения зависит от длины строки "WINDOWS\SYSTEM32" или "WINNT\SYSTEM32". А вот и функция CompareStringsBackwards:
Пример:
Пример Закончен.
Если строки равны и CompareStringsBackwards возвращает единицу, то мы переоткрываем описатель чтобы открыть его с правами DUPLICATE_CLOSE_SOURCE or DUPLICATE_SAME_ACCESS. Флаг DUPLICATE_CLOSE_SOURCE указывает, что функция DuplicateHandle закрывает указанный описатель в указанном процессе.
А теперь посмотрите полные код программки, которая отключает Windows File Protection во время работы ОС. После перезагрузки WFP опять будет включена.
Пример:
Пример Закончен.
Для установки в системе этого перехвата необходимо внедрить DLL в адресное пространство всех текущих процессов или просто скопировать код в Shell-код стиле (если мы не используем DLL), а также всех процессов, которые запустятся потом. Для внедрения во все текущие процессы используем Toolhelp-функции для перечисления процессов. Также можно использовать функцию NtQuerySystemInformation, которая является Native для Toolhelp-функций, а также и для функций Enum... Вот код, который устанавливает перехват для всех запущенных процессов:
Пример:
Пример Закончен.
Чтобы глобально перехватывать функции можно использовать функцию SetWindowsHook. Тогда мы будет перехватывать нужную функцию во всех текущих GUI-приложениях, а также новых, т.к. если мы вызываем функцию SetWindowsHook, то она внедряет DLL и для всех новых процессов.
Другой способ в следующем. Необходимо перехватывать функции, которые создают процесс или которые вызываются при создании процесса. Т.о. мы будет устанавливать перехват и для всех новых процессов. В ОС Windows существует много функций, которые создают процессы - SHELL32.DLL!ShellExecute, KERNEL32.DLL!CreateProcess, NTDLL.DLL!NtCreateProcess. Нам необходимо выяснить какие действия происходят при создании любого процесса, используя любую из функций создания процессов в ОС.
Какой бы функцией не был создан процесс, при создании процесса вызывается функция ZwCreateThread. Вот ее прототип:
В параметре ClientId содержиться указатель на структуру, которая называется CLIENTID. Она определена так:
UniqueProcess - это идентификатор процесса в котором создается поток. Делаем так: в обработчике ZwCreateThread после вызова нормальной функции ZwCreateThread проверяем UniqueProcess из структуры CLIENTID. Если это значение отличается от идентификатора нашего процесса, то заражаем процесс. Но не тут-то было!!! При заражении процесса вызов LoadLibrary окажется неудачным, потому что процесс еще не проинициализирован. Таким образом если идентификаторы нашего процесса и нового не совпали, то мы просто устанавливаем флажок NewProcess. А мы знаем, что при создании процесса основной поток приостановлен до тех пор, пока процесс не будет проинициализирован. После того как новый процесс будет проинициализирован для основного потока вызывается функция ZwResumeThread. Значит и ее тоже надо перехватывать. Я сделал 2 макроса, которые сохраняют и соответственно восстанавливают регистры ESI, EDI, EBX, EBP. Вот эти макросы:
Взгляните на обработчик ZwCreateThread:
Пример:
Пример Закончен.
Теперь нам надо перехватить ZwResumeThread. Вот ее прототип:
Как видите нам передается описатель потока, работа которого возобновляется. Нам необходимо получить id процесса, которому принадлежит этот поток. Если этот id отличается от нашего id'а и установлен флаг NewProcess, то заражаем процесс. Id процесса по описателю потока можно получить с помощью функции NtQueryInformationThread. Вот ее прототип:
Из вложенной структуры ClientId мы узнаем id процесса, которому принадлежит поток, т.к. при вызове функции ZwQueryInformationThread заполняется структура THREAD_BASIC_INFORMATION.
А вот исходный код обработчика ZwResumeThread:
Пример:
Пример Закончен.
В архиве прилагаемой к статье в папке GlobalHooking находиться программа и ее исходный код, где перехватывается MessageBoxA и MessageBoxW во всех текущих процессах и в новых.
Вот список, где можно использовать перехват вызовов функций. Но он конечно не исчерпывающий.
В этой главе мы рассмотрели несколько очень важных техник, без которых далеко не уйдешь. Они используются не только при программировании вирусов, но и вообще в системном программировании. Теперь используя полученный материал, Вы можете программировать любые локальные вирусы. Я понимаю, что этот материал нельзя освоить за один наскок, но Вы должны стараться. Во всяком случае, Вы будете приближаться к истинному пониманию работы ОС Windows, ее идеологии, подводных камнях и т.д. И наша задача заключается именно в понимании тонкостей работы ОС Windows. Я надеюсь, что не будете никому вредить, используя полученные знания. Я категорически против деструкции в вирусах. Лучше напрягитесь и сделайте какую-нибудь красивую или оригинальную полезную нагрузку, чтобы юзверь упал со стула от удивления, например, когда его компьютер начнет пукать :)
Если у Вас есть замечания по статье или вопросы, то свяжитесь со мной по адресу BILL_TPOC@MAIL.RU.
Я представляю лабораторию The Passion Of Code (TPOC) и заявляю: если у Вас есть желание вникать в тонкости ОС и Вы уже что-то умеете, то я прошу Вас связаться со мной по адресу BILL_TPOC@MAIL.RU. Но не беспокойте пожалуйста меня те люди, которых надо подгонять что-то делать - у Вас должен быть свой энтузиазм. Сайт нашей лаборатории http://tpoc.h15.ru.
DayDream, BlackFox, _follower / TPOC, FreeMan / TPOC
Также хотел бы сказать спасибо Ms-Rem за его замечательную статью "Перехват API функций в Windows NT (часть 2). Методы внедрения кода"
[Вернуться к списку] [Комментарии (0)]