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!
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:
func emojiGenerator() (word []rune, attempts int) {
for i := 0; i < 5; i++ {
word = append(word, unicode.ToUpper(level2Emojis[rand.Intn(len(level2Emojis))]))
}
return word, 6
}
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.
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!
cert := &x509.Certificate{
SerialNumber: config.Serial,
Subject: pkix.Name{
Country: []string{"šŗšø"},
Province: []string{"Pennsylvania"},
Locality: []string{"Pittsburgh"},
Organization: []string{"PlaidCTF"},
CommonName: config.CommonName,
},
DNSNames: config.DNSNames,
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Second * time.Duration(config.SecsValid)),
IsCA: config.Parent == nil,
KeyUsage: keyUsage,
ExtKeyUsage: extKeyUsage,
BasicConstraintsValid: true,
}
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:
conn, err := tls.Dial("tcp", sessionServer, tlsConfig)
log.Println(len(conn.ConnectionState().PeerCertificates))
for i, c := range(conn.ConnectionState().PeerCertificates) {
log.Println(i, c.NotBefore.UnixMilli())
}
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:
- Start two connections to the server Level 1, as quickly in succession as humanly possible.
Solve both Wordles.
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?)
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.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.
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.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.)
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))])
}
}
}
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 fori_c_u
, which Iāll include without a writeup in the tradition of what one commenter dubbed āCTF solutions that look like shitpostsā: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.ā©