SCRAMBLE is a misc challenge that was part of the Cyber Security Rumble CTF. We had to play 100 games of wordle (custom word list) with an average score of less than 5 guesses per game to get the flag.

To practice, we were supplied with all files to start a local instance of the game.

Files

main.py

#!/usr/bin/env python3

import random
import string
from secret import FLAG

NUMBER_OF_GAMES = 100
THRESHOLD = 5
SIZE_OF_WORDLIST = 12000

rng = random.SystemRandom()


def game(secret_word, wordlist):
    print("[start game]")
    chars = set(secret_word)

    for guess_count in range(1, 7):
        guess = input("> ")[:5]
        assert(len(guess) == 5)
        assert(guess in wordlist)

        if guess == secret_word:
            print("[guess correct]> ")
            break

        result = ""
        for i in range(5):
            if guess[i] == secret_word[i]:
                result += "+"
            elif guess[i] in chars:
                result += "*"
            else:
                result += "-"

        print(result)
    else:
        print("[number of guesses exceeded]> ")

    return guess_count


def main():
    wordlist = set()
    while len(wordlist) < SIZE_OF_WORDLIST:
        word = ''.join(rng.choices(string.ascii_uppercase, weights=[
            7.58, 1.96, 3.16, 4.98, 18.93, 1.49, 3.02, 4.98, 8.02, 0.24,
             1.32, 3.60, 2.55, 12.53, 2.24, 0.67, 0.02, 6.89, 8.42, 7.79,
              3.83, 0.84, 1.78, 0.05, 0.05, 1.21], k=5))
        wordlist.add(word)

    print("Here is your personal wordlist:")
    print("[begin of wordlist]")
    for word in wordlist:
        print(word)
    print("[end of wordlist]")

    guesses = []
    for _ in range(NUMBER_OF_GAMES):
        secret_word = rng.choice(list(wordlist))
        guesses.append(game(secret_word, wordlist))

    average_guesses = sum(guesses) / NUMBER_OF_GAMES
    print(f'avg.: {average_guesses}')
    if average_guesses < THRESHOLD:
        print(FLAG)


if __name__ == '__main__':
    main()

secret.py

FLAG = b"CSR{lololololololololololol}"

First steps

$ nc chall.rumble.host 2568
Here is your personal wordlist:
[begin of wordlist]
EGLER
RLSRM
NRRIN
EEAEN
TNEEM
GRLNT
...
NITUS
IISBS
UOUEU
RESRR
SCMSF
[end of wordlist]
[start game]
> 

First we get a custom word list, 12000 words, and then the game begins. The wordlist stays the same for all games until we reconnect to the server.

Solution

Writing a wordle-bot in python with pwntools.

Custom word list

The wordlist is between the two strings [begin of wordlist] and [end of wordlist].

from pwn import remote

r = remote("chall.rumble.host", 2568)

r.recvuntil(b"[begin of wordlist]\n")
custom_wordlist = r.recvuntil(b"[end of wordlist]\n") \
    .split(b"\n[")[0].decode().split("\n")

Game logic

Based on the given game logic:

if guess[i] == secret_word[i]:
    result += "+"
elif guess[i] in chars:
    result += "*"
else:
    result += "-"

We just had reverse the logic:

print(f"Wordlist size before: {wordlist.size}")

if "+" in result:
    indices = [i for i, x in enumerate(result) if x == "+"]
    for i in indices:
        print(f"remove words without {guess[i]} at index {i}")
        for word in wordlist:
            if word[i] != guess[i]:
                wordlist = np.delete(wordlist, \
                    np.where(wordlist == word))

if "*" in result:
    indices = [i for i, x in enumerate(result) if x == "*"]
    for i in indices:
        print(f"remove words without {guess[i]}")
        for word in wordlist:
            if guess[i] not in word:
                wordlist = np.delete(wordlist, \
                    np.where(wordlist == word))

if "-" in result:
    indices = [i for i, x in enumerate(result) if x == "-"]
    for i in indices:
        print(f"remove words with {guess[i]}")
        for word in wordlist:
            if guess[i] in word:
                wordlist = np.delete(wordlist, \
                    np.where(wordlist == word))

print(f"Wordlist size after: {wordlist.size}")

Execution

whitelist = set('abcdefghijklmnopqrstuvwxyz ')

for game in range(1, 101):
    print(f"start game {game}")
    wordlist = np.array(custom_wordlist.copy())
    r.recvuntil(b"[start game]\n> ").decode()
    for i in range(1, 7):
        guess = random.choice(wordlist)
        # improve guess?
        r.sendline(guess.encode())
        info = r.recvuntil(b"> ").decode()
        result = info[0:5]

        if "[guess correct]" in info \
            or "[number of guesses exceeded]" in info:
            print(i, "guesses:", \
                "".join(filter(whitelist.__contains__, info)))
            break

        print(f"{guess} --> {result}")
        wordlist = np.delete(wordlist, np.where(wordlist == guess))

        # Game logic ...

Now we have everything and can start the bot:

...
start game 100
OERDN --> -----
UIGHG --> -----
ABWWA --> *---*
VLSAM --> -+*+-
5 guesses: guess correct 
[+] Receiving all data: Done (12B)
[*] Closed connection to chall.rumble.host port 2568

avg.: 5.35

An average > 5 can’t give us the flag.

We have to improve our bot: ABWWA --> *---* is not a good guess, because “A” and “W” are checked twice instead of 5 different letters. We have to improve our guess.

if wordlist.size > 1:
    for x in range(wordlist.size):
        if len(set(guess)) == len(guess):
            break
        else:
            guess = random.choice(wordlist)
else:
    guess = wordlist[0]

Now we use a random word from the wordlist that doesn’t contain any duplicate letters. This is a very simple improvement, but it helps a lot.

Final bot

#!/usr/bin/env python
from pwn import remote
import random
import numpy as np

whitelist = set('abcdefghijklmnopqrstuvwxyz ')

r = remote("chall.rumble.host", 2568)

r.recvuntil(b"[begin of wordlist]\n")
custom_wordlist = r.recvuntil(b"[end of wordlist]\n") \
    .split(b"\n[")[0].decode().split("\n")

for game in range(1, 101):
    print(f"start game {game}")
    wordlist = np.array(custom_wordlist.copy())
    r.recvuntil(b"[start game]\n> ").decode()
    for i in range(1, 7):
        guess = random.choice(wordlist)
        if wordlist.size > 1:
            for x in range(wordlist.size):
                if len(set(guess)) == len(guess):
                    break
                else:
                    guess = random.choice(wordlist)
        else:
            guess = wordlist[0]
        r.sendline(guess.encode())
        info = r.recvuntil(b"> ").decode()
        result = info[0:5]

        if "[guess correct]" in info \
            or "[number of guesses exceeded]" in info:
            print(i, "guesses:", \
                "".join(filter(whitelist.__contains__, info)))
            break

        print(f"{guess} --> {result}")
        wordlist = np.delete(wordlist, np.where(wordlist == guess))

        #print(f"Wordlist size before: {wordlist.size}")

        if "+" in result:
            indices = [i for i, x in enumerate(result) if x == "+"]
            for i in indices:
                #print(f"remove words without {guess[i]} at index {i}")
                for word in wordlist:
                    if word[i] != guess[i]:
                        wordlist = np.delete(wordlist, \
                            np.where(wordlist == word))

        if "*" in result:
            indices = [i for i, x in enumerate(result) if x == "*"]
            for i in indices:
                #print(f"remove words without {guess[i]}")
                for word in wordlist:
                    if guess[i] not in word:
                        wordlist = np.delete(wordlist, \
                            np.where(wordlist == word))

        if "-" in result:
            indices = [i for i, x in enumerate(result) if x == "-"]
            for i in indices:
                #print(f"remove words with {guess[i]}")
                for word in wordlist:
                    if guess[i] in word:
                        wordlist = np.delete(wordlist, \
                            np.where(wordlist == word))

        #print(f"Wordlist size after: {wordlist.size}")

print(r.recvall().decode())
r.close()

Output:

[+] Opening connection to chall.rumble.host on port 2568: Done
start game 1
ACWTD --> -**--
HIWCZ --> --**-
3 guesses: guess correct 
start game 2
ANECZ --> -++--
TNEOV --> -++--
RNEWH --> -++--
INEUS --> -++-*
SNEDF --> *++--
6 guesses: number of guesses exceeded 
start game 3
NETRA --> **---
UENHK --> +**--
UNEGS --> ++*--
4 guesses: guess correct 
start game 4
...
start game 98
GTUNW --> -*---
TCAEI --> +--*-
TESHD --> ++---
TELTR --> ++-+-
5 guesses: guess correct 
start game 99
HRDBI --> ---*-
AKLTB --> ----*
MBOSN --> -+-*+
4 guesses: guess correct 
start game 100
FURAN --> -+*-+
2 guesses: guess correct 
[+] Receiving all data: Done (66B)
[*] Closed connection to chall.rumble.host port 2568

avg.: 4.81
b'CSR{look_at_all_this_entropy_just_floating_around}'

Alternative solution

Instead of writing our own game logic, we could also do some lazy interpreting in-between the server and an existing bot from GitHub.

#!/usr/bin/env python
from pwn import remote, process
from uuid import uuid4


def new_solver(wordlist_name):
    return process(["python3", "wordle-solver/main.py", "-m", "solve",
            "--dict_file", wordlist_name, "--cand_file", wordlist_name])


def main():
    r = remote("chall.rumble.host", 2568)
    r.recvuntil(b"[begin of wordlist]\n")
    raw_wordlist = r.recvuntil(b"[end of wordlist]\n").split(b"\n[")[0]
    # Save the wordlist to a file with a random name
    wordlist_name = str(uuid4())
    with open(wordlist_name, "wb") as f:
        f.write(raw_wordlist.lower())
    print(f"Saved wordlist to {wordlist_name}")

    attempts = 0
    solves = 0
    attempts_sum = 0

    # Create a new process to run the wordle_bot.py script
    p = new_solver(wordlist_name)

    while solves != 100:
        # If no process is running start a new one
        if p.poll() is not None:
            p = new_solver(wordlist_name)
        # Get ready to send the first word
        r.recvuntil(b"> ")

        # Receive the word suggestion
        p.recvuntil(b"Try the word [")
        suggestion = p.recvuntil(b"]").split(b"]")[0]
        print(f"Suggestion: {suggestion}")

        # Send the suggestion to the server
        r.sendline(suggestion)
        attempts += 1

        # How did we do?
        result = r.recvline().strip()

        # If we got it right, we start over
        if b"[guess correct]" in result:
            print(f"Got it after {attempts} attempts!")
            solves += 1
            attempts_sum += attempts
            print(f"Average attempts: {attempts_sum / solves}")
            attempts = 0
            p.close()
            continue

        # Otherwise, we need to tell the bot what we got
        result = result.replace(b"-", b"0") \
                       .replace(b"*", b"1") \
                       .replace(b"+", b"2")
        print(f"Result: {result}")
        p.recvuntil(b"? ")
        p.sendline(result)

    print("Solved 100 words, exiting...")
    print(r.recvall().decode())
    r.close()


if __name__ == "__main__":
    main()

Output:

Saved wordlist to a06e7e83-c079-434d-a01a-164b9d4f3eb5
[+] Starting local process '/usr/bin/python3': pid 397792
Suggestion: b'TISEN'
Result: b'01012'
Suggestion: b'AEIND'
Result: b'01110'
Suggestion: b'ENLIK'
Result: b'21020'
Suggestion: b'EPRIN'
Got it after 4 attempts!
Average attempts: 4.0
[*] Stopped process '/usr/bin/python3' (pid 397792)
[+] Starting local process '/usr/bin/python3': pid 397798
Suggestion: b'TISEN'
Result: b'10010'
Suggestion: b'EHTRA'
Result: b'10110'
Suggestion: b'RDETD'
Result: b'10120'
Suggestion: b'GEERM'
Result: b'22110'
Suggestion: b'GERTE'
Got it after 5 attempts!
Average attempts: 4.5
[*] Stopped process '/usr/bin/python3' (pid 397798)
...
[+] Starting local process '/usr/bin/python3': pid 399733
Suggestion: b'TISEN'
Result: b'00022'
Suggestion: b'ANEHR'
Result: b'01200'
Suggestion: b'BEDOD'
Result: b'02101'
Suggestion: b'DEEEN'
Got it after 4 attempts!
Average attempts: 4.06
[*] Stopped process '/usr/bin/python3' (pid 399733)
Solved 100 words, exiting...
[+] Receiving all data: Done (66B)
[*] Closed connection to chall.rumble.host port 2568

avg.: 4.06
b'CSR{look_at_all_this_entropy_just_floating_around}'

Now our average score is around 4.0 (but only < 5 was necessary).

Flag

The flag is “CSR{look_at_all_this_entropy_just_floating_around}”.