Cours
d'assembleur
NASM, un ami qui vous veut du
bien.
Préface
Introduction
Base
de chiffres
Les
processeurs
Les
registres
Les
mnémoniques
Le format
ELF
Les syscalls
La
Libc
Etude de
cas
Spécifications
Déclaration
des variables
Inclure un
fichier
Adresses
spéciales
Définition
de constantes
Répétition
d'instructions
Opérateurs
SEG
et WRT
Annuler
l'optimisation
Expressions
critiques
Labels
locaux
Définition de
structures
Les
comparaisons
Macros
Macros
simples
Macros sur plusieurs
lignes
Analyse de codes
Macro
ENUM
Recup de argc
Code
polymorphique
Debuggers
Ndisasm
GBD
Strace
Application
Conclusion
Références
Remerciements
Je vous
préviens de suite, je ne suis pas un “gourou” de
l'assembleur, en écrivant ces lignes je continue à
apprendre l'assembleur. Mais je voulais partager cette expérience
avec toi publique :).
Vous me direz, mais à quoi ça
sert de savoir programmer en assembleur ? Et je vous répondrez
que l'assembleur est un langage très proche du langage
machine. De ce fait, en sachant programmer en assembleur vous
comprendrez beaucoup mieux les mécanismes qui régissent
le fonctionnement de votre cpu, et de ses périphériques.
D'un
point de vu pratique nous allons, ensemble, apprendre l'assembleur
pour, dans une suite d'articles prochains, comprendre les principes
du reversing et peut-être commencer à programmer nos
premiers virus.
Nous allons travailler sur un i386 (comme 95% des
PC) sous linux (debian sid). J'ai préféré
travailler avec NASM car il adopte la syntaxe intel ce qui, pour un
passage d'os à windows, pourrait être pratique.
Avant
de commencer je voudrais casser quelques mythes, l'assembleur n'est
pas plus dur à assimiler que le C, on peut l'utiliser pour
faire des programmes de haut niveau (sockets, environnement graphique
...). Si l'assembleur n'est pas aussi utiliser que le C c'est
principalement dûe au fait que le code n'est pas portable et
qu'il est plus fastidieux à créer.
Si vous êtes
déjà familier avec le fonctionnement d'un cpu et que
vous avez déjà les bases en assembleur, passez
l'introduction.
Bien, nous pouvons maintenant rentrer dans le vif
du sujet. Entrez dans mon monde.
Base
de chiffres :
Il y a la base décimale, celle
que nous connaissons tous : en base 10 (de 0 à 9)
La
base binaire : en base 2 (de 0 à 1)
110012
= 1 x 24 + 1 x 23 + 0 x 22 + 0 x 21
+ 1 x 20
=
16 + 8 + 1 = 25
On appel chaque “chiffre”, 0 ou 1
dans le cas présent, un bit (à ne pas confondre avec
Byte qui est un octet).
La base héxadécimale
: en base 16 (de 0 à F)
On compte ainsi en héxadécimal.
0=0; 1=1; 2=2; 3=3; 4=4; 5=5; 6=6; 7=7; 8=8; 9=9; A=10; B=11;
C=12; D=13; E=14; F=15.
2AF16 = 2 x 162
+ 10 x 161 + 15 x 160
=
512 + 160 + 15 = 687
L'intérêt de l'héxadécimal
est qu'il permet de représenter plus simplement des valeurs
binaires. Comme le binaire est en base 2 et l'héxadécimal
en base 16 il est simple de représenter 4 bits (24
= 16).
Par exemple : 1110 1001 0110
1110
E
9 6
E
Il faut également
savoir qu'un ensemble de 8 bits s'appel un octet. De là vos
fichiers en ko, Mo, Go.
Les
processeurs :
Un processeur est un composant électrique
composé, pour ce qui nous intéresse, de registres,
pour stocker des informations temporairement. D'une horloge
envoyant des pulsations toutes les microsecondes ou nanosecondes.
Lorsque vous achetez un processeur de type 1Ghz cela représente
la vitesse de cette horloge ; pour savoir en secondes la durée
d'un cycle de votre horloge faîtes 1/f où f est la
fréquence (soit 1/1e9 = 1 ns pour notre cas). Les
autres composants électronique se basent sur cette horloge
pour effectuer leurs tâches.
Parlons maintenant de
l'organisation de la mémoire – non, pas celle de votre
disque dur ; la mémoire du processeur et dans certains cas de
la RAM –. Chaque octet est repéré par un nombre
unique dans la mémoire de votre processeur. Mais le
processeur traitant des quantités incroyable de données,
il ne traite que rarement de simples octets. C'est pour cela que
l'on a nommé des ensembles d'octets.
word |
2 octets |
double word |
4 octets |
quad word |
8 octets |
paragraph |
16 octets |
Toutes les
données en informatique sont des nombres. Par exemple les
caractères sont sous forme de chiffre. On a seulement
créé une table de correspondance entre les valeurs
décimales et les caractères.
Actuellement, la
plus grande différence entre les différents types de
processeurs se situe donc au niveau de la taille des registres et de
la rapidité de l'horloge mais pas seulement. Si vous voulez
approfondir vos connaissances en matière d'architecture :
http://www.lri.fr/~temam/enseignement/x/.
Les
registres :
Nous travaillerons, comme je l'ai déjà
dit, sur un processeur de type 80386. Ce qui veut dire que
les registres auront une taille de 32 bits.
Les registres
sont des espaces mémoires nous permettant de stocker des
valeurs de 32 bits. Grâce à eux nous pourrons accéder
à une donnée précise, récupérer
la valeur de retour d'une fonction, passer des arguments à
une fonction, etc...
Ils sont organisés de cette façon
:
EAX |
EBX |
ECX |
EDX |
|
registre général |
registre général |
registre général |
registre général |
|
ESI |
EDI |
EBP |
EIP |
ESP |
offset mémoire |
offset mémoire |
offset mémoire gardant l'adresse de la fonction |
offset mémoire du code |
offset mémoire de la pile |
Les registres généraux servent de foure-tout. Tandis que les registres d'offset pointent généralement sur une adresse mémoire utile. Vous apprendrez au fur et à mesure quel registre utiliser pour quoi faire. Il existe d'autres registres plus spécifiques.
La
pile :
Sur les processeurs de type x86, la pile est un outil
permettant de stocker des données de façon temporaire
car rapide d'accès. La pile est dite LIFO (Last In First
Out), ce qui veut dire que la première chose mise dans la
pile sera la dernière chose enlevée de la pile. C'est
donc pour cela que l'on a l'habitude de s'imaginer la pile comme des
assiettes empilées les unes sur les autres, où chaque
assiette représenterait une donnée, puis que l'on
désempilerait de haut en bas.
Les mnémoniques pour
empiler et désempiler sont respectivement “push”
et “pop”. Un petit code pour illustrer ça :
push val1 ; val1 = 10 push val2 ; val2 = 20 pop val1 ; val1 = 20 pop val2 ; val2 = 10
Vous verrez plus loin que la pile est un outil indispensable dans la programmation assembleur.
Les
mnémoniques :
Toute instruction NASM peut être
résumée ainsi :
label : mnémonique opérandes
;commentaire
Il n'est pas obligatoire de retrouver tous les
éléments sur la même ligne. Les mnémoniques
représentent les instructions assembleur, ce qui nous
permet de programmer. Par exemple.
addition: add eax,4 ; j'ajoute 4 à eax et je stock le résultat dans eax
Il existe beaucoup de mnémoniques, pour un listing complet allez voir le manuel de NASM.
Le format ELF :
Sous
linux les éxécutables ont le format ELF pour
Executable and Linking Format. Ce format offre un découpage
modulaire de l'en-tête de l'éxécutable sous
cette forme : (Représentation très simplifiée)
ELF Header Program Header Table Segment #1 Segment #2 . . . Section Header Table Section 1 . . . Section n
Nous utiliserons trois segments dans notre code :
.data |
.bss |
.text |
Sert aux variables de tailles fixes. Variables initialisées. |
Sert aux variables de tailles inconnues. Variables non initialisées. |
C'est là que se trouve le code du programme. |
Pour plus d'informations sur le format ELF :
cat /usr/include/elf.h
Les
syscall :
En
assembleur sous linux, il existe une mnémonique permettant
de faire appel au noyau. C'est à dire que grâce à
cette mnémonique on peut demander au noyau d'effectuer une
action pour nous. La mnémonique en question se nomme int pour
“interruption”. Pour faire appel au kernel il faut lui
passer l'argument 0x80.
Nous verrons son utilisation dans un code
expliquer et commenter.
Pour une liste assez exhaustive des
différents syscalls :
http://www.lxhp.in-berlin.de/lhpsyscal.html
La
libc :
Il est également possible d'utiliser les
fonctions de la libc sous certaines conditions. Cela nous permet
de faire pas mal de choses simplement.
Etude
de cas :
Nous allons étudier là deux petits
programmes tout ce qu'il y a de plus banal, j'ai nommé des
“hello world”.
Le premier utilisant les
syscalls. Le syscall permettant d'écrire (write) est le 4.
Il prend en paramètres :
edx : la longueur de la
chaîne à affichée
ecx : un pointeur sur le
début de la chaîne
ebx : le handle où l'on
veut écrire (en l'occurence 1 pour l'écran)
eax :
le numéro du syscall à appeler
; NASM -f elf hello_world.asm ; ld hello_world.o -o hello_world segment .data hello db "Hello World !", 0xa ; 0xa équivaut à \0 len equ $ - hello ; taille de la chaîne (strlen(hello)) segment .text global _start _start: mov edx, len ; edx = longueur de la chaîne a afficher mov ecx, dword hello ; ecx pointe sur l'adresse du début de la chaîne mov ebx, 1 ; file handle, ou l'on écrit (STDOUT) mov eax, 4 ; sys_write int 0x80 ; call kernel mov eax,1 ; sys_exit xor ebx,ebx ; ebx = 0 (soit exit(0) en c) int 80h ; call kernel
Au début on défini
les variables dans les bons segments (rappelez-vous, variables de
longueurs fixes dans data ; variables de longueurs non définies
dans bss). len contient la taille de la chaîne hello, nous
verrons plus tard ce que représente la ligne qui définie
cette variable. global sert à définir le point
d'entrée du code (_start quand on compile avec ld et main
quand on compile avec gcc). Ensuite le remplissage des paramètres
pour appeler sys_write. dword est un mot clé pour dire que la
variable est de type doubleword. Puis on quitte proprement avec un
sys_exit.
Le second utilisant la fonction printf() de la
libc.
Pour utiliser la libc il faut lier le .o avec gcc. Pour
appeler une fonction de la libc il faut empiler les arguments dans
la pile (push) dans l'ordre inverse qu'ils sont demandés par
la fonction. Un exemple :
int fprintf(FILE *stream, const char *format, ...);
Il faudra donc empiler
le format en premier puis le stream soit :
push format push stream call fprintf pop eax ; valeur de retour de la fonction
extern permet de
dire au compilateur que l'on va appeler une fonction extérieure
à notre programme.
Il faut nettoyer la stack après
chaque appel à une fonction de la libc.
; NASM -f elf hello_printf.asm ; gcc hello_printf.o -o hello_printf global main extern printf ; on déclare la fonction printf comme externe section .data msg db "Hello, World",0Dh,0Ah,0 section .text main: push dword msg ; pointe vers l'adresse du début de la chaîne à afficher call printf ; printf() pop eax ; on nettoie la stack ret
Dans la programmation NASM les mot-clés du type dword, byte ... servent à spécifier que l'on pointe sur l'adresse de la variable et non pas sur son contenu. Comme dans notre cas :
dword msg
Nous allons maintenant apprendre les bases nécessaires pour comprendre un code NASM : la signification des mots clés, la déclaration des variables, l'étude de quelques mnémoniques ...
Déclaration
des variables :
Comme nous l'avons déjà vu, il
existe deux grands types de variables en assembleur : les variables
non-initialisées et les variables initialisées
déclarées respectivement dans les sections .bss et
.data. NASM nous permet de définir à l'aide de lettres
la taille de l'espace mémoire que nous allouons à ces
données.
La section .data :
db 0x55 ; un octet : 0x55 db 0x55, 0x56, 0x57 ; trois octets db 'a', 0x55 ; deux octets (a = 0x41) db 'hello', 13, 10,'$' ; ça marche aussi avec les chaînes de caractères dw 0x1234 ; deux octets : 0x34 0x12 dw 'a' ; 0x41 0x00 dw 'abc' ; 0x41 0x42 0x43 0x00 dd 0x12345678 ; quatre octets : 0x78 0x56 0x34 0x12 dd 1.234567e20 ; nombre à virgule de précision float dq 1.234567e20 ; nombre à virgule de double précision float dt 1.234567e20 ; nombre à virgule de précision float étendue
La précision des
nombres de type “float” désigne le nombre de
chiffres, après la virgule, gardés en mémoire.
La
section .bss :
resb 255 ; REServe 255 octets resb 1 ; REServe 1 octets resw 1 ; REServe 1 Word (1 Word = 2 octets ) resd 1 ; REServe 1 Double word (soit 4 octets) resq 1 ; REServe 1 float à double précision rest 1 ; REServe 1 float à précision étendue
Inclure
un fichier :
Le mot réservé INCBIN permet
d'inclure un fichier binaire dans votre source.
incbin "file.dat" ; inclue un fichier entier incbin "file.dat", 1024 ; inclue le fichier sans les 1024 premiers octets incbin "file.dat", 1024,512 ; inclue le fichier sans les 1024 premiers octets ; et jusqu'à 512 octets
Adresses
spéciales :
Les jetons $ et $$ désignent eux
des adresses spécifiques : $ représente l'adresse de
l'instruction par rapport au début du code et $$ l'adresse de
l'instruction par rapport au début de la section. Ainsi pour
créé une boucle infinie ou pourrait faire
jmp $
Définition
de constantes : EQU :
EQU permet de définir une
constante dont la valeur ne pourra donc pas changée. Par
exemple :
message db 'hello, world' msglen equ $-message
msglen représente la taille de message soit 12 octets.
Répétition
d'instructions : TIMES :
Le préfixe TIMES fait que
l'instruction est assemblée plusieurs fois. L'avantage de
cette instruction est qu'elle peut permettre d'effectuer plusieurs
fois un ensemble d'action plus ou moins complexes.
buffer: db 'hello, world' times 64-$+buffer db ' '
Cet exemple va donc réserver
64 octets pour y stocker buffer.
On peut utiliser TIMES pour
répéter n'importe quelle mnémonique.
Opérateurs
:
NASM, tout comme le C accepte des opérateurs
arithmétiques forts utiles. A la différence que les
opérateurs Nasm ne sont utilisables que dans le préprocesseur
(calcul d'adresse, macros ...), cf : expressions critiques.
|
|
|
|
|
|
+, -, ~ : opérateurs s'applicant sur un argument |
Tout comme en C et en électronique, cet opérateur effectue un OR bit par bit à vos données. |
Tout comme en C et en électronique, cet opérateur effectue un XOR bit par bit à vos données. |
Tout comme en C et en électronique, cet opérateur effectue un AND bit par bit à vos données. |
Comme en C cet opérateur permet de copier le nombre de bits (spécifier par la deuxième opérande) de la première opérande en commençant par le bit de poid faible. |
Tout comme en C, ces opérandes permettent de faire une addition ou une soustraction. |
* permet de faire une
multiplication |
Lorsque vous mettez un
+ ou un – devant une seul variable vous rendez
respectivement la variable en signe positif ou en signe
négatif. |
Comme
il est essentiel de comprendre le fonctionnement de ces opérateurs
nous allons développer un peu les opérateurs agissant
au niveau binaire :
L'opérateur OR.
Bit 1 |
Bit 2 |
Bit 1 | Bit 2 |
0 |
0 |
0 |
0 |
1 |
1 |
1 |
0 |
1 |
1 |
1 |
1 |
L'opérateur
AND.
Bit 1 |
Bit 2 |
Bit 1 & Bit 2 |
0 |
0 |
0 |
0 |
1 |
0 |
1 |
0 |
0 |
1 |
1 |
1 |
L'opérateur
XOR.
Bit 1 |
Bit 2 |
Bit 1 ^ Bit 2 |
0 |
0 |
0 |
0 |
1 |
1 |
1 |
0 |
1 |
1 |
1 |
0 |
L'opérateur
NOT.
Bit |
~Bit |
0 |
1 |
1 |
0 |
L'opérateur
de décalage binaire.
Prenons l'exemple de 42 << 2 :
42 en binaire. |
|||||||||
|
|
0 |
0 |
1 |
0 |
1 |
0 |
1 |
0 |
Le résultat de 42 << 2 soit 168 en décimal. |
|||||||||
0 |
0 |
1 |
0 |
1 |
0 |
1 |
0 |
0 |
0 |
SEG
et WRT :
Dans un
programme en assembleur, il existe différents types de
segments, dont les plus connues sont CS/DS/SS pour Code Segment,
Data Segment et Stack Segment. Ceux-ci permettent d'atteindre des
adresses lointaines. Ainsi en mode 16 bits, ces segments sont
éparpillés car ne pouvant être placés
dans la même zone mémoire tandis qu'en mode 32 bits,
les segments sont accessibles dans la même zone mémoire.
SEG
et WRT permettent d'accéder à l'adresse d'une donnée
lointaine. Ce qui n'est que très rarement utile en mode 32
bits. Les seuls différences de ces mnémoniques sont au
niveau de la syntaxe et au niveau du résultat
obtenu.
Quelques exemples :
mov ax, seg donnee mov es, ax mov bx, donnee ; ES:BX contient un pointeur valide vers donnee
La mnémonique SEG
récupère donc l'offset de donnee. Pour cela elle
utilise un “base segment” par défaut pour obtenir
un offset exploitable.
Ce que l'on peut spécifier grâce
à la mnémonique WRT :
mov ax, base_segment mov es, ax mov bx, donnee wrt base_segment ; ES:BX contient un pointeur different mais tout aussi fonctionnel vers donnee
Ici base_segment représente
donc le “base segment” :).
NASM permet de faire des
appels et des sauts vers des labels lointains sous la forme :
call segment:offset ; ou segment et offset sont des valeurs numériques
On peut donc à l'aide de SEG et WRT réaliser le calcul immédiatement :
call (seg procedure):procedure ; les parentheses ne sont call weird_seg:(procedure wrt weird_seg) ; pas obligatoire en pratique.
Annuler
l'optimisation : STRICT :
Lorsque l'on passe à NASM
l'argument “-On”, où n est un chiffre spécifiant
le niveau d'optimisation, NASM effectue une optimisation du code. Il
est possible que pour certaines instructions vous ne vouliez pas que
l'optimisation ai lieu. C'est pour cela que STRICT a été
inventé.
Par exemple avec l'optimisation :
push dword 33 ; 66 6A 21 push strict dword 33 ; 66 68 21 00 00 00
Expressions
critiques :
Une limitation de NASM est qu'il assemble le code
en deux phases minimum (sans optimisation) ; pas comme tasm ou
d'autres. La première phase (préprocesseur) consiste
pour NASM à récupérer la taille globale du
programme, des segments de données ... Ainsi lors de la
deuxième phase (runtime), la génération de
l'exécutable, NASM connaît toutes les adresses des
données dont fait référence le code
source.
Donc ce que NASM ne peux prendre en compte sont les
instructions du code source faisant référence à
une donnée déclarée après.
Par
exemple :
times (label-$) db 0 label: db 'Where am I ?'
NASM ne pourra compiler ce
code car la ligne déclarant label est après celle lui
allouant de l'espace mémoire.
Ce concept a été
appelé expression critique. Il existe différentes
instructions qui peuvent être restreintent par ces expressions
critiques tel que TIMES, RES*, EQU ...
Labels
locaux :
Le point permet à NASM de déclarer des
labels locaux, comme on le voit dans l'exemple. En plus, NASM permet
de faire référence à un label en-dehors du
domaine local.
label1 ; some code .loop ; some more code jne .loop ret label2 ; some code .loop ; some more code jne .loop ret label3 ; some code ; some more code jmp label1.loop
Ainsi le premier jne .loop ira directement à label1.loop, le deuxième jne .loop ira à label2.loop et le dernier jmp label1.loop ira au début du code (label1.loop).
Définition
de structures : STRUC :
Pour définir une structure on
fait appel au préprocesseur par le biais du mot-clé
STRUC. Il ne prend qu'un argument. STRUC n'est qu'une macro qui
effectue en réalité plusieurs EQU.
struc mytype .long: resd 1 .word: resw 1 .byte: resb 1 .str: resb 32 endstruc
On déclare donc la structure mytype avec des variables locales de types différents : mytype.long, mytype.word, mytype.byte et mytype.str.
Les
comparaisons : CMP :
Les langages de programmation habituels
tels que le C utilisent des structures de contrôle du flux
d'éxécution du programme (if, while, switch) ce qui
n'existe pas en assembleur. Pour pallier à cela on a recoure
à la mnémonique CMP qui effectue une simple
soustraction et stock le résultat dans un registre spécial,
le registre FLAGS.
Pour les entiers non-signés il y a
deux bits importants dans le registre FLAGS : le zero (ZF) et la
portée (CF)
cmp vleft, vright
La soustraction entre vleft
et vright est effectuée et
- si vleft = vright, alors ZF
est mis à 1 et CF est mis à 0
- si vleft >
vright, alors ZF est mis à 0 et CF est mis à 0
- si
vleft < vright, alors ZF est mis à 0 et CF est mis à
1
Pour les entiers signés il y a trois bits importants
dans le registre FLAGS : le zero (ZF), la surcharge (OF) et le signe
(SF)
- si vleft = vright, alors ZF est mis à 1
- si
vleft > vright, alors ZF est mis à 0 et SF = OF
- si
vleft < vright, alors ZF est mis à 0 et SF != OF
Les
valeurs que prennent ces bits servent ensuite à éguiller
le flux d'éxécution du code à l'aide de
mnémoniques d'aiguillage dont voici un bref tableau
récapitualtif :
JZ |
Commute
seulement si ZF = 1 |
L'instruction JMP
saute quelque soit la valeur du registre FLAGS. On peut spécifier
une certaine portée à nos mnémoniques
d'aiguillage pour minimiser la taille du code par exemple :
-
SHORT permet de sauter à 128 octets vers le haut ou vers le
bas du code.
- NEAR permet de sauter n'importe où dans le
segment courant.
- FAR permet de sauter dans n'importe quel autre
segment.
De plus les processeurs de type 80x86 offrent la
possibilités de rendre ces tests plus simples par le biais de
mnémoniques d'aiguillage plus adaptées :
Entiers signés |
Entiers non signés |
JE
Commute
si vleft = vright |
JE
Commute
si vleft = vright |
Et pour finir un
petit bout de code exemple :
cmp eax, 0xffffffff ; si eax != -1 jne thenblock ; on saute vers thenblock mov DWORD PTR [esp], 0x1 ; on empile les arguments pour le call call ptrace ; on appel la fonction jmp next ; on passe à la suite thenblock : mov DWORD PTR [esp], 0x8048564 ; on empile d'autres arguments call ptrace ; on appel la fonction next : ; on continue l'éxécution
Il ne faut pas oublier que le registre FLAGS est modifiable via d'autres mnémoniques.
NASM comporte un outil très puissant, j'ai nommé le processeur de macros. Cet outil nous permet de définir des instructions plus ou moins complexes réutilisables de façon assez simple une fois que l'on a maîtriser la syntaxe. Dans ce chapitre nous allons apprendre à utiliser les macros déjà disponibles et à créer nos macros.
Les
macros simples :
Les macros simples permettent par le biais
du préprocesseur de définir de nouvelles macros tout
comme en C. Le mot clé permettant cette prouesse est
“%define” et ses dérivés.
%define ctrl 0x1F & ; définition de ctrl = 0x1F & %define param(a,b) ((a)+(a)*(b)) ; définition de param() mov byte [param(2,ebx)], ctrl 'D' ; = mov byte [(2)+(2)*(ebx)], 0x1F & 'D'
La macro %define ne subit pas la limitation des expressions critiques.
%define a(x) 1+b(x) %define b(x) 2*x mov ax, a(8) ; mov ax, 1+2*8
Les noms des macros sont sensibles à la casse. C'est pour cela qu'il y a “%idefine” qui permet de définir un ensemble de noms pour la macro.
%define foo bar ; foo %idefine foo bar ; foo, FOO, fOo, foO, ...
Il est également possible de surcharger les arguments d'une macro :
%define foo(x) 1+x %define foo(x, y) 1+x*y mov ax, foo(4) ; ax = 1+4 mov ax, foo(4,5) ; ax = 1+4*5
Parlons un peu de “%xdefine”. Cette instruction permet de déclarer une macro en fonction de son contexte et non pas comme “%define” qui elle déclare une macro en fonction de la valeur au moment de l'appelle à celle-ci. Un exemple :
%define isTrue 1 %define isFalse isTrue %define isTrue 0 val1: db isFalse ; val1 = 0 %define isTrue 1 val2: db isFalse ; val2 = 1 |
%xdefine isTrue 1 %xdefine isFalse isTrue %xdefine isTrue 0 val1: db isFalse ; val1 = 1 %xdefine isTrue 1 val2: db isFalse ; val2 = 1 |
Dans le cas de
“%define”, val1 prend la valeur 0 car lorsque l'on appel
la macro “isFalse”, “isTrue” vaut 0 ; de
même pour val2.
Pour “%xdefine” val1 vaut 1 car
lorsque l'on déclare “isFalse”, “isTrue”
vaut 1. Ainsi “isFalse” vaudra toujours 1 quelque soit
la valeur de “isTrue“ plus loin dans le code.
Il
existe une autre instruction pour créer des macros :
“%assign”. Celle-ci n'est utilisée que pour les
macros d'une ligne ne prenant aucuns arguments et de valeur
numérique. On la retrouve souvent lorsqu'il faut effectuer
une incrémentation ou décrémentation dans une
macro.
Pour détruire une macro on utilise le mot clé
“%undef”
Les
macros sur plusieurs lignes :
La définition d'une
macro de plusieurs lignes passe par le mot-clé “%macro”
avec cette syntaxe :
%macro nom_de_macro nb_arguments ; un peu de code %endmacro
Où nom_de_macro est le nom de la macro et nb_arguments un nombre représentant le nombre d'arguments passés à la macro. Un exemple :
%macro silly 2 %2: db %1 %endmacro
Où %2 représente le deuxième argument et %1 le premier argument.
silly 'hello_world', hello ; hello: db 'hello_world'
De plus on peut déclarer un intervalle d'arguments et des valeurs par défaut :
%macro silly 2+ ; 2 arguments ou plus %endmacro %macro foobar 1-3 eax, [ebx+2] ; au minimum 1 argument ; et pas plus de 3 arguments %endmacro
Pour la macro foobar,
lorsque les arguments %2 et %3 ne sont pas déclarés,
%2 = eax et %3 = [ebx+2].
L'astérisque (*) permet de
déclarer un nombre infini d'arguments.
L'argument %0
est le nombre d'arguments passés à la
macro.
L'instruction %rotate permet de faire tourner les
arguments de la macro de x vers la gauche ou la droite
respectivement si x est positif ou si x est négatif.
%macro multipush 1-* ; 1 arguments minimum %rep %0 ; boucle tant qu'il y a des arguments push %1 ; on push l'argument courant %rotate 1 ; on passe à l'argument suivant %endrep %endmacro
Maintenant que nous possédons les notions théoriques essentielles (et un peu plus quand même :D) nous allons étudier quelques codes nasm. Histoire de voir à quoi cela peut nous servir en pratique.
Macro
ENUM :
Un petit code source de mammon_ tiré du
Assembly
Programming Journal. Cette macro permet de créer une
énumération de variables associées à des
valeurs numériques. Tout comme le fait le mot-clé enum
en C.
;Summary: A NASM macro emulating the C 'ENUM" command ;Assembler: NASM ;by mammon_ && modified by Flyers %macro ENUM 2-* ;Usage: ENUM int SYMBOLS %assign i %1 ; where int is the number to begin enumeration at [0] %rep %0 -1 ; SYMBOLS is a list of Symbols to define %2 EQU i ;Example: ENUM 0, TRUE, FALSE %assign i i+1 ; this EQUates TRUE to 0 and FALSE to 1 %rotate 1 ;Example: ENUM 11, JACK, QUEEN, KING %endrep ; this EQUs JACK to 11, QUEEN to 12, KING to 13 %endmacro
Ce code ne devrait pas vous
être si dur à assimiler maintenant mais je vais vous
aider.
On commence par créer la macro avec au minimum
deux paramètres.
On initialise i à la valeur du
premier élément à énumérer (soit
le premier argument de la macro).
On attribue ensuite à
chaque argument (-1 pour ne pas prendre en compte le premier
argument) la valeur de i que l'on incrémente. On peut résumer
cela par une boucle “for ( i = %1; i < %0 -1; i++)”.
Recup
de argc :
Nous allons étudier un bout de code qui
s'occupe de récupérer argc (vous savez c'est la
variable contenant le nombre d'arguments passés au programme
en ligne de commande), qui le transforme en caractère et qui
l'affiche à l'écran.
Avant de commencer il faut que
vous ayez quelques connaissances indispensables :
- La pile,
lorsqu'un programme est lancer sous linux, ressemble à ça
:
argc |
[dword] compteur d'arguments (integer) |
argv[0] |
[dword] nom du programme (pointer) |
argv[1] ... argv[argc-1] |
[dword] arguments du programme (pointers) |
NULL |
[dword] fin des arguments (integer) |
env[0] env[1] ... env[n] |
[dword] variables d'environnement (pointers) |
NULL |
[dword] fin des variables d'environnement (integer) |
Mais il faut savoir que
ce schémas ne s'applique que lorsqu'on ne lie pas notre
programme avec la libc cad lorsque l'on n'utilise pas gcc. Quand la
libc est utilisée il reste dans la pile une valeur de retour.
Il faut donc la nettoyée comme on le fait dans le code.
-
En informatique on a créé des tables de caractères
qui sont des correspondances entre des caractères et des
valeurs numériques. La plus vieille est la
table de caractère ASCII. On remarque dans celle-ci que
le nombre 0 est représenter par la valeur 48 en décimal
soit 0x30 en héxadécimal. C'est donc en ajoutant 0x30
à la valeur numérique que j'obtiens la string
correspondante. Il y a un problème majeur à cette
astuce : je ne peux afficher que les valeurs ASCII des nombres
allant de 0 à 9 seulement. Donc le programme souffre d'un bug
connu (dommage pour les bidouilleurs qui auraient cru faire une
trouvaille).
- Il faut se souvenir que le registre esp pointe
sur le haut de la pile.
Voila, maintenant le code en question
commenter et j'espère compréhensible :
; nasm -f elf argc.asm ; ld argc.o -o argc ; pour ne pas passer par la libc ; gcc argc.o -o argc ; pour utiliser la libc ; by Flyers segment .text: global _start ; gcc : global main _start: ; gcc : main: pop eax ; ld : argc ; gcc : valeur de retour de la libc ; pop eax ; gcc : argc ; pop eax ; argv[0] ; pop eax ; the first real arg push 0x0A30 ; on push 30 pour convertir argc en ASCII ; et 0A pour la fin de chaîne add [esp],eax ; on ajoute ce que contient la stack à argc mov edx, 2 ; on affichera 2 bytes (argc+\0) mov ecx, esp ; la chaîne à afficher mov ebx,1 ; file handle, ou l'on écrit (STDOUT) mov eax,4 ; sys_write int 80h ; call kernel ; pour éviter que le programme ne segfault à la fin on appel sys_exit mov eax,1 xor ebx,ebx int 80h
Code
s'auto-modifiant :
Un code s'auto-modifiant est un code
capable de se modifier lui même lors de l'éxécution.
L'étude d'un tel code peut être une bonne introduction
à la programmation de virii polymorphiques. Pour créé
un code s'auto-modifiant, il faut être capable de jongler
entre les adresses des différentes instructions car on ne
peut spécifier d'adresse statique lorsque le programme est en
éxécution, on utilise donc des adresses relatives
comme vous allez le voir.
Karsten Scheibler a écrit
un très bon article
dont voici une brève traduction :
L'idée
principale : il existe un syscall, sys_mprotect, qui permet de
modifier les flags pour (presque) toutes les pages. Une page est la
plus petite unité dans la gestion de la mémoire
virtuelle. Sur les processeurs x86, la taille de la page est de 4Ko.
Mais il n'est pas nécessaire de faire appel à ce
syscall pour donner à la section .bss les droits en éxécution
car sur les processeurs x86, une page avec les droits de lecture a
également les droits en éxécution et la section
.bss est en lecture/écriture. Mais ce syscall risque de
devenir obsolète avec l'apparition du NX-flag sur les
nouveaux processeurs.
Les deux premiers exemples copient un
bout de code dans la section .bss puis l'éxécute.
Parce que nous avons les droits en lecture/écriture/éxécution
dans cette zone mémoire, le programme peut s'auto-modifier.
Le premier exemple (code1_start) copie un simple hello_world
(utilisant sys_write), mais avant de l'éxécuter nous
modifions quelques valeurs dans le code (le début et la
taille de la chaîne à afficher). Le deuxième
(code2_start) effectue une vraie auto-modification. L'instruction
“rep stosb” écrase les quatre premiers “inc
ebx” avec des “nop”, ainsi la chaîne à
afficher à l'écran contient 04h au lieu de 08h
attendu. Le troisième exemple (endless), quant à lui,
modifie du code dans la section .text en y ajoutant les droits en
lecture/écriture/éxécution via
sys_mprotect.
Note : Si vous voyez un 08h au lieu de 04h à
l'écran, vous devriez connaître un drôle de
comportement des codes s'auto-modifiants. Sur les processeurs clone
du Pentium, la modification de la queue n'est pas prise en compte
tout de suite. Il faut en quelque sorte rafraîchir la queue,
pour cela un simple jmp suffit (essayez avec un “jmp”
juste après le “rep stosb”).
;**************************************************************************** ;**************************************************************************** ;* ;* USING SELF MODIFYING CODE UNDER LINUX ;* ;* written by Karsten Scheibler, 2004-AUG-09 ;* ;**************************************************************************** ;**************************************************************************** global _start ;**************************************************************************** ;* some assign's ;**************************************************************************** %assign SYS_WRITE 4 %assign SYS_MPROTECT 125 %assign PROT_READ 1 %assign PROT_WRITE 2 %assign PROT_EXEC 4 ;**************************************************************************** ;* data ;**************************************************************************** section .bss alignb 4 modified_code: resb 0x2000 ;**************************************************************************** ;* smc_start ;**************************************************************************** section .text _start: ;calcul l'adresse de la section .bss, elle doit se trouvée entre ;deux pages (x86: 4KB = 0x1000) ;NOTE: Dans cet exemple c'est inutile car chaque segment est aligné ; en fonction des pages et nous ne l'utilisons qu'une fois, ; donc nous savons qu'il est entre deux pages, mais si vous avez ; plus d'une section .bss dans votre programme vous ne pouvez ; en être sûr. mov dword ebp, (modified_code + 0x1000) and dword ebp, 0xfffff000 ;change les flags de la section en lecture/écriture/éxécution, ;NOTE: Sur les processeurs x86 cela est inutile comme cela a ; déjà été vu plus haut. mov dword eax, SYS_MPROTECT mov dword ebx, ebp mov dword ecx, 0x1000 mov dword edx, (PROT_READ | PROT_WRITE | PROT_EXEC) int byte 0x80 test dword eax, eax js near smc_error ;éxécute le code non modifié code1_start: mov dword eax, SYS_WRITE mov dword ebx, 1 mov dword ecx, hello_world_1 code1_mark_1: mov dword edx, (hello_world_2 - hello_world_1) code1_mark_2: int byte 0x80 code1_end: ;copie le code vers la bonne page (dont l'adresse est encore dans ebp) mov dword ecx, (code1_end - code1_start) mov dword esi, code1_start mov dword edi, ebp cld rep movsb ;on y ajoute le code héxa de l'instruction 'ret', comme cela ;nous pourrons utiliser call mov byte al, [return] stosb ;change quelques valeurs dans le code: l'adresse de début du texte ;et sa taille mov dword eax, hello_world_2 mov dword ebx, (code1_mark_1 - code1_start) mov dword [ebx + ebp - 4], eax mov dword eax, (hello_world_3 - hello_world_2) mov dword ebx, (code1_mark_2 - code1_start) mov dword [ebx + ebp - 4], eax ;finalement on l'appel call dword ebp ;copie le deuxième exemple mov dword ecx, (code2_end - code2_start) mov dword esi, code2_start mov dword edi, ebp rep movsb ;fait quelque chose de vraiment méchant: edi pointe juste après ;l'instruction 'rep stosb', donc cela va réellement modifier le code mov dword edi, ebp add dword edi, (code2_mark - code2_start) call dword ebp ;modifie le code dans la section .text endless: ;ajoute les droits à la section .text pour la modifiée mov dword eax, SYS_MPROTECT mov dword ebx, smc_start and dword ebx, 0xfffff000 mov dword ecx, 0x2000 mov dword edx, (PROT_READ | PROT_WRITE | PROT_EXEC) int byte 0x80 test dword eax, eax js near smc_error ;affiche le message à l'écran mov dword eax, SYS_WRITE mov dword ebx, 1 mov dword ecx, endless_loop mov dword edx, (hello_world_1 - endless_loop) int byte 0x80 ;ici, les instructions empêchant la boucle sans fin mov dword ecx, (smc_end_1 - smc_end) mov dword esi, smc_end mov dword edi, endless rep movsb ;et on recommence jmp short endless ;**************************************************************************** ;* code2 ;**************************************************************************** ;ici les adresses des instructions dont on rajoute les ;codes héxa dans notre code return: ret no_operation: nop ;ici du vrai code s'auto-modifiant, s'il est bien ;copier dans .bss et edi bien charger, ebx doit contenir ;0x4 au lieu de 0x8 code2_start: mov byte al, [no_operation] xor dword ebx, ebx mov dword ecx, 0x04 rep stosb code2_mark: inc dword ebx inc dword ebx inc dword ebx inc dword ebx inc dword ebx inc dword ebx inc dword ebx inc dword ebx call dword [function_pointer] ret code2_end: align 4 function_pointer: dd write_hex ;**************************************************************************** ;* write_hex ;**************************************************************************** write_hex: mov byte bh, bl shr byte bl, 4 add byte bl, 0x30 cmp byte bl, 0x3a jb short .number_1 add byte bl, 0x07 .number_1: mov byte [hex_number], bl and byte bh, 0x0f add byte bh, 0x30 cmp byte bh, 0x3a jb short .number_2 add byte bh, 0x07 .number_2: mov byte [hex_number + 1], bh mov dword eax, SYS_WRITE mov dword ebx, 1 mov dword ecx, hex_text mov dword edx, 9 int byte 0x80 ret section .data hex_text: db "ebx: " hex_number: db "00h", 10 ;**************************************************************************** ;* some text ;**************************************************************************** endless_loop: db "No endless loop here!", 10 hello_world_1: db "Hello World!", 10 hello_world_2: db "This code was modified!", 10 hello_world_3: ;**************************************************************************** ;* smc_error ;**************************************************************************** section .text smc_error: xor dword eax, eax inc dword eax mov dword ebx, eax int byte 0x80 ;**************************************************************************** ;* smc_end ;**************************************************************************** section .text smc_end: xor dword eax, eax xor dword ebx, ebx inc dword eax int byte 0x80 smc_end_1: ;*********************************************** linuxassembly@unusedino.de *
Vous avez toutes les connaissances théoriques pour comprendre le fonctionnement du code en général. Si vous voulez le comprendre plus en détail, allez voir les mnémoniques que vous ne comprenez pas dans le manuel de nasm ou essayez de calculer les adresses relatives en imaginant que les labels représentent des adresses fixes.
Pour découvrir les sources de bugs dans un code compiler on utilise des debuggers. Il en existe de deux sortes : les debuggers passifs et les debuggers actifs. Les debuggers passifs sont également appelés désassembleurs car ils ne font qu'afficher le code source de l'éxécutable. Ainsi, ils sont passifs du fait qu'ils n'éxécutent pas une ligne de code. Les debuggers actifs, eux, éxécutent le code tout en permettant grâce à certains signaux système de bloquer l'éxécution du programme.
Ndisasm
:
Cet outil est le désassembleur Nasm. Créé
par les développeurs de notre compilo favori, il affiche en
sortie du code nasm (cad avec la syntaxe intel). Bien que le code de
sortie puisse paraître différent du fichier source.
Une
petite astuce : si vous voulez que le code désassembler
ressemble un peu plus à ce que vous avez coder utilisez
l'option -b 32. Qui spécifie que le code sera désassemblé
en utilisant des registres de 32 bits.
ndisasm -b 32 hello_world | less
GDB
:
gdb est le debugger fournie par défaut avec Linux,
il est à gcc ce qu'est ndisasm à nasm. Bien qu'il soit
beaucoup plus développé. gdb est un debugger ce qui
veut dire qu'il est capable de lancer un processus, de le bloquer,
de le désassembler ...
Nous allons voir comment effectuer
quelques opérations de bases avec gdb via un exemple.
Nous
allons donc chercher à debugger un programme simple qui
s'occupe de nous donner la moyenne de plusieurs nombres contenus
dans un tableau.
#include <stdio.h> int main(void) { int a[10] = {1, 58, 45, 87, 78, 98, 15, 56, 78, 21}; printf ("Moyenne du tableau a : %d", moyenne(a, sizeof(a))); return 0; } int moyenne(int tableau[], int taille) { int total = 0, moyenne, i; for( i=0; i<taille; i++) total += tableau[i]; return total/taille; }
Pour compiler ce code nous allons utiliser l'option -g de gcc permettant d'avoir des informations de debuggage plus précises : gcc moyenne.c -g -o moyenne.
flyers@Cyfik:~$ gdb moyenne (gdb) run Starting program: /home/flyers/moyenne Moyenne du tableau a : 10027686 Program exited normally. (gdb) break main Breakpoint 1 at 0x8048394: file moyenne.c, line 12.
On place un breakpoint à l'adresse de la fonction main() : 0x8048394. Et on relance le programme.
(gdb) run Starting program: /home/flyers/moyenne Breakpoint 1, main () at moyenne.c:12 12 int a[10] = {1, 58, 45, 87, 78, 98, 15, 56, 78, 21}; (gdb) print a $1 = {0, 134518396, -1073742984, 134513293, -1208116144, -1073742972, -1073742952, 134513787, -1073742804, -1208116128}
On affiche notre tableau qui n'est pas encore initialisé (c'est pour ça qu'il contient des valeurs fantaisistes).
(gdb) next 13 printf ("Moyenne du tableau a : %d", moyenne(a, sizeof(a))); (gdb) print a $2 = {1, 58, 45, 87, 78, 98, 15, 56, 78, 21}
Après initialisation, il est tout beau le tableau :)
(gdb) step moyenne (tableau=0xbffffb60, taille=40) at moyenne.c:18 18 int total = 0, moyenne, i;
next permet de passer à l'instruction suivante mais pas à sauter vers la fonction appelée tandis que step sert justement à cela. On saute dans la fonction moyenne()
(gdb) display tableau[i] 1: tableau[i] = 1 (gdb) display total 2: total = -1073742896
display permet d'afficher les valeurs des variables de façon actualisée.
(gdb) next 19 for( i=0; i<taille; i++) 2: total = -1208115591 1: tableau[i] = -1208116128
Après une bonne
vingtaine de next (lorsque l'on valide une ligne vide, gdb refait
l'instruction précédente), on se rend compte que des
valeurs bizarres sont ajoutées à la variable total.
Cela est dûe à la variable taille qui est à la
base du fait que l'on effectue trop d'itérations, ce qui fait
que la variable tableau[i] prend des valeurs au hasard dans la pile
et celles-ci sont ajoutées à la valeur de la variable
total.
L'initialisation de la variable taille est donc foireuse.
En effet, sizeof ne renvoie pas le nombre d'éléments
contenues dans le tableau mais la taille en octet de celui-ci ce qui
donne donc 40 (10 * int où int = 4 sur notre x86).
(gdb) quit The program is running. Exit anyway? (y or n) y
Ainsi si l'on modifie la ligne
moyenne(a, sizeof(a))
par
moyenne(a, sizeof(a)/sizeof(*a))
notre programme fonctionnera
parfaitement avec une variable taille contenant 10.
gdb
permet d'utiliser de nombreuses commandes, ce qui risque d'être
fastidieux à apprendre et à taper surtout pour les
gros programmes à debugger c'est pour cela qu'il existe des
abbréviations de ces commandes (ainsi que l'auto-complétion
cf: touche “TAB”) dont voici un petit récapitulatif
:
help |
h |
Accéder à l'aide |
run |
r |
Lancer le processus |
next |
n |
Ligne suivante dans le code |
step |
s |
Sauter dans une fonction |
break |
b |
Mettre en place un breakpoint |
|
p |
Afficher une variable |
quit |
q |
Quitter gdb |
frame |
f |
Affiche des informations sur la page de pile courante |
backtrace |
bt |
Affiche la pile d'appels |
info |
i |
Donne des informations (info sans arguments pour savoir sur quoi) |
disable |
dis |
Désactiver momentanément un breakpoint |
enable |
en |
Activer un breakpoint |
delete |
d |
Supprimer un breakpoint |
continue |
c |
Continuer le programme après un breakpoint |
disassemble |
disass |
Désassemble tout ou partie du code. |
La commande set
permet de définir toute sorte de paramètres (dans gdb
ou dans le programme en cours de debuggage). Pour voir tout ce que
l'on peut définir : “h set“. Une petite astuce
pour nous autre asmongueurs :) à la sauce intel : gdb de base
désassemble le code dans la syntaxe gas ce qui n'est pas très
compréhensible pour nous, qu'à cela ne tienne, la
commande “set disassembly-flavor intel” réparera
cet affront et faira en sorte que le code désassemblé
sera dans la syntaxe intel.
Strace
:
Strace est un debugger actif permettant de suivre tous les
appels et les signaux émis par le programme. Cet utilitaire
est très utile pour commencer l'étude du
fonctionnement d'un binaire au sein du système.
Par
exemple, si l'on strace notre hello_world de début d'article,
on ne verra que les appels aux syscall write et exit. Tandis que le
strace de hello_printf nous montreras tous les appels et signaux de
la libc lors du lancement du programme ainsi que lors de la sortie.
Je vous laisse tester par vous mêmes :).
Application
:
Nous allons maintenant appliquer les notions que nous avons
apprises ci-avant. Pour cela on va tenter d'outre-passer une
protection de type anti-ptrace. Le code contenant la protection
“ptraced.c”:
#include <sys/ptrace.h> int main(){ if (ptrace(PTRACE_TRACEME, 0, 1, 0) == -1) exit(1); puts("FOO"); }
Ainsi donc, si le programme est ptracé alors le programme quitte. Sinon il nous affiche la chaîne “FOO”.
flyers@Cyfik:~$ gdb ptraced (gdb) r Starting program: /home/flyers/Data/Code/tmp/ptraced Program exited with code 01. # Le code a bien repéré qu'on le ptrace (gdb) set disassembly-flavor intel (gdb) disass main Dump of assembler code for function main: 0x080483f4 : push ebp 0x080483f5 : mov ebp,esp 0x080483f7 : sub esp,0x18 0x080483fa : and esp,0xfffffff0 0x080483fd : mov eax,0x0 0x08048402 : sub esp,eax 0x08048404 : mov DWORD PTR [esp+12],0x0 0x0804840c : mov DWORD PTR [esp+8],0x1 0x08048414 : mov DWORD PTR [esp+4],0x0 0x0804841c : mov DWORD PTR [esp],0x0 0x08048423 : call 0x80482f8 <_init+56> 0x08048428 : cmp eax,0xffffffff # c'est ici que le code vérifie qu'on # le ptrace 0x0804842b : jne 0x8048439 # si eax != -1 on saute à 0x8048439 0x0804842d : mov DWORD PTR [esp],0x1 0x08048434 : call 0x8048318 <_init+88> 0x08048439 : mov DWORD PTR [esp],0x8048564 0x08048440 : call 0x80482e8 <_init+40> 0x08048445 : leave 0x08048446 : ret End of assembler dump. (gdb) b *0x08048428 # un breakpoint juste avant le jne Breakpoint 1 at 0x8048428: file ptraced.c, line 4. (gdb) r Starting program: /home/flyers/Data/Code/tmp/ptraced Breakpoint 1, 0x08048428 in main () at ptraced.c:4 4 if (ptrace(PTRACE_TRACEME, 0, 1, 0) == -1) exit(1); (gdb) p $eax $1 = -1 (gdb) set $eax=0 # on change la valeur de eax pour faire croire au programme qu'il # n'est pas ptracé (gdb) c Continuing. FOO Program exited with code 06. # et ça marche ! (gdb)
Comme le montre cette session, la méthodologie est tout d'abord de regarder (si on le peut) où se trouve la protection puis de trouver la méthode la plus adaptée pour l'outre-passer.
Vous devriez maintenant posséder toutes les notions pour faire vos propres programmes mais également pour comprendre ceux des autres. N'oubliez pas que si vous ne comprenez pas une mnémonique, le manuel nasm est là pour vous aidez ; si vous ne comprenez pas un syscall, il y a la liste des syscalls et enfin, si votre problème est impénétrable, pensez à nos amis debuggers :)
PC
Assembly Language par Paul A. Carter
Assembly
Programming Journal
Linux
Assembly dot Org
NASM
manual
System
Calls for Assembly
Login
Hors-Série n°18
Un grand merci
à tous ceux qui m'ont aidés à écrire cet
article et à mieux comprendre l'assembleur. J'ai nommé
:
edcba
Karsten Scheibler
kaze
mammon_
neil
viriiz
Par Flyers