Input e Output in assembly x86_64

Guida pratica all'uso delle principali syscall Linux (write, read, open, exit) in Assembly x86_64. L'articolo illustra le convenzioni di chiamata nei registri, l'allocazione dinamica di buffer sullo stack e le tecniche di indirizzamento RIP-relative con .asciz e lea.

Immagine generata con AI
Immagine generata con AI
Riassunto generato automaticamente tramite intelligenza artificiale:

La syscall `write` inoltra al kernel l'indirizzo e la lunghezza di un blocco di memoria da stampare, utilizzando i registri `rax`, `rdi`, `rsi` e `rdx` per specificare l'ID della syscall, il file descriptor, l'indirizzo dei dati e il numero di byte. Per terminare correttamente un programma, è necessario invocare in sequenza la syscall `write` per l'output e `exit` per la chiusura, passando gli opportuni argomenti ai registri appropriati.

La chiamata write, stampare a schermo

Quando un programma deve mostrare del testo, non invia i caratteri uno alla volta: passa al kernel l'indirizzo di memoria del blocco da stampare, la sua lunghezza e il canale di output, in un'unica invocazione della syscall write.

L'equivalente concettuale in linguaggio C è write(file_descriptor, indirizzo_memoria, lunghezza). In assembly x86_64 su Linux, i parametri vanno caricati nei registri prima di eseguire l'istruzione syscall.

Registro Ruolo nella write
rax ID della syscall, deve valere 1
rdi File descriptor di destinazione: 0 stdin, 1 stdout, 2 stderr
rsi Indirizzo di memoria da cui iniziare a leggere i caratteri
rdx Numero esatto di byte da stampare

Leggere gli argomenti da riga di comando

Quando si avvia un programma passando un parametro, ad esempio ./programma H, Linux carica queste informazioni sullo stack. Lo stack non contiene direttamente il carattere 'H', ma un puntatore che indica dove si trova quel testo in memoria. Nel contesto di questa serie di esercizi, il puntatore al primo argomento si trova alla posizione relativa [rsp+16].

Per preparare l'output basta copiare quel puntatore nel registro dedicato ai dati e impostare gli altri parametri per stampare un solo carattere su stdout.

.intel_syntax noprefix
.global _start
_start:
mov rax,1
mov rdi,1
mov rsi,[rsp+16]
mov rdx,1
syscall

Con rdi a 1, rdx a 1 e rax a 1, la syscall stampa a terminale esattamente il carattere passato da riga di comando.

Concatenare write ed exit, chiudere il programma correttamente

Un programma che esegue solo la write e non termina esplicitamente continua a far leggere alla CPU le locazioni di memoria successive, eseguendo istruzioni non valide e andando in crash. Per gestire correttamente il ciclo di vita del processo occorre invocare due syscall in sequenza nello stesso flusso: prima write per l'output, poi exit per la chiusura.

La seconda fase riutilizza i registri con valori diversi:

  • rdi = il codice di uscita da restituire al sistema operativo (da 0 a 255)
  • rax = 60, l'ID numerico della syscall sys_exit
.intel_syntax noprefix
.global _start
_start:
mov rax,1
mov rdi,1
mov rsi,[rsp+16]
mov rdx,1
syscall
mov rdi,42
mov rax,60
syscall

La struttura completa richiede in totale otto istruzioni, cinque per la fase di scrittura e tre per la terminazione, e garantisce che l'output venga mostrato prima che il processo si chiuda con stato 42.

Scrivere stringhe più lunghe di un singolo byte

Per scrivere un testo più lungo, ad esempio una stringa di 64 caratteri già presente in memoria, non serve cambiare la logica del programma: basta estendere il valore caricato in rdx, il registro che dice al kernel quanti byte consecutivi leggere a partire dall'indirizzo puntato da rsi.

.intel_syntax noprefix
.global _start
_start:
mov rax,1
mov rdi,1
mov rsi,[rsp+16]
mov rdx,64
syscall
mov rdi,42
mov rax,60
syscall

L'unica variazione rispetto al blocco precedente è il valore di rdx, che passa da 1 a 64: tutto il resto del flusso, incluse la chiusura con exit e i valori di rdi e rax per la write, resta identico.

La chiamata read, leggere input da stdin

La syscall read compie il percorso opposto rispetto a write: quando il programma la invoca, è il kernel a trasferire i byte ricevuti dallo standard input all'interno di un'area di memoria del programma. La sintassi concettuale è read(file_descriptor, indirizzo_memoria, lunghezza)

Registro Ruolo nella read
rax ID della syscall, deve valere 0
rdi File descriptor sorgente, 0 per stdin
rsi Indirizzo del buffer in cui salvare i caratteri ricevuti
rdx Numero massimo di byte da raccogliere dall'input

Lo stack come buffer temporaneo

I dati letti devono finire in un'area di RAM valida e scrivibile. Il registro rsp punta alla sommità dello stack, una zona di memoria già allocata per il programma e liberamente utilizzabile come buffer. A differenza degli esempi precedenti, in cui si caricava il valore contenuto nell'indirizzo dello stack con mov rsi, [rsp+16], qui serve l'indirizzo stesso:

mov rsi,rsp

mov rsi, rsp copia nel registro rsi il valore che rsp contiene in quel momento, cioè l'indirizzo della sommità dello stack: rsi diventa un puntatore a quella zona di memoria, pronta per essere riempita dalla read.
mov rsi, [rsp+16], invece, non copia un indirizzo ma il contenuto presente nella memoria a quella posizione: negli articoli precedenti, in quella locazione lo stack conteneva già un puntatore (all'argomento da riga di comando), quindi l'istruzione dereferenziava quella cella per recuperarlo. Con read non esiste ancora nessun dato scritto in memoria da recuperare, quindi serve l'indirizzo dello stack stesso come destinazione, non il contenuto di una sua cella.

Il flusso completo a tre fasi

Concatenando lettura, scrittura e uscita si ottiene un programma che legge fino a 255 byte da stdin, li ristampa a schermo e chiude il processo con stato 42.

.intel_syntax noprefix
.global _start
_start:
scrittura (write)
mov rax,0
mov rdi,0
mov rsi,rsp
mov rdx,255
syscall
# lettura (read)
mov rax,1
mov rdi,1
mov rsi,rsp
mov rdx,255
# uscita (exit)
syscall
mov rdi,42
mov rax,60
syscall

Lettura a lunghezza variabile

Nei programmi reali la quantità di dati in arrivo da stdin non è quasi mai nota a priori. Specificare un numero rigido di byte nella write porta a due problemi: stampare meno dati del dovuto se l'input è lungo, oppure stampare byte residui presenti sullo stack se l'input è più corto del previsto.

La soluzione sfrutta il valore che il kernel scrive in rax al termine di ogni syscall. Per read, quel valore corrisponde al numero effettivo di byte letti: se si chiedono 128 byte ma l'input ne contiene solo 50, rax conterrà 50 al termine dell'operazione.

Trasferendo quel valore in rdx prima della write, si stampa esattamente la quantità di dati ricevuta.

.intel_syntax noprefix
.global _start
_start:
mov rax,0
mov rdi,0
mov rsi,rsp
mov rdx,128
syscall
mov rdx,rax
mov rax,1
mov rdi,1
mov rsi,rsp
syscall
mov rdi,42
mov rax,60
syscall

L'ordine delle istruzioni è critico: mov rdx, rax deve avvenire prima di mov rax, 1, altrimenti il conteggio dei byte letti verrebbe sovrascritto dall'ID della syscall write prima di poter essere copiato.

Aprire file su disco con open

Fino a questo punto il programma ha lavorato solo con i tre canali standard già aperti all'avvio: stdin (0), stdout (1) e stderr (2). Per leggere un file memorizzato su disco occorre prima chiedere a Linux di aprirlo tramite la syscall open, la cui struttura concettuale è open(percorso_file, modalita).

Registro Ruolo nella open
rax ID della syscall, deve valere 2
rdi Puntatore al percorso del file in RAM
rsi Modalità di accesso, 0 indica la sola lettura

Eseguita la syscall, Linux restituisce in rax un nuovo file descriptor, un intero a partire da 3, collegato al file appena aperto.

Il flusso completo a quattro fasi

Il file descriptor restituito da open va spostato subito nel primo argomento della read con mov rdi, rax, altrimenti la lettura successiva continuerebbe a puntare a stdin.

.intel_syntax noprefix
.global _start
_start:
# apertura file (open)
mov rax,2
mov rdi,[rsp+16]
mov rsi,0
syscall
mov rdi,rax
mov rax,0
mov rsi,rsp
mov rdx,256
syscall
mov rdx,rax
mov rax,1
mov rdi,1
mov rsi,rsp
syscall
mov rdi,42
mov rax,60
syscall

Costruire un percorso file manualmente nello stack

Quando l'indirizzo del file non arriva dagli argomenti da riga di comando, occorre posizionare manualmente i byte del percorso in un'area scrivibile come lo stack. Per la stringa /flag si applicano tre regole fondamentali.

  • Direttiva BYTE PTR: quando si sposta un valore immediato, un carattere tra apici come 'f' o un numero, in una locazione di memoria come [rsp+N], l'assembler non conosce da solo la dimensione della destinazione. BYTE PTR specifica che l'operazione deve occupare esattamente un byte.
  • Codifica ASCII: i singoli apici permettono di scrivere caratteri leggibili, come 'f', che la CPU interpreta direttamente come valori ASCII esadecimali, ad esempio 0x66.
  • Terminatore nullo: l'ultimo byte della stringa deve essere impostato a 0. Linux legge il percorso a partire dall'indirizzo fornito e si arresta solo quando incontra un byte nullo; in sua assenza la open proseguirebbe leggendo la memoria sovrastante, tentando di aprire un file con un nome invalido.
.intel_syntax noprefix
.global _start
_start:
mov BYTE PTR [rsp],'/'
mov BYTE PTR [rsp+1],'f'
mov BYTE PTR [rsp+2],'l'
mov BYTE PTR [rsp+3],'a'
mov BYTE PTR [rsp+4],'g'
mov BYTE PTR [rsp+5],0
mov rax,2
mov rdi,rsp
mov rsi,0
syscall
mov rdi,rax
mov rax,0
mov rsi,rsp
mov rdx,256
syscall
mov rdx,rax
mov rax,1
mov rdi,1
mov rsi,rsp
syscall
mov rdi,42
mov rax,60
syscall

Stringhe RIP-relative con asciz e lea

Scrivere stringhe byte per byte nello stack funziona, ma è lungo e aumenta il rischio di errori. L'assembler permette di posizionare dati statici direttamente nel binario, usando la direttiva .asciz "/flag", che inserisce la sequenza di caratteri ASCII aggiungendo automaticamente il byte nullo finale. Per sicurezza, la stringa va posizionata dopo la syscall di exit, così il processo termina prima di raggiungere quei byte, evitando che la CPU tenti di eseguirli come istruzioni.

Indirizzamento RIP-relative

Nei sistemi moderni la posizione del programma in memoria RAM varia a ogni esecuzione, quindi l'assembler non conosce l'indirizzo assoluto della stringa, ma conosce la distanza tra l'istruzione corrente e la stringa stessa, calcolata rispetto al registro rip, l'Instruction Pointer.

Per caricare l'indirizzo corretto si usa l'istruzione lea, Load Effective Address, e non mov:

  • mov rdi, [rip + path] è errato: legge il contenuto della memoria a quell'indirizzo, quindi rdi riceverebbe i primi otto byte della stringa ASCII anziché il suo puntatore.
  • lea rdi, [rip + path] è corretto: calcola l'indirizzo di memoria in cui risiede la stringa e lo salva in rdi, fornendo al kernel il puntatore necessario per la open.
.intel_syntax noprefix
.global _start
_start:
lea rdi,[rip+path]
mov rax,2
mov rsi,0
syscall
mov rdi,rax
mov rax,0
mov rsi,rsp
mov rdx,256
syscall
mov rdx,rax
mov rax,1
mov rdi,1
mov rsi,rsp
syscall
mov rdi,42
mov rax,60
syscall
path:
.asciz "/flag"

Riepilogo delle syscall trattate

Syscall Valore rax Funzione
read 0 Legge byte da un file descriptor in un buffer di memoria
write 1 Scrive byte da un buffer di memoria verso un file descriptor
open 2 Apre un file su disco e restituisce un nuovo file descriptor
exit 60 Termina il processo restituendo un codice di stato

Errori comuni da evitare

  • Dimenticare la exit finale: senza la syscall sys_exit, la CPU continua a eseguire le locazioni di memoria successive alla write come se fossero istruzioni valide, causando un crash del programma.
  • Invertire l'ordine tra mov rdx, rax e mov rax, 1: il valore restituito da read in rax va copiato in rdx prima di sovrascrivere rax con l'ID della syscall write, altrimenti il conteggio dei byte letti va perso.
  • Dimenticare il byte nullo di terminazione: quando un percorso file viene costruito manualmente nello stack, l'assenza del terminatore a 0 fa sì che open continui a leggere memoria oltre la stringa prevista, tentando di aprire un percorso corrotto o inesistente.

Articolo scritto con il supporto di LLM per la formattazione e la struttura del codice.

Commenti