Tags: tls misc prng go wordle
Rating:
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 2365≈7.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:
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
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:
select
with a default
pathThe 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.