Skip to main content

Vulnerabilidades alcançáveis: como priorizar com eficácia a segurança de código aberto

Escrito por
Headshot of Krysztof Huszcza

Krysztof Huszcza

Prioritisation header feature

18 de agosto de 2020

0 minutos de leitura

Um problema comum relatado por nossos clientes é a sensação de estar sobrecarregado por vulnerabilidades.

Em projetos pequenos, você pode acabar dependendo de dezenas ou até centenas de bibliotecas de código aberto. Em grandes aplicações corporativas, pode parecer que suas dependências incluem metade do ecossistema. A proliferação de dependências de terceiros leva à proliferação de vulnerabilidades associadas a elas. Embora a maioria dos desenvolvedores aceite a contragosto o tamanho da presença de componentes de terceiros em seus projetos, lançar um app com centenas ou milhares de vulnerabilidades conhecidas costuma passar dos limites.

Quando as vulnerabilidades que afetam seu app parecem demais, é razoável se perguntar: será que todos esses problemas podem ser explorados? E se algumas vulnerabilidades estiverem em códigos de terceiros que nunca são executados pelo seu app? Em outras palavras, e se algumas vulnerabilidades nunca forem alcançáveis a partir do código da sua aplicação? Se soubesse disso, você poderia deixar para depois a correção das vulnerabilidades inalcançáveis e se concentrar nas alcançáveis, ou seja, naquelas que realmente afetam você agora em produção. Em algum momento, ainda seria necessário resolver todas as vulnerabilidades, mas pelo menos você saberia por onde começar.

Parece perfeito, mas como determinar quais vulnerabilidades são alcançáveis e quais não são? Na Snyk, resolvemos esse problema combinando pesquisa especializada em segurança com análise estática automatizada. Vamos conhecer melhor essa solução.

Funções vulneráveis

Primeiro, vamos analisar a parte de pesquisa em segurança, que busca responder a uma pergunta muito importante: qual código é realmente vulnerável?

Para cada vulnerabilidade, queremos saber quais linhas de código, funções, classes ou módulos de uma determinada biblioteca representam um risco à segurança. Isso é essencial: não podemos dizer se o código vulnerável é alcançável sem especificar qual é esse código. Infelizmente, os bancos de dados de vulnerabilidades disponíveis publicamente não fornecem esses dados.

Então, como identificar o código vulnerável? Uma maneira que consideramos eficaz na prática é a curadoria manual feita por especialistas em segurança. Na Snyk, temos uma equipe de analistas e pesquisadores de segurança que analisa continuamente as vulnerabilidades que afetam nossos clientes. Eles pesquisam cada vulnerabilidade antes de adicioná-la ao nosso banco de dados e a anotam com metadados que apontam para o código realmente vulnerável.

Para descrever qual código é vulnerável, decidimos usar funções do programa. Para cada vulnerabilidade, armazenamos um conjunto de nomes e assinaturas de funções. Às vezes, essas funções contêm literalmente o código vulnerável; em outros casos, contêm as funções que viabilizam o comportamento vulnerável. Independentemente da relação exata com a vulnerabilidade, nós as chamamos de “funções vulneráveis”. As funções que escolhemos como vulneráveis para cada vulnerabilidade são definidas caso a caso.

Análise de alcançabilidade do grafo de chamadas

Depois de identificar as funções vulneráveis de cada biblioteca, precisamos determinar se a aplicação as utiliza. E é aqui que entra a segunda parte da nossa equação: a análise estática. Usamos a análise estática para criar o grafo de chamadas do programa de entrada e, com ele, avaliar a alcançabilidade das funções vulneráveis.

Antes de nos aprofundarmos na análise estática, vamos usar um exemplo para mostrar como um grafo de chamadas pode ajudar a determinar se um código vulnerável é alcançável. Vamos supor que temos uma aplicação web Java com duas dependências diretas: spring-web e mongodb. spring-web depende ainda de outras duas bibliotecas: hibernate e jackson. mongodb depende de slf4j. Podemos ilustrar essas relações como um grafo de dependências.

Gráfico de dependências que mostra o código da aplicação conectado a spring-web e mongodb, que dependem das bibliotecas hibernate, jackson e sfl4j

Agora, vamos acrescentar mais detalhes ao grafo, considerando as chamadas de funções. Vamos simplificar bastante nossos componentes e supor que o código da aplicação e todas as bibliotecas de terceiros tenham apenas duas funções, com exceção de spring-web, que tem três. Vamos adicionar todas as funções como nós do grafo e traçar uma seta da função A para a função B se, e somente se, a função A chamar a função B.

Gráfico de dependências que mostra o código da aplicação conectado às bibliotecas spring-web, hibernate, jackson, mongodb e slf4j.

O resultado é uma estrutura de dados amplamente conhecida chamada grafo de chamadas. Agora, vamos adicionar funções vulneráveis a ele. Para representá-las, usamos um monstrinho de biscoito.

Diagrama que mostra dependências do código da aplicação conectando as bibliotecas spring-web, hibernate, jackson, mongodb e slf4j, e suas funções

Por fim, vamos definir uma regra simples de alcançabilidade para o nosso grafo de chamadas: dizemos que uma função X é alcançável a partir do código da aplicação se, e somente se, houver um caminho no grafo que comece em qualquer nó do código da aplicação e termine na função X. Seguindo essa regra, colorimos de vermelho todas as funções alcançáveis e de verde as inalcançáveis.

Diagrama que mostra as dependências do código de um aplicativo, com caminhos alcançáveis em vermelho e não alcançáveis em verde por Spring Web, Hibernate, Jackson, MongoDB e SQLite.

Como podemos ver, alguns monstrinhos de biscoito (funções vulneráveis) são alcançáveis e outros não. Isso nos permite criar uma regra de priorização para as vulnerabilidades de terceiros no nosso app. Se pelo menos uma função vulnerável associada a uma determinada vulnerabilidade for alcançável, priorizamos a correção dessa vulnerabilidade. Por outro lado, se nenhuma das funções vulneráveis associadas a ela for alcançável, podemos deixar sua correção para depois.

Definimos o que é a análise de alcançabilidade do grafo de chamadas e como ela pode ajudar a priorizar vulnerabilidades. Mas como calcular o grafo de chamadas?

Execute o app para obter o grafo de chamadas

Uma maneira de obter o grafo de chamadas é executar o app e coletar dados sobre a execução das funções usando um agente em tempo de execução. Mas essa solução dinâmica (em tempo de execução) não é ideal.

Primeiro, isso acontece “tarde demais” no processo de desenvolvimento de software, pois exige acesso a uma aplicação em execução. E os desenvolvedores sempre preferem identificar e corrigir vulnerabilidades alcançáveis antes que elas sejam mescladas à branch principal ou levadas à produção. Além disso, configurar agentes em tempo de execução geralmente exige uma configuração personalizada para cada aplicação, o que dá bastante trabalho. Por fim, os desenvolvedores relutam em adicionar esses agentes aos apps por receio de que isso cause indisponibilidade ou reduza o desempenho.

Isso nos levou a uma solução melhor, que resolve todos os problemas da abordagem dinâmica: a análise estática de código.

Como calcular o grafo de chamadas estaticamente

Ao contrário de uma solução em tempo de execução (dinâmica), a análise estática não executa o programa para coletar informações. Em vez disso, analisamos o código-fonte da aplicação. Criamos um algoritmo para avaliar variáveis do programa, atribuições, chamadas de funções, ponteiros etc. e, com base nessa análise, derivamos o grafo de chamadas.

Na verdade, no mundo da segurança, a análise estática já é bastante conhecida, principalmente por causa do Teste de segurança estática de aplicações (SAST). O objetivo do SAST é encontrar problemas de segurança no código da sua aplicação, como uma injeção de SQL.

Renault estacionado com uma placa personalizada exibindo o texto de injeção de SQL “DROP DATABASE TABLE”

Fonte: publicado por um brincalhão polonês, provavelmente nunca funcionou na prática

Lembre-se de que nosso caso é diferente do SAST. Não estamos descobrindo falhas de segurança no seu próprio código. Estamos lidando com falhas de segurança nas suas dependências de terceiros.

Certo, então usamos como entrada um programa (código-fonte) e todas as suas dependências (seus códigos-fonte) e geramos um grafo de chamadas. Parece simples, não é?

Mas a análise estática não é fácil. Embora seja uma área de pesquisa ativa, analisar programas do mundo real ainda é, em certa medida, um problema não resolvido para a maioria das linguagens de programação. Vamos usar Java como exemplo. Java pode parecer uma linguagem fácil de analisar: é fortemente tipada e não é muito dinâmica por natureza (em comparação, por exemplo, com Javascript). Então, vamos ver mais de perto como criar um grafo de chamadas usando análise estática para programas Java e quais são os desafios.

Desafio nº 1 da análise estática: despacho dinâmico

O despacho dinâmico ocorre quando o método correto a ser executado é selecionado somente em tempo de execução, de acordo com o contexto da execução. Ele está presente na grande maioria das linguagens de programação: das orientadas a objetos, como Java (polimorfismo), às funcionais, como Haskell (funções de ordem superior e closures). O despacho dinâmico é um desafio para a análise estática porque ela precisa modelar o contexto que determina quais funções serão chamadas em tempo de execução.

Considere o seguinte exemplo em Java. Vamos supor que temos uma interface Animal com um método, makeSound, e três classes que implementam essa interface: Dog, Cat e GoblinShark. Vamos considerar o programa simples abaixo.

public static void main(String[] args) { 
Animal animal = createAnimal(); 
animal.makeSound(); 
}

Olhando apenas para esse método (ou seja, sem considerar o código de createAnimal()), não conseguimos determinar se um animal é um Dog, um Cat ou talvez um GoblinShark.

Uma maneira de lidar com essa situação é considerar todas as possibilidades. Na verdade, existe um algoritmo de criação de grafos de chamadas que faz exatamente isso: ele se chama Class Hierarchy Analysis (CHA). Para o exemplo acima, o CHA produziria o seguinte grafo de chamadas:

Grafo de chamadas mostrando main chamando createAnimal, Dog.makeSound, Cat.makeSound e GoblinShark.makeSound

Esse grafo de chamadas é correto, mas pode não ser preciso. Por exemplo, vamos considerar agora a função createAnimal, que havíamos omitido e que, como se vê, cria apenas cachorros.

public static void main(String[] args) { 
Animal animal = createAnimal(); 
animal.makeSound(); 
} 
private static Animal createAnimal() { 
return new Dog();
}

Como o CHA considera todas as possibilidades, o grafo de chamadas não muda e fica impreciso. A análise que resolve esse problema é chamada de análise points-to e exige acompanhar os valores possíveis para os quais uma determinada referência aponta.

Trecho de código que mostra um objeto Animal criado como Dog e a chamada de makeSound(), com setas apontando para uma ilustração de cachorro identificada como “Heap”.

A análise points-to de Andersen é um exemplo de análise que faz isso. Um algoritmo de criação de grafos de chamadas que usa a análise points-to de Andersen produziria o seguinte resultado.

Grafo de chamadas mostrando main chamando createAnimal e Dog.makeSound, com createAnimal apontando para Dog.<init>

Fluxo de controle

Acompanhar ponteiros fica ainda mais difícil quando consideramos o fluxo de controle do programa. Veja o caso mais simples:

Animal animal = new Dog(); 
animal = new Cat(); 
animal.makeSound();

No programa acima, um cachorro nunca faz nenhum som. Mas, para determinar isso corretamente, a análise estática precisa levar em conta a ordem das instruções. Em programas simples como o acima, podemos usar um truque antigo dos compiladores e converter o código para a forma estática de atribuição única (SSA). A exigência da SSA é que cada variável do programa seja atribuída exatamente uma vez. Assim, o problema acima desapareceria.

Mas, em geral, a ordem das instruções influencia o estado do programa e, portanto, pode influenciar o grafo de chamadas. Um padrão comum, por exemplo, é chamar alguma função init de uma biblioteca de terceiros antes de invocar os métodos públicos dessa biblioteca.

LibClass someLibClass = new LibClass(); 
someLibClass.init(); 
someLibClass.doStuff();

O método init pode configurar um estado interno complexo. Por isso, o grafo de chamadas do programa acima poderia ser muito diferente do grafo de chamadas do programa abaixo.

LibClass someLibClass = new LibClass(); 
someLibClass.doStuff(); 
someLibClass.init();

Outro aspecto importante são as instruções de fluxo de controle, como if, while, for, switch etc. Considere o programa:

Animal a = null; 
if (someCondition) { 
a = new Cat(); 
} else { 
a = new Dog(); 
}
a.makeSound();

Nesse caso, uma abordagem razoável é ignorar a condição e considerar que os dois ramos da instrução if são alcançáveis. Por causa desse desvio, na prática, a análise points-to acompanha um conjunto de valores possíveis para os quais cada variável do programa aponta, em vez de acompanhar apenas um.

Outros aspectos

Há muitos outros desafios envolvidos na criação de grafos de chamadas para linguagens de programação modernas. Os exemplos acima mal arranham a superfície do problema. Outros desafios incluem:

  • Coleções: acompanhar o que está armazenado em cada índice de uma coleção.

  • Analisar os métodos públicos de uma biblioteca: em linguagens como Java, os métodos públicos das bibliotecas geralmente recebem interfaces como entrada para permitir que os clientes personalizem as funcionalidades, o que dificulta a análise points-to desses métodos.

  • Execução dinâmica de código: um padrão muito popular, especialmente em frameworks, consiste em construir o nome da função durante a execução, armazená-lo como uma string e usar uma biblioteca especial que recebe o nome da função como entrada e a executa (por exemplo, a reflexão em Java).

Na prática, ao usar análise estática, você precisa decidir explicitamente o que quer oferecer suporte, pois oferecer suporte a tudo seria inviável do ponto de vista computacional. Em outras palavras, o uso da análise estática sempre envolve uma troca entre precisão e viabilidade computacional (o tempo e os recursos necessários para calcular a resposta). Por exemplo, na análise de alcançabilidade do grafo de chamadas de programas Java, uma possível abordagem seria executar uma análise points-to padrão de Andersen com SSA, sempre considerar todos os ramos das condicionais (por exemplo, instruções if), analisar todas as coleções como conjuntos sem considerar os índices e ignorar a execução dinâmica de código (exceto em frameworks populares, como o Spring).

Conclusão

Nesta publicação, mostramos como identificar vulnerabilidades alcançáveis combinando pesquisa especializada em segurança com análise estática automatizada. O resultado dessa análise é sempre uma aproximação da realidade. Ainda assim, é uma aproximação útil, especialmente para quem desenvolve e precisa identificar as vulnerabilidades com maior probabilidade de representar um risco imediato para o negócio.

Na Snyk, fazemos tudo o que está ao nosso alcance para ajudar quem desenvolve a lidar com seus problemas de segurança. Recentemente, lançamos vários recursos para ajudar a priorizar vulnerabilidades. Avaliar a alcançabilidade de vulnerabilidades é um deles. Para saber como usar esse recurso, confira o anúncio de lançamento.

Mantenha-se em segurança!

Comece a participar de desafios de Capture the Flag

Aprenda a resolver desafios de Capture the Flag assistindo sob demanda ao nosso workshop virtual introdutório.

Publicado em: