Dificilmente existe uma pessoa hoje em dia que nunca clicou no botão “Recuperar senha” em profunda frustração. Mesmo que pareça que a senha estava sem dúvida correta, a próxima etapa para recuperá-la ocorre sem problemas, visitando um link de um e-mail e inserindo a nova senha (não vamos enganar ninguém; não é novidade, pois você acabou de digitá-la três vezes já na etapa 1 antes de pressionar o botão desagradável).
A lógica por trás dos links de e-mail, no entanto, é algo que deve ser examinado minuciosamente, pois deixar sua geração insegura abre uma enxurrada de vulnerabilidades relacionadas ao acesso não autorizado a contas de usuários. Infelizmente, aqui está um exemplo de estrutura de URL de recuperação baseada em UUID que muitos provavelmente encontraram, mas que, no entanto, não segue as diretrizes de segurança:
https://.../recover/d17ff6da-f5bf-11ee-9ce2-35a784c01695
Se esse link for usado, geralmente significa que qualquer pessoa pode obter sua senha, e é simples assim. Este artigo tem como objetivo aprofundar os métodos de geração de UUID e selecionar abordagens inseguras para sua aplicação.
UUID é um rótulo de 128 bits comumente usado na geração de identificadores pseudoaleatórios com dois atributos valiosos: é suficientemente complexo e único o suficiente. Principalmente, esses são requisitos essenciais para que o ID saia do back-end e seja mostrado ao usuário explicitamente no front-end ou geralmente enviado pela API com a capacidade de ser observado. Isso dificulta a adivinhação ou a força bruta em comparação com id = 123 (complexidade) e evita colisões quando o id gerado é duplicado para o usado anteriormente, por exemplo, um número aleatório de 0 a 1000 (singularidade).
As partes "suficientes" na verdade vêm, em primeiro lugar, de algumas versões do Universally Unique IDentifier, deixando-o aberto a pequenas possibilidades de duplicações, o que é, no entanto, facilmente mitigado por lógica de comparação adicional e não representa uma ameaça devido a condições dificilmente controladas para sua ocorrência. E em segundo lugar, a complexidade de várias versões de UUID é descrita no artigo; em geral, é considerado muito bom, exceto em outros casos extremos.
As chaves primárias nas tabelas de banco de dados parecem confiar nos mesmos princípios de complexidade e exclusividade que o UUID. Com a ampla adoção de métodos integrados para sua geração em muitas linguagens de programação e sistemas de gerenciamento de banco de dados, o UUID muitas vezes surge como a primeira escolha para identificar entradas de dados armazenadas e como um campo para unir tabelas em geral e subtabelas divididas por normalização. O envio de IDs de usuário provenientes de um banco de dados por meio de API em resposta a determinadas ações também é uma prática comum para simplificar um processo de unificação de fluxos de dados, sem geração extra de ID temporário e vinculá-los aos do armazenamento de dados de produção.
Em termos de exemplos de redefinição de senha, a arquitetura provavelmente inclui uma tabela responsável por tal operação que insere linhas de dados com o UUID gerado sempre que um usuário clica no botão. Ele inicia o processo de recuperação enviando um e-mail para o endereço associado ao usuário por seu user_id e verificando para qual usuário redefinir a senha com base no identificador que ele possui assim que o link de redefinição for aberto. Existem, no entanto, diretrizes de segurança para tais identificadores visíveis aos usuários, e certas implementações de UUID as atendem com vários graus de sucesso.
A versão 1 da geração de UUID divide seus 128 bits no uso de um endereço MAC de 48 bits do identificador de geração do dispositivo, um carimbo de data / hora de 60 bits, 14 bits armazenados para incrementar o valor e 6 para controle de versão. A garantia de exclusividade é, portanto, transferida das regras na lógica do código para os fabricantes de hardware, que devem atribuir valores corretamente para cada nova máquina em produção. Deixar apenas 60+14 bits para representar a carga útil mutável deteriora a integridade do identificador, especialmente com essa lógica transparente por trás dele. Vamos dar uma olhada em uma sequência de números de UUID v1 gerados consequentemente:
from uuid import uuid1 for _ in range(8): print(uuid1())
d17ff6da-f5bf-11ee-9ce2-35a784c01695 d17ff6db-f5bf-11ee-9ce2-35a784c01695 d17ff6dc-f5bf-11ee-9ce2-35a784c01695 d17ff6dd-f5bf-11ee-9ce2-35a784c01695 d17ff6de-f5bf-11ee-9ce2-35a784c01695 d17ff6df-f5bf-11ee-9ce2-35a784c01695 d17ff6e0-f5bf-11ee-9ce2-35a784c01695 d17ff6e1-f5bf-11ee-9ce2-35a784c01695
Como pode ser visto, a parte “-f5bf-11ee-9ce2-35a784c01695” permanece a mesma o tempo todo. A parte alterável é simplesmente uma representação hexadecimal de 16 bits da sequência 3514824410 - 3514824417. É um exemplo superficial, pois os valores de produção geralmente são gerados com intervalos de tempo mais significativos, portanto a parte relacionada ao carimbo de data e hora também é alterada. A parte do carimbo de data/hora de 60 bits também significa que uma parte mais significativa do identificador é alterada visualmente em uma amostra maior de IDs. O ponto central permanece o mesmo: o UUIDv1 é facilmente adivinhado, por mais aleatório que pareça inicialmente.
Pegue apenas o primeiro e o último valores da lista fornecida de 8 ids. Como os identificadores são gerados de forma estrita, conseqüentemente, fica claro que existem apenas 6 IDs gerados entre os dois fornecidos (subtraindo as partes hexadecimais alteráveis), e seus valores também podem ser encontrados definitivamente. A extrapolação de tal lógica é a parte subjacente por trás do chamado ataque Sandwich, que visa forçar o UUID a conhecer esses dois valores de fronteira. O fluxo de ataque é direto: o usuário gera o UUID A antes que ocorra a geração do UUID alvo e o UUID B logo depois. Supondo que o mesmo dispositivo com uma parte MAC estática de 48 bits seja responsável por todas as três gerações, ele define ao usuário uma sequência de IDs potenciais entre A e B, onde o UUID de destino está localizado. Dependendo da proximidade temporal entre os IDs gerados e o alvo, o intervalo pode estar em volumes acessíveis à abordagem de força bruta: verifique todos os UUID possíveis para encontrar os existentes entre os vazios.
Em solicitações de API com o endpoint de recuperação de senha descrito anteriormente, isso se traduz no envio de centenas ou milhares de solicitações com UUIDs consequentes até que uma resposta informando a URL existente seja encontrada. Com a redefinição de senha, isso leva a uma configuração onde o usuário pode gerar links de recuperação em duas contas que ele controla o mais próximo possível para pressionar o botão de recuperação na conta de destino à qual ele não tem acesso, mas apenas conhece e-mail/login. As cartas para contas controladas com UUIDs de recuperação A e B são então conhecidas, e o link de destino para recuperar a senha da conta de destino pode ser forçado sem ter acesso ao e-mail de redefinição real.
A vulnerabilidade origina-se do conceito de confiar apenas no UUIDv1 para autenticação do usuário. Ao enviar um link de recuperação que dá acesso à redefinição de senhas, presume-se que, ao seguir o link, um usuário é autenticado como aquele que deveria receber o link. Esta é a parte em que a regra de autenticação falha devido ao UUIDv1 ser exposto à força bruta direta, da mesma forma como se a porta de alguém pudesse ser aberta sabendo como são as chaves de ambas as portas vizinhas.
A primeira versão do UUID é considerada legada principalmente porque a lógica de geração usa apenas uma parte menor do tamanho do identificador como um valor aleatório. Outras versões, como a v4, tentam resolver esse problema mantendo o mínimo de espaço possível para versionamento e deixando até 122 bits para carga aleatória. Em geral, traz um total de variações possíveis para um convulso 2^122
, que por enquanto é considerado como satisfazendo a parte "suficiente" em relação ao requisito de exclusividade do identificador e, assim, atendendo aos padrões de segurança. A abertura para a vulnerabilidade de força bruta pode aparecer se a implementação da geração diminuir de alguma forma significativamente os bits restantes para a parte aleatória. Mas sem ferramentas de produção ou bibliotecas, deveria ser esse o caso?
Vamos nos aprofundar um pouco na criptografia e dar uma olhada mais de perto na implementação comum de geração de UUID em JavaScript. Aqui está a função randomUUID()
que depende do módulo math.random
para geração de números pseudo-aleatórios:
Math.floor(Math.random()*0x10);
E a função aleatória em si, resumindo, é apenas a parte de interesse do tópico deste artigo:
hi = 36969 * (hi & 0xFFFF) + (hi >> 16); lo = 18273 * (lo & 0xFFFF) + (lo >> 16); return ((hi << 16) + (lo & 0xFFFF)) / Math.pow(2, 32);
A geração pseudo-aleatória requer o valor inicial como base para realizar operações matemáticas sobre ele para produzir sequências de números aleatórios o suficiente. Tais funções são baseadas exclusivamente nele, o que significa que se forem reinicializadas com a mesma semente de antes, a sequência de saída irá corresponder. O valor inicial na função JavaScript em questão compreende as variáveis hi e lo, cada uma delas um número inteiro não assinado de 32 bits (0 a 4294967295 decimal). Uma combinação de ambos é necessária para fins criptográficos, tornando quase impossível reverter definitivamente os dois valores iniciais conhecendo seus múltiplos, pois depende da complexidade da fatoração de inteiros com números grandes.
Dois números inteiros de 32 bits juntos trazem 2^64
casos possíveis para adivinhar variáveis hi e lo por trás da função inicializada que produz UUIDs. Se os valores hi e lo forem conhecidos de alguma forma, não será necessário nenhum esforço para duplicar a função de geração e conhecer todos os valores que ela produz e produzirá no futuro devido à exposição do valor inicial. No entanto, 64 bits nos padrões de segurança podem ser considerados intolerantes à força bruta em um período de tempo mensurável para que faça sentido. Como sempre, o problema vem da implementação específica. Math.random()
transforma vários 16 bits de cada hi e lo em resultados de 32 bits; no entanto, randomUUID()
além dele muda o valor mais uma vez devido à operação .floor()
, e a única parte significativa de repente agora vem exclusivamente de hi. Isso não afeta a geração de forma alguma, mas faz com que as abordagens de criptografia desmoronem, pois deixa apenas 2^32
combinações possíveis para toda a semente da função de geração (não há necessidade de força bruta em hi e lo, pois lo pode ser definido como qualquer valor e não influencia a saída).
O fluxo de força bruta consiste em adquirir um único ID e testar possíveis valores elevados que possam tê-lo gerado. Com alguma otimização e hardware de laptop médio, pode levar apenas alguns minutos e não requer o envio de muitas solicitações ao servidor como no ataque Sandwich, mas executa todas as operações offline. O resultado dessa abordagem causa a replicação do estado da função de geração usado no back-end para obter todos os links de redefinição criados e futuros no exemplo de recuperação de senha. As etapas para evitar o surgimento de vulnerabilidades são diretas e exigem o uso de funções criptograficamente seguras, por exemplo, crypto.randomUUID()
.
UUID é um ótimo conceito e facilita muito a vida dos engenheiros de dados em muitas áreas de aplicação. Porém, nunca deve ser utilizado em relação à autenticação, pois neste artigo são trazidas à tona falhas em determinados casos de suas técnicas de geração. Obviamente, isso não se traduz na ideia de que todos os UUIDs sejam inseguros. A abordagem básica, porém, é persuadir as pessoas a não usá-los de forma alguma para fins de segurança, o que é mais eficiente e, bem, seguro do que estabelecer limites complexos na documentação sobre como usá-los ou como não gerá-los para esse fim.