[GreHack 2023][Write Up – Crypto] The Fist Of The Hacker


I always forget my passwords, and passwords managers are insecure, so I decided to implement an authentication method using behavioral biometrics: I can authenticate myself using keystroke dynamics just by typing my public passphrase. Since keystroke dynamics are proper to each person, you’ll never be able to usurp my identity and recover my flag !

The sources attached to the challenge are used to connect to the server. Remplace localhost with 10.0.201.101 and 1234 with 20004 then run client.py.

Connection : nc 10.0.201.101 20004
Author : Olivier


Découverte

 

On se retrouve donc avec un client en python :

import json
import socket
from measure import Measure


class Client:
    def __init__(self, host, port) -> None:
        self.__measure = Measure('Strong authentication')
        self.__host = host
        self.__port = port

    def authenticate(self) -> None:
        print('Authenticate with keystroke dynamics!')
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.connect((self.__host, self.__port))
            template = self.__measure.measure()
            serialized = template.serialize()
            sock.sendall(serialized + b'\n')
            data = sock.recv(1024)
            resp = json.loads(data)
            if 'error' in resp:
                print(f'An error occurred: {resp["error"]}')
            else:
                if resp['authenticated']:
                    print('The flag is:', resp['flag'])
                else:
                    print('Authentication failed')

if __name__ == "__main__":
    client = Client('10.0.201.101', 20004)
    client.authenticate()

 

La classe Measure implémente la fonction measure qui permet de calculer une métrique à envoyer au serveur.

Cette fonction semble calculer des délais entre des appuis et relâchements de touches, mais en réalité une compréhension précise de ce que fait cette fonction n’est pas nécessaire pour flag.

La première chose à faire est évidemment de voir ce que le client envoie au serveur et ce que le serveur lui répond.
On va donc mettre des print et on obtient quelque chose du genre :

ENVOI : {"passphrase": "Strong authentication", "times": [0.1028287410736084, 0.17987561225891113, 0.10290122032165527, 0.08740973472595215, 0.05573439598083496, 0.12426257133483887, 0.08687615394592285, 0.13477826118469238, 0.0952138900756836, 0.08710360527038574, 0.0001575946807861328, 0.0868988037109375, 0.00011897087097167969, 0.09469747543334961, 0.10299205780029297, 0.07918906211853027, 0.0789339542388916, 0.09472036361694336, 0.10296082496643066, 0.023270368576049805, 0.04672122001647949, 0.10877394676208496, 0.9473681449890137, 0.951209306716919, 0.1866776943206787, 0.25628042221069336, 8.225440979003906e-05, 0.5041184425354004, 0.4807918071746826, 0.13070917129516602, 0.25031399726867676, 0.320664644241333, 0.1983351707458496, 0.2773408889770508, 0.27114319801330566, 0.08945155143737793, 0.14119696617126465, 0.12580084800720215, 0.12322163581848145, 0.01673436164855957, 0.1036074161529541, 0.18971800804138184]}

RECEPTION : {"score": 2036.16557538910666, "authenticated": 0, "flag": "Nope"}

La passphrase est donnée, la seule chose à modifier pour récupérer le flag est donc le tableau de temps.

Résolution

 

On s’aperçoit assez vite en modifiant ce tableau qu’en y mettant des valeurs extravagantes, le score monte très vite, il doit donc s’agir d’une métrique calculant un taux de ressemblance avec un tableau de référence, notre objectif va donc être de modifier les valeurs du tableau une par une afin de faire baisser le score au maximum, jusque ce que la correspondance soit assez bonne et que le flag nous soit envoyé.

On ne sait cependant pas à quel point le score doit être bas pour arriver à s’authentifier, on va donc faire décimale par décimale, les valeurs semblent toutes êtres entre 0 et 1, pour chaque valeur, on va donc commencer à 0 et monter de 0.1 en 0.1 et garder celle qui correspond à un score minimum :

import pwn
import json

a = {"passphrase": "Strong authentication"}

def test(c, d, times):
    times[c] = d
    a["times"] = times
    res = json.dumps(a).encode()
    print(f"RES : {res.decode()}")
    r = pwn.remote("10.0.201.101", 20004, level="error")
    r.sendline(res)
    out = r.recvline().decode()[:-1]
    print(f"OUT : {out}")
    score = float(json.loads(out)["score"])
    print(f"SCORE : {score}")
    r.close()
    return score

if __name__ == "__main__":
    times = 42 * [0.0]
    for i in range(42):
        last_score = test(i, 0.0, times)
            for j in range(1, 11):
                score = test(i, j / 10, times)
                if score > last_score:
                    best = (j - 1) / 10
                    break
                else:
                    last_score = score
                    times[i] = best

Avec cette méthode, on arrive bien avec un tableau qui correspond au mieux que nous puissions faire en affinant au dixième :

[0.1, 0.1, 0.0, 0.1, 0.0, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.0, 0.1, 0.1, 0.1, 0.1, 0.0, 0.0, 0.0, 0.1, 0.1, 0.1, 0.2, 0.0, 0.1, 0.0, 0.0, 0.0, 0.0, 0.1, 0.0, 0.1, 0.1, 0.1, 0.0, 0.1, 0.1, 0.2, 0.0, 0.0, 0.1]

Mais ce n’est pas suffisant, on va donc passer aux millièmes et refaire la même chose en repartant du tableau ainsi obtenu.

À noter que les valeurs obtenues sont optimales au dixième près, pour une valeur de i, on va donc tout tester de i-0.1 à i+0.1, en allant de 0.01 en 0.01 (sauf quand i est 0 étant donné que les valeurs doivent être positives).

if __name__ == "__main__":
    times = [0.1, 0.1, 0.0, 0.1, 0.0, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.0, 0.1, 0.1, 0.1, 0.1, 0.0, 0.0, 0.0, 0.1, 0.1, 0.1, 0.2, 0.0, 0.1, 0.0, 0.0, 0.0, 0.0, 0.1, 0.0, 0.1, 0.1, 0.1, 0.0, 0.1, 0.1, 0.2, 0.0, 0.0, 0.1]
    for i in range(42):
        current = times[i]

        if current == 0.0:
            last_score = test(i, current, times)
            for j in range(1, 11):
                score = test(i, current + j / 100, times)
                if score > last_score:
                    best = current + ((j - 1) / 100)
                    break
                else:
                    last_score = score
                    times[i] = best
        else:
            last_score = test(i, current - 0.1, times)
            for j in range(1, 21):
                score = test(i, (current - 0.1) + (j / 100), times)
                if score > last_score:
                    best = (current - 0.1) + ((j - 1) / 100)
                    break
                else:
                    last_score = score
            times[i] = best

Il se trouve qu’en étant précis au millième sur une bonne partie des valeurs suffit à avoir un score inférieur à 500, ce qui permet donc de s’authentifier et de flag :

RES : {"passphrase": "Strong authentication", "times": [0.07, 0.12, 0.05, 0.07, 0.05, 0.08, 0.07, 0.07, 0.08, 0.07, 0.07, 0.07, 0.02, 0.07, 0.07, 0.05, 0.07, 0.03, 0.02, 0.03, 0.04, 0.1, 0.1, 0.2, 0.0, 0.1, 0.0, 0.0, 0.0, 0.0, 0.1, 0.0, 0.1, 0.1, 0.1, 0.0, 0.1, 0.1, 0.2, 0.0, 0.0, 0.1]}
OUT : {"score": 490.16557538910666, "authenticated": 1, "flag": "GH{7yp1ng_15_4ll_y0u_n33d}"}

HEADORTEILINGÉNIEUR SÉCURITÉ
Pentester & CTF enjoyer

Add a comment

*Please complete all fields correctly