Cyber Security Rumble 2022 - SCRAMBLE
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}”.