Writeup by spiker00t for PTSD - Init

reverse

April 18, 2024

Introduction

For this challenge, we are given:

  • a file keys.db containing the following:
05:0040:12121212121212121212121212121212
06:FFFF:FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
  • two files containing fake flags: lv1.flag and lv2.flag (the second one will be for the second part of the challenge),
  • the libraries libcrypto.so.3 and libssl.so.3,
  • a file records.txt for the second part of the challenge (PTSD - Encore une fois) containing a capture of some exchanges between client 5 and the server:
SEND: 0500010441046A3D5BB1422D914E7F9EADEDAD0F2AFBE0F8CCCD41D97AE89DE40E081C7CF623A528F770985E319B63805C59B267DC67F9712CF7284A6E496F2BBD185F58ED26
RECV: 050002020101
RECV: 050003044104CE16547B40B5CDBE7B4211C371B09F3B09C5BB4811CDFB3A787941B5B1E1B487A461928EB2167B7233EA4E61E0F51F580ADF080F7B709F7C0BB857A5B8801654
SEND: 050004020101
SEND: 05000509BF451252B5BC306E4E7B7BD192D7C261BC0C9C73A8F25C87DF50B0DE08F564A77A57BADA34
RECV: 050006020101
RECV: 05000709780E623D3DFA61E9565F58A991D331CC8024D1BB213B34E5BA99AA8D08FAF6AFA9E18935C4
SEND: 050008020101
SEND: 050009118226534CF1B81165981130FDBD44CF60ED046B7116D6836B65DF6CE246E0C63E6A4B748224F4958838A8852480864E4E12D5307A18BD1BDAD20800127E927C5B63FE579B47B399A5EFBA09B97ABD060AC330ADC18CE826388A2FD03087621C7B370948
RECV: 05000A033A16B0CF2CA0DFA9C3F856365BC41DE3DFAB62E8FCA6D51A69226A2901FA
SEND: 05000B2153F7BC6C0614B8EF4732B055D833658E08CC29B89602BC9E9589498008F73F71CD5A4173AC
RECV: 05000C036C4D63FC7F5FE25094D84F86157D7F1783EEAD297538B97C9F1C31BC0194
SEND: 05000D4168EA4C0EFBC5B6CB5113BF2BDE37386615AD65D77AD90992DDA8881803F621A4
RECV: 05000E03D5263BBB06616AF317B444049C2EAFA93DF4B11358A9D9E9A8612BBB01E4
RECV: 05000F81EA7140C5616837D0698F733A801FC4CB08180703E17AE332FB4997F4105FEA343BCB979948100C387DF2A1A070
SEND: 05001003A0BD2E66B65B053DC12AE07667FC6BF930A54279F487FF29438ADE2F01F7
  • the remote binary server.

When we run the binary, the server performs some internal operations, sends us a packet, and waits for an answer.

INFO -- release -- --- PTSD Server v1.54 ---
INFO -- release -- Loading clients...
INFO -- release -- Initializing secure channels...
INFO -- release -- Secure channel already done for 5
SEND: 06000104410498125B036A337AB3EE6DAC6FDE487C00546281FE65605298CCF41F1873E690C30B8A79C63C0F5EE3D359AA306D6DFCBB7D461C4584B3A3A4ADD0BAC9E4522B61
RECV:

Let’s dive in the code of the binary to understand what happens.

After decompilation and variable renaming, we can identify the main parts of the program.

The main function performs in an endless loop various operations depending on the status of the server.

__int64 __fastcall main(int a1, char **a2, char **a3, __int64 a4, __int64 a5, __int64 a6)
{
  __int64 p_num_keys; // rsi
  void **v7; // rdi
  __int64 v8; // rdx
  __int64 v9; // rcx
  __int64 v10; // r8
  __int64 v11; // r9
  unsigned int num_keys; // [rsp+8h] [rbp-18h] BYREF
  unsigned int status; // [rsp+Ch] [rbp-14h]
  void *keys[2]; // [rsp+10h] [rbp-10h] BYREF

  keys[1] = (void *)__readfsqword(0x28u);
  keys[0] = 0LL;
  num_keys = 0;
  status = 2;
  p_num_keys = (__int64)"--- PTSD Server v1.54 ---";
  v7 = (void **)"release";
  log("release", "--- PTSD Server v1.54 ---", (__int64)a3, a4, a5, a6);
  while ( status > 1 )
  {
    switch ( status )
    {
      case 2u:
        log("release", "Loading clients...", v8, v9, v10, v11);
        p_num_keys = (__int64)&num_keys;
        v7 = keys;
        status = load_clients((__int64)keys, (__int64)&num_keys);
        break;
      case 3u:
        log("release", "Initializing secure channels...", v8, v9, v10, v11);
        p_num_keys = num_keys;
        v7 = (void **)keys[0];
        status = init_channels((__int64)keys[0], num_keys);
        break;
      case 4u:
        log("release", "Checking clients health...", v8, v9, v10, v11);
        p_num_keys = num_keys;
        v7 = (void **)keys[0];
        status = get_health((__int64)keys[0], num_keys);
        break;
      case 5u:
        log("release", "Getting clients info...", v8, v9, v10, v11);
        p_num_keys = num_keys;
        v7 = (void **)keys[0];
        status = get_info((__int64)keys[0], num_keys);
        break;
      case 6u:
        status = attack_detected((__int64)v7, p_num_keys, v8, v9, v10, v11);
        break;
      default:
        status = 0;
        break;
    }
  }
  if ( keys[0] )
  {
    free(keys[0]);
    keys[0] = 0LL;
  }
  return 0LL;
}

We can identify four steps: loading clients (status 2), establishing secure channels (status 3), checking clients health (status 4) and getting clients info (status 5). Status 6 should not be reached. Other statuses stop the server.

Status 2 - Loading clients

When the server “loads the clients”, it basically reads the file keys.db and parses it. This file contains, for each client, a line

NN:XXXX:YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY

where NN is the client number. We will see later what correspond to XXXX and YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY.

__int64 __fastcall load_clients(void **keys, int *num_keys)
{
  if ( !keys || (unsigned __int8)load_keys(keys, num_keys) != 1 )
    return 1LL;
  else
    return 3LL;
}

Upon success, this function returns 3 so the server will go forward to the next step.

Status 3 - Establishing secure channels

Interesting things start as the server establish communication channels with the clients.

__int64 __fastcall init_channels(__int64 keys, int num_keys)
{
  [...]
  if ( gen_keypair((__int64)&id_server) && retrieve_pub_server(id_server, &pub_server, &len_pub_server) == 1 )
  {
    for ( i = 0; i < num_keys && *(_BYTE *)(41LL * i + keys) != 0xFF; ++i )
    {
      if ( !memcmp((const void *)(41LL * i + keys + 8), &str_0xfffffff, 0x10uLL) )
      {
        *(_WORD *)(41LL * i + keys + 3) = 0;
        if ( (unsigned __int8)send_pub(41LL * i + keys, pub_server, len_pub_server) != 1
          || recv_ack(keys + 41LL * i, &packet) != 1
          || *((_BYTE *)packet + 6) != 1 )
        {
          goto LABEL_42;
        }
        if ( packet )
        {
          free(packet);
          packet = 0LL;
        }
        if ( recv_pub(keys + 41LL * i, &packet) != 1
          || send_ack(41LL * i + keys, 1) != 1
          || !retrieve_pub_client((__int64)packet + 6, *((_BYTE *)packet + 5), (__int64)&pub_client)
          || derive_skey(id_server, pub_client, &skey) != 1 )
        {
          goto LABEL_42;
        }
        v5 = (_QWORD *)(41LL * i + keys + 8);
        v6 = *(_QWORD *)((char *)skey + 9);
        *v5 = *(_QWORD *)((char *)skey + 1);
        v5[1] = v6;
        if ( packet )
        {
          free(packet);
          packet = 0LL;
        }
        if ( send_heloehlo(41LL * i + keys) != 1
          || recv_ack(keys + 41LL * i, &packet) != 1
          || *((_BYTE *)packet + 6) != 1 )
        {
          goto LABEL_42;
        }
        if ( packet )
        {
          free(packet);
          packet = 0LL;
        }
        if ( recv_msg(keys + 41LL * i, &packet) != 1 || send_ack(41LL * i + keys, 1) != 1 )
          goto LABEL_42;
        if ( packet )
        {
          free(packet);
          packet = 0LL;
        }
        if ( read_flag1((void **)a2) != 1
          || send_flag(41LL * i + keys, a2[0]) != 1
          || recv_encrypted_ack(keys + 41LL * i, &packet) != 1
          || *((_BYTE *)packet + 6) != 1 )
        {
          goto LABEL_42;
        }
        if ( packet )
        {
          free(packet);
          packet = 0LL;
        }
        log(
          "release",
          "Succeeded in mounting the secure channel with %d",
          *(unsigned __int8 *)(41LL * i + keys),
          v7,
          v8,
          v9);
        if ( skey )
        {
          free(skey);
          skey = 0LL;
        }
        if ( a2[0] )
        {
          free(a2[0]);
          a2[0] = 0LL;
        }
        if ( pub_client )
        {
          EVP_PKEY_free(pub_client);
          pub_client = 0LL;
        }
      }
      else
      {
        log("release", "Secure channel already done for %d", *(unsigned __int8 *)(41LL * i + keys), v2, v3, v4);
      }
    }
    new_status = 4;
  }
LABEL_42:
  [some cleaning...]
  return new_status;
}

First of all, for each key k, the server checks if memory at k + 8 is equal to 16 bytes 0xFF. Actually, k + 8 corresponds to the field YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY from the file keys.db. If this field is equal to FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, it means that a channel must be established between the server and the client. The client 5 have already been initialized (that is why we have a message “Secure channel already done for 5” when running the server) but the channel has to be established for client 6.

Format of the packets

But how are the exchanged packets formatted?

If we take a look at the file records.txt, we can guess for the first packets that:

  • The packets all have a header containing:
    • the client number N,
    • a kind of “packet counter” C,
    • a “packet type” T,
    • the length of the transmitted data L. For instance, the first packet is
    05 0001 04 41 046A32D[...]ED26
    [N][ C ][T][L][     data     ]
    
  • Some packets just carry the byte “01”, they are probably acknowledgement (ACK) packets.

This can be verified looking at the code of the functions recv_* and send_* which we will dissect later. Moreover, for each client, the “packet counter” is stored at key + 3, which is loaded from the XXXX parts of the file keys.db.

Elliptic Curve Diffie-Hellman (ECDH) key agreement

The first exchanged messages consist in a basic Elliptic Curve Diffie-Hellman (ECDH) key agreement. To sum up:

  • The server generates a private/public key pair (functions gen_keypair and retrieve_pub_server)
  • The server sends its public key and waits for an ACK from the client (functions send_pub and recv_ack)
  • The client sends back its public key to the server, the server replies with an ACK (functions recv_pub and send_ack)
  • Both derive the same shared secret: the server derives the secret from its private key and the public key of the client, and the client derives it from its private key and the public key of the server (server-side functions retrieve_pub_client and derive_skey).
  • They both derive the same AES key to exchange encrypted messages (function derive_skey).

If we look at the code of the function gen_keypair called at the beginning, we can know that the elliptic curve used is SECP256R1, also known as prime256v1, which corresponds to nid = 415 in EVP_PKEY_CTX_set_ec_paramgen_curve_nid according to the openssl source code.

v4 = EVP_PKEY_CTX_new_id(408LL, 0LL);
if ( v4 )
{
  if ( (unsigned int)EVP_PKEY_paramgen_init(v4) == 1
    && (unsigned int)EVP_PKEY_CTX_set_ec_paramgen_curve_nid(v4, 415LL) == 1
    && (unsigned int)EVP_PKEY_paramgen(v4, &v3) == 1 )
  {
    v5 = EVP_PKEY_CTX_new(v3, 0LL);
    if ( v5 )
    {
      if ( (unsigned int)EVP_PKEY_keygen_init(v5) == 1 )
        v2 = (unsigned int)EVP_PKEY_keygen() == 1;
    }
  }
}

The shared secret is basically a point on the elliptic curve, represented as bytes. The AES key is generated from the 16 first bytes of the SHA-1 of this secret: see the function derive_skey:

__int8 __fastcall derive_skey(__int64 id_server, __int64 pub_client, void **a3)
{
    [...]
    ctx = EVP_PKEY_CTX_new(id_server, 0LL);
    if ( ctx )
    {
      if ( (unsigned int)EVP_PKEY_derive_init(ctx) == 1
        && (unsigned int)EVP_PKEY_derive_set_peer(ctx, pub_client) == 1
        && (unsigned int)EVP_PKEY_derive(ctx, 0LL, &v6) == 1 )
      {
        *(_BYTE *)ptr = v6;
        ptr = realloc(ptr, *(unsigned __int8 *)ptr + 1LL);
        if ( ptr )
        {
          if ( (unsigned int)EVP_PKEY_derive(ctx, (char *)ptr + 1, &v6) == 1 )
          {
            *(_BYTE *)ptr = v6;
            if ( SHA1((char *)ptr + 1, *(unsigned __int8 *)ptr, src) )
            {
              *(_BYTE *)ptr = 16;
              ptr = realloc(ptr, *(unsigned __int8 *)ptr + 1LL);
              if ( ptr )
              {
                memcpy((char *)ptr + 1, src, *(unsigned __int8 *)ptr);
                *a3 = ptr;
                v5 = 1;
              }
            }
          }
        }
      }
    }
    [...]
    return v5
}

Encrypted messages

After the key agreement phase, both parts send messages encrypted with AES-GCM using the shared key. AES-GCM (Galois/Counter mode) is an operation mode with authentication: the encryption takes as input a random nonce, the plain message and public authentication data (AD). It returns the ciphertext along with a tag which depends on the ciphertext, the AD and the key. On decryption, the tag is checked with respect to ciphertext and AD and an error is triggered if it isn’t the case: it means that AD or ciphertext has been tampered with. It theoretically guarantees the integrity of authentication data, since a man-in-the-middle attacker who does not know the key cannot forge a valid tag for corrupted AD or ciphertext.

The encrypted packets are formatted a bit differently: instead of the length and the data, we have:

  • the nonce used for encryption
  • the authentication tag
  • the length of the ciphertext
  • the ciphertext.

For example,

05000509BF451252B5BC306E4E7B7BD192D7C261BC0C9C73A8F25C87DF50B0DE 08 F564A77A57BADA34
[header][        nonce        ][              tag              ][LL][     data     ]

But where is the authentication data here?

If we take a look at the function that sends encrypted messages, we can figure out that it is actually the packet counter:

  if ( packet
    && (unsigned __int8)alloc_copy(packet + 2, 2, &ptr) == 1
    && (unsigned __int8)aes_encrypt(key + 8, packet + 5, (unsigned __int8 *)ptr, aes) == 1 )

The counter, stored at &packet + 2, is copied into a new location ptr, and used as authentication data in aes_encrypt (first EVP_EncryptUpdate):

__int64 __fastcall aes_encrypt(__int64 skey, unsigned __int8 *a2, unsigned __int8 *a3, void **a4)
{
    [...]
    nonce = malloc(*a2 + 29LL);
    if ( nonce )
    {
      ctx = EVP_CIPHER_CTX_new();
      if ( ctx )
      {
        v4 = EVP_aes_128_gcm();
        if ( (unsigned int)EVP_EncryptInit_ex(ctx, v4, 0LL, 0LL, 0LL) == 1
          && (unsigned int)RAND_bytes(nonce, 12LL) == 1
          && (unsigned int)EVP_EncryptInit_ex(ctx, 0LL, 0LL, skey, nonce) == 1
          && (unsigned int)EVP_EncryptUpdate(ctx, 0LL, &v9, a3 + 1, *a3) == 1
          && (unsigned int)EVP_EncryptUpdate(ctx, (char *)nonce + 29, &v9, a2 + 1, *a2) == 1
          && *a2 <= v9 )
        {
          *((_BYTE *)nonce + 28) = v9;
          if ( (unsigned int)EVP_EncryptFinal_ex(ctx, (char *)nonce + v9 + 29, &v9) == 1
            && *a2 <= *((unsigned __int8 *)nonce + 28) + v9 )
          {
            *((_BYTE *)nonce + 28) += v9;
            if ( (unsigned int)EVP_CIPHER_CTX_ctrl(ctx, 16LL, 16LL, (char *)nonce + 12) == 1 )
            {
              *a4 = nonce;
              v8 = 1;
            }
          }
        }
      }
    }
    [...]
    return v8;
}
Establishment of the communication channel

After sharing the AES key, the communication channel is established as follows:

  • The server sends the message “HELOEHLO” (sic), the client replies with an ACK (functions send_heloehlo and recv_ack)
  • The client sends back any encrypted message with a valid tag (function recv_msg ).
  • If server-side decryption succeeds, the server replies with an ACK and the first flag is sent to the client (encrypted).

From now it is quite straightforward to get the flag of the first part: the goal is to implement a PTSD client.

The flag!

I implemented my client in Python + pwntools, with the libraries ec and AESGCM from cryptography.hazmat.primitives to work with elliptic curves and AES-GCM encryption.

It just consists in following the protocol: here is the main part of my script. The full script is available below. The functions send_msg and recv_msg respectively send and receive encrypted messages with the provided AES key.

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 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

    io.close()

if __name__ == "__main__":
    exp()