Uma breve descrição das tecnologias usadas na ferramenta PVS-Studio que podem efetivamente detectar um grande número de padrões de erros e possíveis vulnerabilidades. O artigo descreve a implementação do analisador para código C e C ++, no entanto, as informações acima também são válidas para os módulos responsáveis pela análise de código C # e Java.
1. Introdução
Existem conceitos errôneos de que os analisadores de código estático são programas bastante simples, com base na pesquisa de padrões de código usando expressões regulares. Isso está longe da verdade. Além disso, identificar a grande maioria dos erros usando expressões regulares simplesmente
não é possível .
O erro surgiu com base na experiência dos programadores ao trabalhar com algumas ferramentas que existiam 10 a 20 anos atrás. O trabalho das ferramentas geralmente se resumia a encontrar padrões perigosos de código e funções como
strcpy ,
strcat , etc. Como representante dessa classe de ferramentas, pode ser chamado de
RATS .
Tais ferramentas, embora pudessem ser úteis, eram geralmente estúpidas e ineficazes. É a partir desses tempos que muitos programadores ainda têm memórias que os analisadores estáticos são ferramentas muito inúteis que interferem mais no trabalho do que em ajudá-lo.
O tempo passou e os analisadores estáticos começaram a constituir soluções complexas que realizam análises detalhadas do código e encontram erros que permanecem no código, mesmo após uma cuidadosa revisão do código. Infelizmente, devido à experiência negativa do passado, muitos programadores ainda consideram a metodologia de análise estática inútil e não têm pressa em introduzi-la no processo de desenvolvimento.
Neste artigo, tentarei corrigir um pouco a situação. Peço aos leitores que demorem 15 minutos para se familiarizar com as tecnologias usadas no analisador de código estático PVS-Studio para detectar erros. Talvez depois disso, você dê uma nova olhada nas ferramentas de análise estática e deseje aplicá-las em seu trabalho.
Análise de fluxo de dados
A análise do fluxo de dados permite encontrar uma variedade de erros. Entre eles: sair dos limites de uma matriz, vazamentos de memória, sempre condições verdadeiras / falsas, desreferenciar um ponteiro nulo e assim por diante.
Além disso, a análise de dados pode ser usada para procurar situações em que dados não verificados que vieram de fora do programa são usados. Um invasor pode preparar esse conjunto de dados de entrada para que o programa funcione da maneira que precisa. Em outras palavras, ele pode usar o erro de controle de entrada insuficiente como uma vulnerabilidade. Para procurar o uso de dados não verificados no PVS-Studio, o diagnóstico especializado
V1010 foi implementado e continua a melhorar.
A análise do fluxo de dados (Análise de fluxo de
dados ) é calcular os possíveis valores das variáveis em vários pontos de um programa de computador. Por exemplo, se o ponteiro for desreferenciado e for sabido que neste momento pode ser zero, isso é um erro e o analisador estático o reportará.
Vejamos um exemplo prático de uso da análise de fluxo de dados para procurar erros. À nossa frente está uma função do projeto Protocol Buffers (protobuf), projetada para verificar a correção da data.
static const int kDaysInMonth[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; bool ValidateDateTime(const DateTime& time) { if (time.year < 1 || time.year > 9999 || time.month < 1 || time.month > 12 || time.day < 1 || time.day > 31 || time.hour < 0 || time.hour > 23 || time.minute < 0 || time.minute > 59 || time.second < 0 || time.second > 59) { return false; } if (time.month == 2 && IsLeapYear(time.year)) { return time.month <= kDaysInMonth[time.month] + 1; } else { return time.month <= kDaysInMonth[time.month]; } }
O analisador PVS-Studio detectou dois erros lógicos na função ao mesmo tempo e exibe as seguintes mensagens:
- V547 / CWE-571 A expressão 'time.month <= kDaysInMonth [time.month] + 1' sempre é verdadeira. time.cc 83
- V547 / CWE-571 A expressão 'time.month <= kDaysInMonth [time.month]' sempre é verdadeira. time.cc 85
Observe a subexpressão “time.month <1 || time.month> 12 ". Se o valor do
mês estiver fora do intervalo [1..12], a função interromperá o trabalho. O analisador leva isso em consideração e sabe que, se a segunda
instrução if começar a ser executada, o valor do
mês estará exatamente no intervalo [1..12]. Da mesma forma, ele conhece o leque de outras variáveis (ano, dia etc.), mas elas não são interessantes para nós agora.
Agora, vamos dar uma olhada em dois operadores idênticos para acessar os elementos da matriz:
kDaysInMonth [time.month] .
A matriz é definida estaticamente e o analisador conhece os valores de todos os seus elementos:
static const int kDaysInMonth[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
Como os meses são numerados de 1, o analisador não considera 0 no início da matriz. Acontece que um valor no intervalo [28..31] pode ser extraído da matriz.
Dependendo se o ano é bissexto ou não, é adicionado 1 ao número de dias, mas isso também não é interessante para nós agora. As comparações são importantes:
time.month <= kDaysInMonth[time.month] + 1; time.month <= kDaysInMonth[time.month];
O intervalo [1..12] (número do mês) é comparado com o número de dias no mês.
Considerando que, no primeiro caso, o mês é sempre fevereiro (
time.month == 2 ), obtemos que os seguintes intervalos são comparados:
- 2 <= 29
- [1..12] <= [28..31]
Como você pode ver, o resultado da comparação é sempre verdadeiro, e é sobre isso que o analisador PVS-Studio alerta. De fato, o código contém dois erros de digitação idênticos. O lado esquerdo da expressão deve usar um membro da classe
day , e não um
mês .
O código correto deve ser assim:
if (time.month == 2 && IsLeapYear(time.year)) { return time.day <= kDaysInMonth[time.month] + 1; } else { return time.day <= kDaysInMonth[time.month]; }
O erro discutido aqui também foi descrito anteriormente no artigo "
31 de fevereiro ".
Execução simbólica
Na seção anterior, consideramos um método em que o analisador calcula os possíveis valores das variáveis. No entanto, para encontrar alguns erros, não é necessário conhecer os valores das variáveis.
Execução simbólica significa resolver equações em forma simbólica.
Não encontrei uma demonstração adequada em nosso
banco de dados de erros , portanto, considere um exemplo de código sintético.
int Foo(int A, int B) { if (A == B) return 10 / (A - B); return 1; }
O analisador PVS-Studio gera um aviso V609 / CWE-369 Dividir por zero. Denominador 'A - B' == 0. test.cpp 12
Os valores das variáveis
A e
B são desconhecidos para o analisador. Mas o analisador sabe que, no momento do cálculo da expressão
10 / (A - B), as variáveis
A e
B são iguais. Portanto, a divisão por 0 ocorrerá.
Eu disse que os valores de
A e
B são desconhecidos. Para o caso geral, isso é verdade. No entanto, se o analisador vir uma chamada de função com valores específicos dos argumentos reais, isso será levado em consideração. Considere um exemplo:
int Div(int X) { return 10 / X; } void Foo() { for (int i = 0; i < 5; ++i) Div(i); }
O analisador PVS-Studio alerta para divisão por zero: V609 CWE-628 Divida por zero. Denominador 'X' == 0. A função 'Div' processa o valor '[0..4]'. Inspecione o primeiro argumento. Verifique as linhas: 106, 110. consoleapplication2017.cpp 106
Uma mistura de tecnologias já funciona aqui: análise de fluxo de dados, execução simbólica e anotação automática de método (discutiremos essa tecnologia na próxima seção). O analisador vê que a variável
X é usada como um divisor na função
Div . Com base nisso, uma anotação especial é criada automaticamente para a função
Div . Também é levado em consideração que um intervalo de valores [0..4] é passado para a função como argumento
X. O analisador conclui que a divisão por 0 deve ocorrer.
Anotações de método
Nossa equipe anotou milhares de funções e classes fornecidas em:
- Winapi
- Biblioteca padrão C
- biblioteca de modelos padrão (STL),
- glibc (Biblioteca GNU C)
- Qt
- Mfc
- zlib
- libpng
- Openssl
- e assim por diante
Todas as funções são anotadas manualmente, o que permite definir muitas características importantes em termos de localização de erros. Por exemplo, é especificado que o tamanho do buffer passado para a função
fread não deve ser menor que o número de bytes planejados para serem lidos no arquivo. A relação entre o segundo, o terceiro argumento e o valor que a função pode retornar também é indicada. Tudo se parece com isso:
Graças a esta anotação, o código a seguir, que usa a função
fread , revelará imediatamente dois erros.
void Foo(FILE *f) { char buf[100]; size_t i = fread(buf, sizeof(char), 1000, f); buf[i] = 1; .... }
Avisos do PVS-Studio:
- V512 CWE-119 Uma chamada da função 'fread' levará ao estouro do buffer 'buf'. test.cpp 116
- É possível saturação da matriz V557 CWE-787. O valor do índice 'i' pode chegar a 1000. test.cpp 117
Primeiro, o analisador multiplicou o segundo e o terceiro argumento real e calculou que a função pode ler até 1000 bytes de dados. Nesse caso, o tamanho do buffer é de apenas 100 bytes e pode exceder.
Em segundo lugar, como a função pode ler até 1000 bytes, o intervalo de valores possíveis da variável
i é [0..1000]. Por conseguinte, o acesso à matriz pode ocorrer no índice errado.
Vejamos outro exemplo simples de erro, cuja detecção foi possível graças à marcação da função
memset . Aqui está um trecho de código do projeto CryEngine V.
void EnableFloatExceptions(....) { .... CONTEXT ctx; memset(&ctx, sizeof(ctx), 0); .... }
O analisador PVS-Studio encontrou um erro de digitação: V575 A função 'memset' processa elementos '0'. Inspecione o terceiro argumento. crythreadutil_win32.h 294
Confundiu o 2º e o 3º argumento da função. Como resultado, a função processa 0 bytes e não faz nada. O analisador percebe essa anomalia e avisa os programadores sobre isso. Anteriormente, já descrevemos esse erro no artigo "A
tão esperada verificação do CryEngine V ".
O analisador PVS-Studio não se limita às anotações que definimos manualmente. Além disso, ele tenta criar anotações de maneira independente, estudando os corpos das funções. Isso permite encontrar erros de uso inadequado de funções. Por exemplo, o analisador lembra que uma função pode retornar nullptr. Se o ponteiro retornado por esta função for usado sem verificação preliminar, o analisador avisará sobre isso. Um exemplo:
int GlobalInt; int *Get() { return (rand() % 2) ? nullptr : &GlobalInt; } void Use() { *Get() = 1; }
Aviso: V522 CWE-690 Pode haver desreferenciação de um ponteiro nulo potencial 'Get ()'. test.cpp 129
Nota Você pode abordar a busca pelo erro apenas examinado da maneira oposta. Não se lembre de nada e sempre que uma chamada para a função
Get for encontrada, analise-a conhecendo os argumentos reais. Teoricamente, esse algoritmo permite encontrar mais erros, mas possui complexidade exponencial. O tempo de análise do programa aumenta centenas de milhares de vezes, e consideramos essa abordagem um beco sem saída do ponto de vista prático. No PVS-Studio, estamos desenvolvendo a direção da anotação automática de funções.
Correspondência de padrões
À primeira vista, a tecnologia correspondente a um padrão pode parecer uma pesquisa com expressões regulares. De fato, não é assim, e tudo é muito mais complicado.
Em primeiro lugar, como eu já
disse , expressões regulares geralmente não têm valor. Em segundo lugar, os analisadores não funcionam com linhas de texto, mas com árvores de sintaxe, o que permite reconhecer padrões de erro mais complexos e de alto nível.
Considere dois exemplos, um mais simples e outro mais complexo. O primeiro erro que encontrei ao verificar o código-fonte do Android.
void TagMonitor::parseTagsToMonitor(String8 tagNames) { std::lock_guard<std::mutex> lock(mMonitorMutex); if (ssize_t idx = tagNames.find("3a") != -1) { ssize_t end = tagNames.find(",", idx); char* start = tagNames.lockBuffer(tagNames.size()); start[idx] = '\0'; .... } .... }
O analisador PVS-Studio reconhece o padrão de erro clássico associado ao equívoco de um programador sobre a prioridade das operações em C ++: V593 / CWE-783 Considere revisar a expressão do tipo 'A = B! = C'. A expressão é calculada da seguinte forma: 'A = (B! = C)'. TagMonitor.cpp 50
Dê uma olhada nesta linha:
if (ssize_t idx = tagNames.find("3a") != -1) {
O programador assume que uma atribuição é realizada no início e somente então uma comparação com
-1 . De fato, a comparação vem primeiro. Clássico Este erro é
descrito em mais detalhes no
artigo dedicado à verificação do Android (consulte o capítulo "Outros erros").
Agora considere uma opção de correspondência de padrão de nível superior.
static inline void sha1ProcessChunk(....) { .... quint8 chunkBuffer[64]; .... #ifdef SHA1_WIPE_VARIABLES .... memset(chunkBuffer, 0, 64); #endif }
Aviso do PVS-Studio: V597 CWE-14 O compilador pode excluir a chamada de função 'memset', usada para liberar o buffer 'chunkBuffer'. A função RtlSecureZeroMemory () deve ser usada para apagar os dados particulares. sha1.cpp 189
A essência do problema é que, depois de preencher um buffer com zeros usando a função
memset , esse buffer não é usado em nenhum lugar. Ao compilar código com sinalizadores de otimização, o compilador decidirá que essa chamada de função é redundante e a excluirá. Ele tem direito a isso, pois, do ponto de vista da linguagem C ++, chamar uma função não tem nenhum comportamento observável no programa. Imediatamente após preencher o buffer
chunkBuffer , a função
sha1ProcessChunk termina. Como o buffer é criado na pilha, após sair da função, ele fica indisponível para uso. Portanto, do ponto de vista do compilador, não faz sentido preenchê-lo com zeros.
Como resultado, em algum lugar da pilha permanecerão dados privados, o que pode causar problemas. Este tópico é discutido em mais detalhes no artigo "
Limpeza segura de dados particulares ".
Este é um exemplo de correspondência de padrão de alto nível. Primeiro, o analisador deve estar ciente da existência dessa falha de segurança, classificada de acordo com a Enumeração de Fraqueza Comum como
CWE-14: Remoção de Código do Compilador para Limpar Buffers .
Em segundo lugar, ele deve encontrar no código todos os locais onde o buffer é criado na pilha, é limpo usando a função
memset e não é usado em nenhum outro lugar.
Conclusão
Como você pode ver, a análise estática é uma metodologia muito interessante e útil. Ele permite eliminar um grande número de erros e possíveis vulnerabilidades nos estágios iniciais (consulte
SAST ). Se você ainda não está completamente imbuído da análise estática, convido você a ler nosso
blog , onde analisamos regularmente os erros encontrados usando o PVS-Studio em vários projetos. Você simplesmente não pode permanecer indiferente.
Teremos o maior prazer em ver sua empresa entre
nossos clientes e ajudar a tornar seus aplicativos melhores, mais confiáveis e mais seguros.

Se você deseja compartilhar este artigo com um público que fala inglês, use o link para a tradução: Andrey Karpov.
Tecnologias usadas no analisador de código PVS-Studio para encontrar erros e possíveis vulnerabilidades .