1001 pattes

BreizhCTF 2023 - Programmation.

BreizhCTF 2023 - 1001 pattes

Challenge details

Event Challenge Category Points Solves
BreizhCTF 2023 1001 pattes Programmation ??? ???

ant

Les fourmis sont des artistes dont l’inspiration n’est encore que trop peu connu.

https://www.youtube.com/watch?v=1X-gtr4pEBU (à regarder pour comprendre le challenge)

Auteur: Zeecka

  • Fichier: Examples.zip
  • md5sum: 74984c1c14a69cff6d5d76985919a55c
  • Fichier: challenge.png
  • md5sum: aa4e2b232bced0d882967d35570afc14

TL;DR

Le challenge nécessite la réimplementation d’un automate appelé “Langton’s Ant” (ou fourmis de Langtons). Un bruteforce est ensuite à effectuer sur un nombre de paramètre limités pour aboutir à l’image recherchée. Une fois l’image identifiée, le flag peut être dérivé des différents paramètres d’entrée.

Méthodologie

Le challenge nécessite la réimplementation d’un automate appelé “Langton’s Ant” (ou fourmis de Langtons). Une description visuelle et pertinente de l’automate est disponible sur youtube.

La solution attendue est l’empreinte d’une configuration JSON dont les paramètre sont les suivants (extrait du fichier config1.json):

{
    "colors": {
        "cadetblue": "L",
        "hotpink": "R",
        "seagreen": "L",
        "white": "R"
    },
    "sizes": {
        "cell": 15,
        "border": 5,
        "grid": 30
    },
    "steps": 200
}

Ici, nous disposons de 3 clés principales:

  • colors (permettant d’indiquer les direction à suivre pour l’automate)
  • sizes (définissant les propriétés graphiques du rendu)
  • steps (nombre d’itération de l’automate)

Dans un premier temps, il est important de réimplémenter un algorithme permettant de générer les fichiers d’exemples à partir de ces configurations:

Exemple 1 Exemple 2
exemple1.png exemple2.png

Dans ces fichiers et leurs configuration, on retrouve bien les propriétés de la clés “sizes” (à savoir, la taille des cellules, taille des bordures et nombre de cases).

Le point noir indique la position finale de la fourmis.

Les différentes informations déductible des différents exemples et des notes additionelles du challenge permettent d’aboutir à un script fonctionnel dont voici une proposition d’implémentation:

Implémentation de l’exemple

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
# pylint: disable=invalid-name, line-too-long

"""
    Langton's Ant program
"""

import sys
import json
from PIL import Image, ImageDraw

sys.setrecursionlimit(10000)


class Ant:
    """ Langton's Ant. """

    def __init__(self, config, position=None):
        self.img = Image.new("RGBA", (WINDOW_WIDTH, WINDOW_HEIGHT))
        self.steps = 0
        self.config = config
        self.colors = list(self.config.keys())
        self.grid = [[None for i in range(GRID_WIDTH)]
                     for j in range(GRID_HEIGHT)]
        self.position = position
        if position is None:
            self.position = (GRID_WIDTH // 2, GRID_HEIGHT //
                             2)  # Middle of the screen
        self.prev_position = self.position
        self.orientation = "T"
        self.update()

    def update(self):
        """ Move ant and change color. """
        if self.steps < MAX_STEPS:
            self.steps += 1
            x, y = self.position
            if self.grid[y][x] is None:  # White by default
                self.grid[y][x] = "white"
            for i, color in enumerate(self.colors):
                if self.grid[y][x] == color:
                    new_color = self.colors[(i+1) % len(self.colors)]
                    self.grid[y][x] = new_color  # Update color
                    self.move(self.config[new_color])  # Move ant
                    break
            try:
                self.update()
            except IndexError:
                self.save()
        else:
            self.save()

    def move(self, turn):
        """ Move ant. Turn can be L or R """
        self.prev_position = self.position
        x, y = self.position
        if self.orientation == "T":
            if turn == "L":
                x, o = x-1, "L"
            else:
                x, o = x+1, "R"
        elif self.orientation == "L":
            if turn == "L":
                y, o = y+1, "B"
            else:
                y, o = y-1, "T"
        elif self.orientation == "B":
            if turn == "L":
                x, o = x+1, "R"
            else:
                x, o = x-1, "L"
        else:  # R
            if turn == "L":
                y, o = y-1, "T"
            else:
                y, o = y+1, "B"

        self.position = (x, y)
        self.orientation = o

    def save(self):
        """ Save ant drawing as image. """
        # Draw squares
        img = ImageDraw.Draw(self.img)
        for y in range(GRID_HEIGHT):
            for x in range(GRID_WIDTH):
                color = self.grid[y][x]
                if color is None:
                    continue
                img.rectangle([
                    (x * CELL_SIZE, y * CELL_SIZE),
                    (x * CELL_SIZE + CELL_SIZE + CELL_BORDER - 1, y * CELL_SIZE + CELL_SIZE + CELL_BORDER - 1)],
                    outline="black", fill=color, width=CELL_BORDER
                )
        # Draw ant
        x, y = self.prev_position
        ant_size = ANT_SIZE - 1
        padding = (CELL_SIZE + CELL_BORDER - ant_size)//2
        img.ellipse([
            (x * CELL_SIZE + padding, y * CELL_SIZE + padding),
            (x * CELL_SIZE + ant_size + padding, y * CELL_SIZE + ant_size + padding)],
            fill="black"
        )
        filename = "exemple1.png"
        self.img.save(filename)


if __name__ == "__main__":
    # Load config
    with open('config1.json', encoding='utf-8') as json_file:
        data = json.load(json_file)
        CONFIG = data["colors"]
        MAX_STEPS = data["steps"]
        SIZES = data["sizes"]
        CELL_INSIDE = SIZES["cell"]
        CELL_BORDER = SIZES["border"]
        GRID_WIDTH = SIZES["grid"]

    CELL_SIZE = CELL_INSIDE + CELL_BORDER
    ANT_SIZE = CELL_INSIDE - 2
    GRID_HEIGHT = GRID_WIDTH  # Square image
    WINDOW_WIDTH = CELL_SIZE * GRID_WIDTH + CELL_BORDER
    WINDOW_HEIGHT = CELL_SIZE * GRID_HEIGHT + CELL_BORDER

    Ant(CONFIG)

Une fois l’implémentation des exemples réalisée, la solution du challenge consiste à retrouver les paramètres d’une configuration initiale pour une image donnée.

Implémentation de la solution

challenge.png Image du challenge

A partir de l’image de challenge, nous pouvons récupérer les variables liées à la taille de la grille. Le nombre de couleur est également un facteur important à récupérer depuis le fichier de challenge. Nous savons également que le nombre d’itérations est inférieur à 10000.

Nous disposons donc d’un fichier de configuration partiel:

{
    "colors": {
        "cadetblue": "?",
        "chartreuse": "?",
        "hotpink": "?",
        "red": "?",
        "seagreen": "?",
        "white": "?",
        "yellow": "?"
    },
    "sizes": {
        "cell": 15,
        "border": 5,
        "grid": 55
    },
    "steps": 10000  // maximum
}

La solution consiste donc à générer l’ensemble des solutions de rotation de couleurs possibles avec des itérations pouvant aller jusqu’à 10000 maximum.

Afin d’améliorer notre attaque par force brute, il est intéressant de noter:

  • Lorsqu’une case (transparente sur l’image du challenge) vient à être colorée, la configuration de couleur (ainsi que les itérations suivantes) peuvent être exclues. Il n’est donc pas nécessaire de les calculer.
  • Lorsque la position finale de la fourmis ne correspond pas entre le challenge et l’image générée, cette dernière peut être exclus. Ainsi, la vérification d’image peut se baser dans un premier temps sur la position finale de la fourmis.
  • Lorsque qu’un cycle est détecté (état de la grille, position et orientation de la fourmis similaires), le calcul des prochaines itérations peut être exclus. Cette implémentation est cependant compliquée et couteuse, et n’est donc pas nécessaire.

Afin d’effectuer notre attaque par force brute sur les différents paramètres, nous réutilisons notre script initial, auquel nous ajoutons les vérifications précédemment mentionnées, ainsi qu’un produit des différentes combinaisons de couleurs avec une boucle for. Une vérification de la solution est effectuée à chaque itération:

# Test each combination of L/R up to `MAX_STEPS` steps.
N = 2**len(CONFIG)
for j in range(N):
    print(f"{j}/{N}")
    bin_j = bin(j)[2:].zfill(len(CONFIG))  # binary notation tricks
    conf = bin_j.replace("0", "L").replace("1", "R")
    CONFIG = {
        "cadetblue": conf[0],
        "chartreuse": conf[1],
        "hotpink": conf[2],
        "red": conf[3],
        "seagreen": conf[4],
        "white": conf[5],
        "yellow": conf[6]
    }

    # Start drawing
    try:
        Ant(CONFIG)
    except Exception() as e:
        print(e)
        continue

Boucle sur l’ensemble des combinaisons de couleurs

def update(self):
    """ Move ant and change color. """
    # We need to quit as soon as possible to reduce computing
    # Compute current shape
    current_shape = [[k is not None for k in row] for row in self.grid]
    # Check if current shape is already bigger (if so, quit)
    for row in range(len(SHAPE)):
        for p in range(len(SHAPE[0])):
            if current_shape[row][p] is not None and SHAPE[row][p] is None:
                return

    # Check if shape is the same
    if current_shape == SHAPE:
        if self.grid == MATRIX:
            print("[+] Found valid solution !")
            print(f"[i] Steps number: {self.steps}")
            print(f"[i] Valid configuration: {self.config}")
            self.save()

    if self.steps < MAX_STEPS:

Modification de la fonction update pour vérifier la solution

L’implémentation de la solution finale est la suivante:

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
# pylint: disable=invalid-name, line-too-long, consider-using-enumerate

"""
    Langton's Ant program
"""

import sys
import json
from PIL import Image, ImageDraw

sys.setrecursionlimit(15000)


class Ant:
    """ Langton's Ant. """

    def __init__(self, config, position=None):
        self.img = Image.new("RGBA", (WINDOW_WIDTH, WINDOW_HEIGHT))
        self.steps = 0
        self.status = []
        self.config = config
        self.colors = list(self.config.keys())
        self.grid = [[None for i in range(GRID_WIDTH)]
                     for j in range(GRID_HEIGHT)]
        self.position = position
        if position is None:
            self.position = (GRID_WIDTH // 2, GRID_HEIGHT //
                             2)  # Middle of the screen
        self.prev_position = self.position
        self.orientation = "T"
        self.update()

    def update(self):
        """ Move ant and change color. """
        # We need to quit as soon as possible to reduce computing
        # Compute current shape
        current_shape = [[k is not None for k in row] for row in self.grid]
        # Check if current shape is already bigger (if so, quit)
        for row in range(len(SHAPE)):
            for p in range(len(SHAPE[0])):
                if current_shape[row][p] is not None and SHAPE[row][p] is None:
                    return

        # Check if shape is the same
        if current_shape == SHAPE:
            if self.grid == MATRIX:
                print("[+] Found valid solution !")
                print(f"[i] Steps number: {self.steps}")
                print(f"[i] Valid configuration: {self.config}")
                self.save()

        if self.steps < MAX_STEPS:
            self.steps += 1
            x, y = self.position
            if self.grid[y][x] is None:  # White by default
                self.grid[y][x] = "white"
            for i, color in enumerate(self.colors):
                if self.grid[y][x] == color:
                    new_color = self.colors[(i+1) % len(self.colors)]
                    self.grid[y][x] = new_color  # Update color
                    self.move(self.config[new_color])  # Move ant
                    break
            try:
                self.update()
            except IndexError:
                return

    def move(self, turn):
        """ Move ant. Turn can be L or R """
        self.prev_position = self.position
        x, y = self.position
        if self.orientation == "T":
            if turn == "L":
                x, o = x-1, "L"
            else:
                x, o = x+1, "R"
        elif self.orientation == "L":
            if turn == "L":
                y, o = y+1, "B"
            else:
                y, o = y-1, "T"
        elif self.orientation == "B":
            if turn == "L":
                x, o = x+1, "R"
            else:
                x, o = x-1, "L"
        else:  # R
            if turn == "L":
                y, o = y-1, "T"
            else:
                y, o = y+1, "B"

        self.position = (x, y)
        self.orientation = o

    def save(self):
        """ Save ant drawing as image. """
        # Draw squares
        img = ImageDraw.Draw(self.img)
        for y in range(GRID_HEIGHT):
            for x in range(GRID_WIDTH):
                color = self.grid[y][x]
                if color is None:
                    continue
                img.rectangle([
                    (x * CELL_SIZE, y * CELL_SIZE),
                    (x * CELL_SIZE + CELL_SIZE + CELL_BORDER - 1, y * CELL_SIZE + CELL_SIZE + CELL_BORDER - 1)],
                    outline="black", fill=color, width=CELL_BORDER
                )
        # Draw ant
        x, y = self.prev_position
        ant_size = ANT_SIZE - 1
        padding = (CELL_SIZE + CELL_BORDER - ant_size)//2
        img.ellipse([
            (x * CELL_SIZE + padding, y * CELL_SIZE + padding),
            (x * CELL_SIZE + ant_size + padding, y * CELL_SIZE + ant_size + padding)],
            fill="black"
        )
        filename = f"imgs/challenge_{self.steps}_{''.join(self.config.values())}.png"
        self.img.save(filename)

def get_challenge(filename):
    """ Return shape and matrix for a given ant art. """
    challenge = Image.open(filename)
    rgba_matrix_1d = []
    for my in range(CELL_BORDER + 1, WINDOW_HEIGHT, CELL_SIZE):
        for mx in range(CELL_BORDER + 1, WINDOW_WIDTH, CELL_SIZE):
            rgba_matrix_1d.append(challenge.getpixel((mx, my)))
    colors_map = {
        (95, 158, 160, 255): "cadetblue",
        (127, 255, 0, 255): "chartreuse",
        (255, 105, 180, 255): "hotpink",
        (255, 0, 0, 255): "red",
        (46, 139, 87, 255): "seagreen",
        (255, 255, 255, 255): "white",
        (0, 0, 0, 0): None,
        (255, 255, 0, 255): "yellow"
    }
    rgba_matrix = []
    for i in range(0, len(rgba_matrix_1d), GRID_WIDTH):
        rgba_matrix.append(rgba_matrix_1d[i: i+GRID_WIDTH])

    matrix = [[colors_map[k] for k in row] for row in rgba_matrix]
    shape = [[k is not None for k in row] for row in matrix]

    return shape, matrix

if __name__ == "__main__":
    # Load partial config with known variables such as sizes and colors number
    with open('partial_config.json', encoding='utf-8') as json_file:
        data = json.load(json_file)
        CONFIG = data["colors"]
        MAX_STEPS = data["steps"]
        SIZES = data["sizes"]
        CELL_INSIDE = SIZES["cell"]
        CELL_BORDER = SIZES["border"]
        GRID_WIDTH = SIZES["grid"]

    CELL_SIZE = CELL_INSIDE + CELL_BORDER
    ANT_SIZE = CELL_INSIDE - 2
    GRID_HEIGHT = GRID_WIDTH  # Square image
    WINDOW_WIDTH = CELL_SIZE * GRID_WIDTH + CELL_BORDER
    WINDOW_HEIGHT = CELL_SIZE * GRID_HEIGHT + CELL_BORDER

    # Convert image to matrix of colors & shape
    SHAPE, MATRIX = get_challenge("challenge.png")

    # Test each combination of L/R up to `MAX_STEPS` steps.
    N = 2**len(CONFIG)
    for j in range(N):
        print(f"{j}/{N}")
        bin_j = bin(j)[2:].zfill(len(CONFIG))  # binary notation tricks
        conf = bin_j.replace("0", "L").replace("1", "R")
        CONFIG = {
            "cadetblue": conf[0],
            "chartreuse": conf[1],
            "hotpink": conf[2],
            "red": conf[3],
            "seagreen": conf[4],
            "white": conf[5],
            "yellow": conf[6]
        }

        # Start drawing
        try:
            Ant(CONFIG)
        except Exception() as e:
            print(e)
            continue

Le résultat du script nous apporte la sortie suivante:

0/128
1/128
2/128
3/128
...
98/128
99/128
[+] Found valid solution !
[i] Steps number: 7103
[i] Valid configuration: {'cadetblue': 'R', 'chartreuse': 'R', 'hotpink': 'L', 'red': 'L', 'seagreen': 'L', 'white': 'R', 'yellow': 'R'}
100/128
101/128

Le fichier de solution est par ailleur généré par notre script et sauvegardé sous le nom challenge_7103_RRLLLRR.png.

En adaptant le fichier de configuration, on retrouve le fichier de configuration initial suivant:

{
    "colors": {
        "cadetblue": "R",
        "chartreuse": "R",
        "hotpink": "L",
        "red": "L",
        "seagreen": "L",
        "white": "R",
        "yellow": "R"
    },
    "sizes": {
        "cell": 15,
        "border": 5,
        "grid": 55
    },
    "steps": 7103
}

En respectant sa mise en forme et en calculant sa somme de controle, on retrouve le flag:

$ echo "BZHCTF{$(md5sum solution.json | awk '{print $1}')}"

BZHCTF{aaa2ed309b4ce6caf181f3923e80b136}

Flag

BZHCTF{aaa2ed309b4ce6caf181f3923e80b136}

Auteur: Zeecka