Eric Vachon
Roxanne Kouassi
Supervisé par Pr. Dr. Kamel Adi
Université du Québec en Outaouais
Avant les années 80, les ordinateurs étaient utilisés par des
scientifiques pour effectuer les calculs complexes et longs, ce
qui leur sauvait beaucoup de temps. Cependant la situation a
changée lorsque les ordinateurs personnels ont fait leur entrée
dans les résidences où les utilisateurs n'étaient pas
nécessairement formés pour l'utilisation de ceux-ci. C'est alors
que des programmes n'offrant pas les résultats escomptés font leur
apparition. Ces logiciels sont des codes malicieux, conçus par des
programmeurs voulant démontrer qu'ils peuvent dominer les
ordinateurs de différentes manières.
Ces codes peuvent prendre plusieurs formes et sont plus souvent
une nuisance qu'autre chose. Lors des débuts des ordinateurs
personnels, les codes malicieux ont besoin d'une grande
intervention humaine pour se reproduire. L'utilisateur doit
envoyer les codes sur des dépôts de donnés (FTP), sur des
babillards (BBS), avoir une disquette infectée et l'amener sur un
autre ordinateur, etc. La propagation se fait donc très lentement
et les concepteurs de logiciels de protection contre ces codes
malicieux ont amplement le temps de trouver et de distribuer une
solution contre ceux-ci avant qu'ils n'atteignent plusieurs
ordinateurs.
Aujourd'hui l'Internet à changé ce paysage. Les codes malicieux
peuvent se propager à une très grande vitesse. L'utilisateur n'est
parfois impliqué que dans quelques cliques de la souris pour
envoyer un courriel ou visiter une page web. Parfois il n'a même
pas à lever le petit doigt, le seul fait d'être branché sur le
réseau mondial le rend vulnérable aux codes malicieux. La seule
limite imposée à ceux-ci est la vitesse des réseaux et de la
puissance de calculs des différents ordinateurs. Les concepteurs
de logiciels de protections doivent agir très rapidement afin
d'éviter l'infection de millions d'ordinateurs lorsqu'une nouvelle
menace voit le jour. Nous voyons rapidement que la réaction n'est
plus la méthode à utiliser, nous devons trouver un autre moyen de
défense.
Une définition large des codes malicieux est qu'ils englobent tous
les logiciels ayant des actions négatives sur le système. Si nous
prenons la totalité des logiciels conçus, nous pouvons voir que la
majorité de ceux-ci n'ont pas été conçus dans l'intention de
causer des problèmes aux systèmes. Nous pouvons retrouver des
logiciels qui fonctionnent correctement et accomplissent leurs
tâches sans jamais nuire au système. Toutefois, certains d'entre
eux contiennent des bogues qui pourraient peut-être causer des
dommages, sans que ceux-ci ne soient intentionnels, ils sont tout
de même considérés comme des codes malicieux, cependant ce ne sont
pas les pires.
Une certaine partie des logiciels sont conçus dans l'intention
d'accomplir une tâche malicieuse. C'est donc ceux-ci que nous
allons considérer comme étant les véritables codes malicieux. Afin
de détecter ces logiciels indésirables, nous devons définir une
propriété mesurable avec laquelle nous pourrons les identifier et
mettre un terme à leurs actions négatives.
De tous les codes malicieux, les virus sont ceux qui détiennent la
propriété la plus précisément définie et mesurable. Par contre si
l'on prend les chevaux de Troie, nous pouvons difficilement
trouver une propriété qui nous permet de définir avec exactitude
si le code est malicieux ou non. C'est pourquoi nous devons faire
une distinction entre les différentes catégories de codes
malicieux et trouver la propriété qui définit exactement chacune
des catégories. Il se peut que plus d'une propriété définisse ces
différentes catégories, par exemple lorsqu'une catégorie comporte
des sous classes. [15]
Les virus affectant les fichiers et l'amorce du système posent
encore un sérieux problème, cependant ces virus ont été remplacés
par ces codes beaucoup plus complexes. Ces nouveaux codes évitent
les technologies de balayage en changeant leurs signatures à
chaque infection. Ils peuvent cacher leurs actions malicieuses en
faisant leurs manipulations sur le système lors de conditions très
spécifiques et en espaçant le plus possible ces actions. Certains
d'entre eux peuvent même infecter les anti-virus en utilisant les
techniques de mises à jours de celui-ci ou en le désactivant tout
simplement.
Autrefois, les entreprises croyaient que les virus ne pouvaient
pas infecter les fichiers n'étant pas exécutables et plusieurs
compagnie d'anti-virus ne balayaient même pas ces fichiers. En
1995, l'apparition des virus sous forme de macro a changé cette
mentalité. Les vers ont connus une vitesse de propagation
surprenante grâce à ces macros qui permettent d'écrire du code
dans un langage plus facile à comprendre que les langages comme
assembleur ou c++ dans lesquels les anciens virus étaient écrits.
Le nombre de codes malicieux présents dans la communauté Internet
est très difficile à évaluer, les variantes de codes déjà présents
font en sorte que des centaines de nouveaux codes malicieux
apparaissent chaque jour. Les concepts, les attaques qui ont étés
démontrés, mais qui n'ont pas été relâchés dans la communauté,
poussent le nombre de codes malicieux encore plus haut.
La création de codes malicieux n'est plus réservée à ceux ayant
une connaissance technique très approfondie des systèmes
d'exploitation. Il existe maintenant des modules de constructions
qui permettent aux novices de produire des virus et des vers très
facilement en suivant les indications à l'écran et en choisissant
les effets désirés. Un enfant pourrait utiliser «
[K]alamar's VBS Worm Generator 1.5 » pour créer un vers. En
effet, l'auteur (OnTheFly) du vers VBS.SST@mm, aussi connus
sous le nom de Anna Kournikova, s'est servis d'un de ces
générateurs pour infecter des centaines de milliers d'ordinateurs
à travers le monde. Ces générateurs ne sont pas nouveaux, «
The Mutation Engine (MtE) », « Virus Creation
Laboratory (VCL) » et « Phalcon/Skism Mass-Produced Code
Generator » ont été conçus au début des années 90s. Il existe
maintenant des centaines de générateurs sur
Internet. [8]
Il est aussi intéressant de noter que les virus peuvent se
combiner pour créer une autre sorte de virus qui peut lui aussi se
répandre sur Internet. Sophos a identifié un incident où deux
virus (WM97/Class-D et WM97/FF-A) ont interagi entre
eux sur un système pour créer un nouveau virus (WM97/FF-H).
Cet incident nous montre que les codes malicieux peuvent
s'assembler et ainsi créer de nouvelles souches qui ne peuvent
être détectées par les anti-virus de balayage
traditionnels. [14]
En plus de l'attaque des « vrais » codes malicieux, les attrapes
augmentent le temps nécessaire aux techniciens pour répondre aux
usagers ayant été victime d'une « attaque » par ces codes
malicieux inexistants. Même les systèmes sont touchés par les
attrapes, les serveurs de courriels sont submergés par les chaînes
de lettres que les utilisateurs s'envoient croyant avertir leur
prochain d'une attaque probable. Les utilisateurs deviennent alors
moins alertes à ces avertissements et baissent ainsi leurs
défenses envers des attaques véritables.
Les pertes financières dues aux codes malicieux sont estimées à
des milliards de dollars par années aux États-Unis seulement.
Wired News indique qu'une recherche du Computer Economics que
Nimda a fait perdre US$635 millions en coûts de réparation
et en pertes de productivité. La somme totale pour les différentes
versions de Code Red sont de US$2.62 milliards,
SirCam est allé chercher US$1.15 milliards à lui seul et
finalement Love Bug a coûté US$8.75 milliards pour
l'éliminer. Ces chiffres proviennent des grandes entreprises et ne
comptent pas les pertes subies par les petites entreprises et les
usagers d'ordinateurs personnels. [1]
Il n'existe pas d'estimés pour quantifier les pertes secondaires
dûes à une attaque majeure de code malicieux. Les coûts associés à
la perte de donnés confidentielles résultant de l'activité d'un
cheval de Troie sont variés : le temps de fermeture d'un réseau,
la vérification des systèmes n'étant pas infectés, le stress subis
par les utilisateurs et les techniciens du système, les
interruptions dans les affaires de la compagnie, la détérioration
de la valeur des actions, les relations publiques et la liste
continue longtemps.
Il est clair que les codes malicieux sont d'une importance cruciale et que le coût engendré par la perte de productivité et d'informations n'est pas à négliger dans l'effort à fournir pour combattre ce fléau.
Notre objectif principal est de créer un prototype d'anti-virus
basé sur le comportement. Il consiste dans le développement d'un
module de surveillance des ressources critiques du système. Dans
un premier temps, nous allons nous focaliser sur la surveillance
des appels systèmes : le module devra être capable d'intercepter
tous les appels systèmes et de les afficher dans une fenêtre
Windows. Une fois ce module conçu, nous devrons concevoir un
évaluateur de traces se basant sur un langage pouvant exprimer les
comportements malicieux. Lorsqu'une règle est satisfaite, nous
serons en présence d'un code pouvant potentiellement nuire au
système.
Pour la surveillance des appels systèmes, un pilote devra être
conçu pour se placer entre la couche noyau du système
d'exploitation et la couche application de manière à ce que tous
les appels systèmes lancés soient interceptés, enregistrés et
ensuite redirigés vers l'appel système noyau correspondant. Ainsi,
il sera possible d'exécuter une procédure à chaque appel système
avant que le noyau effectue l'opération demandée. Cette procédure
dans notre cas serait l'évaluation de la trace du processus
appelant. Cette information pourra aussi être enregistrée dans un
fichier texte afin de garder une trace de ce qui se
produit.
Le module de surveillance devra s'exécuter sous le système
d'exploitation Windows XP. Bien qu'une version sous Windows 9x ou
Linux est envisageable, la plus grande difficulté est de produire
une version pour le code propriétaire et très secret de Windows
NT. Une recherche approfondie du fonctionnement des API et de la
manière d'installer un pilote de surveillance sera nécessaire pour
le développement de notre application.
Pour ce qui est de l'évaluation des traces d'appels systèmes, nous
devrons concevoir un automate à partir d'une formule spécifiée par
l'utilisateur. Il sera donc très important de bien maîtriser ce
langage d'interprétation. Nous passerons donc les appels systèmes
dans cet automate et l'état de celui-ci nous indiquera si une
trace est malicieuse ou non.
Nous utiliserons les technologies de Microsoft pour le développement de notre application, soit Microsoft Visual Studio .NET pour la section en mode utilisateur et Microsoft Driver Development Kit for Windows XP pour la section en mode noyau. Les rapports seront développés à l'aide de LATEX.
Ce type de logiciel fonctionne à l'aide de la signature des codes
malicieux. Pour simplifier l'explication, considérons qu'un code
malicieux infecte les programmes hôtes avec une copie exacte ou
presque exacte d'eux-mêmes. Nous ne prenons pas en compte les
virus polymorphes qui changent leur code à chaque
infection.
Supposons que nous voulons déterminer si un logiciel hôte est
infecté par un virus . La manière la plus simple serait de
balayer le code machine de pour trouver une suite de bytes qui
correspondent à . Cependant il existe plusieurs problèmes à
cette approche. Les virus typiques sont d'une taille de quelques
milliers d'octets et l'existence de plusieurs milliers de codes
malicieux ferait en sorte que la quantité de mémoire nécessaire
pour contenir tout les codes serait de quelques mégaoctets. De
plus, il serait dangereux pour les logiciels d'anti-virus de
contenir une grande librairie de codes malicieux connus. Les
concepteurs de ceux-ci seraient doublement reconnaissants s'ils
pouvaient avoir une collection de codes malicieux si facilement
disponible.
Au lieu d'avoir besoin d'une correspondance parfaite, les
concepteurs d'anti-virus par balayage n'utilisent seulement qu'une
petite partie du code malicieux pour l'identification. Ces petits
fragments appelés signatures sont beaucoup plus facile à
utiliser, entreposer et surtout ne révèlent rien d'utile aux
concepteurs de codes malicieux. Un autre avantage d'utiliser des
signatures est que même lorsque d'autres parties du code changent,
l'anti-virus peut toujours les détecter.
Pour ce qui est des codes malicieux polymorphes, il existe deux
sortes de changements possibles. Premièrement ces derniers peuvent
être modifiés par des personnes qui veulent produire une nouvelle
souche sans prendre le temps de créer un nouveau code malicieux.
Ces nouvelles souches seront détectées si elles contiennent
toujours la séquence d'octets utilisée dans la signature. La
deuxième source de mutation est faite à l'aide de la
programmation. Une petite minorité de codes malicieux sont faits
pour changer de forme lorsqu'ils copient leur code dans une autre
source. Cette procédure est normalement faite à l'aide d'une
routine au début du code pour encrypter le corps du code
malicieux. Une signature peut donc être trouvée en début de
fichier. Par contre les nouveaux virus combattent cette technique
en utilisant un très grand éventail d'entêtes de décryptions. Des
algorithmes plus complexes doivent alors être utilisés pour
détecter ces derniers.
Le balayage par signature comporte certains désavantages. Premièrement les signatures doivent être les plus courtes possibles pour capturer le plus grand nombre de changements possibles dans le corps du code malicieux. Ces séquences de codes sont donc beaucoup plus susceptibles de contenir des fragments utilisés légitimement par d'autres programmes. Surtout si le code malicieux à été fait à l'aide d'un langage de haut niveau, les routines utilisées se retrouvent fort probablement dans d'autres logiciels. Il en résulte alors de fausses alertes que l'utilisateur doit régler par lui-même. De plus, le temps que prend une compagnie d'anti-virus pour recevoir un nouveau code malicieux, en analyser son contenu, déterminer une signature et envoyer la mise à jour à tous les utilisateurs de leur logiciel prend souvent quelques heures. Il est toutefois connu que c'est dans les premières heures d'une attaque que le plus de dommages sont subis par les compagnies et les utilisateurs. Ces dommages se chiffrent parfois dans les milliards de dollars. [7]
Ce type d'anti-virus fonctionne en calculant la somme CRC des
fichiers et des secteurs du système. Ces sommes sont sauvegardées
dans la base de donnés du logiciel avec d'autres informations
comme la taille des fichiers, la date de la dernière modification,
etc. Lorsque le balayeur recommence sont processus, il compare sa
base de donnés avec l'information qu'il calcule. Lorsqu'une entrée
de la base de donnés et l'information perçue sur un fichier
diffèrent, le logiciel avertit l'utilisateur d'une modification
sur le fichier ou bien il indique qu'un code malicieux s'est
infiltré.
Les balayeurs de CRC qui utilisent des algorithmes contre les camouflages de fichiers sont des outils très puissants contre les codes malicieux. Près de 100% de ces derniers sont détectés presque immédiatement après leur infiltration dans l'ordinateur. Cependant ce type d'anti-virus a un désavantage qui diminue de manière significative leur efficacité. Ils ne peuvent attraper un virus immédiatement après son entrée dans le système, mais seulement après un certain temps, lorsque le virus s'est propagé à travers l'ordinateur. Ils ne peuvent donc non plus détecter les virus sur les nouveaux fichiers qui viennent d'arriver par courriel, sur disquette, d'archives, etc. parce que leur base de donnés ne contient pas d'entrées sur ces fichiers. Donc il existe de nouveaux virus qui vont exploiter cette faiblesse et infecter que les nouveaux fichiers, ce qui les rend invisibles à ces anti-virus. [6]
Les immunisations sont divisées en deux types, ceux qui
avertissent qu'une infection a eu lieu et ceux qui bloquent les
tentatives de certains codes malicieux d'infiltrer le système. Les
logiciels du premier type s'attachent à la fin des fichiers comme
le font certains virus et à chaque lancement de ce fichier, ils
vérifient s'il y a eu un changement dans le corps de ce dernier.
Ils n'ont qu'une seule faille, c'est qu'ils ne peuvent pas
détecter les infections faites par les codes malicieux utilisant
des techniques de camouflages. C'est pour cette raison que les
immunisations ne sont presque pas utilisées.
Les logiciels du deuxième type protègent le système de certaines
infections. Les fichiers du disque sont modifiés d'une certaine
manière à ce que le code malicieux les considèrent comme déjà
infectés. Comme par exemple la ligne « MsDos » qui protège les
fichiers contre l'ancien virus Jérusalem. Pour ce qui est
des codes malicieux résidents en mémoire, un petit logiciel est
installé dans la mémoire et lorsque le code malicieux le
rencontre, il croit que le système est déjà infecté et il termine
son exécution.
Les immunisations de ce type ne sont pas versatiles, il est impossible d'immuniser les fichiers contre tous les codes malicieux existants. Certains considèrent le fichier comme infecté lorsque le temps de la dernière modification contient « 62 » dans les secondes, tandis que d'autres serait « 60 ». Malgré cette faiblesse, les immunisations peuvent être utilisées pour protéger les fichiers d'un nouveau code malicieux jusqu'au moment où il devient détectable par d'autres types d'anti-virus. [6]
L'architecture de notre prototype d'anti-virus basé sur la détection de comportements malicieux est la suivante :
Nous allons voir le fonctionnement interne des différentes parties de notre prototype dans les sections suivantes. L'installation du pilote qui est une procédure critique de notre application sera analysée en premier. Elle sera suivie de la modification de la table des appels systèmes de Windows. Cette procédure est complexe et très peu documentée, nous devons comprendre que Microsoft n'accepte pas que l'on modifie leur produit et qu'ils font tout en leur pouvoir pour rendre cette tâche difficile. La communication entre le pilote et l'application sera ensuite exposée. Il est important de comprendre comment une section en mode noyau peut transmettre de l'information vers une application de la manière la plus efficace possible. La création d'une trace des appels systèmes ne serait pas utile si on ne l'analyse pas par la suite. La logique linéaire temporelle nous permet de définir les comportements malicieux et nous l'utilisons pour créer notre évaluateur de trace d'appel système. Finalement l'interface utilisateur sera abordée, nous avons opté pour une interface conçue à l'aide des Microsoft Foundation Class afin d'avoir une plus grande liberté dans la conception de celle-ci.
Afin d'effectuer les interceptions d'appel système au niveau le
plus bas possible, nous avons conçu un pilote qui se situe en mode
noyau qui pourra faire le traitement nécessaire pour créer une
trace unique de chaque processus en exécution dans le
système.
La création d'un pilote est une tâche à la fois très complexe et
délicate. La plus petite erreur précipite le système dans un état
qui est plus souvent qu'autrement irrécupérable, ce qui nécessite
un redémarrage (qui se fait d'ailleurs par lui-même dans 70% des
cas). Il est donc important de bien maîtriser les concepts qui
sous-tendent l'installation du pilote en mode noyau et les
commandes qui nous sont disponibles dans cette couche du
système.
Premièrement il faut savoir que les pilotes pour Windows NT
doivent être conçus en C et qu'ils nécessitent une trousse
d'outils provenant de Microsoft, le Driver Development Kit (DDK).
Lorsque l'installation de ces outils est terminée, l'environnement
nécessaire à la création des pilotes pour un environnement très
spécifique (dans notre cas Windows XP) est présent.
La compilation requiert certains fichiers particuliers tel le
makefile et le sources. Le fichier makefile
contient les instructions d'environnement pour compiler le pilote,
une seule ligne est nécessaire pour les pilotes simples :
. Le fichier
sources, par contre, contient les informations spécifiques
pour la création du pilote : le nom, son type, les différents
fichiers sources (sources, entêtes, ressources, etc.) et les
instructions pour les codes spéciaux comme les caractères UNICODE
par exemple.
Pour ce qui est du corps du pilote, nous pouvons le comparer à une
librairie de liens dynamiques (DLL). Le pilote possède un point
d'entrée qui ressemble au « DllMain » qui se nomme « DriverEntry
». Dans le cas des pilotes, la fonction « DriverEntry » est
appelée une seule fois lorsque le système d'exploitation charge le
pilote. Contrairement au « DriverEntry », le « DllMain » peut être
appelé de nombreuses fois, tout dépendamment combien
d'applications utilisent le DLL.
Un DLL définit ses interfaces qu'il exporte à travers un fichier
librairie (.LIB) qu'une application peut utiliser pour faire le
lien avec la librairie. Un pilote utilise un ensemble de points
d'entrées standard qu'il définit dans la fonction « DriverEntry »
en indiquant quelle procédure répond à quel appel. Il indique
ceux-ci à travers une structure DRIVER_OBJECT qui a été crée par
le système d'exploitation. Le système d'exploitation peut alors
vérifier cette structure pour récupérer un pointeur vers la
fonction approprié lorsqu'une requête est dirigée vers le pilote.
Ces fonctions représentent soit le matériel physique ou virtuel
que le pilote supporte.
Un DRIVER_OBJECT est un bloc de mémoire alloué et en partie initialisé par le système d'exploitation. Cet objet décrit à quel endroit le code du pilote est situé en mémoire, le nom du pilote et des pointeurs vers l'adresse des fonctions (Function Address Pointers) que le pilote doit remplir pour indiquer quelles tâches il supporte. Une des fonctions qui doit être remplis dans cette table est appelée la table d'envoie des appels (Function Dispatch Table). Cette table contient une entrée pour chaque code de fonction majeure (Major Function Code) que le système d'exploitation supporte. Il existe présentement 28 fonctions que le pilote peut décider de supporter, cependant la majorité des pilotes n'en supporte que huit ; IRP_MJ_CREATE, IRP_MJ_CLOSE, IRP_MJ_READ, IRP_MJ_WRITE, IRP_MJ_PNP, IRP_MJ_POWER, IRP_MJ_DEVICE_CONTROL, et IRP_MJ_SYSTEM_CONTROL. Lorsque le pilote reçoit cette table, toutes les entrées contiennent un pointeur vers une routine qui indique que cette fonction majeure n'est pas supportée. Donc chaque fois que l'utilisateur fait une requête au système d'exploitation pour qu'il performe une action spécifique au pilote, celui-ci détermine quelle code de fonction majeure correspond à cette action et envoie cette requête au pilote que l'utilisateur à spécifié. Dans notre cas, nous n'avons supporté que les fonctions IRP_MJ_CREATE, IRP_MJ_CLOSE et IRP_MJ_SYSTEM_CONTROL, car nous n'avons pas réellement du matériel à contrôler. Notre objectif étant d'être en mode noyau pour obtenir la liberté de modifier la structure interne du système d'exploitation, nous n'avions pas besoin d'en faire plus. [12]
Afin de pouvoir faire une surveillance des accès aux fichiers ou
bien à la base de registre au niveau le plus bas possible, il est
nécessaire d'intercepter ces appels aux services du système. Il
est avantageux pour nous d'intercepter ces appels en étant dans le
noyau, et ce, pour un meilleur contrôle de la sécurité de notre
processus et de sa confidentialité.
Chaque service du système est désigné par un identificateur unique qui est utilisé pour trouver la fonction pouvant faire l'action demandée. Ces identificateurs sont codés sur des entiers de 32 bits définis de la manière suivante (du bit ayant le poids le plus fort jusqu'au poids le plus faible.) :
Les tables de services sont utilisées pour grouper les fonctionnalités qui ont un liens entre elles. Les tables suivantes sont présentes dans Windows 2000 et XP :
Ces tables sont sauvegardés dans la table de services du système
(System Service Table) KeServiceDescriptorTable qui est déclaré
comme suit :
Chaque SDT contient les informations à propos des services de son
groupe. Ils contiennent quatre éléments, les pointeurs vers les
fonctions qui effectuent l'action demandée par l'utilisateur, un
pointeurs vers des compteurs, le nombre de services disponibles et
un pointeur vers une table qui contient le nombre de bytes de
paramètres requis pour chaque fonction. Cette structure est codée
de la manière suivante :
#pragma pack(push, 1)
typedef struct {
PVOID *rgpfnHandlerTable;
PULONG rgulCounterTable;
ULONG cServices;
PUCHAR rguchParamTable;
} sst; #pragma pack(pop, 1)
L'interception des appels systèmes en niveau noyau nécessite la
localisation de l'information à propos du service que l'on désire
intercepter et modifier l'information sur ce service pour appeler
une de nos fonctions. Un pointeur vers la fonction originale est
sauvegardé, ce qui permet à notre fonction d'appeler l'originale
pour que le travail soit effectué. Nous devons donc trouver
l'identificateur du service que l'on veut intercepter. Lorsque
nous utilisons une procédure de désassemblage sur une fonction
exécutant un appel système, nous constatons que chacun commence
avec l'instruction MOV EAX, identificateur_de_service.
Donc l'information hexadécimale sauvegardée à chaque début de
fonction système est B8 xx xx xx xx. L'instruction B8 correspond à
l'instruction i386 MOV EAX, imm32 opcode et le 32 bits
suivant sont l'identificateur de service. Nous pouvons alors
récupérer l'identificateur avec l'expression suivante :
ulService = *(PULONG)((PUCHAR)pbHandler + 1);
Sous le système d'exploitation Windows XP, nous rencontrons une
difficulté supplémentaire. Pour décourager les programmeurs
d'utiliser ces interfaces non documentées, Microsoft à mis une
protection en écriture sur cette table de services. Lorsqu'un
logiciel essaie d'écrire dans cette table, le système arrête et
donne un écran bleu d'erreur critique. À moins que l'on enlève la
protection en écriture avant de lancer notre fonction. La méthode
la plus simple pour désactiver la protection est de mettre le WP
bit à zéro dans le registre CR0. Une autre méthode serait de
changer les pointeurs vers les pages utilisées en mémoire pour la
table de services afin que notre procédure puisse écrire sur ces
pages et ensuite restaurer ces pointeurs. Cependant, nous
utiliserons plutôt la procédure suivante :
MOV EAX, CR0
AND EAX, NOT 10000H
MOV CR0, EAX
; code pour la modification de la table
MOV EAX, CR0
OR EAX, 10000H
MOV CR0, EAX
Il est important de bien comprendre que ces méthodes utilisent des interfaces noyau qui ne sont pas documentées. Il est possible qu'elles cessent de fonctionner dans des versions plus récentes de Windows. Microsoft a démontré plus d'une fois qu'il n'aime pas que l'on utilise ces interfaces et feront probablement d'autres changements dans les versions ultérieurs de Windows pour que les fonctions utilisant ces interfaces cessent de fonctionner. [10] [9] [3] [11]
Les codes de contrôles d'entrées/sorties (IOTCL) sont utilisés
pour la communication entre une application en mode utilisateur et
un pilote ou bien pour communiquer entre les pilotes à l'aide
d'une pile. Ces codes de contrôles sont envoyés avec l'aide des
IRPs (fonctions majeures). L'application utilisateur envoie son
IOCTL au pilote en appelant la fonction DeviceIoControl,
qui est décrite dans la documentation SDK de Microsoft. Un appel à
cette fonction pousse le contrôleur d'entrées/sorties à appeler
une requête IRP_MJ_DEVICE_CONTROL et l'envoie au pilote visé.
De plus, les pilotes peuvent eux-mêmes envoyer des IOCTLs vers des
pilotes de plus bas niveau (plus près de la machine) en créant et
en envoyant une requête IRP_MJ_DEVICE_CONTROL ou
IRP_MJ_INTERNAL_DEVICE_CONTROL. Les pilotes peuvent envoyer
ces requêtes en invoquant les fonctions
DispatchDeviceControl et
DispatchInternalDeviceControl. (Les applications
utilisateurs ne peuvent pas envoyer une requête de contrôle
interne.)
Quelques IOTCLs sont publics et d'autres sont privés. Ceux publics
sont normalement définis par le système et sont documentés par
Microsoft, soit dans la DDK ou bien dans la SDK. Ils peuvent être
envoyés du mode usagé à partir de la fonction
DeviceIoControl ou bien d'un pilote à l'autre. Les privés
d'un autre coté sont fait exclusivement par les concepteurs de
logiciels pour communiquer entre eux. Les IOTCLs privés sont
documentés dans un fichier entête du concepteur et ne sont
normalement pas documentés publiquement. Ils peuvent, comme les
IOTCLs publics, être envoyés à l'aide de la commande
DeviceIoControl ou bien d'un pilote à l'autre. Il n'existe
donc pas de différence entre les deux IOTCLs d'un point de vue
utilisateur, cependant la différence se retrouve dans le code
interne qui peut être utilisé dans ceux privé comparé à ceux
publics. Si les IOTCLs fournis par Microsoft ne satisfont pas les
besoins du concepteur, il peut donc en créer lui-même. C'est ce
que nous avons fait pour notre prototype.
[5]
Il est important de bien comprendre les règles suivantes lorsque l'on fait de nouveaux IOTCLs :
Un code de contrôle d'entrées/sorties est codé sur 32 bits et
comprend plusieurs champs. La figure 1 montre le
corps de celui-ci.
Pour la création de nouveaux codes de contrôle d'entrées/sorties,
nous pouvons utiliser le macro CTL_CODE qui est fournis avec
les entêtes wdm.h et ntddk.h. La définition de ces
nouveaux codes, qu'ils soit conçus pour une utilisation avec
IRP_MJ_DEVICE_CONTROL ou bien
IRP_MJ_INTERNAL_DEVICE_CONTROL doivent utiliser la
commande suivante : #define IOCTL_Device_Function
CTL_CODE(DeviceType, FunctionCode, TransferType,
RequiredAccess) Il est important de choisir des noms descriptifs
pour la constante qui est ainsi définie, Device doit être
le nom du pilote qui utilise le code et Function est
l'opération effectuée. De cette manière le code source d'une
application sera beaucoup plus intuitif. Nous devons fournir les
paramètres suivants au macro CTL_CODE :
Les codes de contrôles sont contenus dans les requêtes IRP_MJ_DEVICE_CONTROL et IRP_MJ_INTERNAL_DEVICE_CONTROL. Le contrôleur d'entrées/sorties crée ces requêtes lorsque les fonctions DeviceIoControl et IoBuildDeviceIoControlRequest sont appelées. Ces deux fonctions acceptent comme paramètre un tampon d'entrée et un tampon de sortie. Donc, toutes les requêtes lancées vont fournir un tampon d'entrée et un tampon de sortie. La manière qu'il sont traités par le système dépend du type de transfert définis par le IOTCL. Nous pouvons donc utiliser les tampons de la manière suivante, dépendamment quel type de transfert à été définis :
Dans la structure interne de notre application, nous avons deux tampons qui définissent chacun une trace. Nous plaçons donc l'information de chaque appel système dans une des traces en prenant soin d'indiquer le processus appelant et le temps de l'appel (nous pourrions placer les informations supplémentaires concernant chaque appel, mais ce n'est pas dans les objectifs de notre prototype). Un évènement est créé pour avertir l'application lorsque l'un des tampons interne du pilote est plein. L'application peut donc utiliser un thread séparé pour faire une requête à une intervalle régulière ou bien lorsqu'un tampon interne est plein, afin de récupérer la trace des appels systèmes. Cette trace est alors soit filtrée par processus et passée à l'évaluateur de modèle ou bien elle est tout simplement affichée à l'écran.
Nous allons tout d'abord définir et expliquer les bases nécessaires pour implanter l'évaluateur de modèle. Ces bases sont : la logique linéaire temporelle (LTL), l'automate de Büchi et la méthode de conversion de la formule LTL en un automate de Büchi. Ensuite nous expliquerons la méthode d'évaluation de modèle.
Soit une proposition atomique, l'ensemble des formules LTL est défini par :
::= |
Les opérateurs booléens habituels : la conjonction (), l'implication () et l'équivalence ( ) ainsi que les formules true et false se définissent comme suit :
Quant aux opérateurs temporels, leur signification respective est la suivante :
Dans notre application :
Ainsi, la logique linéaire temporelle sera utilisée pour formuler le comportement non désiré du système, ensuite la formule sera convertie en un automate de Büchi, de même que le modèle du système à vérifier.
Le but est de construire un automate qui génère toutes les séquences d'exécution satisfaisant une formule dans la logique temporelle donnée. [2] L'automate qui sera construit est appelé automate de Büchi généralisé car il comporte plusieurs ensembles d'états finaux contrairement à l'automate de Büchi simple qui n'en comporte qu'un seul. Un automate de Büchi généralisé est la donnée d'un quadruplet , où :
Une exécution de est une séquence infinie
telle que et, pour chaque ,
. Une exécution acceptée
est une exécution tel que pour chaque ensemble d'états finaux, il
existe au moins un état appartenant à cette ensemble qui
apparaît infiniment souvent dans .
Il faut à présent définir l'alphabet de cet automate :
Soit , un domaine fini ou l'alphabet et la fonction associant à chaque état un ensemble d'éléments de cet alphabet. Le mot est accepté par l'automate si et seulement si il existe une exécution acceptée tel que pour chaque .
L'automate de Büchi sera représenté par un graphe et la structure de chaque noeud est la suivante :
L'algorithme [2], pour convertir une formule
dans la logique LTL en un automate de Büchi, commence
avec un noeud unique. Au départ, le champ m_new ne
contient que la seule formule à convertir et tous les
autres ensembles sont vides. Avec le nud courant ,
l'algorithme vérifie s'il reste des formules non traitées dans
l'ensemble m_new. Si c'est le cas, le nud est étendu
et une formule est retirée de l'ensemble m_new.
Dans le cas où est une proposition ou la négation d'une
proposition et que est dans l'ensemble m_old,
donc le nud est supprimé, sinon il est rajouté à
m_atomic. Si n'est pas un littéral, le nud
peut être divisé ou non et de nouvelles formules peuvent être
rajoutées aux ensembles m_new et m_old.
Dépendamment de on aura :
Lorsque toutes les formules étant dans l'ensemble m_new du noeud ont été traitées, il faut ensuite vérifier si ce noeud sera ajouté au graphe. S'il existe déjà un nud dans le graphe qui a les mêmes contenus pour les ensembles m_next et m_old, ce noeud doit être mis à jour en fusionnant son ensemble m_incoming à celui du nud . Cependant si le nud est un nouveau nud, il est rajouté tout simplement au graphe et un autre nud est créé de la manière suivante :
Comme il est dit plus haut, l'évaluation de modèle sert à faire de la vérification de système afin de voir si le système n'a pas un comportement indésirable. Ainsi, supposons que l'on veuille vérifier que le propriété est toujours vérifiée par le système. Les étapes à suivre sont les suivantes :
L'algorithme le plus souvent utilisé pour implanter l'évaluation
de modèle est le « Nested Depth-First Search », autrement
dit la recherche en profondeur itérative. [4]
Pour améliorer les performances de cet algorithme et réduire
considérablement l'espace d'états générés par le produit
synchrone, la construction de ce produit peut être fait «
sur demande ». Cela veut dire qu'un nouveau noeud est
considéré dans le graphe de la formule que si aucun cycle
acceptant n'a été trouvé. Ainsi il est possible de trouver un
résultat sans avoir à construire tout le graphe de la formule et,
donc par la même occasion tout le graphe du
produit. [2]
L'application en mode utilisateur contient un thread qui, lorsque
démarr/, communique avec le pilote pour obtenir l'information sur
les interceptions de processus. Ce thread est activé lorsque
l'utilisateur lance la fonction démarrer les interceptions d'appel
système. À ce moment, il envoie un message au pilote pour qu'il
commence à intercepter les appels systèmes et remplisse ses
tampons. Le thread se met en attente d'un événement de tampon
plein, mais avec un délai d'attente d'une centaine de
millisecondes. Lorsque l'un des deux arrive à échéance, il va
chercher le contenu du tampon dans le pilote et place cette
information dans une trace interne à l'application.
Lorsque l'application reçoit les appels système, il n'a qu'un
numéro d'appel qu'il doit référer à l'appel système correspondant,
ce qui est fait à l'aide d'une information partagée entre les deux
applications. Les autres informations présentes sont le temps,
c'est-à-dire le nombre de tics qui ont eu lieu depuis le premier
janvier 1970 et l'identificateur de processus. Nous convertissons
donc le temps sous un format que l'utilisateur peut comprendre et
mettons cette information avec le numéro d'appel système.
L'utilisateur ne peut pas faire un lien direct entre
l'identificateur de processus et le nom de l'application. Nous
devons donc faire une recherche pour retrouver ce nom
d'application. La technique utilisée comprend plusieurs étapes.
Nous débutons par prendre une photo des processus présents en
mémoire et nous parcourons cette liste. Nous tentons ensuite de
trouver une combinaison entre l'identificateur du processus
appelant et l'image en mémoire. De cette manière nous avons toute
l'information nécessaire pour notre prototype. Nous plaçons le
tout dans une structure qui est contenue dans une liste chaînée.
Cette liste chaînée est notre trace, nous utilisons une classe de la librairie MFC qui nous permet de placer différents éléments et les référer lorsque nécessaire. Cette liste contient plusieurs outils que nous pourrons utilisés pour l'affichage et l'enregistrement de la dite trace.
Notre fenêtre principale contient des menus, des boutons et un
écran pour l'affichage des données. Nous avons fait en sorte que
l'affichage des données représente l'aspect de la liste chaînée,
nous utilisons un comptage des éléments à la gauche, suivit du nom
de l'image, du numéro de processus, du nom de l'appel système
lancé et du temps de l'appel. Il est à noter qu'ici on affiche le
nom de l'appel de haut niveau, tandis que l'appel intercepté était
de très bas niveau. Par exemple nous devrions avoir
ZwReadFile au lieu de ReadFile. Cependant nous avons
cru que pour simplifier la lecture de l'utilisateur, il serait
mieux d'utiliser des noms qu'il comprend. Cet affichage était très
simple à concevoir, car il est en lien direct avec la liste
chaînée utilisée, donc lorsque la liste est modifiée, l'affichage
change automatiquement.
Nous utilisons aussi des options d'affichage pour rendre la vue plus facile à l'utilisateur. Par exemple, nous avons une option qui empêche l'écran de dérouler automatiquement lorsqu'un nouvel appel système est enregistré, ce qui permet de lire ce qui est afficher et de ne pas en être étourdis. Il est à noter que l'affichage de plusieurs centaines d'appels à la seconde est demandant sur des processeurs plus anciens et il est recommandé de ne pas mettre à jour l'écran automatiquement (déroulement automatique) afin d'optimiser les performance de l'application. Nous avons aussi une commande pour effacer la trace en mémoire lorsque l'on désire analyser un processus différent et lorsque nous voulons avoir les appels systèmes fait par celui-ci. Finalement, une option pour sauvegarder la trace est disponible si l'utilisateur veut l'analyser ultérieurement.
L'option la plus importante de notre application est la fenêtre où
l'on peut déterminer quel comportement est malicieux et indiquer
l'application à vérifier. Cette fenêtre comporte deux champs, le
premier doit contenir le nom de l'application à vérifier. Ce nom
doit être exactement le même que celui de l'application et doit
respecter la case. Autrement, les appels systèmes ne seront pas
interceptés pour cette application. Le deuxième champ est utilisé
pour entrer la formule à vérifier. Cette formule doit être une
formule valide LTL et elle doit être entrée en mode préfixé.
L'application comprend des boutons qui permettent d'entrer les
codes spécifiques au langage LTL et les appels systèmes doivent
être entrés en lettres majuscules. Par exemple l'appel
ReadFile() doit être entré de la manière suivante :
READFILE. Il existe présentement 29 appels systèmes pouvant être
interceptés et analysés dans notre application : OPENFILE,
QUERYINFORMATIONFILE, SETINFORMATIONFILE, READFILE, WRITEFILE,
CREATEDIRECTORYOBJECT, MAKETEMPORARYOBJECT, OPENSECTION,
MAPVIEWOFSECTION, UNMAPVIEWOFSECTION, SETINFORMATIONTHREAD,
CREATEKEY, OPENKEY, DELETEKEY, ENUMERATEKEY, ENUMERATEVALUEKEY,
FLUSHKEY, QUERYKEY, QUERYVALUEKEY, SETVALUEKEY,
OPENSYMBOLICLINKOBJECT, QUERYSYMBOLICLINKOBJECT, CREATETIMER,
OPENTIMER, CANCELTIMER, SETTIMER, QUERYDIRECTORYFILE,
QUERYSYSTEMINFORMATION et CREATEFILE.
Lorsque l'utilisateur clique sur 'OK', l'application commence automatiquement à n'intercepter que les appels systèmes étant faits par l'application donnée. Chaque appel est passé à l'évaluateur de modèle qui fait changer l'automate d'état et détermine s'il est dans un état final. Lorsque l'automate entre dans un état final, un message est affiché à l'écran indiquant qu'un comportement malicieux a été découvert et indique la formule correspondante. Les appels systèmes s'arrêtent à ce moment. Il se peut que la formule écrite par l'utilisateur ne soit pas correcte, dans ce cas un message sera affiché à l'écran indiquant que la formule est incorrecte et les appels systèmes de l'application seront interceptés, mais pas analysés.
Nous avons fait plusieurs tests sur notre prototype avec
différentes applications et de nombreuses formules logiques.
Présentement, tout semble fonctionner correctement. Pour ce qui
est de l'analyse des comportements, l'application détecte une
suite d'appels systèmes malicieux qui ont étés définis par une
formule LTL. Cependant, il ne peut analyser qu'un seul processus à
la fois et lorsque deux processus du même nom sont activés, il
prend la trace des deux à la fois.
L'application reste tout de même fonctionnelle pour les objectifs
que nous avions fixés au départ. Nous avons un prototype qui
démontre qu'il est possible d'intercepter les appels systèmes en
mode noyau, de passer ceux-ci à un interpréteur en mode
utilisateur et de déterminer si un enchaînement spécifique des
processus nous donne un comportement malicieux.
Un autre objectif sous tendant à ce prototype est l'apprentissage
que nous avons fait au cours du développement de notre projet
synthèse. En effet, nous savons maintenant comment développer un
pilote qui se place en mode noyau, chose que nous ne savions pas
avant de commencer. Cette connaissance nous offre une vue plus
approfondie sur le fonctionnement même du système d'exploitation
et de l'importance d'avoir du code très fonctionnel dans la couche
noyau. Nous avons aussi assimilé d'importantes connaissances pour
ce qui est de la logique linéaire temporelle. Cet acquis nous
permet d'analyser non seulement les appels systèmes comme dans
notre application, mais aussi les événements pouvant survenir lors
de l'exécution d'une application en temps réel. Nous pourrons
facilement transférer ces connaissances à d'autres domaines
d'applications.
Ce prototype nous a placé devant un grand défi, celui de concevoir
un anti-virus viable dans un environnement réel. Nous devons
comprendre ce qu'il reste à faire pour rendre ce logiciel
performant et aussi ce qui doit être changé. L'optimisation du
code en mode noyau est une tâche très importante, chaque appel
système qui est lancé est dérouté vers notre application et est
analysé. Ce traitement doit être le plus rapide possible pour ne
pas que l'utilisateur puisse s'apercevoir du changement dans le
temps de réponse du système. Nous pourrions avoir des objectif
comme une utilisation du temps de processeur par notre application
ne pouvant dépasser un certain nombre de cycles par appels.
L'analyse comportementale devrait avoir lieu en mode noyau afin
d'améliorer la vitesse de l'application en générale et afin de
permettre au pilote de suspendre l'application fautive en cas de
comportement malicieux. De cette manière, nous pourrions aussi
garder une trace et une image des appels faits par l'application
fautive et tenter de faire un retour en arrière pour réparer les
dommages potentiels sur les fichiers.
Une recherche approfondie sur les codes malicieux, ainsi qu'une
analyse des différents types nous permettrais d'acquérir les
connaissances nécessaires à l'élaboration de formules logiques
pouvant détecter les codes malicieux avec finesse, et laisser
passer les applications légitimes. Il est important d'avoir la
formule la plus concise possible afin d'éviter les fausses alarmes
et ainsi blaser l'utilisateur qui laisserait passer toutes les
applications sans se soucier d'observer quel est le comportement
détecté.
Notre application devrait aussi permettre à l'utilisateur de
spécifier différentes formules LTL qui déterminent des
comportements qu'il ne veut pas dans son système. Ces formules
seraient sauvegardées avec les autres qui seraient fournies par
l'application. De plus, l'utilisateur devrait pouvoir éliminer des
comportements qu'il considère comme souhaitables dans son système,
afin de permettre certaines actions qui seraient bloquées en temps
normal.
Une autre application serait aussi de mettre notre évaluateur de
modèle dans un système d'exploitation virtuel et d'analyser les
comportements des logiciels entrant dans le réseau d'une
entreprise, afin de déterminer s'ils ont un comportement
malicieux. Cette technique appelée Sandbox est en voie de
développement et est très prometteuse dans le domaine des
anti-virus. Il est cependant important d'avoir une machine
virtuelle la plus complète possible afin que le code malicieux ne
puisse déterminer s'il est dans un vrai environnement ou non.
Comme nous l'avons vu, beaucoup de choses peuvent être ajoutées à notre prototype pour le rendre au produit final. Il serait intéressant de poursuivre la recherche dans ce domaine pour combattre les codes malicieux à la source, avant même qu'ils soient conçus
~
fyre/sst.html, May 2003.