Featured image of post Lire de l'ASCII art en moins de 2 secondes
photo parJason Leung

Lire de l'ASCII art en moins de 2 secondes

Défi CTF - ASCII vers texte

Read in English

Le défi

Aujourd’hui, j’aimerais vous raconter mon tout premier défi CTF, lors du Hackathon OVHcloud 2024 (événement interne).

De tous les défis de ce Hackathon auxquels j’ai participé, c’est celui qui m’a le plus marqué. Je l’ai trouvé à la fois intéressant et amusant. J’ai particulièrement apprécié sa simplicité : le problème se comprend très vite, mais nécessite plusieurs étapes pour être résolu.

Il se présente sous la forme de cette simple page web :

OK, tout est écrit. Il y a un code qui apparaît en ASCII art. Je dois renvoyer ce code via l’URL de soumission en moins de 2 secondes. Totalement impossible pour un humain, je vais donc devoir automatiser le processus.

Prise d’informations

La toute première étape, c’est d’éliminer les inconnues et d’obtenir le maximum d’informations. En rafraîchissant la page quelques fois et en inspectant le code HTML, j’obtiens mes réponses.

Combien de caractères ?
Il y en a toujours 12.

La police est-elle toujours la même ?
Non. Il y a plein de polices différentes à chaque fois. Ça rajoute une difficulté supplémentaire.

Le code correspondant est-il présent quelque part dans le code HTML de la page web ?
Bien sûr que non, mais ça valait le coup de vérifier.

Les étapes

Ceci étant dit, il est temps d’automatiser le processus. Posons-nous une question : il existe des tonnes d’outils dans le monde du logiciel capables de générer de l’ASCII art à partir de caractères, mais est-ce que l’inverse existe ?
Pour être honnête, je n’ai pas beaucoup d’espoir. L’utilité d’un tel système étant très limitée, je doute que beaucoup de gens se soient amusés à programmer ce genre de chose, sauf pour un défi.
Mais pour être encore plus honnête, je n’avais même pas vraiment envie de chercher, car j’avais une idée plus intéressante en tête.

Avez-vous déjà entendu parler des OCR ? Ça signifie Optical Character Recognition (reconnaissance optique de caractères). Ce sont des outils basés sur le deep learning qui se spécialisent dans la reconnaissance de caractères, de mots ou de phrases dans des contextes visuels.

On les retrouve dans des outils qui nous entourent au quotidien : reconnaissance des codes postaux sur les enveloppes, génération de documents PDF à partir d’une photo de texte, automatisation de la vérification de documents d’identité, lecture de photos de radars, ou encore aides pour les personnes malvoyantes.

C’est exactement ce dont on a besoin, non ? Oui, mais il va falloir passer par plusieurs étapes.
D’abord, l’ASCII art sur la page web n’est pas une image mais un ensemble de caractères affichés.
Ensuite, on ne s’intéresse pas à toute la page web, juste à la partie contenant l’ASCII art.
Enfin, l’idée serait de prendre une capture de cette partie de la page web et de l’envoyer à l’OCR.

Sans avoir de connaissance particulière en OCR, et même si j’ai un minimum de confiance en son fonctionnement, s’il y a bien une chose que j’ai toujours retenue en IA, c’est que le prétraitement des données est aussi important, si ce n’est plus, que le traitement par deep learning en lui-même.
La donnée ici, c’est une image, on va donc devoir faire un minimum de traitement d’image.

Si je ne me trompe pas, mon plan est parfait : il ne reste plus qu’à envoyer la sortie de l’OCR dans la requête de soumission.

Voici donc le plan :

  • Récupérer la page web via une requête HTTP GET
  • Faire du web scraping sur la page web afin d’extraire uniquement la partie qui nous intéresse (l’ASCII art)
  • Générer une image à partir de l’ASCII art
  • Faire du traitement d’image afin de la rendre plus facilement lisible pour un OCR. Ici, je vais juste mettre le dessin en noir sur blanc, en jouant sur les contrastes pour le rendre plus net
  • Donner l’image à l’OCR
  • Récupérer le résultat pour le coller dans la requête de soumission
  • Il ne reste plus qu’à lire le résultat

C’est parti

Web scraping

Je choisis d’utiliser le langage Python, le plus adapté et le plus simple pour ce qu’on veut faire.

Grâce à la librairie Requests, je récupère le contenu complet de la page web.

1
response = requests.get(url, headers=headers)

Ensuite, je parse le contenu avec BeautifulSoup. Il n’y a qu’une seule balise <pre> dans le code HTML. Cette balise représente du texte préformaté qui doit être présenté exactement tel qu’il est écrit dans le fichier HTML. C’est elle qui contient les données qui nous intéressent.

1
2
soup = BeautifulSoup(response.text, 'html.parser')
pre_tag = soup.find('pre')

Après quelques essais, voici ce qui s’affiche dans mon terminal :

Traitement d’image

Passons maintenant au traitement d’image.
J’ai d’abord pensé à OpenCV. Mais comme l’installation sur mon laptop prenait une éternité, j’ai trouvé une autre librairie de traitement d’image que je ne connaissais pas : Pillow. D’après ce que j’ai lu, elle est certes plus limitée en fonctionnalités mais beaucoup plus légère, et plus rapide pour des traitements simples. Exactement ce qu’il me faut, essayons !

D’abord, je crée l’image à partir de la sortie :

1
2
3
4
5
6
7
8
9
# Create the image
img = Image.new('RGB', (max_width, line_height * len(lines)), color='white')
d = ImageDraw.Draw(img)

# Render the ASCII art onto the image
y = 0
for line in lines:
    d.text((0, y), line, font=font, fill='black')
    y += line_height

Ensuite, je définis une fonction de prétraitement :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def preprocess_image(img):
    """Apply a series of image enhancements to improve OCR results"""
    
    # 1. Convert image to grayscale
    img = img.convert('L')
    
    # 2. Increase contrast and brightness
    enhancer = ImageEnhance.Contrast(img)
    img = enhancer.enhance(2.5)  # Increase contrast even more
    enhancer = ImageEnhance.Brightness(img)
    img = enhancer.enhance(1.7)  # Further enhance brightness
    
    # 3. Apply sharpening filter to emphasize edges
    img = img.filter(ImageFilter.SHARPEN)
    
    # 4. Apply binary thresholding to make the text stand out
    img = img.point(lambda p: p > 128 and 255)  # Convert to pure black & white
    
    # 5. Resize image to a larger scale
    img = img.resize((img.width * 2, img.height * 2), Image.LANCZOS)  # Scale up the image
    
    return img

Il ne reste plus qu’à appeler ma fonction de prétraitement :

1
img = preprocess_image(img)

Voici quelques résultats :

Pas mal du tout ! Toutes les étapes préliminaires sont faites, place maintenant à la pièce maîtresse de notre solution : l’OCR.

Reconnaissance optique de caractères (OCR)

Si j’ai immédiatement pensé à un OCR en attaquant ce défi, c’est parce que j’en connaissais déjà un avec lequel j’avais un peu joué il y a plusieurs années : Tesseract OCR.
Cet OCR, développé par Google, a un taux de réussite très élevé, mon choix était déjà fait.
Il se trouve qu’il existe un wrapper Python de cette librairie : pytesseract.

Cette librairie est très simple d’utilisation et clé en main. On lui donne une image contenant du texte, elle nous renvoie le texte. Facile.

Écrivons ça :

1
extracted_text = pytesseract.image_to_string(img).strip()

Voyons les résultats !

L’image :

Le résultat :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
( TINA LO

Pn
fI\ ( ) )

\
CTD)

—T"( (>><
LI\' ")

yy)! »)
( /( J \

|r if \

Aïe. On dirait que quelque chose ne va pas.
Vous savez, je suis juste développeur. Parfois je ne lis pas toute la documentation.
Si je l’avais fait, j’aurais su une chose importante à propos de Tesseract OCR : il y a 13 modes de fonctionnement, et le mode par défaut n’est pas du tout celui dont j’avais besoin.
Le mode par défaut est le PSM 3 : Fully Automatic Page Segmentation, But No OSD. (OSD signifie “Orientation and Script Detection”. C’est une fonctionnalité de Tesseract qui détecte automatiquement l’orientation du texte dans une image et tente d’identifier le script utilisé)
Dans ce mode, Tesseract va automatiquement segmenter la page et essayer de reconnaître le texte.

Voici quelques modes utiles à connaître :

  • PSM 5 : Assume a single uniform block of vertically aligned text
    Tesseract considère que l’image en entrée contient un seul bloc de texte, comme un paragraphe ou une page avec une taille de police et une structure uniformes.

  • PSM 6 : Assume a single uniform block of text
    Similaire au PSM 5, mais ce mode considère que le bloc de texte est plus susceptible d’être aligné horizontalement. Souvent utile pour les images simples contenant un seul bloc de texte.

  • PSM 7 : Treat the image as a single text line
    Tesseract considère que l’image en entrée ne contient qu’une seule ligne de texte, ce qui peut améliorer la précision avec des entrées plus petites.

  • PSM 8 : Treat the image as a single word
    Tesseract considère que l’image en entrée ne contient qu’un seul mot. Ce mode est utile pour reconnaître des mots isolés.

Vous avez deviné quel mode va nous intéresser ici ?
Le mode PSM 8 bien sûr !
Ce mode se configure via l’option --psm 8

J’ai également compris autre chose sur le fonctionnement par défaut de Tesseract : il va chercher la correspondance entre les mots qu’il lit et ceux d’un dictionnaire, en anglais par défaut.
Ici, ça ne nous intéresse pas et on ne veut pas que Tesseract soit influencé par un quelconque dictionnaire, mais qu’il interprète ce qu’il lit caractère par caractère.
Ça se configure via l’option -c

Mieux encore, Tesseract offre la possibilité de limiter le domaine de reconnaissance de caractères à une liste blanche. Je lui donne donc les caractères a-z, A-Z et 0-9.

Voici donc le code pour appeler pytesseract, avec la bonne configuration :

1
2
config = '--psm 8 -c tessedit_char_whitelist=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
extracted_text = pytesseract.image_to_string(img, config=config).strip()

Testons ça :

L’image :

Le résultat :

1
SVZGR41AXQJK

On tient enfin notre résultat !

Capture the flag

J’aurais aimé vous dire que j’ai mesuré le temps exact de toute l’opération, mais j’avoue que dans le feu de l’action je n’y ai pas du tout pensé. Je n’ai clairement pas choisi les technologies les plus rapides d’ailleurs, mais ce que je peux vous dire, c’est que ça a pris moins de deux secondes.

La durée du processus étant respectée, je n’ai plus qu’à ajouter le résultat dans la requête de soumission. J’ajoute au cas où une condition qui invalide le résultat de Tesseract si le mot trouvé ne fait pas exactement 12 caractères, et c’est parti !

1
2
3
submission_url = f"http://.../challenges/ascii-text/url?solution={extracted_text}"
submit_response = requests.get(submission_url, headers=headers)
print(submit_response.text)

Réponse :

Flag capturé, en route vers le prochain !

Encore un très grand merci à OVHcloud et à tous les organisateurs de ce Hackathon, qui ont fait passer un super moment à mes collègues et moi-même ♥

Généré avec Hugo
Thème Stack conçu par Jimmy