Writeup by spiker00t for PTSD - Encore une fois

reverse

April 18, 2024

Status 4 - Checking clients health

After establishing successfully the communication channels in the first part of the challenge, the server gets forward to status 4: checking clients health.

__int64 __fastcall get_health(__int64 a1, int a2)
{
  unsigned int v3; // [rsp+18h] [rbp-18h]
  int i; // [rsp+1Ch] [rbp-14h]
  void *ptr[2]; // [rsp+20h] [rbp-10h] BYREF

  ptr[1] = (void *)__readfsqword(0x28u);
  v3 = 4;
  ptr[0] = 0LL;
  if ( a1 )
  {
    for ( i = 0; i < a2 && *(_BYTE *)(41LL * i + a1) != 0xFF; ++i )
    {
      if ( (unsigned __int8)send_pullpull(41LL * i + a1) != 1 )
        goto LABEL_12;
      alarm(0xAu);
      if ( (unsigned __int8)recv_encrypted_ack(a1 + 41LL * i, ptr) != 1 || *((_BYTE *)ptr[0] + 6) != 1 )
        goto LABEL_12;
      if ( ptr[0] )
      {
        free(ptr[0]);
        ptr[0] = 0LL;
      }
      alarm(0);
    }
    v3 = 5;
  }
LABEL_12:
  alarm(0);
  if ( ptr[0] )
  {
    free(ptr[0]);
    ptr[0] = 0LL;
  }
  return v3;
}

During this phase, for each registered client, the server sends the encrypted message “PULLPULL” (function send_pullpull) and waits up to 10 seconds for the client to send back an encrypted ACK (namely, an encrypted message containing the byte “01”). After checking each client’s health, the server resumes to status 5.

What prevents us from answering to client 5 health check is that we do not know the AES key for client 5. Indeed, we cannot respond to the server with valid encrypted messages. We can check for classical crypto vulnerabilities, such as a nonce reuse, but this direction do not leads anywhere here. However, the description states “Your goal is to impersonate this client by sending their information to the server.” This clearly suggests us to set up a replay attack with the intercepted packets.

When the server receives a packet, it checks that the value of the packet counter in the packet is greater than the current server-side counter value. Since the packet counter of the client 5 is equal to 0x40 according to keys.db, we cannot simply send back the logged packets from records.txt, because their packet counter is lower. This should in theory prevent replay attacks.

if ( *(_WORD *)(key + 3) < *((_WORD *)packet + 1) )
{
  *(_WORD *)(key + 3) = *((_WORD *)packet + 1);
  [...]
}

However, there is no upper bound on the counter received from the client, and the counter is updated to the value sent by the client. For example, if the current server-side counter is 42,

  • If the client sends a packet with counter value 41, the reception fails
  • If the client sends a packet with counter value 43, the reception succeeds and the new counter value is 43
  • If the client sends a packet with counter value 666, the reception succeeds and the new counter value is 666

Moreover, when the server sends a message, it simply increments the current counter value (which is stored on 2 bytes). The server never performs any checks for integer overflow. Thus, if we send a packet with counter value FFFF, the next counter value from the server will be 0000! As a result, we will be able to replay intercepted packets since their counter value are obviously greater than 0. Thus, we can bypass authentication from AES-GCM and impersonate client 5.

Last but not least, if the decryption of the received ACK fails (recv_encrypted_ack returns a value not equal to 1), the server do not stop, but instead the status remains equal to 4 and the phase “Getting health clients” is executed again. As a result, we can reply with invalid messages, the server will keep sending us “PULLPULL” messages.

The attack scenario will then be the following:

  • We receive a PULLPULL message from the server, we ignore it
  • We send an ACK with a dummy nonce/tag and with counter value FFFF. The decryption will fail, but the server will continue sending packets…
  • The server sends a PULLPULL message with a counter equal to 0000
  • We send back the first intercepted packet: 05000C036C4D63FC7F5FE25094D84F86157D7F1783EEAD297538B97C9F1C31BC0194. We managed to impersonate client 5.

Finally, we must not forget to answer the health check as client 6 too, since the server iterates on all the clients. This is not a problem since we know the AES key for client 6.

Status 5 - Getting clients info

__int64 __fastcall get_info(__int64 a1, int a2)
{
  unsigned int v3; // [rsp+18h] [rbp-28h]
  int i; // [rsp+1Ch] [rbp-24h]
  void *ptr; // [rsp+20h] [rbp-20h] BYREF
  void *v6; // [rsp+28h] [rbp-18h] BYREF
  __int64 v7; // [rsp+30h] [rbp-10h]
  unsigned __int64 v8; // [rsp+38h] [rbp-8h]

  v8 = __readfsqword(0x28u);
  v3 = 4;
  ptr = 0LL;
  v6 = 0LL;
  v7 = 0LL;
  for ( i = 0; i < a2 && *(_BYTE *)(41LL * i + a1) != 0xFF; ++i )
  {
    if ( *(_BYTE *)(41LL * i + a1) == 6 )
      v7 = 41LL * i + a1;
    if ( (unsigned __int8)send_0xface(41LL * i + a1, 0xFACE, 16) != 1
      || (unsigned __int8)recv_encrypted_ack(a1 + 41LL * i, &ptr) != 1
      || *((_BYTE *)ptr + 6) != 1 )
    {
      goto LABEL_27;
    }
    if ( ptr )
    {
      free(ptr);
      ptr = 0LL;
    }
    if ( (unsigned __int8)recv_even(a1 + 41LL * i, &ptr) != 1
      || (unsigned __int8)send_encrypted_ack(41LL * i + a1, 1LL) != 1 )
    {
      goto LABEL_27;
    }
    if ( (unsigned __int8)check_even((unsigned __int8 *)ptr + 5) != 1 )
    {
      v3 = 6;
      goto LABEL_27;
    }
    if ( ptr )
    {
      free(ptr);
      ptr = 0LL;
    }
    if ( v6 )
    {
      free(v6);
      v6 = 0LL;
    }
  }
  if ( (unsigned __int8)read_flag2(&v6) == 1
    && (unsigned __int8)send_flag(v7, (unsigned __int8 *)v6) == 1
    && (unsigned __int8)recv_encrypted_ack(v7, &ptr) == 1
    && *((_BYTE *)ptr + 6) == 1 )
  {
    if ( ptr )
    {
      free(ptr);
      ptr = 0LL;
    }
    v3 = 0;
  }
LABEL_27:
  [...]
  return v3;
}

In this last phase, for each registered client:

  • The server sends the (encrypted) bytes \xce\xfa\x10, the client answers with an encrypted ACK (functions send_0xface and recv_encrypted_ack)
  • The client sends back an encrypted message such that the plaintext only contains even bytes (why not?). If decryption succeeds and the client message indeed contains only even bytes (functions recv_even and send_encrypted_ack), the server replies with an encrypted ACK. Otherwise, it gets to status 6, which just prints the messages “Event raised: system under attacks” and “Action: active protection measures activated ‘pew pew pew’” :-)
  • Finally, the server sends the second flag to the client

The attack can be continued straightforwardly since we have successfully tampered with the packet counter: in records.txt, we have all the packets exchanged with client 5 up to the moment the second flag was sent.

But… the flag will be sent encrypted with AES key for client 5… how do I do…

It is not a problem: after successfully getting info for client 5, the server will get info for client 6. We can send back encrypted ACKs and even bytes without any difficulty since we know the AES key for client 6. And finally, the flag will be sent encrypted with the key for client 6. It’s a win!

Full script

#!/usr/bin/env python
# -*- coding: utf-8 -*-

# this exploit was generated via
# 1) pwntools
# 2) ctfmate

import os
import time
import pwn
import binascii

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

import hashlib
from Crypto.Cipher import AES

BINARY = "server"
LIBC = "/usr/lib/libc.so.6"
LD = "/lib64/ld-linux-x86-64.so.2"

# Set up pwntools for the correct architecture
exe = pwn.context.binary = pwn.ELF(BINARY)
libc = pwn.ELF(LIBC)
ld = pwn.ELF(LD)
pwn.context.terminal = ["tmux", "splitw", "-h", "-l", "110"]
pwn.context.delete_corefiles = True
pwn.context.rename_corefiles = False
p64 = pwn.p64
u64 = pwn.u64
p32 = pwn.p32
u32 = pwn.u32
p16 = pwn.p16
u16 = pwn.u16
p8  = pwn.p8
u8  = pwn.u8

host = 'challenges.france-cybersecurity-challenge.fr'
port = 2251

def local(argv=[], *a, **kw):
    '''Execute the target binary locally'''
    if pwn.args.GDB:
        return pwn.gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return pwn.process([exe.path] + argv, *a, **kw, stderr=1)


def remote(argv=[], *a, **kw):
    '''Connect to the process on the remote host'''
    io = pwn.connect(host, port)
    if pwn.args.GDB:
        pwn.gdb.attach(io, gdbscript=gdbscript)
    return io


def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if pwn.args.LOCAL:
        return local(argv, *a, **kw)
    else:
        return remote(argv, *a, **kw)


gdbscript = '''
init-pwndbg
brva 0x5010
continue
'''.format(**locals())

def recv_ack(io):
    io.recvuntil(b'\n')

def send_ack(io, cli, c):
    packet = int.to_bytes(cli, 1, 'big').hex().upper().encode('utf-8')
    packet += int.to_bytes(c, 2, 'big').hex().upper().encode('utf-8')
    packet += b'020101'
    io.sendlineafter(b'RECV:\n', packet)

def recv_msg(io, aes_key, c):
    io.recvuntil(b'SEND: ')
    x = io.recv(8)
    msg_server = bytes.fromhex(io.recvuntil(b'\n')[:-1].decode('utf-8'))
    nonce = msg_server[:12]
    tag = msg_server[12:28]
    cipher = msg_server[29:]
    pwn.debug("[+] Nonce: %s" % nonce.hex())
    pwn.debug("[+] Tag: %s" % tag.hex())
    pwn.debug("[+] Cipher: %s" % cipher.hex())
    data = cipher + tag
    aesgcm = AESGCM(aes_key)
    m = aesgcm.decrypt(nonce, data, p16(c))
    pwn.success("Client 6 received server message: %s" % m)
    return m

def send_msg(io, cli, typ, aes_key, data, c):
    nonce = os.urandom(12)
    aesgcm = AESGCM(aes_key)
    cipher = aesgcm.encrypt(nonce, data, p16(c))
    packet = int.to_bytes(cli, 1, 'big').hex().upper().encode('utf-8')
    packet += int.to_bytes(c, 2, 'big').hex().upper().encode('utf-8')
    packet += hex(typ)[2:].zfill(2).encode('utf-8')
    packet += nonce.hex().upper().encode('utf-8')
    packet += cipher[-16:].hex().upper().encode('utf-8')
    packet += int.to_bytes(len(data), 1, 'big').hex().upper().encode('utf-8')
    packet += cipher[:-16].hex().upper().encode('utf-8')
    io.sendlineafter(b'RECV:\n', packet)
    return

def recv_encrypted_msg(io):
    io.recvuntil(b'SEND: ')
    msg_server = bytes.fromhex(io.recvuntil(b'\n')[:-1].decode('utf-8'))
    nonce = msg_server[:12]
    tag = msg_server[12:28]
    cipher = msg_server[29:]
    pwn.debug("[+] Nonce: %s" % nonce.hex())
    pwn.debug("[+] Tag: %s" % tag.hex())
    pwn.debug("[+] Cipher: %s" % cipher.hex())
    return (nonce, tag, cipher)

def send_encrypted_ack(io, cli, aes_key, c):
    send_msg(io, cli, 3, aes_key, b'\x01', c)


def exp():

    io = start()

    # Get raw public key from server
    io.recvuntil(b'SEND: ')
    io.recv(10)
    raw_pub_server = bytes.fromhex(io.recvuntil(b'\n')[:-1].decode('utf-8'))
    pwn.debug("Server public key: %s" % raw_pub_server.hex())

    # Convert it to an ec public key object
    x_server = int.from_bytes(raw_pub_server[1:33], 'big')
    y_server = int.from_bytes(raw_pub_server[33:65], 'big')
    vals = ec.EllipticCurvePublicNumbers(x_server, y_server, ec.SECP256R1())
    pub_server = vals.public_key()

    # Generate a client private/public key pair
    id_client = ec.generate_private_key(ec.SECP256R1())
    x = id_client.public_key().public_numbers().x
    y = id_client.public_key().public_numbers().y
    pub_client = '04' + hex(x)[2:].rjust(64, '0') + hex(y)[2:].rjust(64, '0')

    # Send it back to the server and derive locally the AES key
    send_ack(io, 6, 2)
    io.sendlineafter(b'RECV:\n', b'0600030441' + pub_client.upper().encode('utf-8')
)
    shared_key = id_client.exchange(ec.ECDH(), pub_server)
    aes_key = hashlib.sha1(shared_key).digest()[:16]
    pwn.success("Derived AES key: %s" % aes_key.hex())

    # Receive 'HELOHELO' and send back a dummy encrypted message
    recv_ack(io)
    recv_msg(io, aes_key, 5) # HELOEHLO
    send_ack(io, 6, 6)
    send_msg(io, 6, 9, aes_key, b'spikeroot', 7)

    # Receive the flag!
    recv_ack(io)
    recv_msg(io, aes_key, 9) # flag 1
    send_encrypted_ack(io, 6, aes_key, 10)

    # Send dummy packet to trigger integer overflow on the packet counter
    recv_encrypted_msg(io) # PULLPULL (encrypted)
    io.sendlineafter(b'RECV:\n', b'05FFFF03' + b'00'*28 + b'0100')

    # Replay response from records.txt
    recv_encrypted_msg(io) # PULLPULL (encrypted)
    io.sendlineafter(b'RECV:\n', b'05000C036C4D63FC7F5FE25094D84F86157D7F1783EEAD297538B97C9F1C31BC0194')

    # Answer the health check for client 6
    recv_msg(io, aes_key, 11) # PULLPULL
    send_encrypted_ack(io, 6, aes_key, 12)

    # Replay responses from records.txt
    io.sendlineafter(b'RECV:\n', b'05000E03D5263BBB06616AF317B444049C2EAFA93DF4B11358A9D9E9A8612BBB01E4')
    io.sendlineafter(b'RECV:\n', b'05000F81EA7140C5616837D0698F733A801FC4CB08180703E17AE332FB4997F4105FEA343BCB979948100C387DF2A1A070')
    io.recvuntil(b'\n') # Encrypted flag for client 5

    # Send back correct information for client 6
    recv_msg(io, aes_key, 13)
    send_encrypted_ack(io, 6, aes_key, 14)
    send_msg(io, 6, 0x81, aes_key, b'\x20', 15)

    # Receive the flag! (again)
    recv_ack(io)
    recv_msg(io, aes_key, 17) # flag 2

    # ===========================================================
    #                    EXPLOIT GOES HERE
    # ===========================================================

    io.close()

if __name__ == "__main__":
    exp()