Многоуровневое разграничение прав в системах класса Windows, накладывает свой отпечаток на систему безопасности в целом. Разработчикам логичней было создать один защитный пласт и закрыть его на большой замок, но они раскромсали его на мелкие части, в результате чего появилось множество дыр и обходных путей на разных уровнях. В данной статье рассматривается одна из таких лазеек, а точнее – скрытый запуск ядерных API-функций, что даёт возможность прямо под юзером обходить различные гуарды, мониторы безопасности, API-шпионы, антивирусы и прочие силовые структуры. Не сказать, что тема новая.. просто она незаслуженно забыта, и в силу своей сложности всё реже встречается в защитных механизмах наших дней.
Оглавление:
Общие сведения
Жизненный цикл прикладных задач проходит на уровне подсистемы Win32, в состав которой входят библиотеки user32.dll, shell32.dll, kernel32.dll и другие с суффиксами(32). Однако имеются и глобальные вопросы, которые должна регистрировать в своих недрах ОС и вести над ними учёт. В таких случаях, коду нашего приложения необходимо перейти внутрь защищённого периметра, т.е. в старшую половину адресного пространства, выше
Исторически, для перехода из юзера в кернел был предусмотрен шлюз, который обслуживало прерывание INT-2Eh. Как это принято, обработчик любого прерывания находится в памяти ОЗУ, поэтому переход посредством шлюза работал очень медленно – для доступа к памяти требовалось ждать освобождения её шины, да и скорость самого обмена оставляет желать лучшего (см.тайминги памяти). В результате, переход в ядро превращался в рутину и срочно требовалось решение этой проблемы.
Позже, начиная с процессоров Pentium-II и системы Win-XP, для передачи управления в кернел была введена специальная инструкция процессора SYSENTER (опкод 0F34h). Она использует уже не память, а три MSR-регистра с номерами
Рассмотрим случай, когда наша программа планирует создать файл или открыть какое-нибудь устройство, типа жёсткий диск. Для этого предусмотрена функция CreateFile() из библиотеки kernel32.dll, которую (в зависимости от параметра dwCreationDistribution), можно использовать как для создания, так и для открытия существующего файла. Обработчик этой функции не создаёт файл, а лишь производит абсолютно не нужные нам различные проверки, после чего kernel32 передаёт управление в нативную функцию NtCreateFile() библиотеки ntdll.dll.
В свою очередь, эта функция так-же не оправдывает надежд и только делает вид, что занимается созданием долгожданного файла, а по факту – кладёт в регистр
Здесь видно, что вызов нами функции CreateFile() транзитом проходит через kernel32.dll и Ntdll.dll, после чего уходит в нёдра Ntoskrnl.exe. Прибывший верхом на инструкции SYSENTER код-сервиса в регистре EAX указывает ядру, какой именно сервис мы запрашиваем, и по этому коду ядро берёт из своей таблицы SSDT адрес обработчика этого сервиса и кол-во его аргументов. На выходе из обработчика, через инструкцию SYSEXIT в том-же регистре EAX система возвращает нам дескриптор Handle созданного файла, который в последующем мы можем использовать для обращения к нему.
Суть данной статьи в том, что если мы сможем пропарсить таблицу SSDT и вытащить из неё нужные нам номера системных сервисов SSN, это даст возможность вызывать нативные API-функции напрямую через SYSENTER, вообще не обращаясь к поднадзорным системой библиотекам kernel32 и Ntdll.dll. Основная проблема здесь в том, что SSDT находится в пространстве ядра и у нашего/смертного приложения нет прямого доступа к ней. Во-вторых – номера сервисов сильно отличаются от версии к версии Win, поэтому придётся искать их динамически в зависимости от того, в какую среду забросит судьба наш шелл-код.
Системные вызовы, для которых требуется переход в ядро можно условно разделить на 4 основных категорий. Во-всех этих случаях, ОС создаёт структуры (или делает отметки в уже существующих), чтобы в последующем пристально держать их под своим колпаком:
1. Контроль над процессом
2. Управление файлами и устройствами
3. Общение
4. Информационное обслуживание
SSDT – System Service Descriptor Table
Системные сервисы работают под управлением их диспетчера, в прямом подчинении которого имеются две таблицы SSDT – затенённая (shadow) и открытая. Нас будет интересовать только открытая таблица, а она в свою очередь включает в себя две таблицы: KiServiceTable и KiArgumentTable. В первой, по именам отсортированы все имеющиеся в системе нативные API-функций, с указанием точек-входа в них. Вторая таблица жёстко привязана к первой, и хранит кол-во аргументов к каждой из перечисленных в первой таблице функций. Индекс (порядковый номер) каждой записи в этих таблицах и назвали SSN – именно он передаётся инструкции SYSENTER в регистре EAX. Вот-что думает на этот счёт отладчик WinDbg:
Таким образом, никакого SSN в реале не существует – это лишь индекс в таблице KiServiceTable начиная с нуля. Например, если инструкции SYSENTER передать номер-сервиса
Повторюсь, что номера системных сервисов отличаются не только в разных версиях Win-XP/Vista/7/8/10, но и внутри их сборок (build) и сервис-паков SP. Связано это с тем, что мелкософт без уведомления может запросто добавить или удалить какие-нибудь функции из этого списка (они это часто практикуют), в результате чего остальные записи дружненько съезжают с насиженных мест. Как говорится, проблемы индейцев шерифа не волнуют.
Внимание заслуживает и расположение этих таблиц в памяти – старшая половина адресного пространства системы, т.е. больше
Закрылось ядро в себе – ну и ладно. Зато его хвост остался снаружи в виде библиотеки Ntdll.dll, которая проецируется системой в память любого пользовательского приложения (см.карту памяти в отладчике). Код именно этой библиотеки загружает в регистр EAX номер-сервиса, и посредством инструкции SYSENTER передаёт его ядру. Вот-она лазейка, за которую можно ухватиться и вытянуть все SSN-номера! Достаточно пропарсить экспорт этой библиотеки, по-ходу проверяя точки-входа во все функции, на загрузку некоторого значения (аля ssn) в регистр EAX.
На рисунке ниже представлен дизассемблерный листинг обработчиков где видно, что инструкция
Функции с префиксами "Nt" и "Zw" имеют одинаковые точки-входа, хотя разные ординалы (порядковые номера) внутри библиотеки. Поэтому их сервисные номера SSN так-же будут одинаковы, что собственно и демонстрирует скрин выше (Zw' введена для драйверов). Всё-что нужно будет сделать нашему коду, это в цикле обойти всю таблицу-экспорта Ntdll.dll и прочитав из точек первые 4-байта, проверить младший из них на опкод
Смотрим, что делает обработчик функции из Ntdll.dll, получив управление от kernel32.dll..
Сразу-же в регистр EAX пересылается номер запрашиваемого сервиса SSN (в данном случае 42h), после чего в регистр EDX заносится адрес стаба KiFastSystemCall с последующим его вызовов инструкцией
Последующий алгоритм нас уже мало интересует, поскольку без драйвера мы не сможем в него вмешаться. Тут на аппаратном уровне идёт переключение со-стека пользователя на стек ядра (его адрес хранится в сегменте TSS текущей задачи) и со-всем юзерским барахлом управление получает ядро Ntoskrnl.exe. Другими словами, мы можем модифицировать только то, что находится в нижней половине адресного пространства памяти, до
Разбор "таблицы-экспорта" библиотеки Ntdll.dll
Выше упоминалось, что Ntdll.dll уже висит в памяти нашего приложения, а значит мы имеем к ней полный доступ. Главное найти её базу в памяти, и здесь препятствует нам рандоммно меняющий эту базу системный механизм ASLR (Address Space Layout Randomization). Поэтому проще воспользоваться функцией GetModuleHandle(). Получив долгожданную базу, сразу-же наткнёмся на стандартную для файлов exe/sys/dll сигнатуру "MZ", и сместившись от неё на
Взяв этот RVA-указатель и прибавив к нему базу, получим виртуальный адрес таблицы-экспорта, которую описывает такая структура:
Первые три поля нам не интересны, а в четвёртом хранится указатель на строку с именем текущей библиотеки – в нашем случае это будет Ntdll.dll. В поле "OrdinalBase" зашито значение, с которого начинаются номера ординалов – в большинстве случаях это 1, но не факт. А вот дальше уже интересней.. Если комбинацией [Ctrl+Q] в "Total Commander" просмотреть файл Ntdll.dll, то можно найти в нём таблицу-экспорта с перечислением всех функций с их ординалами и точками-входа.
Как видим на рис.ниже, библиотека экспортирует 8 безликих функции, поэтому в "таблице-экспорта" имеется два счётчика с префиксами "Num". Я буду перебирать только именованные из них, так-что выбираю счётчик "NumOfNamePointers". Более того, либа выдаёт на экспорт не только нужные нам Nt-функции, но и огромное кол-во других, которые не зарегистрированны в таблице SSDT (не имеют SSN) – например с префиксами Rtl, Ldr, Dbg и т.д. Значит при поиске имён, нужен будет фильтр по маске (Nt).
Дальше, в таблице-экспорта идут 3 сырых RVA-указателя на таблицы: Address, Name и Ordinal. Важно понимать, что в этих таблицах так-же лежат указатели, а не готовые имена/адреса/ординалы. В идеале, чтобы обойти всю таблицу-экспорта, мы должны в цикле передвигать всю/эту тройку, тогда получим такую-же распечатку, как в окне тотала выше.
Но в демо-примере ниже я ограничусь лишь указателем на NamePointersRVA, а сами точки-входа буду запрашивать через GetProcAddress(). Это позволит сконцентрировать внимание только на полезной нагрузке кода. Строки с именами функций там нуль-терминальные, поэтому при выводе на консоль и передачи их в GetProcAddress() проблем не возникает.
Для перемещения внутри РЕ-заголовка имеет смысл создать инклуд, от чего я вас избавил прицепив его в скрепке. Этот инклуд только для 32-битных РЕ-файлов, хотя с небольшими поправками можно заточить его и под 64-бит:
•• Практика ••
1. Программа поиска SSN нативных функций
В первой демке приводится код, который соберёт все номера-сервисов SSN с указанием их функций, из уже загруженной в память библиотеки Ntdll.dll. Эти номера будут в точности совпадать с теми, что лежат в недоступной нам таблице SSDT. Данный код вернёт валидные данные только если будет запущен на любой 32-битной платформе, поскольку на x64 обработчики Nt-функций оформлены иначе и сигнатура
По этой причине, нужно будет при старте программы определить платформу 32 или 64 – проблему решает функция IsWow64Process(). Она возвращает 1, если 32-битный код запущен на 64-битной системе под оболочкой WOW64 (Windows on Windows), или нуль в противном случае. То-есть нуль будет означать, что система х32. Остальные моменты закомментированы в коде, поэтому повторяться не буду. Вот пример:
Если вернуться к рисунку(2) то там упоминалось, что на данной системе Win7-SP2 в таблице SSDT зарегистрированы 191h=401 функций. Как показывает скрин, программа определила их все, значит алгоритм рабочий и нам остаётся лишь выбирать из этого списка нужные SSN для своей задачи. В качестве маски поиска, вместо "Nt" можно указать "Zw" и результат будет точно такой-же, т.к. точки-входа у этих функций совпадают.
2. Поиск SSN по фильтру, и вызов их посредством SYSENTER
Код следующей программы продемонстрирует, как из всей/этой портянки найти только SSN нужных нам API. В качестве примера, при помощи натива я создам файл на диске, запишу в него данные, после чего закрою дескриптор. Значит будут нужны номера сисколов: NtCreateFile(), NtWriteFile() и NtClose() – остальные отправляем в игнор.
Чтобы не сравнивать строки целиком (они обнаружат себя в секции-данных), имеет смысл вычислить их 16-битную контрольную сумму. То-есть просто складываем все коды ASCII-символов строки, и получаем 2-байтное число. Теперь взломщику придётся осуществлять только брутфорс, чтобы понять, какую именно из API-функций мы используем в своём коде.
В списке ниже перечисляются основные моменты, на которые следует обратить внимание:
1. В ядре нет привычных нам ANSI-строк – все строки только UNICODE. Поэтому всё-что планируется передать Native-функциям в аргументах, нужно переводить в юникод. Для этого, к каждому символу тупо добавляем нуль, в результате чего строка становится в 2-раза длиннее. В данном случае, это касается имени создаваемого файла в аргументе NtCreateFile(). Причём этой функции нужно указывать только полный путь, в формате: \??\C:\temp\example.txt
2. Буфер с именем файла обязательно должен быть выровнен на 4-байтную границу, иначе функция создания файла будет возвращать ошибку "Неверный аргумент"
3. Прототип нативных функций сильно отличается от юзермодных, так-что не забываем про MSDN. Как видно из табл.ниже, Nt-вариант раскрывает всю мощь, в то время как прикладной аналог прост в использовании. В частности натив возвращает хэндл и статус-выполнения в перемененные, а Win-API всегда в регистр EAX:
4. Оформив в стеке подобающим образом все аргументы, нужно будет загнать в него и адрес-возврата, чтобы вдохнуть жизнь в код после инструкции SYSENTER. Так вот, этот адрес-возврата требуется помещать 2-раза, а не один. Я так и не понял, почему именно два, поскольку логическая ветвь уходит в ядро (просто примем это как должное). Взяв SYSENTER в свои руки, мы берём и всю ответственность за него, ..в том числе и очистку стека от аргументов, включая лишний адрес-возврата. Например если у NtCreateFile() 11-аргументов, то выталкивать из стека нужно 12.
Здесь перечислены лишь основные моменты программирования в Native-API, хотя в полной красе проблемы раскрываются только на практике. Чем созирцать это всё со-стороны, приведём наглядный пример, который из таблицы-экспорта Ntdll.dll вытащит номера SSN только нужных нам функций, снимая хэш с их имён и сравнивая его с заранее заготовленным. Код работоспособен на любой 32-битной системе Windows, поскольку вычисляет SSN динамически. Его можно воспринимать как скелет "дроппера" – программы, которая скрытно создаёт на машине жертвы какой-нибудь файл:
Как видим, простая задача по записи в файл разбухла до размеров слона, хотя её можно было-бы сжать в пару-тройку строк. Но на это и делается ставка, чтобы сбить с толку армию пионеров-взломщиков. Сейчас не просто разобраться, что именно делает этот код, ведь API-функций как-таковых нет. Если и поставить в отладчике точку-останова на обнажённую функцию GetProcAddress(), то это даст ~400 ложных срабатываний, по числу экспортируемых либой функций. Но несомненный плюс в том, что номера SSN вычисляются динамически и не зависят от конкретной платформы – лишь-бы она была 32-битная.
Заключение
Всё, о чём шла речь выше, применительно только к системам х32, хотя с незначительными правками может быть модернизирована и под х64. Во-первых, нужно будет изменить некоторые поля в РЕ-инклуде (см.выше). Во-вторых, точки-входа в нативные функции 64-битных библиотек Ntdll.dll имеют другой вид, а потому придётся в качестве сигнатуры SSN использовать уже не первый байт со-значением
Как-видим отличия не значительны, и SSN пересылается в регистр EAX не сразу, а ему предшествует инструкция
Оглавление:
1. Инструкция SYSENTER – общие сведения.
2. SSDT – System Service Descriptor Table.
3. Разбор таблицы-экспорта библиотеки Ntdll.dll.
4. Программа поиска нативных сервисов.
5. Вызов функций посредством SYSENTER.
6. Заключение.
-----------------------------------------------------------Общие сведения
Жизненный цикл прикладных задач проходит на уровне подсистемы Win32, в состав которой входят библиотеки user32.dll, shell32.dll, kernel32.dll и другие с суффиксами(32). Однако имеются и глобальные вопросы, которые должна регистрировать в своих недрах ОС и вести над ними учёт. В таких случаях, коду нашего приложения необходимо перейти внутрь защищённого периметра, т.е. в старшую половину адресного пространства, выше
0x7FFFFFFF
. Здесь на помощь приходят Native-функции с префиксами Nt и Zw, из ядерной библиотеки Ntdll.dll.Исторически, для перехода из юзера в кернел был предусмотрен шлюз, который обслуживало прерывание INT-2Eh. Как это принято, обработчик любого прерывания находится в памяти ОЗУ, поэтому переход посредством шлюза работал очень медленно – для доступа к памяти требовалось ждать освобождения её шины, да и скорость самого обмена оставляет желать лучшего (см.тайминги памяти). В результате, переход в ядро превращался в рутину и срочно требовалось решение этой проблемы.
Позже, начиная с процессоров Pentium-II и системы Win-XP, для передачи управления в кернел была введена специальная инструкция процессора SYSENTER (опкод 0F34h). Она использует уже не память, а три MSR-регистра с номерами
174,175,176h
. Поскольку обмен с регистрами происходит намного быстрее, нововведение позволило на порядок (~40%) повысить скорость выполнения задачи. Обратно юзеру, возвращает управление сопутствующая ей инструкция SYSEXIT.Рассмотрим случай, когда наша программа планирует создать файл или открыть какое-нибудь устройство, типа жёсткий диск. Для этого предусмотрена функция CreateFile() из библиотеки kernel32.dll, которую (в зависимости от параметра dwCreationDistribution), можно использовать как для создания, так и для открытия существующего файла. Обработчик этой функции не создаёт файл, а лишь производит абсолютно не нужные нам различные проверки, после чего kernel32 передаёт управление в нативную функцию NtCreateFile() библиотеки ntdll.dll.
В свою очередь, эта функция так-же не оправдывает надежд и только делает вид, что занимается созданием долгожданного файла, а по факту – кладёт в регистр
EAX
номер-сервиса создания файла (в Win7 это код 42h), и посредством инструкции SYSENTER вручает его исполнительному ядру Ntoskrnl.exe. В технической литературе, номер системного сервиса назвали SSN или "System Service Number". Для каждой из задач типа создание файла/процесса/потока и прочее, в системе зарегистрирован свой сервис, а информация о них собрана в ядерной таблице SSDT – "System Service Descriptor Table". Рисунок ниже представляет сказанное в визуальной форме, где красной стрелкой выделен наш план:Здесь видно, что вызов нами функции CreateFile() транзитом проходит через kernel32.dll и Ntdll.dll, после чего уходит в нёдра Ntoskrnl.exe. Прибывший верхом на инструкции SYSENTER код-сервиса в регистре EAX указывает ядру, какой именно сервис мы запрашиваем, и по этому коду ядро берёт из своей таблицы SSDT адрес обработчика этого сервиса и кол-во его аргументов. На выходе из обработчика, через инструкцию SYSEXIT в том-же регистре EAX система возвращает нам дескриптор Handle созданного файла, который в последующем мы можем использовать для обращения к нему.
Суть данной статьи в том, что если мы сможем пропарсить таблицу SSDT и вытащить из неё нужные нам номера системных сервисов SSN, это даст возможность вызывать нативные API-функции напрямую через SYSENTER, вообще не обращаясь к поднадзорным системой библиотекам kernel32 и Ntdll.dll. Основная проблема здесь в том, что SSDT находится в пространстве ядра и у нашего/смертного приложения нет прямого доступа к ней. Во-вторых – номера сервисов сильно отличаются от версии к версии Win, поэтому придётся искать их динамически в зависимости от того, в какую среду забросит судьба наш шелл-код.
Системные вызовы, для которых требуется переход в ядро можно условно разделить на 4 основных категорий. Во-всех этих случаях, ОС создаёт структуры (или делает отметки в уже существующих), чтобы в последующем пристально держать их под своим колпаком:
1. Контроль над процессом
• создать, загрузить, выполнить, прекратить процесс;
• получить/установить атрибуты процесса;
• выделить или освободить память;
• ждать события, подать сигнал о событии.
2. Управление файлами и устройствами
• создать/открыть, читать/писать, закрыть/удалить файл или устройство;
• получить/установить их атрибуты.
3. Общение
• создать/удалить коммуникационное соединение;
• отправить/получить сообщения;
• информация о статусе передачи;
• подключить или отключить удалённые устройства.
4. Информационное обслуживание
• получить/установить системные данные;
• получить/установить время или дату.
SSDT – System Service Descriptor Table
Системные сервисы работают под управлением их диспетчера, в прямом подчинении которого имеются две таблицы SSDT – затенённая (shadow) и открытая. Нас будет интересовать только открытая таблица, а она в свою очередь включает в себя две таблицы: KiServiceTable и KiArgumentTable. В первой, по именам отсортированы все имеющиеся в системе нативные API-функций, с указанием точек-входа в них. Вторая таблица жёстко привязана к первой, и хранит кол-во аргументов к каждой из перечисленных в первой таблице функций. Индекс (порядковый номер) каждой записи в этих таблицах и назвали SSN – именно он передаётся инструкции SYSENTER в регистре EAX. Вот-что думает на этот счёт отладчик WinDbg:
Таким образом, никакого SSN в реале не существует – это лишь индекс в таблице KiServiceTable начиная с нуля. Например, если инструкции SYSENTER передать номер-сервиса
EAX=1
, это приведёт к вызову функции NtAccessCheck(); значение в регистре EAX=8
дёрнет функцию NtAddAtom() и т.д. Если в окне отладчика WinDbg прокрутить список дальше, то под порядковым номером 42h
можно обнаружить подопытную нашу функцию NtCreateFile(). В таблице SSDT имеется поле с общим числом зарегистрированных в системе Nt-функций – в данном случае этот счётчик равен 191h=401
(см.третью сверху запись на рис.выше, система Win7 sp2).Повторюсь, что номера системных сервисов отличаются не только в разных версиях Win-XP/Vista/7/8/10, но и внутри их сборок (build) и сервис-паков SP. Связано это с тем, что мелкософт без уведомления может запросто добавить или удалить какие-нибудь функции из этого списка (они это часто практикуют), в результате чего остальные записи дружненько съезжают с насиженных мест. Как говорится, проблемы индейцев шерифа не волнуют.
Внимание заслуживает и расположение этих таблиц в памяти – старшая половина адресного пространства системы, т.е. больше
0х80000000
. Поскольку эта область не доступна нашему приложению выходит, что из своей программы мы не сможем просканировать данную таблицу, в надежде получить номера SSN всех функций. Если придерживаться общих правил, то дорога туда нам явно закрыта. Однако правила для того и существуют, чтобы их нарушать.Закрылось ядро в себе – ну и ладно. Зато его хвост остался снаружи в виде библиотеки Ntdll.dll, которая проецируется системой в память любого пользовательского приложения (см.карту памяти в отладчике). Код именно этой библиотеки загружает в регистр EAX номер-сервиса, и посредством инструкции SYSENTER передаёт его ядру. Вот-она лазейка, за которую можно ухватиться и вытянуть все SSN-номера! Достаточно пропарсить экспорт этой библиотеки, по-ходу проверяя точки-входа во все функции, на загрузку некоторого значения (аля ssn) в регистр EAX.
На рисунке ниже представлен дизассемблерный листинг обработчиков где видно, что инструкция
MOV EAX,xxx
всегда будет иметь 1-байтный опкод В8h
и дальше некоторое значение (выделены зелёным), которое есть ничто-иное, как номер сервиса SSN. На 32-битных системах, любой обработчик Nt-функции начинается с этой инструкции, а значит байт со-значением В8h
у точки-входа, может послужить нам сигнатурой поиска:Функции с префиксами "Nt" и "Zw" имеют одинаковые точки-входа, хотя разные ординалы (порядковые номера) внутри библиотеки. Поэтому их сервисные номера SSN так-же будут одинаковы, что собственно и демонстрирует скрин выше (Zw' введена для драйверов). Всё-что нужно будет сделать нашему коду, это в цикле обойти всю таблицу-экспорта Ntdll.dll и прочитав из точек первые 4-байта, проверить младший из них на опкод
В8h
. Если совпадёт, значит это инструкция MOV EAX,ххх
и следующие 2-байта будут SSN найденной функции. Всю цепочку вызовов Native-API более детально раскрывает отладчик:Смотрим, что делает обработчик функции из Ntdll.dll, получив управление от kernel32.dll..
Сразу-же в регистр EAX пересылается номер запрашиваемого сервиса SSN (в данном случае 42h), после чего в регистр EDX заносится адрес стаба KiFastSystemCall с последующим его вызовов инструкцией
CALL dword[EDX]
. В результате, с загруженным регистром EAX мы попадаем в стаб SYSENTER, внутри которого регистр EDX перезаписывается новым значением, указывающим на предварительно оформленный нами стек, с аргументами вызываемой функции.Последующий алгоритм нас уже мало интересует, поскольку без драйвера мы не сможем в него вмешаться. Тут на аппаратном уровне идёт переключение со-стека пользователя на стек ядра (его адрес хранится в сегменте TSS текущей задачи) и со-всем юзерским барахлом управление получает ядро Ntoskrnl.exe. Другими словами, мы можем модифицировать только то, что находится в нижней половине адресного пространства памяти, до
0х7FFFF000
.Разбор "таблицы-экспорта" библиотеки Ntdll.dll
Выше упоминалось, что Ntdll.dll уже висит в памяти нашего приложения, а значит мы имеем к ней полный доступ. Главное найти её базу в памяти, и здесь препятствует нам рандоммно меняющий эту базу системный механизм ASLR (Address Space Layout Randomization). Поэтому проще воспользоваться функцией GetModuleHandle(). Получив долгожданную базу, сразу-же наткнёмся на стандартную для файлов exe/sys/dll сигнатуру "MZ", и сместившись от неё на
0х3С
получим указатель на РЕ-заголовок библиотеки. Дальше, не заморачиваясь с мелочами, прыгаем по смещению 0х78
от РЕ-заголовка на DATA-DIRECTORY, и взяв от туда первый-же указатель, становимся обладателем адреса таблицы-экспорта:Взяв этот RVA-указатель и прибавив к нему базу, получим виртуальный адрес таблицы-экспорта, которую описывает такая структура:
C-подобный:
struct EXPORT_TABLE
Flags dd 0 ;//
TimeStamp dd 0 ;//
Version dd 0 ;//
NameRVA dd 0 ;// имя образа (exe/dll)
OrdinalBase dd 0 ;//
NumOfFunction dd 0 ;// всего функций-экспорта
NumOfNamePointers dd 0 ;// всего указателей на имена функций
AddressTableRVA dd 0 ;// указатель на таблицу адресов
NamePointersRVA dd 0 ;// указатель на таблицу имён
OrdinalTableRVA dd 0 ;// указатель на таблицу ординалов
ends
Первые три поля нам не интересны, а в четвёртом хранится указатель на строку с именем текущей библиотеки – в нашем случае это будет Ntdll.dll. В поле "OrdinalBase" зашито значение, с которого начинаются номера ординалов – в большинстве случаях это 1, но не факт. А вот дальше уже интересней.. Если комбинацией [Ctrl+Q] в "Total Commander" просмотреть файл Ntdll.dll, то можно найти в нём таблицу-экспорта с перечислением всех функций с их ординалами и точками-входа.
Как видим на рис.ниже, библиотека экспортирует 8 безликих функции, поэтому в "таблице-экспорта" имеется два счётчика с префиксами "Num". Я буду перебирать только именованные из них, так-что выбираю счётчик "NumOfNamePointers". Более того, либа выдаёт на экспорт не только нужные нам Nt-функции, но и огромное кол-во других, которые не зарегистрированны в таблице SSDT (не имеют SSN) – например с префиксами Rtl, Ldr, Dbg и т.д. Значит при поиске имён, нужен будет фильтр по маске (Nt).
Дальше, в таблице-экспорта идут 3 сырых RVA-указателя на таблицы: Address, Name и Ordinal. Важно понимать, что в этих таблицах так-же лежат указатели, а не готовые имена/адреса/ординалы. В идеале, чтобы обойти всю таблицу-экспорта, мы должны в цикле передвигать всю/эту тройку, тогда получим такую-же распечатку, как в окне тотала выше.
Но в демо-примере ниже я ограничусь лишь указателем на NamePointersRVA, а сами точки-входа буду запрашивать через GetProcAddress(). Это позволит сконцентрировать внимание только на полезной нагрузке кода. Строки с именами функций там нуль-терминальные, поэтому при выводе на консоль и передачи их в GetProcAddress() проблем не возникает.
Для перемещения внутри РЕ-заголовка имеет смысл создать инклуд, от чего я вас избавил прицепив его в скрепке. Этот инклуд только для 32-битных РЕ-файлов, хотя с небольшими поправками можно заточить его и под 64-бит:
C-подобный:
struct PE_HEADER
Signature dd 0 ;// PE = 50 45 00 00
Machine dw 0
NumberOfSection dw 0
TimeStamp dd 0
SymbolPointer dd 0
SymbolSize dd 0
OptionalHdrSize dw 0
Flags dw 0
;//--- OPTIONAL HEADER -------------------
Magic dw 0
LinkerVersion dw 0
SizeOfCode dd 0
SizeOfInitData dd 0
SizeOfUnInitData dd 0
EntryPointRVA dd 0
CodeBaseRVA dd 0
DataBaseRVA dd 0
ImageBase dd 0
SectionAlign dd 0
FileAlign dd 0
OsVersion dd 0
ImageVersion dd 0
SubSysVersion dd 0
Reserved dd 0
ImageSize dd 0
HeaderSize dd 0
FileChecksum dd 0
SubSystem dw 0
DLLflags dw 0
StackReserve dd 0
StackCommit dd 0
HeapReserve dd 0
HeapCommit dd 0
LoaderFlag dd 0
NumberDataDir dd 0
;//--- DATA DIRECTORY --------------------
ExportRVA dd 0,0
ImportRVA dd 0,0
ResourceRVA dd 0,0
ExceptionRVA dd 0,0
SecurityRVA dd 0,0
FixUpRVA dd 0,0
DebugRVA dd 0,0
ArchitectureRVA dd 0,0
GlobalRVA dd 0,0
TlsRVA dd 0,0
LoadConfigRVA dd 0,0
BoundImportRVA dd 0,0
IatRVA dd 0,0
DelayImportRVA dd 0,0
COMdescriptor dd 0,0
Reserved1 dd 0,0
ends
struct SECTION_TABLE
ObjectName rb 8 ;// имя секции
VirtualSize dd 0 ;// размер в памяти
VirtualOffsetRVA dd 0 ;// адрес в памяти
PhysicalOffsetRVA dd 0 ;// адрес на диске
Reserved rb 12 ;//
SectionFlags dd 0 ;// флаги секции
ends
;//**************************************************
struct EXPORT_TABLE
Flags dd 0 ;//
TimeStamp dd 0 ;//
Version dd 0 ;//
NameRVA dd 0 ;// имя образа (exe/dll)
OrdinalBase dd 0 ;//
NumOfFunction dd 0 ;// всего функций
NumOfNamePointers dd 0 ;// всего указателей на имена функций
AddressTableRVA dd 0 ;// указатель на таблицу адресов
NamePointersRVA dd 0 ;// указатель на таблицу имён
OrdinalTableRVA dd 0 ;// указатель на таблицу ординалов
ends
struct IMPORT_TABLE
ImportLookUp dd 0
TimeStamp dd 0
FotwardChain dd 0
NameRVA dd 0
AddressRVA dd 0
ends
struct TLS_TABLE
StartBlockVA dd 0
EndBlockVA dd 0
IndexVA dd 0
CallBackTableVA dd 0
ends
struct RESOURCE_TABLE
Flags dd 0
TimeStamp dd 0
Version dd 0
NameCount dw 0
IdCount dw 0
ends
struct FIXUP_TABLE
PageRVA dd 0
BlockSize dd 0
RecordOffset dw 0
ends
•• Практика ••
1. Программа поиска SSN нативных функций
В первой демке приводится код, который соберёт все номера-сервисов SSN с указанием их функций, из уже загруженной в память библиотеки Ntdll.dll. Эти номера будут в точности совпадать с теми, что лежат в недоступной нам таблице SSDT. Данный код вернёт валидные данные только если будет запущен на любой 32-битной платформе, поскольку на x64 обработчики Nt-функций оформлены иначе и сигнатура
В8h
уже не сработает.По этой причине, нужно будет при старте программы определить платформу 32 или 64 – проблему решает функция IsWow64Process(). Она возвращает 1, если 32-битный код запущен на 64-битной системе под оболочкой WOW64 (Windows on Windows), или нуль в противном случае. То-есть нуль будет означать, что система х32. Остальные моменты закомментированы в коде, поэтому повторяться не буду. Вот пример:
C-подобный:
format pe console
include 'win32ax.inc'
include 'equates\pestruct.inc' ;// подключаем РЕ-инклуд
entry start
;//----------
.data
counter dd 0 ;// под счётчик найденных функций
pe dd 0 ;// под указатель на РЕ-заголовок
base dd 0 ;// под базу образа DLL
fnName dd 0 ;// под указатель на имя функций
buff db 0 ;// под всякое барахло..
;//----------
.code
start:
;// Обзовём окно и проверим на 64-бит систему ======================
invoke SetConsoleTitle,<' .:: Parse SSDT ::.',0>
invoke IsWow64Process,-1,buff
cmp [buff],1
jne @ok
cinvoke printf,<10,' ERROR! 64-bit system is not supported.',0>
jmp @exit
;// Получаем базу Ntdll.dll в памяти ===============================
@ok: invoke GetModuleHandle,<'ntdll.dll',0>
mov [base],eax
;// Собираем инфу из РЕ-заголовка библиотеки =======================
xchg esi,eax ;// ESI = база
mov esi,[esi+0x3c] ;// ESI = RVA-указатель на РЕ-заголовок
add esi,[base] ;// ..(преобразовать в VA-адрес)
mov [pe],esi ;// запомнить адрес РЕ-заголовка
mov eax,[esi+PE_HEADER.ExportRVA] ;// РЕ + 78h
add eax,[base] ;// EAX = указатель на таблицу-экспорта
mov ebx,[eax+EXPORT_TABLE.NameRVA]
add ebx,[base] ;// EВX = указатель на имя DLL-библиотеки
cinvoke printf,<10,' Module name....: %s',\
10,' Base address...: 0x%X',\
10,' Export table...: 0x%X',10,0>,ebx,[base],eax
;// Ищем функции и их SSN-номера в таблице-экспорта ===============
cinvoke printf,<10,' Find SYSENTER EAX-code...',10,\
10,' EAX Native function',\
10,' ----------------------------',0>
mov esi,[pe]
mov ebx,[esi+PE_HEADER.ExportRVA]
add ebx,[base] ;// EBX = указатель на таблицу-экспорта
mov esi,[ebx+EXPORT_TABLE.NamePointersRVA]
add esi,[base] ;// ESI = адрес таблицы-указателей имён
mov ecx,[ebx+EXPORT_TABLE.NumOfNamePointers] ;// ECX = всего именованных функций в DLL
@@: lodsd ;// EAX = RVA-указатель на очередное имя функции из ESI
add eax,[base] ;// ..делаем из него виртуальный адрес.
cmp word[eax],'Nt' ;// проверить имя на маску "Nt"
jne @fuck ;// если нет..
mov [fnName],eax ;// иначе: запомнить указатель для вывода на консоль
push esi ecx ;//
invoke GetProcAddress,[base],eax ;// получить точку-входа в функцию
pop ecx esi ;//
mov ebx,[eax] ;// EBX = первые(4) опкода обработчика функции
cmp bl,0xb8 ;// мл.байт = B8h, значит это [mov eax,] – наш клиент!
jne @fuck ;// если нет..
shr ebx,8 ;// иначе: сдвинуть на байт вправо (удалить опкод B8h)
;// EBX = номер SSN найденной функции!
push esi ecx
cinvoke printf,<10,' %03X %s',0>,ebx,[fnName] ;// вывести номер и имя функции
pop ecx esi
inc [counter] ;// увеличить счётчик найденных
@fuck: loop @b ;// повторить цикл ЕСХ-раз..
cinvoke printf,<10,' -------------------------------',\
10,' Found services: %d',0>,[counter]
@exit: cinvoke gets,buff
cinvoke exit,0
;//----------
section '.idata' import data readable
library msvcrt,'msvcrt.dll',kernel32,'kernel32.dll'
import msvcrt, printf,'printf',gets,'gets',exit,'exit'
include 'api\kernel32.inc'
Если вернуться к рисунку(2) то там упоминалось, что на данной системе Win7-SP2 в таблице SSDT зарегистрированы 191h=401 функций. Как показывает скрин, программа определила их все, значит алгоритм рабочий и нам остаётся лишь выбирать из этого списка нужные SSN для своей задачи. В качестве маски поиска, вместо "Nt" можно указать "Zw" и результат будет точно такой-же, т.к. точки-входа у этих функций совпадают.
2. Поиск SSN по фильтру, и вызов их посредством SYSENTER
Код следующей программы продемонстрирует, как из всей/этой портянки найти только SSN нужных нам API. В качестве примера, при помощи натива я создам файл на диске, запишу в него данные, после чего закрою дескриптор. Значит будут нужны номера сисколов: NtCreateFile(), NtWriteFile() и NtClose() – остальные отправляем в игнор.
Чтобы не сравнивать строки целиком (они обнаружат себя в секции-данных), имеет смысл вычислить их 16-битную контрольную сумму. То-есть просто складываем все коды ASCII-символов строки, и получаем 2-байтное число. Теперь взломщику придётся осуществлять только брутфорс, чтобы понять, какую именно из API-функций мы используем в своём коде.
В списке ниже перечисляются основные моменты, на которые следует обратить внимание:
1. В ядре нет привычных нам ANSI-строк – все строки только UNICODE. Поэтому всё-что планируется передать Native-функциям в аргументах, нужно переводить в юникод. Для этого, к каждому символу тупо добавляем нуль, в результате чего строка становится в 2-раза длиннее. В данном случае, это касается имени создаваемого файла в аргументе NtCreateFile(). Причём этой функции нужно указывать только полный путь, в формате: \??\C:\temp\example.txt
2. Буфер с именем файла обязательно должен быть выровнен на 4-байтную границу, иначе функция создания файла будет возвращать ошибку "Неверный аргумент"
0xC000000D
. Более того, требуется указать длину Unicode-строки с именем файла, и что особенно важно – без терминального нуля! В своё время мне пришлось долго разбираться в этих мелочах, зато теперь этот опыт "записался в кэш".3. Прототип нативных функций сильно отличается от юзермодных, так-что не забываем про MSDN. Как видно из табл.ниже, Nt-вариант раскрывает всю мощь, в то время как прикладной аналог прост в использовании. В частности натив возвращает хэндл и статус-выполнения в перемененные, а Win-API всегда в регистр EAX:
4. Оформив в стеке подобающим образом все аргументы, нужно будет загнать в него и адрес-возврата, чтобы вдохнуть жизнь в код после инструкции SYSENTER. Так вот, этот адрес-возврата требуется помещать 2-раза, а не один. Я так и не понял, почему именно два, поскольку логическая ветвь уходит в ядро (просто примем это как должное). Взяв SYSENTER в свои руки, мы берём и всю ответственность за него, ..в том числе и очистку стека от аргументов, включая лишний адрес-возврата. Например если у NtCreateFile() 11-аргументов, то выталкивать из стека нужно 12.
Здесь перечислены лишь основные моменты программирования в Native-API, хотя в полной красе проблемы раскрываются только на практике. Чем созирцать это всё со-стороны, приведём наглядный пример, который из таблицы-экспорта Ntdll.dll вытащит номера SSN только нужных нам функций, снимая хэш с их имён и сравнивая его с заранее заготовленным. Код работоспособен на любой 32-битной системе Windows, поскольку вычисляет SSN динамически. Его можно воспринимать как скелет "дроппера" – программы, которая скрытно создаёт на машине жертвы какой-нибудь файл:
C-подобный:
format pe console
include 'win32ax.inc'
include 'equates\pestruct.inc'
entry start
;//----------
.data
struct OBJECT_ATTRIBUTES ;// структура, для передачи атрибутов в NtCreateFile()
Length dd sizeof.OBJECT_ATTRIBUTES
RootDirectory dd 0
ObjectName dd 0
Attributes dd 40h
SecurityDescriptor dd 0
SecurityQualityOfService dd 0
ends
struct UNICODE_STRING ;// структура описывает UNICODE-строку
Length dw 0
MaxLength dw 255
Buffer dd 0
ends
oa OBJECT_ATTRIBUTES
us UNICODE_STRING
iosb dd 0,0,0 ;// Io_Status_Block. Сюда возвращается статус операций
create dd 'N'+'t'+'C'+'r'+'e'+'a'+'t'+'e'+'F'+'i'+'l'+'e' ;// контрольная сумма имени fn.
write dd 'N'+'t'+'W'+'r'+'i'+'t'+'e'+'F'+'i'+'l'+'e'
close dd 'N'+'t'+'C'+'l'+'o'+'s'+'e'
NtCreate dd 0 ;// под SSN-номера сисколов
NtWrite dd 0
NtClose dd 0
Status db 10,' Return code: %08Xh',0
base dd 0
fHndl dd 0
fName db '\??\D:\Sysenter.txt',0 ;// имя создаваемого файла
fLen = $ - fName ;// его длина
align 4 ;// выравнивание на 4-байт границу
buff db 0 ;// здесь будет UNICODE-строка
;//----------
.code
start:
invoke SetConsoleTitle,<' .:: SYSENTER Example ::.',0>
invoke IsWow64Process,-1,buff
cmp [buff],1
jne @ok
cinvoke printf,<10,' ERROR! 64-bit system not support.',0>
jmp @exit
@ok: invoke GetModuleHandle,<'ntdll.dll',0>
mov [base],eax ;// база библиотеки,
xchg esi,eax ;// ...в регистр ESI
mov esi,[esi+0x3c] ;//
add esi,[base] ;// ESI = РЕ-заголовок
mov ebx,[esi+PE_HEADER.ExportRVA]
add ebx,[base]
mov esi,[ebx+EXPORT_TABLE.NamePointersRVA]
add esi,[base]
mov ecx,[ebx+EXPORT_TABLE.NumOfNamePointers]
;//==== ESI = указатель на таблицу имён, ECX = их счётчик. ====================
;//==== Начинаем поиск нужных функций в таблице-экспорта Ntdll.dll ============
@findFunctionSSN:
lodsd ;//
add eax,[base] ;// указатель на очередное имя
cmp word[eax],'Nt' ;// сравнить с маской
jne @fuck ;// прокол..
push eax esi ecx ;//
invoke GetProcAddress,[base],eax ;// иначе: EAX = точка-входа
pop ecx esi ebx ;// EBX = указатель на очередное имя
mov edx,[eax] ;// EDX = первый DWORD из точки-входа
cmp dl,0xb8 ;// проверить на сигнатуру SSN
jne @fuck ;// прокол..
shr edx,8 ;// иначе: EDX = SSN функции
push esi
xchg esi,ebx ;// ESI = указатель на имя
xor ebx,ebx ;// очистить регистры
xor eax,eax ;// ^^^^^
@hash: lodsb ;// считаем контрольную сумму очередного имени,
add ebx,eax ;// ..в регистр EBX,
or al,al ;// ..пока не встретим нуль.
jnz @hash ;//
pop esi
cmp ebx,[create] ;// сравить полученную контр.сумму с валидной, из секции-данных
jne @f ;// вниз, если промах..
mov [NtCreate],edx ;// иначе: запомнить в переменной её SSN.
@@: cmp ebx,[write] ;//
jne @f ;//
mov [NtWrite],edx ;//
@@: cmp ebx,[close] ;//
jne @fuck ;//
mov [NtClose],edx ;//
@fuck: loop @findFunctionSSN ;// продолжить поиск имён, по длинне ECX..
nop
;//==== Нашли SSN требуемых функций! ======================================
;//==== Теперь воспользуемся ими для создания файла через SYSENTER ========
;//========================================================================
mov esi,fName ;// ESI = адрсе строки с именем файла
mov edi,buff ;// EDI = адрес приёмного буфера
mov ecx,fLen ;// ECX = длина строки
xor eax,eax ;//
@unicode: ;// конвертируем в UNICODE..
lodsb ;// берём очередной байт из ESI
stosw ;// записываем его как слово в EDI
loop @unicode ;// повторить ECX-раз..
mov ecx,fLen ;// ECX = длина строки
dec ecx ;// Внимание! ..без терминального нуля!
shl ecx,1 ;// умножить длину на 2
mov [us.Length],cx ;// отправляем длину в структуру UNICODE_STRING
mov [us.Buffer],buff ;// туда-же адрес буфера со-строкой
mov [oa.ObjectName],us ;// определяем struct.Unicode как имя в OBJECT_ATTRIBUTES
;//==== Теперь всё готово для вызова SYSENTER ==============================
;// https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-ntcreatefile
xor eax,eax
push eax ;// SecureLength = 0
push eax ;// SecureBuffer = 0
push 0x60 ;// Create Options = Non Dir
push 0x05 ;// Create Disposition = Max_Disposition
push eax ;// Share Access = 0
push 0x80 ;// File attributes = Normal
push eax ;// Allocation size = 0
push iosb ;// IoStatusBlock
push oa ;// ObjectAttributes
push 0xC0100080 ;// Desired Access = R/W
push fHndl ;// Handle
mov eax,@f ;// EAX = адрес-возврата
push eax eax ;// Внимание!!! Отправляем его дважды в стек!
mov eax,[NtCreate] ;// <----------- NtCreateFile
mov edx,esp ;// EDX = указатель на аргументы
sysenter ;// Первый пошёл..
@@: add esp,4*12 ;// вытолкнуть аргументы из стека 11+1
cinvoke printf,Status,eax
;//==== Таким-же макаром записываем в созданный файл ========================
;//==== в данном случае сбрасывается строка с именем файла ==================
xor eax,eax
push eax ;// Key = 0
push eax ;// ByteOffset = 0
push fLen ;// Length
push fName ;// Buffer
push iosb ;// IoStatusBlock
push eax ;// ApcContext = 0
push eax ;// ApcRoutine = 0
push eax ;// Event = 0
push [fHndl] ;// FileHandle
mov eax,@f ;// Retn-offset
push eax eax ;// Retn-address
mov eax,[NtWrite] ;// <----------- NtWriteFile
mov edx,esp ;// Arguments
sysenter
@@: add esp,4*10 ;// Clear Stack 9+1
cinvoke printf,Status,eax
;//==== Прихлопнем дескриптор открытого файла ===============================
push [fHndl] ;// FileHandle
mov eax,@f ;// Retn-offset
push eax eax ;// Retn-address
mov eax,[NtClose] ;// <----------- NtClose
mov edx,esp ;// Arguments
sysenter
@@: add esp,4*2 ;// Clear Stack 1+1
cinvoke printf,Status,eax
@exit: cinvoke gets,buff
cinvoke exit,0
;//----------
section '.idata' import data readable
library msvcrt,'msvcrt.dll',kernel32,'kernel32.dll'
import msvcrt, printf,'printf',gets,'gets',exit,'exit'
include 'api\kernel32.inc'
Как видим, простая задача по записи в файл разбухла до размеров слона, хотя её можно было-бы сжать в пару-тройку строк. Но на это и делается ставка, чтобы сбить с толку армию пионеров-взломщиков. Сейчас не просто разобраться, что именно делает этот код, ведь API-функций как-таковых нет. Если и поставить в отладчике точку-останова на обнажённую функцию GetProcAddress(), то это даст ~400 ложных срабатываний, по числу экспортируемых либой функций. Но несомненный плюс в том, что номера SSN вычисляются динамически и не зависят от конкретной платформы – лишь-бы она была 32-битная.
Заключение
Всё, о чём шла речь выше, применительно только к системам х32, хотя с незначительными правками может быть модернизирована и под х64. Во-первых, нужно будет изменить некоторые поля в РЕ-инклуде (см.выше). Во-вторых, точки-входа в нативные функции 64-битных библиотек Ntdll.dll имеют другой вид, а потому придётся в качестве сигнатуры SSN использовать уже не первый байт со-значением
0хВ8
, а он смещён от начала и занимает позицию(4). То-есть можно так-же читать dword'ами, но проверять не младший, а старший байт - если в яблочко, значит в следующем dword'e будет лежать SSN найденной Nt-функции. Двоичный редактор HIEW в курсе дел..Как-видим отличия не значительны, и SSN пересылается в регистр EAX не сразу, а ему предшествует инструкция
mov r10,rcx
. Таким образом, при желании можно воплотить идею и на х64, но оставим это дело до лучших времён, когда вдоволь напрактикуемся с х32. В скрепке лежат два рассмотренных исполняемых файла, чтобы у желающих была возможность погонять их в отладчике, а так-же лист со-списком моих/нативных функций, система Win-7 SP2. До скорого..