• PT/BR
  • ENG/US

Armazenamento de senhas: o bom, o mau e o velho

  • 17/06/2021
  • Ramiro Pozzani
  • Blog
  • Alto Contraste
  • +Aumentar fonte
  • -Diminuir fonte
Armazenamento de senhas: o bom, o mau e o velho

Desde cedo na história da computação, pessoas notaram a necessidade de limitar o acesso à informação. O método mais prático (e em muitos casos, insuficiente) encontrado foi a utilização da combinação de usuário e senha como método de autenticação e autorização.

Como alguém que pratica análise de segurança/pentest, você rotineiramente irá avaliar a segurança na forma como essas senhas são armazenadas, neste artigo vamos caminhar pela evolução dessa solução, vendo como cada bloco foi construído para trazer a segurança que temos hoje.

Ao invés de simplesmente mostrar o que funciona, vamos partir por todas as ideias que foram (e infelizmente ainda são) implementadas, explicando por que muitas delas são insuficientes, e finalmente estudaremos os métodos atuais considerados seguros, deixando ainda alguns questionamentos que podem ser levantados durante o processo de teste ou mesmo design de uma solução.

A primeira ideia, e mais simples, foi a de gravar as senhas em texto puro (plain text).
Encontramos diversos problemas óbvios com esta solução. É extremamente perigoso o fato de que pessoas com acesso ao banco de dados acessarem a senha de todos.
Em caso de uma invasão, o atacante pode utilizar de qualquer senha para se autenticar no sistema como o usuário, e pior ainda, em caso de leak (divulgação pública dessas informações), todos os seus usuários estarão em risco não apenas no seu sistema, mas também a outros sistemas caso o usuário utilize da mesma combinação de usuário e senha.

Uma solução (horrível) para o problema de simplesmente “ver” as senhas, foi a utilização de encodes. Mudando o encode padrão (normalmente ASCII ou UTF-8) para algo que não faça sentido (uma pessoa não entenda o conteúdo da informação apenas olhando para ela) em um primeiro momento, como o base64.

Base64, como todos os outros tipos de enconding, é apenas uma forma diferente de codificar a informação; não traz nenhuma propriedade de segurança (no caso, confidencialidade, a garantia que os dados só serão acessados por pessoas que tem permissão de acessar tal dado).
É fato o que talvez isso proteja contra ataques de shoulder surfing, isto é, caso alguém olhe para a tela do administrador da tabela de dados enquanto este está olhando as senhas. O atacante (a não ser que tenha memória fotográfica ou tenha um PHD em algoritmos) não conseguirá “desvendar” qual a senha armazenada.

Porém, qualquer pessoa com acesso aos dados, poderá usar o decode da função utilizada e simplesmente obter a senha em texto puro. Apesar do intuito do artigo ser criar uma jornada pelos erros que muitos desenvolvedores cometeram, vale notar que esse, apesar de grave, continua sendo encontrado em pleno 2021 (e provavelmente continuará existindo em anos futuros).
Existe uma versão atualizada dessa “solução”, na qual desenvolvedores simplesmente modificam a tabela de encoding, logo o decode padrão não funciona. Apesar de dar um pouco mais de trabalho, ainda assim é possível fazer o decode da informação. Logo está solução atualizada não tem serventia alguma.

Um paralelo: apesar de base64 não prover nenhum tipo de proteção ao dado, é muito comum ela ser utilizada ao final de operações criptográficas, principalmente de cifras como AES, RSA, entre outras.
O motivo é que essas cifras normalmente produzem um array de bytes, que muitas vezes causa algum tipo de erro se um desenvolvedor tentar imprimir este dado utilizando os já mencionados encodings padrões como UTF-8. Como o base64 possui uma tabela de entrada que engloba todas as combinações de bits, e sua saída pode ser interpretada por caracteres alfanuméricos, é comum que após a cifração de um dado, ele seja convertido para base64. Isto é perfeitamente normal e aceitável.

Ao entender por que a mudança de encoding não funciona, percebemos que ela não garante nenhum tipo de propriedade de segurança (confidencialidade, integridade, disponibilidade). Por outro lado, temos as funções criptográficas que, entre outros, garantem confidencialidade.

Isto nos dá uma pista do que poderia ser o próximo passo.

Pensando em criptografia, poderíamos pensar facilmente nas funções mais conhecidas: simétricas e assimétricas. Em ambos os casos temos na entrada um texto puro, utilizamos uma função primitiva em conjunto com uma chave, e o resultado é um texto cifrado que, em um primeiro momento, não faz nenhum sentido.

Diferente da solução com enconding, você não pode simplesmente invocar a função de decifrar e obter o dado em texto puro. Para tal você precisaria de mais informações, no caso uma chave (e muitas vezes mais informações como IVs, Tags, que nesse momento iremos ignorar).

A questão desse tipo de solução é que o dado ainda pode ser recuperado. Você “só” precisa da chave.
Já é um avanço considerável no caminho certo, mas ainda possui um risco inerente muito grande. Não seria lindo se pudéssemos utilizar alguma função na qual, não interessa o que o atacante possua, ele “nunca” (em termos de ataques, a palavra nunca é muito forte para ser utilizada literalmente) consiga obter o valor original? Além de mais seguro, tiraria do dev/DBA a responsabilidade de armazenar as chaves.

Outro paralelo: Em temos de LGPD e GDPR, muita gente tem considerado o texto cifrado como o texto anônimo (e, no caso da LGPD, não estaria sob a jurisdição da lei), e isso simplesmente não é verdade. Devido ao fato do dado poder ser decifrado com uma chave, ele fica fora da categoria de anônimo, mas isto é assunto para um outro blog post.

Voltando às cifras…

Há muito tempo a comunidade de segurança percebeu que seria mais interessante utilizar de alguma função que fosse apenas de “uma mão”, isto é, dado um texto plano, fosse gerada uma informação (texto cifrado) que não pudesse ser transformada no texto plano de volta.

Isso resolve o problema de autenticação porque é possível comparar a informação gerada com a senha que o usuário digitou com o que eu tenho armazenado no meu banco de dados. Sem precisar armazenar o que o usuário digitou.

E no caso de o atacante ter acesso aos dados, ele simplesmente não conseguirá entender quais são a senha em texto puro baseado apenas no que possui.

Aí entram as funções de hash. Antes de falarmos sobre elas vale lembrar que funções de hash não foram criadas para armazenar senhas, e que em geral, quando alguém cria alguma função criptográfica nova, velocidade de processamento e facilidade de optimização são atributos levados em consideração, lembre-se disso pois terá impacto mais pra frente.

Vale lembrar que nem toda função hash foi criada igual, algumas foram criadas para fins específicos como encontrar erros em cadeia de bits (os famosos checksums), outras mapeiam uma entrada para uma saída com o intuito de diminuir a probabilidade de colisões ao máximo. Como não temos interesse em checar à integridade de um dado, iremos deixar de lado os checksums como CRC, SUM, etc, e iremos nos concentrar nas funções hashs como md5, sha1, sha2, sha3, etc.

A função hash escolhida no momento foi a md5 (lembrando, estamos andando pela história), rápida (não tanto quanto a sha1), fácil de ser implementada, e aparentemente forte o suficiente para proteger os dados armazenados.
Uma característica interessante dos hashs é que você não consegue distinguir nada olhando apenas para o hash gerado, e que textos parecidos geram hashs totalmente diferentes, como por exemplo:

senha1 -> md5 -> 92f20dafc5e5ac1c66820903c492cc04
senha2-> md5 -> 34ae07655b9a94e90556aff79bfd60b0

Supersenha123 -> md5 -> 65d13bbb06a63ddc6ec01956d5b1e624

Reparem que analisando apenas o hash, não só não conseguimos ter ideia do texto gerado, mas também não temos nenhuma informação relacionado ao tamanho da senha.

Todo hash tem uma característica única, o texto de saída tem sempre o mesmo tamanho, independente da entrada. Uma string de apenas um caractere, irá gerar uma hash do mesmo tamanho de uma string de um milhão de caracteres.

Ao mesmo tempo que isso é interessante, pois como comentado impede que o atacante tenha qualquer tipo de informação sobre o tamanho original do texto, gera um problema, ao aceitar um tamanho N infinito e gerar um tamanho X fixo e, em muitos casos, menor que N. É de se imaginar que devem existir textos diferentes que geram o mesmo hash, este tipo de caso é conhecido como colisão. É importante lembrar este termo, pois para o seu hash ser considerado seguro, ele tem que ser resistente a colisão, isto é, dado um valor do hash, deve ser difícil para o atacante encontrar dois textos que resultem neste mesmo hash.

Colisão é algo totalmente fatal para a segurança de senhas, pois nesse caso não é necessário, nem ao menos, a senha real do usuário para acessar o sistema, basta encontrar um texto que gere o hash esperado. Muitas vezes isso é mais rápido do que achar o texto original. Se quiser saber mais sobre colisões recomendo que pesquisem não apenas pelo md5, mas pelo hash nativo do MySQL nterior ao 4.1, que possui diversos problemas de segurança.

MD5 Security: https://en.wikipedia.org/wiki/MD5#Security
Mysql 4.1 CVE: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2003-1480

Mas vale o reforço, a partir do momento que encontram colisões para sua função hash, ela não funciona mais para armazenamento de senhas (e não custa lembrar, para garantir integridade dos dados também).

Bom, com muitas pessoas utilizando md5 para armazenar suas senhas, os atacantes rapidamente perceberam uma coisa: não existe necessidade de ficar calculando o hash para as mesmas senhas para cada ataque. O md5 de “senha1” é sempre 92f20dafc5e5ac1c66820903c492cc04.
Então, a partir do momento que eu o calculei uma vez, eu posso armazenar esse valor em uma tabela, e procurar em todos os leaks que eu tenha por senhas que utilizam md5. Se alguma delas corresponde a 92f20dafc5e5ac1c66820903c492cc04, eu sei que essa senha é “senha1”

Nascem assim as rainbow tables. Atacantes passam a compartilhar essas tabelas, e elas começam a ficar gigantes em tamanho, cobrindo boa parte das senhas utilizadas.

Hoje em dia temos diversas tabelas para download, assim como para consulta online. Incluindo tabelas direcionadas a sistemas específicos como WordPress, Joomla, LANMAN (Windows), etc.

Para combater estas tabelas, muitos sistemas passaram a utilizar o conceito de sal único.
O sal (do inglês salt) nada mais é do que um texto concatenado a senha, e o hash seria feito na base dessa informação. Logo passamos de md5(senha) para md5(senha.sal).

Caso o sal seja “segredo”, o hash de senha1 muda de 92f20dafc5e5ac1c66820903c492cc04 para 8ebfaf95dbe8909f0baa3441b9e7cf7f (que é o hash de senha1segredo).

Nos protegemos de rainbow tables genéricas, mas não temos nenhuma proteção contra análise de frequência.
Sabemos que algumas senhas são mais populares do que outras, ao ter acesso a um banco de dados com uma tabela de senhas com sal fixo, podemos notar que alguns hashes se repetem.
Esses que se repetem indicam as senhas mais populares. Muitas vezes uma dessas senhas é de um administrador ou de alguém com acesso maior ao sistema.

Para nos proteger desse tipo de análise, passamos a utilizar sals únicos para cada senha. Um sal muito comum quando essa ideia começou a ser implementada o nome do usuário como sal, assim se o usuário João e a usuária Maria possuíam a mesma senha, “senha1”, ambos ficam com senhas totalmente diferentes no banco de dados pois:

João ->     sha1(senha1João}   – > 88727cfb5a5b3e950df9cf4a887060051af77032
Maria -> sha1 (senha1Maria}  -> 13429eb4fb18a56a16034cd7286346d3e6a07d9c

Como podem reparar, também atualizamos a função hash para sha1, pois ela, na época, possuía menos falhas do que o md5, e é executada de forma mais rápida (sim, mesmo gerando um texto de tamanho maior).

Ao mesmo tempo que as técnicas evoluíram, evoluía também o poder computacional. Não apenas poder puro de processamento, mas a forma como esse processamento era feito. Memórias mais rápidas, computação distribuída, e o extremamente popular uso de GPU e seus múltiplos núcleos para operações matemáticas.

Com a popularização das GPU e inicialmente do CUDA (NVIDIA}, a um custo relativamente baixo, pessoas conseguiam testar dezenas de milhões de combinações por segundo. Tornando esse estilo de hash com salta aleatório muito menos seguro do antes.

A solução encontrada foi criar um número maior de iterações, ao invés de fazer o hash apenas uma vez, passamos a fazer 100 vezes, 1.000 vezes, 10.000 vezes. O código agora se parecia com algo do tipo

for(int i = ; i < 10000;i++)
senha  = sha1(senha.sal);

Se estou utilizando 10.000 iterações, eu aumento o tempo que levará para alguém quebrar essa senha em 10.000 vezes. Por complicações de implementação, na verdade o tempo para quebrar fica consideravelmente menor que 10.000 vezes. Segue abaixo um exemplo mais atual, que mostra a quantidade de senhas por segundo que o programa hashcat consegue testar (executando em 8 placas de vídeo Nvidia GTX 1080, de um lado utilizando sha1 puro, do outro pbkdf1 (sha1 com mil interações e um sal):

Como podemos ver, no sha1 puro o programa consegue testar 68 bilhões de senhas por segundo, já com o PBKDF2, que utiliza o sha1, consegue “apenas” 26 milhões, uma redução em torno de 2.500 vezes. Isto é, se no caso do sha1 puro o atacante iria demorar 1 dia para descobrir a senha, agora demoraria 2.500 dias.

Se você está acostumado a fazer análise de códigos, percebe um problema nesta solução.

O problema de usar o código acima é que estamos quebrando a regra 0 sobre criptografia: “Não utilize criptografia feita em casa”. Estamos inventando ao invés de utilizar o que já existe.

Para esse caso temos, por exemplo, o PBKDF2, que nos permite escolher a função hash, a quantidade de iterações, até mesmo tamanho do resultado. O interessante de utilizar funções públicas dessa forma é que caso alguém encontre alguma falha, seja na implementação, nos hashes utilizados, você fica sabendo mais facilmente, pois sairão notícias avisando para atualizar a utilização do PBKDF2 (e relativos}.

Agora por fim nos falta livrarmos do sha1, pois já foram é possível encontrar colisões nesta função (https://security.googleblog.com/2017/02/announcing-first-sha1-collision.html), neste caso recomendamos a utilização de sha256. SHA3, ou BLAKE2.

Ao comentar o PBKDF2 finalmente chegamos ao aceitável, mas as vezes você quer ir além do aceitável, seja pela criticidade do sistema, sensibilidade dos dados, ou simplesmente não quer correr o risco de ter algum analista de segurança analisando seu código e falando que daria para melhorar 😉

Neste artigo foi dito que funções hash não foram feitas para armazenar senhas, e como uma boa função ela consegue ser facilmente otimizada em hardware. Como vimos no caso do hashcat, as ferramentas para quebrarem senha têm evoluído de forma impressionante.

No caso do popular CUDA, por exemplo, processamento você tem de monte, mas te falta memória. E mesmo quando não te falta, ficar acessando memória pode se tornar um gargalo.
Com isso em mente temos funções como a scrypt, que fazem basicamente o que o PBKDF2 faz, porém, utilizando muito mais memória, portanto muito mais difícil de ser otimizada em hardware.

Utilizando a mesma ferramenta e o mesmo setup do PBKDF2, veja a quantidade de palavras por segundo que o hashcat consegue testar para o scrypt.

 

Um total de 3.5 milhões de senhas por segundo; muito menor do que o do pbkf2 não? Cabe a você escolher qual o melhor para o seu sistema, pbkdf2, scrypt, bcrypt, todos são válidos, mas lembre-se de utilizar uma implementação que tenha tempo no mercado.

E é isso. Espero que tenham aproveitado.

Pensamentos avulsos:

Quando for analisar um sistema, tenha certeza que ele tem um limite para o tamanho das senhas, existem alguns que se preocupam tanto em garantir que a senha vai ter um tamanho mínimo, que esquecem do tamanho máximo, visto que você normalmente envia a senha por POST, existe a chance de você conseguir um DoS (o sistema gasta todo seu processamento calculando o hash, e não consegue mais responder nenhum request de um usuário real, fazendo com que o sistema fique, na prática, offline) no sistema, devido à senha gigante.

Apesar desse texto tentar trazer um pouco de como foi a evolução do armazenamento de senhas, a verdade é que teve muito sistema fazendo isso de forma correta, alguns inclusive já utilizando padrões extremamente seguro décadas atrás. Bcrypt por exemplo existe desde 1999.

Quer ver algo diferente, e com sérios problemas sendo implementado? estude o LANMAN, que era como o Windows primitivo armazenava senhas.
https://en.wikipedia.org/wiki/LAN_Manager#Security_weaknesses

E, por fim, muito se pergunta sobre onde armazenar o hash aleatório, lembre-se que, se você seguir boas práticas de segurança em database, uma delas é utilizar múltiplos usuários para diferentes tabelas, você pode proteger o seu salt, mesmo que alguém encontre um sqlinjection no seu sistema.
Isso infelizmente é raro. A maioria das vezes o mesmo usuário que acessa a tabela de “notícias” (um exemplo) tem acesso à tabela dos usuários, e também a todas as tabelas do sistema. Pense em criar diferentes usuários para diferentes fins, muitos leaks teriam sido evitados se o usuário do database que acessava um dado não sensível como uma notícia, mensagem, etc, não tivesse acesso também a tabela de usuários. Este fato por isso só, já é um problema de segurança.

Últimos pensamentos, apesar de ser interessante exigir que o usuário coloque algum tipo de complexidade em sua senha (tamanho mínimo, não pode conter caracteres em sequência, etc, fazê-lo mudar a senha a cada X dias), até que ponto isso realmente protege o sistema? Se o usuário sempre esquece sua senha, ou vive recebendo mensagem falando que está na hora de trocar a senha, não estaria ele mais sujeito a cair em um ataque de phishing? Vale a reflexão.

#SejaSiDier

Faça parte do nosso universo tecnológico

Trabalhe no sidi