Zeecka zeecka

1001 pattes

BreizhCTF 2023 - Programming

BreizhCTF 2023 - 1001 pattes

Challenge details

EventChallengeCategoryPointsSolves
BreizhCTF 20231001 pattesProgramming??????

ant

Ants are artists whose inspiration is still far too little known.

https://www.youtube.com/watch?v=1X-gtr4pEBU (watch it to understand the challenge)

Author: Zeecka

TL;DR

The challenge requires reimplementing an automaton called “Langton’s Ant” (or Langton’s ants). A bruteforce then has to be performed on a limited number of parameters in order to reach the target image. Once the image is identified, the flag can be derived from the various input parameters.

Methodology

The challenge requires reimplementing an automaton called “Langton’s Ant” (or Langton’s ants). A relevant visual description of the automaton is available on youtube.

The expected solution is the hash of a JSON configuration whose parameters are as follows (excerpt from the config1.json file):

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

Here, we have 3 main keys:

  • colors (indicating the directions the automaton must follow)
  • sizes (defining the graphical properties of the render)
  • steps (number of iterations of the automaton)

First, it is important to reimplement an algorithm that generates the example files from these configurations:

Example 1Example 2
exemple1.pngexemple2.png

In these files and their configuration, we do find the properties of the “sizes” key (namely, the cell size, the border size and the number of cells).

The black dot indicates the final position of the ant.

The various pieces of information that can be deduced from the different examples and from the challenge’s additional notes make it possible to arrive at a working script, a proposed implementation of which is shown below:

Example implementation

#! /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)

Once the examples have been implemented, the challenge’s solution consists of recovering the parameters of an initial configuration for a given image.

Solution implementation

challenge.png Challenge image

From the challenge image, we can recover the variables related to the grid size. The number of colors is also an important factor to recover from the challenge file. We also know that the number of iterations is less than 10000.

We therefore have a partial configuration file:

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

The solution therefore consists of generating all the possible color rotation solutions with iterations that can go up to a maximum of 10000.

In order to improve our bruteforce attack, it is worth noting that:

  • When a cell (transparent in the challenge image) becomes colored, the color configuration (as well as the following iterations) can be excluded. There is therefore no need to compute them.
  • When the final position of the ant does not match between the challenge and the generated image, the latter can be excluded. Thus, image verification can, as a first step, be based on the final position of the ant.
  • When a cycle is detected (grid state, ant position and orientation are similar), the computation of the next iterations can be excluded. This implementation is, however, complicated and costly, and is therefore not necessary.

In order to carry out our bruteforce attack on the various parameters, we reuse our initial script, to which we add the previously mentioned checks, as well as a product of the different color combinations with a for loop. A check of the solution is performed at each iteration:

# 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

Loop over all the color combinations

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 of the update function to check the solution

The final solution implementation is as follows:

#! /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

Running the script gives us the following output:

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

The solution file is, incidentally, generated by our script and saved under the name challenge_7103_RRLLLRR.png.

By adapting the configuration file, we recover the following initial configuration file:

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

By respecting its formatting and computing its checksum, we recover the flag:

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

BZHCTF{aaa2ed309b4ce6caf181f3923e80b136}

Flag

BZHCTF{aaa2ed309b4ce6caf181f3923e80b136}

Author: Zeecka