#+title: 6 – Vetor de argumentos de linha de comando #+author: Blau Araujo #+email: cursos@blauaraujo.com #+options: toc:3 * Objetivos - Compreender a passagem de argumentos de linha de comando para processos. - Descobrir como o kernel Linux estrutura o vetor de argumentos. - Acessar a quantidade e os elementos do vetor de argumentos. - Criar programas em Assembly que trabalhem com argumentos de linha de comando. - Implementar parsing manual de argumentos no Assembly. * O quadro inicial da pilha do processo Ao iniciar a pilha de um processo, o kernel inclui diversos conjuntos de dados relativos ao ambiente de execução do programa e outras informações auxiliares para uso do /runtime/ da linguagem C (=glibc=) e do carregados dinâmico do sistema (=ld-linux=). Na arquitetura Linux x86_64, dos endereços mais altos para os mais baixos, nós temos: - Strings dos argumentos de linha de comando; - Strings das definições de variáveis exportadas para o ambiente do processo; - Semente aleatória (=AT_RANDOM=); - Nome do executável (=AT_EXECFN=); - Vetor auxiliar (=auxv=); - Vetor dos endereços dos dados de ambiente (=envp=); - Vetor dos endereços dos argumentos (=argv=); - Contador de argumentos (=argc=). O diagrama abaixo é uma representação simplificada do estado inicial da pilha, segundo especificado pela /System V Application Binary Interface/ (ABI) para a arquitetura AMD64: #+begin_example ┌────────────────────────────────┐ │ NÃO ESPECIFICADO │ ENDEREÇOS MAIS ALTOS ├────────────────────────────────┤ ─┐ │ STRINGS DE ARGUMENTOS │ │ ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤ │ │ STRINGS DE AMBIENTE │ ├─ BLOCO DE INFORMAÇÕES ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤ │ (SEM ORDEM ESPECÍFICA) │ DADOS BINÁRIOS AUXILIARES │ │ ├────────────────────────────────┤ ─┘ │ NÃO ESPECIFICADO │ ├────────────────────────────────┤ │ ENTRADA NULA DO VETOR AUXILIAR │ QWORD (0x00) ├────────────────────────────────┤ │ ENTRADAS DO VETOR AUXILIAR │ 2 QWORDS POR ENTRADA ├────────────────────────────────┤ │ SEPARADOR NULO │ QWORD (0X00) ├────────────────────────────────┤ │ VETOR AMBIENTE (ENVP) │ 1 QWORD POR ENDEREÇO ├────────────────────────────────┤ RSP + (8 * ARGC) + 8 │ SEPARADOR NULO │ QWORD (0X00) ├────────────────────────────────┤ RSP + 8 (ARGV[0]) │ VETOR DE ARGUMENTOS (ARGV) │ 1 QWORD POR ENDEREÇO ├────────────────────────────────┤ RSP │ ENDEREÇO DO VALOR DE ARGC │ 1 QWORD ├────────────────────────────────┤ │ NÃO DEFINIDO │ ENDEREÇO MAIS BAIXO └────────────────────────────────┘ QWORD = 8 BYTES (64 BITS) #+end_example Embora a ABI diga que o conteúdo do bloco de informações não precisa seguir uma ordem específica, o Linux padronizou uma ordem em sua implementação da carga de programas (em [[https://github.com/torvalds/linux/blob/94305e83eccb3120c921cd3a015cd74731140bac/fs/binfmt_elf.c#L824][kernel.org]]), dos endereços mais altos para os mais baixos: - Outros eventuais dados auxiliares - Strings das definições das variáveis exportadas - Strings de argumentos - Semente aleatória (=AT_RANDOM=, ponteiro para 16 bytes aleatórios) - Endereço da string da arquitetura (=AT_PLATAFORM=) - Endereço da string do caminho do executável (=AT_EXECFN=) No contexto deste e do próximo tópico dos nossos estudos, interessam apenas os dados do quadro inicial da pilha que, de algum modo, propiciam alguma forma de interação do usuário com os nossos programas: os argumentos passados pela linha de comandos e as variáveis exportadas para o processo do programa. * Inspecionando argumentos e variáveis exportadas Para investigar o estado do quadro inicial da pilha, nós vamos utilizar o programa [[exemplos/06/exit42.asm][exit42.asm]]: #+begin_src asm ; Retorna 42 como estado de término section .text global _start _start: mov rax, 60 ; syscall: exit mov rdi, 42 ; código de saída syscall #+end_src *Montagem e link-edição:* #+begin_example :~$ nasm -f elf64 -g exit42.asm :~$ ld -o exit42 exit42.o #+end_example Agora, vamos abrir o executável no GDB, definir =_start= como ponto de parada e iniciar sua execução: #+begin_example :~$ gdb exit42 Reading symbols from exit42... (gdb) break _start Breakpoint 1 at 0x401000: file exit42.asm, line 7. (gdb) run Starting program: /home/blau/git/pbn/curso/exemplos/06/exit42 Breakpoint 1, _start () at exit42.asm:7 7 mov rax, 60 ; syscall: exit (gdb) #+end_example ** Contador de argumentos Primeiro, vamos examinar o conteúdo do registrador =rsp= (/stack pointer/): #+begin_example (gdb) info registers rsp rsp 0x7fffffffdfd0 0x7fffffffdfd0 #+end_example Como resultado, nós temos um endereço que, segundo o diagrama anterior, deve nos levar à quantidade de argumentos de linha de comando escritos para invocar o programa (=argc=). Como o programa não foi iniciado com argumentos, o valor de =argc= deve ser =1= (escrito em 8 bytes), correspondendo ao caminho do nosso executável. Sendo assim, vamos utilizar o comando =x= para exibir uma /giant-word/ em hexa (=/1gx=) a partir do endereço em =rsp= (=$rsp=): #+begin_example (gdb) x /1gx $rsp 0x7fffffffdfd0: 0x0000000000000001 #+end_example E lá está o valor =1= escrito em 8 bytes. #+begin_quote *Nota:* É por conta dessa ambiguidade do termo /argumentos/, utilizado tanto no sentido de /"argumentos passados para um programa"/ quanto de /"todas as palavras utilizadas para invocar o programa"/, que o vetor de argumentos também é chamado de /vetor de parâmetros/, bem mais adequado para expressar o que nós encontramos no quadro inicial da pilha (todas as palavras utilizadas para invocar o programa). Mas, feita a observação, vamos nos manter na terminologia da documentação da ABI e do kernel. #+end_quote ** Primeiro elemento do vetor de argumentos Lembrando que o registrador =rsp= sempre contém o dado que está no topo da pilha (ou seja, em seu endereço mais baixo) e sabendo que o dado anterior está 8 bytes acima, nós podemos obter o endereço do primeiro elemento do vetor de argumentos (=argv=) desta forma: #+begin_example (gdb) x /1gx $rsp+8 0x7fffffffdfd8: 0x00007fffffffe2ec #+end_example Se o endereço estiver correto, ele nos levará à string do caminho completo do nosso executável. Para confirmar, vamos examinar com o formato =/1s=, de /"uma string"/: #+begin_example (gdb) x /1s 0x00007fffffffe2ec 0x7fffffffe2ec: "/home/blau/git/pbn/curso/exemplos/06/exit42" #+end_example Confirmado! ** Separador nulo do vetor de argumentos Ainda segundo o diagrama do quadro inicial da pilha, tendo apenas um argumento, a posição seguinte na pilha (=rsp+16=) deve conter um endereço que nos leva a uma cadeia de 8 bytes zerados (/separador nulo/). Então, vejamos o que mostra o GDB: #+begin_example (gdb) x /1gx $rsp+16 0x7fffffffdfe0: 0x0000000000000000 #+end_example Não é o que estamos vendo... Na verdade, o que esperávamos resulta de uma interpretação equivocada bastante comum das especificações da ABI. Os dados no bloco de informações são compactados, ou seja, escritos sem separação de forma contígua. Sendo assim, a delimitação dos vetores (os /separadores nulos/) é feita pelos valores dos elementos mais acima na pilha (nos endereços mais baixos), como vimos no GDB. A posição do separador nulo de =argv= pode ser calculado com: #+begin_example rsp + (argc * 8) + 8 #+end_example Como o valor em =argc= é =1=: #+begin_example rsp + (1 * 8) + 8 = rsp + 8 + 8 = rsp + 16 #+end_example Ou seja, nós teríamos que encontrar o valor =0= escrito com 8 bytes na posição da pilha resultante de =rsp + 16= -- e encontramos. ** Primeiro elemento do vetor ambiente A posição imediatamente abaixo da pilha (=rsp + 24=) deve conter o endereço da primeira string com a definição de uma variável exportada para o processo do programa -- ou seja, a string em =envp[0]=. Então, vamos examinar novamente: #+begin_example (gdb) x /1gx $rsp+24 0x7fffffffdfe8: 0x00007fffffffe318 (gdb) x /1s 0x00007fffffffe318 0x7fffffffe318: "SHELL=/bin/bash" #+end_example O quadro inicial da pilha não tem uma informação sobre a quantidade de variáveis exportadas para o ambiente do processo, então é difícil saber onde ela termina sem continuar a percorrer a pilha até encontrar o separador nulo de =envp=. No meu sistema, eu testei manualmente e descobri que o endereço do último elemento de =envp= está em =rsp + 368=: #+begin_example (gdb) x /1gx $rsp+368 0x7fffffffe140: 0x00007fffffffefa3 (gdb) x /1s 0x00007fffffffefa3 0x7fffffffefa3: "OLDPWD=/home/blau/git/pbn/curso/exemplos" #+end_example Portanto, em =rsp + 376= eu devo encontrar o separador nulo: #+begin_example (gdb) x /1gx $rsp+376 0x7fffffffe148: 0x0000000000000000 #+end_example E lá está ele! ** Resultados com mais de um argumento Também é interessante investigar o que acontece com o quadro inicial da pilha quando nós executamos o programa com outros argumentos de linha de comando além, é claro, de seu próprio caminho. Para isso, vamos iniciar novamente o GDB, mas vamos rodar o programa com argumentos: #+begin_example $ gdb exit42 Reading symbols from exit42... (gdb) b _start Breakpoint 1 at 0x401000: file exit42.asm, line 7. (gdb) run banana laranja Starting program: /home/blau/git/pbn/curso/exemplos/06/exit42 banana laranja Breakpoint 1, _start () at exit42.asm:7 7 mov rax, 60 ; syscall: exit #+end_example *Conferindo =argc=:* #+begin_example (gdb) info registers rsp rsp 0x7fffffffdfb0 0x7fffffffdfb0 (gdb) x /1gx 0x7fffffffdfb0 0x7fffffffdfb0: 0x0000000000000003 #+end_example *Valor em =argv[0]=:* #+begin_example (gdb) x /1gx $rsp + 8 0x7fffffffdfb8: 0x00007fffffffe2dd (gdb) x /1s 0x00007fffffffe2dd 0x7fffffffe2dd: "/home/blau/git/pbn/curso/exemplos/06/exit42" #+end_example *Valor em =argv[1]=:* #+begin_example (gdb) x /1gx $rsp + 16 0x7fffffffdfc0: 0x00007fffffffe309 (gdb) x /1s 0x00007fffffffe309 0x7fffffffe309: "banana" #+end_example *Valor em =argv[2]=:* #+begin_example (gdb) x /1gx $rsp + 24 0x7fffffffdfc8: 0x00007fffffffe310 (gdb) x /1s 0x00007fffffffe310 0x7fffffffe310: "laranja" #+end_example *Separador nulo de =argv=:* #+begin_example (gdb) x /1gx $rsp + 32 0x7fffffffdfd0: 0x0000000000000000 #+end_example * Acesso aos argumentos em baixo nível Em Assembly, as duas técnicas mais comuns para acessar os argumentos de linha de comando são a leitura da pilha após o ponto de entrada (=_start=) e, no caso de programas híbridos que seguem as convenções da linguagem C, exportando uma função =main=, deixando que a =glibc= cuide da pilha e das convenções de chamada. ** Acesso direto à pilha Basta salvar o endereço em =rsp= e utilizá-lo para obter =argc= e os demais elementos em =argv=: #+begin_src asm section .bss argc: resq 1 ; reservar 8 bytes para receber o valor de argc. argv: resq 1 ; reservar 8 bytes para receber o endereço de argv[0]. section .text global _start _start: ; ---------------------------------------------------- ; Inicialização de argc e argv... ; ---------------------------------------------------- mov rbx, rsp ; endereço de argc mov rax, [rbx] ; valor de argc lea rcx, [rbx + 8] ; endereço de argv[0] mov [argc], rax mov [argv], rcx ; Agora podemos usar [argc] e [argv] em qualquer parte do código. ; ... #+end_src #+begin_quote *Nota:* a instrução =lea= (/Load Effective Address/) calcula um endereço e carrega o resultado em um registrador de destino. #+end_quote ** Acesso em Assembly exportando main Exportando a rotina principal do programa com o nome =main=, nós podemos gerar o executável com o =gcc= que, utilizando o objeto de inicialização da linguagem C (=crt0.o=), executará todas as tarefas de preparação para o acesso aos dados de argumentos e ambiente, o que inclui: - Carregar =argc= em =rdi=; - Carregar o endereço de =argv[0]= em =rsi=; - Carregar o endereço de =envp[0]= em =rdx=; - Configurar variáveis globais para a =glibc= (se usada); - Chamar =main=, esperando que retorne um =int= em =eax=; - Terminar o programa chamando a função =exit= (=glibc=). O exemplo abaixo imprime o nome do programa (string =argv[0]=) utilizando a função =printf=, da glibc: #+begin_src asm :tangle exemplos/06/prog.asm section .data fmt db "Programa: %s", 10, 0 global main ; símbolo chamado por _start de crt0.o extern printf ; (opcional) usar funções da libc section .text main: ; argc → rdi ; argv → rsi ; envp → rdx (não obrigatório) ; Exemplo: imprimir argv[0] mov rdi, fmt mov rsi, [rsi] ; argv[0] xor rax, rax ; terminação de argumentos (printf é variádica) call printf mov eax, 0 ; retorno de 'main' = (int)0 ret #+end_src Para montar e compilar: #+begin_example :~$ nasm -g -f elf64 prog.asm :~$ gcc -no-pie -o prog prog.o #+end_example Atenção para a opção =-no-pie= no =gcc=: Isso é necessário porque o exemplo não foi escrito nem montado prevendo suporte a endereçamento relativo (PIE). Executando: #+begin_example :~$ ./prog Programa: ./prog #+end_example Mas note que, ao preparar a chamada de =printf=, nós tivemos que seguir as convenções da ABI e passar o primeiro argumento em =rdi= e o segundo em =rsi=: que são justamente os registradores onde as informações de argumentos foram carregadas. Portanto, se quisermos reutilizar esses dados, nós devemos salvá-los em outro lugar, como fizemos antes: #+begin_src asm section .bss argc: resq 1 ; reservar 8 bytes para receber o valor de argc. argv: resq 1 ; reservar 8 bytes para receber o endereço de argv[0]. section .data fmt db "Programa: %s", 10, 0 global main ; símbolo chamado por _start de crt0.o extern printf ; (opcional) usar funções da libc section .text main: ; argc → rdi ; argv → rsi ; envp → rdx (não obrigatório) mov [argc], rdi ; salvar argc mov [argv], rsi ; salvar endereço de argv[0] ; Exemplo: imprimir argv[0] mov rdi, fmt mov rsi, [argv] ; argv[0] xor rax, rax ; terminação de argumentos (printf é variádica) call printf mov eax, 0 ; retorno de 'main' = (int)0 ret #+end_src * Listando todos os dados de argumentos Para encerrar este tópico, vamos criar dois programas que acessam dos dados de argumentos na pilha e lista seus valores no terminal: um utilizando o /runtime/ da linguagem C (=crt0.o=) e outro totalmente em Assembly, mas com um pequeno módulo de sub-rotinas para conversão e impressão que só será estudada nos próximos tópicos. ** Exemplo em Assembly com main e gcc Arquivo: [[exemplos/06/cargs.asm][cargs.asm]] #+begin_src asm :tangle exemplos/06/cargs.asm section .bss argc: resq 1 ; 8 bytes para armazenar argc argv: resq 1 ; 8 bytes para armazenar ponteiro para argv[0] section .data fmt_argc db "argc : %ld", 10, 0 fmt_argv db "argv[%d]: %s", 10, 0 global main extern printf section .text main: ; rdi = argc ; rsi = argv mov [argc], rdi ; salvar argc mov [argv], rsi ; salvar argv[0] ; Imprimir com: printf("argc : %ld", argc); mov rdi, fmt_argc ; primeiro argumento: formato mov rsi, [argc] ; segundo argumento: argc (qword = long) xor rax, rax ; terminação de argumentos call printf ; preparar o laço: i = 0 xor rcx, rcx ; i = 0 mov rbx, [argv] ; rbx = argv base mov r8, [argc] ; r8 = argc .loop: cmp rcx, r8 ; compara i com argc jge .done ; se i >= argc, fim ; Imprimir com: printf("argv[%d]: %s\n", i, argv[i]); mov rdi, fmt_argv ; primeiro argumento: formato mov rsi, rcx ; segundo argumento: i mov rdx, [rbx + rcx*8] ; terceiro argumento: argv[i] xor rax, rax ; terminação de argumentos push rcx ; salvar rcx (caller saved) na pilha push r8 ; salvar r8 (caller saved) na pilha call printf pop r8 ; restaurar rcx pop rcx ; restaurar rcx inc rcx jmp .loop .done: xor eax, eax ; retorno de main: (int)0 ret #+end_src Compilando e executando: #+begin_example :~$ nasm -g -felf64 cargs.asm :~$ gcc -no-pie -o cargs cargs.o :~$ ./cargs a b c argc : 4 argv[0]: ./cargs argv[1]: a argv[2]: b argv[3]: c #+end_example *** Nota sobre registradores /"caller saved"/ #+begin_src asm push rcx ; salvar rcx (caller saved) na pilha push r8 ; salvar r8 (caller saved) na pilha call printf pop r8 ; restaurar rcx pop rcx ; restaurar rcx #+end_src Nesse trecho, nós salvamos na pilha os registradores =rcx= e =r8=, usados como referências do nosso /loop/, antes de chamar a função =printf=. Isso foi necessário porque, segundo a ABI, eles estão entre os registradores /"caller saved"/, ou seja: registradores que podem ser alterados pela chamada de uma função com a instrução =call= e, portanto, terão que ser salvos pela função chamadora, caso estejam em uso para outros propósitos. São registradores /caller-saved/ segundo a ABI (System V AMD64): - =rax=: usado para retorno de funções; - =rcx=: usado como argumento 4 em chamadas de funções; - =rdx=: usado como argumento 3 em chamadas de funções; - =rsi=: usado como argumento 2 em chamadas de funções; - =rdi=: usado como argumento 1 em chamadas de funções; - =r8= e =r9=: usados como argumentos de 5 e 6 em chamadas de funções; - =r10= e =r11=: uso interno em funções. A volatilidade desses registradores não está relacionada com seu uso antes da chamada da função (=call=), mas com o fato da ABI não obrigar a função chamada a salvar e restaurar seus conteúdos. ** Exemplo com acesso direto à pilha - Arquivo: [[exemplos/06/args.asm][args.asm]] - Módulo: [[exemplos/06/print_utils.asm][print_utils.asm]] Neste exemplo, não há necessidade de salvar =argc= e =argv= na memória. #+begin_src asm ; ---------------------------------------------------------- ; Montar módulo com : nasm -f elf64 print_utils.asm ; Montar programa com : nasm -f elf64 args.asm ; Gerar executável com: ls -o args args.o print_utils.o ; ---------------------------------------------------------- section .rodata pref_argc db "argc : ", 0 ; Prefixo da impressão de argc pref_argv db "argv[", 0 ; Prefixo da impressão de argv posf_argv db "]: ", 0 ; Sufixo da impressão de argv ; sub-rotinas do módulo print_utils.o extern print_int extern print_str extern print_char section .text global _start _start: ; ---------------------------------------------------------- ; Impressão de argc ; ---------------------------------------------------------- mov rdi, pref_argc ; endereço de pref_argc call print_str ; imprime pref_argc mov rax, [rsp] ; rax = argc call print_int ; imprime argc mov rdi, 10 ; rdi = \n call print_char ; imprime \n ; ---------------------------------------------------------- ; Impressão de argv ; ---------------------------------------------------------- mov rcx, 0 ; índice i = 0 lea rbx, [rsp + 8] ; rbx = endereço de argv[0] .print_argv_loop: cmp rcx, [rsp] ; enquanto i < argc jge .done_argv mov rdi, pref_argv ; imprime prefixo "argv[" call print_str mov rax, rcx ; rax = i call print_int ; imprime índice mov rdi, posf_argv ; imprime sufixo "]: " call print_str mov rdi, [rbx + rcx*8] ; rdi = argv[i] call print_str mov rdi, 10 ; imprime \n call print_char inc rcx jmp .print_argv_loop .done_argv: ; ---------------------------------------------------------- ; Termina o programa com exit(0) ; ---------------------------------------------------------- mov rax, 60 ; syscall: exit xor rdi, rdi ; status = 0 syscall #+end_src Montagem, link-edição e execução: #+begin_example :~$ nasm -f elf64 print_utils.asm :~$ nasm -f elf64 args.asm :~$ ld -o args args.o print_utils.o :~$ ./args a b c d argc : 5 argv[0]: ./args argv[1]: a argv[2]: b argv[3]: c argv[4]: d #+end_example * Exercícios propostos 1. Crie um programa em Assembly que receba seu nome como argumento e imprima =Salve, NOME!=. 2. Crie uma versão desse mesmo programa em Assembly chamando funções da linguagem C. 3. Utilizando o GDB e o exemplo =cargs.asm=, analise o comportamento dos registradores voláteis nas chamadas de funções da =glibc=. 4. Com base nos exemplos do tópico, crie um programa que liste as variáveis no vetor de ambiente. * Referências - [[https://refspecs.linuxfoundation.org/][System V Application Binary Interface – AMD64 Architecture Processor Supplement]] - [[https://github.com/torvalds/linux/blob/master/fs/binfmt_elf.c][Linux kernel: fs/binfmt_elf.c]] - =man 2 execve= - =man 3 environ= - =man 7 exec=