Можно ли провести атаку на ядро и остаться при этом незамеченным? Конечно можно, только при этом нужно быть максимально незаметным.
Философия
LKM (Loadable Kernel Module) attacks – атаки с использованием кода нулевого кольца - тема, которая всегда привлекала хакеров. Возможность работать на одном уровне привилегий с операционной системой давала поистине неограниченные возможности. Однако можно сказать, что большого распространения они так и не получили (становясь популярными только сейчас). И тому есть несколько причин. Во-первых, NT поставляется без исходного кода, а во-вторых, для внедрения кода режима ядра нужны полномочия администратора (точнее, привилегия SeLoadDriverPrivilege).
В последнее время тема внутреннего устройства NT стала освещаться все шире; стремительно развивается технология rootkits (предназначенная для скрытия объектов в системе); также появляются люди, пишущие продвинутые статьи по этому поводу. Это статьи всем известного Марка Руссиновича, который пишет об алгоритмах работы NT. Замечательные «недокументированности» открывает Алекс Ионеску, один из разработчиков ReactOS (кстати, на их сайте wwwreactos.com ты можешь найти функции на C кода NT, так как ReactOS во многом похожа на нее). Существует также сайт wwwinvisiblethings.org известной специалистки в области rootkits Джоанны Рутковской. На нем ты найдешь White Papers, которые посвящены внутреннему устройству NT и rootkits.
Путь
Предположим, что ты достиг цели, которую перед собой поставил. Например, написал промежуточный NDIS-драйвер, который работает с драйвером сетевого адаптера. Но долго ли он будет находиться на машине жертвы? Достаточно запустить утилиту Autoruns или посмотреть раздел реестра HKLM\ SYSTEM\CurrentControlSet\Services на предмет подозрительных драйверов, или драйверов, которые пользователь не ставил (посмотреть загруженные драйверы можно с помощью IceSword) - и твой драйвер сразу же будет обнаружен и ликвидирован.
Здесь можно воспользоваться руткитами или самостоятельно встроить в драйвер стелс-модуль, который не позволит тем или иным средствам обнаруживать присутствие драйвера. Ты можешь использовать, например, руткиты FU или FUTo, которые скрывают драйверы и процессы на уровне ядра. FUTo обладает более продвинутыми техниками скрытия и сделает твой процесс более незаметным. Эти руткиты можно скачать на wwwrootkit.com. Они поставляются с исходным кодом.
Работа диспетчера
Подробно работу диспетчера мы описывать не будем, поскольку это уже сделано (Шрайбер «Недокументированные возможности Windows 2000», глава 2, а также Руссинович, Соломон «Внутреннее устройство Windows», глава 3). Остановимся лишь на самых важных моментах.
Диспетчером системных сервисов называется функция ntoskrnl, которая получает управление в результате срабатывания ловушки с вектором 0x2E или через MSR-регистр IA32_SYSENTER_EIP. Диспетчер системных сервисов в Windows 2000 – KiSystemService (активируется по ловушке), в Windows XP – KiFastCallEntry (активируется по инструкции sysenter). Для совместимости с приложениями Windows 2000, в Windows XP также оставлен диспетчер KiSystemService. Но он выполняет отнюдь не главную роль, и в конечном итоге отдает управление KiFastCallEntry.
Для диспетчеризации ядром используются служебные структуры данных: таблицы дескрипторов сервисов (Service Descriptor Table, SDT), таблицы диспетчеризации системных сервисов (System Service Dispatcher Table, SSDT). Таблиц дескрипторов две: KeServiceDescriptorTable и KeServiceDescriptorTableShadow. Они имеют следующий формат.
typedef struct _SERVICE_DESCRIPTOR_TABLE
{SYSTEM_SERVICE_TABLE ntoskrnl; (дескриптор сервисов ntoskrnl)SYSTEM_SERVICE_TABLE win32k; (дескриптор сервисов win32k.sys)SYSTEM_SERVICE_TABLE Table3;SYSTEM_SERVICE_TABLE Table4;} SERVICE_DESCRIPTOR_TABLE;
Каждая таблица дескрипторов содержит по четыре дескриптора, которые описывают таблицы диспетчеризации. Формат самого дескриптора следующий.
typedef struct _SYSTEM_SERVICE_TABLE
{PNTPROC ServiceTable; PDWORD CounterTable; DWORD ServiceLimit; PBYTE ArgumentTable; } SYSTEM_SERVICE_TABLE,
Элемент структуры ServiceTable указывает на требуемую таблицу диспетчеризации. Для диспетчеризации сервисов ядра (kernel32.dll и advapi32.dll) используется таблица из первого дескриптора (внутреннее имя KiServiceTable). Для диспетчеризации USER- и GDI-интерфейсов используется второй дескриптор, но только в KeServiceDescriptorTableShadow. В структуре ядра для каждого потока (KTHREAD) содержится указатель на таблицу дескрипторов. При рождении потока его указатель изначально указывает на таблицу KeServiceDescriptorTable. Как только поток вызывает USER/GDI-сервис, система переключает его указатель на KeServiceDescriptorTableShadow.
При вызове сервиса в EAX кладется селектор системного сервиса. Селектор разбивается на битовые поля. 12 младших бит – индекс в таблице, следующие два – индекс таблицы в SDT.
Типы атак на ядро
В NT в режиме ядра целесообразно воздействовать на два объекта: само ядро и объекты ядра. При этом воздействие на ядро, скорее всего, повлечет изменение хода выполнения команд того потока, который перешел в режим ядра. Воздействие на объекты ядра не влечет к изменению потока выполнения, но изменяет поведение NT при работе с различными видами объектов. В соответствии с этим атаки делятся на:
модифицирующие путь выполнения (modifying execution path);
модифицирующие объекты ядра (Directly Kernel Object Manipulation, DKOM).
Первые атаки легче обнаружить, поскольку есть масса способов обнаружить изменения пути выполнения. В следующем списке приведены общие типы атак первого и второго типа, а в скобках указаны способы их обнаружения:
модификация SSDT (сканирование смещений в таблице на предмет не принадлежности к ntoskrnl);
модификация IDT (сканирование дескрипторов на валидность принадлежности ядру);
изменений первых байт на jmp (поиск дизассемблером команд перехода и анализ смещений, на которые тот осуществляется);
изменение указателя KTHREAD.pServiceDescriptorTable (сканирование всех потоков в системе на предмет указателя на валидную SDT);
DKOM для списков, например, PsActiveprocessHead, KiDispatcherReadyListHead (по косвенным признакам).
Без сомнения, DKOM-атаки - наиболее перспективный и наименее заметный метод. Но он небезопасен тем, что объекты меняются не только от версии NT к версии, а также от SP к SP. Например, поэтому разработчики FUTo решили в период отработки DriverEntry провести инициализацию переменных, которые содержат смещения в объектах ядра. Для этого тебе может помочь функция RtlQueryRegistryValues, документированная в DDK.
С патчингом ядра нужно быть осторожным в системах с активированной Write-Protected System Code (защита системного кода от записи). Последняя применяется ядром для обнаружения попыток записи в область системного кода (в том числе и для драйверов устройств). Если ты запишешь что-то на страницу с включенной в системе защитой, то будет сгенерирована STOP-ошибка (процессор сгенерирует #GP при доступе на запись, и управление перейдет к ядру). Функционирует не на всех машинах, а только на машинах с физической памятью менее 256 Мб (в Windows 2000 - менее 128 Мб). Реализация технологии строится полностью на аппаратной основе, откуда и выливаются ограничения. Дело в том, что в системах с превышенными лимитами ОЗУ для оптимизации TLB ядро проецируется на большую страницу памяти. Это означает, что на такой странице нельзя отличить код от данных, и, следовательно, страница должна быть доступна для записи. Оптимизация заключается в том, что для четырехмегабайтных страниц процессор использует специфичный для процесса TLB. Write-Protected System Code реализуется с использованием PTE страниц и бита WP (Write-Protected) в CR0. PTE страниц с кодом ntoskrnl помечаются как доступные только для чтения (бит W обнулен), а бит WP установлен. Если обнулить бит WP, то все страницы будут доступны для чтения и записи и исключение генерироваться не будет! Дословно в Intel-документации написано так: «When the processor is in supervisor mode and the WP flag in register CR0 is clear (its state following reset initialization), all pages are both readable and writable (write protection is ignored)». Таким образом, следующие два макроса отключают и включают Write-Protected System Code:
#define DISABLE_WRITE_PROTECTED_SYSTEM_CODE \__asm push eax \__asm mov eax,cr0 \__asm and eax,0xFFFEFFFF \__asm mov cr0,eax \__asm pop eax
#define ENABLE_WRITE_PROTECTED_SYSTEM_CODE \__asm push eax \__asm mov eax,cr0 \__asm or eax,0x10000 \__asm mov cr0,eax \__asm pop eax
Используй этот код при патчинге кода ядра.
Самое интересное, что в Intel-документации по x386 сказано, что бит WP может быть использован для реализации копирования при записи (например, как он реализуется для оптимизации fork в UNIX). Речи о каких-то защитах даже не ведется. Кстати, в Microsoft давно знали про патчинг ядра, поэтому и разработали для Vistа x64, Windows XP х64 PatchGuard (обход которой уже описан).
Также обрати внимание, что при патчинге ядра код функции может находиться в разделе PAGE. При этом функции нельзя править при высоких IRQL (больше APC Level). На уровне IRQL>=Dispatch Level это приведет к генерации I/O, что заставит диспетчер переключить контекст, но перераспределение процессорного времени происходит при уровне IRQL==Dispatch Level, а значит, можно будет маскироваться, пока поток не понизит IRQL. Таким образом, мы получаем коллизию. А повышение IRQL необходимо для синхронизации доступа, потому как другой поток может в момент модификации считывать данные из таблицы. В многопроцессорных машинах для синхронизации нужно повышать IRQL на всех процессорах путем закрепления DPC за конкретным процессором и, дожидаясь, когда IRQL всех процессоров повысится, начинать патчить ядро (подробности в «Rootkits. Subverting Windows Kernel», глава 7). Еще хуже, если при доступе к PAGE-коду ты используешь cli/sti, поскольку это маскирует и аппаратные прерывания (а IRQL в x386 вещь программная).
Руткит также может перехватывать прерывания, при этом тебе поможет знание форматов шлюзов IDT. Обычно используются шлюзы прерывания и ловушки (для ловушки флаг IF не сбрасывается при входе в обработчик). Например, следующий код получает линейный адрес старого обработчика:
typedef struct _IDTINFO
{USHORT IDTLimit;USHORT LowIDTBase;USHORT HiIDTBase; } IDTINFO, *PIDTINFO;
__asm sidt _idtr;//вычислим адрес IDT
IDTAddr=MAKELONG(_idtr.HiIDTBase,_idtr.LowIDTBase);//найдем дескрипторpidt_entry = (PIDTHANDLE)(IDTAddr + 0x2E * 0x8);SSMAddr = MAKELONG(pidt_entry->HiOffset,pidt_entry->LowOffset);
Если необходимо переписать или считать MSR-регистры, используй инструкции wrmsr/rdmsr, которые в ECX принимают код MSR-регистра, а в EAX возвращают его значение. Например, следующий код считывает IA32_SYSENTER_EIP по адресу в переменной AddrForSaveOrRestore:
__asm
{
pusha
pushfmov ecx,0x176
rdmsr mov edi,AddrForSaveOrRestoremov [edi],eax
popf
popa
}
DKOM
Теперь поговорим, собственно, о скрытии и о том, как оно осуществляется. Вспомним, что одна из наших целей - сделать запущенный драйвер (или процесс) невидимым. Драйверы, как и следовало ожидать, объединены в двусвязный список структур MODULE_ENTRY, который адресует DRIVER_OBJECT. В общем, ситуация следующая: каждый DRIVER_OBJECT содержит по смещению 0x14 от начала структуры указатель на структуру MODULE_ENTRY, а последние уже объединяются в двусвязные списки. MODULE_ENTRY также содержит путь к sys-файлу и его имя. Определение таково:
typedef struct _MODULE_ENTRY
{LIST_ENTRY List;DWORD unknown[4];DWORD base;DWORD driver_start;DWORD unk1;UNICODE_STRING driver_Path;UNICODE_STRING driver_Name;
//...} MODULE_ENTRY, *PMODULE_ENTRY;
С процессами ситуация следующая. Каждый EPROCESS процесса содержит по смещению 0x88 (для Windows XP) указатель на следующий элемент (то есть структуру LIST_ENTRY). Таким образом, модифицируя списки MODULE_ENTRY и EPROCESS, можно скрыть драйвер или процесс. Понятно, что для скрытия достаточно поменять указатели соседних элементов, как, например, в следующем коде:
VOID HideProcess(PEPROCESS pProcess)
{pProcess->ActiveProcessLinks.Blink=pProcess->ActiveProcessLinks.Flink;pProcess->ActiveProcessLinks.Flink->Blink=pProcess->ActiveProcessLinks.Blink;}
Дескрипторы, таблицы дескрипторов и их трансляция ядром
Все программисты знают, что дескриптор - это некое значение, которое используется для доступа к ресурсу. Пока открыт хотя бы один дескриптор (точнее, в OBJECT_HEADER счетчик ссылок больше нуля), объект не может быть уничтожен. При существовании ссылок на скрываемый объект ядра он не является абсолютно скрытым. Это означает, что через какую-либо таблицу дескрипторов можно получить доступ к скрываемому тобой объекту ядра.
Грубо говоря, дескриптор является индексом в таблице дескрипторов и делится ядром на битовые поля. В Winodws XP поля девятибитные. Зачем так было сделано, мы расскажем ниже. А пока обрати внимание на структуру дескриптора.
typedef struct _HANDLE
{ULONG Reserve1:2;ULONG HandleEntry3:9;ULONG HandleEntry2:9;ULONG HandleEntry1:9;ULONG Reserve2:3;} HANDLE;
typedef struct _HANDLE_ENTRY
{
union
{POBJECT_HEADER ObjectHeader;ULONG Attr:3;
};
union
{ACCESS_MASK GrantedAccess;ULONG NextEntry;
};} HANDLE_ENTRY, * PHANDLE_ENTRY;
3 промежуточных битовых поля интерпретируются как индексы в соответствующих трех таблицах дескрипторов. Все дело в том, что в NT таблица дескрипторов организована по трехуровневой схеме, так же MMU в x386 транслирует виртуальные адреса на физические. В Windows XP индексы таблиц девятибитные. Здесь для таблиц дескрипторов имеется правило: уровень таблицы должен умещаться на одной странице. Поэтому PAGE_SIZE/sizeof(HANDLE_ENTRY)==512, соответственно, индекс может адресоваться девяти битами. Каждый EPROCESS в NT имеет указатель на таблицу дескрипторов. Таблица дескрипторов - это не первый ее уровень, а структура HANDLE_TABLE, определение которой дано ниже:
typedef struct _HANDLE_TABLE
{
union
{PVOID TableCode; //указатель на один из уровнейULONG Attr:2; //Attr+1 – число задействованных уровней} x;KPROCESS *QuotaProcess; //KPROCESS владельцаULONG UniqueProcessId; // PID владельцаEX_PUSH_LOCK HandleTableLock [4];LIST_ENTRY HandleTableList; EX_PUSH_LOCK HandleContentionEvent;PVOID DebugInfo;ULONG ExtraInfoPages;ULONG FirstFree; //первый свободный дескрипторULONG LastFree; ULONG NextHandleNeedingPool;ULONG HandleCount; //счетчик дескрипторовULONG Flags;} HANDLE_TABLE, *PHANDLE_TABLE;
В Windows XP есть счетчик задействованных таблиц – первые два бита объединения x, которые содержат индекс задействованных таблиц, т. е. Attr+1 число задействованных таблиц. Первый член объединения есть указатель на первый уровень таблицы. Т. к. адрес уровня выравнивается по странице, то кратность всегда будет обеспечиваться и эти биты будут свободны.
Например, если Attr равен нулю, то число таблиц равно единице, следовательно, первый уровень содержит элементы TABLE_ENTRY (то есть TableCode указывает на массив TABLE_ENTRY). Если Attr равен единице, то таблиц две, и значит, TableCode указывает на промежуточную (вторую) таблицу, каждый элемент которой и включает указатели на TABLE_ENTRY. Соответственно, для трансляции будут задействованы HandleEntry2 и HandleEntry3.
Ты заметил, что HANDLE_ENTRY, кроме GrantedAccess, содержит поле NextEntry. Оно используется, если дескриптор свободен. В таком случае элемент ObjectHeader будет обнулен и задействуется поле NextEntry, которое содержит следующий в цепочке свободный дескриптор. А поле FirstFree в HANDLE_TABLE содержит первый свободный дескриптор. То, как осуществляется трансляция в случае с трехуровневой схемой таблиц в Windows XP, ты можешь увидеть в журнале, там приведен рисунок.
Поскольку объекты ядра выравниваются по границе 8, первые 3 бита не задействованы и зарезервированы для атрибутов дескриптора.
В ядре существует функция, которую остальные компоненты используют для трансляции дескриптора и получения указателя на элемент таблицы:
PVOID __stdcall
ExpLookupHandleTableEntry(PHANDLE_TABLE pProcessHandleTable,HANDLE hObject);
Она принимает на вход указатель на таблицу дескрипторов и дескриптор и возвращает адрес элемента таблицы. Такая реализация также обусловлена тем, что не все таблицы дескрипторов хранят указатели на OBJECT_HEADER.
В ядре существует неэкспортируемая переменная PspCidTable, которая хранит указатель на HANDLE_TABLE таблицы дескрипторов. Эта таблица содержит дескрипторы процессов и потоков и принадлежит ядру. В ней PID (или TID, в случае потока) используется как дескриптор. Этот дескриптор транслируется по вышеописанной схеме, и на выходе ядро имеет указатель на EPROCESS. При этом элементы таблицы PspCidTable содержат указатели не на заголовки объектов, а на их тела (то есть для получения адреса тела смещение 0x18 прибавлять не нужно).
Поскольку PspCidTable не экспортируется ядром, ее нужно как-то искать. Разработчики FUTo применили метод поиска дизассемблером по функции PsLookupProcessByProcessId, которая оперирует адресом PspCidTable. PspCidTable важна тем, что там хранятся дескрипторы процессов и потоков, а значит, по этой таблице можно получить указатель на EPROCESS скрываемого процесса. FUTo, анализируя эту таблицу, находит соответствующий PID и обнуляет элемент в ней, дополнительно проводя манипуляции с FirstFree, как было рассказано выше.
Заключение
Итак, мы увидели, что техники скрытия весьма разнообразны и поле деятельности хакеров весьма велико. Без сомнения, с помощью FUTo был сделан большой шаг в реализации продвинутых стелс-механизмов. Но тот же FUTo, например, можно обнаружить по спискам потоков планировщика.