Aller au contenu
  1. Articles/

Débuter avec l'assembleur NASM

Ixonae
Auteur
Ixonae
Sommaire

Avertissement : Cet article a été traduit de l’anglais par un LLM. La précision n’est pas garantie. Vous pouvez lire l’article original en anglais.

Le langage assembleur (ASM) est le langage de programmation de plus bas niveau que l’on puisse trouver. Il est très proche des instructions du processeur et, par conséquent, il existe une multitude de langages assembleurs, chacun conçu pour une architecture informatique spécifique. L’ASM est utilisé dans diverses situations, comme pour les programmes sensibles aux performances, le code de démarrage des systèmes ou la rétro-ingénierie de programmes.

Dans l’article d’aujourd’hui, nous étudierons NASM (« Netwide Assembler »), qui est un langage ASM pour l’architecture Intel x86. Il peut être utilisé pour écrire des programmes 16 bits, 32 bits et 64 bits et est considéré comme l’un des assembleurs les plus populaires pour Linux (bien qu’il puisse aussi être utilisé avec Mac et Windows).

Cet article passera en revue la plupart des choses que vous devez savoir pour pouvoir commencer à coder en NASM ou pour comprendre le code NASM si vous souhaitez faire de la rétro-ingénierie de programmes. Bien que certaines choses changent selon le système d’exploitation pour lequel vous codez, les concepts généraux restent les mêmes, donc ce tutoriel peut quand même vous être utile, même si vous ne prévoyez pas d’utiliser Linux.

Comme vous pouvez l’imaginer, l’assembleur est un sujet assez long et complexe, il n’est donc pas possible de couvrir 100% des choses. Si vous voulez en savoir plus sur NASM, vous pouvez vous référer à cette documentation.

Structure d’un programme NASM
#

Tout d’abord, étudions un morceau de code écrit en NASM. L’extrait ci-dessous présente un code « Hello World » et sera utilisé pour introduire certains concepts principaux et comment le code NASM est structuré. Certaines des notions seront discutées plus en profondeur dans les parties suivantes.

global _start

section .text
_start:
	mov rax, 1		; Set the function to call (1 is write)
	mov rdi, 1		; Set the first argument of write (fd 1)
	mov rsi, msg		; Set the 2nd argument, the text to write
	mov rdx, msg.len	; Set the 3rd argument, lengh to write
	syscall			; Call write w/ previously defined things

	mov rax, 60		; Set the function (syscall) exit (id 60)
	xor rdi, rdi		; Set the exit return to be 0
        syscall			; Execute the syscall exit

section .data
msg: db	"Hello, world!",10	; Assign "Hello, world!\n" to the var msg
.len: equ $ - msg		; Assign len(msg) to msg.len

Premièrement, nous pouvons observer que le code est divisé en sections. Il n’y en a que deux dans notre exemple (.text et .data), mais il y en a quelques autres que vous pouvez utiliser (notez que cela changera selon le système d’exploitation cible : par exemple, Windows utiliserait .code au lieu de .text).

  • .text est la section où vous écrirez votre code ASM. rax, rdi, rsi et rdx sont appelés registres, et mov, syscall et xor sont des instructions. Nous les verrons dans la partie suivante
  • .data vous permet d’allouer statiquement des objets globaux et statiques initialisés pour la durée de l’exécution du programme
  • .bss vous permet de réserver de l’espace pour des objets globaux et statiques non initialisés
  • .rodata est identique à .data avec la différence que les variables déclarées ici seront en lecture seule (c’est-à-dire constantes)

Les éléments suivants qui ressortent sont _start et msg. Ce sont ce qu’on appelle des étiquettes (labels). _start est l’équivalent de la fonction main dans un langage de plus haut niveau comme C ou C++. C’est là que le programme commencera son exécution. Alternativement, il est aussi possible d’utiliser des étiquettes pour définir des fonctions et comme points de saut (un peu comme goto en C - nous en reparlerons plus tard). Les étiquettes sont aussi utilisées pour définir des variables, comme msg dans notre exemple ; nous en verrons davantage plus tard également.

Une chose que vous remarquerez sous msg est le .len. Une étiquette qui commence par un point est appelée étiquette locale et sera associée à la précédente étiquette non locale. Dans notre exemple, elle sera appelée msg.len.

Le code est probablement assez explicite mais ; est utilisé pour mettre des commentaires : tout ce qui est placé après ne sera pas interprété.

Registres
#

Les registres sont des emplacements de stockage conservés à l’intérieur du processeur, ce qui les rend très rapides. Il y en a 17, et certains ont une attribution d’usage spécifique (par exemple passer des arguments à une fonction). Techniquement, tous peuvent être modifiés à volonté (sauf rip), même s’il existe des conventions indiquant comment cela devrait être fait.

64 bits32 bits16 bits8 bitsCommentaire
raxeaxaxalPour fournir le numéro d’appel système
Pour fournir la valeur de retour de la fonction
Registre sauvegardé par l’appelant
rcxecxcxcl4ème paramètre de fonction
Registre sauvegardé par l’appelant
rdxedxdxdl3ème paramètre de fonction
Registre sauvegardé par l’appelant
rbxebxbxblRegistre sauvegardé par l’appelé
rsiesisisil2ème paramètre de fonction
Registre sauvegardé par l’appelant
Pointeur source pour les instructions de chaîne
rdiedididil1er paramètre de fonction
Registre sauvegardé par l’appelant
rspespspsplPointeur de pile (élément du sommet)
Registre sauvegardé par l’appelant
rbpebpbpbplPointeur de base de pile
Registre sauvegardé par l’appelé
r8r8dr8wr8b5ème paramètre de fonction
Registre sauvegardé par l’appelant
r9r9dr9wr9b6ème paramètre de fonction
Registre sauvegardé par l’appelant
r10r10dr10wr10bRegistre sauvegardé par l’appelant
r11r11dr11wr11bRegistre sauvegardé par l’appelant
r12r12dr12wr12bRegistre sauvegardé par l’appelé
r13r13dr13wr13bRegistre sauvegardé par l’appelé
r14r14dr14wr14bRegistre sauvegardé par l’appelé
r15r15dr15wr15bRegistre sauvegardé par l’appelé
ripeipProchaine instruction à exécuter (rip ne peut pas être accédé directement par le programmeur)

Vous remarquerez qu’il n’y a que 6 registres pouvant être utilisés pour passer des paramètres à une fonction. Ils ne permettent de passer que des entiers ou des pointeurs. Pour passer des paramètres de plus de 64 bits, ou pour en passer plus de 6, ils doivent être poussés sur la pile, avec le premier argument au sommet.

Vous remarquerez aussi qu’il existe deux types de registres : « sauvegardés par l’appelé » et « sauvegardés par l’appelant ». Il s’agit en fait d’une convention plutôt que de quelque chose de strict, mais ce que cela signifie est que :

  • Les registres sauvegardés par l’appelant (volatils) sont destinés à un usage général et à contenir des informations temporaires. Ils peuvent être réécrits par n’importe quelle sous-routine
  • Les registres sauvegardés par l’appelé (non volatils) sont destinés à contenir des valeurs à longue durée de vie et doivent être préservés entre les appels. C’est-à-dire : une fonction est censée les sauvegarder dans la pile au début et les restaurer depuis celle-ci à la fin (si la fonction veut utiliser ces registres)

Instructions
#

Le prochain concept important en NASM est celui des instructions, qui sont essentiellement des mots-clés nous permettant de dire à l’ordinateur quoi faire. Cette partie vise à lister les principales.

Déplacement de données
#

InstructionEffet
mov dest, srcCopier la valeur de src dans dest

Fonctions
#

InstructionEffet
syscallAppeler une fonction
Voir le code dans la première partie ou la partie « Exécuter du code ASM et structure de fichiers plus complexe » pour plus de détails sur son utilisation. Cette page liste le code et les arguments à utiliser pour les fonctions courantes
int codeEnvoyer un signal d’interruption. Peut être une autre façon de faire syscall
Voir l’exemple Linux sur Wikipedia. Les appels de fonctions pour Linux x32 sont définis dans sys/syscall.h
call labelPermettre d’appeler une étiquette définie (c’est-à-dire une fonction - pouvant provenir d’un autre fichier)
push itemPousser un élément sur la pile
pull itemExtraire un élément de la pile vers un registre

Opérations arithmétiques
#

InstructionEffet
inc destdest = dest + 1
dec destdest = dest - 1
add dest, srcdest = dest + src
sub dest, srcdest = dest - src
shr dest, kdest = dest >> k
shl dest, kdest = dest << k
xor dest, srcdest = dest ^ src
shl dest, srcdest = dest & src
shl dest, srcdest = dest | src

Sauts et conditions
#

InstructionEffet
jmp locationSauter à l’emplacement (peut être un registre ou une étiquette)
test reg, constComparaison bit à bit d’un registre et d’une constante. jz ou jnz doit suivre
jz labelSauter à l’étiquette si les bits n’étaient pas égaux à 0
jnz labelSauter à l’étiquette si les bits étaient égaux à 0
cmp x, yComparer x et y. Doit être suivi de jn, jne, jg, jge, ji, ou jil
je labelSauter à l’étiquette si x est égal à y
jne labelSauter à l’étiquette si x est différent de y
jg labelSauter à l’étiquette si x est supérieur à y
ji labelSauter à l’étiquette si x est inférieur à y
jge labelSauter à l’étiquette si x est supérieur ou égal à y
jil labelSauter à l’étiquette si x est inférieur ou égal à y

Pour plus d’exemples, vous pouvez consulter cette antisèche qui est très utile.

Types de données
#

Dans l’exemple au début de cet article, nous avions la ligne de code suivante : msg: db "Hello, world!,10", et nous avons expliqué que c’était une variable msg recevant la valeur Hello, world!\n. db ici est le type de variable. Contrairement à un langage de plus haut niveau où vous auriez int pour stocker des nombres, char pour stocker un seul caractère, … les types en NASM servent simplement à indiquer combien d’espace vos données occuperont. Les types disponibles sont listés dans le tableau suivant.

Type de donnéesSuffixeAssignation de donnéesTaille (bits)
Bytedbresb8
Worddwresw16
Double wordddresd32
Quad worddqresq64
Ten bytesdtrest80
Octo Worddoreso128
Y Worddyresy256
Z Worddzresz512

Dans notre exemple précédent, nous pouvons voir que nous utilisons un suffixe db, ce qui fonctionne car un caractère fait 8 bits.

Une chose que nous n’avons pas vue auparavant est la colonne d’assignation de données. Cela est utilisé dans la section .bss pour pouvoir définir combien d’espace nous voulons réserver. Par exemple :

.bss
buffer:         resb    64              ; reserve 64 bytes
wordvar:        resw    1               ; reserve a word
realarray:      resq    10              ; array of ten reals

Notez qu’il existe plusieurs façons d’écrire des valeurs en NASM. Le code suivant montre les différentes manières qui peuvent être utilisées. Remarquez que lors de l’écriture en bases autres que 10, les valeurs auront un suffixe (par exemple h pour hexadécimal, b pour binaire, …) ou un préfixe (par exemple 0x pour hexadécimal, 0o pour octal, …). Voir la partie 3.4.1 de la documentation pour plus de détails.

db    0x55                ; just the byte 0x55
db    0x55,0x56,0x57      ; three bytes in succession
db    'a',0x55            ; character constants are OK
db    'hello',13,10,'$'   ; so are string constants
dw    0x1234              ; 0x34 0x12
dw    'a'                 ; 0x61 0x00 (it is just a number)
dw    'ab'                ; 0x61 0x62 (character constant)
dw    'abc'               ; 0x61 0x62 0x63 0x00 (string)
dd    0x12345678          ; 0x78 0x56 0x34 0x12
dd    1.234567e20         ; floating-point constant
dq    0x123456789abcdef0  ; eight byte constant
dq    1.234567e20         ; double-precision float
dt    1.234567e20         ; extended-precision float

mov     ax,200            ; decimal
mov     ax,0200           ; still decimal
mov     ax,0200d          ; explicitly decimal
mov     ax,0d200          ; also decimal
mov     ax,0c8h           ; hex
mov     ax,$0c8           ; hex again: the 0 is required
mov     ax,0xc8           ; hex yet again
mov     ax,0hc8           ; still hex
mov     ax,310q           ; octal
mov     ax,310o           ; octal again
mov     ax,0o310          ; octal yet again
mov     ax,0q310          ; octal yet again
mov     ax,11001000b      ; binary
mov     ax,1100_1000b     ; same binary constant
mov     ax,1100_1000y     ; same binary constant once more
mov     ax,0b1100_1000    ; same binary constant yet again
mov     ax,0y1100_1000    ; same binary constant yet again

Un autre concept important en NASM est l’adresse effective : un opérande d’une instruction qui référence la mémoire. La syntaxe sera une expression contenue entre crochets. L’extrait de code suivant montre quelques exemples d’utilisation :

; Accessing a variable
msg              ; The msg variable address
byte[msg]        ; The value of the first byte of the variable msg
byte[msg + 1]  	 ; The value of the second byte of the msg variable
word[msg]        ; The value of the first two bytes of the msg variable

; Various operations
cmp BYTE [rdi], 0h ; Check if the first byte of rdi is 0h

Une dernière chose que je souhaite aborder dans cette partie est la portion .len: equ $ - msg de notre premier exemple :

  • equ est utilisé pour définir un symbole comme une valeur constante. Quand il est utilisé, ce sera toujours pour attribuer une étiquette. La définition est absolue et ne peut pas être modifiée ultérieurement
  • Le $ est l’adresse de la position actuelle. Puisque nous avons défini msg juste avant, nous pouvons savoir que la longueur de msg sera la distance en octets obtenue par adresse actuelle - adresse de msg

Exécuter du code ASM et structure de fichiers plus complexe
#

Pour terminer cet article, nous allons écrire un court programme en ASM. Il récupérera les arguments du programme, et affichera ceux-ci ainsi que leur taille. Pour les besoins de l’exemple, nous écrirons une fonction strlen dans un fichier différent du fichier main.

global my_strlen:function	; We declare the label as a global function

section .text

my_strlen:			; This is the function we will call later
  xor rax, rax			; We set the return value as 0

while:
  cmp BYTE[rdi], 0h		; If this is the end of the string (\0)
  je end			; Then we jump to the label end

  inc rax			; We increment the return value by one
  inc rdi			; We continue to the next char in the string

  jmp while			; We jump to the label while (start of the loop)

end:
  ret				; We reached the end of the string, return rax

Si vous avez tout lu jusqu’ici, rien dans la fonction my_strlen ne devrait vous surprendre. Une chose qui n’avait pas été mentionnée auparavant est que vous devez déclarer votre étiquette comme une fonction globale si vous voulez pouvoir l’appeler depuis un autre fichier. Passons à la partie principale du programme.

global main

; We use the extern keyword to be able to use the functions defined outside of the file
extern printf
extern my_strlen

section .text
main:
  mov r10, rdi		; We save argv into r10
  mov r11, 0		; We initialize r11 to 0 and will use it as a loop counter

loop:
  ; rsi is the address of the first argv
  ; We use counter * 8 to be able to get the address of argv[r11]
  mov r12, qword [rsi + r11 * 8]

  ; We call our my_strlen function and give argv[r11] as an argument
  ; It will return the result in rax
  mov rdi, r12
  call my_strlen

  ; The prinf call will overwrite some registers, we save them in the stack
  push r10
  push r11
  push r12
  push rsi

  ; We set the printf arguments, and call the function
  mov rdi, printf_format
  mov rsi, r11
  mov rdx, r12
  mov rcx, rax
  mov rax, 0 ; We need to set this to 0 or the program will segfault
  call printf

  ; Once we called printf, we restore the registers from the stack
  pop r10
  pop r11
  pop r12
  pop rsi

  ; We increase our counter and check that r11 < argc. If so, we jump to the loop label
  inc r11
  cmp r10, r11
  jg loop

  ; We call exit(0)
  mov rax, 60
  xor rdi, rdi
  syscall

section .data
; The string we will pass to printf as required by the prototype
; Unless write in the first example, we won't give printf the numbers of characters to write, so we need to string to end with '\0'
printf_format: db "The argument number %d ('%s') is %d characters long.",10,0

Nous pouvons ensuite compiler notre programme comme suit, et voir que la sortie est celle attendue.

user@vm1:/tmp/test$ nasm -f elf64 main.S && nasm -f elf64 function.S
user@vm1:/tmp/test$ gcc -o a.out -no-pie main.o function.o
user@vm1:/tmp/test$ ./a.out 1234 123 12345
The argument number 0 ('./a.out') is 7 characters long.
The argument number 1 ('1234') is 4 characters long.
The argument number 2 ('123') is 3 characters long.
The argument number 3 ('12345') is 5 characters long.

Cette fois encore, vous ne devriez pas être trop surpris par le contenu du fichier, sauf pour une chose. Nous avons mentionné précédemment que les programmes NASM devraient commencer par _start, mais notre programme ici commence par main. La raison est que nous utilisons gcc pour l’édition de liens, et il génèrera le _start lui-même avant d’appeler la fonction main.

Si nous voulions utiliser _start, nous aurions pu compiler avec ld. Le problème est que c’est moins pratique lors de l’utilisation de fonctions externes comme printf (voir ceci pour plus d’informations). Si nous voulions compiler notre premier exemple, ce serait cependant simple à faire avec ld :

user@vm1:/tmp/test$ nasm -f elf64 example.S
user@vm1:/tmp/test$ ld -o a.out example.o

Une dernière chose qui pourrait vous intriguer concernant la compilation est pourquoi nous utilisons -no-pie avec GCC. La raison est que sur Ubuntu, GCC génèrera des exécutables indépendants de la position (PIE), avec lesquels notre code actuel n’est pas compatible. L’argument -no-pie indique à GCC de ne pas générer un exécutable PIE, mais nous avons aussi la possibilité de faire call printf wrt ..plt dans le code, ce qui rendrait notre code indépendant de la position.


Ressources
#

Crédits
#