Zeecka zeecka

Mastermind

BreizhCTF 2023 - Programming

BreizhCTF 2023 - Mastermind

Challenge details

EventChallengeCategoryPointsSolves
BreizhCTF 2023MastermindProgramming??????

mastermind

Hello World! Did you know that the game of Mastermind is NP-complete? Solve the games to win the flag.

nc challenge.ctf.bzh 30020

Author: Zeecka

TL;DR

To solve the challenge, you need to implement a solver for the game of Mastermind; an optimized algorithm proposed by Donald Knuth is available on Wikipedia. Several implementations are also available on Github. External solvers are also available.

Methodology

The challenge statement is explicit; a TCP connection using netcat gives us a more complete statement:

$ nc challenge.ctf.bzh 30020

TCP Connection

A few manual tests confirm how the game works. The limited number of moves (6) makes the game particularly difficult to solve.

tests

Searching the internet for an efficient algorithm leads us to a solution that works in 5 moves at most (Five guess algorithm).

The principle of this algorithm relies on eliminating probable combinations as the game progresses. This elimination is accompanied by a strategic choice of moves based on a MinMax-type algorithm aimed at choosing the most advantageous move given the worst conceivable combination:

  1. Create a set “S” of the 1296 possible codes {YYYY, YYYB, … PPPW, PPPP}.
  2. Start with an initial guess YYBB. (The author of the algorithm provides examples showing that the algorithm using numbers other than “two pairs”; such as YYYY, YYYW, YYWR, or YWRG; does not work in 5 attempts for all the possible combinations.)
  3. Play the guess to get the number of correct and misplaced colors.
  4. If the game reports 4 correct colors, the game is won and the algorithm stops.
  5. Otherwise, remove from “S” all candidates that would not give the same answer. The idea here is to check whether the number of correct and misplaced colors matches between the candidates and the game’s secret combination. So we initially place each candidate under the assumption that it is the secret combination. Note: Checking the candidate involves re-coding a function that computes the number of correct and misplaced colors for one of our guesses against the candidate.
  6. The next guess is based on the MinMax algorithm. The answers to a guess are defined by the combinations (nombre correcte, nombre mal placés); a guess has several probable combinations. The score of each combination is defined by the number of candidates (codes in “S”) still possible after learning the answer (and therefore after invalidating candidates). Computing the probable candidates is necessary for each combination of each guess. The score kept for a guess is the least favorable one across all the answer combinations (thus the one with the most candidates still in play). Indeed, we assume that the server’s combination is the least advantageous, and therefore the one that leaves us with the most candidates. We then try to minimize the candidates by selecting the most favorable guess, the one offering the fewest possible candidates. An important rule is to prefer a guess already present in “S” (a candidate, then) whenever possible.
  7. Repeat from step 3.

Without further ado, here is the algorithm implemented in Python3 with the pwntool library:

#!/usr/bin/env python3
# -*- coding:utf-8 -*-
#
# Solution basée sur l'algorithme proposé par Donald Knuth
# https://en.wikipedia.org/wiki/Mastermind_(board_game)#Worst_case:_Five-guess_algorithm

from pwn import *
from itertools import product
import base64

HOST = "mastermind.breizh.ctf"
PORT = 1337
r = remote(HOST, PORT)  # Connexion au serveur
colors = ['Y', 'B', 'R', 'G', 'W', 'P']


def send(c):
    r.sendline((c).encode())


def reponse(essai):
    """ Requète le serveur en jouant un coup. """
    send(','.join(essai))
    print(f">>>{','.join(essai)}")
    try:
        data = r.recvuntil(b">>>").decode()
    except:  # Got the flag
        r.interactive()
    board = '\n'.join(data.split('-\n')[0:2]).strip()
    print(board)
    score = data.split('-\n')[2].strip()
    if "[+]" in score:
        print(score)
        return (4, 0)
    correct = int(score.split(" ")[2][0])
    mal_place = int(score.split(" ")[6][0])
    print(f"Correctes = {correct}, Mal placés = {mal_place}")
    return (correct, mal_place)


def partie():
    """ Déroulement d'une partie. """
    gagne = False
    essai = "YYBB"  # Notre premier coup est 1122

    # Creation des 1296 possibilités
    possible = [''.join(p) for p in product('YBRGWP', repeat=4)]
    candidats = [''.join(p) for p in product('YBRGWP', repeat=4)]
    while not gagne:
        # On retire notre essai
        possible.remove(essai)
        if essai in candidats:
            candidats.remove(essai)
        # On joue notre essai pour récupéré les Corrects / Mal placés
        rep_code = reponse(essai)

        if rep_code[0] == 4:
            gagne = True
            break

        # Maj des candidats
        candidats = maj_candidats(candidats, essai, rep_code)
        # Calcul le prochain coup
        essai = minimax(possible, candidats)


def calculer_reponse(data, secret):
    """ Calcule la réponse que pourrait renvoyer le serveur
    pour un code et un secret donné. """
    nb_correct = 0
    nb_wrongplace = 0
    data_2 = []
    secret_2 = []
    for i in range(4):
        if secret[i] == data[i]:
            nb_correct += 1
        else:
            data_2.append(data[i])
            secret_2.append(secret[i])
    for p in data_2:
        if p in secret_2:
            nb_wrongplace += 1
            del secret_2[secret_2.index(p)]
    return (nb_correct, nb_wrongplace)


def maj_candidats(candidats, essai, rep_code):
    """ Mise à jours des candidats potentiels.

    Pour chaque candidat, on compare notre essai avec le candidat,
    comme si celui-ci était le code secret.
    Si la réponse est différente, alors le candidat est élimité,
    car la réponse attendue ne correspond pas à la réponse recue.
    A l'inverse si le réponses coincident, alors on garde le candidat.

    """
    nouveau_candidats = []
    for c in candidats:
        if calculer_reponse(essai, c) == rep_code:
            nouveau_candidats.append(c)
    return nouveau_candidats

def minimax(possible, candidats):
    """ 
    On parcours l'ensemble des coups jouables et on regarde
    le nombre maximum de possibilités retirées pour chaque coup
    et chaque réponse possible du serveur (0, 1), (1, 1)...
    Parmis ces réponses possibles, on retiendra la réponse la moins
    avantageuse (avec le nombre de candidats éliminés le plus bas).
    Enfin parmis les candidats minimum éliminés pour chaque coup,
    on choisi le coup le plus avantageux.

    https://fr.wikipedia.org/wiki/Algorithme_minimax
    """
    couts = {}
    rp = [  # Réponses possibles du serveur
        (0, 0), (0, 1), (0, 2), (0, 3), (0, 4),
        (1, 0), (1, 1), (1, 2), (1, 3),
        (2, 0), (2, 1), (2, 2),
        (3, 0), (3, 1), (4, 0)]
    for p in possible:  # Pour chaque coup jouable
        couts[p] = []
        for r in rp:  # On calcule pour chaque réponse du serveur...
            total = 0
            for c in candidats:  # ...si un candidat peut être enlevé
                if calculer_reponse(p, c) != r:  # 
                    total += 1
            couts[p].append(total)  # On enregistre le nombre de candidat
        couts[p] = min(couts[p])  # On garde la réponse la moins avantageuse
    max_cout = max(couts.values())
    # On recup la liste des coups les plus avantageux
    coups_max = [k for k,v in couts.items() if v == max_cout]
    # On retourne le coup le plus avantageux (candidat en priorité)
    for c in coups_max:
        if c in candidats:
            return c
    return c


if __name__ == "__main__":
    intro = r.recvuntil(b">>>").decode().strip(">>>")
    print(intro)
    while True:
        partie()

Flag

BZHCTF{Th4G4M3___3:D}

Author: Zeecka