/ BLUEHENSCTF  MISC

Wordles with Dads

Another variation of Wordle, just like my previous writeup on Vocaloid Heardle.

Kid (easy) Mode

Problem Description

Welcome to Dad Wordle: nc 0.cloud.chals.io 29788

Source: https://gist.github.com/AndyNovo/0c35d07b460609fd457a9d1c5b8663d1

Author: ProfNinja

Understanding the problem

We were provided with the file wordleswithdads.py and an IP address & port 0.cloud.chals.io:29788 to connect to.

Looking at wordleswithdads.py (which is the source code for the game), it became clear that Wordles With Dads:

  1. Has a list of dad jokes scraped from icanhazdadjoke.com & saved into jokes.txt (we have no access to it)
  2. Loads one random joke from jokes.txt and reveals to the user the length of the joke as well as the first two characters.
  3. With such limited information, the user has to guess what the joke is in 6 tries.
  4. If the user guesses it correctly, the program returns the flag!

Also I noticed there is a checkguess(answer, guess_in) function that returns two arrays: correct and position:

  • correct stores the indices with correct character and correct position
  • position stores the indices with correct character but incorrect position

For example, if the answer = ABCDEFG and guess_in = GFEDCBA Then checkguess(answer, guess_in) will return

  • correct = [3]
  • position = [0, 1, 2, 4, 5, 6]

which is exactly how Wordle works!

def checkguess(answer, guess_in):
    if len(guess_in) != len(answer):
        print("Not the right guess length")
        return False
    if not alphacheck(guess_in):
        print("Invalid characters A-Z only")
        return False

    # histomaker returns the histogram of the alphabet positions
    # i.e. histomaker('ABA') will return
    #    {'A': [0, 2], 'B': [1], 'C': [], 'D': [], ..., 'Z': []}
    truth = histomaker(answer)
    guess = histomaker(guess_in)
    correct = []
    position = []
    for ltr in alphabet:
        tmp = guess[ltr]
        truetmp = truth[ltr]
        counter = 0
        for i in tmp:
            if i in truetmp:
                counter += 1
                correct.append(i)
        for i in tmp:
            if not i in correct:
                if len(truetmp) > counter:
                    counter += 1
                    position.append(i)
    correct.sort()
    position.sort()
    return {"correct": correct, "position": position}

Initial intuition

My first intuition was to scrape all the dad jokes from icanhazdadjoke.com and implement a function called search(length, start_with) that returns jokes of given length and which starts with the two characters provided.

When in doubt, scrape everything

Lucky for us, the website has an API endpoint that allows us to scrape all the jokes using a simple function:

import requests
search_url = "https://icanhazdadjoke.com/search"

# creates a jokes.txt file and insert all jokes scraped
# ... all the jokes are formatted the same way as `wordleswithdads.py`
def scrape():
    for page in range(22): # hard-coding 22 because there are 22 pages of jokes
        response = requests.get(search_url,
                                headers={"Accept": "application/json"},
                                params={"limit": 30, "page": page + 1}) # API only allows max 30 jokes per query
        joke_request = response.json()
        for joke in joke_request["results"]:
            # write to file
            with open("jokes.txt", "a") as joke_file:
                joke_file.write(joke["joke"] + "\n")
scrape()

Now we have a jokes.txt file that consists of 649 jokes:

# jokes.txt
I'm tired of following my dreams. I'm just going to ask them where they are going and meet up with them later.
Did you hear about the guy whose whole left side was cut off? He's all right now.
Why didn’t the skeleton cross the road? Because he had no guts.
...

If we look closer at the wordleswithdads.py, we would see that the jokes have all been sanitized to be A-Z only, where other characters have been removed.

Thus we need to implement a small function that formats our jokes in the same way as the provided code:

# opens our existing jokes.txt and sanitize the jokes
# store the resulting jokes (sorted by length) into jokes_format.txt
def format():
    output = []
    with open("jokes.txt", "r") as joke_file:
        joke_list = joke_file.readlines()
        for joke in joke_list:
            # remove space
            s = joke.replace(" ", "")
            # capitalize
            s = s.upper()
            # remove non-alphabetic characters
            s = ''.join([i for i in s if i.isalpha()])
            output.append(s)

    # sort array by length
    output.sort(key=len, reverse=True)

    # write output to file
    with open("jokes_format.txt", "w") as joke_file:
        for joke in output:
            joke_file.write(joke + "\n")
format()

Now we have a jokes_format.txt file that looks like this:

# jokes_format.txt
TWOMUFFINSWERESITTINGINANOVENANDTHEFIRSTLOOKSOVERTOTHESECONDANDSAYSMANITSREALLYHOTINHERETHESECONDLOOKSOVERATTHEFIRSTWITHASURPRISEDLOOKANDANSWERSWHOAATALKINGMUFFIN
SOMEPEOPLESAYTHATCOMEDIANSWHOTELLONETOOMANYLIGHTBULBJOKESSOONBURNOUTBUTTHEYDONTKNOWWATTTHEYARETALKINGABOUTTHEYRENOTTHATBRIGHT
AMANWASCAUGHTSTEALINGINASUPERMARKETTODAYWHILEBALANCEDONTHESHOULDERSOFACOUPLEOFVAMPIRESHEWASCHARGEDWITHSHOPLIFTINGONTWOCOUNTS
...

Getting the flag

As the saying goes: With great power comes great responsibility.

Now here’s what you need to know before moving on: With a complete list of dad jokes comes the flag.

Now let’s implement the search() function we’ve long awaited for!

def search(length, start_with=''):
    search = []
    # load our database of jokes
    with open("jokes_format.txt", "r") as joke_file:
        # read the jokes line by line
        joke_list = joke_file.readlines()
        # iterate through the jokes
        for joke in joke_list:
            # get rid of white spaces
            joke = joke.strip()
            # if we get the right joke candidate, we add it to search
            if len(joke) == length and joke.startswith(start_with):
                search.append(joke)
    # return the list of jokes of given length & start with given characters
    return search

# this will print out list of likely candidates of length 44 and starts with 'WH'
print(search(44, 'WH'))

Now if we pass in the hints provided by the game into our search() function, we get a list of potential candidates. We can just keep trying until it works, which fortunately doesn’t take that long.

Here is our flag: UDCTF{S000_iPh0n3_ch4rg3rs_c4ll_3m_APPLE_JU1C3!} 😎

Dad (hard) Mode

Problem Description

See challenge here: https://ctftime.org/task/23797

In Hard mode you get 2 guesses, no hint, 10 problems and at most 60 seconds. But I don’t think you need that much time honestly…

Source: https://gist.github.com/AndyNovo/1a207eb7b6042686d6e447fa872e09e4

Author: ProfNinja

Initial intuition

Oh my god. This just became 100x harder. Not only do we have to play the game 10 times consecutively, we only have 2 guesses for each – and only 60 seconds total!

This challenge actually reminded me of Sekai CTF’s Console Port Pro – kudos to Akash for teaching me how pwntools works, because it was exactly the knowledge I needed to solve this challenge.

So my immediate intuition was to use pwntools to automate playing the game. 🤖

Installing Pwntools for the first time (and resorting to Docker)

If you have never had any issues installing packages/tools, do you even CTF?

I immediately faced an issue while installing pwntools on my Mac, as this simple install script

$ pip3 install pwntools

Gave me an error:

note: This error originates from a subprocess, and is likely not a problem with pip.
  ERROR: Failed building wheel for unicorn

I am a busy person! I can’t afford to waste time being stuck on installing packages >:(

In reality, I looked up everywhere online but still couldn’t resolve the issue…

So I decided to try pulling a Docker image with pwntools installed. This repo taught me what I needed to do:

$ docker run -it pwntools/pwntools:stable

I wanted to automatically clean up the docker image when I close it, so I added --rm:

$ docker run --rm -it pwntools/pwntools:stable

Finally, I wanted to mount my current directory as the working directory in the container, so I added -v "$(pwd):$(pwd)" -w "$(pwd)" (see cheatsheet):

$ docker run --rm -v "$(pwd):$(pwd)" -w "$(pwd)" -it pwntools/pwntools:stable

With this script, I am now able to run python3 wordle_solver.py with pwntools and even save my progress directly to my local current working directory!

Getting the flag

The game now consists of 10 rounds.

So my plan was to write a function that plays one round of the wordle game, and put that function in an infinite loop until we win all 10 rounds.

After a lot of bug fixing, frustration, and polishing code, this is what my function looked like:

def play_one_round():
    # read game start message (which includes joke length)
    welcome_to_dad_joke_msg = str(r.recvline())

    # get the joke length (it's stored in the 8th word)
    length = int(welcome_do_dad_joke_msg.split(' ')[8])

    # search for all jokes of this length
    db = search(length)

    # read away useless line
    r.recvuntil('Guess? >')

    # print out what we are guessing
    print(f"{'Guess #1:':<30}{db[0]:<40}")

    # send our first guess to game server
    r.sendline(db[0])

    # read away useless line
    r.recvline()

    # response will return msg that hints to us if correct/wrong
    response = str(r.recvline())

    # print out what our response is
    print(f"{'Response 1:':<30}{response:<40}")

    # guess is not correct if we can find 'position' and 'correct' in the response (both are arrays)
    if 'position' in response and 'correct' in response:
        print('[Guess is Wrong]')

        # get ready for next guess
        r.recvuntil("Guess? >")

        # parseStats() will return the arrays correct, position from the response string
        correct, position = parseStats(response)

        # get the most likely candidate satisfying the given correct & position arrays
        candidates = getCandidates(correct, position, db[0], db)

        # no candidates found :( which means that sth went wrong so go into interactive mode to debug
        if len(candidates) == 0:
            print("[No candidates]")
            r.interactive()

        # at least one candidate found, so let's guess with that
        else:
            # print out what we are guessing
            print(f"{'Guess #2:':<30}{candidates[0]:<40}")

            # send our second guess to game server
            r.sendline(candidates[0])

            # receive response
            response = str(r.recvline())

            # print out what our response is
            print(f"{'Response 2:':<30}{response:<40}")

            # sad
            if str(r.recvline()) == "You lose":
                print('[Guess 2 Wrong]')
                # we failed :(
                return False

    # guess is correct!!!
    else:
        print('[Guess 1 Correct]')
        # yay!
        return True

    # if we reach here it probably means we didn't guess right
    return False

Finally, in the main function we can call this function repeatedly until we win 10 rounds!

from pwn import *

# connect to the game server
r = remote("0.cloud.chals.io", 33282)

# read away the first useless line
r.recvline()

def restart_game():
    r.close()
    r = remote("0.cloud.chals.io", 33282)
    r.recvline()

def main():
    # start playing!
    games_won = 0
    while True:
        if play_one_round():
            games_win += 1
        else:
            games_won = 0
            restart_game()
            print("It's okay. We try again")
            continue

        if games_won >= 10:
            print('-------------------------------------------')
            print('We did it..!')
            print('-------------------------------------------')
            r.interactive()
            break

main()

It didn’t work out when I first ran it, but after a few more attempts, we got the flag!!

The flag: UDCTF{wh4ts_th3_be5t_th1ng_ab0ut_Sw1tzerl4nd? Dunn0_bu7_th3_flag_15_a_b1g_plu5!} 🎉