Hooking Windows Named Pipes
Lors d'audits de sécurité, nous sommes souvent confrontés à des applications lourdes complexes, composées de plusieurs processus. Certains de ces processus tournent en tant que SYSTEM, et d'autres dans le contexte de la session utilisateur, sans privilèges particuliers. Ces processus ont besoin de communiquer d'une certaine manière, et utilisent souvent des Named Pipes (ou canaux nommés) comme mécanisme IPC (Inter-Process Communication - Canal de communication entre processus). Une fois ouvert, un Named Pipe n'est autre qu'un canal de communication bidirectionnel (le plus souvent), tout comme les communications TCP ou Websocket. Ce canal peut être une surface d'attaque de processus privilégiés, depuis un processus non privilégié.
Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus
API Windows
Les Named Pipes Windows distinguent client et serveur, le serveur écoute sur un nom donné et le client se connecte à ce même nom. Ces objets peuvent être créés en utilisant l'API Windows CreateNamedPipe, qui exécute l'appel système NtCreateNamedPipe. Cette fonction prend en paramètre un nom ressemblant à \\.\pipe\example_pipe_name, car l'ensemble des named pipes d'un système sont référencés sous le pseudo-filesystem \\.\pipe\.
Un client peut alors ouvrir le canal de communication en appelant la fonction CreateFile sur cette même chaine de caractères \\.\pipe\example_pipe. Les deux fonctions CreateNamedPipe et CreateFile retournent un handle qui peut être utilisé avec les APIs Windows ReadFile et WriteFile pour lire et écrite des données dans le named pipe. Le noyau Windows s'assure ensuite de la bonne transmission des messages, dans le bon ordre, de l'autre côté du named pipe.
Comme la hiérarchie \\.\pipe\ est partagée par tous les processus d'une même machine, il est possible de lister les named pipes existants avec la commande Get-ChildItem \\.\pipe\, ou à l'aide de pipelist64.exe de Sysinternals.
PS > .\pipelist64.exe
Pipe Name Instances Max Instances
--------- --------- -------------
InitShutdown 3 -1
lsass 9 -1
ntsvcs 3 -1
scerpc 3 -1
Winsock2\CatalogChangeListener-2ec-0 1 1
Winsock2\CatalogChangeListener-3e0-0 1 1
epmapper 3 -1
Winsock2\CatalogChangeListener-254-0 1 1
LSM_API_service 3 -1
Winsock2\CatalogChangeListener-1d8-0 1 1
atsvc 3 -1
Dans la capture ci-dessus, on constate que pipelist64.exe expose deux propriétés des named pipes : le nombre d'instances et le nombre maximal d'instances. La première propriété correspond au nombre d'appels à CreateNamedPipe en utilisant le même nom. Ces appels peuvent venir de processus différents, et sont ajoutés à une file FIFO (first-in-first-out). Ainsi, le premier client se connectera au premier processus ayant appelé CreateNamedPipe, le deuxième au second, etc. Le nombre maximal d'instances peut être défini lors du premier appel à CreateNamedPipe.
Les Named Pipes sont des objets sécurisables (securable objects), ils ont donc une DACL qui peut être manipulée à la création du Named Pipe en utilisant l'argument lpSecurityAttributes de la fonction CreateNamedPipe. Les ACE ont une signification légèrement différente par rapport à celles définies sur des fichiers standards, et peuvent être interprétées de cette manière :
FILE_GENERIC_READcorrespond au droit de lecture des données, des attributs du Named Pipe, des attributs étendus, et de la DACL.FILE_GENERIC_WRITEcombine les droits d'écriture de données dans le Named Pipe, d'écriture des attributs, des attributs étendus et la création d'une nouvelle instance du Named Pipe avec le même nom.
Par défaut, lors qu'un Named Pipe est créé, Administrators et NT AUTHORITY\System ont les droits FILE_GENERIC_READ et FILE_GENERIC_WRITE sur le Named Pipe, tandis que Everybody et Anonymous Logon ont FILE_GENERIC_READ. De plus, si un processus ne tourne pas dans un contexte privilégié, l'utilisateur courant est automatiquement ajouté à la DACL avec les droits FILE_GENERIC_READ et FILE_GENERIC_WRITE. Ce fonctionnement permet donc de se placer en man-in-the-middle.
ACL permissives
Supposons qu'un processus privilégié écoute sur un Named Pipe et crée de nouvelles instances à mesure que des clients se connectent. Il y a systématiquement une nouvelle instance du Named Pipe, et, comme le serveur s'attend à ce qu'un processus non privilégié se connecte, celui-ci positionne les ACL sur le Named Pipe pour être permissives, permettant aux processus non privilégiés de lire et écrire des données.
Dans certains cas, la permission GENERIC_WRITE est même accordée à l'ensemble des utilisateurs. Ceci permet d'ajouter une nouvelle instance du Named Pipe en haut de la file, et d'attendre qu'un client légitime se connecte au Named Pipe. Il suffit d'appeler CreateNamedPipe, afin d'ajouter une nouvelle instance du Named Pipe, puis d'appeler CreateFile afin de se connecter, en tant que client, à une instance légitime. Ce procédé permet d'interposer des instances légitimes du Named Pipe avec des instances créées par un processus attaquant, permettant de se positionner en man-in-the-middle.
La remédiation consiste ici à s'assurer d'ACL restrictives, en retirant le droit FILE_APPEND_DATA aux autres utilisateurs.
Flags incorrects
On suppose maintenant que le processus privilégié crée le Named Pipe en utilisant l'argument lpSecurityAttributes. Comme ce paramètre est évalué uniquement lors de la création de la première instance du Named Pipe, il est parfois possible de créer le Named Pipe avant que le processus légitime, et ainsi ajouter des ACLs permissives sur le Named Pipe.
Il est possible d'ajouter le bit FILE_FLAG_FIRST_PIPE_INSTANCE au paramètre dwOpenMode lors de l'appel initial à CreateNamedPipe. De cette manière, l'appel système échouera lorsque le Named Pipe créé n'est pas la première instance.
Protections des Named Pipes
Supposons désormais que le processus privilégié cherche à autoriser un processus spécifique à se connecter au Named Pipe, au lieu d'exposer ce dernier à l'ensemble du contexte de l'utilisateur non privilégié cible. Ce cas arrive souvent lorsqu'une application a besoin d'exécuter du code dans le contexte d'un utilisateur authentifié, comme les sessions graphiques Windows. Une approche habituellement suivie est de vérifier que l'image (le PE) du processus client est signé par une autorité de certification jugée de confiance, ou que le processus se connectant a un PID (process id) bien identifié.
Étant donné que la limite de sécurité choisie par Windows est les droits de l'utilisateur, il est possible, pour un autre processus s'exécutant dans la même session, d'injecter une charge utile dans le client Named Pipe légitime. De cette manière, il est possible d'inspecter les données transitant dans le Named Pipe.
Cette approche permet également de se passer des privilèges d'administration nécessaires pour inspecter le contenu des Named Pipes par d'autres outils comme API Monitor.
Thats No Pipe
Pour implémenter une telle technique, nous avons créé un outil basé sur Frida, s'injectant dans un processus cible et interceptant les appels systèmes utilisés pour lire et écrire des données sur des Named Pipes. Pour rendre ces données accessibles en lecture et écriture à d'autres outils, nous avons choisi d'envoyer ces données dans un canal Websocket, comme le ferait un navigateur web discutant avec son serveur backend, de manière bidirectionnelle. Ce choix permet également de modifier facilement les messages à l'aide de scripts, sans avoir à modifier l'interface utilisateur. Cet outil est disponible sur le GitHub de Synacktiv.
Par la suite, on suppose que l'outil s'injecte dans le processus client, dans le contexte d'un utilisateur non privilégié. L'architecture ressemble alors à la suivante :
Cas 1 : API synchrones
Le cas le plus simple intervient lorsqu'un processus client ouvre un Named Pipe pour une communication synchrone. Il s'agit du comportement par défaut de CreateFile. Dans ce cas, le processus client appelle NtWriteFile et NtReadFile afin d'écrire et de lire des données dans le Named Pipe. Comme toutes les opérations sont synchrones, une fois que l'appel système termine, l'opération est terminée du point de vue noyau. Ceci est particulièrement intéressant pour NtReadFile, car le paramètre lpBuffer va alors contenir les données que le processus s'apprête à lire. Le cas de NtWriteFile est plus simple, car les données sont présentes dans un buffer passé en paramètre et lu par le noyau, ce qui signifie qu'il n'est nécessaire d'interagir avec celui-ci qu'avant l'appel à NtWriteFile. Dans les deux cas, il est possible d'envoyer les données vers un processus de gestion qui va lui-même envoyer les données en Websocket à travers un proxy HTTP. Les données vont ensuite revenir vers le processus de management, puis dans le processus injecté pour modification.
Le flux de données entre le processus client voulant écrire dans un Named Pipe ressemble alors au suivant :
Cependant, pour les opérations de lecture, il est nécessaire d'attendre la fin de l'opération dans le noyau avant de lire et modifier le buffer :
Cas 2 : API asynchrones
Si le développeur cherche à manipuler des opérations asynchrones, la fonction NtReadFile va terminer immédiatement, ce qui reprend l'exécution avec un buffer non modifié. Lire ce buffer immédiatement est inutile, car les données n'ont pas encore été écrites.
Comme le processus légitime doit vérifier si l'opération s'est terminé, ou doit attendre jusqu'à ce que des données soient écrites dans le buffer, il est possible d'intercepter les appels aux fonctions Windows utilisées pour ces opérations, comme NtWaitForSingleObject, NtWairForMultipleObjects ou NtRemoveIoCompletion. Lorsque ces fonctions sont appelées, l'un des arguments permet de lier l'opération asynchrone à l'appel initial à NtReadFile.
Lorsque NtReadFile est appelée pour une opération asynchrone, l'argument IoStatusBlock doit contenir un pointeur vers une structure overlapped. Cette structure doit elle-même contenir un Event, utilisé, par exemple, dans NtWairForSingleObject, pour mettre en pause le thread courant jusqu'à ce que l'Event passe dans un état signalé (par le noyau). La structure overlapped peut aussi être utilisée dans la fonction GetOverlappedResult pour s'assurer que celle-ci est initialisée et que le buffer concerné contient effectivement des données placées ici par le noyau.
De ce fait, il est possible d'intercepter NtReadFile, afin de sauvegarder la structure overlapped. Ensuite, lorsque le processus appelle la fonction GetOverlappedResult avec la structure overlapped en paramètre, il sera possible de vérifier quel buffer de lecture de donnée est maintenant initialisé, en parcourant les structures overlapped sauvegardées précédemment. Lors de cet appel précis, il sera alors possible de modifier les données du buffer initialisé, avant que le processus client n'utilise ces données.
Cas 3 : Completion ports
Parfois, un processus peut avoir besoin de lire des données depuis plusieurs Named Pipes en même temps. Windows expose une API nommée Completion Port pour ce cas d'usage.
Lorsque cette API est utilisée, le développeur doit appeler la fonction CreateIoCompletionPort avec un handle d'un premier Named Pipe A pour obtenir un handle vers un Completion Port cphandle. Ensuite, CreateIoCompletionPort doit être appelé avec un handle vers un Named Pipe B et le cphandle, afin de lier B avec le Completion Port. De cette manière, lorsque la fonction GetQueuedCompletionStatus est appelée avec l'objet cphandle, un événement sera déclenché par le noyau lorsque des données sont disponibles dans le Named Pipe A ou dans le Named Pipe B.
La fonction GetQueuedCompletionStatus fait ensuite l'appel système NtRemoveIoCompletion, qui prend en troisième argument un ApcContext. Ce dernier est un pointeur vers une structure overlapped, la même que celle qui a été utilisée dans l'appel à NtReadFile, ce qui nous permet alors de lire et modifier les données juste avant qu'elles soient utilisées par le processus.
Cas 4 : Completion routines
Windows permet également la spécification de Completion Routines à la fonction ReadFileEx. Les Completion Routines sont des fonctions avec une signature prédéfinie accessible sur la documentation de Microsoft. Elles sont appelées dès que des données sont disponibles. Il s'agit du quatrième argument à ReadFileEx, qui est ensuite passé à NtReadFile dans son paramètre ApcContext. Il faut par ailleurs intercepter les appels à la Completion Routine lorsqu'une telle fonction est spécifiée à NtReadFile.
Cette méthode fonctionne très bien pour modifier des données, mais diffère des autres méthodes lorsqu'il s'agit de l'injection de données. La principale différence est que, contrairement aux autres méthodes où le développeur est chargé de l'appel aux différentes fonctions utilisées pour lire les données, c'est ici le noyau qui "appelle" la Completion Routine. Le noyau va ajouter cette fonction dans la file APC (asynchronous procedural call) du thread, ce qui signifie que, lorsque le noyau va reprendre l'exécution du thread depuis un état alertable, il va d'abord prendre les fonctions de la file APC avant de reprendre le contexte d'exécution dans lequel le thread s'est mis en pause.
Heureusement, il est possible d'ajouter manuellement une fonction à la file APC depuis l'intérieur du thread, en utilisant la fonction QueueUserAPC. La seule subtilité étant que les fonctions ainsi ajoutées ne peuvent prendre qu'un argument, alors que la Completion Routine en prend trois : le code d'erreur, le nombre d'octets transférés, et un pointeur vers la structure overlapped.
Une approche est alors de créer un dictionnaire global prenant en entrée une clé arbitraire, et en valeur une liste de trois arguments. Il suffit d'ajouter une fonction d'acheminement avec cette clé comme seul argument. Cette fonction d'acheminement va ensuite prendre la liste d'arguments dans le dictionnaire, puis appeler la Completion Routine.
Développements futurs et conclusion
Cet outil rend possible et raisonnablement facile l'interception, la modification et l'injection de données dans un Named Pipe en interceptant des fonctions spécifiques dans les processus clients non privilégiés. Il est particulièrement utile lorsque l'exécution en tant qu'administrateur n'est pas possible.
Ces techniques montrent l'importance de systématiquement valider les données reçues au travers des canaux non sécurisés comme les Named Pipes, surtout lorsque les données viennent d'un contexte de sécurité différent (par exemple, un autre utilisateur). Assurer la mise en place d'ACL strictes sur les Named Pipes et l'utilisation du flag de sécurité FILE_FLAG_FIRST_PIPE_INSTANCE permet également de réduire la surface d'attaque efficacement.
Les durcissements, comme la vérification du PID du processus distant ou la validation de la signature de son image permettent de ralentir les attaques, mais ne peuvent être considérés comme une mesure de protection en elle-même.