msdn04_capa.JPG

Clique aqui para ler todos os artigos desta edição

 

Dicas de um especialista para identificar falhas de segurança no seu código

por Michael Howard

 

Rever o código para identificar falhas de segurança é um importante fator do processo de criação de software, juntamente com o planejamento, o design e o teste. Neste artigo o autor reflete sobre sua experiência com revisões de códigos de segurança para identificar padrões e práticas recomendadas que todos os desenvolvedores devem seguir ao pesquisar possíveis falhas de segurança. O processo começa com o exame do ambiente de execução do código, considerando as funções dos usuários que o executarão e estudando o histórico dos problemas de segurança já ocorridos com o código. Depois que esses assuntos fundamentais forem compreendidos, será possível vencer vulnerabilidades específicas, incluindo ataques de injeção de SQL (SQL Injection), scripting entre sites (Cross-site scripting) e buffer overruns. Além disso, certos sinais, como nomes de variáveis do tipo "password", "secret" e outras falhas graves de segurança óbvias, mas comuns, podem ser localizadas e remediadas.

 

Grande parte do meu trabalho envolve a revisão de códigos de outras pessoas, em busca de falhas de segurança. Devo admitir que essa não é minha principal função – que tende a ser revisão de design e modelagem de ameaças – mas certamente acabo trabalhando muito com códigos.

Espero que você perceba que revisar o código de outra pessoa, embora seja uma ótima prática, não é a forma correta de criar um software seguro. Um software seguro é produzido estabelecendo um processo para desenhar, escrever, testar e documentar sistemas seguros e dedicando algum tempo na agenda para a revisão da segurança, o treinamento e o uso de ferramentas. Simplesmente desenhar, escrever, testar e documentar um projeto e, depois, procurar falhas de segurança não representa criar um software seguro. A revisão do código é apenas uma parte do processo, mas por si só não cria um código seguro.

Neste artigo, não discutirei a natureza das vulnerabilidades dos códigos, como ataques de overflow de inteiros, injeção de SQL (SQL Injection) e buffer overruns; você poderá obter informações sobre esses assuntos em livros (Writing Secure Code, Microsoft Press®, 2002). Em vez disso, analisarei detalhadamente assuntos que exigem minha atenção durante uma revisão de código. No entanto, antes de começar, quero enfatizar que esta é a forma como eu procuro falhas de segurança em códigos e não significa que essa deva ser necessariamente a forma como você fará a revisão. Também não garanto que esta seja a forma mais completa de identificar certos tipos de falhas. Quero registrar o que se passa na minha cabeça quando examino um código e espero que isso seja útil para você também.

Há três formas de rever um código: análise profunda, análise rápida e abordagem híbrida. A minha tendência é utilizar uma abordagem híbrida, já que ela tem a vantagem de cobrir grande parte do terreno rapidamente; se encontro algo que julgo merecer uma análise mais aprofundada, faço uma marcação para rever o código futuramente, possivelmente envolvendo a participação de outros especialistas da área em questão. Agora, porém, vou discutir a revisão inicial rápida de código ou, como gosto de chamar, o método Exame e Etiqueta – examinamos o código rapidamente e “colamos” uma etiqueta indicando a necessidade de uma revisão futura. Veja abaixo um esboço do meu processo.

Alocando tempo e trabalho

Criei um sistema de classificação que uso para determinar quanto tempo preciso gastar revisando o código em relação à tarefa inteira. O sistema se baseia no potencial de dano caso uma vulnerabilidade seja descoberta e no potencial de ataque. O sistema de cotas é baseado nas seguintes características:

Ø      O código é executado por padrão?

Ø      O código é executado com privilégios elevados?

Ø      O código fica em escuta em uma interface de rede?

Ø      A interface de rede não é autenticada?

Ø      O código é escrito em C/C++?

Ø      O código possui um histórico de vulnerabilidade?

Ø      O componente é objeto de olhares examinadores de pesquisadores de segurança?

Ø      O código lida com dados confidenciais ou particulares?

Ø      O código é reutilizável (por exemplo DLL, C++ class header, library, ou assembly)?

Ø      Baseado no modelo de ameaças, o componente está em um ambiente de alto risco ou está sujeito a muitas ameaças de alto risco?

 

Se a resposta for afirmativa para mais de três ou quatro perguntas da lista, terei que revisar o código mais profundamente. Na verdade, se o código estiver em escuta em um Transmission Control Protocol (TCP) ou User Datagram Protocol (UDP) socket e estiver sendo executado por padrão, esteja preparado para dedicar muito tempo à sua revisão.

Ao procurar erros de segurança, tenho a tendência de rever três grandes categorias de códigos: C/C++, código de aplicativo Web Server (como ASP, ASP.NET, CGI e Perl) e código gerenciado (principalmente C# e um pouco de Visual Basic® .NET).

É preciso estar ciente de algumas nuances relativas a cada linguagem. Primeiro, o principal problema da linguagem C e C++ é o buffer overrun. Na verdade, há alguns outros problemas; mas quando ouvimos as palavras “buffer” e “overrun” na mesma frase, é quase certo que C ou C++ esteja envolvido. As linguagens de alto nível, como C#, Visual Basic .NET e Perl, não devem apresentar buffer overruns. Se ocorrer algum, a falha estará provavelmente no ambiente de tempo de execução, e não no código revisado. No entanto, essas linguagens são geralmente usadas em códigos de aplicativos Web Server e estão sujeitos a outros tipos de falhas. Buffer overruns são perigosos porque o invasor pode injetar código no processo executado e seqüestrá-lo. Portanto, primeiro vamos examinar o buffer overrun.


 Buffer overruns em C e C++

Buffer overruns são o pesadelo da indústria de software, e você deve fazer o que for possível para removê-los do seu código. Melhor ainda, assegure-se de nem incluí-los no código.

Existem duas maneiras para revisar buffer overruns. A primeira é identificar todos os pontos de entrada do aplicativo, principalmente pontos de entrada de rede, rastreando o caminho dos dados pelo código e questionando como esses dados estão sendo manipulados. Pressuponho que todos os dados são perigosos. Ao examinar qualquer código que lida com dados (lê dados ou escreve neles), me pergunto: "Existe alguma forma dos dados poderem causar uma falha no código?" Esse método é perfeito, mas consome muito tempo. Outra técnica é procurar construções conhecidas e potencialmente perigosas e rastrear o caminho inverso dos dados até o ponto de entrada. Por exemplo, veja este código:

 

void function(char *p) {

    char buff[16];

    •••

 

    strcpy(buff,p);

 

    •••

}

 

Quando vejo um código como esse, rastreio o caminho inverso da variável p até sua origem. Se percebo que ela veio de um local pouco confiável, ou se a validação não for checada próximo ao ponto no qual é copiada, concluo que encontrei uma falha na segurança. Observe que strcpy, por si só, não é perigoso ou inseguro. São os dados que tornam essas funções preocupantes. Se você verifica que os dados estão ok, strcpy poderá ser considerado seguro. É claro que, se você estiver errado, terá uma falha de segurança. Também verifico as "n" funções de manipulação de string, como strncpy, porque também é necessário conferir se o cálculo do tamanho do buffer está correto.

Tenho cuidado com códigos que manipulam formatos de arquivos tagged (tagged file format). Refiro-me aos arquivos compostos de blocos, em que cada bloco possui um cabeçalho que descreve o próximo bloco de dados. Um bom exemplo é o formato musical MIDI. Uma grave falha de segurança foi encontrada e corrigida em um componente do Windows chamado quartz.dll, que manipula arquivos MIDI. Uma construção MIDI mal escrito causou uma falha, ou resultados piores, no código que estava manipulando o arquivo.

Outra construção que me faz ficar alerta é esta:

 

while (*s != '\\')

    *d++ = *s++;

 

Esse loop é restringido por um caractere na origem, e não pelo tamanho do destino. Basicamente, procuro *x++ = *y++ usando a seguinte regular expression:

 

\*\w+\+\+\s?=\s?\*\w+\+\+

 

É claro que as pessoas podem usar *++x = *++y. Por isso, também é necessário procurar essa construção. Quero enfatizar mais uma vez que essa construção não é perigosa quando a origem é confiável. Portanto, é necessário determinar o grau de confiabilidade dos dados originais.

Existe ainda outro tipo de assunto relacionado a buffer overruns que merece atenção: a vulnerabilidade do estouro de inteiro (integer overflow).

Integer overflow em C e C++

Nesses defeitos, a verdadeira brecha na segurança ocorre quando é realizada uma operação aritmética para calcular o tamanho do buffer e o cálculo ultrapassa a capacidade do buffer (overflow) ou fica muito abaixo dela (underflow). Veja o seguinte exemplo:

 

void func(char *b1, size_t c1, char *b2, size_t c2) {

    const size_t MAX = 48;

    if (c1 + c2 > MAX) return;

    char *pBuff = new char[MAX];

    memcpy(pBuff,b1,c1);

    memcpy(pBuff+c1,b2,c2);

}

 

O código parece normal até que percebemos um problema quando somamos c1 e c2 e o resultado fica acima de 232-1. Por exemplo, o resultado da soma de 0xFFFFFFF0 e 0x40 é 0x30 (48 decimal). Quando esses valores são usados para c1 e c2, a soma passa na verificação de tamanho e o código copia quase 4GB para um buffer de 48 bytes. Acabou de ocorrer um buffer overrun! Muitas falhas como essa podem ser exploradas, permitindo que um invasor injete códigos no seu processo.

Ao verificar integer overflows nos códigos C e C++, verifico todas as instâncias do operador new e das funções de alocação dinâmica de memória (alloca, malloc, calloc, HeapAlloc etc.) e, depois, determino como o tamanho do buffer será estimado. Em seguida, faço perguntas como:

 

Ø      Os valores poderão ultrapassar o valor máximo?

Ø      Os valores poderão ficar abaixo de zero?

Ø      Os dados serão truncados (copiando um valor de 32 bits para um valor de 16 bits e, depois, copiando o tamanho de 32 bits)?

 

Há uma regra básica que é empregada por um colega da Microsoft: Se você realizar uma operação matemática em uma expressão usada em uma comparação, então existe a possibilidade de existir um overflow ou underflow nos dados. Isso poderá ser duplamente prejudicial se o cálculo for usado para determinar um tamanho de buffer, principalmente se um ou mais elementos desse cálculo forem violados por um invasor.

Código de acesso ao banco de dados em qualquer linguagem

Em geral, os desenvolvedores escrevem aplicativos de bancos de dados em linguagens de alto nível, como C#, linguagens de script e outras linguagens semelhantes. Relativamente falando, pouca parte do código do banco de dados é escrita em C e C++, mas algumas pessoas usam diferentes bibliotecas de classes C/C++, como a classe CDatabase em MFC.

Dois problemas podem ser detectados. O primeiro, são as strings de conexão que incluem senhas que podem ser facilmente descobertas ou que se conectam usando contas administrativas. O segundo problema que pode ser detectado é a vulnerabilidade a ataques de injeção de SQL.

Quando examino um código gerenciado, a primeira coisa que faço é verificar se ele possui o namespace System.Data, especialmente System.Data.SqlClient. É só avistar um deles para o alarme de perigo soar! Em seguida, procuro palavras como "connect" no código (geralmente, perto dela há uma string de conexão). Duas propriedades interessantes devem ser observadas na string de conexão: a id da conexão (geralmente, uid) e a senha (geralmente, pwd). Algo como o exemplo abaixo representa uma possível brecha na segurança:

 

DRIVER={SQL Server};SERVER=hrserver;UID=sa;PWD=$esame

 

Existem duas falhas nesse exemplo. Primeiro, a conexão é feita com a conta do administrador do sistema, sa; isso viola o princípio de conceder somente os privilégios necessários. O código nunca deve fazer a conexão com o banco de dados com a conta do administrador do sistema (sysadmin) porque uma conta como essa pode colocar em perigo o banco de dados quando usado por pessoas mal-intencionadas. Em segundo lugar, a senha é de fácil percepção e está diretamente no código. O código está errado por dois motivos: primeiro, será descoberto e, segundo, o que acontecerá se a senha for alterada? (Você teria que atualizar todos os clientes.)

O próximo tópico é o ataque de injeção de SQL (SQL injection). O ponto crítico da SQL injection é o uso de concatenação de strings para criar instruções SQL. Ao verificar o código, procuro ver onde as instruções SQL são criadas. Geralmente, é necessário procurar palavras como "update", "select", "insert", "exec", e tabelas ou nomes de bancos de dados que sei que estão sendo usados. Para ajudar, uso o ildasm.exe a seguir no assembly gerenciado sob exame:

 

ildasm /adv /metadata /out:file test.exe

 

Depois, verifico a seção "User Strings" na saída resultante. Qualquer consulta ao banco de dados que use concatenação de strings é considerada uma possível falha de segurança que deve ser corrigida com consultas parametrizadas.

A utilização de concatenação de strings para criar Stored Procedures também não é a solução para a SQL injection. Resumindo, não convém usar concatenação de strings junto com instruções SQL, mas é um desastre ainda maior usar concatenação de strings com instruções SQL e a conta de administrador do sistema.

Código de página da Web em qualquer linguagem

Os erros mais comuns em aplicativos Web são problemas de cross-site scripting (XSS). Embora existam outros problemas, como SQL injection e criptografia deficiente, as falhas XSS são muito comuns. A principal vulnerabilidade do XSS é a possibilidade de exibir entradas de usuários não confiáveis no navegador da vítima. Por isso, primeiro procuro construções de códigos que enviam dados ao usuário. Por exemplo, em ASP procuro por Response.Write e tags do tipo . Em seguida, analiso os dados que estão sendo gravados para saber sua origem. Existirá uma falha XSS se os dados forem provenientes de uma entidade HTTP, como um form ou uma querystring, cuja validade não foi verificada e forem enviados para o navegador do usuário. Veja um exemplo extremamente simples, mas nem um pouco incomum:

 

Hello

 

image01.jpg

 

Como você pode ver, o parâmetro "Name" é enviado de volta para o usuário sem primeiro ter sua validade verificada.

Segredos e criptografia em qualquer linguagem

Alguns desenvolvedores adoram armazenar dados secretos em código, como senhas e chaves criptográficas, e criar seus próprios algoritmos criptográficos mágicos. Não faça nada disso!

Primeiro eu procuro nomes de variáveis e funções que incluam "key", "password", "pwd", "secret", "cipher" e "crypt". Todas as ocorrências devem ser analisadas. É comum ocorrerem falsos positivos para "key", mas os outros são interessantes e podem produzir dados secretos embutidos, ou sistemas criptográficos “mágicos”. Ao procurar algoritmos criptográficos, também busco operadores XOR, já que eles são freqüentemente usados em criptografia. O pior código é aquele que usa uma chave incorporada para XOR ou para um fluxo de dados!

Controles ActiveX no Visual Basic e em C++

Sempre faço uma pergunta quando estou revisando um novo controle ActiveX®: por que não foi escrito em código gerenciado? Pergunto isso porque o código gerenciado permite o uso de cenários parcialmente confiáveis, ao contrário do ActiveX.

Em seguida, analiso todos os métodos e propriedades do controle (o arquivo .IDL é a melhor fonte para isso) e tento me colocar na pele de uma pessoa mal-intencionada. Que coisas ruins eu poderia fazer com cada um desses métodos ou propriedades? Geralmente, os métodos são nomeados no formato VerboSubstantivo, como ReadRegistry, WriteFile, GetUserName e NukeKey. Por isso, procuro verbos sonoramente onerosos e substantivos (recursos) que fazem parte de recursos sensíveis.

Por exemplo, o método SendFile será potencialmente perigoso se o invasor conseguir acessar qualquer arquivo do disco rígido do usuário e enviá-lo para qualquer local, como um site controlado por um intruso! Tudo o que acessar recursos na máquina do usuário estará sujeito a uma análise mais detalhada.

Faço uma revisão extra quando o controle estiver marcado como seguro para scripting (SFS), já que ele pode ser chamado no navegador da Web sem qualquer aviso para o usuário. É possível identificar se o controle é SFS se ele implementar a interface ATL IObjectSafetyImpl ou definir as seguintes categorias de implementação "safe for scripting" ou "safe for activation" no momento da instalação:

 

[HKEY_CLASSES_ROOT\CLSID\\Implemented

Categories\{7DD95801-9882-11CF-9FA9-00AA006C42C4}]

[HKEY_CLASSES_ROOT\CLSID\\Implemented

Categories\{7DD95802-9882-11CF-9FA9-00AA006C42C4}]

 

Mencionei acima que não é uma boa idéia acessar e enviar arquivos do usuário usando o método SendFile. Na verdade, ela será uma falha de segurança se eu conseguir acessar esse método e determinar que existe um arquivo no disco rígido do usuário com base no código de erro retornado pelo método.

Conclusão

Esta é uma análise de alto nível que realizo na primeira passagem de revisão de um código. Muitas dessas falhas são simples e pode-se argumentar que os desenvolvedores não deveriam cometer tais erros. Mas eles ocorrem. Saber que o código será revisado para fins de segurança, no entanto, geralmente força o desenvolvedor a escrever um código mais seguro desde o início.

Você também pode ter notado que é tema comum em alguns dos tipos de falhas o fato de muitos serem causados por entradas de dados não confiáveis. Ao revisar um código, sempre verifique a origem dos dados e se é possível confiar neles.