El sistema operativo Windows admite muchos sistemas de archivos distintos, siendo el más común NTFS. En Windows 1809, Microsoft introdujo un nuevo sistema de archivos llamado Projected File System (ProjFS).
Había escuchado sobre este sistema de archivos hace un par de años y tenía la intención de investigarlo a principios del año pasado, pero nunca lo hice. Después de una conversación con Casey Smith, Staff Threat Intelligence Analyst en Huntress, sobre el Projected File System, decidí profundizar un poco en el tema.
Funcionamiento interno del Projected File System
El ProjFS hace exactamente lo que su nombre indica. "Proyecta" archivos y carpetas desde un almacén de datos de respaldo hacia el sistema de archivos, haciendo que parezcan existir en el disco. Cuando listas un directorio, ProjFS muestra archivos/carpetas virtuales. Cuando realmente abres o lees un archivo, ProjFS lo "hidrata" obteniendo los datos reales bajo demanda desde el almacén de respaldo. Para implementar esto, hay tres requisitos:
- Habilitar la función ProjFS. No está habilitada de forma predeterminada, pero se puede habilitar sin reiniciar mediante: Enable-WindowsOptionalFeature -Online -FeatureName Client-ProjFS -NoRestart
- El controlador de ProjFS debe estar instalado y en ejecución. Esto se descarga mediante TiWorker.exe (el actualizador de Windows) una vez que habilitas la función ProjFS.
- Un proveedor: una aplicación en modo usuario que especifica la raíz de virtualización, los archivos/carpetas que se proyectarán, y los datos que se proyectarán.
Por suerte, Microsoft ha documentado las API de ProjFS, y son increíblemente fáciles de seguir. No voy a repasar las API, ya que tienen muy buena documentación. Además, el blog de Pavel Yosifovich "Projected File System" también lo explica paso a paso.
Sin embargo, aquí tienes un ejemplo de cómo lanzar un proveedor y proyectar archivos y una carpeta.
Como puedes ver, ProjFS.exe es el proveedor de ProjFS, C:\ProjFSDir es la raíz de virtualización, y los archivos monitoreados son C:\ProjFSDir\ProjectedFile.txt y C:\ProjFSDir\TestDir\SecondProjectedFile.txt.
Cuando una aplicación intenta acceder a uno de los archivos proyectados, se notifica al proveedor y este elige qué datos proyectar. Esto podría significar que para ProjectedFile.txt podría elegir proyectar "hello world" y para SecondProjectedFile podría proyectar "hello world from second file".
Figura 1: Ejemplo de proyección de archivo
ProjFS admite notificaciones cuando ocurre una operación de archivo dentro de la raíz virtualizada. Por ejemplo, si creo un nuevo archivo llamado NewFile.txt dentro de C:\ProjFSDir\, veré la notificación NEW_FILE_CREATED:
[*] NEW_FILE_CREATED. File: NewFile.txt, TriggeringImageInfo: \Device\HarddiskVolume3\Windows\System32\WindowsPowerShell\v1.0\powershell.exe (PID: 23888)
[*] FILE_OPENED. File: NewFile.txt, TriggeringImageInfo: \Device\HarddiskVolume3\Windows\System32\WindowsPowerShell\v1.0\powershell.exe (PID: 23888)
Las notificaciones admitidas son, en realidad, bastante útiles. La lista completa de notificaciones admitidas es:
Notarás que tres de estas notificaciones, concretamente PRJ_NOTIFICATION_PRE_DELETE, PRJ_NOTIFICATION_PRE_RENAME, y PRJ_NOTIFICATION_PRE_SET_HARDLINK tienen "PRE" en su nombre. Esto corresponde a una devolución de llamada de notificación de operación "pre", lo que significa que esas devoluciones de llamada de notificación te permiten devolver un error si alguien intenta realizar una de esas operaciones. Por ejemplo, si intento eliminar C:\ProjFSDir\TestDir\SecondProjectedFile.txt, puedo denegar ese acceso:
Figura 2: Ejemplo de acceso de eliminación denegado
También puedes técnicamente obtener notificaciones cuando ocurren operaciones de FileRead, porque siempre que alguien quiere leer un archivo, el proveedor debe admitir la devolución de llamada GetFileDataCallback, dentro de la cual el proveedor puede llamar a PrjWriteFileData o no proyectar ningún dato y devolver un error (¿se deniega el acceso?)... pero de eso hablaré más adelante. De cualquier forma, cuando se activa la devolución de llamada GetFileDataCallback, el proveedor puede imprimir que se leyeron datos de un archivo.
[*] FILE_OPENED. File: ProjectedFile.txt, TriggeringImageInfo: \Device\HarddiskVolume3\Windows\System32\WindowsPowerShell\v1.0\powershell.exe (PID: 23888)
[*] FILE_READ. File: ProjectedFile.txt, TriggeringImageInfo: \Device\HarddiskVolume3\Windows\System32\WindowsPowerShell\v1.0\powershell.exe (PID: 23888)
Estas notificaciones son extremadamente útiles para registrar cuándo alguien intenta realizar operaciones de archivo sobre tu archivo. Un buen caso de uso para esto serían los archivos señuelo, en lugar de recurrir a una alternativa como un minifiltro. Pero antes de comenzar a profundizar en los beneficios de esta tecnología, quiero repasar el funcionamiento interno de ProjFS, cómo funciona y qué es realmente. De esta manera, cuando describa los beneficios (tanto ofensivos como defensivos), tendrá más sentido.
Arquitectura de ProjFS
Desde el punto de vista arquitectónico, ProjFS no es un sistema de archivos. Es un minifiltro que depende de puntos de reanálisis para identificar sus propios archivos/directorios generados por el proveedor. Para quienes no lo sepan, los puntos de reanálisis son un atributo con datos predefinidos que entiende una aplicación en modo usuario y, por lo general, un sistema de archivos, pero en este caso un minifiltro. Los puntos de reanálisis contienen una etiqueta, un GUID opcional y los datos definidos. ProjFS tiene dos etiquetas de punto de reanálisis definidas:
- IO_REPARSE_TAG_PROJFS: punto de reanálisis principal. Se usa en archivos/directorios que se crean
- IO_REPARSE_TAG_PROJFS_TOMBSTONE: marca archivos/directorios que se eliminaron localmente para evitar que se vuelvan a proyectar
Desde el punto de vista programático, se puede consultar un punto de reanálisis pasando FSCTL_GET_REPARSE_POINT a DeviceIoControl, pero Windows tiene una aplicación llamada fsutil.exe que facilita un poco esto:
Figura 3: ejemplo de fsutil.exe
Los puntos de nueva ruta de análisis (reparse points) se diseñaron para sistemas de archivos, como NTFS. Los enlaces simbólicos de NTFS aprovechan estos puntos, de modo que NTFS maneja la solicitud correctamente. El hecho de que los reparse points se hayan diseñado para sistemas de archivos no significa que otros elementos de la pila de E/S de archivos no puedan aprovecharlos también, como un minifiltro.
Sabemos que ProjFS es un minifiltro por un par de razones:
1. ProjFS tiene un número de altitud al instalarse
Después de instalar prjflt.sys (el driver de ProjFS), noté que el driver tenía una altitud de filtro de 189800. Esto se puede ver ejecutando fltmc.exe en un símbolo del sistema o ejecutando el comando de WinDbg !fltkd.filters:
2. ProjFS admite callbacks Pre/Post para varias operaciones de E/S de archivos
Después de abrir el driver prjflt.sys, confirmó mis sospechas al ver que admitía callbacks de operaciones de archivos pre/post dentro de WinDbg:
Tras investigar un poco más, quedó claro que el minifiltro prjflt admitía una buena cantidad de operaciones de archivos:
Por último, esto se puede comprobar al establecer un punto de interrupción en uno de los callbacks (PrjfPreCreate y PrjfPostCreate, por ejemplo) y observar cómo llega una solicitud de E/S de archivo para uno de los archivos proyectados.
Ahora que entendemos que ProjFS es un minifiltro, algunos podrían preguntarse: "¿Cómo se comunican el proveedor y el minifiltro?" Para quienes conocen bien los minifiltros, esto no sorprenderá, pero el proveedor y el minifiltro se comunican a través de puertos de comunicación de filtro.
Los puertos de comunicación de filtro son una vía común utilizada para enviar mensajes hacia y desde un minifiltro. Yarden Shafir tiene un excelente blog sobre puertos de comunicación de filtro llamado "Investigating Filter Communication Ports" que recomiendo mucho leer.
Aprovechando fltkd de nuevo, podemos enumerar fácilmente la lista de puertos de PrjFlt:
Aprovechando un truco que Yarden mostró en su blog, podemos identificar que el nombre del puerto de comunicación de filtro es: PrjFltPort.
Esto también se nota mucho al mirar dentro de los binarios prjflt.sys y ProjectedFSLib.dll. Se ven muchas llamadas a FilterGetMessage / FilterSendMessage (versión en modo usuario) y FltSendMessage (versión en modo kernel). La creación de estos puertos se realiza cuando se carga el driver y llama a FltCreateCommunicationPort:
status = FltCreateCommunicationPort(
Filter,
&FilterPort,
&ObjectAttributes,
0,
PrjfPortConnect,
PrjfPortDisconnect,
PrjfPortMessage,
0x7FFFFFFF);
FltCreateCommunicationPort registra tres callbacks:
- PrjfPortConnect: para cuando una aplicación en modo usuario envía una solicitud de conexión al minifiltro prjflt
- PrjfPortDisconnect: maneja la desconexión del puerto del filtro
- PrjfPortMessage : maneja las solicitudes de mensajes enviadas desde una aplicación en modo usuario
Veamos un ejemplo real de esto. Supongamos que, en el ejemplo anterior, niego el acceso en mi callback de notificación para eliminaciones de archivos, así:
Cuando voy a eliminar el archivo, el minifiltro PRJFLT recibe esta solicitud a través de su callback PreSetInformation (prjflt!PrjfPreSetInformation), que luego verifica si la llamada es una solicitud de eliminación de archivo, y después llama a prjflt!PrjfPreSetDispositionInformation y luego a prjflt!PrjfPrepareForDelete.
La función de PrjfPrepareForDelete es empaquetar toda la información relevante y enviarla al proveedor, eventualmente a través de FLTMGR!FltSendMessage, y recibida mediante FilterGetMessage dentro de la función PROJECTEDFSLIB!PrjpCommandHandlerWorkerThreadProc. El mensaje luego se analiza y se pasa al callback de notificación, donde se procesa la solicitud (en nuestro caso, devuelve acceso denegado). El valor de retorno se envía de vuelta al minifiltro mediante PROJECTEDFSLIB!FilterSendMessage, lo que activa prjflt!PrjfPortMessage y luego prjflt!PrjfCompleteCommandHandler. Esto activa el evento de finalización, que despierta a FltCancellableWaitForMultipleObjects dentro de PrjfSendCommandAndWaitForCompletion, devolviendo el NTSTATUS de acceso denegado, que se lee y se devuelve a través de PrjfPrepareForDelete -> PrjfPreSetDispositionInformation -> PrjfPreSetInformation, que lo devuelve al Filter Manager y la eliminación del archivo se deniega.
Aquí hay una imagen para ayudar a visualizar esto:
Figura 4: Flujo de código entre prjflt y el proveedor en modo usuario
Casos de uso
A medida que avanzaba en esto, puede que te surjan preguntas sobre por qué esta funcionalidad es útil, ya sea desde una perspectiva defensiva u ofensiva. Intentaré explicar algunas de ellas en las siguientes secciones. Habrá más casos de uso de los que menciono, y animo a todos a explorar ProjFS para identificarlos.
Ofensivo
Hay tres casos de uso interesantes que quiero señalar aquí, aunque seré un poco ambiguo a propósito:
No hay restricciones de privilegios de usuario para iniciar un proveedor
Una cosa que noté en mis pruebas es que, cuando se levanta un proveedor mediante la función PrjStartVirtualizing, no había ninguna verificación del privilegio del usuario que indicara que solo los usuarios de alta integridad (administradores) pueden iniciar un proveedor y mostrar archivos virtualizados. Aquí tienes un ejemplo de un usuario de IL medio lanzando un proveedor.
Figura 5: Usuario de IL medio lanzando un proveedor
Obviamente, hay una salvedad: el ProjFS debe estar habilitado en la máquina, y para eso necesitas ser administrador.
Capacidad para impedir eliminaciones
Como mostré antes, a través de NotificationCallback hay tres operaciones “pre” que permiten que el proveedor UM devuelva un estado —como ERROR_ACCESS_DENIED— PRJ_NOTIFICATION_PRE_DELETE, PRJ_NOTIFICATION_PRE_RENAME, y PRJ_NOTIFICATION_PRE_SET_HARDLINK. La que realmente me llamó la atención fue PRJ_NOTIFICATION_PRE_DELETE y me hizo preguntarme: "Como usuario de IL medio, ¿puedo impedir que una aplicación con un contexto más alto elimine mis archivos?" La respuesta fue "sí".
Figura 6: Impedir que usuarios de nivel más alto eliminen archivos
Obviamente esto tiene algunos… beneficios si alguien quiere profundizar más en eso.
Capacidad para impedir lecturas o cambiar lo que se lee desde un proceso determinado
Aunque no hay una preoperación donde alguien pueda devolver ERROR_ACCESS_DENIED para la operación de lectura, todavía hay una forma de generar este error o incluso cambiar lo que un proceso lee en comparación con otro. Esto es posible porque todo proveedor debe soportar una función GetFileDataCallback. Esta función se usa cuando alguien intenta leer el contenido de ese archivo.
Aquí tienes un ejemplo de cómo se devuelve ERROR_ACCESS_DENIED a un proceso que se ejecuta como SYSTEM, pero se permite el acceso a otro proceso de PowerShell:
Figura 7: Proyección de resultados distintos por proceso en lecturas de archivos, ejemplo 1
Aquí tienes otro ejemplo en el que se entrega contenido distinto del mismo archivo a procesos diferentes:
Figura 8: Proyección de resultados distintos por proceso en lecturas de archivos, ejemplo 2
Esta no es la implementación natural del ProjFS. Normalmente, si un archivo se lee con éxito, se considera rehidratado y ya no vuelve a pasar por GetFileDataCallback. Sin embargo, es posible rastrear cuándo se cierra el identificador del archivo observando las notificaciones PRJ_NOTIFICATION_FILE_HANDLE_CLOSED_FILE_MODIFIED o PRJ_NOTIFICATION_FILE_HANDLE_CLOSED_NO_MODIFICATION y eliminar el archivo mediante PrjDeleteFile. Esto forzará otra “rehidratación”.
Nota: Esto solo funciona si la aplicación anterior cierra su identificador del archivo antes de que otra intente abrirlo.
Defensivo
El ProjFS tiene un caso de uso realmente excelente para el trabajo defensivo: los canarios. Los datos que obtienes del ProjFS son inmensos y casi equivalentes a lo que obtendrías dentro de un minifiltro. Claro, no tendrás tanto “control” sobre cada operación de E/S de archivos, pero obtienes lo importante desde la estructura PRJ_CALLBACK_DATA:
typedef struct PRJ_CALLBACK_DATA
{
UINT32 Size;
PRJ_CALLBACK_DATA_FLAGS Flags;
PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT NamespaceVirtualizationContext;
INT32 CommandId;
GUID FileId;
GUID DataStreamId;
PCWSTR FilePathName;
PRJ_PLACEHOLDER_VERSION_INFO* VersionInfo;
UINT32 TriggeringProcessId;
PCWSTR TriggeringProcessImageFileName;
void* InstanceContext;
} PRJ_CALLBACK_DATA;
Esto parece algo obvio y una forma muy eficiente de rastrear archivos, quién accede a ellos y quién intenta modificarlos. Todo esto ayuda mucho en ataques como ransomware. Es especialmente útil ya que Microsoft está hablando de alejarse del kernel en la Windows Resiliency Initiative. Piensa en esta combinación: ProjFS para canarios junto con el proveedor ETW Microsoft-Windows-Kernel-File. Esto daría mucha visibilidad de archivos sin necesidad de un minifiltro en absoluto (de nuevo, eso sí, no tendrías capacidad de bloqueo, o sería muy limitada a través del proveedor ProjFS).
Conclusión
Quiero agradecer a Casey Smith por recordarme este “sistema de archivos”, porque explorar ProjFS fue un proyecto muy divertido. Animo a quien no lo haya hecho todavía a interactuar un poco con esta tecnología. Creo que tiene mucho valor (más para el lado defensivo, pero también hay cosas interesantes que puedes hacer en el lado ofensivo si realmente quieres). Espero que hayas disfrutado este recorrido, y si tienes alguna pregunta, como siempre, ¡escríbeme!