web-dev-qa-db-fra.com

Comment fonctionne vraiment le shellcode?

J'ai lu le livre "The Shellcoders Handbook", et il contient du code C qui exécutera le shellcode (il appellera uniquement exit syscall).

 char shellcode [] = “\ xbb\x00\x00\x00\x00\xb8\x01\x00\x00\x00\xcd\x80”; 
 
 int main ( ) {
 int * ret; 
 ret = (int *) & ret + 2; 
 (* ret) = (int) shellcode; 
} 

Im intéressé par ces trois lignes dans la fonction principale. Que font-ils exactement et comment exécutent-ils le shellcode?

J'aurais peut-être compris: Avant d'appeler main sur la pile, on a poussé ebp et renvoyé l'adresse à partir d'un cadre de pile précédent, donc ici nous remplaçons cette adresse et y plaçons notre shellcode. Est-ce correct?

18

TL; DR C'est un moyen d'exécuter du shellcode qui ne fonctionne plus.

Qu'est-ce qu'une fonction?

Shellcode est juste du code machine dans des endroits où il n'est pas normalement trouvé, comme une variable de type char. En C, il n'y a pas de distinction entre les fonctions et les variables. Une fonction est juste une variable qui pointe vers du code exécutable. Cela signifie que, si vous créez une variable qui pointe vers du code exécutable et l'appelez comme s'il s'agissait d'une fonction, elle s'exécutera. Pour illustrer comment c'est juste une variable, voyez ce programme simple:

#include <stdio.h>
#include <stdint.h>

void print_hello(void)
{
    printf("Hello, world!\n");
}

void main(void)
{
    uintptr_t new_print_hello;

    printf("print_hello = %p\n", print_hello);
    new_print_hello = (uintptr_t)print_hello;
    (*(void(*)())new_print_hello)();
    print_hello();
}

Une fois compilé et exécuté, ce programme donne une sortie comme ceci:

$ ./a.out
print_hello = 0x28bc4bf6da
Hello, world!
Hello, world!

Cela permet de voir facilement qu'une fonction n'est rien d'autre qu'une adresse en mémoire, compatible avec le type uintptr_t. Vous pouvez voir comment une fonction peut être référencée simplement en tant que variable, dans ce cas en imprimant sa valeur, ou en la copiant dans une autre variable d'un type compatible et en appelant la variable comme une fonction, mais avec un peu de magie pour pour rendre le compilateur C heureux. Une fois que vous voyez comment une fonction n'est rien de plus qu'une variable pointant vers une mémoire exécutable, ce n'est pas un tronçon de voir comment une variable pointant vers un bytecode que vous définissez manuellement peut également être exécutée.

Comment fonctionnent les fonctions?

Maintenant que vous savez qu'une fonction n'est qu'une adresse en mémoire, vous devez savoir comment une fonction est réellement exécutée. Une fois que vous appelez une fonction, généralement avec l'instruction call, le pointeur d'instruction (qui pointe vers l'instruction en cours d'exécution) change pour pointer vers la première instruction de la fonction. L'emplacement juste avant l'appel de la fonction est enregistré dans la pile par call. Une fois la fonction terminée, elle se termine par l'instruction ret, qui la fait disparaître de la pile et la sauvegarde sur l'IP. Donc, une vue (quelque peu simplifiée) est que call pousse l'IP vers la pile et ret la fait revenir.

Selon l'architecture et le système d'exploitation sur lesquels vous vous trouvez, les arguments de la fonction peuvent être passés dans des registres ou dans la pile, et la valeur de retour peut être dans différents registres ou dans la pile. C'est ce qu'on appelle la fonction ABI , et elle est spécifique à chaque type de système. Le code shell conçu pour un type de système peut ne pas fonctionner sur un autre, même si l'architecture est la même et le système d'exploitation différent, ou vice versa.

Que fait votre shellcode?

Regardons le démontage du shellcode que vous avez fourni:

0000000000201010 <shellcode>:
   201010:      bb 00 00 00 00          mov    ebx,0x0
   201015:      b8 01 00 00 00          mov    eax,0x1
   20101a:      cd 80                   int    0x80

Cela fait trois choses. Premièrement, il définit le ebx sur 0. Deuxièmement, il définit le registre eax sur 1. Enfin, il déclenche l'interruption 0x80 qui, sur les systèmes 32 bits, est l'interruption syscall. Dans le SysV appelant ABI, le numéro d'appel système est placé dans eax et jusqu'à 6 arguments sont passés dans ebx, ecx, edx, esi, edi et ebp. Dans ce cas, seul ebx est défini, ce qui signifie que l'appel système ne prend qu'un seul argument. Une fois l'interruption 0x80 appelée, le noyau prend le relais et examine ces valeurs, exécutant l'appel système correct. Les numéros d'appel système sont définis dans /usr/include/asm/unistd_32.h. En regardant cela, nous voyons que syscall 1 est exit(). De cela, nous pouvons voir les trois choses que ce shellcode fait:

  1. Il définit le premier argument de l'appel système à 0 (ce qui signifie le succès de la sortie).
  2. Il définit le numéro d'appel système sur 1, qui est l'appel de sortie.
  3. Il appelle l'appel système, provoquant la sortie du programme avec l'état 0.

Quand vous regardez la grande image, nous voyons que le shellcode est essentiellement équivalent à exit(0). Il n'a pas besoin de ret car il ne revient jamais, et provoque à la place la fin du programme. Si vous voulez que la fonction revienne, vous devrez ajouter ret à la fin. Si vous n'utilisez pas, à tout le moins, ret, le programme se bloquera à moins qu'il ne se termine avant d'atteindre la fin de la fonction, comme dans votre exemple avec le syscall exit() .

Quel est le problème avec votre shellcode?

La méthode d'appel du shellcode que vous montrez ne fonctionne plus plus. Auparavant, mais de nos jours, Linux ne permet pas d'exécuter des données arbitraires, ce qui nécessite une conversion obscure. Cette ancienne technique est bien expliquée dans le célèbre Smashing The Stack For Fun And Profit article:

   Lets try to modify our first example so that it overwrites the return
address, and demonstrate how we can make it execute arbitrary code.  Just
before buffer1[] on the stack is SFP, and before it, the return address.
That is 4 bytes pass the end of buffer1[].  But remember that buffer1[] is
really 2 Word so its 8 bytes long.  So the return address is 12 bytes from
the start of buffer1[].  We'll modify the return value in such a way that the
assignment statement 'x = 1;' after the function call will be jumped.  To do
so we add 8 bytes to the return address.  Our code is now:

example3.c:
------------------------------------------------------------------------------
void function(int a, int b, int c) {
   char buffer1[5];
   char buffer2[10];
   int *ret;

   ret = buffer1 + 12;
   (*ret) += 8;
}

void main() {
  int x;

  x = 0;
  function(1,2,3);
  x = 1;
  printf("%d\n",x);
}
------------------------------------------------------------------------------

   What we have done is add 12 to buffer1[]'s address.  This new address is
where the return address is stored.  We want to skip pass the assignment to
the printf call.  How did we know to add 8 to the return address?  We used a
test value first (for example 1), compiled the program, and then started gdb

Le version correcte de votre shellcode pour les nouveaux systèmes serait:

const char shellcode[] = “\xbb\x00\x00\x00\x00\xb8\x01\x00\x00\x00\xcd\x80”;

int main(){
    int (*ret)() = (int(*)())shellcode;
    ret();
}
39
forest