Desafio 7: alteração de hash de senhas em um banco de dados

Você possui um banco de dados legado que armazena registros de senhas criptografadas com o algoritmo MD5. Neste desafio, você precisa criar uma solução de migração das senhas para que sejam armazenadas usando outro algoritmo, como Argon2, Bcrypt ou qualquer outro algoritmo adequado.

É importante ressaltar que o uso do algoritmo MD5 para qualquer finalidade que necessite de segurança mínima é absolutamente desaconselhável. Talvez a única situação em que o uso desse algoritmo seja considerado minimamente válido é a que menciono aqui, porém, mesmo assim, não recomendo totalmente essa abordagem.

Esse problema da segurança de senhas é interessante e requer uma contextualização adequada para projetar uma solução levando em consideração toda a complexidade envolvida. Portanto, antes de discutir arquitetura em si, vamos examinar a abordagem mais simples (e incorreta) de se resolver isso.

A pior maneira possível de se lidar com senhas em uma aplicação é armazená-la sem qualquer forma de criptografia diretamente no banco de dados. Esse tipo de situação pode gerar a seguinte feature: quando o usuário solicita a recuperação de senha, o sistema a envia por e-mail em texto simples (já vi isso acontecer).

Obviamente, qualquer usuário mal-intencionado que possua acesso ao e-mail em questão pode invadir a conta sem maiores esforços. Esse é o cenário que o atacante ainda terá alguma dificuldade. Além disso, qualquer usuário com acesso ao banco de dados (como um profissional de tecnologia que trabalhe naquela empresa, seja terceiro ou não), pode simplesmente acessar qualquer conta que desejar. Com todos esses problemas, é necessário aumentar a segurança do armazenamento de senhas de alguma forma.

Agora, vamos considerar a aplicação de uma criptografia básica para garantir que as senhas armazenadas não estejam em texto simples no banco de dados. A abordagem mais simples e direta seria criptografar as senhas usando o algoritmo MD5, que é o algoritmo mais simples disponível para esse propósito. No entanto, não considerarei o Base64 (que tecnicamente não é um método de criptografia, por ser reversível) como uma opção válida, pois ele é ainda mais fácil de ser comprometido do que o MD5.

Criptografia de senhas com MD5

Vamos supor que a lista abaixo seja a TOP 10 senhas mais usadas no Brasil em 2023 (esse tipo de lista pode ser facilmente obtida na internet caso você realmente queira):

et
aliquam
adipisci
velit
pariatur
nisi
perspiciatis
aspernatur
in
eveniet
cupiditate
dolorem
blanditiis
et
debitis
quasi

Para simular o resultado de uma encriptação ingênua, o código abaixo lê um arquivo chamado senhas.txt com o conteúdo acima e encripta todas em MD5:

const fs = require('fs');
const crypto = require('crypto');

//encripta as senhas em md5
function hashWords(words) {
const hashedWords = [];
words.forEach((word) => {
const hash = crypto.createHash('md5').update(word).digest('hex');
hashedWords.push(hash);
});
return hashedWords;
}

const filename = 'senhas.txt';
//le o arquivo, linha a linha
fs.readFile(filename, 'utf8', (err, data) => {
if (err) {
console.error('Error reading file:', err);
} else {
const words = data.split('\n');
const hashedWords = hashWords(words);
console.log(hashedWords);
}
});

O output esperado são os hashes de cada palavra. Abaixo, encontram-se o texto original e os hashes correspondentes para facilitar o entendimento:

Word: et, MD5 Hash: 4de1b7a4dc53e4a84c25ffb7cdb580ee
Word: aliquam, MD5 Hash: 6ccb6c0b0d7845edf67759d85e49257d
Word: adipisci, MD5 Hash: 439a7d9b0548adbedcce838e37e84ba1
Word: velit, MD5 Hash: 5defbc048070dadee1fa6f2e62532f1f
Word: pariatur, MD5 Hash: 316b03751b6ce3ac1a40431fd1b4562b
Word: nisi, MD5 Hash: 41868eeb2fe509f484b6fbff817109fd
Word: perspiciatis, MD5 Hash: 685d971356ab9724b447e8bd797c9607
Word: aspernatur, MD5 Hash: 9bcd7572750706c1363e617f111df8f9
Word: in, MD5 Hash: 13b5bfe96f3e2fe411c9f66f4a582adf
Word: eveniet, MD5 Hash: 403d9c9c22442e35f0058ecffab3f374
Word: cupiditate, MD5 Hash: 27da117980d2b7ecd055226572732fd6
Word: dolorem, MD5 Hash: ca50dfb151104b1ee005d68fa9a970ce
Word: blanditiis, MD5 Hash: 94fc61eee3c0d01abace7a2feb84bc4c
Word: et, MD5 Hash: 4de1b7a4dc53e4a84c25ffb7cdb580ee
Word: debitis, MD5 Hash: 4987eaf14c53e0220179291dc9abad39
Word: quasi, MD5 Hash: 27bec4fedb5a399af4c5b7ad031d1127

Em uma linguagem visual, o algoritmo básico que faz esse tipo de encriptação é basicamente o seguinte:

Pronto, acabamos de criar um mecanismo de criptografia básico. Agora vamos discutir os motivos dessa solução ser problemática.

Problemas óbvios de segurança

Vamos supor agora que eu sou um usuário mal-intencionado. Eu sei que o sistema que eu quero invadir usa MD5 como algoritmo de criptografia e não implementa medidas adicionais de segurança. Caso eu obtenha acesso a esse banco de dados, mesmo com os dados estejam supostamente criptografados (lembrando que: dados vazarem não é uma questão de “se” e sim de “quando”), consigo facilmente acessar as senhas originais. Basta escolher uma lista de senhas comuns (facilmente acessível na internet) e criptografá-las manualmente. Após esse processo, é só comparar os hashes gerados com a base de dados vazada e, em muito pouco tempo, consigo quebrar a “criptografia” em MD5.

O código abaixo demonstra uma forma de se fazer isso. Acredite, o esforço computacional necessário se resume a pesquisar em um arquivo.

const crypto = require('crypto');
const fs = require('fs');

//encontra o hash em uma lista
function breakMD5(targetHash, wordlist) {
const words = fs.readFileSync(wordlist, 'utf8').split('\n');

for (let i = 0; i < words.length; i++) {
const word = words[i].trim();
const hashedWord = crypto.createHash('md5').update(word).digest('hex');

if (hashedWord === targetHash) {
return `Senha encontrada! A senha original é: ${word}`;
}
}

return 'Senha não encontrada.';
}

const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

//colhe os inputs
rl.question('Qual o hash MD5 que será quebrado?', (targetHash) => {
rl.question('Qual é caminho para a lista de senhas comuns?', (wordlist) => {
const result = breakMD5(targetHash, wordlist);
console.log(result);
rl.close();
});
});

Chamemos esse código de bruteforce.js. O resultado esperado desse programa é algo parecido com o abaixo:

output desse programa de bruteforce em nodejs

É possível deixar esse tipo de ataque ainda mais eficiente que isso, com o uso de rainbow tables, por exemplo. Essa técnica consiste em utilizar tabelas pré-calculadas (exemplo) de hashes de algoritmos inseguros de senhas comuns que já vazaram (como 123456, qwerty, etc). Com esse tipo de esforço adicional, fica ainda mais fácil quebrar implementações ingênuas de criptografia como a mencionada.

Melhorando o algoritmo principal

Dado que o problema de criptografar uma senha em texto simples é que o hash resultante é sempre o mesmo, podemos adicionar uma palavra secreta ao texto original para forçar o mecanismo a gerar um hash diferente. A essa palavra secreta damos o nome de salt. O resultado está ilustrado na imagem abaixo:

exemplo de uma solução usando salt

Observe que o hash gerado é diferente justamente por causa do salt usado, o que já dificulta ataques de força bruta e rainbow tables. Lembrando que o salt é um segredo e deve ser tratado como tal no código. Portanto, recomenda-se armazenar o salt em uma ferramenta de gerenciamento de segredos, como Hashicorp Vault, AWS Secrets Manager, Cloud Key Management do Google ou Azure Key Vault.

Ao criptografar a senha + um salt (no caso, usei 123) usando MD5, obtemos o seguinte resultado:

Word: et, MD5 Hash:96cc393455b8a97e6206dd020bbda7d8
Word: aliquam, MD5 Hash:b3cd358e91a2d1979fc6a44381db8ebe
Word: adipisci, MD5 Hash:0c0e8fdd55268b6fce1d68a70cb4510f
Word: velit, MD5 Hash:93e7919f671cbe9e212032647d78abc1
Word: pariatur, MD5 Hash:fea0237dc765245f6553bedebc8c670a
Word: nisi, MD5 Hash:94a99896a9b4a09a35f21b6a85a1fb5d
Word: perspiciatis, MD5 Hash:b35cbd0386b613abf6cacf408d0060f3
Word: aspernatur, MD5 Hash:a355f2be1ea1b0d619f12afec7c30555
Word: in, MD5 Hash:259d2fe446ff0d37213239b7b7c4fed3
Word: eveniet, MD5 Hash:7648ef54ef4d7ac7f03e80114da12eb7
Word: cupiditate, MD5 Hash:aaff93f231bb510fe90ffe25cdfcbef4
Word: dolorem, MD5 Hash:5aff5d5c7713d915585221c2354da9df
Word: blanditiis, MD5 Hash:ef03b8d5d1ef72d824aded7b01fd064a
Word: et, MD5 Hash:96cc393455b8a97e6206dd020bbda7d8
Word: debitis, MD5 Hash:354532bbc71e8085c1260272a8da1c58
Word: quasi, MD5 Hash:4cac058c5b13c1b3ff64916cb7a0ee66

Apesar de, teoricamente, ser mais difícil reverter diretamente esses hashes usando esse método, ainda enfrentamos um problema: caso a gestão do salt não seja feita corretamente (por exemplo, se algum desenvolvedor efetuar um commit acidentalmente do salt no repositório), o salt pode se tornar acessível através do histórico do Git, resultando no mesmo problema inicial. Se você nunca considerou isso, saiba que o seu histórico do Git também pode ser um vetor de ataque para uma pessoa mal-intencionada verdadeira motivada.

Vamos explorar algumas maneiras de melhorar ainda mais o algoritmo.

Derivação de chaves

Além do salt, é recomendado o uso de um algoritmo de derivação de chaves para aumentar a complexidade da chave original. O objetivo é: adicionar etapas adicionais ao processo de geração da senha, tornando-o mais complexo e, consequentemente, mais difícil de ser quebrado. O termo técnico correto para se referir a essa complexidade adicionada é entropia.

O fluxo básico desta técnica está ilustrado na imagem abaixo:

Fluxo básico de uma função de derivação de chaves

Ou seja, a partir do texto original (representado por IKM, na imagem), o algoritmo recebe um salt e uma informação opcional qualquer. Através de uma função hash (neste exemplo, é usado o algoritmo HMAC-SHA256) e um parâmetro de delimitação de tamanho de chave, uma chave do tamanho especificado é gerada partir do texto original. Em um sistema minimamente seguro, é esse resultado que é armazenado em um banco de dados.

A cada tentativa de login, se a senha digitada pelo usuário gerar a mesma hash que está armazenada no banco de dados, o acesso é autorizado.

Solução técnica em si

Peço desculpas pelo ênfase excessiva em técnicas de baixo nível ao invés de focar na arquitetura. No entanto, como o assunto segurança é um pouco obscuro e muitas falhas básicas ainda ocorrem, optei por contextualizar melhor antes de discutir os pontos principais de System Design.

Dito isso, vamos abordar o fluxo macro do problema, que está representado na imagem abaixo:

Fluxo básico de encriptação

Aqui temos dois cenários possíveis. As senhas podem estar armazenadas como hashes MD5 simples ou como hashes MD5 com salts e key derivations aplicados. Em ambos os casos, a reversão por meio de força bruta não é recomendável devido ao esforço computacional envolvido, embora seja, de certa forma, possível.

Exemplo de quanto tempo leva para quebrar uma senha em MD5, usando força bruta, levando a complexidade da senha em consideração

Dado que força bruta não é recomendada, podemos simplesmente forçar o reset de senha por parte dos usuários. No momento em que eles inserirem a nova senha, podemos criptografá-la usando o novo método (usando salts, key derivations e um algoritmo mais confiável, como o BCrypt) e armazenar a nova senha em uma coluna adicional no banco de dados, a fim de evitar alterações destrutivas inicialmente. Após um período de monitoramento após a migração (por exemplo, alguns meses), a coluna anterior pode ser removida sem riscos. É importante ressaltar que, quando se trata de segurança, manter a coluna com os hashes MD5 é um problema caso ocorra uma violação de dados.

Basicamente, o que é necessário para resolver esse problema não é necessariamente uma nova arquitetura, e sim, um novo fluxo de gestão das senhas. Com essas alterações, o fluxo final fica como o da imagem abaixo.

Fluxo final

Caso você encontre algum erro ou informação incompleta neste artigo, por favor, me mande uma mensagem que eu ajustarei o texto.

Obrigado por ler até aqui!

Até!

Você gostou do conteúdo e gostaria de fazer mentoria comigo? Clique aqui e descubra como.

--

--