[ Autor: Nicholas Ferreira ]
[0x5] Implementando o "cat" em assembly x64
19/08/2021Introdução
Neste post eu pretendo mostrar uma implementação que fiz do binário "cat", nativo dos Linux, em assembly x64. Apenas a função mais simples, de ler e printar o conteúdo de um arquivo, foi implementada. Não programei para aceitar mais de um input, nem para aceitar input via stdin. Aviso de antemão que o código talvez não seja o mais otimizado possível.Este não é um tutorial de assembly. É mais um registro que faço para mim mesmo, para que eu me lembre de como eu pensei para fazer o código, já que às vezes os comentários no código não me são suficientes. Pretendo fazer isso com outros códigos ao longo do tempo.
Você pode ver este e outros códigos em assembly aqui.
Funções usadas
O programa terá a única funcionalidade de ler o conteúdo de um arquivo, armazenar em um buffer e em seguida printar na tela. Para isso, usaremos as syscalls open, para abrir o arquivo, fstat, para descobrir seu tamanho, mmap, para criar o buffer, read, para ler o conteúdo para o buffer, write, para printar o conteúdo do buffer na tela, e exit, para finalizar o programa.Inicialmente, defini as constantes de cada syscall e criei a seção .text, que é onde irá o código executável. Você pode ver o número das syscalls no arquivo /usr/include/x86_64-linux-gnu/asm/unistd_64.h.
%define READ 0
%define WRITE 1
%define OPEN 2
%define FSTAT 5
%define MMAP 9
%define EXIT 60
global _start
section .text
_start:
Abrindo o arquivo
De início, precisamos do nome do arquivo que será lido. Este é o primeiro argumento que nosso programa receberá. Analisando qualquer executável em um debugger, podemos notar que assim que ele inicia, no topo da stack teremos algumas informações importantes.nich0las@0x7359:~$ gdb -q asmcat
>>> break _start
>>> run teste1 teste2
>>> x/xg $rsp
0x7fffffffde30: 0x0000000000000003
0x7fffffffde38: 0x00007fffffffe20f
0x7fffffffde40: 0x00007fffffffe235
0x7fffffffde48: 0x00007fffffffe23c
>>> x/s 0x00007fffffffe20f
0x7fffffffe20f: "/home/nich0las/MyCodes/asm/cat/asmcat"
>>> x/s 0x00007fffffffe235
0x7fffffffe235: "teste1"
>>> x/s 0x00007fffffffe23c
0x7fffffffe23c: "teste2"
>>> break _start
>>> run teste1 teste2
>>> x/xg $rsp
0x7fffffffde30: 0x0000000000000003
0x7fffffffde38: 0x00007fffffffe20f
0x7fffffffde40: 0x00007fffffffe235
0x7fffffffde48: 0x00007fffffffe23c
>>> x/s 0x00007fffffffe20f
0x7fffffffe20f: "/home/nich0las/MyCodes/asm/cat/asmcat"
>>> x/s 0x00007fffffffe235
0x7fffffffe235: "teste1"
>>> x/s 0x00007fffffffe23c
0x7fffffffe23c: "teste2"
Como vemos, o primeiro item da stack, no rsp, é o inteiro 3. Este é o argc, ou seja, o número de argumentos que o executável recebeu. Os próximos 3 itens da stack são esses argumentos. O primeiro, em [rsp+8], é o path do executável, o segundo, em [rsp+16], é o primeiro argumento, a saber, "teste1", e o terceiro item da stack, em [rsp+24], é o segundo argumento, ou seja, "teste2".
Então, o nome do executável que precisamos é o primeiro argumento do programa e, portanto, está localizado em [rsp+16].
De acordo com o manual da syscall open(), ela espera no mínimo dois parâmetros: o pathname e as flags. O pathname nós já sabemos que é uma string localizada inicialmente em [rsp+16]. A flag é um inteiro que determina o modo de leitura do arquivo. Como precisamos apenas ler seu conteúdo, o modo será read only. Podemos ver aqui que o modo O_RDONLY tem como flag 0.
Então, a chamada da função open ficaria open([rsp+16],0). Seguindo a convenção de chamada de funções para assembly x64, o primeiro argumento deve ir no registrador RDI e o segundo deve ir no RSI. No RAX nós colocamos o código da syscall correspondente ao open(), e então podemos executar a syscall.
mov rax, OPEN ;valor da syscall
mov rdi, [rsp+16] ;argv[1]
mov rsi, 0 ;read_only
syscall
mov r15, rax ;file descriptor, será usado depois
O retorno da função open(), quando bem sucedido, contém o file descriptor do arquivo aberto, que é um número inteiro não negativo. Este file descriptor será usado para se referir a este arquivo nas próximas funções. Eu salvei esse file descriptor (fd de agora em diante) no registrador r15, porque ele será usado nas próximas funções.
Descobrindo o tamanho do arquivo
Agora, precisamos saber qual é o tamanho do arquivo que foi aberto para sabermos exatamente o quanto de memória precisaremos alocar para armazenar seu conteúdo. Para isso, usaremos a syscall fstat(), que retorna várias informações do arquivo passado pelo seu fd. Essa função também recebe dois argumentos: o fd do arquivo cujas informações serão obtidas e o statbuf, um ponteiro para o lugar onde as informações serão armazenadas. Como precisamos apenas do tamanho do arquivo, o que é pouca informação, podemos retorná-las na própria stack.Na documentação da função, podemos ver que essa função cria uma estrutura com várias informações, como o UID do dono,o ID do dispositivo que contém o arquivo, o tamanho dele, etc. A estrutura é a seguinte:
struct stat {
dev_t st_dev; /* ID of device containing file */
ino_t st_ino; /* Inode number */
mode_t st_mode; /* File type and mode */
nlink_t st_nlink; /* Number of hard links */
uid_t st_uid; /* User ID of owner */
gid_t st_gid; /* Group ID of owner */
dev_t st_rdev; /* Device ID (if special file) */
off_t st_size; /* Total size, in bytes */
blksize_t st_blksize; /* Block size for filesystem I/O */
blkcnt_t st_blocks; /* Number of 512B blocks allocated */
};
Apesar disso, não consegui descobrir exatamente em qual posição o tamanho do arquivo, o off_t, estaria. Porém, foi fácil identificar isso em um debugger modificando o tamanho do arquivo de byte em byte e vendo qual posição modificada. Depois de uns testes, descobri que é em [rsp+48]. A imagem abaixo é a stack depois da execução de fstat(). As informações são de um arquivo de texto com 11 bytes de tamanho (0xb).
Também através de testes, vi que o ideal era reservar 144 bytes na stack para que o retorno do fstat() não sobrepusesse outras informações da stack.
O código ficou assim:
mov rax, FSTAT
mov rdi, r15 ;fd
sub rsp, 114 ;reservado pro fstat()
mov rsi, rsp ;lugar onde a estrutura será escrita
syscall
mov rbx, [rsp+48] ;salva o filesize em rbx
Criando o buffer na memória
Agora que sabemos o tamanho do arquivo, precisamos alocar um buffer de memória com esse tamanho, para que ele seja preenchido com o conteúdo do aquivo. Para isso, usaremos a syscall mmap(). Essa função recebe seis argumentos: o endereço onde o buffer será alocado, o tamanho do buffer, o tipo de proteção, as flags, o fd e o offset.Conforme sugere o manual, o endereço será 0, para deixar que o kernel escolha o endereço onde a memória será alocada. O tamanho será exatamente o que temos agora em rbx, ou seja, o tamanho do arquivo. A proteção será PROT_WRITE, porque precisamos escrever nesse local depois que ele for criado (afinal, é lá que o conteúdo do arquivo será armazenado). Como queremos que o bloco de memória criado esteja vazio e pronto para usar, usaremos a flag MAP_ANONYMOUS e MAP_SHARED. Usar a flag MAP_ANONYMOUS implica em setar o fd para -1 e zerar o offset.
Seguindo as convenções de chamada, os argumentos serão passados no rdi, rsi, rdx, r10, r8 e r9, nessa ordem.
Podemos ver o valor das flags dessa forma:
nich0las@0x7359:~$ echo '#include ' | gcc -E - -dM | grep -i "map"
...
...
#define MAP_SHARED 0x01
...
...
#define MAP_ANONYMOUS 0x20
...
...
#define MAP_SHARED 0x01
...
...
#define MAP_ANONYMOUS 0x20
Como as flags são concatenadas através via bitwise OR, o valor da flag será 0x1+0x20 = 0x21. O código fica assim:
mov rax, MMAP
mov rdi, 0 ;addr
mov rsi, rbx ;length
mov rdx, 0x2 ;PROT_WRITE
mov r10, 0x21 ;MAP_SHARED|MAP_ANONYMOUS
mov r8, -1 ;fd (ignorado)
mov r9, 0 ;offset (zero, por causa do MAP_ANONYMOUS)
syscall
mov rcx, rax ;salvamos o endereço mapeado em rcx
Lendo o arquivo
Agora que temos um local na memória pronto para receber os dados, podemos ler o conteúdo do arquivo e armazená-lo lá. Para isso, usaremos a função read(). Ela espera três argumentos: o fd, o buffer para onde o conteúdo será lido e o número de bytes a serem lidos. O fd é o file descriptor do arquivo que queremos ler, e isso nós já temos. Ele está armazenado em r15. O buffer para onde o conteúdo irá é a região de memória que acabamos de mapear e que agora está em rcx. O número de bytes a serem lidos é o tamanho do arquivo, que também já temos e está em rbx (colocamos ele lá depois do retorno de fstat()).O código fica assim:
mov rax, READ
mov rdi, r15 ;fd
mov rsi, rcx ;buffer
mov rdx, rbx ;tamanho
syscall
O read() retorna o número de bytes lidos, que é igual ao tamanho do arquivo, e armazena no buffer o conteúdo lido. Como o buffer é a região de memória que alocamos através do mmap() anteriormente, agora só nos restar printar o conteúdo dela na tela.
Printando o conteúdo
Para printar o conteúdo, usaremos a função write(), que recebe os mesmos três argumentos, a saber, o fd, o buffer e o quanto será escrito. Neste caso, o fd será igual a 1, que corresponde ao stdout. Ou seja, o conteúdo será escrito para o stdout (em outras palavras, será a saída do terminal). O buffer é a região que contém o que será printado, que é o segmento de memória alocado antes. Coincidentemente, nós o utilizamos na função read(), e ela não altera o conteúdo do registrador rsi. Então, em rsi nós ainda temos o buf da função anterior (read), que será reutilizado agora. Por fim, o número de bytes a serem escritos é o mesmo número de bytes que foram lidos, e isso ainda está salvo em rbx (colocamos lá depois de fstat()). O código fica assim:mov rax, WRITE
mov rdi, 1 ;fd (stdout)
mov rdx, rbx ;tamanho
syscall
Por fim, precisamos agora apenas finalizar o programa corretamente, através da syscall exit(), passando o argumento 0 para indicar que o programa executou sem problemas.
Montando e linkando
O código completo fica assim:%define READ 0
%define WRITE 1
%define OPEN 2
%define FSTAT 5
%define MMAP 9
%define EXIT 60
global _start
section .text
_start:
mov rax, OPEN
mov rdi, [rsp+16] ;argv[1]
mov rsi, 0 ;read_only
syscall
mov r15, rax ;file descriptor, será usado depois
mov rax, FSTAT
mov rdi, r15 ;fd
sub rsp, 114 ;reservado pro fstat()
mov rsi, rsp ;lugar onde a estrutura será escrita
syscall
mov rbx, [rsp+48] ;salva o filesize em rbx
mov rax, MMAP
mov rdi, 0 ;addr
mov rsi, rbx ;length
mov rdx, 0x2 ;PROT_WRITE
mov r10, 0x21 ;MAP_SHARED|MAP_ANONYMOUS
mov r8, -1 ;fd (ignorado)
mov r9, 0 ;offset (zero, por causa do MAP_ANONYMOUS)
syscall
mov rcx, rax ;salvamos o endereço mapeado em rcx
mov rax, READ
mov rdi, r15 ;fd
mov rsi, rcx ;buffer
mov rdx, rbx ;tamanho
syscall
mov rax, WRITE
mov rdi, 1 ;fd (stdout)
mov rdx, rbx ;tamanho
syscall
mov rax EXIT
mov rdi, 0 ;código de saída
syscall
Para montar e linkar o código, usamos o seguinte:
nich0las@0x7359:~$ nasm -felf64 asmcat.nasm -o asmcat.o
nich0las@0x7359:~$ ld asmcat.o -o asmcat
nich0las@0x7359:~$ ld asmcat.o -o asmcat
Agora temos o programa funcional.
nich0las@0x7359:~$ echo AABBCCDDEE > teste
nich0las@0x7359:~$ ./asmcat teste
AABBCCDDEE
nich0las@0x7359:~$ ./asmcat teste
AABBCCDDEE
=)