Introducción a la programación de drivers en Windows

On lunes, 20 de septiembre de 2010 1 comentarios

by Hendrix

E pensado en escribir un artículo sobre la programación de drivers en Windwos. Este primer post lo voy a tener como índice del manual, al terminarlo lo voy a pasar a PDF. (No lo bloqueo ya que me interesa que los lectores pregunten lo que no entiendan, para poderlo resolver e ir editando lo que sera el PDF explicando lo que se pregunte).

Nota: Este artículo lo publico en este subforo y no en el de programación ya que sera orientado al malware.

Índice

1. Nociones básicas
         1.1 Herramientas necesarias
         1.2 Modo kernel y modo Usuario
         1.3 Documentación interesante

2. Introducción
         2.1 Hola mundo desde el driver
         2.2 Comunicación entre Modo kernel y modo usuario

3. El Kernel de Windows
         3.1 La SSDT o System Service Descriptor Table
         3.2 Memoria protegida y Hooks en la SSDT

4. Direct Kernel Object Manipulation (DKOM)
         4.1 Eprocess
         4.2 Ocultando procesos sin Hooks


1. Nociones básicas

En este documentos voy a intentar hacer una introducción a la programación de drivers en Windows. Cabe decir que los temas que se van a exponer aquí son una minúscula parte de lo que realmente se puede hacer con drivers. Antes de empezar nada, primer tenemos que saber realmente que es lo que hace un driver (en este caso, seran módulos del Kernel, aunque lo llame driver), por esto este primer artículo sobre nociones básicas.

Antes de empezar este documento, quiero dar las gracias públicamente a Mek (®®) ya que se tomó la molestia de enseñarnos a programar drivers por un proyecto que teníamos en mente.

1.1 Herramientas necesarias

Para programar drivers no se usa el compilador del Dev ni del MVC++ ni de ningun otro, vamos a usar la DDK (Driver Development Kit), aunque se pueden configurar tanto el MVC++ cono el Dev para usar el compilador del DDK, aunque yo prefiero programarlo directamente con el compilador del DDK.

Una vez tengan el DDK, tenemos que instalarlo, una vez echo esto, se van a Inicio / Todos los programas y buscan el DDK que se instaló, una vez hay tienen que ir a Build Environments, desde hay pueden seleccionar el que quieran, aunque yo recomiendo usar Win XP Free Build Environment (Pueden moverlo al escritorio ya que lo van a usar siempre para compilar, mas info sobre el Build Environment aqui). Para compilar, lo que tienen que hacer es crear una carpeta y dentro meter el codigo (main.c, por ejemplo) y un archivo SOURCES y un MAKEFILE.

El SORCES tiene que se así:

Citar
TARGETNAME=prueba
TARGETPATH=.
TARGETTYPE=DRIVER

SOURCES=main.c

Donde prueba es el nombre del driver que se generará y main.c es el archivo con el código fuente, no tienen que modificar nada más.

El MAKEFILE es siempre el mismo:

Citar
!INCLUDE $(NTMAKEENV)\makefile.def


Una vez tengamos esto, ejecutamos el Win XP Free Build Environment y se nos abrirá una consola, cambiamos el directorio actual con el comando CD, una vez situados dentro de la carpeta donde estan los 3 archivos (como mínimo), tenemos que escribir:

build -cZ

Esto se encarga de compilar y enlazar (linkear) el archivo, si ha habido errores nos lo va a marcar. Si no ha habido errores, nos va a generar el driver dentro de \i386 (se genera una carpeta dentro de la del proyecto). Una vez echo esto, el trabajo de la DDK se puede dar por concluido.

El tema de las herramientas no a terminado, ahora nos falta, como mínimo, una herramienta para cargar el driver y otra para ver los mensajes que el driver nos envía. Ya que un driver no se ejecuta con una consola como los archivos de C/C++ o con una ventana, para poder ver los mensajes que nos envía debemos tener una herramienta adicional.

DebugView: Como su nombre indica, esta herramienta sirve para ver los mensajes que se enviar a través de debug (con el comando DbgPrint). Aunque no solo esta orientada a modo kernel, también sirve para aplicaciones de modo usuario.

OSRLoader: Aunque hay otras, esta es la que uso para cargar los drivers, utiliza el método de cargar lo drivers como servicios, primero seleccionan el driver, registran el servicio y lo ejecutan, una vez terminado, lo pueden parar y para no dejar rastro en el registro pueden eliminar el servicio, aunque esto es opcional.

WinDbg: (Opcional) Sirve para poder analizar la memoria del kernel, es una herramienta que recomiendo aprender su uso, ya que se puede sacar muchísima información (Por ejemplo, la información sobre la Eprocess se saca con esta herramienta).

IceSword: (opcional) Esta herramienta te permite ver los hooks en la SSDT entre otras cosas, aunque ya se que no es la mejor herramienta anti-rootkit, la recomiendo ya que te muestra la dirección original de la API hookeada, el Rootkit Unhooker no lo hace.

1.2 Modo kernel y modo Usuario







Los Ring's (Anillos) de la imagen, son privilege rings (anillos de privilegios), y como ven, los que tienen mas privilegios están en el corazón (Ring0) y los que menos, al exterior (Ring3). Las aplicaciones que ejecutamos en nuestro PC, estan todas en Ring3, hay algunas que tienen una parte en Ring3 y otra en Ring0 (Anti-Virus, Firewalls). Esta mezcla entre modo usuario y modo kernel es que en modo usuario esta muy limitado en cuanto a privilegios.

El kernel de Windows incorpora dentro del modo usuario un Subsistemas de Ambiente Protegido, esto significa que controla los errores en los procesos, lo que a su vez implica, que si se produce un error en algún programa, este puede ser descargado de memoria sin provocar en el sistema operativo ningún tipo de error (dependiendo de que aplicación tenga el error, evidentemente). En modo Kernel no existe esta protección, lo que provoca que si un driver tiene un error, el sistema operativo se ve obligado a reiniciar el PC.

Los procesos que se ejecutan dentro de modo usuario, poseen memoria virtual, eso significa que la posición de memoria 0x00457ab no es la misma posición físicamente, esto lo maneja el Kernel de windows, para impedir que otros procesos escriban o modifiquen datos de otro procesos. Los drivers en cambio se ejecutan dentro de la memoria física, esto equivale a que es posible escribir en la memoria de otro drivers y en el mismo kernel, aunque alguna paginas de memoria tengan protección de escritura (que se puede eliminar).

Los programas en modo usuario, hacen función de las API's, estas, en sus rutinas llaman a las API's nativas, que son las que ejecuta el kernel. Esto implica, que si se hookea una API, se puede cambiar la información que van a recibir todos los procesos que llamen a esa API.


1.3 Documentación interesante

Esta breve introducción al modo kernel y al modo usuario es muy corta, para una mayor información, pueden buscar en internet o comprarse algunos libros que voy a menciona ahora, donde se explica perfectamente.

- Microsoft Windows Internals (4th Edition)
- Rootkits: Subverting the Windows Kernel

Para temas sobre rootkits pueden visitar la pagina : www.rootkit.com
Para charlar sobre modo kernel, pueden visitar el foro de SysInternals: http://forum.sysinternals.com


2. Introducción

2.1 Hola mundo desde el driver

Una vez leído el apartado de nociones básicas, vamos a centrarnos en lo que es la programación de drivers.

Como en todo programa, tiene que haber un punto de partida, un main. El de los drivers es así:

Código
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
 //Codigo
}
 

Como vemos, al DriverEntry se le pasan 2 parámetros, el primero es un puntero a la estructura DRIVER_OBJECT, más adelante veremos como usar esto. El segundo es un puntero a una cadena Unicode donde esta guardada la ruta del registrot con el que se cargó el driver.

Una vez realizadas las tareas en el DriverEntry, tenemos que retornar un valor, si no ha habido ningún error retornaremos STATUS_SUCCESS, de lo contrario, el código de error pertinente.

Cabe decir que retornando el valor no se descarga el driver, ya que para poderse descargar se tiene que crear una rutina, pecisamente esta rutina se crea a partir del puntero al primer parametro. Veamos como se hace:

Código
void Salir(PDRIVER_OBJECT DriverObject)
{
    //Codigo de salida
}
 
NTSTATUS DriverEntry( PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
    DriverObject->DriverUnload=Salir;
    return STATUS_SUCCESS;
}
 

Como vemos aquí, creamos una rutina para que al descargar el driver se llame a la rutina, dentro podemos escribir un mensaje de "Cerrando driver..." o algo asi, o en su caso, unhookear las apis, ya que si cerramos el driver si unhookear la SSDT nos va a mostrar una bonita pantalla azul, ya que se va a llamar una zona de memoria donde no hay nada.

El comando para poder escribir datos al DebugView es el comando DbgPrint y funciona exactamente igual que el printf de C/C++. Si nos miramos la información, vemos que esta dentro de Ntddk.h, asi que la tenemos que incluir. El programa que nos dirá hola mundo al iniciarse y Adiós al cerrarse nos quedaría así:

Código
#include 
 
void Salir(PDRIVER_OBJECT DriverObject)
{
    DbgPrint("Adiós");
}
 
NTSTATUS DriverEntry( PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
    DriverObject->DriverUnload=Salir;
 DbgPrint("Hola mundo!!!");
 return STATUS_SUCCESS;
}

Una vez echo esto ya podemos abrir el DebugView, le habilitamos la opción para capturar mensajes del Kernel y lo podemos ejecutar. Este ejemplo lo e probado yo mismo y pueden ejecutarlo en el PC, aunque es recomendable siempre hacer pruebas en una maquina virtual, ya que un error en el driver provocaría un reinicio de sistema.

2.2 Comunicación entre Modo kernel y modo usuario

Este tema ya es algo mas lioso. Hay varias formas de pasar información desde modo usuario a modo kernel y viceversa,  la que yo voy a utilizar es el metodo que utiliza la API DeviceIoControl.

Esto me permite enviar un mensaje desde modo usuario (MU desde ahora en adelante) hacia modo kernel (MK). Además, me retorna un puntero hacia un buffer de salida (de MK a MU) y la longitud de este, si la longitud es igual a 0 no hay datos, de lo contrario si.

La estructura donde se fundamenta la comunicación entre MU y MK es la estructura IRP. Para poder manejarla, en el driver tendremos que crear una funcion que maneje esta estructura. Esta función se declarará igual que declaramos la función de salida en el DriverEntry. Aqui un ejemplo:

Código
NTSTATUS Control(PDEVICE_OBJECT DeviceObject,PIRP Irp)
{
 //Codigo
}
 
NTSTATUS DriverEntry( PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
    DriverObject->DriverUnload=Salir;
    for(i=0;i<IRP_MJ_MAXIMUM_FUNCTION;i++)
    DriverObject->MajorFunction[i]=Control;
 
 return STATUS_SUCESS;
}

Aquí declaramos esa estructura como control.

El for que hay en el programa es para indicarle que todas la funciones de control las redirija a esa función. Pueden ver todas las funciones aquí.

Para poder crear un handle desde MU hacia MK, necesitamos usar la API CreateFile, aunque antes tenemos que crear el objeto del driver. Para esto hacer esto se usa la API IoCreateDevice.

Si leen la información de esta API, veran que se le tiene que pasar una cadena en formato unicode, esto es importante, al igual que el paso que le sigue, el de crear un vinculo para poderse abrir desde MU. Este paso se hace con la API IoCreateSymbolicLink, al que se le pasa una cadena que sera usada en MU. Aqui un ejemplo de lo hablado hasta ahora.

Código
//Variables globales
const WCHAR    Device[]=L"\\device\\driver5";
const WCHAR  sLink[]=L"\\??\\midriver5";
UNICODE_STRING Dev,lnk;
//Fin variables globales
 
NTSTATUS DriverEntry( PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
 NTSTATUS  s;
 unsigned int  i;
 
    DriverObject->DriverUnload=Salir;
    for(i=0;i<IRP_MJ_MAXIMUM_FUNCTION;i++)
    DriverObject->MajorFunction[i]=Control;
 
    RtlInitUnicodeString(&Dev,Device);
    RtlInitUnicodeString(&lnk,sLink);
    s=IoCreateDevice(DriverObject,0,&Dev,FILE_DEVICE_UNKNOWN,0,0,&DriverObject->DeviceObject);
 
 if (NT_SUCCESS(s))
    {
  s=IoCreateSymbolicLink(&lnk,&Dev);
  if(!NT_SUCCESS(s))
     {
   IoDeleteDevice(DriverObject->DeviceObject);
   DbgPrint("Error Link");
  }else
  DbgPrint("Cargado");
 }else
  DbgPrint("Error IoCreate");
 
 return  s;
}
 


Si no ha habido error, nos podemos centrar en la función control.

En la función de control, normalmente se analiza el tipo de mensaje que se transfiere con un IoControlCode, por ejemplo:

Hookear Datos --> 1
UnHookear --> 2

Esto se filtra mediante un switch/case. En el ejemplo usaremos un filtro para escribir que nos inventemos, yo le e llamado escribe. Para usarlo en la consola, tienen que agregar la librería winioctl.h.

Código
NTSTATUS Control(PDEVICE_OBJECT DeviceObject,PIRP Irp)
{
    NTSTATUS            s=STATUS_SUCCESS;
    PIO_STACK_LOCATION  Stack;
 unsigned int    escritos;
 char *iBuffer;
    char *oBuffer;
    char *Mensaje = "Hola desde el kernel!";
    unsigned int Tam = sizeof("Hola desde el kernel!");
 
    Stack=IoGetCurrentIrpStackLocation(Irp);
 
 switch(Stack->Parameters.DeviceIoControl.IoControlCode)
    {
  case Escribe:
   DbgPrint("Funcion escribir llamada");
      DbgPrint("Asociando buffers...");
      iBuffer = oBuffer = Irp->AssociatedIrp.SystemBuffer;
      if(oBuffer && oBuffer)
      {
    DbgPrint("OK");
         if(Stack->Parameters.DeviceIoControl.InputBufferLength !=0)
      {
     DbgPrint("Datos desde modo usuario: %s",iBuffer);
 
     if(Stack->Parameters.DeviceIoControl.OutputBufferLength>= Tam)
              {
      DbgPrint("Enviando datos...");
                  RtlCopyMemory(oBuffer, Mensaje, Tam);
      Irp->IoStatus.Information = Tam;
                  s = STATUS_SUCCESS;
              }else{
      DbgPrint("NO ENVIAMOS LOS DATOS");
      Irp->IoStatus.Information = 0;
                  s = STATUS_BUFFER_TOO_SMALL;
              }
         }
      }
   else DbgPrint("ERROR");
   break;
 }
 Irp->IoStatus.Status = STATUS_SUCCESS;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return s;
}

Al inicio declaramos los buffers de entrara y salida y los mensajes que vamos a enviar a MU.

Código:
Stack=IoGetCurrentIrpStackLocation(Irp);

Esta linea sirve para poder localizar los datos que vamos a usar posteriormente, Stack esta declarada como un puntero a IO_STACK_LOCATION.

Código:
iBuffer = oBuffer = Irp->AssociatedIrp.SystemBuffer;

Assignamos los buffers de E/S, si no hay error proseguimos.

Código:
RtlCopyMemory(oBuffer, Mensaje, Tam);
Irp->IoStatus.Information = Tam;

En la primera linea copiamos los datos al buffer de salida, en la segunda, ajustamos el tamaño del buffer de salida, esto es importante, ya que si no se configura no se transmitiran datos.

Código:
Irp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return s;

Completamos y salimos.

Ahora vamos a ver la aplicación de consola en MU:

Código
#include 
#include 
#include 
 
#define Escribe CTL_CODE(FILE_DEVICE_UNKNOWN, 0x00000001, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA)
 
int main()
{
 DWORD a;
 HANDLE hDevice = CreateFile("\\\\.\\midriver5",GENERIC_READ | GENERIC_WRITE ,FILE_SHARE_READ | FILE_SHARE_WRITE,0,OPEN_EXISTING,FILE_FLAG_OVERLAPPED,0);
 char    iBuffer[30];
 char    oBuffer[1024];
 
    if (hDevice!=INVALID_HANDLE_VALUE)
    {
  printf("Conectado");
  strcpy(iBuffer,"Hola desde Modo Usuario!!!");
  if(DeviceIoControl(hDevice,(DWORD)Escribe,iBuffer,(DWORD)sizeof(iBuffer),(LPVOID)oBuffer,(DWORD)sizeof(oBuffer),&a,NULL)==true)
  {
   printf("\n%d Bytes\n%s\n%s",a,iBuffer,oBuffer);
  }else
  printf("0x%08x",GetLastError());
 }
 
 system("pause");
 return 0;
}
 

La verdad es que no hay mucho que explicar de este código.

En este ejemplo se transfieren cadenas, pero se puede transferir todo lo que nosotros queramos.

Dicho esto doy por zanjado este segundo capítulo, si alguien tiene alguna duda comentenlo.

PD: Tengo que decir que parte de estos codigo son de lympex, de un codigo que publicó, lo e modificado un poco ya que usaba mas funciona, pero los nombres de las variables y eso no lo e cambiado.

1 comentarios:

WhiteSkull dijo...

Muy bueno la explicación, lo probaré cuando tenga tiempo... gracias Hendrix

Publicar un comentario