quinta-feira, 19 de novembro de 2015

Nada de novo no front

Sempre gostei de histórias onde pessoas contam seus casos de trincheira, casos onde soluções complexas e, algumas vezes, totalmente fora da caixa foram essenciais para que um problema fosse solucionado. Não somente casos envolvendo programadores, todos os campos do saber tem dessas histórias e são sempre interessantes! Se me recordo bem, mais ou menos a 9 meses atrás foi o último caso desse tipo que tive, antes de eu ser catapultado de forma abrupta da vida de programador. Infelizmente foi um lance bem kafkaesco mas, lamúrias à parte, vamos lá!

Existem vários níveis de pesadelo quando você está trabalhando com um sistema legado. Talvez o sistema não tenha nenhuma documentação, o que é péssimo, mas se o código for bem escrito você estará bem. Mesmo se for mal escrito,  o código está ai! use o método de dividir em partes pequenas e ir entendendo, e reescrevendo o que for necessário. Pode ser que você não esteja lidando diretamente com código legado, tem vez que o pesadelo vem na forma de um device fabricado na Galileia e com um manual em aramaico, justo quando o especialista em aramaico da equipe está ocupado com outras áreas do sistema.



Mas, mesmo o texto estando em um idioma que você não domina, usualmente o código estará escrito numa linguagem de programação que você conhece. Então você copia e cola o que tem no manual, contando com uma certa ajuda divina e vai ajustando até que o device faça o que tem que fazer de forma aceitável, é um processo terrível e que toma muito tempo, sem contar que o código resultante funciona sem ninguém saber ao certo o porquê, mas funciona. Outro caso acontece quando o um dos desenvolvedores é obrigado a trabalhar no natal e nomeou as variáveis de “porco_com_polenta, papai_noel, renas” (CASO REAL) e o sistema é uma mistura profana de COM+ e loucura (Redundante quando se trata de COM+), mas com ferramentas atuais de edição de código e alguma paciência, só o COM+ será um problema.

Só que algumas vezes o universo conspira contra você, no meu caso o problema veio na forma de uma biblioteca que gerava erros, às vezes eles eram esporádicos e causavam a quebra do sistema, outras vezes eles eram aparentemente inofensivos, mas sempre que o código executava em um debugger, as ferramentas piscavam como um arvore de natal doidona de LSD. E o maior problema dessa biblioteca é que o código fonte foi perdido! Perdido? Sim, perdido! Até onde fui informado, o código estava em um disquete que estragou e tudo o que restou foi uma versão já compilada no repositório.

Para melhorar, a empresa onde eu estava trabalhando tinha regras sobre instalação e utilização de software com licença de avaliação, sem contar com diversos NDA’s dizendo que eles vão sequestrar seu cachorro se qualquer linha de código do sistema vazar para qualquer mídia que seja. Então, mesmo que você tenha pensado anteriormente: “Fácil, baixo o IdaPRO, pego o que ele gera, já organizado, e posto uma dúvida no stackoverflow”. Não, caro desconhecido, você não terá essa opção. Esse sistema vinha sendo portado entre diversas plataformas (OS/2, Cygwin, Diversas distribuições e versões diferentes de kernel do linux) e, nessa dança macabra, o fonte se perdeu. O modulo era muito importante, mas por questões de tempo nunca houve apoio gerencial para parar a equipe e reescrever tudo. O erro que ele causava não era uma preocupação imediata, então os anos foram passando e ficou por isso mesmo.

Um dos erros nesse modulo era gerar um erro de read ao tentar acessar memória um byte além do tamanho do buffer passado como parâmetro, o clássico off-by-one. Por sorte, o modulo utilizava N chars, embora tentasse acessar o N+1, por esse acaso o resultado do calculo que ele realizava retornava correto. Por preguiça, todos os pontos do sistema que utilizavam essa biblioteca sempre alocavam um byte a mais do que o necessário, evitando que o debugger reclamasse. 

Mas, IMHO, independente de o erro ser algo sério e que acontece sempre ou só uma anotação na lista de bugs do debugger que aparentemente nunca causou nenhum problema, você não deve ser barato e se contentar com código mal escrito. Tem erro? conserte! Mas, como? Bem, vamos analisar o mesmo problema usando o seguinte programa exemplo (Neste caso o código falha praticamente sempre, pois quero um erro que ninguém diria: “ah, mas o resultado volta correto, deixa como está!”):

#include <stdio.h>
#include <stdlib.h>
#include "broken_lib.h"

int main()
{
  int value;
  int * data;

  data = (int *) malloc(sizeof(int) * 10);
  data[0] = 1;
  data[1] = 2;
  data[2] = 3;
  data[3] = 4;
  data[4] = 5;
  data[5] = 6;
  data[6] = 7;
  data[7] = 8;
  data[8] = 9;
  data[9] = 0;

  value = compute_some_stuff(data, 10);

  printf("[%d]\n",value);

  free(data);
  return 0;
}

#############################################################################

O header broken_lib.h:

$ cat broken_lib.h
#pragma once

 /* essa função deve somar todos os valores do buffer ‘input_data’ que tem tamanho ‘data_len’ e retornar o resultado, se algum dos valores do buffer for impar, antes de somar ela zera o bit 0, tornando este valor pár */
int compute_some_stuff(int * input_data, int data_len);

#############################################################################

O fonte correspondente ‘broken_lib.c’, como já foi informado, está perdido no limbo. tudo o que temos é uma lib estática:

$ ls *.a
libbroken.a

Quando executado, essa é a saída do programa:

$ ./main_prog 
[135160]

Ops… Levando em conta a documentação da função, deveriamos esperar o valor 40:

BUFFER ORIGINAL EM DECIMAL: 1+2+3+4+5+6+7+8+9+0
EM BINÁRIO: 0001 + 0010 + 0011 + 0100 + 0101 + 0110 + 0111 + 1000 + 1001 + 0000
REMOVENDO O BIT 0: 0000 + 0010 + 0010 + 0100 + 0100 + 0110 + 0110 + 1000 + 1000 + 0000
BUFFER FINAL EM DECIMAL: 0+2+2+4+4+6+6+8+8+0 = 40

Algo está muito errado! Vamos rodar no valgrind para ver se ele nos fornece alguma luz sobre o problema:

$ valgrind ./main_prog
==5035== Memcheck, a memory error detector
==5035== Copyright (C) 2002-2013, and GNU GPL'd, by Julian Seward et al.
==5035== Using Valgrind-3.10.0.SVN and LibVEX; rerun with -h for copyright info
==5035== Command: ./main_prog
==5035== 
==5035== Invalid read of size 4
==5035==    at 0x4006A6: compute_some_stuff (in /home/killocan/tuthack1/main_prog)
==5035==    by 0x400650: main (in /home/killocan/tuthack1/main_prog)
==5035==  Address 0x51fc068 is 0 bytes after a block of size 40 alloc'd
==5035==    at 0x4C2AB80: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==5035==    by 0x4005B3: main (in /home/killocan/tuthack1/main_prog)
==5035== 
[40]
==5035== 
==5035== HEAP SUMMARY:
==5035==     in use at exit: 0 bytes in 0 blocks
==5035==   total heap usage: 1 allocs, 1 frees, 40 bytes allocated
==5035== 
==5035== All heap blocks were freed -- no leaks are possible
==5035== 
==5035== For counts of detected and suppressed errors, rerun with: -v
==5035== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

Vale notar que a saída (em azul) é correta quando o valgrind está rodando, isso se deve à forma como ele gerencia a memória do programa sendo instrumentado. Em vermelho notamos que o valgrind indica que houve um read inválido de 4 bytes. Ele informa também que esse acesso foi em uma região de memória depois de um bloco de 40 bytes alocado pela malloc (na minha máquina um int tem tamanho 4 bytes por isso o bloco alocado tem tamanho 40). Ele nos diz, também, que essa chamada à malloc foi realizada na função main. Com essa informação é seguro assumir que a função tentou acessar um indice N+1 no buffer passado pela main, o buffer apontado por ‘int * data’.



Ainda em vermelho, o valgrind nos diz o offset dentro do processo onde ocorreu esse read inválido. O código ofensivo está dentro da imagem do processo pois, no nosso caso fictício, a lib foi compilada estática no programa (i.e: todo código é copiado direto para o executável resultante).

Temos que ver o código da função ‘compute_some_stuff’ de alguma forma, no caso a ferramenta objdump era a minha única alternativa. O objdump, como diz o nome, exibe diversas informações sobre um ou mais arquivos objeto. Uma dessas funções é exibir o código assembly do código contido no arquivo. Nota para os que não sabem nada de assembly: Você deve aprender! É indispensável saber ler o asm da plataforma onde você desenvolve, é sua obrigação =D Isso vale para a turma das linguagens baseadas em VM, se você nunca olhou o bytecode gerado você está falhando como programador!



Saber assembly permite entender melhor como seu programa realmente funciona na máquina, além de permitir resolver problemas que seriam impossíveis caso você não saiba. Primeiro vamos ver quais funções existem na lib e quais estão sendo exportadas.

$ objdump -t libbroken.a 
In archive libbroken.a:

broken_lib.o:     file format elf64-x86-64

SYMBOL TABLE:
0000000000000000 l    df *ABS* 0000000000000000 broken_lib.c
0000000000000000 l    d  .text 0000000000000000 .text
0000000000000000 l    d  .data 0000000000000000 .data
0000000000000000 l    d  .bss 0000000000000000 .bss
0000000000000000 l    d  .note.GNU-stack 0000000000000000 .note.GNU-stack
0000000000000000 l    d  .eh_frame 0000000000000000 .eh_frame
0000000000000000 l    d  .comment 0000000000000000 .comment
0000000000000000 g    F .text 0000000000000041 compute_some_stuff

Ótimo, a unica função (F) dentro da lib é a ‘compute_some_stuff’, e ela é global (g) isso vai tornar a saída em assembly mais fácil de ler.

$ objdump -d libbroken.a
In archive libbroken.a:

broken_lib.o:     file format elf64-x86-64
Disassembly of section .text:

 0000000000000000 :
   0: 55                    push   %rbp
   1: 48 89 e5              mov    %rsp,%rbp
   4: 48 89 7d e8           mov    %rdi,-0x18(%rbp)
   8: 89 75 e4              mov    %esi,-0x1c(%rbp)
   b: c7 45 f8 00 00 00 00  movl   $0x0,-0x8(%rbp)
  12: c7 45 fc 00 00 00 00  movl   $0x0,-0x4(%rbp)
  19: eb 19                 jmp    34 
  1b: 8b 45 f8              mov    -0x8(%rbp),%eax
  1e: 48 98                 cltq   
  20: 48 c1 e0 02           shl    $0x2,%rax
  24: 48 03 45 e8           add    -0x18(%rbp),%rax
  28: 8b 00                 mov    (%rax),%eax
  2a: 83 e0 fe              and    $0xfffffffe,%eax
  2d: 01 45 fc              add    %eax,-0x4(%rbp)
  30: 83 45 f8 01           addl   $0x1,-0x8(%rbp)
  34: 8b 45 f8              mov    -0x8(%rbp),%eax
  37: 3b 45 e4              cmp    -0x1c(%rbp),%eax
  3a: 7e df                 jle    1b 
  3c: 8b 45 fc              mov    -0x4(%rbp),%eax
  3f: c9                    leaveq 
  40: c3                    retq   

Antes de tentar entender como ela funciona é bom saber exatamente em qual instrução ela cometeu o read inválido, para saber essa informação devemos analisar qual instrução se encontra no offset 0x4006A6 do programa principal, que é onde o valgrind diz ter ocorrido o read:

$ objdump main_prog -d
[CODE]
4006a6: 8b 00                 mov    (%rax),%eax
[MORE CODE]

O que ele está fazendo é desreferenciando o que está em rax que certamente é um ponteiro para nosso buffer, e colocando o valor em eax. Essa mistura de um registrador de 64bits com a porção 32bits dele mesmo se deve ao fato de que ponteiros, na minha máquina, tem 8bytes só que ele aponta para um buffer de integers de 4 bytes, por isso a mistura de rax e eax. Durante um desses acessos o programa fez referencia para memória inválida. Vamos focar em entender a função nos atendo a como esse read aconteceu e não aos detalhes do funcionamento interno da mesma.

Vamos organizar o código por partes para ignorar o que não for relevante: 

Primeiro o setup do frame da função, nada que importe para nosso caso:
push   %rbp
mov    %rsp,%rbp

O código que segue pode parecer curioso para quem está acostumado com asm 32bits, pois ele está salvando em variáveis locais o valor dos registradores rdi e rsi:

mov    %rdi,-0x18(%rbp)
mov    %esi,-0x1c(%rbp)

Mas a razão é simples, eis um pedaço do código da função main quando vai chamar ‘compute_some_stuff’:

mov    -0x10(%rbp),%rax
mov    $0xa,%esi
mov    %rax,%rdi
callq  40067e

Ela passa os parâmetros nos registradores ao invés de colocar na pilha. Isso se deve a uma diferença importante entre o i386 system V ABI e o amd64 system V ABI. Estou usando uma maquina 64 com um kernel 64. Na i386 System V ABI os argumentos para uma função são passados na pilha e somente pela pilha. Já no amd64 System V ABI os argumentos são passados primeiro nos registradores (rdi, rsi, rdx, rcx, r8 e r9 se o argumento for inteiro e nos registradores xmm0..xmm7 se for um float). Somente quando esses registradores foram todos utilizados é que a pilha é usada.

A seguir a função zera duas variáveis locais:
movl   $0x0,-0x8(%rbp)
movl   $0x0,-0x4(%rbp)

Daí, então, a função salta imediatamente para o offset 0x34:
jmp    34 

No offset 0x34 encontramos:

34: mov    -0x8(%rbp),%eax
37: cmp    -0x1c(%rbp),%eax
3a: jle    1b 

Até o momento, sabemos que a função zera duas variáveis locais e salta para o offset relativo 0x34, lá ela move para eax uma dessas variáveis locais (variavel_1) e compara o valor do registrador com o parâmetro que foi passado para a função (vou assumir, baseado no código da função main ao fazer a chamada, que seja o parâmetro data_len, até porque não faria muito sentido comparar com o ponteiro do buffer). Se (variavel_1) for menor ou igual ao (data_len) a função retorna o controle para o offset 0x1b:

1b: mov    -0x8(%rbp),%eax

Então o código recupera novamente a variável local(variavel_1) e armazena em eax. Neste ponto já podemos ter uma visão da organização das variáveis locais e dos parâmetros da função:


É fácil notar que estamos olhando para um loop. Passando o olho no código vamos notar que essa variável é sempre incrementada em 1, antes de um salto condicional (jle):

30: addl   $0x1,-0x8(%rbp)

Vamos organizar as idéias. O código salta direto para um teste, caso esse teste seja avaliado como verdadeiro ela salta de volta e executa algum código, sempre incrementando variavel_1 em uma unidade, é válido assumir que estamos olhando para um laço for:

int variavel_1;
for (variavel_1 = 0; variavel_1 <= data_len; variavel_1++)

BINGO! Não precisamos entender mais nada do funcionamento interno da função. Basta lembrar que nós passamos como parâmetro o tamanho do nosso buffer, se o loop é controlado por uma condição <=, ele sempre vai acessar um item a mais do que deveria. Por sinal, sabemos que ele usa <= pelo uso da instrução “jle”, que significa “jump if less or equal”. Qualquer coisa, além do google, claro, olhe os manuais da intel, são ótimos.

E agora? Agora vamos realizar um patch binário nessa lib e mudar a instrução de jle para jl(jump less) que terá o efeito de mudar o teste para: variavel_1 < data_len. Isso vai corrigir o problema do loop, claro que podem existir outros erros na função, mas vamos corrigir o mais óbvio primeiro. Uma olhada no manual da intel de instruções (ou qualquer site ou livro que lhe apetecer) nos mostra que a instrução jle, para saltos curtos, usa o opcode 0x7E, já a instrução jl usa o opcode 0x7C. Então basta substituir o byte correto na lib, compilar novamente o programa e testar.

Para realizar esse trabalho existem diversas maneiras, uma delas é usar um editor hexadecimal, na época optei por escrever um programinha em C que abre a lib e atualiza o byte. Primeiro ache em qual offset esse byte está dentro da lib, uma ideia é usar a ferramenta hexdump em conjunto com o grep para filtrar a saida.

$ hexdump libbroken.a | grep 7e
00000e0 8948 e87d 7589 c7e4 f845 0000 0000 45c7
0000110 458b 3bf8 e445 df7e 458b c9fc 00c3 4347

Vale lembrar que a ferramenta hexdump, na configuração default, agrupa os bytes em words de 16bits e como estou usando uma máquina little endian a ordem dos bytes estarão “trocados” na visualização, então o par destacado em negrito estará, na verdade, ordenado como “7e df” no arquivo. Essa sequencia lembra algo?

3a: 7e df                 jle    1b 

Exato! Achamos o nosso jump. E o opcode da instrução está no offset 0x110 + 6. Fiz o seguinte programa:

patch.c:
int main()
{
  FILE * fp = fopen("libbroken.a", "rb+");
  fseek(fp, 0x116, SEEK_SET);
  fputc(0x7C, fp);
  fclose(fp);
  
  return 0;
}

Vamos testar?

$ gcc -o patch patch.c
$ ./patch
$ gcc -o main_prog main_prog.c -L. -lbroken
$ ./main_prog 
[40]

Ho Ho Ho! Agora a função retorna o valor que deveria. Vamos ver o código dela em assembly:

$ objdump -d libbroken.a
In archive libbroken.a:
[CODE]
37: 3b 45 e4              cmp    -0x1c(%rbp),%eax
3a: 7c df                 jl     1b 
3c: 8b 45 fc              mov    -0x4(%rbp),%eax
[MODE CODE]

Nosso patch funcionou perfeitamente e agora a função retorna o valor correto. Mas, será que passa no valgrind? Vejamos:

$ valgrind ./main_prog
==4503== Memcheck, a memory error detector
==4503== Copyright (C) 2002-2013, and GNU GPL'd, by Julian Seward et al.
==4503== Using Valgrind-3.10.0.SVN and LibVEX; rerun with -h for copyright info
==4503== Command: ./main_prog
==4503== 
[40]
==4503== 
==4503== HEAP SUMMARY:
==4503==     in use at exit: 0 bytes in 0 blocks
==4503==   total heap usage: 1 allocs, 1 frees, 40 bytes allocated
==4503== 
==4503== All heap blocks were freed -- no leaks are possible
==4503== 
==4503== For counts of detected and suppressed errors, rerun with: -v
==4503== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)



Agora nossa lib se comporta corretamente, retornando o valor esperado e sem gerar nenhuma operação de READ inválida. Muito bem, basta levantar da cadeira do trabalho e ir pra casa programar!!! That's all folks!

Nenhum comentário: