.d8888b. 8888888888 .d8888b. 888888888 .d8888b. d88P Y88b d88P d88P Y88b 888 d88P Y88b 888 888 d88P .d88P 888 888 888 888 888 888 888 d88P 8888" 8888888b. Y88b. d888 888 888 `Y8bd8P' 88888888 "Y8b. "Y88b "Y888P888 888 888 X88K d88P 888 888 888 888 Y88b d88P .d8""8b. d88P Y88b d88P Y88b d88P Y88b d88P "Y8888P" 8888 8888 d88P "Y8888P" "Y8888P" "Y8888P"

[ Autor: Nicholas Ferreira ]


[0x5] Implementando o "cat" em assembly x64

19/08/2021

Introduçã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"


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


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


Agora temos o programa funcional.


>_
nich0las@0x7359:~$ echo AABBCCDDEE > teste
nich0las@0x7359:~$ ./asmcat teste
AABBCCDDEE


=)