Sécurité applicative : le détournement des fonctions internes d'un programme - Partie 2

Préambule à l'article

Nous avons vu dans la première partie, le concept des doppelgangers, ces fonctions “pirates” qui détournent les véritables fonctions de notre programme. Nous pourrions imaginer en avoir d'autres plus complexes et mieux cachés encore, effectuant certaines actions spécifiques dans notre système à des moments précis.

Jusqu’à maintenant nous avons adopté le point de vue de l’attaquant. Nous allons voir maintenant dans cette seconde partie, celui des défenseurs - des développeurs du programme d’origine et des méthodes pour détecter si notre programme a un comportement étrange et s’il n’est pas en train de se faire détourner…


Petit intermède musical technique : Cacher le subterfuge LD_PRELOAD ?

Avant de sauter dans la véritable seconde partie, petit interlude par rapport à la précédente partie, vous remarquerez que notre librairie “doppelganger” doit être définie avant l'exécution du programme par exemple via la variable LD_PRELOAD. C’est une énorme “balafre” - visible par le simple quidam - sur notre détournement: Pour la discrétion, on repassera...

S’il existait une méthode pour… je ne sais pas… cacher ce lien vers notre doppelganger au sein même du programme et ainsi ne plus avoir besoin de ce LD_PRELOAD ? Cela serait tellement génial…

Lors du linkage (avec ld) pendant le processus de compilation, vous avez moyen de linker vos librairies au programme, mais dans notre cas, nous n’avons plus qu’un binaire à analyser (ou attaquer). Donc, est-il possible de rajouter une librairie après coup ? Oui, il est tout à fait possible et je vais vous présenter une méthode parmi d’autres.

Préambule: les librairies sont référencées dans la section “dynamic” et vous avez plusieurs méthodes pour lire ces sections :

$ readelf -x .dynstr  example          # contenu brut
$ readelf -x .dynamic example          # contenu brut
$ readelf -d example | grep NEEDED     # contenu interprété
 0x0000000000000001 (NEEDED)    Shared library: [libcrypto.so.3]
 0x0000000000000001 (NEEDED)    Shared library: [libc.so.6]

Pour ajouter notre librairie, nous allons devoir taper dans ces sections.

L’une des méthodes est de s’aider du programme patchelf, un programme qui permet de modifier et manipuler certaines sections des programmes au format ELF (Linux, BSD, Unix*, Playstation, …) :

$ patchelf --add-needed "./doppelganger.so" "example"


Si nous analysons notre programme de nouveau :

$ ldd "example"
    linux-vdso.so.1 (0x...)
    ./doppelganger.so (0x...)
    libcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3 (0x...)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x...)
    /lib64/ld-linux-x86-64.so.2 (0x...)

On constate que notre doppelganger s’est greffé à notre programme, comme une tique à une jambe.

Nos .dynamic et .dynstr ont été modifiés (entre autres) :

$ readelf -x .dynstr "example" | tail -n3
  0x00005388     435f322e 322e3500 2e2f646f 7070656c     C_2.2.5../doppel
  0x00005398     67616e67 65722e73 6f0000                ganger.so..


Et si nous exécutons notre programme sans LD_PRELOAD :

$ ./example
0x54 0x68 0x69 0x73 0x49 0x73 0x4d 0x79 0x4d 0x61 0x67 0x69 0x63 0x61 0x6c 
0x41 0x6e 0x64 0x48 0x69 0x64 0x64 0x65 0x6e 0x4b 0x65 0x79 0x21  
"ThisIsMyMagicalAndHiddenKey!"

Notre doppelganger devient (quasiment) invisible maintenant.

On constate que nous avons ajouté la référence à la librairie doppelganger.so au sein même du binaire sans avoir besoin maintenant d’un LD_PRELOAD.

Bien entendu, notre référence à doppelganger.so ne peut pas passer inaperçue, mais nous pouvons imaginer des scénarios de “cache-cache” où notre doppelganger.so soit renommé dans un nom plus “conventionnel”: Imaginez que notre doppelganger soit caché sous un nom comme /lib/x86_64-linux-gnu/libcrypto.so.3.1 comme un nom de librairie légitime. A moins d'analyser chaque binaire et de vérifier les dépendances, vous ne pourrez quasiment jamais déterminer si une librairie non-légitime a été greffée à un binaire lambda. (sauf si vous le connaissez très bien et que vous savez que quelque chose cloche, et encore...)

Notez que les systèmes de packaging sous Linux ont des checksums sur les fichiers. Par exemple, sous un système apt, vous pouvez utiliser debsums (ou plus simplement via les fichiers /var/lib/dpkg/info/*.md5sums). Vous avez l'équivalent dans (quasi) tous les autres systèmes de packaging dans les autres distributions Linux.


Through the looking glass : Passons de l’autre côté du miroir : la détection … (et la protection ?)

Passons maintenant à notre véritable seconde partie sur des mécanismes de détection de contournement et - peut-être - de protection ? Voyons cela étape par étape.

L’emplacement et l’empreinte mémoire

Ce que nous allons faire maintenant, c’est de déterminer si notre fonction a été détournée. Pour cela, nous allons étudier où se trouve notre fonction détournée en mémoire. Ajoutons quelques lignes à notre programme example.c :

printf("main addr               = %p\n", main);
printf("EVP_EncryptInit_ex addr = %p\n", EVP_EncryptInit_ex);
printf("EVP_EncryptInit addr    = %p\n", EVP_EncryptInit);

unsigned char *p = (unsigned char *)EVP_EncryptInit;
for(unsigned int i=0; i<64; i++)
    printf("%02x ", (unsigned char)*(p+i));
printf("\n");

Ici, nous allons récupérer et afficher les adresses mémoires des 3 fonctions : main, EVP_EncryptInit_ex (nous verrons pourquoi plus tard) et notre EVP_EncryptInit, dans la deuxième partie, nous allons lire à partir de l'adresse de EVP_EncryptInit.

main addr                = 0x5c7ef190e1f9            
EVP_EncryptInit_ex addr  = 0x7bc59cff9920   
EVP_EncryptInit addr     = 0x7bc59cff98d0 
f3 0f 1e fa 41 b8 01 00 00 00 e9 81 ff ff ff 90 f3 0f 1e fa 45 31 c0 e9 74 
ff ff ff 0f 1f 40 00 f3 0f 1e fa 55 48 89 e5 48 83 ec 08 6a 00 e8 cd f2 ff 
ff c9 31 d2 31 c9 31 f6 31 ff 45 31 c0 45

Nous constatons que notre main est à l’adresse 0x5c… et nos deux fonctions sont aux adresses 0x7bc… Les valeurs hexadécimales juste en dessous sont simplement… les réelles instructions de notre fonction !

Pour constater cela, il nous suffit d’aller désassembler notre EVP_EncryptInit qui se trouve bien évidemment dans libcrypto.so :

$ objdump -dr -M intel “/lib/x86_64-linux-gnu/libcrypto.so.3” \
| grep -A5 "<EVP_EncryptInit@'

00000000001f98d0 <EVP_EncryptInit@@OPENSSL_3.0.0>:
1f98d0:   f3 0f 1e fa             endbr64
1f98d4:   41 b8 01 00 00 00       mov    r8d,0x1
1f98da:   e9 81 ff ff ff          jmp    1f9860 <EVP_CipherInit@@OPENSSL_3.0.0>
1f98df:   90                      nop   

Déjà, nous constatons deux choses: les terminaisons des adresses (98d0) sont équivalentes dans le programme (0x7bc59cff98d0 => 00000000001f98d0) que dans la fonction libcrypto.

Et la deuxième chose, ce sont les instructions (de 0xf3 jusqu’à 0x90). On constate donc que quand notre programme se lance, si nous suivons le pointeur de la fonction, nous “passons” bien dans la fonction EVP_EncryptInit de la libcrypto.so.

A propos de l'adresse 0x7bc59cff98d0 de notre fonction EVP_EncryptInit, elle correspond à son emplacement dans la zone mappée pour librairie libcrypto. Informations que vous pouvez voir par exemple, via le /proc/maps de l’application :

cat /proc/<pid de example>/maps
(..)
7bc59ce00000-7bc59ceb3000 r--p 00000000 fc:01 30414995    /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7bc59ceb3000-7bc59d1e6000 r-xp 000b3000 fc:01 30414995    /usr/lib/x86_64-linux-gnu/libcrypto.so.3    <== "x"
7bc59d1e6000-7bc59d2b1000 r--p 003e6000 fc:01 30414995    /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7bc59d2b1000-7bc59d30d000 r--p 004b0000 fc:01 30414995    /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7bc59d30d000-7bc59d310000 rw-p 0050c000 fc:01 30414995    /usr/lib/x86_64-linux-gnu/libcrypto.so.3
(..)

Notre fonction EVP_EncryptInit se situe dans la partie exécutable (notez le x dans la deuxième colonne) de notre librairie libcrypto.so qui a été mappée à l'adresse 0x7bc59ceb3000 lorsque l'application a été lancée.

Voyez cela comme un hôtel avec différents étages: chaque étage représente une zone mémoire où se situe la librairie; c’est un peu comme si la librairie avait loué toutes les chambres à cet étage - pour lui et ses “invités”: les chambres représentant ainsi les différentes fonctions de la libcrypto. Ainsi, si votre fonction de libcrypto se trouve à l’étage 5, chambre 505, il suffit d’y aller. Ici, notre étage est 7bc59ceb3000 et numéro de chambre est 7bc59cff98d0

Vu indirectement: si on vous dit que votre chambre - qui se trouverait normalement à l’étage 5 - se trouve maintenant à la chambre 713, donc à un autre étage et une autre pièce, cela ne vous alerterait-il pas ? Voyons comment cela se passe avec notre programme :

Si nous activons notre doppelganger :

$ LD_PRELOAD=./doppelganger.so 
0x54 0x68 0x69 0x73 0x49 0x73 0x4d 0x79 0x4d 0x61 0x67 0x69 0x63 0x61 0x6c 
0x41 0x6e 0x64 0x48 0x69 0x64 0x64 0x65 0x6e 0x4b 0x65 0x79 0x21  
"ThisIsMyMagicalAndHiddenKey!"
main addr               = 0x62d4ea589455
EVP_EncryptInit_ex addr = 0x72f666ff9920 (!)
EVP_EncryptInit addr    = 0x72f6673ee139 (!)
f3 0f 1e fa 55 48 89 e5 48 83 ec 20 48 89 7d f8 48 89 75 f0 48 89 55 
e8 48 89 4d e0 48 8d 05 a4 0e 00 00 48 89 c7 e8 fc fe ff ff 48 83 7d 
e8 00 74 1e 48 8b

Qu’est-ce que nous constatons ? Notre adresse EVP_EncryptInit a changé, c’est normal. A chaque rechargement, le mapping diffère. Donc notre libcrypto est mappée autre part.

Non, ce qui devrait vous interpeller, c’est que les adresses de EVP_EncryptInit et de EVP_EncryptInit_ex sont maintenant très espacées, alors que dans notre précédent exemple, ils n’étaient séparés que de seulement 0x50 octets.

Autre détail: Hormis la base d’instructions (f3 0f 1e fa), le reste a complètement changé :

Avant

Après

f3 0f 1e fa 41 b8 01 00 00 00 e9 81 ff ff ff 90 f3 0f 1e fa 45 31 c0 e9 74 ff ff ff 0f 1f 40 00 f3 0f 1e fa ...

f3 0f 1e fa 55 48 89 e5 48 83 ec 20 48 89 7d f8 48 89 75 f0 48 89 55 e8 48 89 4d e0 48 8d 05 a4 0e 00 00 ...

Et pourquoi ? Analysons notre doppelganger.so pour comprendre rapidement :

$ objdump -dr -M intel "doppelganger.so" \
| grep -A23 '<EVP_EncryptInit>:'

0000000000001139 <EVP_EncryptInit>:
    1139:   f3 0f 1e fa             endbr64
    113d:   55                      push   rbp
    113e:   48 89 e5                mov    rbp,rsp
    1141:   48 83 ec 20             sub    rsp,0x20
    1145:   48 89 7d f8             mov    QWORD PTR [rbp-0x8],rdi
    1149:   48 89 75 f0             mov    QWORD PTR [rbp-0x10],rsi
    114d:   48 89 55 e8             mov    QWORD PTR [rbp-0x18],rdx
    1151:   48 89 4d e0             mov    QWORD PTR [rbp-0x20],rcx
    1155:   48 8d 05 a4 0e 00 00    lea    rax,[rip+0xea4]        # 2000

Est-ce que vous voyez les ressemblances ? La terminaison de l’adresse est 139 comme pour notre application et les instructions sont 0x55 0x48 0x89 … Notre fonction EVP_EncryptInit_ex a été détournée et est maintenant gérée par notre doppelganger. Nous sommes donc capables, depuis notre application, de déterminer si quelque chose cloche.

Notez que nous prenons EVP_EncryptInit_ex comme référence au hasard parce que nous savons - pour les besoins de cet exemple - que cette fonction n'a pas été détournée, donc nous pouvons voir les différences des adresses entre les deux fonctions qui normalement se trouvent proches (ou au moins dans la même “zone” mémoire avec ses frères et soeurs fonctions). Mais si l’attaquant décide également de détourner cette fonction, les adresses seront de nouveau proche et/ou dans la même “zone” mémoire.

L’idée de faire un checksum des fonctions (en lisant l’ensemble des instructions) peut être une idée attirante. Doit-on utiliser cette technique pour identifier si nos fonctions externes ont été détournées ?

En premier lieu, je ne le vous recommande pas. Pour une simple et bonne raison:

Les empreintes, adresses (et offsets) de vos fonctions (par exemple EVP_EncryptInit) vont changer au cours du temps et de l'espace:

  • Du temps: il suffit que les développeurs de la librairie ajoutent une seule instruction pour que l'empreinte ne soit plus la même du tout. Et cela peut arriver à n'importe quel moment dans le temps où la librairie va être encore maintenue et donc être mise à jour. Les différentes adresses et offsets des fonctions peuvent (et vont) également être modifiées.

  • De l'espace: un compilateur traduit votre code (ici en C) en instruction. Un compilateur A (gcc par exemple) va traduire et également apporter des optimisations à votre code. Un compilateur B (llvm par exemple) va traduire et introduire d'autres types d'optimisations. Imaginez maintenant que vous diffusiez votre programme sur plusieurs plateformes comme Linux, BSD, MacOS et Windows, vous aurez très probablement des librairies compilées avec différentes méthodes produisant ainsi différentes instructions, et donc différentes empreintes.

La seule approche viable pour utiliser cette méthode serait de contrôler aussi les librairies. Et encore : cela s’accompagne de tous les désagréments qui vont avec.

Gardez juste cette méthode dans un coin de votre tête: Il existe sur le marché des outils et des solutions de sécurité utilisant peu ou prou ce genre de méthodes mais de façon différente, par exemple en analysant la mémoire de l'application et en identifiant si des instructions ont été modifiées avant et durant son exécution.

La whitelist des librairies

Le fait de se baser sur un checksum est probablement overkill, mais peut-être pouvons-nous trouver une méthode intermédiaire ? Et pourquoi pas récupérer la liste des librairies chargées ?

Pour cela, nous pouvons utiliser un nouvel ami surnommé dl_iterate_phdr :

#define _GNU_SOURCE
#include <link.h>

int callback(struct dl_phdr_info *info, size_t size, void *data) {
    printf("%s = %p\n", info->dlpi_name, (void *)info->dlpi_addr);
    return 0;
}

int main(void) {
    dl_iterate_phdr(callback, NULL);
    return 0;
}

Et il nous faudra compiler avec un petit -ldl :

$ gcc -Wall example.c -o example `pkg-config --libs --cflags libcrypto libssl` -ldl

Ce qui nous donne :

$ ./example
 = 0x58c31f895000
linux-vdso.so.1 = 0x7ffe2f976000
/lib/x86_64-linux-gnu/libcrypto.so.3 = 0x7f8de1c00000
/lib/x86_64-linux-gnu/libc.so.6 = 0x7f8de1800000
/lib64/ld-linux-x86-64.so.2 = 0x7f8de2315000

Notre callback nous donne les chemins complets et les adresses des différentes librairies chargées en mémoire pour les besoins du programme. Avec cette méthode, nous pourrions donc définir une whitelist des librairies chargées que nous pourrions accepter, et si une librairie étrange apparaît, nous pourrions stopper l'application en amont.

Pour sa totale implémentation, il nous faut une structure de données avec l'ensemble des librairies acceptées en amont du code, des fonctions de vérifications entre cette liste et les occurrences récupérées via dl_iterate_phdr. Mais cette méthode est aussi perfectible pour diverses raisons.

Bref, peut-être qu'il existe une méthode encore plus simple…?

Récupérer les informations via sa fonction

Il existe effectivement une méthode plus simple pour retrouver l'adresse de notre librairie grâce aux fonctions dladdr et son cousin dladdr1. Ces dernières nous permettent de récupérer diverses informations directement en utilisant l'appel de la fonction. Nous n'avons donc plus besoin de faire une itération sur l'ensemble des librairies chargées.

#define _GNU_SOURCE
#include <link.h>
#include <dlfcn.h>
(...)
// DL Info
Dl_info *info = (Dl_info *)malloc(sizeof(Dl_info));
dladdr(EVP_EncryptInit, info);
printf("%s : %p : %s : %p\n", info->dli_fname, info->dli_fbase, info->dli_sname, info->dli_saddr);

Et si nous exécutons notre programme dans les deux contextes :

$ ./example
/lib/x86_64-linux-gnu/libcrypto.so.3 : 0x753692600000 : EVP_EncryptInit : 0x7536927f98d0

$ LD_PRELOAD=doppelganger.so ./example
./doppelganger.so : 0x70dbad258000 : EVP_EncryptInit : 0x70dbad259139

Tada ! nous voyons que notre EVP_EncryptInit ne provient plus de libcrypto.so mais de doppelganger.so. Il suffirait de faire une simple vérification sur ce résultat (avec un simple if par exemple) pour déterminer une action à faire (arrêter le programme par exemple).

Mais finalement, est-ce que tout ceci nous protège ? Je vais vous décevoir, mais non: L'attaquant peut écraser simplement /lib/x86_64-linux-gnu/libcrypto.so.3 avec son doppelganger (dans une version plus complète que nos 2-3 fonctions, je vous l’accorde), ou bien votre vérification peut-être trop perfectible dans l’analyse du nom et que l’attaquant renomme son doppelganger avec un nom pouvant faire illusion à cette analyse.

Cela ne change donc rien à notre petit système de vérification: échec et mat en notre défaveur.

Signer nos librairies (et nos binaires) ?

Avant de conclure, nous allons couvrir rapidement la possibilité de signature.

A l’heure actuelle, il n’existe pas de méthode officielle pour signer les applications sous Linux (hormis des prototypes comme elfsign). Mais rien ne nous empêche d’avoir des idées sur de possibles implémentations.

Avec le format ELF, il est possible de rajouter une section. Voyez une section comme une couche dans un sandwich. Chaque couche a une fonctionnalité particulière. Celles que vous voyez habituellement sont normées. Mais rien n’empêche de créer nos propres sections.

Imaginons un scénario où nous allons stocker des données dans une section au sein même de notre binaire (programme ou librairie, peu importe) :

$ echo "HELLO WORLD" > signature.txt

$ objcopy --add-section .signature=signature.txt example

$ readelf --section-headers example
  (...)
  [26] .comment          PROGBITS         0000000000000000  00003018
       000000000000002b  0000000000000001  MS       0     0     1
  [27] .signature        PROGBITS         0000000000000000  00003043
       000000000000000c  0000000000000000           0     0     1
  [28] .symtab           SYMTAB           0000000000000000  00003050
       00000000000002d0  0000000000000018          29    20     8
  (...)

$ readelf -x .signature example
  Hex dump of section '.signature':
    0x00000000 48454c4c 4f20574f 524c440a          HELLO WORLD.

Nous voyons que notre section “signature” a été intégrée à notre binaire. Avec cette méthode, nous pouvons imaginer stocker des éléments comme de la cryptographie (autre qu’un simple helloworld bien évidemment :) et ainsi vérifier si notre programme ou les librairies ont été modifiés. Mais cela est en dehors du scope de notre article (il nous faudrait un chapitre entier, voire deux…)

De par cette méthode, nous pourrions imaginer que, dès le lancement du programme, ce dernier lise la section signature des différentes librairies et du programme, puis effectue diverses vérifications cryptographiques.

Ceci dit, dans l’absolu, rien n'empêche l'attaquant d’étudier et de patcher directement le binaire du programme et de remplacer une instruction de JMP conditionnel (ex. JZ, JE, etc...) par une autre instruction pour casser la vérification ou la protection. Il faudrait voir différemment pour protéger un binaire et sa signature.

Un moyen sécurisé serait d’intégrer une vérification des signatures au sein même du kernel. Ainsi, si un programme est exécuté, le kernel va se charger de lire les bonnes sections puis de procéder aux vérifications d’usages et de décider si oui ou non, il procède au chargement des librairies et du programme, et enfin à son exécution.

Le static, c’est fantastique ?

Pour éviter nos désagréments avec nos librairies, nous pouvons utiliser une technique relativement simple: compiler en statique ! Et voilà ! tous vos problèmes de librairies seront résolus... Et c'est à ce moment que je vois certains confrères et consoeurs froncer des sourcils [insérer gif air suspicieux: serious or not].

Effectivement, compiler en statique ne fait que reporter le problème également (ou cacher sous le tapis, selon le point de vue).

Comme annoncé auparavant : même compilé, le comportement d'un programme est toujours étudiable, modifiable et donc détournable. En statique, un programme est également soumis à cette règle.

Que ce soit en soft: en modifiant les instructions directement en mémoire, par exemple via des buffer overflow et des shellcodes. Ou en dur: en modifiant les instructions directement dans le binaire: rappelez-vous qu’il existe des patchs binaires pour modifier le "comportement" (*petite toux*) de certains jeux ou programmes propriétaires. Patchs aussi appelés "crack" dans certains milieux non autorisés. Ces petits fichiers (ou programmes) intégrant la méthode de cracking que vous avez probablement dû utiliser une fois dans votre vie pour cracker un logiciel ou un jeu.

Conclusion

Est-ce qu'il existe une méthode pour protéger nos programmes ?

TLDR: Pas vraiment.

Les différentes méthodes étudiées ici permettent juste de repousser les attaques plus loin mais jamais de s'en protéger totalement, il suffit d'un sachant pour bypasser chaque méthode présentée. Vos binaires sont analysables en état. Un grand sage disait "tous les logiciels sont open source quand ils sont désassemblés" :)

Pour ces différentes raisons observées, certains fournisseurs de solutions se tournent vers des solutions hardwares. Une analyse possible est repoussée au niveau matériel. Les analystes capables d’étudier et de trouver des failles dans ce contexte sont plus réduits mais aucunement manquants. Il suffit pour cela de voir ce qu’il se passe quand une console sort, le software est attaqué et également le hardware. Les deux pouvant être des surfaces d’attaques potentielles.

C’est donc une fuite vers l’avant, une course sans fin entre les programmeurs et les attaquants où les premiers gagnent temporairement. Le tout est de connaître la limite de temps avant qu’une protection ne cède.