Baixe o app para aproveitar ainda mais
Prévia do material em texto
Capítulo 1 Introdução .............................................................. 5 Entendendo o termo hacker ................................................................6 0 que é programação? ........................................................................8 0 que significa explorar a vulnerabilidade de um programa .......... 12 Capítulo 2 As técnicas dos hackers: Buffer Overflow no Stack As técnicas gerais utilizadas nos exploits ........................................ 16 Sobre as permissões de acesso a arquivos ..................................... 16 Entendendo o funcionamento da memória RAM ............................ 18 Entendendo o funcionamento das variáveis .................................... 19 0 Buffer Overflow ..............................................................................23 Criando exploits sem código de exploit ........................................... 33 Explorando as variáveis do ambiente de execução ................... 38 Capítulo 3 As técnicas dos hackers: Buffer Overflow em Heap e BSS Sobre os overflows baseados em heap e BSS ................................ 50 Capítulo 4 As técnicas dos hackers: format string ............... 69 Sobre os exploits baseados em format string ................................. 70 Vulnerabilidade do format string ...................................................... 77 Realizando a leitura de dados a partir de endereços de memória arbitrários .............................................. 79 Realizando a escrita de dados em endereços de memória arbitrários ............................................. 81 Sobre o DPA - Direct Parameter Access .......................................... 91 Capítulo 5 As técnicas dos hackers:.dtors e GOT .................. 97 Entendendo os dtors ........................................................................98 Sobrescrevendo a GOT (Global Offset Table) ............................. 106 Capítulo 6 Redes e segurança ...............................................111 Introdução ........................................................................................ 112 Sobre redes e networking ............................................................... 112 Conhecendo o modelo Open System Interconnection ................. 112 Algumas informações importantes sobre as camadas do modelo OSI ............................................................ 117 As palavras hacker e hacking são geralmente associadas a ações negativas, como vandalismo eletrônico, espionagem industrial, interceptação de informações sigilosas e particulares, para citar as mais comuns. De fato, hoje em dia o termo hacker é empregado para definir criminosos que utilizam os computadores para fins de estelionato, roubo e outros atos prejudiciais às suas vítimas. Mas, pelo menos em suas origens, o hacking consistia em encontrar, em determinado contexto, possibilidades alternativas e utilizá-las de maneira criativa para resolver problemas: um hacker não trabalhava para infringir a lei, pelo contrário, sua tarefa era assegurar-se que fosse respeitada, monitorando o tráfego de dados nas redes, observando os potenciais ataques a sistemas críticos etc. Hoje em dia o termo hacker é usado para identificar tanto aqueles que escrevem linhas de códigos quanto os que aproveitam suas vulnerabilidades e falhas utilizando exploits. Saiba mais... No jargão da segurança da informação, um exploit é um programa de computador, uma porção de dados ou uma seqüência de comandos que se aproveita das vulnerabilidades de um sistema computacional - como o próprio sistema operacional ou serviços de interação de protocolos, como por exemplo, os servidores Web. São geralmente elaborados por hackers como programas de demonstração das vulnerabilidades, a fim de que as falhas sejam corrigidas, ou por crackers com objetivo de ganhar acesso não autorizado a sistemas. Por isso muitos crackers não publicam seus exploits, conhecidos como Odays. Até meados dos anos 90, acreditava-se que os exploits exploravam exclusivamente problemas em aplicações e serviços para plataforma Unix. A partir do final da década, especialistas demonstraram a capacidade de explorar vulnerabilidades em plataformas de uso massivo, por exemplo, sistemas operacionais Win32 (Windows 9x, NT, 2000 e XP). Como exemplo, temos o CodeRed, o MyDoom, o Sasser em 2004 e o Zotob em 2005. Para que um exploit possa atacar, o sistema precisa ter uma vulnerabilidade, ou seja, um meio de comunicação com a rede que possa ser usado para entrar no sistema, uma porta ou uma console. Um exploit muito usado é o sistema RPC do Windows: o usuário localiza a porta e envia para a porta RPC uma seqüência de bytes, ao serem recebidos eles são interpretados pelo servidor, o que causam propositadamente uma pane no sistema, que passa então uma seqüência de ordens para controlar a CPU. Assim, essa seqüência de informações toma conta do PC e abre-o para o hacker que aguarda na outra ponta. No sistema Linux, quando existem vulnerabilidades elas são sempre publicadas, como já ocorreu no sistema Apache, Samba ou MySQL, que também apresentam vulnerabilidades e possibilitam o controle do PC por um hacker remoto. Embora os dois tipos de hackers (desenvolvedores e exploradores de falhas) trabalhem com finalidades bem diferentes, ambos utilizam técnicas parecidas de problem solving (resolução de problemas). Essas técnicas consistem em descobrir e desenvolver uma solução inteligente para um determinado problema, evitando os esquemas mais óbvios e tradicionais. Isso envolve o uso das regras no computador de maneira absolutamente imprevista, obtendo resultados que, aos olhares menos experientes, parecem mágicas. Em vista disso, um hack nada mais é que uma solução criativa e diferente dos esquemas tradicionais para resolver um problema de maneira eficiente. Os desenvolvedores utilizam os hacks para impedir ataques aos seus sistemas; já os hackers exploradores de vulnerabilidades criarão hacks para burlar os sistemas de proteção criados pelos desenvolvedores. Em função do incrível aumento da potência de cálculos dos computadores modernos, houve uma inversão de tendência no desenvolvimento dos códigos de programas: se nas décadas de 1970 e 1980 se trabalhava para criar códigos mais enxutos possíveis para economizar cálculos do processador, hoje os desenvolvedores visam criar programas no menor tempo possível, mesmo que isso comporte a criação de códigos inutilmente extensos. De fato, trabalhar horas e horas na lapidação de um código não apresenta nenhuma vantagem econômica, pois a diferença em termos de tempo de execução do programa se resume em poucos milésimos de segundo. Assim, a elegância dos programas passou a ser um aspecto secundário, ou melhor, insignificante, mas não para os hackers. Esses apreciadores da programação mais pura não visam o lucro, mas sim criam códigos incrivelmente curtos e eficientes para conseguir extrair de seus velhos 8086 e Commodore 64 até o último bit de funcionalidade. Imagine o que significa para um hacker conseguir driblar com seu velho 286 o sistema de proteção de um mega-servidor que adota a tecnologia mais avançada disponível no mercado! Fica claro que o conhecimento da programação, desde sua lógica até a criação de linhas de código, é imprescindível para quem deseja saber mais sobre o mundo dos hackers e, quem sabe, se tornar um. O que é programação? Programação é um conceito bastante simples. De maneira geral, um programa nada mais é que uma seqüência de instruções escritas em uma linguagem específica. Os programas são usados em todos os contextos da sociedade atual e mesmo aqueles que dizem detestar a tecnologia os empregam (ou são vítimas deles) todos os dias: as indicaçõespara chegar a um endereço, as receitas de cozinha, as regras de um jogo de futebol, são exemplos de programas que executamos no nosso dia-a-dia. Veja um exemplo: •siga em frente na Avenida São Paulo em direção norte até a primeira rotatória; •vire à direita na Rua Dom Pedro II e mantenha-se à esquerda; •prossiga até o semáforo: se a rua à esquerda estiver interditada, continue reto e vire à esquerda no segundo semáforo, caso contrário vire à esquerda no primeiro semáforo; •no cruzamento com a Avenida Castelo Branco, vire à direita e siga reto até o número 1.504. Basta conhecer e entender o português para poder seguir essas simples instruções até chegar ao endereço desejado. Os computadores, porém, não utilizam a nossa linguagem, portanto, não entenderiam as instruções descritas nos tópicos do exemplo: para que um computador execute uma seqüência de comandos é preciso escrever as instruções utilizando um idioma específico, chamado linguagem de máquina. Trata-se de uma linguagem extremamente complexa, constituída por uma seqüência de bits e bytes brutos incompreensíveis para a quase totalidade das pessoas; além disso, dependendo da arquitetura do computador, essa linguagem pode variar: para escrever um programa na linguagem de máquina para processadores x86 por exemplo, é preciso conhecer o valor associado a cada comando, de que maneira as instruções interagem entre elas e outros inúmeros detalhes. Para resolver esse problema foi criado um intérprete, ou seja, um sistema que traduz códigos mais simples em instruções na linguagem de máquina. Trata-se do Assembler, uma linguagem menos hostil que utiliza nomes para as diversas instruções e variáveis no lugar de valores binários. Todavia, até mesmo o Assembier está longe de ser uma linguagem intuitiva e de fácil compreensão. Os nomes de comandos são complexos e esquisitos e variam de acordo com a plataforma para a qual se está programando, isto é, a linguagem Assembler para processadores Intel é diferente da usada para processadores Sparc. Assim, programas escritos para serem executados em plataformas x86 não funcionarão em computadores baseados em sistemas Sparc. Por esse motivo, surgiu a necessidade de criar um outro tipo de intérprete que fosse capaz de resolver esses problemas de compatibilidade. Nasceram, então, os Compiladores, que convertem uma linguagem de alto nível em linguagem de máquina. As linguagens de alto nível são muito mais compreensíveis do que o Assembler e podem ser convertidas em vários tipos de linguagens, de acordo com a arquitetura do computador que irá executar o programa. Em outras palavras, se um programa é escrito empregando uma linguagem de alto nível, ele será escrito apenas uma vez, mas poderá ser compilado por um compilador específico para cada arquitetura. São exemplos de linguagens de alto nível o C#, C++, Fortran, Java, PHP, ASP, Delphi, Visual Basic e outras. Como dissemos, um programa escrito em uma linguagem de alto nível é muito mais intuitivo do que um escrito em Assembler, mas, mesmo assim, deve seguir normas exatas de sintaxe, caso contrário o compilador não conseguirá convertê-las. Os programadores utilizam também outro tipo de linguagem, que chamam de pseudocódigo. Trata-se de uma seqüência de instruções escritas na linguagem humana, porém, organizadas em uma estru tura praticamente idêntica à de uma linguagem de programação de alto nível. 0 pseudocódigo não é usado ao escrever o código de um programa, mas é muito útil ao programador para ordenar as idéias e definir um esboço do que será o programa. Veja, no exemplo a seguir, como ficaria a seqüência de indicações descritas no início do tópico: Cada instrução é especificada utilizando uma linha de código e as linhas são divididas em estruturas de controle separadas. No exemplo anterior, resumimos as instruções de maneira extremamente simples, mas, na verdade, a série de ações a serem realizadas para cada etapa pode ser muito mais complexa. Por exemplo, para a instrução vire à esquerda no primeiro semáforo é necessário executar muitas operações como localizar o semáforo, diminuir a velocidade, reduzir a marcha, acionar a seta, girar o volante no sentido anti-horário, acelerar, aumentar a marcha etc. Há casos, como este, em que uma mesma seqüência de ações pode ser aplicada em mais de uma circunstância, como por exemplo, todas as vezes que for preciso virar à esquerda em correspondência de um determinado ponto de referência. Para simplificar o código e evitar repetir as mesmas instruções várias vezes no mesmo programa, podemos criar uma função, ou seja, um conjunto de comandos e seus respectivos argumentos resumidos em um único comando que será chamado no momento oportuno do programa. Veja, a seguir, um exemplo do pseudocódigo de uma função: Assim, o pseudocódigo do nosso programa de indicações para chegar a determinado local, no caso à Avenida Castelo Branco n° 1.504, ficaria assim: No código anterior, destacamos todas as vezes que a função virar foi chamada juntamente com seus argumentos, ou seja, os dados relativos à direção e o ponto de referência. Sempre que a função virar for chamada no código, todas as ações em seu interior serão executadas com base nos argumentos especificados pelo usuário. O que significa explorar a vulnerabilidade de um programa Saber explorar os pontos fracos de um programa é um elemento essencial ao hacking. Os programas são conjuntos de regras e instruções a serem executados em determinada ordem para informar ao computador o que ele deve fazer. Explorar as vulnerabilidades signi- fica encontrar uma maneira inteligente de fornecer ao computador instruções diferentes daquelas que o programa inicial foi criado para executar. Visto que um programa de computador pode fazer somente aquilo que foi especificado em seu código, as falhas na segurança são imperfeições na arquitetura do programa ou do ambiente em que o código será executado, devido a erros cometidos pelos programadores. Todavia, é preciso muito conhecimento, criatividade e inteligência para encontrar essas falhas e escrever códigos que as usem com o objetivo de assumir o controle do computador. Não faltam exemplos de falhas de programas exploradas para utilizar computadores para outras finalidades, e os alvos são os mais diversos: de agências bancárias ao Pentágono, dos servidores da NASA a sites de compras on-line, e assim por diante. Basta fazer uma rápida busca na Internet para descobrir que os ataques de hackers estão em crescimento espantoso, o que é sintoma de dois fatores preocupantes: o primeiro é que, por razões meramente econômicas, a segurança dos programas vem sendo deixada sempre em segundo plano, priorizando a rapidez na comercialização, o que favorece a vulnerabilidade; o segundo é que, devido à popularização dos computadores e ao surgimento de pontos de acesso anônimos (Lan-houses, Internet-bars etc.) ser um hacker está se tornando cada vez mais fácil e menos perigoso. Existem nos programas erros e falhas, geralmente recorrentes, encontrados com as mesmas características em diferentes códigos, assim foram criadas técnicas gerais que permitem explorar essas falhas em diversas situações. Os tipos mais comuns de exploits são buffer overflow e format string, usados para assumir o controle da execução do programa para em seguida inserir trechos de código malignos com propósito de executar qualquer tipo de comando no computador vítima do ataque. 0 uso desses exploits pressupõe que o hacker possua um bom conhecimento sobre os sistemas de permissãode acesso aos arquivos, variáveis, alocação de espaço na memória, funções e linguagem Assembler. Sobre as permissões de acesso a arquivos 0 Linux é um sistema operacional multiusuário em que os privilégios absolutos sobre o sistema são dados a um usuário chamado root. Há também outras contas de usuário que podem ser dividas em grupos com privilégios diferentes. Cada usuário pode pertencer a mais de um grupo e cada grupo pode conter muitos usuários. As regras que gerenciam as permissões de acesso aos arquivos podem ser definidas tanto para grupos quanto para usuários específicos, para que cada usuário do sistema possa acessar somente os arquivos e pastas aos quais possua permissão. Quem define as permissões de acesso, isto é, quais usuários poderão abrir os arquivos, é o proprietário dos arquivos e pastas, ou seja, quem os criou. Há três tipos de permissão de acesso a arquivos e pastas: •leitura: permite que um ou mais usuários abram o arquivo, mas não será possível efetuar alterações; •gravação: permite que o arquivo seja aberto, alterado e salvo pelos usuários com este privilégio; •execução: permite iniciar a execução de arquivos com essa característica. Essas permissões podem ser concedidas ou revogadas em três campos, denominados user, group e other. 0 campo user determina o que o usuário poderá fazer com o arquivo (leitura, gravação ou execução), o campo group define o que os outros usuários do grupo poderão fazer e o campo other especifica as permissões para os demais usuários do sistema. As permissões são representadas na tela pelas letras r(read= leitura), w(write=escrita/gravação) e x (execute = executar), dispostas em três colunas adjacentes relacionadas, respectivamente, ao usuário (user), ao grupo (group) e aos outros (other). Veja o exemplo a seguir: A linha de código vista no exemplo indica que o usuário tem permissão para leitura, gravação e execução (-rwx), os outros usuários do mesmo grupo possuem privilégios para leitura e gravação (-rw) e os demais usuários do sistema podem apenas ler e executar (-rx). Em determinadas circunstâncias, como por exemplo, a alteração da senha, pode ser necessário conceder a certo usuário privilégios para realizar uma operação que requeira as permissões do usuário root; todavia, transformar um usuário comum em root é algo altamente desaconselhável para a segurança do sistema. Assim, podemos escrever o código do programa de maneira que opere como se qualquer usuário logado no sistema fosse root; desta forma, dentro do ambiente do programa, todos os usuários poderão executar as tarefas desejadas sem esbarrar em obstáculos causados pelas permissões de acesso ao sistema operacional. Este tipo de permissão é denominado permissão suid (do inglês set user id, ou seja, definir a identidade do usuário). Quando um programa com permissão suid é executado, o euid (effective user id - identidade real do usuário) é substituído pela identidade do proprietário do aplicativo, isto é, do usuário que o instalou no sistema. Da mesma forma, assim que o programa é fechado o user id volta a ser o "euid". Há também a permissão sgid (do inglês Set Group Id- Definir a Identidade do Grupo), que opera de maneira idêntica ao suid, porém, altera temporariamente a identidade do grupo de usuários. Suponhamos, por exemplo, que você fez o logon num sistema Linux e deseja alterar a sua senha de acesso: será preciso executar um programa específico, chamado passwd, que pertence ao usuário root e cuja permissão suid encontra-se ativada; ao executar passwd, o seu user id é substituído temporariamente pelo id root para que seja possível alterar a senha e, ao terminar, é restaurado automaticamente o seu user id original. Programas como o passwd, pertencentes ao usuário root e cuja permissão suid é ativa, são chamados comumente de programas suid root. Situações como essa são um prato cheio para o hacker que deseja desviar o fluxo de execução do programa para assumir o controle do sistema. Um programador que consiga modificar o fluxo de um programa suid root para que possa inserir e executar nele um trecho de código malicioso, poderá fazer qualquer coisa, como se fosse o usuário root. Será capaz, por exemplo, de abrir uma nova console do sistema para acessar todos os recursos do sistema, criar novas contas de usuários, alterar senhas, acessar dados confidenciais armazenados no computador, gerenciar o tráfego dos dados na rede etc. Como dissemos, um programa é simplesmente um conjunto de regras escritas em uma linguagem de alto nível (Delphi, Visual Basic, Java, C++ etc.) que determina as instruções a serem executadas pelo computador. Por trabalharem em linguagens tão distantes da linguagem de máquina, geralmente os programadores ignoram detalhes como as variáveis de memória, stack, ponteiros de execução e outros comandos de baixo nível que não são visíveis nas linguagens de alto nível. Assim, um hacker com domínio da linguagem de máquina e que conheça os comandos resultantes da compilação de um código escrito em linguagem de alto nível, saberá exatamente o que o processador irá fazer e estará em condição de criar um código que, em algum momento da execução do código original, desvie o fluxo de execução para outro código que ele criou e inseriu no programa. Fica claro que o conhecimento das regras de programação em baixo nível é indispensável para o hacker. Entendendo o funcionamento da memória RAM A memória de um computador é constituída por um conjunto de bytes destinados ao armazenamento temporário de dados, esse armazenamento é feito associando cada dado a um endereço numérico que especifica a alocação das informações na memória. 0 acesso à memória é feito utilizando os endereços de alocação, lendo ou gravando dados no endereço especificado pelo processador. Os processadores modernos baseados na tecnologia x86 da Intel utilizam esquemas de endereçamento de dados de 32 e 64 bits, o que significa que, dependendo da arquitetura do processador, podem existir até 264 endereços de memória possíveis. As variáveis de um programa são determinadas posições da memória usadas para armazenar informações. 0 ponteiro é um tipo especial de variável usado para gravar os endereços da memória com objetivo de poder buscar as informações necessárias. Durante a execução de um programa, as informações nele contidas precisam ser copiadas para que possam ser usadas em pontos diferentes. Entretanto, copiar grandes quantidades de dados em vários endereços é uma operação inviável, pois ocuparia inutilmente muito espaço na memória, além disso, o gerenciamento dessa movimentação dos dados na memória seria crítico, pois haveria uma quantidade considerável de dados a serem alocados, o que geraria uma lista de endereços de memória muito extensa. A solução desse problema são os ponteiros que, ao invés de copiarem um bloco de dados, seu respectivo endereço é armazenado em uma variável ponteiro (com tamanho de 4 bytes), de modo que sempre que o programa precisar daquelas informações, o ponteiro indicará ao processador em que local da memória pode encontrá-las. Os processadores também possuem uma memória interna, de tamanho muito pequeno, conhecida como registro. Existem tipos especiais de registros cuja função é monitorar as operações executadas durante a execução de um código de programa. Um dos registros mais conhecidos é o EIP (Extended Instruction Pointer), que armazena os endereços das instruções que serão executados na seqüência, ou seja, logo após o comando que está sendo processadono momento. Outros registros de 32 bits usados como ponteiros são o EPB (Extended Base Pointer) e o ESP (Extended Stack Pointer), este último é específico para o gerenciamento da parte superior do stack, ou seja, a última área livre no empilhamento dos dados na memória. Entendendo o funcionamento das variáveis Ao programar em uma linguagem de alto nível, as variáveis devem ser declaradas especificando o tipo de dado que irão armaze nar; e esses dados podem ser do tipo numérico, alfanumérico ou de estruturas definidas pelo usuário. Essa distinção dos tipos de dados é muito importante, pois permite calcular o espaço de memória necessário a cada variável. Um número inteiro, por exemplo, requer 4 bytes de espaço, já para um caractere de texto é suficiente apenas 1 byte; assim, para um número inteiro são reservados 32 bits, enquanto para um caractere bastam 8 bits. As variáveis também podem ser declaradas como vetores, isto é, uma lista de elementos de um mesmo tipo de dados. Um vetor é geralmente chamado buffer e um vetor que contenha dados do tipo alfanumérico é definido como string. Durante a execução de um código, o sistema usa ponteiros para armazenar o endereço do primeiro elemento de um buffer, esses ponteiros devem ser declarados utilizando um asterisco (*) antes do nome da variável. 0 gerenciamento da memória dos processadores que usam arquitetura x86 possui uma característica que se revelará muito importante no futuro: a ordem dos bytes nas words (palavras) de 4 bytes, conhecido no jargão com o nome de little indian (pequeno índio), que faz com que o byte considerado menos significativo seja o primeiro na ordem. Vetores e bytes nulos Em uma variável do tipo vetor, podem ser armazenados 10 bytes, dos quais apenas alguns são usados de fato. Por exemplo, se armazenarmos em um vetor a palavra casa, o vetor conterá 10 bytes, mas apenas 5 deles contêm valores reais, enquanto os outros 5 serão bytes supérfluos. 0 0 (zero), ou byte null (nulo), é usado como delimitador para encerrar a linha, informado a qualquer instrução que trabalhe com os dados do vetor que deverá desconsiderar os bytes sucessivos. Veja o exemplo a seguir para entender melhor: Tabela 2.1: Representação esquemática de uma variável vetorial. 0 exemplo da Tabela 2.1 mostra a representação esquemática de uma variável vetorial. Nela temos 10 posições (numeradas de 0 a 9), note que os caracteres da palavra casa ocupam apenas as primeiras quatro posições e que, na quinta posição, foi inserido um byte nulo (zero). Isso fará com que quando um trecho de código do programa ler as informações contidas no vetor processará apenas as primeiras quatro posições, desconsiderando os bytes das posições 5 a 9. 0 byte nulo atuou como um comando que instruiu o código do programa para que parasse o processamento na posição 4. Sobre as seções da memória de programa A memória de programa é dividida em cinco seções: Text, Data, BSS, Heap e Stack. Cada seção representa uma porção específica da memória reservada a um determinado fim. A seção Text A seção Text (texto) é utilizada para armazenar o código do programa compilado na linguagem de máquina; a execução das instruções contidas nessa seção não é seqüencial, devido às estruturas de controle e às funções de alto nível, que em Assembler são compiladas em branch, jump e call. Quando um programa está sendo executado, o ponteiro EIP é posicionado na primeira instrução da seção Text, a partir daí o processador executa um loop (repetição) que faz o seguinte: a.primeiramente lê as instruções apontadas pelo EIP; b.depois adiciona o comprimento (em bytes) da instrução ao EIP; c.então executa a instrução lida no primeiro passo; d.por fim, retorna ao início desse processo. A instrução pode ser um jump ou um call que faz com que o ponteiro EIP seja deslocado para outro endereço de memória; o processador não tomará parte dessa transferência, pois sabe que a execução não é seqüencial. Se, por exemplo, o EIP for deslocado no passo (c), o processador retornará de qualquer forma ao passo (a) e lerá a nova instrução apontada pelo EIP. Na seção Text a permissão para a gravação é desabilitada, pois esta porção de memória é destinada a armazenar apenas o código do programa na linguagem de máquina, e não a eventuais variáveis usadas no decorrer da execução do código. Essa é uma maneira de impedir que alguém possa alterar o código do programa. As seções Data e BSS As seções Data e BSS são reservadas para o armazenamento das variáveis globais e estáticas; a seção Data é preenchida com as variáveis globais (inicializadas no começo do código), strings e outras constantes que serão usadas durante todo o processo de execução do programa; a seção BSS contém as partes correspondentes não inicializadas. Estas seções permitem a alteração de seus conteúdos, porém, têm seus tamanhos fixos e predefinidos. A seção Heap A seção Heap é usada para as demais variáveis do programa. Ao contrário das seções Data e BSS, seu tamanho não é fixo e pode ser alterado para aumentá-lo ou reduzi-lo de acordo com as necessidades. Toda a porção de memória da seção Heap é gerenciada por algoritmos de alocação e desalocação que reservam endereços de memória quando necessário e os desocupam quando não são mais utilizados, tornando-os disponíveis para novos dados. A seção Stack A seção Stack também pode ter seu tamanho alterado e é utilizada para armazenar dados contextuais gerados durante a chamada de funções no interior do código. Quando um programa chama uma função, esta possuirá seu próprio conjunto de variáveis que serão transferidas e o código da função será alocado em uma posição diferente da seção Text. Devido à mudança do contexto, o ponteiro EIP deverá mudar de posição quando uma função for chamada. Então o Stack será usado para gravar todas as variáveis transferidas e o endereço do ponto ao qual o ponteiro EIP deverá retornar ao término da execução da função. Tecnicamente falando, o Stack é definido como uma "estrutura abstrata de dados usada com freqüência" e funciona de acordo com um esquema conhecido com o nome de "first-in, last-out" (FILO), que determina que o primeiro valor inserido no Stack seja o último a ser extraído. Em outras palavras, funciona como uma série de caixas empilhadas, sendo que a primeira caixa apoiada será a última a ser retirada e não se pode retirá-la sem remover primeiro as que estão sobre ela. Em informática, se usa o termo push para colocar um dado no Stack, e o termo pop quando o dado é retirado. Como o próprio nome Stack (pilha) indica, essa seção de memória é, de fato, uma estrutura de dados empilhados uns sobre os outros. 0 registro ESP é usado para rastrear o endereço do último elemento do Stack, que muda em continuação na medida em que se realizam o push e o pop dos dados. A característica FILO do Stack é muito útil para armazenar dados contextuais: quando uma função é chamada, vários elementos são inseridos no Stack em uma estrutura denominada Stack frame. 0 registro EBP (também chamado Trame pointer- ponteiro do frame) é usado para fazer referência às variáveis presentes no frame do Stack atual. Cada Stack frame contém os argumentos da função chamada, suas variáveis locais e dois ponteiros necessários para restaurar a situação inicial: o saved frame pointer e o endereço de alocação ao qual o código deve retornar. 0 saved frame pointer é usado para redefinir o EBP para o seu valor anterior à chamada da função, enquanto o endereço de retorno redefine o EIP na posição da situação sucessiva à chamada da função. O Buffer Overflow A linguagem de alto nível C, em suas versões e derivações, é a mais utilizada pelos hackers devido à suaflexibilidade para a criação de programas. Saiba Mais... 0 C é uma linguagem de programação de propósito geral, estruturada, imperativa, procedural, de alto nível e padronizada. Criada em 1972 por Dennis Ritchie, nos laboratórios Bell, para ser usada no sistema operacional UNIX. Desde então, espalhou- se por muitos outros sistemas operacionais, e tornou-se uma das linguagens de programação mais usadas. A linguagem C tem como ponto forte a sua eficiência e é a linguagem de programação preferida para o desenvolvimento de sistemas e softwares de base, apesar de também ser usada para desenvolver programas de computador. É também muito usada no ensino de ciências da computação, mesmo não tendo sido projetada para estudantes e apresentando algumas dificul dades no seu uso. Outra característica importante do C é sua proximidade do código de máquina, que permite que um projetista seja capaz de fazer algumas previsões de como o software irá se comportar, ao ser executado. 0 C tem como ponto fraco a falta de proteção que dá ao programador. Praticamente tudo que se expressa em um programa em C pode ser executado, como por exemplo, pedir o vigésimo membro de um vetor com apenas dez membros. Os resultados são muitas vezes totalmente inesperados e os erros, difíceis de encontrar. Muitas linguagens de programação foram influenciadas pelo C, sendo que a mais utilizada atualmente é o C++, que por sua vez foi uma das inspirações para o Java. 0 desenvolvedor que programa utilizando a linguagem C deve se responsabilizar pela integridade dos dados, pois se deixar essa tarefa por conta do compilador, os códigos resultantes na linguagem de máquina serão extremamente lentos e inutilmente complexos, devido às inúmeras verificações que seriam feitas na integridade de cada variável do ambiente. A simplicidade da linguagem C permite ampliar o poder de controle do programador sobre o código e, como conseqüência, a eficiência dos programas. Por outro lado, essa flexibilidade pode se tornar uma verdadeira faca de dois gumes, pois quando o programador não dedica a atenção necessária à integridade do código que está escrevendo, poderá ocasionar inúmeros problemas de vulnerabilidade, perdas de memória e buffer overflow. Em outras palavras, uma vez que uma variável é alocada em determinado endereço da memória, não existem mecanismos de proteção automática que garantam que o conteúdo da variável caiba no espaço de memória que lhe foi designado; se, por exemplo, o desenvolvedor instruir o programa para que armazene 10 bytes de dados em um endereço de memória com capacidade para apenas 8 bytes, esta operação não será impedida pela linguagem e poderá provocar o travamento (crash) do sistema. Este fenômeno é conhecido no âmbito da informática com o nome de buffer overflow ou buffer overrun, pois os 2 bytes de dados excedentes irão transbordar (overflow) para fora do espaço de memória alocado, provocando a sobrescrita de dados já existentes nos espaços de memória adjacentes. Veja, no exemplo a seguir, um código que provocaria um buffer overflow: 0 código exibido contém uma função chamada função _ BufferOverflow, associada a um ponteiro de linha chamado str, que copia qualquer valor armazenado naquele endereço de memória em um buffer (variável de função local) para o qual foram reservados 20 bytes: A função principal do código aloca um buffer de 128 bytes que foi chamado big_string e utiliza uma estrutura de repetição (ou loop) para preencher todo o buffer com letras "A": Em seguida, é chamada novamente a função função- BufferOverflow que usa como parâmetro o ponteiro que aponta para o buffer de 128 bytes: Isto criará sérios problemas, pois a função função _ BufferOverflow tentará armazenar 128 bytes de dados em um buffer para o qual foram reservados apenas 20 bytes de espaço na memória. Assim, os 108 bytes de dados excedentes irão transbordar (overflow) invadindo os espaços de memória adjacentes, sobrescrevendo qualquer informação que estiver armazenada neles. 0 resultado é que a aplicação travará. Um hacker pode explorar esse tipo de falha no código para causar o travamento de uma aplicação inserindo valores não previstos que induzam o código a sobrecarregar o buffer; e, ainda, poderá assumir o controle do código, pois, ao ocorrer o overflow, alguns dados serão sobrescritos e, portanto, perdidos. Tentemos entender melhor como isso acontece. Examinando o código do exemplo, vimos que quando a função _ BufferOverflow é chamada, é inserido um novo stack frame no Stack. Quando a função é executada pela primeira vez no código, a estrutura do Stack seria basicamente a seguinte: Tabela 2.2: Código estrutural do Stack. Em seguida, quando a função tenta armazenar 128 bytes de dados no buffer de 20 bytes, os 108 bytes excedentes transbordam, sobrescrevendo o conteúdo dos espaços de memória adjacentes, que são o ponteiro do frame do Stack, o endereço de retorno e o parâmetro da função. Assim, ao concluir a execução da função, o código tenta voltar ao endereço de retorno que, devido ao overflow do buffer de 20 bytes, foi preenchido com letras "A" (0x41 em código hexadecimal); então o ponteiro EIP tenta voltar ao endereço de memória 0x41414141, que não existe ou não contém instruções válidas, por isso a execução do código é interrompida e ocorre o travamento do aplicativo. Esse tipo de buffer overflow é chamado overflow baseado no Stack, pois o derramamento dos dados excedentes acontece na área de Stack da memória. Os overflows podem ocorrer em outros segmentos da memória, como Heap ou BSS, mas os baseados no Stack são os mais versáteis e interessantes, pois permitem sobrescrever o conteúdo do endereço de retorno. De fato, o travamento do programa em si não é relevante, enquanto que a razão pela qual ocorre o travamento é fundamental para os hackers. No exemplo anterior vimos que o endereço de retorno foi preenchido com caracteres "A" que, interpretados em código hexadecimal, fizeram o ponteiro do Stack frame ir para o endereço de memória 0x41414141. Se o endereço de retorno fosse preenchido com outros valores, poderíamos fazer com que, após a execução da função, o SFP apontasse para um endereço de memória que contivesse um código executável, fazendo com que a execução do código fosse para um endereço de retorno diferente do que havia sido definido pelo programador. Desta maneira, o hacker acaba assumindo o controle do fluxo de execução do programa, pois pode endereçar o SFP para um endereço de memória no qual ele mesmo inseriu outro código qualquer, com a finalidade que desejar. É nesse momento que o hacker realiza a injeção de bytecode - um trecho de código independente do restante do programa que pode ser inserido dentro do buffer. A criação de um bytecode é bastante complexa e exige muita experiência e malícia por parte do programador. 0 bytecode que será inserido deverá ser interpretado pelo programa principal como um simples buffer de dados e, portanto, não poderá conter certos caracteres especiais e também não poderá depender de nenhuma variável do código principal, mantendo-se totalmente desvinculado e autônomo. Entre os bytecodes mais comuns, o shellcode é o mais conhecido: trata-se de um código de programa que cria uma Shell (ou console) que permite ao hacker acessar todo o sistema com os privilégios de administrador (root). Veja um exemplo: Trata-se de um exemplo de código de programa vulnerável muito parecido com a função _ BufferOverflow que vimos anteriormente: ele utiliza apenas um parâmetro e tenta armazenar o valor desse parâmetro no buffer de 300 bytes. Na verdade, esse código não faz nada de realmente prejudicial ao sistema, apenas gerencia amemória de maneira incorreta. Para que o código se torne útil ao hacker, é necessário que o controle do código seja assumido pelo usuário root e que o bit de permissão suid seja configurado como "on". Supondo que o código acima se refira a um programa de nome "ataque" criado por nós, para que este programa se torne suid, deveríamos fazer o seguinte: Agora que "ataque" é um programa root vulnerável a um buffer overflow, basta inserir o código que gere um buffer e que possa ser inserido dentro do programa principal. 0 buffer deverá conter o shellcode desejado e sobrescrever o endereço de retorno do Stack de modo que o shellcode seja executado. Para isso precisamos saber o endereço de memória em que o shellcode foi armazenado, o que não é fácil, pois como já dissemos, o Stack é dinâmico, ou seja, os dados são realocados continuamente. Além disso, há outra complicação, os 4 bytes nos quais o endereço de retorno é armazenado no stack frame devem ser sobrescritos exatamente com o valor do endereço de retorno desejado para executar o shellcode. Então, o hacker deve não só descobrir o endereço de memória que contém o shellcode, mas também conseguir sobrescrever os 4 bytes que compõem tal endereço exatamente em cima dos 4 bytes que continham o endereço de retorno original definido pelo programador. Em casos como esse, são empregadas duas técnicas para resolver os problemas: 1.A primeira, conhecida com o nome NOP sled (No Operation), consiste em inserir uma instrução de 1 único byte que não faz nada. Essas instruções são usadas em determinadas circunstâncias para fazer com que sejam executados ciclos de cálculos vazios que, por razões de sincronização, são exigidos na arquitetura dos processadores Sparc para garantir a correta execução de seqüências de ciclos de cálculos. No nosso caso, porém, instruções NOP sled são usadas como expedientes: o hacker criará uma longa fila de instruções NOP sled e as inserirá antes do shellcode; quando o ponteiro EIP vai para um endereço qualquer presente nas NOP sled, o EIP será incrementado de uma unidade para cada instrução NOP até alcançar o shellcode. Se o endereço de retorno for sobrescrito com um endereço qualquer presente na NOS sled, o ponteiro EIP pulará para o shellcode, que será executado corretamente. 2.A segunda técnica consiste em despejar no final do buffer uma série de instâncias contíguas do endereço de retorno desejado. Desta forma a execução do programa passará para o novo endereço, desde que uma das instâncias inseridas no buffer sobrescreva exatamente os 4 bytes do endereço armazenado no ponteiro do stack frame (SFP). 0 resultado da manipulação do buffer seria uma estrutura basicamente igual à mostrada a seguir: Tabela 2.3: Resultado da manipulação do buffer. Mesmo dominando essas duas técnicas, é preciso conhecer a posição aproximada do buffer na memória para só então descobrir o endereço de retorno correto. Para encontrar a localização do endereço de memória de maneira aproximada, pode-se utilizar o stack pointer (ponteiro do stack), pois subtraindo deste ponteiro um valor apropriado é possível obter o endereço relativo de uma variável qualquer e, visto que no nosso programa o primeiro elemento do Stack é o buffer no qual estamos inserindo o shellcode, o endereço de retorno correto deve ser o próprio stack pointer, o que significa que o offset deve ser próximo do zero. Vejamos um exemplo de código com o objetivo de violar um programa criando um buffer e inserindo-o em um programa vulnerável: Observação: os códigos dos exemplos foram encontrados na Internet em sites que os oferecem gratuitamente. 0 leitor poderá encontrar outros inúmeros exemplos, se desejar. Tentaremos compreender melhor o significado desse código: trata-se de um exploit projetado para criar um código malicioso, disfarçado de buffer de dados e inseri-lo em um programa vulnerável para que possamos assumir o controle do fluxo de execução e então executar um shellcode que será inserido quando o programa travar em função do buffer overflow. Inicialmente o código adquire o stack pointer atual. Veja no exemplo a seguir: E subtrai dele um valor de offset (que neste caso é zero). Então a memória do buffer é alocada no Heap - buffer = malloc(600); - e todo o buffer é preenchido com o endereço de retorno desejado: Em seguida, os primeiros 200 bytes do buffer são preenchidos com uma NOP sled (na linguagem de máquina para processadores com arquitetura x86, a instrução NOP é dada com 0x90): Depois, o shellcode é posicionado após à NOP sled, deixando o restante do buffer preenchido com o endereço de retorno. Visto que a última posição de um buffer é identificada por um byte nulo (zero), o buffer termina com zero: Por fim, o código chama uma outra função que executa o programa vulnerável e insere o buffer alterado: Executando o programa, o resultado na tela informaria que a posição do SFP é, por exemplo, o endereço Oxbffff978 e que o mesmo será também o endereço de retorno desejado. Criando exploits sem código de exploit A criação de códigos de exploit para violar e assumir o controle de programas é, sem dúvida alguma, uma técnica amplamente utilizada e muito eficaz. Todavia, a interação do hacker com o sistema a ser violado passa pela intermediação do compilador, que se encarregará de traduzir o código gerado em linguagem de máquina. A presença desse intermediário entre o hacker e a máquina é vista por muitos como uma limitação que reduz as possibilidades de interação. Utilizando determinadas linguagens de programação, é possível criar exploits diretamente em linha de comando, sem a necessidade de criar um programa para isso. Vejamos um exemplo: o comando print da linguagem Perl, quando usado com astúcia, pode ser uma ferramenta poderosa para violar programas. Saiba mais... Perl é uma linguagem de programação multiplataforma usada em aplicações de missão crítica em todos os setores, sendo destacado o seu uso no desenvolvimento de aplicações Web de todos os tipos. Foi criada por Larry Wall em dezembro de 1987. A origem do Perl remonta ao shell scripting, Awk e a linguagem C. Está disponível para praticamente todos os sistemas operacionais, embora seja usado mais comumente em sistemas Unix e compatíveis. Originalmente, o nome foi definido por Larry Wall em referência à parábola Pérola, de Mateus 13 (a grafia foi mudada de Pearl para Perl por já ter sido registrada por outra linguagem de programação). Algumas possíveis expansões fo ram posteriormente propostas, como Practical Extraction and Report Language e Pathologically Eclectic Rubbish Lister, este último tendo sido proposto pelo próprio Larry Wall, conhecido por sua personalidade sarcástica e criativa. 0 Perl é uma das linguagens preferidas por administradores de sistema e autores de aplicações Web. É especialmente versátil no processamento de cadeias (strings), manipulação de texto e no pattern matching implementado através de expressões regulares, além de consumir menor tempo de desenvolvimento. A linguagem Perl já foi portada para mais de 100 diferentes plataformas e é bastante usada em desenvolvimento Web, finanças e bioinformática. No geral, a sintaxe de um programa em Perl se parece muito com a de um programa em linguagem C. Existem variáveis, expressões, atribuições, blocos de código delimitados, estruturas de controle e sub-rotinas. Além disso, Perl foi bastante influenciado pelas linguagens de shell script, pois todas as variáveis são precedidas por um cifrão ($). Essa marcação permite identificar perfeitamente as variáveis de um programa, onde quer que elas estejam. Um dos melhores exemplos da utilidade desse recurso é a interpolação de variáveis diretamente no conteúdo de strings.0 Perl também possui muitas funções integradas para tarefas comuns como ordenação e acesso de arquivos em disco. Perl utiliza as listas de Lisp, as arrays associativas (tabelas hash) de awk e as expressões regulares de sed. Isso tudo simplifica e facilita qualquer forma de interpretação e tratamento de textos e dados em geral. A linguagem suporta estruturas de dados arbitrariamente complexas. Ela também possui recursos vindos da programação funcional (as funções são vistas como um outro valor qualquer para uma sub-rotina, por exemplo) e um modelo de programação orientada a objetos. Perl também possui variáveis com escopo léxico, que tornam mais fácil a escrita de código mais robusto e modularizado. Todas as versões do Perl possuem gerenciamento de memória automático e tipificação dinâmica. Os tipos e necessidades de cada objeto de dados no programa são determinados automaticamente; a memória é alocada ou liberada de acordo com o necessário. A conversão entre tipos de variáveis é feita automaticamente em tempo de execução e conversões ilegais são erros fatais. A linguagem Perl permite executar instruções "em-linha" utilizando a opção -e, como no exemplo a seguir: Com esse comando, solicitamos ao Perl que execute a instrução que está dentro das aspas que, neste caso, consiste em imprimir 50 vezes a palavra "Olá". Qualquer caractere pode ser impresso também utilizando a sintaxe \x?? (em que "??" é o código hexadecimal do caractere que desejamos imprimir); por exemplo, para imprimir a letra "A" 50 vezes, poderíamos usar esse comando: É possível também concatenar seqüências de caracteres usando o ponto (.), como no exemplo a seguir: Cujo resultado seria o seguinte: A substituição do comando basti é obtida com um acento grave (): qualquer comando (desde que válido) que se encontre dentro do conjunto de acentos graves é executado e o respectivo output é exibido na tela. Como vimos no último exemplo do tópico anterior, um código de exploit basicamente pega o stack pointer, altera um buffer e insere esse buffer alterado em um programa vulnerável. Com a ajuda do Perl, da substituição de comando e de um endereço de retorno aproximado, podemos iniciar um código de exploit na própria linha de comando, executando o programa vulnerável e utilizando os acentos graves para inserir um buffer modificado no primeiro argumento. Referindo-nos ao mesmo exemplo do tópico anterior (o Código de Exploit 1), deveremos primeiramente criar a NOP Sled: no código do exemplo, foram utilizados 200 bytes de NOP Sled (lembrando que o có digo hexadecimal para uma instrução NOP sled é 0x90). Veja como essa operação seria realizada em Perl: Então, o shellcode deverá ser adicionado no final da NOP sled. Em diversos casos pode se revelar extremamente útil armazenar o shellcode em um arquivo, então vamos fazê-lo. Visto que todos os bytes já estão escritos em hexadecimal na parte inicial do Código de Exploit 1, bastará escrevê-los em um arquivo, operação que pode ser realizada com a ajuda de um editor hexadecimal ou, mais simplesmente, usando o comando print do Perl e especificando como destino um arquivo, como mostrado no exemplo a seguir: Desta forma obtivemos um arquivo de nome Shellcode que contém nosso shellcode, que poderá ser inserido facilmente em qualquer local dentro do conjunto de acentos graves utilizando o comando cat. 0 exemplo a seguir mostra como adicionar o shellcode à NOP sled existente: Agora precisamos concatenar o endereço de retorno, repetindoo várias vezes. Neste ponto surge um problema: no Código de Exploit 1 do tópico anterior, o buffer foi preenchido desde o início com o endereço de retorno, o que garantiu que tal endereço fosse alinhado corretamente, pois é constituído de grupos de 4 bytes. Ao gerar um buffer de exploit na linha de comando em Perl, esse alinhamento deve ser feito manualmente. Isto significa que a quantidade de bytes da NOP sled mais o shellcode deve ser um número divisível por quatro. Entretanto, o shellcode é composto por 46 bytes e a NOP sled por 200: basta um rápido cálculo para descobrir que o total de bytes (246 no nosso caso) não é divisível por quatro, pois faltam 2 bytes para que isso seja possível. Isto acarreta que o endereço de retorno será desali nhado em 2 bytes, portanto, a sobrescrita dos bytes não será exata e a execução do programa irá parar em um endereço de memória inválido (ou vazio). Para esclarecer, a Figura 2.1 representa de maneira esquemática a diferença entre endereços de retorno desalinhados e corretamente alinhados. Figura 2.1: Em "a" os blocos de 4 bytes com os endereços de retorno estão desalinhados; em "b" os blocos estão corretamente alinhados e prontos para serem sobrescritos. Para resolver este problema, podemos acrescentar 2 bytes à NOP sled para que os endereços de retorno sejam alinhados e sobrescritos corretamente. Veja como fazer no exemplo a seguir: Veja que o comando para execução da NOP sled foi repetido 202 vezes, e não 200, como anteriormente; isto fez com que fossem criados mais 2 bytes, resolvendo o empecilho do alinhamento dos blocos de 4 bytes que formam o endereço de retorno. Agora que a primeira parte do buffer de exploit está alinhada corretamente, é preciso inserir no final o endereço de retorno repetido. Por meio do Código de Exploit 1, conhecemos a posição em que o apontador do stack frame se encontrava antes da execução e usamos essa informação para descobrir de maneira muito próxima a posição do endereço de retorno, que é Oxbffff978. Podemos usar o comando print do Perl para imprimir esse endereço usando: Note que a ordem dos bytes de endereço está invertida devido ao sistema de ordenamento dos bytes no modo little indian na arquitetura x86. Visto que o comprimento desejado para o buffer de exploit é de aproximadamente 600 bytes e a NOP sled e o shellcode ocupam 248 bytes, com um rápido cálculo descobrimos que o endereço de retorno deve ser repetido 88 vezes, operação que pode ser realizada utilizando o seguinte comando Perl: Explorando as variáveis do ambiente de execução Pode ocorrer que o espaço de memória alocado para um buffer seja pequeno demais para hospedar um shellcode. Em casos como esse é possível esconder o shellcode em uma variável do ambiente. As variáveis de ambiente são utilizadas normalmente pelo sistema para diversas operações, sendo que o mais importante é que essas variáveis são armazenadas em uma região da memória para a qual pode ser endereçada a execução do programa. Portanto, se um buffer é muito pequeno para conter a NOP sled, o shellcode e o endereço de retorno repetido para a substituição, podemos armazenar a NOP sled e o shellcode em uma variável do ambiente e alterar o endereço de retorno para que aponte para o endereço de memória que contém a variável do ambiente na qual escondemos o shellcode. 0 exemplo a seguir traz um trecho de código vulnerável, que chamaremos MyProgram - 1, no qual é utilizado um buffer muito pequeno para o shellcode: Agora, vejamos como compilar esse código e definir o suid root que o torne realmente vulnerável: Observando o código do MyProgram _ 1 podemos reparar que o buffer é de apenas 5 bytes - charbuffer[5];) - o que nos impede de inserir o shellcode, que deverá ser armazenado em outro lugar. No Código de Exploit 1, usamos a função execl() para executar o programa vulnerável com o buffer alterado no primeiro exploit. Existe outra função, chamada execleO, praticamente idêntica, porém, com um argumento a mais que identifica o ambiente no qual deve acontecer o processo de execução do programa. Esse ambienteé representado por um vetor de ponteiros e linhas de texto (strings) que terminam com um byte nulo (zero), um para cada variável do ambiente. 0 próprio array (conjunto) termina com um ponteiro nulo. Podemos explorar essa estrutura para criar um shellcode usando um conjunto de ponteiros, o primeiro dos quais aponta para o shellcode, e o segundo constituído por um ponteiro nulo. Assim, podemos chamar a função execre() utilizando esse ambiente para executar o segundo programa vulnerável, sobrescrevendo o endereço de retorno com o endereço da variável de ambiente que contém o shellcode. 0 cálculo para descobrir o endereço da variável não é complexo: em sistemas Linux, o endereço será: Visto que, neste caso, obteremos um endereço exato e não aproximado, não será necessário o uso da NOP sled. 0 que acontece no buffer de exploit é que o endereço é repetido um número de vezes suficiente para fazer com que o endereço de retorno no Stack seja sobrescrito corretamente (são suficientes 40 bytes para realizar esta operação). Veja o código: Quando o programa é compilado e executado, o resultado é o seguinte: Essa mesma técnica pode ser utilizada mesmo sem nenhum programa de exploit. Na Shell bash as variáveis de ambiente são definidas e exportadas usando o comando export varname=value. Usando o export, o Perl e os acentos graves, é possível inserir no ambiente o shellcode e uma NOP sled de grandes dimensões. Veja a seguir: 0 próximo passo consiste em descobrir o endereço desta variável de ambiente, o que pode ser feito utilizando um debugger (como o gdb, por exemplo) ou mais simplesmente escrevendo um pequeno código que faça isso para nós. Vejamos ambas as maneiras. Descobrindo o endereço da variável de ambiente com a ajuda do debugger 0 debugger (depurador de código) permite abrir o programa vulnerável e inserir um ponto de interrupção logo no início do código. Desta forma, a execução do programa começará, mas será interrompida antes que qualquer operação seja realizada. Então, pode-se examinar atentamente a memória a partir do stack pointer e deslocar-se para frente usando o comando gdb x/20s $esp; isso fará com que sejam exibidas as vinte linhas de memória após a posição atual do ponteiro do Stack. 0 "x" na linha de comando significa examinar, 20s indica que queremos ver 20 linhas (strings) que terminem por NULL (nulo). Repete-se a operação até encontrar a linha com a variável de ambiente na memória. Em seguida, realizamos a depuração (debug) do programa MyProgram 1 com gdb para examinar o Stack e en contrar o shellcode armazenado na variável de ambiente SHELLCODE: Após encontrarmos o endereço no qual é armazenada a variável do ambiente SHELLCODE, usamos o comando x/s para examinar a linha em questão que, no nosso caso, se refere ao endereço de memória Oxbffffce5. 0 endereço, porém, inclui a linha SHELLCODE=, portanto, será preciso adicionar 16 bytes ao endereço para obter um endereço localizado em um local indefinido da NOP sled. 0 debugger (depurador) detectou que o endereço Oxbffffce5 encontra-se muito próximo ao início da NOP sled, e o shellcode está armazenado na variável de ambiente SHELLCODE. De posse dessas informações e recorrendo mais uma vez à ajuda do Perl e dos acentos graves, podemos criar o exploit do programa vulnerável MyProgram _ 1, como mostra o exemplo a seguir: Descobrindo o endereço da variável de ambiente com a criação de um programa Para obter o endereço de uma variável de ambiente, podemos também escrever um simples programa de ajuda, chamado helper. Neste programa utiliza-se a função getenv() para localizar o primeiro argumento da variável do ambiente. Caso a função não encontre nada, será exibida uma mensagem, mas se a variável for localizada corretamente, seu endereço será exibido. Veja um exemplo da estrutura do código de um helper: Então, basta mandar compilar e executar o programa para que o endereço da variável de ambiente SHELLCODE seja encontrado e exibido na tela. Veja como proceder observando a seqüência de comandos a seguir: Comparando o resultado obtido pelo depurador gdb com o que acabamos de obter com o helper nota-se que os endereços são um pouco diferentes: Tabela 2.4. Isso acontece porque o contexto em que o helper atua é diferente daquele em que é executado o programa vulnerável. Contudo, neste caso específico, os 100 bytes da NOP sled que utilizamos são suficientes para compensar essas pequenas divergências nos resultados. Para conseguirmos descobrir com precisão o endereço de memória, é preciso analisar a diferença entre os endereços obtidos. Sabemos, por exemplo, que o comprimento do nome do programa em execução influencia em parte o endereço das variáveis do ambiente. Então, podemos alterar o nome do programa helper e realizar experiências até obter o mesmo resultado nos dois métodos. Realizar experiências (e ter muita paciência) é fundamental para que um hacker consiga chegar até o resultado desejado, pois geralmente são necessárias inúmeras tentativas até conseguir injetar um código malicioso em um programa vulnerável. No próximo capítulo aprenderemos outras técnicas para causar um overflow nas áreas Heap e BSS da memória. Caso precise rever esses conceitos, leia novamente o tópico Sobre as seções da memória de programa, neste capítulo. Sobre os overflows baseados em heap e BSS No capítulo anterior vimos como criar exploits que causam overflow na área do buffer do stack da memória do programa. Existem também vulnerabilidades relacionadas aos overflows dos buffers das seções heap e BSS - para saber mais sobre as seções da memória, leia o tópico Sobre as seções da memória de programa do Capítulo 2. Esses tipos de overflow não são padronizados, como os do stack, mas quando usados corretamente se tornam muito eficazes. No caso de overflows baseados em heap e BSS, não há endereço de retorno a ser sobrescrito; portanto, seu funcionamento se baseia no registro de variáveis importantes na memória logo após um buffer que pode sofrer overflow, ou seja, um transbordamento de dados. Imagine, por exemplo, uma variável fundamental do sistema que armazena as permissões de acesso dos usuários ao sistema ou do status de um login, se esta variável for armazenada logo após um buffer de memória sujeito a overflow, podemos sobrescrever o conteúdo da variável alterando seu conteúdo e, neste caso, alterando as permissões de acesso. Ou, ainda, se um ponteiro de função for armazenado após um buffer sujeito a overflow, podemos sobrescrever o endereço para o qual ele aponta, enviando a execução do programa para a execução do shellcode quando a função for chamada. É de suma importância entender que o funcionamento dos exploit de overflow baseados em heap e BSS dependem fundamentalmente do layout da memória do programa e, por esse motivo, identificar os pontos fracos é um pouco mais complexo. Nos tópicos a seguir veremos alguns códigos de exemplo para compreender o funcionamento desses exploits. Como no capítulo anterior, lembramos ao leitor que todos os exemplos citados foram obtidos realizando uma busca cuidadosa em sites livres específicos na Internet, sinta-se à vontade para buscar outros exemplos, aprimorando assim seu aprendizado. Overflow baseado no heap Neste tópico veremos um simples programa sujeito ao overflow da seção heap. 0 que interessa nesse caso não é o funcionamento do programa em si (que chamaremos MyProgram_2), pois se trata apenas de um exemplo, e sim identificar os pontos fracos que o tornam vulnerável a ataques hacker. Observe atentamente o código a seguir: Analisemos a estrutura do código para compreender seu funcionamento. Tudocomeça com a declaração das variáveis - como de praxe - em seguida é alocado o espaço de memória no heap (neste caso, 20 bytes): Mais adiante, o espaço de memória alocado é preenchido com dados: Durante a execução do programa serão exibidas algumas mensagens de debug (depuração), como mostra o trecho de código a seguir: Então, o arquivo recebe dados e em seguida, é aberto: Uma estrutura condicional se encarrega de verificar se o arquivo criado está vazio (NULL) e, caso esteja, exibirá uma mensagem avisando que houve erro ao abrir o arquivo: No prompt de comando do sistema, bastará compilar o programa, definir o suid como root e executar para ver o resultado: Como dissemos, trata-se de um programa extremamente simples que aceita apenas um argumento e insere o seu conteúdo no arquivo /tmp/notes. Contudo, apesar da simplicidade de sua estrutura, o programa myprogram_2 apresenta uma característica que merece a nossa atenção: a memória para a variável userinput é alocada no heap antes da memória para a variável outputfile, e as informações de depuração (debug) exibidas durante a execução ajudam a entender melhor isso, pois userinput encontra-se na posição 0x8049840, enquanto inputfile está na posição 0x80498e8. A distância entre os dois endereços (distance between) é de 24 bytes e, visto que o primeiro buffer é encerrado com NULL, a quantidade máxima de dados que podem ser inseridos nesse buffer sem que haja overflow deverá ser de 23 bytes. Se tentássemos preencher o buffer da variável userinput com mais de 23 bytes, os bytes excedentes iriam transbordar, invadindo o buffer de outputfile e gerando um resultado diferente do esperado pelo programador, como no exemplo a seguir: Na primeira linha de comando, iniciamos o programa myprogram_2 especificando como argumento um texto de 35 bytes (muito além dos 23 que ele suporta). Conseqüentemente, o trecho "texto extra", que não coube no buffer, transbordou invadindo o buffer de outputfile, o que resultou na gravação dos dados não em /tmp/ notes, como se queria, mas em um arquivo que foi nomeado "texto-extra". Isso ocorreu porque uma linha é lida até encontrar um byte null, portanto, toda a linha digitada como argumento é gravada no arquivo como userinput. Visto que myprogram_2 é um programa suid que serve para acrescentar dados a um arquivo específico, podemos inserir no arquivo qualquer dado, desde que indiquemos corretamente o nome do arquivo no qual essas informações devem ser adicionadas. Uma maneira interessante de explorar essa falha é adicionar informações no arquivo /etc/passwd, em sistemas Linux. Este arquivo contém todos os nomes de usuários, os ID e as consoles de login para todos os usuários com acesso ao sistema, portanto, basta inserir nele um novo ID para ganhar o acesso ao sistema mesmo não possuindo um login. Os campos do arquivo de sistema /etc/passwd são delimitados com um caractere dois-pontos (:) e sua estrutura é a seguinte: •o primeiro campo é o nome de login; •o segundo campo contém a senha (password); •o terceiro armazena o User ID (uid); •o quarto campo é o Group ID (gid); •o quinto campo é o Username (nome do usuário); •o sexto campo contém o diretório principal do usuário home); •o sétimo campo é a console (shell) de login. Os campos com as senhas contêm apenas caracteres "x", pois são criptografados e o conteúdo real é armazenado em outro local em um arquivo shadow. Isso pode parecer um problema, mas sua solução é extremamente simples: quando o campo é deixado vazio, nenhuma senha é solicitada. Além disso, para qualquer item em que o usuário tenha o User ID "0" (zero) são concedidos os privilégios de root. Então, nosso objetivo será inserir no arquivo /etc/passwd um usuário adicional com privilégios de root e sem a necessidade de informar senha para o acesso; para isso, a linha a ser inserida no arquivo seria como mostra a Figura 3.1 a seguir: Figura 3.1: A linha de comando que usaremos para adicionar um novo usuário no arquivo /etc/passwd. A tipologia desse exploit baseado no overflow do Heap não permitirá inserir a linha de comando como mostra a Figura 3.1 exatamente como ela é, pois a linha deve terminar com o nome do arquivo no qual as informações devem ser inseridas, neste caso /etc/passwd; por outro lado, se acrescentássemos o nome do arquivo à linha de comando, obteríamos um erro, pois a sintaxe do comando ficaria errada. Podemos contornar esse problema usando um link simbólico ao arquivo, de maneira que a linha de comando possa terminar com /etc/passwd e, ao mesmo tempo, ser uma linha válida para o arquivo das passwords. Veja, no exemplo a seguir, como fazer: Assim, o arquivo /tmp/etc/passwd aponta para a shell de login /,bin/ bash e, portanto, passa a ser também uma shell de login válida para o arquivo das senhas. Utilizando esse truque, a linha de comando... ...passa a ser uma linha válida para preencher um registro do arquivo de senhas. Precisamos apenas realizar uma pequena modificação de maneira que a parte que antecede /etc/passwd, ou seja, newuser::0:0:eu:/root:/tmp tenha comprimento de exatos 24 bytes (agora tem 26); então, vamos alterá-la para: Pronto! A nossa linha está pronta para ser inserida no programa vulnerável ao overflow de heap. Lembre-se que estamos explorando o overflow para inserir no arquivo com a lista de usuários e senhas de acesso um novo usuário sem senha e com todos os privilégios de root (super-usuário administrador do sistema). Então, vejamos como isso é feito analisando o código a seguir: Desta forma, criamos um novo usuário cujo nome de login é nuser que pode acessar o sistema sem necessidade de informar senha e que possui todas as permissões de acesso do super-usuário root. Com essa conta o hacker assumirá o controle total sobre o sistema. Overflow baseado nos ponteiros de funções Neste tópico trataremos dos overflows realizados na seção de memória chamada BSS. Para contextualizar o uso desse tipo de exploit, exemplificaremos seu uso no código de um simples jogo baseado nas probabilidades (que chamaremos MyGame_1): para jogar são necessários 10 pontos iniciais e o objetivo do jogo é adivinhar um número de 1 a 20 escolhido randomicamente pelo programa, se acertarmos ganharemos 100 pontos (seremos informados constantemente sobre a nossa pontuação por meio de mensagens que aparecerão na tela). Vejamos o código a seguir: Observação: o trecho de código que subtrai os pontos do saldo estaria aqui, no local desta caixa, ele foi omitido, pois é irrelevante para o nosso exemplo. Observação: o trecho de código que soma os 100 pontos ao saldo estaria aqui, no local desta caixa, ele foi omitido, pois é irrelevante para o nosso exemplo. Vamos analisar as partes do código para compreender melhor o funcionamento desse programa. Como sempre, primeiramente são inicializadas as variáveis e definidos os tamanhos dos buffers. É exibido também um pequeno texto que informa ao usuário o que deve fazer e como obter ajuda (devido ao uso do comando printf): Então o randomizador é zerado e o ponteiro de função é definido de maneira que aponte para a função game: Logo em seguida, são exibidas na tela algumas mensagens de debug, úteis para manter-nos informados sobre o que está ocorrendo durante a execução do código: Agora o programa verifica se o usuário utilizou os argumentos "help" ou "-h" para que seja exibido o texto de ajuda do programa. Se o usuário digitou "help" ou "-h" logo após o nome do programa, então o texto será mostrado. Caso contrário será chamada a função game por meio do ponteiro de função: Finalmente o jogo começa. 0 programa verifica se o usuário digitou um número válido de 1 a 20, pois,caso contrário, será exibida uma mensagem solicitando a digitação de um número válido: Então é sorteado um número randômico, que é comparado com o número digitado pelo usuário como argumento do programa MyGame_1. Se o número sorteado for igual ao do usuário, é iniciada a função jackpot, caso contrário o usuário receberá uma mensagem informando que perdeu o jogo: A função jackpot avisa o usuário que ele ganhou e soma 100 pontos ao saldo: Vejamos o que aconteceria na tela do nosso computador ao compilar e executar o programa (os comandos dados estão destacados em negrito): Ajuda: Como dissemos, trata-se de um simples programa de exemplos, sem utilidade prática. Entretanto, há um aspecto desse código que merece a nossa atenção: o buffer é declarado de maneira estática antes do ponteiro de função que também foi declarado como estático: Isto significa que ambos os elementos (buffer e ponteiro) encontram-se na seção BSS da memória do programa. As informações de debug mostradas durante a execução indicam que o buffer está na posição 0x8049c74 e o ponteiro de função está no endereço 0x80049c88: a diferença entre os dois endereços é de 20 bytes. Então, se inserirmos 21 bytes no buffer, o vigésimo primeiro irá transbordar (overflow) no ponteiro de função, como mostra o exemplo a seguir: Observe (em negrito) o overflow de dados. 0 "jogo" foi executado duas vezes e em ambas informamos um argumento inválido e maior do que 20 bytes. No primeiro caso, o vigésimo primero byte é o byte nulo (zero) que encerra a linha e, visto que o ponteiro da função é gravado ordenando seus bytes com sistema "little indian", o byte menos significativo é sobrescrito com 0x00, por isso recebe o novo endereço 0x8048600. 0 resultado ("illegal instruction") é dado porque nesse endereço não há nenhuma instrução válida para o ponteiro de função. Se outro byte transbordar do buffer da BSS, o byte nulo se desloca para a esquerda e o vigésimo segundo byte sobrescreve o byte menos significativo do ponteiro de função. Na segunda execução, usamos a letra "A", que em hexadecimal corresponde a 0x41. 0 exemplo a seguir demonstra que não só é possível sobrescrever partes do ponteiro de função, mas também permite controlá-lo. Se o hacker fizer com que quatro bytes de dados transbordem da BSS para o ponteiro de função, ele poderá sobrescrever todo o endereço do ponteiro. Veja a seguir: Como podemos observar, o ponteiro da função é sobrescrito com os caracteres "ABCD", representados em hexadecimal e em ordem inversa - de acordo com o esquema "little indian" - pelos valores correspondentes, como mostrados na Tabela 3.1 a seguir: Tabela 3.1: Representação em hexadecimal e em ordem inversa - de acordo com o esquema "little indian". Tanto nesse último exemplo quanto na segunda tentativa do exemplo anterior, o programa trava, devido a um erro de segmentação, pois tenta deslocar o ponteiro de função para um endereço onde não há função alguma. Todavia, a partir do momento que é possível controlar o ponteiro de função, é possível controlar também o fluxo de execução do programa, bastando para isso inserir no lugar de "ABCD" um endereço válido, no qual está armazenada uma função qualquer a ser chamada pelo código na hora certa. 0 comando nm lista os símbolos contidos nos arquivos especificados e pode ser utilizado para descobrir os endereços das funções de um determinado programa, como mostra o exemplo a seguir: Afunção jackpot() é um alvo perfeito para um eventual exploit: as probabilidades de ganhar o jogo são totalmente desfavoráveis para o jogador, mas se o ponteiro da função for sobrescrito com o endereço da função jackpot(), o jogo sequer começará, pois logo no início será chamada a função para somar 100 pontos ao saldo do jogador. Para causar o overflow do buffer da BSS de modo que o ponteiro da função pule para o endereço 0804871c (onde se encontra a função jackpot(), destacada em negrito na listagem anterior), podemos usar o seguinte comando, que emprega o comando printf entre acentos graves: Repare que logo após as mensagens de debug, o código pulou diretamente para a execução da função jackpot, somando facilmente 100 pontos para o nosso saldo, sem depender da sorte. A vulnerabilidade desse jogo seria ainda maior se o programa fosse definido como suid root, da seguinte maneira: De fato, agora que o programa é executado como root, é possível controlar seu fluxo de execução, o que pode permitir obter facilmente uma shell de root no sistema. Para isso, podemos armazenar o shellcode em uma variável de ambiente: Caso o leitor queira rever como utilizar variáveis de ambiente para armazenar o shellcode, consulte novamente o tópico Explorando as variáveis do ambiente de execução, no Capítulo 2 deste livro. Vejamos no exemplo a seguir como fazer: Geralmente, o conceito de buffer overflow é bastante simples: podemos fazer com que os dados sejam colocados além do limite predefinido e aproveitar esta característica para modificar o fluxo do programa e, com isso, seu resultado. A criação de exploits baseados no format string é uma técnica razoavelmente nova e adotada pelos hackers recentemente. Assim como ocorre com os exploits baseados no overflow do buffer, essa técnica também visa sobrescrever dados para assumir o controle do fluxo de execução de programas. Esses exploits se baseiam também em erros de programação que, ao menos aparentemente, não parecem afetar diretamente a segurança do programa. Todavia, desde que essa técnica se tornou conhecida no mundo dos desenvolvedores, ficou mais fácil identificar e corrigir os pontos fracos responsáveis pelas vulnerabilidades relacionadas a format string. Os format string são empregados por funções que prevêem a formatação, como por exemplo, a função printf(). Tratam-se, geralmente, de funções que aceitam um format string como primeiro argumento, seguido pelos demais parâmetros que dependem do próprio format string. Nos exemplos dos capítulos anteriores utilizamos com freqüência a função printf 0, como no trecho de código mostrado a seguir, que foi retirado do programa mygame_1 usado no Capítulo 3: Nessa linha de código o format string é "Você escolheu o número %d\n". A função exibe na tela essa linha e, ao mesmo tempo, realiza uma operação quando encontra o parâmetro %d, fazendo com que o argumento sucessivo à função seja exibido como um número inteiro decimal. Veja na Tabela 4.1 a seguir, outros tipos de formato similares: Tabela 4.1: Outros tipos de formato similares. Todos os parâmetros de formato adquirem os dados como valores, não como ponteiros de valores. Há também outros parâmetros de formato que exigem o uso de ponteiros de valores, como os listados a seguir (Tabela 4.2): Tabela 4.2: Outros parâmetros de formato que exigem o uso de ponteiros de valores. 0 parâmetro de formato %s espera receber um endereço de memória e exibe os dados relativos a esse endereço até encontrar um byte nulo. 0 parâmetro 9.n também precisa receber o endereço de memória a ser analisado para, em seguida, retornar o número de bytes escritos até o momento no endereço de memória especificado. Funções como printf() analisam o valor de format string e realizam determinada operação todas as vezes que é encontrado um parâmetro de formato: cada parâmetro de formato, por sua vez, necessita da transferência de uma variável: se, por exemplo, houver três parâmetros de formato em um format string, serão necessários outros três argumentos para a função. Vejamos, no código exibido a seguir, um
Compartilhar