Construindo um web server em Assembly x86, parte IV, um assembly modesto
Leandro Proença
Posted on May 13, 2024
Uma vez que temos uma compreensão sobre sistema binário, hexadecimal, ASCII e código de máquina, chegou o grande momento de entrarmos no assunto principal desta saga: assembly.
Vamos iniciar transportando o "Hello, World" feito em código de máquina para assembly x86 e, posteriormente, abordar um exemplo de programa que recebe argumento da linha de comando.
Ao longo deste artigo vamos aprender a base de conceitos como rótulos, segmentos de memória, muito gdb, layout de memória, muita stack, procedures (subrotinas, ou funções), loops, condicionais, flags, tipos de registradores e etc
Aperte os cintos, pois este será um artigo bem extenso. Sugiro ao leitor, - que tem interesse em aprender na prática com esta saga -, que tenha o ambiente preparado e que execute cada exemplo seguindo os passos aqui descritos.
Sem mais delongas, vamos ao que importa.
Agenda
- Humanizar é preciso
- Assemblers
- Nosso primeiro programa
- Depurando o programa
-
Evoluindo nosso primeiro programa
- Um programa mais sofisticado
- Falando um pouco de registradores
- Uma side note sobre stack frames
- Conclusão
- Referências
Antes de iniciar, quero novamente deixar uma menção especial ao excelente curso gratuito de Assembly x86 do Blau Araújo. É importante reforçar o quanto este material dele é necessário e foi crucial para que eu pudesse fundamentar diversos conceitos explorados ao longo desta saga
Humanizar é preciso
Como vimos no artigo anterior, CPU só entende código de máquina:
48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 0A ; Hello, World
BF 01 00 00 00 ; RDI ⬅️ 1
48 BE 00 10 40 ; RSI ⬅️ 0x401000
BA 0D 00 00 00 ; RDX ⬅️ 13
B8 01 00 00 00 ; RAX ⬅️ 1
0F 05 ; SYSCALL
BF 00 00 00 00 ; RDI ⬅️ 0
B8 3C 00 00 00 ; RAX ⬅️ 60
0F 05 ; SYSCALL
Entretanto, para uma pessoa desenvolvedora manter um programa em código de máquina, é preciso ter muita paciência e atenção ao detalhe, pelo que também manter programas assim é muito propenso a bugs.
Precisamos de alguma forma, representar cada instrução em código de máquina em uma linguagem mais "human-friendly".
Mnemonics
É aí que entram os mnemonics, que são uma forma textual de representar informações visando facilitar a memorização para o cérebro humano.
Ao invés de trabalharmos com BF 01 00 00 00
, podemos trocar por MOV RDI, 1
, que significa:
estou movendo o valor imediato 1 para o registrador RDI
E assim vamos montando instrução por instrução, tal e qual faríamos com código de máquina, mas utilizando uma linguagem de fácil memorização.
Mas a CPU não entende essa "linguagem". Temos de construir um programa que faz a tradução de mnemonics para código de máquina, ou seja, de MOV RSI, 1
para BF 01 00 00 00
.
Estamos falando de montadores, ou simplesmente assemblers.
Assemblers
Ao longo do tempo, foram desenvolvidos diversos assemblers para diferentes arquiteturas.
Para arquitetura x86, há diversos assemblers já construídos, GNU Assembler (as), NASM, FASM, pra mencionar alguns.
Assemblers para esta arquitetura em específico podem seguir 2 tipos de sintaxe que são predominantes:
- AT&T, desenvolvida pela AT&T corporation
- Intel, desenvolvida pela Intel
Nesta saga, vamos focar no Assembler NASM para arquitetura X86 64-bits (x64), com sintaxe Intel e rodando em sistema GNU/Linux, como já mencionamos algumas vezes em artigos anteriores.
- Arquitetura x86_64 (x64)
- Sistema Operacional GNU/Linux (Ubuntu)
- Assembler NASM 2.16.01
- GNU ld 2.38 (ligador, ou linker)
- Debugger GNU gdb 12.1
- strace 5.16 (tracing de syscalls)
Uma vez que definimos as ferramentas utilizadas, vamos seguir traduzindo o "Hello, World" para asm x86 enquanto entendemos o uso de cada uma delas.
A partir de agora, quando me referir a Assembly ou simplesmente "asm", leia-se Assembly x86_64
Nosso primeiro programa
Em Assembly, todo programa deve ter um ponto de entrada, também chamado de entry point:
global _start
_start:
; código do programa vai aqui
E a primeira coisa que nosso programa vai fazer é sair:
kkkkkkkkkk
A chamada de sistema exit
Brincadeiras à parte, a chamada de sistema que precisamos executar é a exit
, definida da seguinte forma no glibc
:
void _exit(int status);
Com isto, temos de seguir a lógica para montar as instruções tal como fizemos com os opcodes, que seguindo a mesma tabela de syscalls, é:
- nome da syscall vai em RAX
- primeiro argumento (o status de erro) vai em RDI
global _start
_start:
mov rdi, 0 ; error status
mov rax, 60 ; nome da syscall: SYS_exit
syscall
Este programa simplesmente faz aquilo que mencionamos no artigo anterior: que todo programa deve terminar.
-
mov rdi, 0
move o valor imediato 1 para o registrador RDI; vai representar o error code da syscall exit: 0 para término sem erros -
mov rax, 60
move o valor imediato 60 para o registrador RAX; vai representar o nome da syscall em si, exit -
syscall
faz a chamada de sistema da syscall exit, definida em RAX
Para que o programa seja compilado, precisamos primeiro fazer a "montagem" das instruções com NASM:
$ nasm -f elf64 hello.asm -o hello.o
-
-f elf64
: arquitetura de destino, x64 -
hello.asm
input, ou seja, o arquivo que contém o código fonte -
-o hello.o
: define saída para o arquivohello.o
Mas o quê é este arquivo
hello.o
?
Arquivos objeto
Arquivo objeto (Object File) é um arquivo que contém código de máquina gerado por um assembler ou compilador.
Porém este arquivo ainda não é um executável final, porque podemos querer combinar com outros arquivos objeto e bibliotecas nativas do SO.
A partir deste arquivo, que geralmente tem a extensão .o
, podemos utilizar outro programa para "ligar" com outros arquivos, se necessário, no intuito de gerar um arquivo com código de máquina final e executável.
Este programa se chama linker, pelo que utilizaremos a versão padrão do ld
que vem com o GNU no nosso sistema operacional GNU/Linux.
Linker
Linker é o programa responsável por, a partir de um ou mais arquivos objeto, gerar um arquivo final executável com o código de máquina.
Como já geramos anteriormente o arquivo objeto hello.o
utilizando o assembler NASM, podemos concluir o processo de compilação do nosso programa fonte asm x86 com ld
$ ld hello.o -o hello
E agora, vamos rodar o binário final executável hello
:
$ ./hello
echo $?
0
Hurray! Nosso primeiro programa em Assembly concluído com sucesso!
Contudo, vamos lembrar de um ponto importante que vimos na parte II da saga: que o programa e seus dados ficam na memória. Queremos entender o que está acontecendo na memória com este simples programa.
Depurando o programa
Uma das etapas mais importantes, senão a mais importante, em desenvolvimento de software, é a depuração (ou debugging, em inglês).
Depurar é o ato de conseguir interceptar a execução do programa, analisar o estado, alterar o estado, adicionar pontos de parada (breakpoints) entre outras técnicas.
O processo de depuração também consiste em analisar a saída do programa como um todo, seu tamanho e trace de chamadas no sistema operacional.
O utilitário size
Vamos iniciar o processo de depuração do nosso programa analisando o tamanho, com o utilitário GNU size:
$ size hello
text data bss dec hex filename
12 0 0 12 c hello
Mas o quê significa "text, data, bss, etc"?
Cada programa no sistema operacional é dividido em seções, que representam alguma característica para o sistema operacional.
text
Esta seção contém todo o código fonte do programa, e assim o SO sabe que precisa buscar esta seção na memória principal
data
Seção de dados inicializados na memória
bss
Seção de dados não-inicializados na memória
O comando size traz justamente o tamanho (em bytes) de cada seção.
dec
ehex
não são seções, são apenas a representação do valor total (em bytes) tanto em decimal quanto hexadecimal
-
text
: esta seção contém todo o código fonte do nosso programa, também chamado de "texto" -
data
: seção de dados inicializados, logo a seguir neste artigos entramos em detalhe -
bss
: seção de dados não-inicializados, logo a seguir também falaremos deste -
dec
: o tamanho total em decimal -
hex
: o tamanho total em hexadecimal
Nosso programa por enquanto só tem a seção text, que é exatamente todo o código a partir do rótulo _start
.
Nossa, Leandro, nosso programa tem apenas 12 bytes?
Aparentemente sim. Vamos confirmar:
$ ls -lh hello
...... 4.6K ... hello
Como assim o arquivo tem 4,6Kb? O programa não ocupa 12 bytes apenas?
Bom, isto ocorre por causa dos headers que são adicionados pelo linker, que contém informação relevante para que o sistema operacional possa admitir a execução do arquivo.
Vamos novamente utilizar o comando size
mas desta vez:
$ size --format sysv --radix 16 hello
hello :
section size addr
.text 0xc 0x401000
Total 0xc
A opção --format
indica o formato sysv
que traz também os símbolos. E a opção --radix 16
permite visualizar o tamanho de cada seção em hexadecimal.
Na seção size, 0xc
representa o número 12 em decimal. Nada de novo aqui. Mas se repararmos na coluna addr
, temos um valor hexadecimal para a seção text (0x401000).
Já vimos isto no artigo anterior, que 0x401000
se referia ao endereço em hexadecimal de memória virtual que indica o início do programa, lembra?
Hora de confirmar isto com uma análise mais profunda na depuração, chegou o momento de utilizarmos GNU gdb.
Debugging com GDB
GDB é um depurador (debugger em inglês) que permite ver o que está acontecendo dentro de um programa em execução.
Com um depurador, podemos analisar as informações estáticas contidas no binário do programa, estabelecer breakpoints (pontos de parada) em qualquer parte do código, executar e analisar mudanças de estado do programa durante sua execução.
Para habilitar o programa com gdb
, precisamos montar o programa com a opção -g
, que exporta símbolos necessários para depuração:
$ nasm -g -f elf64 hello.asm -o hello.o
$ ld hello.o -o hello
Podemos verificar os símbolos exportados no binário com o comando size novamente:
$ size --format sysv --radix 16 hello
hello :
section size addr
.text 0xc 0x401000
.debug_aranges 0x30 0x0
.debug_info 0x75 0x0
.debug_abbrev 0x1d 0x0
.debug_line 0x3d 0x0
Total 0x10b
##############
$ ls -lh hello
...... 5.1K ... hello
Como demonstrado acima, o binário agora contém seções adicionais de "debug" que serão utilizadas pelo gdb, e consequentemente o tamanho do programa teve um acréscimo de 500MB 512 bytes!
Sem mais delongas, vamos entrar no gdb
:
$ gdb --quiet
(gdb)
E agora, dentro do shell gdb, podemos utilizar diversos comandos de depuração. O comando help
traz a lista de classes de comandos disponíveis:
help
...
aliases -- User-defined aliases of other commands.
breakpoints -- Making program stop at certain points.
data -- Examining data.
files -- Specifying and examining files.
internals -- Maintenance commands.
obscure -- Obscure features.
running -- Running the program.
stack -- Examining the stack.
status -- Status inquiries.
support -- Support facilities.
text-user-interface -- TUI is the GDB text based interface.
tracepoints -- Tracing of program execution without stopping the program.
user-defined -- User-defined commands.
...
Para o escopo deste artigo vamos utilizar apenas alguns comandos para depuração, mas a lista de comandos disponíveis é gigante. Deixo o desafio ao leitor para se aventurar com o
help
do gdb e brincar de depurar qualquer binário executável
Como queremos depurar o binário hello, podemos carregar os símbolos utilizando o comando file
:
(gdb) file hello
Reading symbols from hello...
(gdb)
O comando info files
traz alguns insights:
(gdb) info files
Symbols from "/code/asm-x64/hello".
Local exec file:
`/code/asm-x64/hello', file type elf64-x86-64.
Entry point: 0x401000
0x0000000000401000 - 0x000000000040100c is .text
(gdb)
Que interessante! O entry point do programa começa justamente em 0x401000
, que é o que está definido na seção .text
.
Para visualizar o código fonte do programa, utilizamos o comando list
:
(gdb) list
1 global _start
2
3 _start:
4 mov rdi, 0 ; error code
5 mov rax, 60 ; SYS_exit
6 syscall
(gdb)
Lembrando que o programa ainda não está em execução, estamos apenas analisando o binário executável com o gdb
Com o comando x
, de examine, podemos examinar o rótulo _start
que é o ponto de entrada do programa:
(gdb) x _start
0x401000 <_start>: 0x000000bf
(gdb)
Se quisermos executar o programa, podemos fazê-lo com o comando run
:
(gdb) run
Starting program: /code/asm-x64/hello
[Inferior 1 (process 7991) exited normally]
(gdb)
Entretanto, podemos definir breakpoints antes de executar, assim temos controle do estado do programa em execução:
# Aqui, definimos um ponto de parada no rótulo _start_
(gdb) break _start
Breakpoint 1 at 0x401000: file hello.asm, line 4.
# Info sobre breakpoints
(gdb) info breakpoints
Num Type Disp Enb Address What
1 breakpoint keep y 0x0000000000401000 hello.asm:4
(gdb)
Agora sim, vamos executar:
(gdb) run
Starting program: /code/asm-x64/hello
Breakpoint 1, _start () at hello.asm:4
4 mov rdi, 0 ; error code
(gdb)
O programa está parado na linha 4 como solicitado. Esta linha no código não foi avaliada, pelo que podemos analisar e alterar o estado do programa:
# Neste momento o valor no registrador RDI está 0 (default)
(gdb) info register rdi
rdi 0x0 0
# Mudamos o valor do registrador para 42
(gdb) set $rdi = 42
# Agora verificamos que foi modificado diretamente do GDB
(gdb) info register rdi
rdi 0x2a 42
(gdb)
Para avaliar a linha atual, utilizamos o comando next
:
(gdb) next
5 mov rax, 60 ; SYS_exit
# E podemos agora verificar que o valor de RDI foi modificado para 0,
# conforme descrito no programa
(gdb) info register rdi
rdi 0x0 0
(gdb)
Poderíamos continuar indo linha a linha com next
, ou então continuar a execução do programa com continue
que pára no próximo ponto de parada ou executa todas as instruções que faltam até terminar o programa.
# Inicia execução e pára no primeiro breakpoint definido
(gdb) run
Starting program: /Users/leandronsp/Documents/code/asm-x64/hello
Breakpoint 1, _start () at hello.asm:4
4 mov rdi, 0 ; error code
# Continua execução. Neste caso termina o programa pois
# não há mais breakpoints a partir deste ponto
(gdb) continue
Continuing.
[Inferior 1 (process 8000) exited normally]
(gdb)
Pronto, terminamos a demonstração do primeiro programa com gdb
. Para sair, utilizamos o comando exit
.
Rastreando execução com strace
O utilitário strace
permite rastrear todas as chamadas de sistema e sinais que um programa faz. É bastante útil quando queremos saber o que pode ter acontecido com determinada syscall, quais parâmetros foram enviados e o que a syscall retornou.
$ strace ./hello
execve("./hello", ["./hello"], 0x7ffc504b5710 /* 24 vars */) = 0
exit(0) = ?
+++ exited with 0 +++
Vamos entender a saída do strace por partes.
execve("./hello", ["./hello"], 0x7ffc504b5710 /_ 24 vars _/) = 0:
-
execve
é uma chamada do Linux que executa um determinado programa -
./hello
é o caminho para o programa que será executado -
["./hello"]
é a lista de argumentos passados para o programa. Como só há o nome do programa (que entra na lista ARGV), indica que este programa não recebe argumentos extras na linha de comando -
0x7ffc504b5710
é o endereço de memória onde as variáveis de ambiente do processo em execução estão armazenadas -
/* 24 vars */
indica que há 24 variáveis de ambiente definidas no shell atual -
=0
é o resultado da chamadaexecve
, o que significa que foi bem-sucedido e executado com sucesso
exit(0) = ?:
-
exit
é a chamada de sistema (syscall) feita no sistema operacional, e geralmente é definida nolibc
, sendo no caso de sistema GNU,glibc
. Foi o valor 60 passado para o registrador RAX, lembra? -
(0)
é o parâmetro passado para a função, que neste caso foi o que determinamos no registrador RDI, indicando que nosso programa em execução vai terminar sem erros -
= ?
indica que o resultado da chamada de sistema não é conhecido, ou seja não houve um retorno explícito de valor da chamada de sistema
+++ exited with 0 +++:
-
+++
sinaliza o início de uma mensagem de saída do strace -
exited with 0
indica que o programa terminou sem erros -
+++
sinaliza o fim da mensagem de saída
Uma vez que entendemos como depurar nosso programa, podemos evolui-lo para imprimir a mensagem "Hello, World" na saída do terminal.
Evoluindo nosso primeiro programa
Vamos agora evoluir o programa anterior para que possamos imprimir a mensagem "Hello, World" na saída padrão STDOUT
.
Para isto, conforme vimos na parte III da saga, "Código de Máquina", vamos por partes.
Alocando bytes para "Hello, World"
Precisamos primeiro definir os bytes de cada caracter da string em hexadecimal de acordo com a tabela ASCII, que resulta em 48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 0A
.
0x48 para "H", 0x65 para "e", 0x6C para "l" e assim por diante...
Portanto, se quisermos evoluir o primeiro programa que contém apenas a syscall exit
, podemos começar por definir a string utilizando a diretiva db
que significa define byte, utilizando o endereço do primeiro byte em um rótulo que iremos chamar de msg:
global _start
msg: db 0x48, 0x65, 0x6C, 0x6C, 0x6F, \
0x2C, 0x20, 0x57, 0x6F, 0x72, \
0x6C, 0x64, 0xA
_start:
mov rdi, 0 ; error code
mov rax, 60 ; SYS_exit
syscall
Antes de sair adicionando mais código, vamos utilizar o gdb
para analisar o que esta mudança provoca na memória:
# Examinar o que há no rótulo msg
(gdb) x msg
0x401000 <msg>: 0x6c6c6548
# Examinar o que há no rótulo _start
(gdb) x _start
0x40100d <_start>: 0x000000bf
(gdb)
Ora ora, o que temos aqui?
-
msg
aponta para o endereço0x401000
que era o endereço usado pelo_start
no nosso programa anterior - e agora
_start
aponta para outro endereço,0x40100d
que está 13 bytes ("d" em hexa) acima demsg
, exatamente os 13 bytes da string "Hello, World" adicionado com quebra de linha!!!!!1
Superb! Mas o que significa o valor
0x6c6c6548
?
Se analisarmos com calma, dá pra perceber que se trata dos caracteres da string em ASCII segundo o que foi definido no programa. Mas eles estão invertidos, lembra de endianness que foi explicado no artigo anterior?
Então, esta arquitetura segue o padrão little-endian, onde os bytes são armazenados na ordem inversa, do menos relevante (expoentes menores da base 2) para o mais relevante (expoentes maiores).
Voltando ao gdb, podemos confirmar que todos os bytes da string estão alocados trabalhando com ponteiros de 4 em 4 bytes:
(gdb) x msg
0x401000 <msg>: 0x6c6c6548 ; Hell
(gdb) x msg+4
0x401004: 0x57202c6f ; o, W
(gdb) x msg+8
0x401008: 0x646c726f ; orld
Ou então, o comando x
permite passar uma quantidade junto com o formato de apresentação, por exemplo queremos que traga os primeiros 13 hexabytes a partir do ponteiro msg:
(gdb) x/13xb msg
0x401000 <msg>: 0x48 0x65 0x6c 0x6c 0x6f 0x2c 0x20 0x57
0x401008: 0x6f 0x72 0x6c 0x64 0x0a
Exatamente os hexadecimais da string "Hello, World" com quebra de linha!
Mas em Assembly, não precisamos definir os bytes de uma string em hexadecimal. Podemos utilizar os quotes literais, assim o programa fica menos verboso e o assembler faz o processo de traduzir o caracter para o hexadecimal da tabela ASCII:
msg: db "Hello, World", 0xA
Não conseguimos representar a quebra de linha dentro de quotes literais, então vamos manter esta com 0xA
Adicionando a chamada de sistema write
Como já sabemos, o programa precisa utilizar a syscall write
para escrever na saída, que está definida da seguinte forma no glibc
:
ssize_t write(int fd, const void buf[.count], size_t count);
- nome da syscall vai em RAX
- primeiro argumento (file descriptor, no caso o STDOUT) vai em RDI
- segundo argumento (ponteiro para o início do buffer) vai em RSI
- terceiro argumento (quantidade de bytes a serem escritos) vai em RDX
global _start
msg: db "Hello, World", 0xA
_start:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Chamada de sistema
; glibc -> ssize_t write(int fd,
const void buf[.count],
size_t count)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
mov rdi, 1 ; STDOUT
mov rsi, msg ; ponteiro para o início da string
mov rdx, 13 ; quantidade de bytes a serem escritos
mov rax, 1 ; nome da syscall: SYS_write
syscall ; chamada de sistema
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Chamada de sistema
; glibc -> void _exit(int status)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
mov rdi, 0 ; erro de saída
mov rax, 60 ; nome da syscall: SYS_exit
syscall
Ao compilar o programa com nasm + ld
, seguindo a mesma lógica do primeiro programa, temos de fato a saída tão desejada:
$ ./hello
Hello, World
Yay! que dia maravilhoso!
Vamos ver como fica o trace disso tudo agora?
$ strace ./hello
execve("./hello", ["./hello"], 0x7fff139437f0 /* 24 vars */) = 0
write(1, "Hello, World\n", 13Hello, World
) = 13
exit(0) = ?
+++ exited with 0 +++
Wow, podemos ver que, agora, o programa executa primeiro a syscall write
, que retorna o valor 13
, que é quantidade de bytes escritos com sucesso; e a seguir executa a syscall exit
, também com sucesso, indicando que nosso programa imprime a string na saída e termina sem erros.
Como ficou o tamanho do programa agora?
$ size hello
text data bss dec hex filename
52 0 0 52 34 hello
Hmm, parece que a seção text
aumentou de tamanho, que é a adição da string "Hello, World" e das instruções para a syscall write
. Mas por enquanto é a única seção existente:
$ size --format sysv --radix 16 hello
hello :
section size addr
.text 0x34 0x401000
(omitindo seções de debug)
Total 0x139
Podemos ver que a string definida no rótulo msg
, que começa no endereço 0x401000
está contida na seção .text
.
Isto é um problema?
Mais ou menos:
- O rótulo
msg
, que é um "dado", contendo a string, está definido num endereço de memória anterior, ou seja, em endereço de memória mais baixo em direção a 0 - O rótulo
_start
, que é o início do programa, está definido num endereço posterior, ou seja, em endereço de memória mais alto com relação à string
No sistema operacional, todo programa é encapsulado em um processo tal como vimos no artigo anterior. E sendo um processo, é submetido a um "layout" que deve seguir algumas regras.
Layout de memória
Fazendo paralelo com a saída do comando size
, a memória do programa segue um layout, que basicamente contém as seguintes seções, ou segmentos de memória:
- text
- data
- bss
Já falamos disto anteriormente neste artigo, mas basicamente na seção text
fica todo o código, instruções do programa.
Na seção data
, ficam dados inicializados (aqui deveria estar a nossa string). E na seção bss
vão os dados não-inicializados, mas já com uma área pré-alocada na memória.
Em termos de espaço virtual de memória do programa, a seção text
deve ficar nos endereços de memória mais baixos, próximos ao entry point 0x401000
.
Com isto, o programa deve crescer a partir da seção text
em direção a data
e bss
, dos menores endereços de memória para os maiores (da esquerda pra direita):
text -> data -> bss
Ou então, analisando numa imagem em vertical, de baixo pra cima:
Existem mais seções no layout mas vamos adicioná-las à medida que avançamos no artigo. Por agora, como nosso programa está tratando dados (msg
) como text
, devemos colocar na seção correta, que é data
:
global _start
; segmento de dados (endereços mais altos)
section .data
msg: db "Hello, World", 0xA
; segmento de texto (endereços mais baixos)
section .text
_start:
mov rdi, 1 ; STDOUT
mov rsi, msg ; ponteiro para o início da string
mov rdx, 13 ; quantidade de bytes a serem escritos
mov rax, 1 ; nome da syscall: SYS_write
syscall ; chamada de sistema
mov rdi, 0 ; erro de saída
mov rax, 60 ; nome da syscall: SYS_exit
syscall
Com gdb
, podemos conferir que agora estamos obedecendo o layout de memória estabelecido para o programa:
(gdb) x _start
0x401000 <_start>: 0x000000bf
(gdb) x &msg
0x402000 <msg>: 0x6c6c6548
(gdb)
Note que para acessar
msg
no segmento de dados, precisamos examinar através da referência, com o operador&
Definindo constantes
Em Assembly podemos definir constantes que podem ser reutilizadas em diversas partes do programa, evitando assim alguma redundância com repetição de código e valores.
A diretiva %define
permite definir valores constantes tanto para string quanto números:
global _start
%define SYS_write 1
%define SYS_exit 60
%define EXIT_STATUS 1
%define STDOUT 1
%define NEWLINE 0xA
section .data
msg: db "Hello, World", NEWLINE
section .text
_start:
mov rdi, STDOUT
mov rsi, msg
mov rdx, 13
mov rax, SYS_write
syscall
mov rdi, EXIT_STATUS
mov rax, SYS_exit
syscall
Podemos também definir uma constante baseada em uma expressão aritmética. Por exemplo, ao invés de deixarmos o tamanho em bytes com valor fixo 13, podemos fazer que isto seja calculado com base em aritmética de ponteiros na memória com a diretiva equ
:
...
section .data
msg: db "Hello, World", NEWLINE
msgLen: equ $ - msg
...
O operador $
tem o ponteiro de memória para o último byte no programa, no caso o NEWLINE
definido na linha anterior. Ao subtrair do ponteiro msg
com a expressão $ - msg
, temos o tamanho em bytes calculado e desta forma não precisa ser um valor fixo em RDX:
global _start
%define SYS_write 1
%define SYS_exit 60
%define EXIT_STATUS 1
%define STDOUT 1
%define NEWLINE 0xA
section .data
msg: db "Hello, World", NEWLINE
msgLen: equ $ - msg
section .text
_start:
mov rdi, STDOUT
mov rsi, msg
mov rdx, msgLen
mov rax, SYS_write
syscall
mov rdi, EXIT_STATUS
mov rax, SYS_exit
syscall
Wonderful! Nosso programa agora tá muito mais elegante!
Ufa, parece que terminamos o nosso primeiro programa e este por si só já foi uma jornada longa. Mas tenha um pouco mais de paciência, vem comigo, pois chegou o momento de escrevermos um programa um pouco mais sofisticado.
Hora de explorar mais funcionalidades no Assembly e entrar no mundo da stack.
Um programa mais sofisticado
Vamos começar por um programa simples e evoluindo conforme depuramos e entendemos a memória. Ao fim, o programa deve ser capaz de receber um nome através dos argumentos da linha de comando e imprimir "Hi, <nome>
".
Desejado:
$ ./greeting Leandro
Hi, Leandro
Definindo labels
Já sabemos que o programa precisa imprimir "Hi, " alguma coisa. Então as instruções pra syscall write
são necessárias, e já fazendo uso de constantes:
global _start
%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1
section .data
greet: db "Hi", 0xA
section .text
_start:
mov rdi, STDOUT
mov rsi, greet
mov rdx, 3
mov rax, SYS_write
syscall
mov rdi, 0
mov rax, SYS_exit
syscall
Este programa imprime "Hi" apenas. Mas podemos melhorar a organização separando em blocos com algum valor semântico:
- separar o bloco de
exit
- separar o bloco de
write
Assembly emprega o conceito de labels, que são rótulos, mas que podem ser definidas em qualquer parte do código. Utilizando o caracter ponto (.
), o programa fica bem mais expressivo:
global _start
%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1
section .data
greet: db "Hi", 0xA
section .text
_start:
.print:
mov rdi, STDOUT
mov rsi, greet
mov rdx, 3
mov rax, SYS_write
syscall
.exit:
mov rdi, 0
mov rax, SYS_exit
syscall
Assim como qualquer rótulo, o programa vai executando top-down. O que fizemos aqui foi apenas colocar rótulos em determinadas partes do programa, mas sem alterar seu fluxo de execução.
Desvio de fluxo com jump
Se quisermos alterar o fluxo de execução, podemos utilizar a instrução JMP
que altera o fluxo do programa para outro ponto, continuando a partir desde novo ponto.
global _start
%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1
section .data
greet: db "Hi", 0xA
section .text
_start:
; Faz o jump para a label .print, sem passar por .exit
jmp .print
.exit:
mov rdi, 0
mov rax, SYS_exit
syscall
.print:
mov rdi, STDOUT
mov rsi, greet
mov rdx, 3
mov rax, SYS_write
syscall
; Faz o jump para a label .exit, caso contrário o programa não terminaria
; da forma adequada (todo programa deve terminar)
jmp .exit
Este foi um exemplo bastante simples com jump e desvio de fluxo. Mas é possível também desviar o fluxo, executar a lógica do novo fluxo, e retornar ao ponto anterior.
Entretanto, para que isto funcione, vamos imaginar uma possível solução:
- definir algum registrador "especial" que guarda sempre o ponteiro da próxima instrução
- antes de desviar o fluxo, guardar o endereço de memória da próxima instrução do programa em alguma estrutura de dados para que possa ser resgatado quando a lógica do desvio terminar
Sim, estamos falando do desvio com call
, ret
, registradores e pilha.
Desvio de fluxo com call
Tendo o exemplo anterior, ao invés de fazer jmp
, vamos utilizar a instrução call
que faz o desvio para outra rotina:
call .print ; <------ chamada da rotina
Além disso, a última linha da rotina .print
deve "retornar" o fluxo desviado para o ponto anterior.
.print:
mov rdi, STDOUT
mov rsi, greet
mov rdx, 3
mov rax, SYS_write
syscall
ret ; <------ retorno da rotina
Antes de analisarmos com gdb
passo a passo, precisamos entender um aspecto importante dos programas no sistema operacional.
Quando um programa é executado, ele é definido em uma estrutura chamada processo (já falamos disto no artigo anterior). Todo processo carrega o layout de memória definido no binário do programa, conforme vimos anteriormente:
text -> data -> bss
Nos endereços mais altos da memória virtual do processo (programa em execução), o sistema operacional também define uma outra estrutura de dados, chamada stack, que tem um formato de pilha (LIFO, Last In, First Out).
text -> data -> bss ---------> <-------- stack
A stack fica nos endereços mais altos e carrega informações como argumentos do programa, lista de variáveis de ambiente definidas no shell, argumentos para funções entre qualquer informação pertinente para o programa. Stack sempre cresce para baixo em direção aos endereços menores.
rsp
Em um programa Assembly x86, é preciso armazenar o ponteiro atual do topo da stack, e esta informação fica no registrador RSP, ou stack pointer.
rip
Já o ponteiro da instrução atual fica no registrador RIP, ou instruction pointer.
Com estes dois registradores conseguimos demonstrar o uso de call e ret para desvio de fluxo. Voltando ao programa:
global _start
%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1
section .data
greet: db "Hi", 0xA
section .text
_start:
call .print ; <--------- desvio do fluxo
; aqui neste ponto, já continua a execução normal em direção ao exit
; para terminar o programa
.exit:
mov rdi, 0
mov rax, SYS_exit
syscall
.print:
mov rdi, STDOUT
mov rsi, greet
mov rdx, 3
mov rax, SYS_write
syscall
ret ; <---------- retorno ao ponto anterior
E agora demonstrando com gdb
:
$ gdb greeting
# Breakpoint em _start, início do programa
(gdb) break _start_
(gdb) run
# RIP apontando para 0x401000 (_start), o entry point do programa
(gdb) info register $rip
rip 0x401000 0x401000 <_start>
# RSP apontando para um endereço de memória
# Se formos examinar com x $rsp, temos
# 0x7fffffffe450: 0x00000001, que é a quantidade de argumentos
# passados, no caso 1 representa apenas o nome do programa, ou seja
# não há argumentos na linha de comando
(gdb) i r $rsp
rsp 0x7fffffffe450 0x7fffffffe450
Até aqui okay, agora vamos andar um step
para que o desvio de call
seja avaliado e analisar o RIP:
(gdb) step
18 mov rdi, STDOUT
# O RIP andou conforme esperado
(gdb) i r $rip
rip 0x401011 0x401011 <_start.print>
# RIP apontando para 0x000001bf, que é BF 01 00 00
# Lembra? é o opcode pra MOV RDI, 1
# Exatamente onde paramos
(gdb) x $rip
0x401011 <_start.print>: 0x000001bf
E a stack (RSP) como ficou?
# A pilha andou alguns bytes (no caso foi feito um "push", o que a fez crescer para endereços de memórias menores)
# Lembra? Pilha "cresce pra baixo" na memória
(gdb) i r rsp
rsp 0x7fffffffe448 0x7fffffffe448
# Opaaa, o que temos aqui? 0x00401005
# É alguma pista...
(gdb) x $rsp
0x7fffffffe448: 0x00401005
# Examinando o ponteiro do início do programa...
(gdb) x _start
0x401000 <_start>: 0x00000ce8
# Se andarmos alguns bytes,
# temos exatamente o endereço da label .exit, 0x401005
(gdb) x _start + 5
0x401005 <_start.exit>: 0x000000bf
Se você prestou atenção nos comentários do snippet acima...
É muito importante prestar atenção em todos os comentários, se não estiver fazendo isso, volte o artigo do início e tente acompanhar no terminal, é extremamente importante para entender os conceitos
...se prestarmos a devida atenção, este é o endereço que tá no topo da pilha agora, que foi adicionado pela instrução call
.
Ok Leandro, mas como fazemos então para voltar ao ponto anterior?
Calma, jovem. Estamos parados no início da rotina .print
. Vamos continuar a depuração com gdb
até parar em ret
:
(gdb) next
19 mov rsi, greet
(gdb) next
20 mov rdx, 3
(gdb) next
21 mov rax, SYS_write
(gdb) next
22 syscall
(gdb) next
Hi
_start.print () at greeting.asm:23
23 ret
(gdb)
Nice, antes de avaliar a instrução ret
, podemos ver que RIP andou mas RSP continua na mesma, com o endereço da próxima instrução antes do desvio:
# RIP aponta para a instrução da linha "ret"
(gdb) x $rip
0x40102c <_start.print+27>: 0x000000c3
# RSP aponta para o endereço de memória que está a instrução .exit, que
# vem a seguir ao desvio feito com "call" lá em cima
(gdb) x $rsp
0x7fffffffe448: 0x00401005
Vamos andar com ret
e....
# RIP agora aponta para 0x401005, que é a instrução .exit
(gdb) x $rip
0x401005 <_start.exit>: 0x000000bf
# Foi feito "pop" em RSP e agora este aponta para o topo da pilha
# com o valor exato quando estava no início do programa
(gdb) x $rsp
0x7fffffffe450: 0x00000001
(gdb)
OMG!! Acabamos de demonstrar manipulação de registradores e pilhas.
Brincando com pilhas
Pilhas é divertido.
Mas prefiro filas, gosto de tratar as coisas de modo ordenado. Quem chega primeiro precisa ser atendido primeiro kkkkkkkk
Mas com pilhas não é assim. Quem entra por último sai primeiro.
Com base nisto, como podemos manipular a stack do programa? Vamos alterar um pouco o código adicionando o ponteiro de greet
na stack:
global _start
%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1
section .data
greet: db "Hi", 0xA
section .text
_start:
push greet ; <----- adiciona o ponteiro de greet na stack
call .print
.exit:
mov rdi, 0
mov rax, SYS_exit
syscall
.print:
mov rdi, STDOUT
mov rsi, greet
mov rdx, 3
mov rax, SYS_write
syscall
ret
No gdb
, vamos colocar um breakpoint na linha da chamada call
:
# Breakpoint na linha 13
(gdb) b 13
Breakpoint 1 at 0x401005: file greeting.asm, line 13.
# Run
(gdb) r
# Examinando o topo da pilha
(gdb) x $rsp
0x7fffffffe448: 0x00402000
# Examinando o endereço de memória que tá no topo da pilha
(gdb) x 0x00402000
0x402000 <greet>: 0x2c0a6948
Cool, temos 0x48 0x69 0x0A
(little-endian), exatamente a string "Hi" seguida de uma quebra de linha. Com esta rica informação, ao invés da rotina .print
passar pro registrador RSI o ponteiro de greet
, porque não passar o ponteiro do topo da pilha?
Algo nessa linha:
; ao invés disso (atual)
mov rsi, greet
; que tal mover o ponteiro que tá em rsp (topo da pilha) para rsi
mov rsi, rsp
Por enquanto, seguramos esta ideia no bolso. Ainda no gdb
, vamos continuar analisando a pilha depois de entrar na rotina:
(gdb) step
_start.print () at greeting.asm:19
# Agora o topo da pilha foi modificado, "call" colocou o endereço de
# memória da próxima instrução quando voltar do "ret"
(gdb) x $rsp
0x7fffffffe440: 0x0040100a
# O endereço de memória aponta justamente pra próxima instrução quando voltar do "ret", no caso a instrução que tá na label .exit do programa
(gdb) x 0x0040100a
0x40100a <_start.exit>: 0x000000bf
Mas agora o topo da pilha estraga nossa ideia de fazer mov rsi, rsp
, mas podemos fazer aritmética com ponteiros e mover o conteúdo resultante, e é muito fácil:
# Topo da pilha apontando pra instrução guardada pelo "call"
(gdb) x $rsp
0x7fffffffe440: 0x0040100a
# Topo da pilha + 8 bytes apontando pro endereço onde tá a string "Hi"
(gdb) x $rsp+8
0x7fffffffe448: 0x00402000
Nesta arquitetura, a pilha, assim como os registradores, armazenam por padrão até 8 bytes por cada informação
Então teoricamente, tudo o que precisamos é mov rsi, [rsp + 8]
Note que é preciso usar
[rsp + 8]
, com square brackets é uma forma de fazermos aritmética de ponteiros e acessar o valor resultante da operação na memória, no caso o endereço apontando para a string "Hi"
Para finalizar este primeiro exemplo, é muito importante fazermos "pop" da pilha. Todo push
deve ter um pop
, caso contrário podemos gastar a pilha desnecessariamente e talvez chegar a um stack overflow se exagerarmos bastante.
global _start
%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1
section .data
greet: db "Hi", 0xA
section .text
_start:
push greet ; <----- push na pilha
call .print
pop rbp ; <----- pop da pilha, jogando o valor em rbp
; note que rbp é outro registrador de propósito geral,
; mas que é utilizado para manter a base da pilha
.exit:
mov rdi, 0
mov rax, SYS_exit
syscall
.print:
mov rdi, STDOUT
mov rsi, [rsp + 8] ; <----- 8 bytes depois do topo da pilha está o
; endereço de memória da string
mov rdx, 3
mov rax, SYS_write
syscall
ret
Podemos reparar 2 coisas:
- A rotina
.print
está ficando bastante genérica, ou seja ela não sabe o que está na pilha, simplesmente move para o registrador RSI e faz a syscallwrite
- A rotina
.print
ainda usa o tamanho em bytes como valor fixo, no caso 3 bytes. Deveria ser dinâmico também se quisermos fazer com que esta rotina seja bem genérica
Colocamos o tamanho também na pilha? Nah, seria mais interessante ainda se calculássemos dinamicamente o que vem da pilha. Para fazer este cálculo, teríamos que "iterar", em forma de loop, por cada byte que queremos imprimir, incrementar em um registrador e utilizar isto na syscall.
Vamos entrar no mundo dos loops e condicionais.
Calculando tamanho dinamicamente com loop
Combinando labels e jumps, podemos criar um loop em assembly, como neste pequeno exemplo a seguir:
; Um loop infinito sem condição de parada
; Não façam isso
global _start
_start:
.loop
jmp .loop
Entretanto para adicionarmos uma condição de parada do loop, é necessário utilizar uma instrução de comparação e outra que muda algum estado.
No nosso exemplo, vamos introduzir um loop que calcula o tamanho da string antes de fazer a syscall. Entendendo a necessidade:
; Pseudo-code
.print:
mov rdi, STDOUT
mov rsi, [rsp + 8] ; string em RSI
mov rdx, ? ; <--- aqui devemos introduzir um loop que vai
; modificando o valor de RDX, lendo byte a byte
; o conteúdo da string
mov rax, SYS_write
syscall
ret
Para resolver isto, podemos criar uma label chamada .calculate_size
que contém um jmp
para ela mesma:
.print:
mov rsi, [rsp + 8] ; string em RSI
mov rdx, 0 ; RDX começa em 0
.calculate_size: ; label
jmp .calculate_size ; jmp "recursivo"
.done:
mov rdi, STDOUT
mov rax, SYS_write
syscall
ret
Ao rodarmos o programa, obviamente caímos em loop infinito. Precisamos definir uma condição de parada, que consiste em:
- mudar o estado de alguma variável condicional
- desviar o fluxo para outra label quando a condição for verdadeira
Em Assembly, podemos fazer a mudança de estado utilizando a instrução inc
:
.print:
mov rsi, [rsp + 8]
mov rdx, 0 ; RDX (contador) começa em 0
.calculate_size:
inc rdx ; incrementa o valor que está em RDX (linha 23)
jmp .calculate_size
.done:
mov rdi, STDOUT
mov rax, SYS_write
syscall
ret
Com gdb, verificamos que o valor de RDX está sempre sendo incrementado:
# Adicionar breakpoint na linha 23 (<<inc rdx>>)
(gdb) break 23
Breakpoint 1 at 0x401021: file live.asm, line 23.
# Executar o programa, que vai no primeiro breakpoint
(gdb) run
Breakpoint 1, _start.calculate_size () at live.asm:23
23 inc rdx
# Continuar execução até o próximo breakpoint ou fim do programa.
# Mas como estamos em loop, o programa vai parar de novo nesta linha
(gdb) continue
# Atalho para "info register rdx"
(gdb) i r rdx
rdx 0x1 1
# Próxima iteração...
(gdb) continue
(gdb) i r rdx
rdx 0x2 2
# E assim infinitamente pois não temos ainda a segunda premissa da condição de parada, que é a condicional
(gdb) continue
(gdb) i r rdx
rdx 0x19 25
Como podemos elaborar esta condicional, uma vez que o valor em RDX pode ser infinito, logo ter todas as possibilidades?
Uma ideia é irmos consumindo byte a byte da string até chegar a zero. Para isto, podemos definir o fim da string com 0x0
e fazer aritmética binária na própria string, consumindo os bytes até chegar a 0x0
!
Eis o exemplo com um pseudo-código:
; "Hi", 0
; que em hexabyte fica 0x49, 0x69, 0x00
INCREMENT
0x69 0x00 ; consumiu o byte mais à esquerda 0x49
INCREMENT ; consumiu o byte mais à esquerda 0x69
0x00 0x...
Nossa Leandro, que fantástico! Podemos então fazer
inc
em um registrador que contém a string, nesse caso o próprio RSI?
Isso mesmo!
.....
section .data
greet: db "Hi", 0xA, 0 ; <--- adicionamos o "zero" para identificar o
; fim da string
.....
.print:
mov rsi, [rsp + 8]
mov rdx, 0
.calculate_size:
inc rdx ; incrementa o valor inteiro em RDX (contador)
inc rsi ; <--- além de incrementar o RDX, incrementamos
; também o RSI, que contém o endereço de
; memória para a string. Aritmética em hexabytes
; vai fazer o efeito de "consumir os bytes até zero
jmp .calculate_size
.done:
mov rdi, STDOUT
mov rax, SYS_write
syscall
ret
Agora, com gdb
, vamos verificar o que está acontecendo com nosso programa:
# Breakpoint na linha <jmp .calculate_size>
(gdb) break 25
Breakpoint 1 at 0x401027: file live.asm, line 25.
(gdb) run
# Cool, o contador RDX foi incrementado
(gdb) i r rdx
rdx 0x1 1
# Em RSI, temos outro endereço de memória.
# Anteriormente era o início da string, 0x402000, mas agora está
# apontando para 0x402001
(gdb) i r rsi
rsi 0x402001 4202497
# Wow! Temos os bytes da string "i", seguido de "\n", e depois o 0x00
# Parece que o inc RSI funcionou como esperávamos?
(gdb)x /4xb 0x402001
0x402001: 0x69 0x0a 0x00 0x2c
# A vida continua...
(gdb) continue
# Caminho mais curto
# E parece que RSI andou mais ainda, agora apontando para o byte "\n"
(gdb) x $rsi
0x402002: 0x0a
# A vida continua...
(gdb) continue
# O nosso grande momento! Agora RSI aponta para 0x00
(gdb) x $rsi
0x402003: 0x00
Tudo o que precisamos fazer, neste momento, é comparar o valor que está em RSI com zero. Se chegou a zero, significa que podemos parar o loop. Vamos verificar o que está no contador RDX (esperamos que seja 3):
(gdb) i r rdx
rdx 0x3 3
Yay! Que grande momento!
Mas como verificar em Assembly se chegou ou não no valor? Existe "IF" e "ELSE" em Assembly?
Hell no!
Não. Não tem "IF" e "ELSE" em Assembly.
Uma possível solução seria:
- utilizar uma instrução que compare o valor de um registrador ou em algum endereço de memória com qualquer outro valor
- esta instrução iria guardar o resultado da comparação em outro registrador "especial"
- utilizar outra instrução para fazer desvio do fluxo de acordo com o valor que estive neste registrador especial
Sim, é aí que entramos no tal do registrador RFLAGS.
RFLAGS
O registrador de flags é um registrador de status que mantém sempre o estado atual da CPU, neste caso estamos referindo a uma CPU x86_64, pelo que chamamos este registrador de RFLAGS.
Este registrador guarda opcodes condicionais, que são resultado de diversas operações lógicas e aritméticas que afetam o estado da CPU.
Voltando ao nosso exemplo, podemos comparar o registrador RSI com o valor 0, e então verificar o que está acontecendo com o registrador eflags
:
.print:
mov rsi, [rsp + 8]
mov rdx, 0
.calculate_size:
inc rdx
inc rsi
cmp byte [rsi], 0x00 ; <-- aqui comparamos (em byte) o valor que está
; em RSI com o byte 0x00
jmp .calculate_size
.done:
mov rdi, STDOUT
mov rax, SYS_write
syscall
ret
E com isto podemos conferir com gdb
:
# Breakpoint na linha <cmp byte [rsi], 0x00>
(gdb) break 25
(gdb) run
# O que temos no primeiro byte de RSI? "i", pois o "H" já foi
# consumido no <inc rsi>
(gdb) x /1xb $rsi
0x402002: 0x69
# E no eflags?
# Nossa, temos o IF que estávamos precisando!!!!!11
(gdb) i r eflags
eflags 0x202 [ IF ]
Calma jovem,
IF
não é o que você está pensando!
IF
é uma flag chamada interrupt flag, que está sempre presente no programa em execução. Ela determina se o programa pode ou não sofrer interrupções de hardware. No nosso caso, está sempre habilitada por padrão, e é por este motivo que podemos fazer chamadas de sistema (syscalls).
Continuando no gdb...
(gdb) continue
# O que temos em RSI? "\n"
(gdb) x /1xb $rsi
0x402002: 0x0a
# Executar a instrução <cmp byte [rsi], 0x00>
(gdb) next
# Ok, segunda iteração continua na mesma, sem flags adicionais
(gdb) i r eflags
eflags 0x202 [ IF ]
######## Próxima iteração ##########
(gdb) continue
# O que temos em RSI? 0x00, cool.
(gdb) x /1xb $rsi
0x402003: 0x00
# Executar a instrução <cmp byte [rsi], 0x00>
(gdb) next
# Outras flags foram adicionadas ao estado: PF e ZF
(gdb) i r eflags
eflags 0x246 [ PF ZF IF ]
PF
é a parity flag, que é adicionada quando uma operação aritmética em qualquer registrador resulta em paridade ímpar.
Não é do escopo deste artigo entrar em detalhes sobre PF, sugiro a leitura sobre o assunto
Já a ZF
é chamada zero flag, adicionada quando uma operação aritmética resulta em zero, que é exatamente o que estamos buscando aqui.
Agora o que precisamos é desviar o fluxo (lembra do jmp
) quando a flag zero está presente. Para isto, temos a disposição diversas instruções de jump baseadas em flags:
- jz (jump if zero)
- jnz(jump if not zero)
- je (jump if equal)
- jne (jump if not equal)
Isto pra mencionar apenas algumas, existem muitas outras que podem ser consultadas aqui
Com isto, a instrução que precisamos é a jz
, que verifica se a flag ZF
está presente:
.print:
mov rsi, [rsp + 8]
mov rdx, 0
.calculate_size:
inc rdx
inc rsi
cmp byte [rsi], 0x00 ; <--- compara RSI com 0x00. Adiciona a flag ZF ; quando chegar a zero
jz .done ; <--- desvia fluxo para a label ".done" caso a
; flag ZF esteja presente
jmp .calculate_size
.done:
mov rdi, STDOUT
mov rax, SYS_write
syscall
ret
Com gdb
, colocamos o breakpoint na linha mov rdi, STDOUT
que é depois do loop. Caso o programa fique parado nesta linha, significa que o loop foi concluído com sucesso e os bytes da string devidamente calculados:
# Breakpoint na linha <mov rdi, STDOUT>
(gdb) break 29
(gdb) run
# Olha o que temos aqui
(gdb) i r rdx rsi
rdx 0x3 3
rsi 0x402003 4202499
# E se formos examinar a string (com x/s) em RSI, temos isto:
(gdb) x/s $rsi
0x402003: ""
- Em RDX, temos o contador, que está em 3, que é a quantidade de bytes que será passada como terceiro argumento da syscall write. Okay, aqui ficou tudo certo.
- Em RSI, temos
0x402003
, e o valor está vazio, ou0x00
. Isto é um problema
O problema reside no fato de que RSI precisa ter o ponteiro para a string em si, e as operações de inc rsi
modificaram o registrador, pelo que não queremos que isto aconteça.
Podemos então inicialmente mover o valor que está em RSI para outro registrador temporário, que pode ser um daqueles registradores de rascunho, chamados de draft registers:
.print:
mov rsi, [rsp + 8]
mov r9, rsi ; aqui preservamos RSI, movendo o valor para R9
mov rdx, 0
.calculate_size:
inc rdx
inc r9 ; incrementar o valor em R9, preservando assim RSI
cmp byte [r9], 0x00 ; comparar 0x00 com R9, e não mais RSI
jz .done
jmp .calculate_size
.done:
mov rdi, STDOUT
mov rax, SYS_write
syscall ; no momento da syscall, RSI está intacto, contendo
; o ponteiro para o endereço de memória onde está
; localizada a nossa queridíssima string "Hi"
ret
Após estas alterações, vamos executar o programa completo:
./greeting
Hi
Que dia maravilhoso! Nosso programa imprime a string "Hi" calculando dinamicamente o tamanho dos bytes da string!
Entretanto, queremos implementar a proposta inicial, não é, Leandro? O programa não tem que ler o nome da linha de comando e imprimir "Hi, Leandro"?
Botando mais pilha no negócio
Nosso objetivo é chamar ./greeting
com argumento e assim o programa deve imprimir Hi,
com o argumento enviado:
# Objetivo, isto ainda não funciona
./greeting Leandro
Hi, Leandro
Se pensarmos um pouco, podemos inferir que qualquer argumento pode ser armazenado na stack do processo, que é quando o programa está em execução.
Com gdb
, podemos confirmar isto:
# Breakpoint na primeira linha do programa, depois do _start
(gdb) break 12
# Executa o programa com o argumento "Leandro"
(gdb) run Leandro
# Onde estará Leandro? Na pilha? (rsp)
# -> x de examine
# -> /8xb os primeiros 8 hexa bytes
(gdb) x /8xb $rsp
0x7fffffffe450: 0x02 0x00 0x00 0x00 0x00 0x00 0x00 0x00
Mas o quê significa esse número 2? Vamos examinar a stack e a ordem das informações contidas nela.
Voltando ao gdb
, e se lermos os próximos 8 bytes na stack?
(gdb) x /8xb $rsp + 8
0x7fffffffe458: 0xb1 0xe6 0xff 0xff 0xff 0x7f 0x00 0x00
Lembrando que os bytes são escritos na stack em formato little-endian, ou seja estão invertidos
Com isto, temos um hexadecimal 0x7fffffffe6b1
. Parece um endereço de memória, não?
# Examinando o endereço de memória no formato de string (/s)
(gdb) x /s 0x7fffffffe6b1
0x7fffffffe6b1: "/Users/..../code/asm-x64/live"
Wow, temos o primeiro argumento, também chamado de ARG0 que é o nome do programa com o caminho absoluto no sistema operacional.
Andando mais 8 bytes...
# Endereço de memória...
(gdb) x /8xb $rsp + 16
0x7fffffffe460: 0xdf 0xe6 0xff 0xff 0xff 0x7f 0x00 0x00
# Examinando o valor que está no endereço
(gdb) x /s 0x7fffffffe6df
0x7fffffffe6df: "Leandro"
Yay! Temos o nosso argumento, armazenado na stack. É o primeiro argumento, também chamado de ARG1.
Se continuarmos andando na stack de 8 em 8 bytes, vamos passar por todos os argumentos (no nosso caso não há mais), e a seguir vamos chegar no vetor ambiente, que contém todas as variáveis de ambiente contidas no shell que está executando o nosso programa
Com isto, sabemos que o primeiro argumento está localizado em rsp + 16
:
-
rsp
: quantidade de argumentos -
rsp + 8
: ARG0, nome do programa -
rsp + 16
: ARG1, primeiro argumento (se existir) -
rsp + 24
: ARG2, segundo argumento (se existir) - e assim sucessivamente...até chegar no vetor de variáveis de ambiente (vetor ambiente)
Sabendo que nossa sub-rotina .print
já recebe uma string da stack e calcula dinamicamente o tamanho da string que foi passada, podemos passar pro topo da stack o nosso argumento (que por acaso também está na stack), e em seguida chamada a rotina .print
novamente:
section .data
greet: db "Hi, ", 0
_start:
push greet
call .print
pop rbp
; aqui fazemos push pro topo da stack o valor que está em RSP + 16 (ARG1)
; utilizamos o tipo "qword" que significa "quadword"
push qword [rsp + 16]
call .print
pop rbp
...
O quê significa quadword? Em assembly podemos definir tipos de bytes, que basicamente são grupos de bytes, podendo ou não caber em um registrador ou stack dependendo da arquitetura da CPU.
- byte: especifica 1 byte (8-bit)
- word: 2 bytes (16-bit)
- dword: 4 bytes (32-bit)
- qword: 8 bytes (64-bit)
- tbyte: 10 bytes
Na arquitetura x86_64, precisamos especificar que o tipo de byte adicionado na stack, quando não vier de um registrador mas sim de um lugar arbitrário na memória ou stack, tem um determinado tamanho em bytes.
Neste caso, estamos utilizando qword que é justamente 8 bytes (ou 64-bit) que representa a arquitetura em questão.
Precisamos adicionar mais um caracter, que é o newline, ou \n
, no fim da mensagem. Para isto, podemos definir um dado inicializado e chamar a rotina .print
, que já está bem "crescidinha", não?
Programa completo, com comentários:
global _start
%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1
section .data
greet: db "Hi, ", 0
newline: db 0xA, 0
section .text
_start:
push greet ; adiciona "Hi, " na stack para print
call .print
pop rbp
push qword [rsp + 16] ; adiciona ARG1 na stack para print
call .print
pop rbp
push newline ; adiciona newline na stack para print
call .print
pop rbp
.exit: ; label de término do programa
mov rdi, 0
mov rax, SYS_exit
syscall
.print: ; rotina de print no STDOUT
mov rsi, [rsp + 8]
mov r9, rsi
mov rdx, 0
.calculate_size: ; loop para calcular tamanho da string
inc rdx
inc r9
cmp byte [r9], 0x00
jz .done
jmp .calculate_size
.done: ; label para finalizar a rotina print e retornar
mov rdi, STDOUT
mov rax, SYS_write
syscall
ret
Rodamos o programa e:
./greeting Leandro
Hi, Leandro
OMG! Eu não estou acreditando no que estou vendo!!!!!11
Depurando o programa final com strace
Com strace, podemos fazer o trace de syscalls do programa final. Olha que maravilha isto:
$ strace ./greeting Leandro
execve("./greeting", ["./greeting", "Leandro"], 0x7ffc30f75368 /* 24 vars */) = 0
write(1, "Hi, ", 4Hi, ) = 4
write(1, "Leandro", 7Leandro) = 7
write(1, "\n", 1
) = 1
exit(0) = ?
+++ exited with 0 +++
Foram feitas 4 chamadas de sistema, sendo:
- 1 write, "Hi, "
- 1 write "Leandro"
- 1 write "\n"
- 1 exit
Falando um pouco de registradores
Até o momento, vimos durante este artigo a utilização de alguns registradores que foram muito úteis para o desenvolvimento do programa, dentre eles RSI, RAX, RDX, RSP, RIP, RFLAGS e assim por diante.
Mas qual o propósito de cada registrador? Posso usar qualquer registrador para qualquer operação, de forma aleatória?
De forma prática, sim. Mas nem sempre convém.
Nada impede que o teu programa coloque qualquer valor em um registrador arbitrário. Por exemplo, com gdb
vamos alterar alguns registradores e ver como o programa se comporta:
# Breakpoint & run
(gdb) break 13
(gdb) run Leandro
# Vamos alterar alguns registradores arbitrários
(gdb) set $rax = 42
(gdb) set $rdx = 33
# Confirmando que foram modificados
(gdb) i r rax rdx
rax 0x2a 42
rdx 0x21 33
# Continuando...
(gdb) continue
Continuing.
Hi, Leandro
[Inferior 1 (process 19231) exited normally]
Okay, podemos ver que ter mudado estes registradores para qualquer valor não impactou o programa. No meio do programa, provavelmente eles são sobrescritos novamente e utilizados de acordo com determinada lógica.
Mas e se alterarmos, por exemplo, um registrador como o rip
, que é o ponteiro da próxima instrução?
# Breakpoint & run
(gdb) break 13
(gdb) run Leandro
# Antes de alterar o RIP, podemos ver qual o valor ele carrega,
# que é o ponteiro da próxima instrução
(gdb) i r rip
rip 0x401000 0x401000 <_start>
# Vamos alterar o registrador RIP
(gdb) set $rip = 42
# Confirmando que foi alterando
(gdb) i r rip
rip 0x2a 0x2a
# Continuando...
(gdb) continue
Continuing.
Program received signal SIGSEGV, Segmentation fault.
0x000000000000002a in ?? ()
Ouch! Agora o programa não pôde ser finalizado com sucesso. Confirmamos então que nem sempre convém mudar os registradores sem haver algum critério.
Propósito dos registradores
Os registradores, e falando especificamente da arquitetura x86, seguem um propósito original para o qual foram designados. Mas também podem ser utilizados em convenções de chamadas de sistema tal como vimos na montagem das syscalls write
e exit
, e neste caso a utilização correta importa bastante.
E além disso, alguns registradores contém dados importantes para a execução do programa, tais como o rip
e eflags
.
- Propósito original
- Convenções de chamadas
- Funcionamento crítico do programa
Apesar destas características importantes de uso dos registradores, podem haver situações em que utilizar um registrador de propósito geral é o que faz mais sentido para o programa. Vamos a seguir destacar alguns registradores e seus propósitos originais.
Registradores de propósito geral
Podemos categorizar os registradores de uso geral em 2 partes: manipulação de dados diretos ou endereços de memória.
Dados
Registradores podem manipular dados, que chamamos de valor imediato, e nesta categoria podemos utilizar RAX, RBX, RCX, RDX e os registradores de rascunho que vão de R8 a R15.
- RAX: operações aritméticas e armazenamento de resultados; também usado para o nome de chamadas de sistema em convenções de chamada (syscalls)
- RBX: ponteiro de base, utilizado para o endereço de algumas informações na memória
- RCX: geralmente usado como contador, para armazenar a quantidade de vezes que uma instrução deve ser executada
- RDX: usado para algumas operações de multiplicação e divisão, muito utilizado para armazenar o resto de operações
- R8 a R15: registradores de rascunho utilizados para propósito geral
Endereços de memória
Registradores também permitem manipular endereços de memória. Nesta categoria temos RSI, RDI, RBP e RSP.
- RSI: utilizado como um ponteiro de origem em operações de transferências de dados, frequentemente usado em loops para iterar sobre arrays ou buffers de dados
- RDI: utilizado como ponteiro de destino em operações de transferências de dados, frequentemente usado junto com RSI
- RBP: frequentemente usado como ponteiro base em operações de memória, para referenciar variáveis locais e parâmetros de função na stack
- RSP: ponteiro para o topo da pilha (stack) do programa em execução
Registradores especiais
Vamos destacar apenas 2 dos registradores considerados "especiais":
- RFLAGS: utilizado para armazenar o estado da CPU, frequentemente modificado por instruções aritméticas e controle de paridade binária
-
RIP: ponteiro de instrução, que sempre contém o endereço da próxima instrução a ser executada. Por exemplo, a instrução
ret
busca o endereço do topo da pilha e modifica orip
para que o programa continue a partir daquele ponto
Precisamos sempre utilizar todos os 64 bits?
Sabemos que registradores nesta arquitetura ocupam 64 bits de memória. Mas e quando o dado que estamos manipulando não precisa dos 64 bits? Conseguimos otimizar o uso de memória?
A ideia seria algo do tipo "por favor me dê uma fatia dos 64 bits, não preciso de tudo"
Historicamente, como vimos na parte II desta saga, as CPU's x86 não começaram com 64 bits. Evoluíram de 8 bits, para 16, então 32 até chegar em 64 bits.
Para manter compatibilidade, os registradores "legados" podem ser utilizados na arquitetura x64, e assim quando não houver necessidade de utilizar todos os bits do registrador, podemos utilizar uma fatia menor.
Por exemplo, o registrador RAX de 64-bits tem o seu equivalente de 32-bits que é o EAX, que ocupa os 32 bits mais baixos.
O registrador EAX, por sua vez, tem o equivalente AX de 8-bits. Dentro deste AX, podemos utilizar ainda a parte maior que se chama AH ou a parte menor que se chama AL.
O "H" em AH vem de "high", e consequentemente "L" de AL significa "low". Óbvio, não? :P
Sendo assim, há situações em que ao invés de:
mov rax, 7 ; 1 byte mas ocupa 8 bytes (64 bits)
E sabendo que 42 não ocupa 64 bits, podemos mudar para:
mov eax, 7 ; 1 byte mas ocupa 4 bytes (16 bits)
Ou então:
mov ax, 7 ; 1 byte ocupando exatamente 1 byte (8 bits)
Assim o programa final passa a ocupar menos memória em sua totalidade.
Seguindo esta lógica, podemos aplicar para todos os registradores, trazendo alguns como exemplo:
-
RAX:
EAX -> AX -> AH -> AL
-
RBX:
EBX -> BX -> BH -> BL
-
RDX:
RDX -> DX -> DH -> DL
-
R8:
R8W -> R8B
E assim por diante.
Uma side note sobre stack frames
Depois de publicar o artigo, o Rodrigo Gonçalves de Branco decidiu dar um feedback ultra detalhado executando todos os exemplos aqui demonstrados, e um dos insights foi sobre a utilização de stack frames.
Foi um trabalho fenomenal, meus agradecimentos ao Rodrigo
Voltando ao exemplo dos argumentos na pilha, dentro da rotina _start
, temos a pilha do programa com o seguinte layout:
Quando fazemos a chamada:
...
push greet ; adiciona "Hi, " na stack para print
call .print
...
Estamos basicamente manipulando a pilha original do programa. O push
vai colocar no topo da pilha (RSP) o endereço de greet
, como demonstrado a seguir no GDB:
# Breakpoint no <push greet>
(gdb) break 13
(gdb) run
(gdb) next
(gdb) x $rsp
0x7fffffffe448: 0x00402000
Agora a pilha ficou assim:
Se fizermos step
no GDB, podemos ver que o RSP foi modificado novamente, desta vez adicionando o endereço da próxima instrução por conta da chamada call
:
(gdb) step
(gdb) x $rsp
0x7fffffffe440: 0x0040100a
Isso é o que acontece com a pilha em uma simples chamada de rotina com argumentos!
Bom, sabendo disso, vemos que o argumento que precisamos está em rsp + 8
, exatamente como no nosso programa original. So far, so good.
O problema é que podemos reparar que o RSP é modificado durante as chamadas de funções no programa. Não temos controle sobre isso.
E podem acontecer comportamentos inesperados (bugs?) quando isso ocorre, pelo simples fato de estarmos apontando dados na pilha e eles já estarem em posições que não esperávamos.
Para mitigar este potencial problema, podemos preservar a base da pilha em algum registrador sempre no início de cada função, desta forma cada rotina/função pode ter sua própria "versão" da pilha sem correr riscos de apontar para o dado errado.
Esta técnica é chamada de stack frame.
E é pra isso que usamos o registrador RBP! No prólogo de cada rotina, adicionamos o rbp
na pilha e em seguida colocamos o ponteiro de rsp
dentro do registrador rbp
, igualando assim ambos registradores:
_start:
push rbp
mov rbp, rsp
....
Repare que esta técnica consiste em igualar RSP com RBP, assim pode-se de forma segura manipular o ponteiro em RBP, pois mesmo RSP sendo modificado pelo programa, RBP continua intacto.
Continuando com o programa:
push rbp
mov rbp, rsp
....
push greet
call .print
....
Constatamos no GDB que a stack foi alterada, portanto RSP foi modificado para apontar para o endereço da próxima instrução, ao passo que RBP continua apontando pro valor anterior:
# RBP
(gdb) x $rbp
0x7fffffffe448: 0x00000000
# RSP aponta para o endereço da próxima instrução
# antes da chamada da rotina
(gdb) x $rsp
0x7fffffffe438: 0x0040100e
# RSP + 8 aponta para o primeiro argumento da rotina
(gdb) x $rsp + 8
0x7fffffffe440: 0x00402000
# RSP + 16 aponta para o mesmo valor de RBP (base da pilha),
# ou seja, `RBP = RSP + 16` neste caso porque houve um PUSH
# explícito do argumento e também outro push feito pelo CALL
(gdb) x $rsp + 16
0x7fffffffe448: 0x00000000
E modificando a rotina .print
para também ter seu próprio stack frame, como fica a pilha depois de executar:
.print:
push rbp
mov rbp, rsp
....
Analisando com GDB:
(gdb) x $rbp
0x7fffffffe430: 0xffffe448
(gdb) x $rsp
0x7fffffffe430: 0xffffe448
RSP e RBP ficaram igualados novamente, dando uma característica de stack frame, preservando a pilha como podemos ver na imagem a seguir:
Portanto, o argumento da rotina, ao invés de ser rsp + 8
, passa a ser rbp + 16
por conta da stack frame, ficando da seguinte forma:
.print:
push rbp
mov rbp, rsp
mov rsi, [rbp + 16]
mov r9, rsi
mov rdx, 0
Uma coisa importante: ao final de cada rotina, antes do retorno, devemos fazer pop
do topo da pilha para voltar ao estado original antes do push rbp
feito no início da rotina:
push rbp
mov rbp, rsp
....
pop rbp
ret
Desta forma, ao fazer o pop rbp
, o que está em RSP é justamente o endereço de retorno antes da chamada da função:
Ao continuar com o programa, a instrução ret
(já falamos sobre ela anteriormente) faz pop do topo da pilha (RSP) e continua a execução do programa na próxima instrução:
_start:
push rbp
mov rbp, rsp ; <--- iguala RSP e RBP
push greet ; <--- adiciona <greet> na pilha
call .print ; <--- adiciona ponteiro da próxima
; instrução na pilha
pop rax ; <--- faz pop de <greet> da pilha
............
.print:
push rbp
mov rbp, rsp ; <--- iguala RSP e RBP
mov rsi, [rbp + 16]
.............
syscall
pop rbp ; <--- remove frame RBP da pilha
ret ; <--- faz pop do pointeiro da
; próxima instrução e atualiza RIP
Quando o fluxo volta para quem chamou a rotina, a próxima instrução deve ser sempre o pop
dos argumentos que entraram na pilha.
Neste caso no exemplo anterior estamos fazendo pop do argumento e descartando o valor em RAX com pop rax
, deixando assim a pilha em seu estado anterior à chamada da rotina:
Ao fim do programa (rotina _start
), devemos também fazer pop rbp
, assim a pilha volta ao estado original de quando foi iniciado o programa.
Código completo:
global _start
%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1
section .data
greet: db "Hi, ", 0
newline: db 0xA, 0
section .text
_start:
push rbp ; <-- cria um stack frame
mov rbp, rsp ; para preservar a pilha
push greet ; adiciona "Hi, " na pilha
call .print ; chama sub-rotina
pop rax ; remove "Hi, " da pilha
push qword [rbp + 24] ; adiciona argumento na pilha
call .print ; chama sub-rotina
pop rax ; remove argumento da pilhha
push newline ; adiciona newline na pilha
call .print ; chama-subrotina
pop rax ; remove newline da pilha
pop rbp ; remove RBP da pilha,
; retornando ao estado original
.exit:
mov rdi, 0
mov rax, SYS_exit
syscall ; termina o programa
.print:
push rbp ; <-- cria um stack frame
mov rbp, rsp ; para preservar a pilha
mov rsi, [rbp + 16]
mov r9, rsi
mov rdx, 0
.calculate_size: ; loop para calcular tamanho
inc rdx
inc r9
cmp byte [r9], 0x00
jz .done
jmp .calculate_size
.done:
mov rdi, STDOUT
mov rax, SYS_write
syscall
pop rbp ; <--- remove RBP da pilha,
; retornando ao estado anterior
ret ; <--- retorna fluxo para o
; estado anterior
É isto. Esta seção foi apenas uma demonstração de como utilizar boas práticas de manipulação da pilha quando utilizamos argumentos em funções, através da técnica de criar um frame como base da pilha com o registrador RBP.
Conclusão
É isto, pessoal. Esta parte da saga foi bastante densa. Passamos pela criação de um programa simples em Assembly, ao passo em que íamos depurando o programa com ferramentas como strace, size e muito gdb.
Também aprendemos sobre labels, tipos de registradores, desvio de fluxo com jmp, call, ret, muita stack, depurando tudo e mais um pouco, loops, FLAGS e aritmética de ponteiro.
Apesar de ter sido muito denso, os tópicos aqui abordados servirão de base para entendermos o próximo artigo que já começa pesado com syscalls de rede, para iniciarmos o nosso tão esperado web server.
Nos vemos no próximo artigo!
Referências
Mnemonics
https://en.wikipedia.org/wiki/Mnemonic
Comparison of Assemblers
https://en.wikipedia.org/wiki/Comparison_of_assemblers
Linker (computing)
https://en.wikipedia.org/wiki/Linker_(computing)
Assembly x86 tutorial
https://www.tutorialspoint.com/assembly_programming/index.htm
Data segment
https://en.wikipedia.org/wiki/Data_segment
FLAGS register
https://en.wikipedia.org/wiki/FLAGS_register
Debugging with GDB
https://ncona.com/2019/12/debugging-assembly-with-gdb/
GDB command reference
https://visualgdb.com/gdbreference/commands/
GDB cheatsheet
https://cs.brown.edu/courses/cs033/docs/guides/gdb.pdf
[Vídeo] Introdução ao GNU Debugger - Blau Araújo
https://www.youtube.com/watch?v=t9OKpBKbJ4Q
Posted on May 13, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
May 13, 2024