Loading [MathJax]/jax/output/HTML-CSS/jax.js

Tags: tls misc prng go wordle 

Rating:

Level 2

To get to level 2, we first have to pass level 1. The level 1 however is just a normal wordle game. Although the word list is larger than the standard wordle game, it's easy to pass with existing online tools and by hand.

Reading the logic of the second game, it is generating a string of 5 emojis at random each time at the start of a new game. The emojis are chosen from a fixed list of 236 emojis, meaning there are 23657.3×1011 possible combinations. We have 6 times of guess, which is clearly not enough for guessing even with an algorithm, or we have to get really lucky.

Looking more into the code, we see that the randomzation part of chooing emojis is done by Go's builtin math/rand library. We then look for the potential of a pRNG hack. If this path is correct, we need to know two things: what is the seed of the pRNG and where we are in the random number stream.

We found that at the start of the server, the current time in miliseconds in Unix timestamp will be chosen as the seed for the pRNG. So the question becomes how we can get the start time of the server. The obvious answer is to crash the server, or to ask for a server restart. The former seems to be against the rules, the later is rejected by the admin.

After some more digging, we finally realize that the server's TLS cert is generated at the server start time as well, and the code will choose that time as the cert's NotBefore field. Knowing this, we are able to use the server's cert as an accurate indicator of the server start time. However, after seeing the actual value, we realize that NotBefore field is only down to the seconds, that means we have probably ±1000 miliseconds of range we need to try, due to rounding and delay between two pieces of code.

The second piece of the puzzle, knowing where we are in the random number stream, is rather easy. Level 1 also uses the same pRNG to generate target words, but we know level 1 is solvable and we have the word list, so we are able to know exactly which random number (mod list size) was generated.

Take everything together: we need to query level 1 multiple times to get some consecutive random numbers, then brute-force the possible seeds to get a sequence of random numbers. We then compare each sequence with the acquired random numbers generated by the server, and whichever sequence contains the list of acquired random numbers is the correct sequence, and we are able to know what random numbers will come next. A few details:

  1. We need to query level 1 multiple times consecutively, meaning we need a automatic wordle solver. We used ammario/wordle-solver which is written in Go and supports custom words list.
  2. However, the solver cannot solve with 100% success rate, and there might be other teams solving at the same time. That means the random numbers we get are not necessarily consecutively generated by the pRNG.
  3. That means our matching algorithm should not be a simple range matching, but need to match the sub-list non-consecutively.

Finally we chose to query 10 times and match it against a stream of 10,000 numbers for each seed. We are able to successfully get the seed and solve level 2 using only one guess.

Psuedocode:

wordMap = word -> index
conn = dial(server)
t = UnixMilli(conn.TLS.Cert.NotBefore)
rngs = []
for 10 times:
  answer = guess level 1 until correct
  rng = wordMap[answer]
  rngs.append(rng)

for seed : t - 1000 ... t + 1000:
  prng = fromSeed(seed)
  rngsCopy = copy(rngs)
  for 10000 times:
    if rngsCopy is empty: break
    rng = prng.randN(len(wordMap))
    if rng == rngsCopy[0]:
      rngsCopy.popFront()
  if rngsCopy is empty:
    break # prng is aligned with the server

emojis = []
for 5 times:
    emojis.append(emojiList[prngs.getN(len(emojiList))])
guess level 2 with emojis # profit

Level 4

We were unable to solve this during the CTF, but with the hint later on that level 3 is unsolvable, we quickly turned to other places and found where the bug is.

The bug is a combination of two Go's quirk:

  • non-blocking channel in select with a default path
  • TLS library accepting connection before handshake completes

The former is referring to server/level_server.go:129. Normally the channel will block the receving side if no one's sending anything on the otherside (or there's nothing in channel for a buffered channel). In this case, the function sessionSearch is waiting for an input from the channel connErr, but it is in a select with a default path. That makes the connErr not actually blocking if there's no goroutine sending to the channel, but instead enter the default execution path (like how a default behave in a switch).

Then if we look back at which part of the code should send stuff to connErr channel, we see the function feedbackWriter. The first thing this function does is to write to the connection conn, and if the write fails it will send the error to connErr channel. This is where the second quirk comes to play: if we look at the implementation of net.Conn.Write() by crypto/tls library's *tls.Conn.Write(), the first line we see is that it will call *tls.Conn.Handshake(). This function essential "completes" the handshake in a blocking way, meaning if the handshake is not complete, it will subsequently block the write as well.

Therefore, we could block feedbackWriter by not completing the TLS handshake on the client side, and thus making sessionSearch choosing the default path and pass the check. There are multiple ways of blocking the TLS handshake, like simply not sending anything after ServerHello. The easier way is to block using tls.Config.GetClientCertificate, which will be called when the server's requesting client cert:

tls.Dial("tcp", "pppordle.chal.pwni.ng:1341", &tls.Config{
    GetClientCertificate: func(info *tls.CertificateRequestInfo) (*tls.Certificate, error) {
        time.Sleep(3 * time.Second)
        return nil, errors.New("just nope")
    },
    ...
})

After that, we can connect to level 4 without actual authentication. Level 4 is quite easy to solve because the flag (wordle target) is fixed. All we need to do is to enumerate all possible characters and record the ones that are correct.


Edit Jun 15, 2022: feedbackWriter calls Write() instead of Read(). Thanks to @dumpx86.