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.