FRA Challenge 2021-09

Finally back to a classic FRA challenge. This one turned out to be more of a programming/engineering challenge than a pure security challenge but it was great fun anyway! And I'm always happy when these challenges lead to the development of more general tools, i.e. ReplaySocket, but more about this in the challenge.

Challenge: Underrättelseanalytiker till Cyberförsvaret/Asteroid  (243211 bytes, sha256: 18d37e587debcb35ce4e5efb3789833dfdee649c99f8b41db121c39710b1650d, https://challenge.fra.se/)

 

Intro

In this challenge we are given the code for a game client, some network traffic recorded during a game session, and four objectives (translations by me):

  1. Save a screenshot of the spaceship.
  2. Save a screenshot of the spaceship in flight.
  3. Save a screenshot of a rock and flower.
  4. Save a screenshot of the entires area flown by the spaceship.

 

Starting with a quick look at the PCAP file with network traffic we find that the game session spans a single TCP session and that the server sends JSON-encoded data to the client.
Some examples of data sent by server:

{"msg_type": "code", "encoding": "ascii", "data": "class Player ..."
{"msg_type": "code", "encoding": "long_xor", "data": "241e00051a542b290c ..."
{"msg_type": "update", "encoding": "xor", "data": "3c652e ..."
{"msg_type": "update", "encoding": "ascii", "data": "{\"id\": 1, \"type\" ..."

Noteworthy here is the msg_type, which indicates if the server is sending code for the client to run or data to update the game. Furthermore, the encoding of the data can be either plain ascii or encrypted using either "xor" or "long_xor", which we'll explore later.

 

Running the code

Since we need to take screenshots, it's important that we can run the code. This is not straight-forward as the client is trying to connect to a server, to which we do not have access. Below is the code used to create the socket for communication between the client and server. 

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
s.settimeout(0.0001)

However, since we have the network traffic we could, in theory, replay all this traffic back to the client. And indeed, this is the purpose ReplaySocket. Using this, we can specify the conversation, including direction, i.e. server to client, and all the socket.recv calls should work as intended.  We simply replace the code above with:

s = ReplaySocket("triangle.pcap", ("127.0.0.1", 4372, "127.0.0.1", 57040), 2)
s.timeout_on_empty = True

Next, we create two empty files "xor.key" and "long_xor.key" where we will store the keys later. Also, remove the last s.close() and add a "continue" in the else branch on wrong md5 hash since the hashes will be wrong until we find the xor keys.

Now we should be able to get the first image! (Objective 1)

Hmm, this does not look like a spaceship. Well, to be fair, it does look a bit like a classic flying saucer. Anyway, this is not correct and is caused by the following code (I did not write "bad idea"):

for code in M.update():
exec(code) # bad idea!

So what is happening here is that the client tries to dynamically load the Player class from the network traffic. This could be an RCE vulnerability if the server/communication is not secure, hence the "bad idea!" I assume. But in my case, it does not replace the Player class. Therefore you can simply add a print(code) instead and manually paste it into the code. Now we get a cool spaceship!

Alright, now we can see the spaceship fly around but the space is very empty. To see the "rocks" and "flowers" we need to start cracking!

Cracking XOR

If we look at the first message that uses "xor" we get some hex-encoded data and also an MD5 hash, which is very helpful!

{"msg_type": "update", "encoding": "xor", "data": "3c652e23...73771a3a", "md5": "2074c8a4effcbaa0ceec11754cfa66c3"}

So it's safe to assume this is an XOR cipher. Since we have both "xor" and "long_xor" we can start by assuming the key length of the normal "xor" is just one byte. Thus we can quickly iterate all the 256 possible keys, try to decrypt the data, and finally check the if hashes match.

for key in range(255):
    jd=''.join([chr(i ^ key) for i in codecs.decode(j['data'], 'hex')])
    if hashlib.md5(bytes(jd, 'utf-8')).hexdigest()==j['md5']:
     print(chr(key), " is good")     

Now we add this key to the xor.key file and suddenly space isn't so empty! Here is a screenshot of the spaceship in flight (objective 2).

So far, we are able to decrypt the position of all the rocks and flowers but we still need the code to render them correctly. For this, we need to handle "long xor".

 

Cracking Long XOR

 The messages are very similar to the previous XOR messages, but with more data.

{"msg_type": "code", "encoding": "long_xor", "data": "241e0005...5459664f", "md5": "e9514a2de33051b9c6174d31e35b7584"}

Now from here, there are actually a few methods that can be used. First of all, note that we have two long_xor messages, both encrypted with the same key! The cool thing with XOR ciphers is that they can be literally uncrackable. If, and this is a big if, the key is truly random and not reused! If so, this encryption method is known as a One-time-pad (OTP). Here we don't know if the key is random but it is certainly reused (based on inspection of the client code). Also noteworthy is that we might be able to do a known-plaintext attack to get the key. This is because we know quite a bit about the underlying data, i.e. it's python code and probably starts with "class Rock" or "class Flower".

We know that:

key = cipher ⊕ plaintext

Now we can try:

key = 0x241e00051a ⊕ 'class' = 'Gravi' 

This looks promising! The key is all text, which would be unlikely if it was truly random. Since we don't know if the first or second message corresponds to the code for "Rock" we need to try both.

key_m1 = 0x241e00051a542b290c194b2a251804151d5d ⊕ 'class Rock(Object)' = 'GravityForceGravit' 
key_m2 = 0x4241e00051a543f2a000506176f3d031c0c1 ⊕ 'class Rock(Object)' = 0x212d81... 

Here we can already start to see some repetition in the key, i.e. "GravityForceGravit" might be a repetition of "GravityForce". And indeed, if we put "GravityForce" in the long_xor.key file we can successfully decrypt the code for rendering rocks and flowers.

In this case, guessing the start of the message was enough. But in general, if we know a potential string or crib in the message, say ImageDraw.Draw, we drag this across the message until we find something reasonable, a method known as crib dragging. Here, if we xor the crib 'ImageDraw.Draw' with the first message at an offset of 409 we get "ravityForceGra", further confirming the key we found.

Now if we decrypt the two long_xor messages we can correctly render rocks and flowers (objective 3).

 

Screenshot the entire area

Now that we have decrypted everything all that is left is to screenshot the end result. This turned out to be quite tricky. First of all, the entire are is quite large with objects having x and y positions at closer to 500. Sure, that's not much for a game but this is to be rendered in a terminal! So to start we can change the RESOLUTION variable to 400x450. In addition, I also create a profile in my terminal with font size 3.

In this video, we can follow the spaceship around the map. Sorry, it's quite laggy, this game is running at 100% CPU on my computer. I know some of my friends would disagree but maybe terminals weren't made to run 4K at 60 FPS 😉. We can also force a static camera position by changing:

buf=obj.render(buf, self.model.player.x, self.model.player.y)

to

buf=obj.render(buf, 150, 220)

 

Here is a video with static a camera position.

 

And finally, here is a single screenshot of the entire map.

 

 

This was a fun challenge with a visually pleasing result! The focus here seemed to be less on security and more about implementation and painting the entire map, which indeed is importatn too!

 


Write your comment!

Comments

no.1 fan No. 1425 2022-10-23 18:06:17
Wow, that is very cool. Good job!