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
andlv2.flag
(the second one will be for the second part of the challenge), - the libraries
libcrypto.so.3
andlibssl.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
andretrieve_pub_server
) - The server sends its public key and waits for an ACK from the client
(functions
send_pub
andrecv_ack
) - The client sends back its public key to the server, the server
replies with an ACK (functions
recv_pub
andsend_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
andderive_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
andrecv_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()