šŸ…æļøšŸ…æļøšŸ…æļøordle

PlaidCTF 2022 (350 points)

Donā€™t you hate it when CTFs happen faster than you can write them up? This is probably the only PlaidCTF challenge I get to, unfortunately.1

Web is out, retro is in. Play your favorite word game from the comfort of your terminal!

Itā€™s a terminal Wordle client!

Screenshot of a terminal Wordle client. The puzzle has been solved with the answer COZEY.

I only solved the first half of this challenge. The two halves seem to be unrelated though. (Nobody solved the second half during the CTF.) The challenge was quite big code-wise, with more than a dozen files, so itā€™s hard to replicate the experience in a post like this, but hereā€™s an attempt.

There are four Wordle ā€œlevelsā€. To play the game, the client picks a level, makes a TLS connection to the server using some bundled certificates, and then sends guesses. The server responds to each guess with an ā€œindicatorā€: a sequence of black, yellow, and green emoji. When the client guesses the word correctly, the server creates and sends a TLS certificate to it, and the client can present that certificate on a future connection to connect to the next level.

We are provided with the source code for the client and the server, including the word lists and some other assets, as well as a bunch of dev certificates for local testing; weā€™re only missing the private key for the serverā€™s root certificate (and, of course, the real flag).

Level 1 is classic Wordle (albeit with answers drawn from a much worse word list). However, in Level 2 (which gives the first flag when solved), the ā€œsecret wordā€ is a sequence of five random emojis drawn with replacement from a list of 236 emojis, for 732,082,482,176 possible answers; and you still only get six guesses. Itā€™s not that obvious how to bound the probability of success if you play optimally, but a quick calculation to guide your intuition is that if you guess 25 distinct emoji for your first five guesses, the probability that all five answer emoji are among those guesses is around 0.001%. Connecting to the server also includes a delay, so brute force is not really an option here.

Is there any other way to figure out the answer emoji? Letā€™s look at how theyā€™re generated:

The rand here is "math/rand", which is enough to raise some eyebrows. The challenge contains a lot of other certificate management code that also uses random numbers, but the other code conspicuously imports "crypto/rand", from which one can infer that "math/rand" is likely not cryptographically secure even without knowing anything about Go. Indeed, the math/rand documentation says:

Package rand implements pseudo-random number generators unsuitable for security-sensitive work.

ā€¦

This packageā€™s outputs might be easily predictable regardless of how itā€™s seeded. For random numbers suitable for security-sensitive work, see the crypto/rand package.

The default Go Source for math/rand actually isnā€™t seeded at all, so itā€™s completely deterministic! Unfortunately, the server does seed it with the current time:

Still, this is not that hard to predict. The CTF is only 24 hours long, so assuming the serverā€™s clock is not wildly out of sync and that it started during the CTF, there are only 86,400,000 possible seeds. Thatā€™s already a lot less than 732,082,482,176.

Another thing we can do is play games of šŸ…æļøšŸ…æļøšŸ…æļøordle Level 1, which shares its random number generator state with Level 2, and get information about the RNG state that way. This at least lets us do some amount of ā€œlocal brute forcingā€ for the possible seed. However, if there is a smart way to derive the RNG state from a few samples mathematically (rather than just trying all states), I couldnā€™t see it. Our samples can at best tell us the seed modulo that length at some point in time, but the word listā€™s length is not a nice number, so itā€™s not clear how to use this information.

Still, these insights were enough for me to write a prototype where I started a local server and guessed the word just from the approximate time and some Level 1 solutions. Unfortunately, the seed isnā€™t the only unpredictable piece of information we need; on the real server, the RNG seed is constantly advanced by all the other teams trying to solve the challenge.

Is the state space still usefully bounded? As of time of writing, Goā€™s default math/rand RNG is not well-documented, but between a GitHub issue with some links and the source code, it appears that the state space is a somewhat ridiculous 264Ɨ607 ā‰ˆ 1011,694. That is, shall we say, the opposite of less than 732,082,482,176. Ultimately, thinking about the state space as (initial seed) Ɨ (number of times the RNG has been advanced) still produces more reasonable bounds. To make up some numbers: Say we manage to catch the server within an hour of its restart, and that other teams have advanced the RNG at most 10,000 times. Then the number of RNG states we have to consider is 36,000,000,000. Thatā€™s a little less than 732,082,482,176, but itā€™s still well out of reach of brute force.

How else can we reduce the search space?

What time is it?

One question I found useful to step back and contemplate is simply: why does the challenge have such an absurd amount of infrastructure for making and verifying SSL certificates? Surely there are simpler ways for a server to give clients something they can later use to prove they solved a level. Maybe we can use the server certificate somehow?

Letā€™s look at a certificate. Hereā€™s the one for this blog, at least when I was writing this post.

Firefox display of an SSL certificate from Cloudflare. A timestamp 'Sun, 11 Jul 2021 00:00:00 GMT' is highlighted, next to the label 'Not Before'.

SSL certificates has timestamps. What timestamp does the server put in its certificates, and when?

Turns out, itā€™s actually time.Now(), exactly the same expression used to seed the RNG!

Well, not exactly exactly, because we only get the time down to a granularity of one second. But by looking at the server, and adding one second of leeway on both sides just to not worry about it, we can narrow down the set of seeds to 3,000.

Suddenly the numbers are looking feasible!

We can lightly modify the client to print out the server certificateā€™s NotBefore timestamp:

I think there is a lot of flexibility in how to solve the challenge from here. Another useful observation for effectively narrowing down possible RNG states is that the server samples the secret word and advances the RNG when you connect, so if you start two connections quickly one after another, you have a good chance of sampling two adjacent RNG states. Because I wasnā€™t very fluent in Go, I went about this in a really hacky way and didnā€™t automate too much of the process. I did hack in one keyboard shortcut to quickly submit an isogrammatic triplet of words, but otherwise, I solved Wordles manually and pasted numbers between Go scripts running in different terminals. Hereā€™s what I did:

  1. Start two connections to the server Level 1, as quickly in succession as humanly possible.
  2. Solve both Wordles.

    Two terminals with solved Wordles, with the answers CARDS and FIVES, respectively

    This is not easy because thereā€™s a one-minute time limit (during which you have to solve both) and the answer isnā€™t revealed if you fail. I think itā€™s hilarious that speed-solving Wordles turned out to be a useful skill here. (Obviously I could have scripted this, in a separate script that I manually interacted with if necessary ā€” but whereā€™s the fun in that?)

  3. Look at the client logs to extract the NotBefore timestamp. Using brute force, look for seeds near the timestamp and numbers of iterations that would cause those two words to be sampled.

    Two terminals. In the top terminal, `go run .` has been executed and then the script have been provided with the input 1649537357000 and the solution words CARDS and FIVES, and has output two candidate seeds and interation counts. In the bottom terminal, the timestamp 1649537357000 is highlighted among logging from the client.

    As in the above example, doing this step once is often not enough to uniquely identify the seed, and thereā€™s always a risk that you donā€™t manage to catch two adjacent seeds in step 1.

  4. Repeat steps 1 to 3 until you are confident about the seed, and while the server NotBefore timestamp doesnā€™t change. Usually seeing a particular seed twice is enough to be confident in it.

  5. Start two connections to the server Level 1 and Level 2, as quickly in succession as humanly possible.

    Solve the first Wordle, then brute force a number of iterations that would cause that word to be sampled and submit the emoji sequence that would be generated from the next iteration. (In the below example, we picked the iteration count of 40625 as the first one after the iteration count we got from Step 3, 39476 iterations. Also, since I was on Ubuntu, I manually typed in each emoji using Ctrl-Shift-U, then the Unicode codepoint in hex, then Enter; there are surely better ways to do it for anybody more comfortable tinkering with the Go client.)

    Two terminals with solved Wordles. The left one has the answer COMUS. The right one has the answer šŸ¦¢šŸ˜—šŸŽšŸ„šŸ¦€ and displays the flag below.
    A terminal in which `go run .` has been executed and then the script has been provided with the input 1649537357001 and 'comus', and has outputted four iteration counts and emoji sequences; the third listing has iteration count 40625 and matches the emoji sequence above.

And we have the flag!

PCTF{L3@KY_S33D5_GR0W_INT0_B1G_PR0BL3M5}

For completeness, my solve script follows:

package main

import (
    _ "embed"
    "strings"
    "bytes"
    "math/rand"
    "log"
    "bufio"
    "fmt"
    "os"
    "strconv"
)

//go:embed level1_wordlist.txt
var level1WordlistBytes []byte

//go:embed level2_emojis.txt
var level2ValidEmojis string

func main() {
    reader := bufio.NewReader(os.Stdin)

    var level1Wordlist [][]rune

    scanner := bufio.NewScanner(bytes.NewReader(level1WordlistBytes))
    for scanner.Scan() {
        level1Wordlist = append(level1Wordlist, []rune(scanner.Text()))
    }

    if err := scanner.Err(); err != nil {
        log.Fatal(err)
    }

    level2Emojis := []rune(level2ValidEmojis)
    fmt.Println(len(level1Wordlist), "words")
    fmt.Println(len(level2Emojis), "emojis")

    fmt.Println("Phase 1 or 2?")
    prompt, _ := reader.ReadString('\n')
    for prompt[0] != '1' && prompt[0] != '2' {
        prompt, _ = reader.ReadString('\n')
    }

    fmt.Println("Give me the millis:")
    ms, _ := reader.ReadString('\n')
    ms = strings.TrimSpace(ms)
    millis, err := strconv.Atoi(ms)
    if err != nil {
        panic(err)
    }

    if prompt[0] == '1' {
        fmt.Println("Word 1 of 2:")
        word1, _ := reader.ReadString('\n')
        word1 = strings.TrimSpace(word1)
        fmt.Println("Word 2 of 2:")
        word2, _ := reader.ReadString('\n')
        word2 = strings.TrimSpace(word2)

        for m := millis - 1000; m < millis + 2000; m++ {
            r := rand.New(rand.NewSource(int64(m)))
            gen1 := string(level1Wordlist[r.Intn(len(level1Wordlist))])
            gen2 := string(level1Wordlist[r.Intn(len(level1Wordlist))])
            for its := 0; its < 100000; its += 1 {
                if gen1 == word1 && gen2 == word2 {
                    fmt.Println("Seed might be", m, "with", its, "iterations")
                }
                gen1 = gen2
                gen2 = string(level1Wordlist[r.Intn(len(level1Wordlist))])
            }
        }
    } else {
        fmt.Println("Word:")

        word1, _ := reader.ReadString('\n')
        word1 = strings.TrimSpace(word1)

        r := rand.New(rand.NewSource(int64(millis)))
        gen1 := string(level1Wordlist[r.Intn(len(level1Wordlist))])
        for its := 0; its < 100000; its += 1 {
            if gen1 == word1 {
                fmt.Println("Seed might be", millis, "with", its, "iterations")
                var guess []rune
                for i := 0; i < 5; i++ {
                    guess = append(guess, level2Emojis[r.Intn(len(level2Emojis))])
                }
                fmt.Println("maybe", string(guess))
                fmt.Printf("%x %x %x %x %x\n", guess[0], guess[1], guess[2], guess[3], guess[4])
                fmt.Println("========")
            }
            gen1 = string(level1Wordlist[r.Intn(len(level1Wordlist))])
        }
    }
}

  1. I wrote a script with a brute-force-y approach for choreography (which still involved an interesting insight, but was much less interesting than the intended solution). I also produced this exploit for i_c_u, which Iā€™ll include without a writeup in the tradition of what one commenter dubbed ā€œCTF solutions that look like shitpostsā€:

    An arrangement of gray squares with the text GIVE ME THE FLAG in the top-right corner.

    Since then DEF CON Quals, justCTF, and most recently Google CTF have happened, and Iā€™ll have to carefully triage which tasks I think are useful to write up, if any.ā†©

(note: the commenting setup here is experimental and I may not check my comments often; if you want to tell me something instead of the world, email me!)