Wordler

LakeCTF '25-'26 QualsMiscWriteup by @xabito

yo dawg i heard you like words in your wordle so i put words in your words so words

Connections

  • chall.polygl0ts.ch:6052

Attachments

Recon

The challenge is a Wordle-style game where we must solve for multiple words at the same time, not just one. To win the flag, we need to solve the puzzle within six attempts.

Challenge example

Exploitation

The following script connects to the server, parses the word structure, and manages internal state to track revealed, present, and absent letters. On the first attempt, it guesses the full alphabet to maximize information. The second guess eliminates absent letters and fills unused spots with other letters to expose possible repeats.

For all following guesses, it finds words of the correct lengths, builds candidate sets that satisfy the feedback constraints so far, and tries combinations that are consistent with all previous clues, repeating this process up to six times or until the flag is obtained.

import re

from itertools import product
from pwn import remote, log

HOST = "chall.polygl0ts.ch"
PORT = 6052

WORDLIST_FILE = "word_list.txt"

ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
MAX_ATTEMPTS = 6

COLOR_GREEN = "92"
COLOR_YELLOW = "93"
COLOR_GREY = "90"

r = remote(HOST, PORT)
response = r.recvuntil(b"Your guess:").decode()

match = re.search(r"Structure:\s*([^\n]+)", response)
structure_str = match.group(1).strip()
word_lengths = [len(word) for word in structure_str.split('_')]
total_length = sum(word_lengths)

guesses = []
patterns = []
green_letters = [None] * total_length
present_letters = set()
absent_letters = set()
used = set()
words_by_length = None

for attempt in range(MAX_ATTEMPTS):
    if attempt == 0:
        plain_guess = (ALPHABET * ((total_length + 25) // 26))[:total_length]
    elif attempt == 1:
        available_letters = sorted((set(ALPHABET) - absent_letters) | present_letters) or list(ALPHABET)
        plain_guess = "".join(
            g if g else available_letters[(i + attempt) % len(available_letters)]
            for i, g in enumerate(green_letters)
        )
    else:
        if words_by_length is None:
            words_by_length = {}
            with open(WORDLIST_FILE) as f:
                for line in f:
                    word = line.strip().upper()
                    if not word or "_" in word:
                        continue

                    words_by_length.setdefault(len(word), []).append(word)

        forbidden = [set() for _ in range(total_length)]

        # process feedback from prior guesses
        for g, p in zip(guesses, patterns):
            for i, (c, color) in enumerate(zip(g, p)):
                if color == "g":
                    green_letters[i] = c
                elif color == "y":
                    forbidden[i].add(c)

            for c in set(g):
                indices = [idx for idx, x in enumerate(g) if x == c]
                st = [p[idx] for idx in indices]
                if any(x in "gy" for x in st):
                    present_letters.add(c)
                    absent_letters.discard(c)
                elif c not in present_letters:
                    absent_letters.add(c)

        # build candidate word slots for each position
        slots = []
        ok = True
        idx_offs = [sum(word_lengths[:i]) for i in range(len(word_lengths))]

        for offset, length in zip(idx_offs, word_lengths):
            filtered = []
            for w in words_by_length[length]:
                matched = True
                for j in range(length):
                    in_green = green_letters[offset + j]
                    if (in_green is not None and in_green != w[j]) or (w[j] in forbidden[offset + j]):
                        matched = False
                        break

                if matched:
                    filtered.append(w)

            if not filtered:
                ok = False
                break

            slots.append(filtered)

        if not ok:
            available_letters = sorted(
                (set(ALPHABET) - absent_letters) | present_letters
            ) or list(ALPHABET)
            plain_guess = "".join(
                g if g else available_letters[(i + attempt) % len(available_letters)]
                for i, g in enumerate(green_letters)
            )
        else:
            found = None
            for candidate in product(*slots):
                combined = ''.join(candidate)
                used_before = combined in used

                consistent = True
                for g, p in zip(guesses, patterns):
                    secret, guess = combined, g
                    res = ["b"] * len(secret)
                    remaining = {}

                    for i, char in enumerate(secret):
                        if char == guess[i]:
                            res[i] = "g"
                        else:
                            remaining[char] = remaining.get(char, 0) + 1

                    for i in range(len(secret)):
                        if res[i] == "g":
                            continue
                        if remaining.get(guess[i], 0):
                            res[i] = "y"
                            remaining[guess[i]] -= 1

                    if "".join(res) != p:
                        consistent = False
                        break

                if not consistent:
                    continue

                if found is None or not used_before:
                    found = combined
                    if not used_before:
                        break

            if found is None:
                available_letters = sorted(
                    (set(ALPHABET) - absent_letters) | present_letters
                ) or list(ALPHABET)
                plain_guess = "".join(
                    g if g else available_letters[(i + attempt) % len(available_letters)]
                    for i, g in enumerate(green_letters)
                )
            else:
                plain_guess = found

    i = 0
    parts = []
    for length in word_lengths:
        parts.append(plain_guess[i:i+length])
        i += length
    server_guess = '_'.join(parts)

    log.info(f"guess {attempt+1}/{MAX_ATTEMPTS}: {server_guess}")
    r.sendline(server_guess.encode())

    line = r.recvline(timeout=5).decode()
    if not line:
        break

    color = "b"
    pattern = []
    idx = 0
    ii = 0
    while idx < len(plain_guess) and ii < len(line):
        if line[ii] == "\x1b" and line[ii+1] == "[":
            j = ii+2
            while line[j] != "m":
                j += 1
            codes = set(line[ii+2:j].split(";"))
            if COLOR_GREEN in codes:
                color = "g"
            elif COLOR_YELLOW in codes:
                color = "y"
            elif COLOR_GREY in codes:
                color = "b"
            ii = j+1
            continue
        if line[ii].isalpha():
            pattern.append(color)
            idx += 1
        ii += 1
    if len(pattern) < len(plain_guess):
        pattern += ["b"] * (len(plain_guess) - len(pattern))
    pattern = "".join(pattern[:len(plain_guess)])

    log.success(f"pattern: {pattern}")
    guesses.append(plain_guess)
    patterns.append(pattern)

    if "_" not in server_guess:
        used.add(plain_guess)

    for c in set(plain_guess):
        indices = [i for i, x in enumerate(plain_guess) if x == c]
        statuses = [pattern[i] for i in indices]
        if any(s in "gy" for s in statuses):
            present_letters.add(c)
            absent_letters.discard(c)
        elif c not in present_letters:
            absent_letters.add(c)

    for i, (ch, col) in enumerate(zip(plain_guess, pattern)):
        if col == "g":
            green_letters[i] = ch

    if attempt < MAX_ATTEMPTS - 1:
        try:
            r.recvuntil(b"Your guess:", timeout=5)
        except Exception:
            break

log.info(r.recvall(timeout=3).decode())
r.close()

Note: Apologies for the complex code :P

Flag capture

Just run the script to see the flag:

$ python extract_flag.py
[+] Opening connection to chall.polygl0ts.ch on port 6052: Done
[*] guess 1/6: ABCDEFGHIJKL_MNOPQRSTUVW_XYZA
[+] pattern: gbyyybyyybyygyybbyybbbybbby
[*] guess 2/6: ADEGHIKLMNOR_MWACDEGHIKL_MNOR
[+] pattern: gyyyybyybyyygyygyybygbbbybb
[*] guess 3/6: ACKNOWLEDGED_MERCHANDISE_WHEN
[+] pattern: gggggggggggggggggggggggbybb
[*] guess 4/6: ACKNOWLEDGED_MERCHANDISE_CASH
[+] pattern: gggggggggggggggggggggggbggg
[*] guess 5/6: ACKNOWLEDGED_MERCHANDISE_DASH
[+] pattern: ggggggggggggggggggggggggggg
[+] Receiving all data: Done (53B)
[*] Closed connection to chall.polygl0ts.ch port 6052
[*] You win! Heres the flag: EPFL{5CR1P71NG_15_CH34T1NG}