Shellquine

Divulgâchage : Les shellcodes peuvent faire plein de choses utiles mais peuvent-ils se reproduire ? La Théorie dit oui et on vous explique comment le faire en 38 octets.

Il y a peu, je suis tombé sur un article intéressant de David Madore sur son site perso où il explique ce que sont les Quines (des programmes auto-reproducteurs), comment en construire (en prenant l’exemple du C) et des variations sur ce thème de la cyber-reproduction.

Le sujet n’est pas récent (on en parle depuis 1940) et des exemples ont été publiés un peu partout (depuis 1960) et dans tous les langages de programmation qui se respectent. Une compétition y fait rage pour produire les Quines les plus courts possibles. Le record absolu est tenu par le Quine suivant1 :

Sauvegardez ce contenu dans un fichier (e.g. quine.txt) et vous pouvez ensuite l’exécuter en tant que script PHP (mais aussi Python, Bash, Perl, …) pour confirmer qu’il écrit bien, sur la sortie standard, son propre code source. Voici les commandes pour le vérifier chez vous (sous GNU/Linux) :

$ echo -n "" > quine.php
$ php quine.txt > result.txt
$ diff quine.php result.txt
$ echo $?
0

Mais je m’égare, le but n’est pas de produire un Quine encore plus petit que ce record universel mais de compléter la grande famille des Quines car nous n’en avons pas trouvés en shellcodes. En assembleur oui, et même des tas, mais pas de version qu’on puisse injecter dans un programme.

Quine binaire

L’intérêt des shellcodes, c’est que ce sont des codes qui sont écrits directement en binaire. En suivant ce principe, le code source d’un shellcode et son code binaire ne font qu’un. Un Quine à la mode shellcode va donc s’imprimer lui même.

L’idée n’est pas si extravagante que ça et on trouve ce genre de Quine dans beaucoup d’autres langages interprétés2. L’exemple suivant utilise PHP pour que le programme s’affiche lui-même :

<?php readfile(__FILE__) ;

Ne reste donc qu’à écrire l’équivalent en binaire… Et comme ce n’est pas la chose la plus intuitive du monde3, on va commencer par l’écrire en assembleur (annoté par du C). Voici donc ce que ça pourrait donner :

write:
    mov $0x01,       %rax # write(
    mov $0x01,       %rdi #   stdout,
    lea write(%rip), %rsi #   shellcode,
    mov $len,        %rdx #   sizeof(shellcode)
    syscall               # ) ;
exit:
    mov $0x3c,       %rax # exit(
    mov $0x00,       %rdi #   0
    syscall               # ) ;
    
    len = . - write

Pour paraphraser ce code, le premier bloc est un appel système à write() pour écrire, sur la sortie standard, la zone mémoire qui débute à l’adresse du shellcode (relativement à l’adresse de la prochaine instruction) et fait le même nombre d’octet que lui (cette taille est calculée par gas). Le deuxième bloc est l’appel système exit() pour terminer l’exécution du programme proprement.

La traduction de ces instructions en binaire est expliquée dans notre livre (i.e. SYSCALL en page 17, MOV en page 35 et LEA (relativement à RIP) en page 57). Voici le résultat où le code assembleur est annoté par sa traduction en hexadécimal :

write:
    mov $0x01,       %rax # 48 c7 c0 01 00 00 00
    mov $0x01,       %rdi # 48 c7 c7 01 00 00 00
    lea write(%rip), %rsi # 48 8d 35 eb ff ff ff
    mov $0x2e,       %rdx # 48 c7 c2 2e 00 00 00
    syscall               # 0f 05
exit:
    mov $0x3c,       %rax # 48 c7 c0 3c 00 00 00
    mov $0x00,       %rdi # 48 c7 c7 00 00 00 00
    syscall               # 0f 05

On peut alors convertir ce fichier en shellcode binaire via deux commandes (i.e. sed puis ssd), ou utiliser notre makefile qui automatise tout ça :

$ make quine-binary.bin
sed -n "s/.*# *//p" quine-binary.s > quine-binary.hex
xxd -r -p quine-binary.hex > quine-binary.bin
rm quine-binary.hex

C’est le fichier quine-binary.bin qui constitue le Quine à la mode des shellcodes. C’est un programme écrit en binaire qui, une fois injecté dans un autre programme, produit son propre code. Voici l’enchaînement de commandes pour le vérifier.

$ ./loader quine-binary.bin > out.bin
$ diff quine-binary.bin out.bin
$ echo $?
0

Ce premier Quine fonctionne mais il est un peu long. 46 octets ! C’est juste énorme pour le peu que ça fait. On doit pouvoir faire mieux.

Commençons par remarquer que 5 instructions MOV utilisent des valeurs de 4 octets (32 bits), dont les 3 octets de poids fort sont à zéro. C’est un gaspillage de 15 octets qu’on va maintenant économiser.

Pour ça, nous remplaçons les 4 premières instructions par des couples d’instructions PUSH/POP (p. 352 de notre livre) et la dernière par un XOR (p. 351 de notre livre). Voici ce que donne cette première optimisation (les couples d’instructions sont sur la même ligne pour garder cette idée qu’elles ne constituent qu’une action) :

write:
    push $0x01 ; pop %rax # 6a 01 58
    push $0x01 ; pop %rdi # 6a 01 5f
    lea write(%rip), %rsi # 48 8d 35 f3 ff ff ff
    push $0x1a ; pop %rdx # 6a 1a 5a
    syscall               # 0f 05
exit:
    push $0x3c ; pop %rax # 6a 3c 58
    xor  %rdi,       %rdi # 48 31 ff
    syscall               # 0f 05

Notez la troisième instruction PUSH qui n’empile plus 0x2e (la taille du précédent shellcode) mais 0x1a (la taille de celui-ci). De même l’instruction LEA n’utilise plus la même adresse relative puisque la portion du shellcode qui se trouve au début est plus courte (13 octets au lieu de 21).

Poursuivons avec le XOR (qui a remplacé un MOV)… Il sert à fournir le code de retour du programme ; ici 0 pour dire que tout s’est bien passé. Si on supprime cette instruction, c’est la valeur déjà présente dans RDI qui sera utilisée. C’est à dire 1 (placé là par le deuxième POP). Sémantiquement, cette optimisation dit au système que le programme a eu un problème, mais personne n’y fera attention de toutes façons… C’est donc trois nouveaux octets d’économisés.

write:
    push $0x01 ; pop %rax # 6a 01 58
    push $0x01 ; pop %rdi # 6a 01 5f
    lea write(%rip), %rsi # 48 8d 35 f3 ff ff ff
    push $0x17 ; pop %rdx # 6a 17 5a
    syscall               # 0f 05
exit:
    push $0x3c ; pop %rax # 6a 3c 58
    syscall               # 0f 05

Pour la suite, on va utiliser des hypothèses sur l’environnement. Les optimisations précédentes restent compatibles avec des injections dans n’importe quel autre programme. Les suivantes partent du principe qu’on injecte dans notre loader (cf. page 27).

Un des grands problèmes avec les shellcodes, c’est de savoir où il est chargé dans la mémoire. Il y a plusieurs méthodes pour y arriver et ce shellcode utilise l’adressage relatif à RIP. Les autres techniques habituelles fonctionneraient mais elles utilisent un peu trop d’octets. Nous allons voir qu’on peut s’en passer lorsqu’on trouve où le processeur a rangé l’adresse du shellcode avant de l’exécuter.

Pour ça, on va regarder le code assembleur du loader produit par gcc. La sortie suivante est issue de GDB et montre que l’adresse de la page allouée par mmap() est stockée sur la pile (emplacement pour la variable buffer) puis copiée sur un autre emplacement de pile (pour la variable function) puis copiée dans un registre (RDX) qui sert d’adresse cible à l’appel de procédure (instruction CALL).

$ gdb loader
[...]
(gdb) disass main
[...]
   0x0000000000001202 <+169>:   call   0x1030 <mmap@plt>
   0x0000000000001207 <+174>:   mov    %rax,-0x18(%rbp)
[...]
   0x0000000000001219 <+192>:   mov    -0x18(%rbp),%rax
   0x000000000000121d <+196>:   mov    %rax,-0x20(%rbp)
   0x0000000000001221 <+200>:   mov    -0x20(%rbp),%rdx
   0x0000000000001225 <+204>:   mov    $0x0,%eax
   0x000000000000122a <+209>:   call   *%rdx
   0x000000000000122c <+211>:   leave
   0x000000000000122d <+212>:   ret

Plutôt que l’instruction LEA qui calcule l’adresse en utilisant 7 octets, nous pourrions utiliser MOV pour copier l’adresse déjà présente de RDX vers RSI en n’utilisant que 3 octets. Et comme on cherche à faire plus court, on va plutôt utiliser un couple PUSH/POP qui n’utilise que 2 octets.

write:
    push $0x01 ; pop %rax # 6a 01 58
    push $0x01 ; pop %rdi # 6a 01 5f
    push %rdx  ; pop %rsi # 52 5e
    push $0x12 ; pop %rdx # 6a 12 5a
    syscall               # 0f 05
exit:
    push $0x3c ; pop %rax # 6a 3c 58
    syscall               # 0f 05

Si on continue avec la session GDB en se demandant quelles valeurs sont dans les registres, on remarque aussi que RAX et RDI valent zéro au moment où le shellcode va être appelé.

$ gdb loader
[...]
(gdb) b *main+209
(gdb) run quine-binary.bin
(gdb) info registers
rax            0x0                 0
[...]
rdi            0x0                 0
[...]

Plutôt qu’utiliser des instructions PUSH/POP pour y copier la valeur 1 (avec trois octets), on obtiendrait le même résultat avec INC puisque ajouter 1 à 0 produit 1. Le truc c’est que cette opération ne modifie que le premier bit du registre ; le résultat ne changera donc pas si on utilise les 8 ou 32 bits de poids faibles (ces variantes de l’instruction sont encodée avec un octet de moins).

write:
    inc              %eax # ff c0
    inc              %edi # ff c7
    push %rdx  ; pop %rsi # 52 5e
    push $0x10 ; pop %rdx # 6a 10 5a
    syscall               # 0f 05
exit:
    push $0x3c ; pop %rax # 6a 3c 58
    syscall               # 0f 05

Dans la même idée, une fois le premier appel système effectué, on sait que RAX contient le nombre d’octets écrits. Plutôt qu’écraser la valeur par 0x3c, pourquoi ne pas la calculer en lui ajoutant la différence ? On peut se restreindre aux 8 bits de poids faibles en utilisant AL et ça prendra un octet de mois à encoder…

write:
    inc              %eax # ff c0
    inc              %edi # ff c7
    push %rdx  ; pop %rsi # 52 5e
    push $0x0f ; pop %rdx # 6a 0f 5a
    syscall               # 0f 05
exit:
    add  $0x2d,       %al # 04 2d
    syscall               # 0f 05

Pour terminer, on peut aller encore plus loin en remarquant que l’appel système exit() n’est utile que pour terminer proprement le programme. On l’utilise pour faire preuve d’une sorte de respect envers le système et ses utilisateurs mais aucune règle n’impose de terminer le programme de la sorte…

Que se passerait-il si on ne l’utilisait pas ? Le processeur exécuterait alors les instructions qui suivent dans la mémoire. Dans le cas de notre chargeur de shellcodes, ces octets sont tous à zéro. Pris par couples, ils consistent à demander au processeur d’ajouter zéro dans AL et à passer au suivant. Jusqu’à la fin de la page mémoire allouée pour y copier le shellcode. La page suivante n’étant pas allouée, le processeur lève un défaut de page et le système met fin au programme par une erreur de segmentation.

Mais qui s’en soucie ? Le shellcode a pu s’exécuter et copier son contenu sur la sortie standard avant de planter…

write:
    inc              %eax # ff c0
    inc              %edi # ff c7
    push %rdx  ; pop %rsi # 52 5e
    push $0x0b ; pop %rdx # 6a 0b 5a
    syscall               # 0f 05

Ce qui donne un Quine à la mode shellcode de 11 octets ; 35 octets de moins que la version initiale de 46 octets.

ShellQuine

On me souffle dans l’oreillette que le code précédent, bien qu’il s’agisse d’un Quine, n’est pas un shellcode car pour mériter ce qualificatif, il faudrait invoquer un shell et qu’aucune invite de commande n’a été lancée jusqu’à présent…

Oui, c’est vrai. Et nous allons corriger tout ça immédiatement en ajoutant ce que David Madore appelle un intron. Un bout de code qui n’a aucun rôle dans la reproduction du Quine mais qui s’y trouve quand même parce qu’au fond, Eris a le sens de l’humour.

Il ne suffira pas d’ajouter, après le write(), le bout qui lance un shell avec execve() car la règle est claire : le Quine doit produire son contenu sur la sortie standard. Si on lance un shell, la sortie standard des commandes va s’ajouter à la suite du Quine qui n’en sera donc plus un…

Nous allons donc insérer, entre ces appels systèmes, un appel à dup2() pour rediriger la sortie standard du shell (et donc des commandes) vers la sortie d’erreur du Quine. On pourra ainsi lancer autant de commandes qu’on veut, voir leurs résultats, sans que ça vienne interférer avec la sortie du Quine.

Le code suivant vous montre les instructions de ce nouveau Quine. Ici l’assembleur est annoté par sa traduction en hexadécimal pour voir la quantité d’octets utilisée. Les deux nouveaux blocs (dup2 et execve) sont repris du livre (respectivement pages 148 et 78).

write:
    inc              %eax # ff c0
    inc              %edi # ff c7
    mov  %rdx,       %rsi # 48 89 d6
    push $0x4b ; pop %rdx # 6a 4b 5a
    syscall               # 0f 05
dup2:
    mov $0x21,       %rax # 48 c7 c0 21 00 00 00
    mov $0x02,       %rdi # 48 c7 c7 02 00 00 00
    mov $0x01,       %rsi # 48 c7 c6 01 00 00 00
    syscall               # 0f 05
execve:
    mov $0x3b,       %rax # 48 c7 c0 3b 00 00 00
    lea arg0(%rip),  %rdi # 48 8d 3d 12 00 00 00
    push $x00000000       # 68 00 00 00 00
    push %rdi             # 57
    mov %rsp,        %rsi # 48 89 e6
    mov $0,          %rdx # 48 c7 c2 00 00 00 00
    syscall               # 0f 05
arg0:
    .string "/bin/sh"     # 2f 62 69 6e 2f 73 68 00

Cette première version du Quine qui lance un shell fait 74 octets. Pour faire mieux, on va d’abord appliquer les techniques du début de l’article pour remplacer les 6 instructions MOV qui prennent tant de place :

Le code suivant vous montre le résultat une fois ces optimisations réalisées. Notez qu’il faut adapter la taille du shellcode (0x31 octets) et l’adresse relative de arg0 (à 0x0d).

write:
    inc              %eax # ff c0
    inc              %edi # ff c7
    push %rdx  ; pop %rsi # 52 5e
    push $0x31 ; pop %rdx # 6a 31 5a
    syscall               # 0f 05
dup2:
    sub  $0x10,      %al  # 2c 10
    push %rdi             # 57
    inc              %edi # ff c7
    pop              %rsi # 5e
    syscall               # 0f 05
execve:
    add $0x3a,       %al  # 04 3a
    lea arg0(%rip),  %rdi # 48 8d 3d 0d 00 00 00
    push $x00000000       # 68 00 00 00 00
    push %rdi             # 57
    push %rsp ; pop %rsi  # 54 5e
    push $0x0 ; pop %rdx  # 6a 00 5a
    syscall               # 0f 05
arg0:
    .string "/bin/sh"     # 2f 62 69 6e 2f 73 68 00

Cette seconde version fait 49 octets (on en a donc économisé 25, en moyenne 4 par MOV). C’est pas mal mais on peut faire mieux en appliquant quelques techniques supplémentaires que voici.

Adressage relatif à RIP. Lorsqu’on a besoin de l’adresse d’une donnée dans le shellcode, utiliser RIP comme adresse de base est très pratique (c’est direct et plutôt lisible), mais ça coûte quelques octets. Pour ce Quine, ça utilise 7 octets pour l’instruction et 8 pour la chaîne, soit 15 au total.

Le tableau suivant liste les techniques disponibles (et leur numéro de page dans notre livre) pour obtenir l’adresse de la chaîne arg0 dans le registre RDI et leur coûts respectifs. Certaines empirent la situation (e.g. empiler la donnée ou utiliser le classique JMP/CALL/POP) et d’autres l’améliorent. Dans notre cas, c’est en utilisant SYSCALL puis une instruction LEA par rapport à RCX que nous utiliserons le moins d’octets.

Technique Coût
Empiler la chaîne (p. 54) 13
Adresse relative à RIP (p. 58) 15
CALL par dessus la chaîne (p. 62) 14
JMP / CALL / POP (p. 64) 16
CALL .+5 / POP / ADD (p. 69) 16
SYSCALL puis relatif à RCX (p. 78) 12

(In)Utilité de argv. Lors de l’appel système execve(), le deuxième paramètre (argv) est sensé contenir l’adresse du tableau des paramètres (eux-même des adresses vers les chaînes de caractères correspondantes). Mais, comme l’explique l’extrait de la page de manuel suivant, le noyau Linux accepte qu’au lieu de passer un pointeur vers un tableau vide, nous passions un pointeur NULL.

Sous Linux, argv et envp peuvent être NULL, ce qui a le même effet que d’indiquer ces paramètres comme un pointeur vers une liste contenant un pointeur NULL unique. Ne vous servez pas de cette caractéristique ! Elle n’est ni standard ni portable : sur la plupart des systèmes UNIX, faire cela causera une erreur.

man execve, traduction de Christophe Blaess, Alain Portal, Julien Cristau et l’équipe francophone de traduction de Debian.

Comme la documentation nous le suggère, nous allons donc nous servir de cette caractéristique et économiser la construction de argv sur la pile en mettant ce paramètre à zéro. Via un XOR (3 octets en 64 bits) mais puisque les 32 bits de poids fort de RSI sont déjà à zéro, nous le ferons en 32 bits, en n’utilisant donc que deux octets.

Mettre RDX à zéro. La méthode classique, pour mettre un registre à zéro, consiste à utiliser l’instruction XOR entre ce registre et lui-même ; ce qui prend 3 octets. Mais pour RDX, nous pouvons utiliser une autre instruction ; CLTD4 qui étend le bit de signe de RAX dans RDX. Puisque RAX contient une valeur positive, son bit de signe est à zéro et RDX vaudra donc zéro.

La version 64 bits utiliser l’opcode 0x99 précédé du préfixe REX.W. Ce qui fait deux octets au lieux des trois habituels. Mais dans notre cas, on remarque que RAX et RDX contiennent déjà des valeurs qui tiennent sur moins de 32 bits (RAX contient le numéro de l’appel système et RDX contient 1). On peut donc se passer du préfixe REX.W, faire l’opération sur les 32 bits de poids faible (c’est donc le bit de signe de EAX qui sera étendu dans EDX) et obtenir le même résultat ; mais avec un seul octet cette fois.

Le code suivant correspond à cette nouvelle étape d’optimisation. Notez que les tailles et adresses relatives doivent encore être adaptées puisque le code a rétréci.

write:
    inc              %eax # ff c0
    inc              %edi # ff c7
    push %rdx  ; pop %rsi # 52 5e
    push $0x26 ; pop %rdx # 6a 26 5a
    syscall               # 0f 05
dup2:
    sub  $0x05,      %al  # 2c 05
    push %rdi             # 57
    inc              %edi # ff c7
    pop              %rsi # 5e
    syscall               # 0f 05
execve:
    add $0x3a,       %al  # 04 3a
    lea 0x0b(%rcx),  %rdi # 48 8d 79 0b
    xor %esi,        %esi # 31 f6
    cltd                  # 99
    syscall               # 0f 05
arg0:
    .string "/bin/sh"     # 2f 62 69 6e 2f 73 68 00

Cette version utilise 38 octets. Soit 11 de moins que la version intermédiaire et 36 de moins que la version initiale.

Et après

Comme souvent, ce shellcode n’a aucune utilité d’usage propre. J’ai du mal à voir un contexte ou vous ayez besoin d’injecter un shellcode qui affiche son code sur la sortie standard. Sauf pour s’amuser, apprendre et se faire plaisir. Et c’est peut être pour ça qu’écrire ce genre de shellcode est de l’Art.

Mais il suffirait de pas grand chose pour transformer ce shellcode pour qu’il s’injecte lui-même dans d’autres processus ou d’autres machines à travers le réseau. Les copies s’exécutant de même, il se répandrait comme un virus…