Pendant ces vacances, j'ai eu l'occasion de me pencher sur les messages LPC - Local Procedure Call. Ça a commencé quand je me suis remis en tête de comprendre le fonctionnement de la fonction printf(). Vous vous souvenez ?
J'ai donc recommencé ce parcours. Voici ce dont je suis sûr pour le moment : printf() fais appel à la fonction standard write(), qui elle va appeler WriteFile(). Cette dernière écrit dans le flux stdout. Et à ma grande surprise - pas seulement à la mienne, en tout cas - WriteFile ne va pas faire appel à NtWriteFile() (qui est une fonction native) mais à WriteConsole() - on reste toujours dans l'api win32) et cette fameuse WriteConsole() ne fais pas non plus appel à NtWriteFile... mais à ZwRequestWaitReplyPort().
Un nom qui fait peur, non ?
J'ai donc mené des recherches, et j'ai appris que cette fonction faisait partie d'un jeu de fonctions qui assurait la communication "souterraine" entre processus/threads.
La communication LPC peut permettre de communiquer de l'userland au kerneland, et assure une rapide transmission de données. Tout se situe au niveau du noyau, puisqu'on utilise des api natives (celles qui font mettre un "syscall" dans le registre eax avant d'appeler KiFastSystemCallRet et donc faire un "sysenter").
Cet article va donc parler des messages LPC.
Comment ça se passe ?
Pour communiquer, nous avons besoin d'un client et d'un serveur. Le serveur va créer un port accessible en local uniquement. Il ne faut pas confondre avec les sockets. Les ports doivent toujours avoir un nom de la forme \NomDuPort. L'anti-slash est important.
Après, pour ceux qui connaissent les sockets, c'est un peu le même schéma. On se met en écoute sur le port créé et on attend que ça se passe. J'ai pas vraiment creusé le tout, mais il me semble évident qu'il doive exister certains ports fournissant des services windows.
Les fonctions permettant d'implémenter les messages LPC sont exportées par la ténébreuse ntdll. J'ai trouvé les prototypes ici : http://undocumented.ntinternals.net/UserMode/Undocumented%20Functions/NT%20Objects/Port/NtCreatePort.html.
Cependant, il s'avère que certains prototypes ne soient pas forcément exacts ; en effet, j'ai eu à utiliser des structures différentes au fur et à mesure que mes recherches devenaient fructueuses.
Côté serveur
Pour créer un port, on utilise NtCreatePort() :
NTSYSAPI
NTSTATUS
NTAPI
NtCreatePort(
OUT PHANDLE PortHandle,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN ULONG MaxConnectInfoLength,
IN ULONG MaxDataLength,
IN OUT PULONG Reserved OPTIONAL );
NTSTATUS
NTAPI
NtCreatePort(
OUT PHANDLE PortHandle,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN ULONG MaxConnectInfoLength,
IN ULONG MaxDataLength,
IN OUT PULONG Reserved OPTIONAL );
La fonction prend en argument un HANDLE qu'elle va initialiser. Ce handle va nous servir par la suite (logique). Pour le reste des arguments, ils sont à peu près compréhensibles, ben que l'argument ObjectAttributes me paraisse encore flou (je ne m'y suis pas penché, à vrai dire). Je ne connais pas encore la véritable fonction de la structure OBJECT_ATTRIBUTES. Ce n'est, à vrai dire, pas la seule qui me paraît floue. Mais ça ne m'a pas empêché d'émuler une communication client/serveur.
Bref, continuons : MaxDataLength est un argument qui correspond au maximum d'octets transférables sur le port. Pour le reste, on s'en tamponne le coquillage (pour le moment, hein !).
Après avoir créé notre port, il faut se mettre en écoute dessus via NtListenPort(). Cette fonction est bloquante et attend qu'une connexion se fasse de la part du client (nous verrons quelles fonctions il utilise).
Prototype de NtListenPort :
NTSYSAPI
NTSTATUS
NTAPI
NtListenPort(
IN HANDLE PortHandle,
OUT PLPC_MESSAGE ConnectionRequest );
NTSTATUS
NTAPI
NtListenPort(
IN HANDLE PortHandle,
OUT PLPC_MESSAGE ConnectionRequest );
Le handle attendu est celui qui a été traité, au préalable, par notre fonction NtCreatePort. Enfin, l'argument ConnectionRequest n'est pas une structure LPC_MESSAGE, mais plutôt une structure PORT_MESSAGE. Cet argument représente une en-tête de message LPC qui sera initialisée par le serveur.
La fonction bloque jusqu'à la connexion d'un client. Ensuite, lorsqu'un client demande à se connecter, il faut accepter la connexion via NtAcceptConnectPort().
Prototype :
NTSYSAPI
NTSTATUS
NTAPI
NtAcceptConnectPort(
OUT PHANDLE ServerPortHandle,
IN HANDLE AlternativeReceivePortHandle OPTIONAL,
IN PLPC_MESSAGE ConnectionReply,
IN BOOLEAN AcceptConnection,
IN OUT PLPC_SECTION_OWNER_MEMORY ServerSharedMemory OPTIONAL,
OUT PLPC_SECTION_MEMORY ClientSharedMemory OPTIONAL );
NTSTATUS
NTAPI
NtAcceptConnectPort(
OUT PHANDLE ServerPortHandle,
IN HANDLE AlternativeReceivePortHandle OPTIONAL,
IN PLPC_MESSAGE ConnectionReply,
IN BOOLEAN AcceptConnection,
IN OUT PLPC_SECTION_OWNER_MEMORY ServerSharedMemory OPTIONAL,
OUT PLPC_SECTION_MEMORY ClientSharedMemory OPTIONAL );
Le premier argument correspond au handle du serveur que va créer la fonction. Ce handle sera différent de celui créé par le port, bien entendu. Ceux qui sont marqués en OPTIONAL, on s'en moque pas mal pour l'instant. Ceux qui sont intéressants sont ConnectionReply et AcceptConnection.
L'argument ConnectionReply - qui est une structure PORT_MESSAGE au lieu de LPC_MESSAGE (je marquerai dès à présent "PPORT_MESSAGE", donc...) - correspond à la structure qu'on a envoyée à NtListenPort(), également en tant que PORT_MESSAGE. Quant au booléen AcceptConnection, il indique simplement et clairement si on doit accepter la connexion côté client ou non (mais j'avoue ne pas trop savoir pourquoi un tel argument ; je me suis mal renseigné, une fois de plus).
La connexion est acceptée. Il faut la "finaliser" via NtCompleteConnectPort. C'est comme si le serveur dit au client : "Ok, tout est en règle, on peut commencer à bosser ensemble".
Prototype :
NTSYSAPI
NTSTATUS
NTAPI
NtCompleteConnectPort(
IN HANDLE PortHandle );
La fonction attend, en argument, l'handle du serveur. Tout simplement.
Le serveur peut enfin attendre de recevoir des données. Il va recevoir, respectivement, les en-têtes du message, une commande assignée par le client et le corps du message.
Voici la structure d'un message transféré entre clients et serveurs LPC :
typedef struct _TRANSFERRED_MESSAGE
{
PORT_MESSAGE Header; // En-tête
WCHAR MessageText[48]; // Le message lui-même
} TRANSFERRED_MESSAGE, *PTRANSFERRED_MESSAGE;
{
PORT_MESSAGE Header; // En-tête
WCHAR MessageText[48]; // Le message lui-même
} TRANSFERRED_MESSAGE, *PTRANSFERRED_MESSAGE;
Bien qu'on ne demande qu'en argument PORT_MESSAGE, les fonctions pourront tout de même écrire dans le champ MessageText puisqu'il suit. Inutile de préciser qu'ils respectent l'alignement des structures (taille alignée sur un multiple de 4).
J'ai trouvé cette structure dans un code relatif à un article que je mettrai en référence à la fin du blog.
Voici la structure PORT_MESSAGE :
typedef struct _PORT_MESSAGE
{
union
{
struct
{
USHORT DataLength; // Length of data following the header (bytes)
USHORT TotalLength; // Length of data + sizeof(PORT_MESSAGE)
} s1;
ULONG Length;
} u1;
union
{
struct
{
USHORT Type;
USHORT DataInfoOffset;
} s2;
ULONG ZeroInit;
} u2;
union
{
CLIENT_ID ClientId;
double DoNotUseThisField; // Force quadword alignment
};
ULONG MessageId; // Identifier of the particular message instance
union
{
ULONG_PTR ClientViewSize; // Size of section created by the sender (in bytes)
ULONG CallbackId; //
};
} PORT_MESSAGE, *PPORT_MESSAGE;
{
union
{
struct
{
USHORT DataLength; // Length of data following the header (bytes)
USHORT TotalLength; // Length of data + sizeof(PORT_MESSAGE)
} s1;
ULONG Length;
} u1;
union
{
struct
{
USHORT Type;
USHORT DataInfoOffset;
} s2;
ULONG ZeroInit;
} u2;
union
{
CLIENT_ID ClientId;
double DoNotUseThisField; // Force quadword alignment
};
ULONG MessageId; // Identifier of the particular message instance
union
{
ULONG_PTR ClientViewSize; // Size of section created by the sender (in bytes)
ULONG CallbackId; //
};
} PORT_MESSAGE, *PPORT_MESSAGE;
On remarque que le champ DataLength va permettre aux fonctions de chercher les données après le header.
Voici la fonction native qui va permettre la réception des données sur le port : NtReplyWaitReceivePort() :
NTSYSAPI
NTSTATUS
NTAPI
NtReplyWaitReceivePort(
IN HANDLE PortHandle,
OUT PHANDLE ReceivePortHandle OPTIONAL,
IN PLPC_MESSAGE Reply OPTIONAL,
OUT PLPC_MESSAGE IncomingRequest );
NTSTATUS
NTAPI
NtReplyWaitReceivePort(
IN HANDLE PortHandle,
OUT PHANDLE ReceivePortHandle OPTIONAL,
IN PLPC_MESSAGE Reply OPTIONAL,
OUT PLPC_MESSAGE IncomingRequest );
Les arguments marqués en OPTIONAL, comme d'habitude, on s'en moque. On voit, une fois de plus, que la fonction attend le handle du serveur en argument premier. Le dernier argument correspondra au message LPC qu'elle aura récupéré en kerneland ; celui envoyé par le noyau.
Lorsque le serveur en a fini avec le client, il doit fermer le handle du serveur sans détruire le handle retourné par NtCreatePort. Pour cela, il a recourt à la fonction suivante : NtClose() :
NTSTATUS NtClose(
HANDLE Handle
);
HANDLE Handle
);
La fonction attend simplement un handle à fermer. On soumettra le handle du serveur à fermer.
Côté Client
Un client, ça se connecte (sans déconner ?). On va utiliser la fonction NtConnectPort() pour se connecter. Son prototype est assez chargé, puisqu'il faut encore s'occuper des "security descriptor" et tout le bordel.
Prototype :
NTSYSAPI
NTSTATUS
NTAPI
NtConnectPort(
OUT PHANDLE ClientPortHandle,
IN PUNICODE_STRING ServerPortName,
IN PSECURITY_QUALITY_OF_SERVICE SecurityQos,
IN OUT PLPC_SECTION_OWNER_MEMORY ClientSharedMemory OPTIONAL,
OUT PLPC_SECTION_MEMORY ServerSharedMemory OPTIONAL,
OUT PULONG MaximumMessageLength OPTIONAL,
IN ConnectionInfo OPTIONAL,
IN PULONG ConnectionInfoLength OPTIONAL );
NTSTATUS
NTAPI
NtConnectPort(
OUT PHANDLE ClientPortHandle,
IN PUNICODE_STRING ServerPortName,
IN PSECURITY_QUALITY_OF_SERVICE SecurityQos,
IN OUT PLPC_SECTION_OWNER_MEMORY ClientSharedMemory OPTIONAL,
OUT PLPC_SECTION_MEMORY ServerSharedMemory OPTIONAL,
OUT PULONG MaximumMessageLength OPTIONAL,
IN ConnectionInfo OPTIONAL,
IN PULONG ConnectionInfoLength OPTIONAL );
On oublie les arguments optionnels. Le premier argument correspond au handle qui sera créé par la fonction. Le second argument correspond au nom du port auquel se connecter (doit être le même que celui créé par le serveur, logiquement). L'argument MaximumMessageLength correspond à la taille maximale que le serveur aura fixé. Pour le reste, j'évite de détailler, de peur de dire de la mouise.
Pour envoyer des données, le client utilisera NtRequestPort() :
NTSYSAPI
NTSTATUS
NTAPI
NtRequestPort(
IN HANDLE PortHandle,
IN PPORT_MESSAGE Request );
NTSTATUS
NTAPI
NtRequestPort(
IN HANDLE PortHandle,
IN PPORT_MESSAGE Request );
Pareil, on attend un handle et une en-tête LPC.
Remarquez, il peut aussi utiliser NtRequestWaitReplyPort() (comme printf, sauf qu'elle se sert de celle en Zw*) :
NTSYSAPI
NTSTATUS
NTAPI
NtRequestWaitReplyPort(
IN HANDLE PortHandle,
IN PLPC_MESSAGE Request,
OUT PLPC_MESSAGE IncomingReply );
NTSTATUS
NTAPI
NtRequestWaitReplyPort(
IN HANDLE PortHandle,
IN PLPC_MESSAGE Request,
OUT PLPC_MESSAGE IncomingReply );
L'argument supplémentaire contiendra la réponse du serveur.
On code
Pourquoi ne pas passer à la pratique, après un brin de théorie ? Certes, je ne vais pas vous apprendre à faire de "privilege escalation" (j'ai lu qu'on pouvait faire ça via les messages LPC). J'ai déjà assez bataillé pour faire marcher mes codes.
L'affichage du blog étant pourri, je me résignerai à mettre des urls pour que vous puissiez accéder aux sources. Tout est disponible ici.
Conclusion
Le domaine des messages LPC est très vaste ; un domaine que je ne maîtrise pas mais qui m'intéresse, ce pourquoi j'en parle.
Je remercie Ivanlef0u pour avoir usé de son temps afin de me sauver la mise ; sans lui, rien de tout cela ne fonctionnerait. Merci également à 0vercl0k (je le cite au moins une fois par article, c'est fou) pour m'avoir boosté.
Références :
- http://www.zezula.net/en/prog/lpc.html : communications LCP ;
- Local Procedure Call - Wikipédia
A très bientôt.
Geo
... comprendre le fonctionnement de la fonction printf() ...
RépondreSupprimerYa de la Martin la dessous ou jme gourre?