Crash causado por corrupção de memória de heap [Crash por Heap Corruption]


Por: Roberto Alexis Farah

Nesse artigo explicarei sobre exceções fatais que ocorrem devido a corrupção de memória de heap.

Esse é um dos mais difíceis problemas para se diagnosticar porque é intermitente, de difícil reprodução e requer ferramentas adicionais para investigá-lo.

Aliado a isso, problemas de corrupção de heap podem, num primeiro instante, levar o desenvolvedor a acreditar que a causa raiz está num lugar quando, na verdade, se encontra em outro lugar. No decorrer do artigo explicarei porque isso pode acontecer.

O que é o Heap?

Tecnicamente falando o heap é uma região de uma ou mais páginas de memória que pode ser dividida e alocada em pequenos blocos.

Todo processo sempre tem um default heap, chamado o heap do processo. Entretanto, mais heaps podem existir e eles são limitados somente pela memória virtual disponível.

Cada bloco de memória ocupada do heap tem um cabeçalho (header) de 8 bytes representado pela estrutura _HEAP_ENTRY que, como numa lista duplamente ligada, aponta para o próximo bloco e para o bloco anterior.

Após esse header há outro header de 8 bytes do tipo _LIST_ENTRY quem também é uma lista duplamente ligada. Esse header é usado para apontar para os segmentos de memória livres.

Por segmento me refiro a um bloco contínuo de memória virtual.

Como curiosidade quem controla essas listas do heap é o heap manager que é um componente interno do Windows.

O heap é usado quando, por exemplo, usamos funções como malloc via C ou new via C++, ou seja, quando fazemos alocação dinâmica de memória. Além dessas chamadas, há outras API’s públicas do Windows que interagem com o heap, portanto, mesmo em Visual Basic 6 é possível se fazer uma aplicação que faz uso da memória de heap, ou, em alguns casos, mal uso da memória de heap. J

O que significa Heap Corruption ou Corrupção de Heap?

Ótimo, agora que dei uma visão geral do heap vou explicar o que é uma corrupção de heap, ou, como chamamos aqui, heap corruption.

Corrupção de heap é quando escrevemos além do limite inferior ou superior da área reservada para nosso dado.

Imagine, por exemplo, uma lista ligada que armazene strings cujo tamanho máximo é 100 caracteres não UNICODE mais o espaço do terminador NULL.

Se você escrever antes da região de memória onde o buffer foi alocado, a alocação vai sobreescrever o header do heap. Se a alocação passar o tamanho do buffer mas iniciar no endereço correto então você vai sobreescrever o próximo bloco de memória, seja ele livre ou ocupado.

Na imensa maioria dos incidentes que lidamos aqui a corrupção ocorre por ultrapassar a área de dados, como por exemplo, quando você insere 150 caracteres em um nó da lista ligada mencionada acima que aceita apenas 101 caracteres. Esse tipo de corrupção é mais comum porque é mais fácil de ser causado, basta sobreescrever além da região alocada.

Agora você pode estar se perguntando porque esse problema é tão difícil de isolar se quando a memória for invadida uma exceção fatal (crash) vai ocorrer.

Esse é o problema! A exceção não vai ocorrer na maioria das vezes salvo se invadir alguma região de memória que estiver como acesso proibido, o que é bem raro diga-se de passagem, ou quando um bloco de memória que foi corrompido devido a informação de outro bloco adjacente que ultrapassou o limite, tenta ser liberado.

Explicando mais detalhadamente: a exceção será causada não quando a memória de heap estiver sendo invadida mas quando, por exemplo, a aplicação está liberando um bloco de memória que não foi o causador da corrupção, mas sim, o bloco que foi invadido pela corrupção. Em outras palavras, quando sua aplicação C/C++ estiver usando free ou delete para liberar algo previamente alocado!

Acompanhe comigo… De modo bem simplificado quando a aplicação faz uma chamada para liberar memória o heap manager vai localizar o bloco de memória marcado como busy para desconsiderá-lo da lista de blocos ocupados e anexá-lo a lista de blocos livres. Portanto, ele vai percorrer listas duplamente ligadas cujos nós contém cabeçalhos. Um desses cabeçalhos foi corrompido quando a memória foi invadida, assim, quando o bloco contendo esse cabeçalho for acessado haverá um crash por access violation!

Logo, a chamada de função que causou o crash não é, na grande maioria das vezes, a chamada que corrompeu a memória num primeiro instante.

Pior, na maioria dos incidentes, como os de IIS e ASP, a dll liberando a memória corrompida não é a mesma dll que corrompeu a memória!

Isso explica porque problemas de heap corruption são difíceis de serem isolados. Entretanto, algumas ferramentas tornam esse trabalho mais simples.

Estratégia para se identificar um Heap Corruption

A estratégia para se identificar um heap corruption baseia-se em fazer a aplicação sofrer uma exceção por violação de acesso (access violation) sempre que ela tenta invadir memória do heap além da região alocada e não quando ela acessa a região corrompida. Em termos técnicos, quando um commit de dados de tamanho “x+n” está sendo efetuado em um bloco de memória tamanho “x”, onde os “n” bytes a mais podem estar entrando antes do início do buffer ou após o final dele.

Como fazemos isso? Para fazer isso é necessário se usar algumas ferramentas que mudam algumas chaves de registry do heap de modo a fazer com que determinada aplicação ao alocar memória dinamicamente também aloque “n” bytes logo antes e/ou depois do bloco de memória alocado e, além disso, marque esses bytes adicionais como acesso negado.

Pegou o espírito da coisa, né? Como a memória adicional tem status de acesso negado quando houver uma tentativa de commit dos dados nessa região haverá uma exceção por access violation na hora.

Atente para o fato que a aplicação vai consumir mais memória durante o período de investigação, devido aos bytes adicionais que serão colocados para cada alocação.

Essa é a primeira parte, a segunda é que além da ferramenta que mude os flags do heap é necessário outra ferramenta, um depurador, que colete um dump quando essa exceção ocorrer. Usando essa estratégia teremos um dump quando houver uma tentativa de corrupção de memória de heap. J

O termo que costumamos usar para as ferramentas que colocam os bytes protegidos acima ou abaixo (ou ambos) de cada alocação é chamado Page Heap.

Uma curiosidade: aqui costumamos dizer coisas como “habilite o page heap com Gflags” ou ainda “use DebugDiag e habilite o Page Heap” ou “habilite o Page Heap”. Isso pode parecer confuso de início, mas na realidade todas as ferramentas que cito abaixo habilitam uma página de heap protegida contra escrita, portanto, habilitam o page heap.

FERRAMENTAS

Eis algumas das mais populares ferramentas que utilizamos para mudar os flags de alocações do heap para determinada aplicação:

- Application Verifier

- Gflags que é parte do Debugging Tools For Windows

- PageHeap

- DebugDiag que pode ser encontrado em diferentes links

Essas ferramentas além de colocar como acesso negado alguns bytes de memória antes e/ou depois da alocação elas preenchem as alocações do usuário com bytes de determinado padrão:

- Blocos liberados: 0xF0F0F0F0

- Blocos alocados: 0xE0E0E0E0 para normal page heap e 0xC0C0C0C0 para full page heap.

Normal Page Heap: De modo simplificado não usa páginas protegidas, ao invés, confia em padrões de bytes para determinar a causa. Essa abordagem usa menos memória, mas não é tão eficiente para se isolar o problema.

Full Page Heap: De modo simplificado usa páginas de memória protegidas contra escrita que são colocadas logo antes e/ou logo depois do bloco de memória do usuário. Com Full Page Heap habilitado a aplicação sofre um crash no momento que a memória alocada está tentando colocar dados além da região alocada.

Nota: Na grande maioria das vezes que for isolar um problema de heap corruption use Normal Page Heap pois consome menos memória. Além disso, é muito raro ter um heap underflow, mas muito comum ter um heap overflow, ou seja, se escrever além do bloco de memória alocado.

EXEMPLO

Para exemplificar como ocorre uma corrupção de heap e como identificar a causa raiz do problema, usaremos uma aplicação bem simples.

Primeiro mostrarei a execução normal da aplicação que corrompe o heap, em seguida, mostrarei a execução com Page Heap habilitado.

Depois usarei o DebugDiag para coletar um dump e analisá-lo automaticamente. Isso é tudo que você precisa saber para usar no dia a dia.

CÓDIGO PARA DEMONSTRAÇÃO

PROTOTYPES.H

#ifndef __PROTOTYPES_H__

#define __PROTOTYPES_H__

// Definição da estrutura de dados da lista.

typedef struct tagList

{

          // Se o ponteiro estivesse após o buffer, um buffer overflow iria corromper

          // o ponteiro para o próximo nó.

          tagList* pstNext;

          char szData[21]; // Espaço para 20 chars mais NULL.

} stList;

// Protótipos de funções.

int  Insert(stList** ppstHead, char* pszValue);

void RemoveAll(stList** ppstHead);

#endif;

LINKEDLIST.CPP

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

#include "prototypes.h"

int Insert(stList** ppstHead, char* pszValue)

{

          stList* pstNewElement;

         

          // Aloca espaço para novo elemento.

          pstNewElement = (stList*) malloc(sizeof(stList));

         

          if(! pstNewElement)

                   return 0;

         

          // Aqui colocamos dados em um nó da lista.

          pstNewElement->pstNext = *ppstHead;

         

          // A rotina strcpy do C runtime é um furo de segurança.

          // Numa aplicação real use strcpy_s().

          strcpy(pstNewElement->szData, pszValue);

         

          *ppstHead = pstNewElement;

         

          return 1;

}

void RemoveAll(stList** ppstHead)

{

          stList* pstNode = NULL;

         

          if(*ppstHead)

          {

                   while(*ppstHead)

                   {

                             pstNode = (*ppstHead)->pstNext;

                                                         

                             free(*ppstHead);

                            

                             *ppstHead = pstNode;

                   }

                  

                   *ppstHead = NULL;

          }

}

HEAPCORRUPTION.CPP

#include "stdafx.h"

#include <conio.h>

#include "prototypes.h"

int _tmain(int argc, _TCHAR* argv[])

{

          stList* pstHead = NULL;

          char szValue[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZA";

         

          for(int i = 0; i < 9999; i++)

          {

                   Insert(&pstHead, szValue);

          }

          printf("Pressione alguma tecla para remover os elementos da lista.\n");

          _getch();

          printf("REMOVENDO ELEMENTOS DA LISTA...\n");

         

          RemoveAll(&pstHead);

          return 0;

}

Nota: Heap corruptions são bastante imprevisíveis. Entretanto, o sintoma esperado ao rodar a aplicação na versão Release é.... nada. O heap será corrompido mas apenas na região que faz parte da alocação portanto será difícil ocorrer um crash. Se vocês aumentarem tremendamente o tamanho da string então o problema poderá ocorrer durante a alocação. Infelizmente, na prática dificilmente um heap é corrompido por uma grande quantidade de bytes. Frequentemente é um número pequeno de bytes. Se você rodar a versão Debug a aplicação vai proceder como se estivesse com Page Heap em modo Normal. Um aviso será mostrado quando a região de memória contendo mais dados do que deveria está tentando ser desalocada.

DEPURAÇÃO COM PAGE HEAP DESABILITADO

Função Insert():

ChildEBP RetAddr

0012fe4c 00411439 HeapCorruption!Insert+0x35

0012ff68 00411c06 HeapCorruption!wmain+0x69

0012ffb8 00411a4d HeapCorruption!__tmainCRTStartup+0x1a6

0012ffc0 7c816fd7 HeapCorruption!wmainCRTStartup+0xd

0012fff0 00000000 kernel32!BaseProcessStart+0x23

malloc() retorna 28 bytes que são:

HeapCorruption!stList

   +0x000 pstNext : Ptr32 tagList ß 4 bytes tamanho do ponteiro.

   +0x004 szData : [21] Char ß 24 bytes por causa do alinhamento que por default são 8 bytes.

Após a alocação temos para pstNewElement:

Local var @ 0x12fe44 Type tagList*

0x00357910

   +0x000 pstNext : 0xcdcdcdcd tagList ß 0xCD = alloCated Data, usado na versão Debug.

   +0x004 szData : [21] "???"

A região de memória do campo szData antes de receber a string é:

00357914 cdcdcdcd cdcdcdcd cdcdcdcd cdcdcdcd

00357924 cdcdcdcd cdcdcdcd fdfdfdfd abababab ß 0xFDFDFDFD indica o limite da alocação. Fence Data.

00357934 abababab 00000000 00000000 000b00d8

00357944 00ee14ee 003547d0 00350178 feeefeee ß 0xFEEEFEEE indica memória livre. Lembre de free.

Colocando em caracteres temos:

00357914 44434241 48474645 4c4b4a49 504f4e4d ABCDEFGHIJKLMNOP

00357924 54535251 58575655 00415a59 abababab QRSTUVWXYZA.....

00357934 abababab 00000000 00000000 000b00d8 ................

Após o commit, onde colocamos a informação na memória alocada temos:

00357914 44434241 48474645 4c4b4a49 504f4e4d

00357924 54535251 58575655 00415a59 abababab ß Sobreescrevemos os bytes que delimitam o fim da alocação.

00357934 abababab 00000000 00000000 000b00d8

00357944 00ee14ee 003547d0 00350178 feeefeee

Acabamos de corromper o heap por alguns poucos bytes. Nenhum crash de aplicação.

Agora ppstHead possui:

0x0012ff5c

 -> 0x00357910

   +0x000 pstNext : (null)

   +0x004 szData : [21] "ABCDEFGHIJKLMNOPQRSTU" ß O espaço para o terminador NULL foi sobreescrito. Isso por si só pode gerar problemas graves.

Todos os items da lista ligada foram corrompidos porque estão usando a mesma string. Nesse ponto deixei o depurador rodando até alcançarmos o getch() .

A partir de getch() a aplicação exemplo libera a memória previamente alocada no heap:

0x0012ff5c ß Ponteiro head, o primeiro da lista.

 -> 0x004ed5a8

   +0x000 pstNext : 0x004ed550 tagList

   +0x004 szData : [21] "ABCDEFGHIJKLMNOPQRSTU"

Observem que o ponteiro pstNext não está corrompido porque está antes do buffer szData. Eis o próximo nó para ilustrar:

0x004ed550

HeapCorruption!tagList

   +0x000 pstNext : 0x004ed4f8 tagList

   +0x004 szData : [21] "ABCDEFGHIJKLMNOPQRSTU"

Agora vem a chamada de free(*ppstHead) .

Como estou depurando a versão Debug, free chama MSVCR80D!_free_dbg e MSVCR80D!_free_dbg_nolock que dispara um aviso após validar a memória a ser liberada.

ChildEBP RetAddr

0012fd5c 102105de MSVCR80D!_free_dbg+0x4e

0012fd6c 0041161d MSVCR80D!free+0xe

0012fe50 00411481 HeapCorruption!RemoveAll+0x4d

0012ff68 00411c06 HeapCorruption!wmain+0xb1

0012ffb8 00411a4d HeapCorruption!__tmainCRTStartup+0x1a6

0012ffc0 7c816fd7 HeapCorruption!wmainCRTStartup+0xd

0012fff0 00000000 kernel32!BaseProcessStart+0x23

Após o MessageBox de aviso, se Ignore for clicado a aplicação tenta liberar o próximo item da lista e assim sucessivamente.

<LEMBRETE>

- Heap Corruption causa Crash intermitente de aplicação.

- O Crash geralmente ocorre no momento que um bloco de memória foi corrompido (devido a corrupção causada em um bloco adjacente) e está tentando ser liberado.

- A versão Debug alerta, por default, quando o bloco de memória sendo liberado foi corrompido. Na versão Release, por default, não há avisos.

- Normal Page Heap faz a aplicação sofrer um crash quando o bloco de memória causador da corrupção está sendo desalocado, uma vez que bytes com um determinado padrão são verificados e podem ter sido sobreescritos.

- Full Page Heap faz a aplicação sofrer um crash quando dados estão sobreescrevendo um determinado bloco de memória. Consome mais memória, porém é mais eficiente.

- commit refere-se a operação de associar páginas de memória aos endereços virtuais reservados.

</LEMBRETE>

DEPURAÇÃO COM PAGE HEAP HABILITADO

Próximo passo, habilitar o Full Page Heap para obtermos um crash de aplicação no momento que a memória está sendo corrompida.

Mas antes disso, vamos fazer a corrupção de memória passar os bytes delimitadores. Para isso, use essa versão:

#include "stdafx.h"

#include <conio.h>

#include "prototypes.h"

#include <string.h>

int _tmain(int argc, _TCHAR* argv[])

{

          stList* pstHead = NULL;

          char szValue[50] = "ABCDEFGHIJKLMNOPQRSTUVWXYZA"; ß Aqui aumentei o buffer para 50… desnecessariamente exagerado, mas apenas para evitar um buffer overflow.

         

          for(int i = 0; i < 9999; i++)

          {

                   Insert(&pstHead, szValue);

          }

          strcat(szValue, "A"); ß Adiciona apenas um único caracter a mais em apenas uma única alocação. strcat() tem furos de segurança.

          Insert(&pstHead, szValue); ß Insere a nova string.

                  

          printf("Pressione alguma tecla para remover os elementos da lista.\n");

          _getch();

          printf("REMOVENDO ELEMENTOS DA LISTA...\n");

         

          RemoveAll(&pstHead);

          return 0;

}

Em seguida recompile a aplicação para modo Debug e Release.

A versão Debug continua avisando sobre o problema no momento que a memória é liberada mas a versão Release sofre um crash durante a liberação da memória do heap.

Quando clientes abrem incidentes de corrupção de memória conosco, esse é o sintoma típico que observamos!

Teste ambas versões e observe como elas reagem a corrupção de memória de heap.

Nesse ponto o que queremos é um crash, mas no momento que a memória é invadida ou quando o bloco de memória corrompido tenta ser desalocado.

Habilitei o Full Page Heap para a versão Release. Embora o consumo de memória é maior, a aplicação é pequena e de rápida execução, então não será impactante:

C:\Debuggers>gflags -p /enable path\heapcorruption.exe /full

Se você executar a aplicação Release sem nenhum depurador conectado vai notar que o crash ocorre durante a fase que a memória está sendo preenchida com dados e eles sobreescrevem o buffer.

Faça um teste para ver.

Agora vou depurar essa versão com Full Page Heap habilitado, até o momento da exceção.

Logo antes do strcat() temos:

(a68.adc): Access violation - code c0000005 (first chance)

First chance exceptions are reported before any exception handling.

This exception may be expected and handled.

eax=065bafe0 ebx=065b8fe0 ecx=0012ff80 edx=0012ff00 esi=0648b080 edi=0012ff7f

eip=00401092 esp=0012ff54 ebp=78134d0b iopl=0 nv up ei pl nz na pe nc

cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010206

HeapCorruption!wmain+0x92:

00401092 88140e mov byte ptr [esi+ecx],dl ds:0023:065bb000=??

Status do Gflag no processo:

Current NtGlobalFlag contents: 0x02000000 ß Habilitado em full mode.

    hpa - Place heap allocations at ends of pages

Essa versão é Release, portanto o código está otimizado:

ChildEBP RetAddr

0012ffc0 7c816fd7 HeapCorruption!wmain+0x92 ß Função Insert() foi chamada.

02b4ec84 00000000 kernel32!BaseProcessStart+0x23

A linha de código sendo executada é:

strcat(szValue, "A");

Insert(&pstHead, szValue); ß Eis!!! No momento da corrupção do heap.

Eis a prova, o heap no momento que está sendo invadido:

065bafe4 44434241 48474645 4c4b4a49 504f4e4d ABCDEFGHIJKLMNOP

065baff4 54535251 58575655 41415a59 ???????? QRSTUVWXYZAA????

A exceção ocorre porque o Full Page Heap colocou blocos de memória que não podem ser escritos, antes e depois da alocação.

Vejam a região dos dados:

BaseAddress: 065ba000

AllocationBase: 06580000

AllocationProtect: 00000001 PAGE_NOACCESS

RegionSize: 00001000

State: 00001000 MEM_COMMIT

Protect: 00000004 PAGE_READWRITE ß Ok, esperado, afinal os dados serão colocados nessa região.

Type: 00020000 MEM_PRIVATE

Agora vejam a região protegida logo após a alocação feita pela aplicação:

BaseAddress: 065bb000

AllocationBase: 06580000

AllocationProtect: 00000001 PAGE_NOACCESS

RegionSize: 000c5000

State: 00001000 MEM_COMMIT

Protect: 00000001 PAGE_NOACCESS ß Isso explica o artifício usado para conseguir a exceção!

Type: 00020000 MEM_PRIVATE

Ótimo, com esse longo artigo espero ter conseguido explicar como um Heap Corruption ocorre e como isolar a causa raiz.

Mas há um jeito mais simples que, embora mais superficial, será útil na imensa maioria das situações e, melhor, não exige horas dispendidas atrás de um depurador.

ISOLANDO O PROBLEMA DE HEAP CORRUPTION SEM FAZER DEPURAÇÃO MANUAL

Instale e configure o DebugDiag.

Faça uma pequena alteração na aplicação teste para que ela chame _getch() no ínicio também, simulando uma aplicação real que fica executando.

Nota: Se você usou Gflags para refazer os passos acima, desabilite-o com:

Gflags –p /disable path\heapcorruption.exe

Se você não usou Gflags ignore a linha acima.

Eis as ações:

a) Chame a aplicação que você acaba de recompilar com _getch() no início. Use a versão Release para ficar bem real.

b) Rode o DebugDiag e clique em modo Crash.

c) Selecione “A specific process”.

d) Clique “Next” e selecione a aplicação teste. No meu caso estou usando o nome HeapCorruption.exe.

e) Clique “Next”.

f) Clique no botão “PageHeap Flags...”. Aqui vamos fazer via DebugDiag o mesmo que fiz acima com Gflags.

g) Selecione “Enable Full PageHeap”.

h) Depois pressione “Next” várias vezes e “Finish”. Isso vai ativar a regra de Crash do DebugDiag com Full Page Heap habilitado para nossa aplicação de teste.

i) Feche a aplicação se ela estiver rodando e chame-a novamente para que ela use as configurações do Page Heap. Nota: Isso vale para Gflags também!

j) Clique qualquer tecla após chamá-la. Ela vai congelar por alguns segundos porque o DebugDiag está coletando um dump.

k) Onde o DebugDiag foi instalado você deverá ver um diretório de nome sugestivo como “Crash rule for all instances of HeapCorruption.exe”.

l) Usando o DebugDiag clique em Advanced Analysis e selecione o dump gerado.

m) Clique em Tools e “Symbol Search Path for Analysis” e selecione o arquivo PDB da aplicação. Isso não é necessário, mas ajuda o DebugDiag a obter mais detalhes.

n) Agora selecione os 2 scripts de análise e inicie a análise automatizada. Se é a primeira vez que você usa o DebugDiag ele vai demorar porque vai baixar nossos símbolos (arquivos PDB), do contrário é rápido.

Espero que esse artigo tenha ajudado a esclarecer o que é e como isolar uma corrupção na memória de heap.

Ótima caçada de bugs a todos e até o próximo artigo!