Skip to content

Instantly share code, notes, and snippets.

@gsilvis
Last active May 22, 2023 04:22
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gsilvis/ab36dc48337b1168e9ba69043e217c62 to your computer and use it in GitHub Desktop.
Save gsilvis/ab36dc48337b1168e9ba69043e217c62 to your computer and use it in GitHub Desktop.
NSEC 2021 Zencastle Writeup
Event: NSEC 2021
Challenge: Zencastle (cryptography)
Team: Skiddies as a Service

challenge description

In Zencastle, we were given access to a server running some Python code. The server allowed users to log in, create tickets, and view tickets they created. We were told that there was at least one existing user, "jester". The protocol was as follows:

Phase 1 (login):

  • The client sends 16 bytes (the "client challenge")
  • The server sends 16 bytes, selected randomly (the "server challenge")
  • The client sends their username. (All strings are encoded as 4 bytes of length, followed by the string. This detail doesn't affect the solution much.)
  • At this point, the server generates an AES key by SHA256'ing the concatenation of the user's password hash and both challenge strings. Because the password hash is unknown and the client challenge varies, this key is both random and variable.
  • The client sends 16 bytes. These bytes are required to be the encryption of the client challenge under the AES key, using the CBF-8 mode of operation and an all zeros IV. (More on this later.)
  • The server accepts or rejects the client at this point.

Phase 2 (the rest):

  • The client repeatedly sends command buffers as strings. Command buffers are allowed to contain multiple commands each, but don't have to. The command buffers are encrypted under the AES key derived during login, still using CBF-8 and an all-zeroes IV.
  • Commands are either "\x01" (generate a new ticket); or "\x02" followed by a 16-byte ticket ID. The first command returns a message including the flag (encrypted). The second message returns something inconsequential (also encrypted). Any other command byte returns an "unknown command" error that includes the byte; this message is sent clear-text.

narrative

When I first read through this, I saw the following code:

cipher = AES.new(key, AES.MODE_CFB, b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00')

I wasn't familiar with this mode of operation, so I looked it up on Wikipedia. Wikipedia has a fantastic illustration of how the data flows through CBF. Looking at the 'login' phase in particular, I realized that only one block was being encrypted. From the diagram, this means that, to encrypt, you XOR the plaintext with the AES encryption of 0. Since the key is unknown, so is this pad. I therefore concluded that the challenge is obviously impossible.

Thankfully, a teammate pointed out to me that the Python Crypto library does something surprising with this line of code. Here is the full documentation for AES.new:

Help on function new in module Crypto.Cipher.AES:

new(key, *args, **kwargs)
    Create a new AES cipher

    :Parameters:
      key : byte string
        The secret key to use in the symmetric cipher.
        It must be 16 (*AES-128*), 24 (*AES-192*), or 32 (*AES-256*) bytes long.
    :Keywords:
       mode : a *MODE_** constant
        The chaining mode to use for encryption or decryption.
        Default is `MODE_ECB`.
      IV : byte string
        The initialization vector to use for encryption or decryption.

        It is ignored for `MODE_ECB` and `MODE_CTR`.

        For `MODE_OPENPGP`, IV must be `block_size` bytes long for encryption
        and `block_size` +2 bytes for decryption (in the latter case, it is
        actually the *encrypted* IV which was prefixed to the ciphertext).
        It is mandatory.

        For all other modes, it must be `block_size` bytes longs.
      counter : callable
        (*Only* `MODE_CTR`). A stateful function that returns the next
        *counter block*, which is a byte string of `block_size` bytes.
        For better performance, use `Crypto.Util.Counter`.
      segment_size : integer
        (*Only* `MODE_CFB`).The number of bits the plaintext and ciphertext
        are segmented in.
        It must be a multiple of 8. If 0 or not specified, it will be assumed to be 8.

    :Return: an `AESCipher` object

Buried in there under "segment_size" is an important note. When CFB mode is selected, it defaults to CFB with a "width" of 8 bits. The diagram on Wikipedia is captioned "full-width CFB"; there are also several other options. These are documented much less clearly, which is unfortunate, because they are actually used quite often. It's especially sad that Python defaults to the option with less documentation; I'm not clear if this is Wikipedia lacking information it should have, or Python having a bad default.

Here is my own explanation of CFB-8; if you change some numbers, it applies just as well to the other widths.

In CFB-8, we encrypt 8 bits at a time, and store a "context" buffer of 256 bits. At the beginning, this context is initialized to the IV. At each step, we take in a byte of plaintext p, with the context ctxt_0 || rest_of_ctxt. We AES encrypt this context and get high_byte || rest_of_result, and output p ^ high_byte. Finally, we update the context to rest_of_context || (p^high_byte), essentially shifting one byte of ciphertext onto the context on the right, and losing the oldest byte of the context on the left.

Decryption manages the context the same way; but since the context is based on the ciphertext, we now shift in bytes of the input, not the output. This means that you can actually decrypt every byte of a long string in parallel, which can be convenient.

zerologon

In the login phase, we have to send the encrypted value of our client challenge. Suppose we send all zeroes as the client challenge, and let's start by trying to just match the first byte. (Any client challenge with sixteen identical bytes would work, it turns out.)

The server should pick a random IV every time. Instead, it always uses the zero IV. So, to AES-CBF-8 encrypt the first byte of our message, we AES encrypt 16 bytes of zeroes and XOR the high byte of that with our plaintext. Now, we still don't know the key, so we have no information on what the pad is. No matter what, we have a 1/256 chance of being right, so let's guess zero.

Suppose we get lucky. Now, the new context is the last 15 byte of the IV, plus the first byte of the ciphertext. This is still all zeroes! The context is determined by the ciphertext, which we choose. (This is a little glib; we choose the ciphertext, but in this case we have to match the plaintext. But we selected the plaintext, so we can choose a plaintext that's convenient for us.) Because of this, the second byte will also encrypt to zero.

Going through the entire challenge process, if we send a client challenge of all zeroes, and then guess that it encrypts to all zeroes, we will be right 1/256 of the time.

This vulnerability is known as "zerologon" (CVE-2020-1472); there are a fair number of news articles about it.

getting the encrypted flag

So we've logged in. Now we need to somehow send commands. Let's start by sending the '\x01' command, which will give us the key, encrypted.

To AES-CBF-8 encrypt a one byte string, you AES encrypt the initial value of the context (that is, the IV) and XOR the most significant byte of the result with the plaintext. This is using the same AES key we used to login, so we already know the result of that: you XOR with zero!

So, we can send the encrypted command buffer '\x01', and receive back an encrypted string that includes the flag.

a decryption oracle

Now we need to decrypt what we got back from the server. As described above, when decrypting AES-CFB-8, you xor each byte of ciphertext with the most significant byte of the AES encryption of the context, and the context is exactly the previous 16 bytes of ciphertext (or IV if you run out of ciphertext). How do we get that value?

This is where we use the other two functionalities of the server. The '\x02' command is followed by a 16 byte value representing a ticket ID. Notably, the ticket ID doesn't really do anything interesting; so it's okay if the value we send decrypts to garbage. Extremely conveniently, 16 bytes also happens to be the width of the AES-CFB context!

So, send '\x02', followed by the sixteen bytes of context, and then any byte. The server will respond with info on some ticket (which you ignore), and will then attempt to run the command the last byte decrypts to. If the last byte decrypts to '\x01' or '\x02', we can figure that out by the length of the response; for all other values, the server just tells us what it decrypted to.

You could put the actual byte of ciphertext you want to decrypt. I instead put '\x00', which gives you the "pad" value to XOR with, and then did the XOR in the client.

Putting this all together, you can decrypt the message you got, one byte at a time, and get:

Your ticket "FLAG-ac1d12cf22dac7ab1ee63764dd8cad0f" has been successfully created.

Note that it was critically important here that we could send two commands in a single command buffer, but that we were also allowed to send multiple command buffers which were decrypted independently.

code listing

# some of this code (the string/int formatting on the wire) is copied from the
# challenge code.

import socket
from struct import pack,unpack

HOST = 'zencastle.ctf'
PORT = 9090

def write_string(value):
  assert(len(value) <= 128)
  SOCKET.send(pack("<I", len(value)))
  SOCKET.send(value)

def read_string():
  size = SOCKET.recv(4)
  size = unpack("<I", size)[0]

  if size > 128:
    size = 128

  return SOCKET.recv(size)

def initialize():
  while True:
    global SOCKET
    SOCKET = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
    SOCKET.connect((HOST, PORT))
    SOCKET.send('\x00'*16)
    CHALLENGE = SOCKET.recv(16)

    write_string("jester")
    SOCKET.send('\x00'*16)
    if SOCKET.recv(1) != '\x01':
      SOCKET.close()
      continue
    print("Auth'd!")
    break

def oracle(context):
  write_string('\x02' + context + '\x00')
  read_string()
  result = read_string()
  if result.startswith("Command"):
    _, num, _ = result.split("'")
    return chr(int(num))
  elif len(result) < 40:
    return '\x02'
  else:
    return '\x01'

def decrypt_string(s):
  context = '\x00'*16
  result = []
  for c in s:
    result.append(chr(ord(c) ^ ord(oracle(context))))
    context = context[1:16] + c
  return "".join(result)

if __name__=="__main__":
  initialize()
  write_string('\x01')
  encrypted = read_string()
  print(decrypt_string(encrypted))

the result

george@henry:~/NSEC-2021$ python zencastle_client.py
Auth'd!
Your ticket "FLAG-ac1d12cf22dac7ab1ee63764dd8cad0f" has been successfully created.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment