home
tags
whoami

Hacktober - Ghost Dash

2018/10/16

Le Hacktober se déroulait du 09 au 18 octobre 2018, sous forme de CTF jeopardy en ligne. Avec Hackatsuki on s'est inscrit l'avant-veille de la fin, mais même sans tryhard dessus j'ai beaucoup apprécié le CTF. Certains challs étaient vraiment bien foutus, il y avait même une catégorie "Adventure" dans laquelle il fallait progresser à travers un mini-RPG créé spécialement. Dans le même style, le challenge Ghost Dash prenait la forme d'un mini-jeu à compléter en modifiant son code source.


Challenge : Ghost Dash
Catégorie : Programmation
Points : 250
Énoncé : Some guy created a game that he claims is unbeatable. Prove him wrong by getting the trick-or-treater past 100 ghosts.
Ressource : unbreakable.zip

Reconnaissance

L'archive contient un fichier unbreakble_game.py et un dossier img, ce dernier servant simplement à stocker les images utilisées dans le jeu.

On lance python3 unbreakable_game.py :

screen

Et on clique sur "GO!" :

screen

On incarne donc Morty et le but du jeu est d'esquiver les fantômes en déplaçant de gauche à droite. Si un fantôme est trop proche de nous, c'est perdu :

screen

De plus, chaque fantôme est plus rapide que le précédent, et au bout de 20 et quelques il devient impossible de les esquiver volontairement. Sauf que d'après l'énoncé du challenge, pour obtenir le flag il faut obtenir un score de 100. Intentionnellement impossible à réaliser, on va donc devoir aller bidouiller dans le code du jeu pour faire en sorte d'avoir un score de 100 ou plus. C'est là que la partie marrante commence :)

Bidouillage

Ouvrons avec notre éditeur de texte préféré le fichier unbreakable.py. Le code faisant au total 256 lignes, je vous laisse le consulter par vous-même (cf. unbreakable.zip plus haut), je ne mettrai des extraits que des parties intéressantes.
Il s'agit donc d'un jeu développé avec pygame. La première chose à faire est de regarder le code dans son intégralité et comprendre sa structure.
En gros, au début on a la déclaration des variables, puis une suite de fonctions, et à la fin la fonction gameLoop(), dans laquelle tout se passe.
De plus, on remarque que des chaînes de caractères de la forme " b'hexa' " sont disséminées un peu partout dans le code :

# ...
alpha = b'4142434445464748494a4b4c4d4e4f505152535455565758595a6162636465666768696a6b6c6d6e6f707172737475767778797a313233343536373839305f2d'
# ...
hidden = b'666c61672d6a6b5f6e6f745f7468655f7265616c5f666c6167'
# ...
text = font.render(u(b'446f646765643a20') + str(count), True, colWhite)
# ...
def do_coll():
    messageShow(u(b'424f4f2120476f7463686121'))

Il pourrait être intéressant de savoir ce que ces strings veulent dire. Pour ce faire on se rend sur asciitohex par exemple, et on copy/paste les chaînes en hexa.
On en conclut que alpha contient l'alphabet, hidden un faux flag "flag-jk_not_the_real_flag", la troisième ligne affiche le score actuel "Dodged:score", et la fonction do_coll() est responsable de l'affichage du message de défaite "BOO! Gotcha!".
Ces informations valent de l'or, car à partir de certaines d'entre elles nous allons pouvoir remonter progressivement aux mécanismes du jeu et modifier son comportement.
Je vais présenter 3 manières différentes de flag, chacune ayant un point de départ différent.

À partir du game over

La manière la plus intuitive à mon sens est de remonter en partant du message "BOO! Gotcha!", c'est-à-dire la fonction do_coll(). En effet, le but du jeu est de ne pas perdre (si si, je vous jure). Regardons alors comment empêcher algorithmiquement le game over :)
Pour remonter, un simple ctrl+F dans notre éditeur (Atom dans mon cas) suffit, et on tombe sur ce bout de code :

def get_intersect(x1,x2,w1,w2,y1,y2,h1):
    if y1 < (y2 + h1):
        if x1 > x2 and x1 < (x2 + w2) or (x1 + w1) > x2 and (x1 + w1) < (x2 + w2):
            do_coll()

Intéressant ! On est donc devant la fonction qui décide si on a perdu ou pas. Voyons maintenant quand est-ce que cette fonction est appelée (ctrl+F get_intersect) :

if x > displayWidth - (imgMySprite.get_rect().size[0] / 2):
    x = displayWidth - (imgMySprite.get_rect().size[0] / 2)
elif x < 0 - (imgMySprite.get_rect().size[0] / 2):
    x = 0 - (imgMySprite.get_rect().size[0] / 2)
if y > displayHeight - (imgMySprite.get_rect().size[1] / 1.5):
    y = displayHeight - (imgMySprite.get_rect().size[1] / 1.5)
elif y < 0 - (imgMySprite.get_rect().size[1] / 4):
    y = 0 - (imgMySprite.get_rect().size[1] / 4)

get_intersect(x, thingStartx, imgMySprite.get_rect().size[0], thingWidth, y, thingStarty, thingHeight)

pygame.display.update()
clock.tick(60)

On comprend qu'elle prend en paramètres nos coordonnées et celles du fantôme pour déterminer est-ce qu'on est "trop proche" de ce dernier.
Donc rien de plus simple, on rajoute un bon gros '#' devant l'appel à la fonction, afin qu'elle ne soit jamais appelée. Ainsi, l'algorithme ne peut plus nous faire perdre :

#get_intersect(x, thingStartx, imgMySprite.get_rect().size[0], thingWidth, y, thingStarty, thingHeight)

Et le résultat en jeu :

screen

Le fantôme est collé à Morty mais le jeu continue. on a qu'à attendre les 100 dodges, ce qui est plutôt rapide étant donné que les fantômes vont de plus en plus vite.
Une fois les 100 points atteints, le jeu continue et le flag s'affiche dans le terminal à partir duquel il est lancé :

screen

À partir du compteur de points

Deuxième méthode, ici il s'agit de dire au jeu "bonjour j'ai 100 points". Avant de commencer il est préférable de décommenter l'appel à la fonction get_intersect(), on fait comme si on avait rien flag. La ligne de code suivante :

text = font.render(u(b'446f646765643a20') + str(count), True, colWhite)

nous permet de découvrir la variable count, contenant notre score actuel. Cette variable n'est en réalité initialisée que dans la fonction escaped() :

def escaped(count):
    font = pygame.font.SysFont(None, 25)
    text = font.render(u(b'446f646765643a20') + str(count), True, colWhite)
    gameDisplay.blit(text, (0,0))

De manière analogue à la partie précédente, on va remonter vers l'appel de la fonction escaped() :

escaped(gss)

if thingStarty > displayHeight:
    thingStarty = 0 - thingHeight
    thingStartx = random.randrange(0, displayWidth)
    gss += colGrey[0]
    gf = gss
    thingSpeed += 1
    if gf > (5*20):
            gf = (25 * 4)
            pauseText = gs(gf)

La fonction prend en paramètre la variable gss (initialisée à 0 plus haut, ligne 189).
En analysant le if plus bas, on remarque que la variable gf se voit attribuer la valeur de gss, puis qu'un second if compare gf à la valeur 100... on dirait bien que le script check si on a esquivé 100 fantômes !
En partant de ce principe, modifions la ligne 189 pour que gss soit initialisée à 100 directement :

gss = 100

On enregistre et on relance python unbreakable_game.py :

screen

Effectivement, la modification de la variable gss nous a fait commencer directement à 100. Le flag apparaît dans le terminal une fois que le premier fantôme a été dodge.

screen

À partir de la condition de flag

Troisième méthode que j'ai trouvé pour flag. C'est pas la plus intéressante des 3 mais je suis obligé tellement elle est obvious ^^
Prenons le bout de code que nous avons trouvé à la fin de la partie précédente, où la fonction escaped() est appelée :

escaped(gss)

if thingStarty > displayHeight:
    thingStarty = 0 - thingHeight
    thingStartx = random.randrange(0, displayWidth)
    gss += colGrey[0]
    gf = gss
    thingSpeed += 1
    if gf > (5*20):
            gf = (25 * 4)
            pauseText = gs(gf)

Attardons-nous plus spécialement sur la dernière ligne. D'après la condition dans laquelle elle est située, on sait que l'instruction a pour but d'afficher le flag. C'est tout bête, mais ça veut dire que le flag est forcément écrit quelque part dans le script, certainement sous forme obfusquée. Alors creusons, que fait la fonction gs() ?

def gs(b):
    gs_ = str(a[32:34]+a[40]+a[44:46]+a[3]+a[40]+a[29]+a[32]+a[30])
    print("{}{}".format(set_pref(), gs_))

Premièrement, une variable gs_ est initialisée avec pour valeur la concaténation d'une multitude de substrings de la châine a.
Ensuite est affichée la valeur retournée par la fonction set_pref() suivie de la variable gs_. Retrouver ces 2 valeurs revient à retrouver le flag donc.
Commençons par set_pref() :

def set_pref():
    return str(a[31] + a[37] + a[26] + a[32] + a[-1:])

La fonction retourne simplement une substring de a. Regardons ce que vaut a, que l'on trouve ligne 72 :

a = u(alpha)

a est le retour de la fonction u dans laquelle on passe en paramètre l'alphabet initialisé au début du script.
Que fait la fonction u ?

def u(n):
    return unhexlify(n).decode('utf-8')

Parfait ! La fonction u décode simplement la chaîne hexadécimale passée en paramètre, afin de la renvoyer en ascii.
On a maintenant tout ce qu'il nous faut pour trouver le flag. Plutôt que de procéder sous-chaîne par sous-chaîne, reproduisons les parties intéressantes du script dans une console Python, tout au copier/coller :

[email protected] > python3
>>> a = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890_-"
>>> f1 = a[31] + a[37] + a[26] + a[32] + a[-1:]
>>> f2 = a[32:34]+a[40]+a[44:46]+a[3]+a[40]+a[29]+a[32]+a[30]
>>> f1 + f2
'flag-ghostDodge'

Conclusion

Pour conclure, j'ai trouvé ce chall très amusant, singulier, et ça m'a suffit pour en faire une write-up. Même s'il valait beaucoup de points comparé à sa difficulté réelle qui n'est pas insurmontable (loin de là), il était résolvable par plusieurs méthodes différentes et en cela je le trouve intéressant.
Je félicite également les développeurs derrière l'épreuve et derrière toutes celles du CTF globalement. Les mecs ont carrément fait un RPG pour 3 ou 4 challs, et à côté de ça ils font des mini-jeux en Python.
Dommage de pas être tombé sur le CTF dès son lancement, il y avait matière à tryhard !
Bisous


Tags

ctf hacktober programmation python pygame