Рассмотрим формат исполняемого файла..
Важной его частью является формируемый компилятором заголовок PE-Header – этакий паспорт с полной информацией о своём клиенте. Когда система запускает файл на исполнение, загрузчик образов в Ntdll.dll сначала берёт только заголовок из дискового файла, а остальную его часть не трогает. На основании этой информации, лоадер создаёт указанное число секций и прочих ресурсов, и только потом заполняет эти секции кодом и данными. Под заголовок выделяется одна 4К- страница виртуальной памяти размером 0х1000 байт.
Header имеет довольно запутанную структуру и чтобы разобраться в нём хотя-бы на начальном уровне, придётся проштудировать манны как-минимум раз 10. Здесь уже подымался этот вопрос, и человек написал целую статью на эту тему, где достаточно внятно освятил общее положение дел – советую ознакомиться с ней. Так-что не буду повторяться, а уделю внимание только разбору секции-экспорта, но для начала пробежимся по-макушкам..
Всё-что будет сказано ниже, нужно воспринимать как вводную часть для программирования Shell-кода и внедрения его в чужой процесс. Любой шелл устроен так, что у него нет секции-импорта ..и вообще нет ничего, кроме своих мозгов. А раз нет импорта, значит он не может вызывать API по их именам, и ему нужно самому искать точки-входа в нужные функции. Вот тут-то и приходит на помощь PE-заголовок, в котором перечисляются ординалы, имена и адреса экспортируемых функций.
Шелл – это скрытый агент, блуждающий в чужом контексте в маске-анонимуса. Излюбленными его API являются функции из Kernel32.dll. В этой библиотеке есть всё для создания полноценного кода, и в большинстве случаях остальные либы просто не нужны. Так-что сделаем упор на Kernel32, которая вместе с Ntdll.dll присутствует во-всех процессах, причём всегда по одинаковому адресу – для WinXP это 0х7с800000, для семёрки 0х77а90000:
Комбинация Ctrl+G в окне отладчика позволяет просматривать регионы памяти. Если ввести туда базу кернела 0х7с800000, то попадём в начало его РЕ-заголовка (см.рис.ниже). Сигнатура ‘MZ’ гарантирует, что перед нами именно заголовок исполняемого файла, а не что-то иное. MZ – это инициалы разработчика досовских экзешников Mark Zbikowski (видимо любил себя чел), так-что каждый файл формата РЕ начинается с такой заглушки Dos-Stub. Непосредственно РЕ-заголовок следует ниже, а его конкретный RVA-адрес лежит по смещению 0x3с от начала файла:
В документации на РЕ-файл фигурируют три типа адресов: базовый Base, виртуальный VA и относительный RVA (Relative-Virtual-Address). Виртуальный адрес получается из комбинации: VA = Base+RVA. Например, в данном случае по смещению 0х3с лежит значение 0х000000F0 – это адрес относительно базы, который назвали RVA-адресом. Чтобы получить виртуальный адрес начала РЕ-заголовка, нужно к базе 0х7с800000 прибавить 0xF0. Как показывает скрин выше, по этому адресу находится сигнатура РЕ, от Portable-Executable (портируемый экзе). Теперь посмотрим на формат этого заголовка..
Выделенный красным блок – это и есть РЕ-заголовок, который описывает глобальные свойства файла. Так, после сигнатуры по смещению(04) указывается привязка программы к процессору – 0х014С означает i80386 и выше. Смещение(06) хранит кол-во секций в файле = 0х0004. Дальше идёт закодированные дата/время создания файла = 0х480381ЕА. Следущие 8-байт зарезервированы и всегда равны нулю. Предпоследние два по смещению(14h) – это размер двух/следующих блоков, он жёстко зафиксирован на отметке 0х00Е0. И последнее слово 0х210Е хранит атрибуты данного файла – исполняемая Win32 библиотека.
В коде, удобно указывать все смещения относительно начала РЕ-заголовка, поместив его например в регистр ESI в качестве базы. Поэтому здесь и далее мы будем придерживаться этих правил (одна строка – это параграф памяти размером 10h-байт). В опциональном заголовке нас будут интересовать всего несколько полей:
Серый блок ‘Image_Directory’ для нас представляет особый интерес, именно он послужит проводником в секцию-экспорта системных API. Формат каталога такой, что первые 4 байта это RVA-указатели на соответствующую таблицу, а вторые – её размер. Hiew непонаслышке знаком с этим диром, только указанные им адреса не совпадают с адресами в памяти, т.к. он работает с образом файла на диске, а отладчик OllyDbg отображает виртуальные адреса в памяти. Но сейчас важна структура каталога, для просмотра которой нужно пройтись по цепочке меню: Enter (Hex) -> F8 (РЕ_Header) -> F10 (Dir):
На что здесь стОит обратить внимание, так это на отсутствие в данном списке линков на секцию-кода и данных. Дело в том, что эти две секции вообще не требуют таблиц для своего описания. Загрузчик образов просто выделяет 1000h байтные страницы и тупо копирует в них данные и код в том виде, в котором они хранятся на диске. Если код превышает размер одной страницы (например 1200h), то выделяются две страницы, а лишние 800h забиваются нулями. Это известно как ‘выравнивание секций в памяти на 1000h байтную границу’ – Section_Aligment.
Нужно сказать, что выравниваются секции не только в памяти, но и на диске, только не на 1000h, а на 200h байт - File_Aligment. Такая разница позволяет экономить дисковое пространство. Заражающая файлы малварь, любит дописывать себя в такие бесхозные байты-выравнивания. Если посмотреть на дампы файлов (хоть в памяти, хоть на диске), то у них всегда имеется болото нулей в хвосте секций.
Кому интересно назначение полей всех заголовков, некто Ю.С.Лукач разложил это по-полочкам в своём туториале:
4.4.0. Разбор таблицы-экспорта в поисках адресов функций
Теперь посмотрим, каким образом шелл может получить адреса API без системной поддержки типа GetProcAddress(). Во-первых, на системах х32 каталог секций IMAGE_DIRECTORY всегда начинается по смещению РЕ+78h. Первым элементом в нём лежит как-раз RVA-указатель на таблицу экспорта (см.скрин Hiew’a выше), в данном случае он равен 0х0000262с. Соответственно, чтобы получить из него виртуальный адрес VA, суммируем RVA с базой и получаем 0х7с80262с. Топаем туда в отладчике и видим такую таблицу-экспорта размером 28h байт (выделена красным):
Соберём всё вышесказанное вместе..
Значит таблица-экспорта имеет три вложенные таблицы:
Кроме того, у нас имеется счётчик общего кол-ва функций в либе по смещению 0х14 – здесь он равен 0х03b9 или 953 функции API. Таким образом, первой экспортируемой функцией из библиотеки Kernel32.dll является ActivateActCtx(), а точкой-входа в неё – адрес 0x7c80a6d4 (выделенный синим блок). На всякий/пожарный, проверим наши доводы в WinDbg ..и точно совпадает:
Получив по такому алгоритму адрес функции в памяти, шелл-код может вызывать её по адресу, а не по имени. Например можно считать адрес функции в регистр EDX, и дальше CALL_EDX. Это прекрасно работает, только есть одна загвоздка – как среди такого кол-ва функций (953), найти имя нужной нам API, ..не сравнивать-же все строки контекстным поиском?
4.4.1. Вычисление хеша имени функций
Любой поиск – это сравнение двух значений, а значит нужна маска для поиска. Если зашить в шелл имя искомой функции в формате ASCIIZ, то это получиться не шелл, а ёлка с гирляндами - ASCII-строки сдадут его с потрахами. Одним из возможных вариантов является вычисление хеша строки, или её контрольной суммы. То-есть мы складываем все байты имени функции и получаем их сумму. Так, имя API длинною например в 20-символов, можно будет сжать до пару байт. Разрядность хеша должна быть минимум 16-бит (слово), тогда вероятность коллизии (совпадении) хешей на порядок уменьшится. Вычислить хеш строки можно в цикле тремя строчками кода:
4.4.1. Поиск базы Kernel32.dll в памяти
До этих пор мы рассматривали внутренности кернела, теперь выйдем наружу и найдём её адрес в виртуальной памяти. Это первое, что должен будет сделать шелл попав в тело жертвы из эксплоита. Известных лично мне способов поиска базы только два, а остальные – их модификация. В основе первого лежит идея, что если найти любую функцию из Kernel32.dll и потянуть за неё, она обязательно выведёт нас к базе. Недостатком является поиск перебором, что отнимает относительно много времени. Альтернативой служит второй способ, когда мы берём уже готовый адрес из структуры РЕВ процесса. Рассмотрим оба метода подробней..
Во-первых нужно взять во-внимание то, что базы всех модулей одного приложения всегда выровнены на 64К-байтную границу, а это 10000h байт (см. MemoryMap в оле). Соответственно, чтобы найти базу Kernel32 в памяти, нужно получить указатель на любую функцию из этой библиотеки, и сделать её кратной 0х10000. Если повезёт и по выровненному адресу увидим сигнатуру ‘MZ’, значит мы у цели. Иначе, нужно в цикле отнимать по 0х10000 и мы обязательно упрёмся на базу. На резонный вопрос ‘от куда взять адрес функции’ ответит нам стек в отладчике OllyDbg:
Здесь я выделил зелёным два адреса внутри Kernel32.dll, однако первый отпадает сразу, т.к. жертва могла поместить уже что-нибудь в стек. Зато второй (обозначеный красным) блок будет присутствовать всегда – это отлавливающий системные исключения SEH-фрейм Structured_Exeption_Handler.
Замечу, что у одной программы может быть несколько таких фреймов (для каждого исключения свой), и каждый последующий указывает на предыдущий – именно поэтому один фрейм содержит в себе два значения, где нижнее это указатель на обработчик исключения, а верхнее – указатель на следующий фрейм в цепочке. Маркером последнего фрейма является значение 0xFFFFFFFF, его-то процедура обработки и находится внутри кернела. Указатель на первый SEH-фрейм прошит в структуре ТЕВ по смещению(00h), а на сам ТЕВ всегда указывает сегментный регистр FS.
Как видим, код получается довольно объёмный, да ещё и с циклами внутри, что отнимает время. Поэтому рассмотрим альтернативу, когда мы просто берём готовый адрес из структуры РЕВ процесса-жертвы. Указатель на РЕВ лежит в структуре ТЕВ по смещению +30h.
При создании системой любого процесса, она логирует свои действия в его блоке окружения PEB – Process_Environment_Block. На определённом этапе рождения нового процесса, в игру вступает загрузчик образов из Ntdll.dll – именно он подгружает системные либы в память процессов. Как и следовало ожидать, этот факт фиксируется в структуре РЕВ, а точнее в его поле ‘PEB_LDR_DATA’, которое предоставляет информацию о загруженных модулях DLL. Заглянем в структуру РЕВ отладчиком WinDbg:
Если вскрыть эту вложенную структуру PEB_LDR_DATA (лоадер), можно увидеть в ней указатели на три двусвязных списка LIST_ENTRY (двусвязные списки уже упоминались – это Forward (следующий в цепочке), и Backward (предыдущий)). Два из эти списка нам особенно интересны – InInitializationOrderModuleList (содержит список DLL в порядке их инициализации), и InMemoryOrderModuleList – либы в порядке их появления в памяти. Базовый адрес библиотеки DLL хранится в 0x10 байтах от блока, на который указывает поле Flink в списке LIST_ENTRY.
Вот пример, в который заложены эти 'высокие принципы.'
Его трейс в отладчике скажет сам-за-себя:
Этот код опирается на то, что все системы класса Win32/64 загружают библиотеки всегда в определённом порядке – сначала загрузчик Ntdll.dll, потом Kernel32.dll, и только потом все/остальные либы типа User32.dll, GDI32.dll и прочие. Поэтому мы пропускаем первый 'InMemoryOrderModuleList', и берём второй. Имена библиотек в этом варианте поиска базы нигде не фиксируются, и шелл надеется только на порядок загрузки либ в память.
В демке ниже приводится вариант, как можно получить имена и адреса всех функций из Kernel32.dll. Хотя если учесть, что вывод осуществляется в цикле, я не буду искать все 953 функции, а ограничусь только первыми 20-30 штук, число которых указывается в счётчике ECX.
Значит сначала ищем базу кернела в памяти, потом IMAGE_DIRECTORY, ну и дальше разбор её секции-экспорта по указанному выше алго. Большую часть кода занимает тут оформление вывода на экран, т.к. после каждой функции нужно править буфер (вставлять перевод строки 13,10, чтобы не затереть предыдущие данные):
Важной его частью является формируемый компилятором заголовок PE-Header – этакий паспорт с полной информацией о своём клиенте. Когда система запускает файл на исполнение, загрузчик образов в Ntdll.dll сначала берёт только заголовок из дискового файла, а остальную его часть не трогает. На основании этой информации, лоадер создаёт указанное число секций и прочих ресурсов, и только потом заполняет эти секции кодом и данными. Под заголовок выделяется одна 4К- страница виртуальной памяти размером 0х1000 байт.
Header имеет довольно запутанную структуру и чтобы разобраться в нём хотя-бы на начальном уровне, придётся проштудировать манны как-минимум раз 10. Здесь уже подымался этот вопрос, и человек написал целую статью на эту тему, где достаточно внятно освятил общее положение дел – советую ознакомиться с ней. Так-что не буду повторяться, а уделю внимание только разбору секции-экспорта, но для начала пробежимся по-макушкам..
Всё-что будет сказано ниже, нужно воспринимать как вводную часть для программирования Shell-кода и внедрения его в чужой процесс. Любой шелл устроен так, что у него нет секции-импорта ..и вообще нет ничего, кроме своих мозгов. А раз нет импорта, значит он не может вызывать API по их именам, и ему нужно самому искать точки-входа в нужные функции. Вот тут-то и приходит на помощь PE-заголовок, в котором перечисляются ординалы, имена и адреса экспортируемых функций.
Шелл – это скрытый агент, блуждающий в чужом контексте в маске-анонимуса. Излюбленными его API являются функции из Kernel32.dll. В этой библиотеке есть всё для создания полноценного кода, и в большинстве случаях остальные либы просто не нужны. Так-что сделаем упор на Kernel32, которая вместе с Ntdll.dll присутствует во-всех процессах, причём всегда по одинаковому адресу – для WinXP это 0х7с800000, для семёрки 0х77а90000:
Комбинация Ctrl+G в окне отладчика позволяет просматривать регионы памяти. Если ввести туда базу кернела 0х7с800000, то попадём в начало его РЕ-заголовка (см.рис.ниже). Сигнатура ‘MZ’ гарантирует, что перед нами именно заголовок исполняемого файла, а не что-то иное. MZ – это инициалы разработчика досовских экзешников Mark Zbikowski (видимо любил себя чел), так-что каждый файл формата РЕ начинается с такой заглушки Dos-Stub. Непосредственно РЕ-заголовок следует ниже, а его конкретный RVA-адрес лежит по смещению 0x3с от начала файла:
В документации на РЕ-файл фигурируют три типа адресов: базовый Base, виртуальный VA и относительный RVA (Relative-Virtual-Address). Виртуальный адрес получается из комбинации: VA = Base+RVA. Например, в данном случае по смещению 0х3с лежит значение 0х000000F0 – это адрес относительно базы, который назвали RVA-адресом. Чтобы получить виртуальный адрес начала РЕ-заголовка, нужно к базе 0х7с800000 прибавить 0xF0. Как показывает скрин выше, по этому адресу находится сигнатура РЕ, от Portable-Executable (портируемый экзе). Теперь посмотрим на формат этого заголовка..
Выделенный красным блок – это и есть РЕ-заголовок, который описывает глобальные свойства файла. Так, после сигнатуры по смещению(04) указывается привязка программы к процессору – 0х014С означает i80386 и выше. Смещение(06) хранит кол-во секций в файле = 0х0004. Дальше идёт закодированные дата/время создания файла = 0х480381ЕА. Следущие 8-байт зарезервированы и всегда равны нулю. Предпоследние два по смещению(14h) – это размер двух/следующих блоков, он жёстко зафиксирован на отметке 0х00Е0. И последнее слово 0х210Е хранит атрибуты данного файла – исполняемая Win32 библиотека.
В коде, удобно указывать все смещения относительно начала РЕ-заголовка, поместив его например в регистр ESI в качестве базы. Поэтому здесь и далее мы будем придерживаться этих правил (одна строка – это параграф памяти размером 10h-байт). В опциональном заголовке нас будут интересовать всего несколько полей:
- РЕ+28h = 0х0000b63e: RVA точки-входа в программу (Entry_Point);
- РЕ+2Сh = 0х00001000: RVA начала секции-кода;
- РЕ+30h = 0х00080000: RVA начала секции-данных;
- РЕ+34h = 0х7с800000: Image_Base, база образа в памяти;
- РЕ+38h = 0х00001000: Выравнивание секций в памяти;
- РЕ+74h = 0х00000010: Кол-во элементов в следующем каталоге-секций (всегда 0х10=16);
На что здесь стОит обратить внимание, так это на отсутствие в данном списке линков на секцию-кода и данных. Дело в том, что эти две секции вообще не требуют таблиц для своего описания. Загрузчик образов просто выделяет 1000h байтные страницы и тупо копирует в них данные и код в том виде, в котором они хранятся на диске. Если код превышает размер одной страницы (например 1200h), то выделяются две страницы, а лишние 800h забиваются нулями. Это известно как ‘выравнивание секций в памяти на 1000h байтную границу’ – Section_Aligment.
Нужно сказать, что выравниваются секции не только в памяти, но и на диске, только не на 1000h, а на 200h байт - File_Aligment. Такая разница позволяет экономить дисковое пространство. Заражающая файлы малварь, любит дописывать себя в такие бесхозные байты-выравнивания. Если посмотреть на дампы файлов (хоть в памяти, хоть на диске), то у них всегда имеется болото нулей в хвосте секций.
Кому интересно назначение полей всех заголовков, некто Ю.С.Лукач разложил это по-полочкам в своём туториале:
Ссылка скрыта от гостей
Нужно отдать должное автору за сбор такого мануала. Это одно из лучших описаний РЕ-файла в сети рунет.4.4.0. Разбор таблицы-экспорта в поисках адресов функций
Теперь посмотрим, каким образом шелл может получить адреса API без системной поддержки типа GetProcAddress(). Во-первых, на системах х32 каталог секций IMAGE_DIRECTORY всегда начинается по смещению РЕ+78h. Первым элементом в нём лежит как-раз RVA-указатель на таблицу экспорта (см.скрин Hiew’a выше), в данном случае он равен 0х0000262с. Соответственно, чтобы получить из него виртуальный адрес VA, суммируем RVA с базой и получаем 0х7с80262с. Топаем туда в отладчике и видим такую таблицу-экспорта размером 28h байт (выделена красным):
Соберём всё вышесказанное вместе..
Значит таблица-экспорта имеет три вложенные таблицы:
• Таблица точек-входов в экспортируемые функций, RVA-указатель на которую лежит по смещению 0х1с,
• Таблица указателей на имена функций по смещению 0х20,
• Таблица-ординалов функций, которая в данном случаем нам не нужна.
Кроме того, у нас имеется счётчик общего кол-ва функций в либе по смещению 0х14 – здесь он равен 0х03b9 или 953 функции API. Таким образом, первой экспортируемой функцией из библиотеки Kernel32.dll является ActivateActCtx(), а точкой-входа в неё – адрес 0x7c80a6d4 (выделенный синим блок). На всякий/пожарный, проверим наши доводы в WinDbg ..и точно совпадает:
Получив по такому алгоритму адрес функции в памяти, шелл-код может вызывать её по адресу, а не по имени. Например можно считать адрес функции в регистр EDX, и дальше CALL_EDX. Это прекрасно работает, только есть одна загвоздка – как среди такого кол-ва функций (953), найти имя нужной нам API, ..не сравнивать-же все строки контекстным поиском?
4.4.1. Вычисление хеша имени функций
Любой поиск – это сравнение двух значений, а значит нужна маска для поиска. Если зашить в шелл имя искомой функции в формате ASCIIZ, то это получиться не шелл, а ёлка с гирляндами - ASCII-строки сдадут его с потрахами. Одним из возможных вариантов является вычисление хеша строки, или её контрольной суммы. То-есть мы складываем все байты имени функции и получаем их сумму. Так, имя API длинною например в 20-символов, можно будет сжать до пару байт. Разрядность хеша должна быть минимум 16-бит (слово), тогда вероятность коллизии (совпадении) хешей на порядок уменьшится. Вычислить хеш строки можно в цикле тремя строчками кода:
C-подобный:
include 'win32ax.inc'
.data
frmt db 'Хеш от строки "CreateToolhelp32Snapshot" = 0х%04x',0
funcName db 'CreateToolhelp32Snapshot'
strLen = $-fName
text db 128 dup(0)
;-------
.code
start: mov ecx,strLen ;// ECX = длина строки с именем функции
mov esi,funcName ;// её адрес
xor eax,eax ;// обнулить EAX и EBX
xor ebx,ebx ;//
@hash: lodsb ;// AL = очередной байт из ESI
add ebx,eax ;// суммируем их в EBX
loop @hash ;// повторить ECX-раз..
cinvoke wsprintf,text,frmt,ebx ;// хеш в EBX - переведём его в символы
invoke MessageBox,0,text,0,0
invoke ExitProcess, 0
.end start
4.4.1. Поиск базы Kernel32.dll в памяти
До этих пор мы рассматривали внутренности кернела, теперь выйдем наружу и найдём её адрес в виртуальной памяти. Это первое, что должен будет сделать шелл попав в тело жертвы из эксплоита. Известных лично мне способов поиска базы только два, а остальные – их модификация. В основе первого лежит идея, что если найти любую функцию из Kernel32.dll и потянуть за неё, она обязательно выведёт нас к базе. Недостатком является поиск перебором, что отнимает относительно много времени. Альтернативой служит второй способ, когда мы берём уже готовый адрес из структуры РЕВ процесса. Рассмотрим оба метода подробней..
Во-первых нужно взять во-внимание то, что базы всех модулей одного приложения всегда выровнены на 64К-байтную границу, а это 10000h байт (см. MemoryMap в оле). Соответственно, чтобы найти базу Kernel32 в памяти, нужно получить указатель на любую функцию из этой библиотеки, и сделать её кратной 0х10000. Если повезёт и по выровненному адресу увидим сигнатуру ‘MZ’, значит мы у цели. Иначе, нужно в цикле отнимать по 0х10000 и мы обязательно упрёмся на базу. На резонный вопрос ‘от куда взять адрес функции’ ответит нам стек в отладчике OllyDbg:
Здесь я выделил зелёным два адреса внутри Kernel32.dll, однако первый отпадает сразу, т.к. жертва могла поместить уже что-нибудь в стек. Зато второй (обозначеный красным) блок будет присутствовать всегда – это отлавливающий системные исключения SEH-фрейм Structured_Exeption_Handler.
Замечу, что у одной программы может быть несколько таких фреймов (для каждого исключения свой), и каждый последующий указывает на предыдущий – именно поэтому один фрейм содержит в себе два значения, где нижнее это указатель на обработчик исключения, а верхнее – указатель на следующий фрейм в цепочке. Маркером последнего фрейма является значение 0xFFFFFFFF, его-то процедура обработки и находится внутри кернела. Указатель на первый SEH-фрейм прошит в структуре ТЕВ по смещению(00h), а на сам ТЕВ всегда указывает сегментный регистр FS.
C-подобный:
include 'win32ax.inc'
.data
frmt db 'База Kernel32 = 0х%08X',0
text db 64 dup(0)
;-------
.code
start: nop
;// Этот код будет внутри шелла,
;// и в нём нельзя напрямую вызывать API-функции
mov esi,[fs:0] ; указатель на начало SEH-цепочки
@findSeh: cmp dword[esi],-1 ; проверить значение из ESI на 0хFFFFFFFF (маркер окончания)
je @found ; перейти, если нашли
mov esi,[esi] ; иначе: двигаемся по цепочке вверх
jmp @findSeh ; повторить, пока не найдём последний SEH
@found: mov esi,[esi+4] ; нашли! сл.DWORD - это адрес обработчика SEH
and esi,-0x10000 ; выровнить адрес на 64К границу
mov ecx, 0x10 ; кол-во возможных блоков в памяти
@findPE: cmp word[esi],'MZ' ; проверить блок на сигнатуру РЕ-заголовка
je @kernelBase ; если совпало..
sub esi, 0x10000 ; иначе: двигаемся вверх по адресу
loop @findPE ; проверить все блоки!
;//ннннннннннннннннннннннннннннннннннннннннннннн
@kernelBase:
cinvoke wsprintf,text,frmt,esi ;// в ESI лежит база Kernel32.dll
invoke MessageBox,0,text,0,0
invoke ExitProcess, 0
.end start
Как видим, код получается довольно объёмный, да ещё и с циклами внутри, что отнимает время. Поэтому рассмотрим альтернативу, когда мы просто берём готовый адрес из структуры РЕВ процесса-жертвы. Указатель на РЕВ лежит в структуре ТЕВ по смещению +30h.
При создании системой любого процесса, она логирует свои действия в его блоке окружения PEB – Process_Environment_Block. На определённом этапе рождения нового процесса, в игру вступает загрузчик образов из Ntdll.dll – именно он подгружает системные либы в память процессов. Как и следовало ожидать, этот факт фиксируется в структуре РЕВ, а точнее в его поле ‘PEB_LDR_DATA’, которое предоставляет информацию о загруженных модулях DLL. Заглянем в структуру РЕВ отладчиком WinDbg:
Если вскрыть эту вложенную структуру PEB_LDR_DATA (лоадер), можно увидеть в ней указатели на три двусвязных списка LIST_ENTRY (двусвязные списки уже упоминались – это Forward (следующий в цепочке), и Backward (предыдущий)). Два из эти списка нам особенно интересны – InInitializationOrderModuleList (содержит список DLL в порядке их инициализации), и InMemoryOrderModuleList – либы в порядке их появления в памяти. Базовый адрес библиотеки DLL хранится в 0x10 байтах от блока, на который указывает поле Flink в списке LIST_ENTRY.
Вот пример, в который заложены эти 'высокие принципы.'
Его трейс в отладчике скажет сам-за-себя:
C-подобный:
include 'win32ax.inc'
.data
frmt db 'База Kernel32 = 0х%08X',0
text db 64 dup(0)
;-------
.code
start: nop
;// Этот код будет внутри шелла,
;// и в нём нельзя напрямую вызывать API-функции
mov esi,[fs:0x30] ; берём указатель на PEB из ТЕВ
mov esi,[esi+0x0C] ; смещаемся в РЕВ к LDR_DATA,
mov esi,[esi+0x14] ; ..и дальше к 'InMemoryOrderModuleList'
mov esi,[esi] ; первый указатель в нём будет Ntdll.dll (нам не нужен)
mov esi,[esi] ; второй Kernel32.dll - наш клиент!
mov esi,[esi+0x10] ; по смещению 10h от начала, найдём его базу.
;//ннннннннннннннннннннннннннннннннннннннннннннн
@kernelBase:
cinvoke wsprintf,text,frmt,esi ;// в ESI лежит база Kernel32.dll
invoke MessageBox,0,text,0,0
invoke ExitProcess, 0
.end start
Этот код опирается на то, что все системы класса Win32/64 загружают библиотеки всегда в определённом порядке – сначала загрузчик Ntdll.dll, потом Kernel32.dll, и только потом все/остальные либы типа User32.dll, GDI32.dll и прочие. Поэтому мы пропускаем первый 'InMemoryOrderModuleList', и берём второй. Имена библиотек в этом варианте поиска базы нигде не фиксируются, и шелл надеется только на порядок загрузки либ в память.
В демке ниже приводится вариант, как можно получить имена и адреса всех функций из Kernel32.dll. Хотя если учесть, что вывод осуществляется в цикле, я не буду искать все 953 функции, а ограничусь только первыми 20-30 штук, число которых указывается в счётчике ECX.
Значит сначала ищем базу кернела в памяти, потом IMAGE_DIRECTORY, ну и дальше разбор её секции-экспорта по указанному выше алго. Большую часть кода занимает тут оформление вывода на экран, т.к. после каждой функции нужно править буфер (вставлять перевод строки 13,10, чтобы не затереть предыдущие данные):
C-подобный:
include 'win32ax.inc'
.data
capt db 'Список функций из Kernel32.dll',0
frmt db '0х%08X %s',13,10,0
text db 1024 dup(0)
;-------
.code
start: nop
;// Кладём базу Kernel32 в регистр EBX
mov ebx,[fs:0x30]
mov ebx,[ebx+0x0C]
mov ebx,[ebx+0x14]
mov ebx,[ebx]
mov ebx,[ebx]
mov ebx,[ebx+0x10]
;// Содержимое регистров:
;// EBX - база, ESI - адрес функции, EDX - имя
mov esi,[ebx+0x3c] ; RVA на РЕ-заголовок
add esi,ebx ; ..(делаем из него VA)
mov esi,[esi+0x78] ; RVA на таблицу-экспорта
add esi,ebx ; ^^^
mov edx,esi ; запомнить начало в EDX
mov esi,[esi+0x1c] ; RVA на таблицу-адресов
add esi,ebx ; ^^^
mov edx,[edx+0x20] ; RVA на указатели имён функций
add edx,ebx ; ^^^
mov ecx,20 ; сколько функций вывести на экран
mov ebp,edx ; адресуем имена через EBP
mov edi,text ; приёмник для wsprintf()
@addr: lodsd ; EAX = очередной адрес функции из ESI
mov edx,dword[ebp] ; EDX = указатель на её имя
add eax,ebx ; перевод обоих из RVA,
add edx,ebx ; ..в виртуальные VA-адреса.
add ebp,4 ; следующее имя..
pusha ; wsprintf портит все регистры, поэтому запомнить их
cinvoke wsprintf,edi,frmt,eax,edx ; сбрасываем данные в буфер, как строку
popa ; восстанавливаем все регистры
push ecx ; смещем указатель в буфере
mov ecx,-1 ; ..(счёчик для SCASB на максимум)
xor al,al ; ..(что искать в буфере - нуль)
repne scasb ; ..(поиск AL в EDI) !!!
dec edi ; ..(не считая терминального нуля)
pop ecx ; ECX на родину.
loop @addr ; промотать цикл ECX-раз..
invoke MessageBox,0,text,capt,0
invoke ExitProcess, 0
.end start