22/10/2025
Blog technique
European Cyber Week 2025: Challenges & Write ups
Team CESTI
Like every year, the European Cyber Week organized an online qualification round for their CTF event that will take place during the event in November. AMOSSYS, once again, helped on this qualification phase by creating a couple of challenges. These challenges were of various complexity and various themes (like crackme, forensic or crypto).
This year, the theme of the CTF is the space, thus our challenges were designed around this topic. One challenge was meant to crack a software deployed on a satellite and locked up by an automatic protection. The forensic one was based on a post-mortem analysis of a drone communication. Finally, to find the flag of the cryptographic one, the challengers had to break a post-quantum Key Exchange Mechanism.
On this post, we provide the detailed write-ups for these three challenges, in case you didn’t manage to find the flags.
Challenges
Write Ups
Sky Seal 10
The year is 2039. Humanity has grown heavily reliant on an ultra-secure satellite network known as SkyNet Quantum Link. But disaster strikes: a critical node, Satellite #10 – Sky Seal, suddenly goes dark.
A cybersecurity analyst team uncovers that an automatic lockdown has been triggered. To restore access, a valid authentication key must be regenerated.
Your objective is to analyze the binary file, understand the internal logic, and reconstruct the legitimate key that will restore access to the satellite’s data systems.
Time is critical. The longer Sky Seal 10 stays offline, the greater the risk of downstream system collapse.
A binary is given and a key is asked when executed.
$ ./sky_seal_10
Welcome to the sky_seal_10 challenge!
Key:
The challenger has to find the correct key. It is now time to reverse the binary using IDA Pro or Ghidra to understand the logic.
Before to start, the binary validates the input type. A 16-bytes string composed of hexadecimal chars is required.
fn validate_input(input: &str) {
let re: Regex = Regex::new(r"^[0-9a-fA-F]{32}$").unwrap();
if !re.is_match(input) {
println!("Key must be 32 hex chars!");
println!("Exiting.");
process::exit(0);
}
}
The entered key is then splitted into two parts. Each part is checked in a dedicated function.
In the first function, the first part of the key is checked.
fn check_key_part_0(internal_state: &mut InternalState, key_part_0: &[u8]) -> i32 {
let mut tmp: [u8; 8] = [0xe4, 0x7f, 0x85, 0xa2, 0x5c, 0x31, 0x56, 0x19];
let key: [u8; 4] = [0x89, 0x9F, 0x22, 0x76];
for i in 4..8 {
tmp[i] ^= key[i % 4];
internal_state.tick += 1;
}
for i in 0..8 {
if key_part_0[i]!= tmp[i] {
return -1;
}
internal_state.tick += 2;
}
0
}
An array (tmp on the picture) is xored with an internal key array (key) modulo 4. Only the second part of the array is xored, not the first part. Also, a tick value is incremented from 0 to 4. After that, the first part of the entered key is checked byte by byte against the obtained value. During this phase, the tick value is incremented by 2 each round. So, at the end, if the entered key is good, the tick value is 20.
In the second function, the second part of the key is checked.
fn check_key_part_1(tick: u8, key_part_1: &[u8]) -> i32 {
let mut tmp: [u8; 8] = [0; 8];
let tmp_final: [u8; 8] = [0xea, 0xc6, 0xd5, 0x9a, 0x08, 0x3f, 0x27, 0xea];
for i in 0..8 {
tmp[i] = key_part_1[i].rotate_left(tick.into());
tmp[i] ^= tick - i as u8;
tmp[i] = tmp[i].rotate_right(tick.into());
}
for i in 0..8 {
if tmp[i]!= tmp_final[i] {
return -1;
}
}
0
}
For each byte of the key, the following operations are performed:
- rotation left by tick value
- the result is xored with the tick value – i (i being the iteration number from 0 to 7)
- rotation right by tick value is applied on the result
At the end, the obtained array is compared byte by byte with the tmp_final array. By following these instructions, the following Python3 script can be implemented in order to solve the challenge.
#!/usr/bin/python3
-- coding: utf-8 --
def ror(value, nb):
nb %= 8
return value >> nb | (value << (8 - nb)) & 0xff
def rol(value, nb):
nb %= 8
return ((value << nb) & 0xff) | (value >> (8 - nb))
def compute_key_part_0(tick):
tmp = [0xe4, 0x7f, 0x85, 0xa2, 0x5c, 0x31, 0x56, 0x19]
key = [0x89, 0x9F, 0x22, 0x76]
for i in range(4, 8):
tmp[i] ^= key[i % 4]
tick += 1
for i in range(8):
tick += 2
return tick, tmp
def compute_key_part_1(tick):
tmp_final = [0xea, 0xc6, 0xd5, 0x9a, 0x08, 0x3f, 0x27, 0xea]
tmp = [0] * 8
for i in range(8):
tmp[i] = ror(tmp_final[i], tick)
tmp[i] ^= tick – i
tmp[i] = rol(tmp[i], tick)
return 0, tmp
if name == 'main':
#print('[+] The two parts of the original key must be found')
#print('[+] Use Ghidra or IDA to understand the logic')
#print('[+] A first function is dedicated to check the first part of the key')
#print('[+] A second function is dedicated to check the second part of the key')
#print('[+] The size of the key is 16 bytes')
#print('[+] Also, a tick is incremented during each check')
#print()
tick, key_part_0 = compute_key_part_0(0)
print('key_part_0:', ''.join('{:02x}'.format(x) for x in key_part_0))
tick, key_part_1 = compute_key_part_1(tick)
print('key_part_1:', ''.join('{:02x}'.format(x) for x in key_part_1))
key = key_part_0 + key_part_1
print('key:', ''.join('{:02x}'.format(x) for x in key))
If the two functions succeed, the encrypted flag is decrypted and printed.
$ python3 solve.py
key_part_0: e47f85a2d5ae746f
key_part_1: abf7f48b09cfc73a
key: e47f85a2d5ae746fabf7f48b09cfc73a
To finish, the flag is obtained by executing the binary with the good key.
$ ./sky_seal_10
Welcome to the sky_seal_10 challenge!
Key: e47f85a2d5ae746fabf7f48b09cfc73a
Good key, congratulations! You can validate the challenge using the following flag: ECW{UNL0CK3D_BY_REV3RS3_ENG1N3!}
The Silent Packets
April 2025 – In a sensitive maritime surveillance context, one of our autonomous drones operating offshore experienced a critical security incident.
This drone was remotely operated via a satellite link and was equipped with an onboard Linux system integrating both OT and IT layers, including a web-based supervision server for operators.
Log data shows that the drone temporarily:
- lost its GPS connection,
- experienced partial communication jamming,
- and most notably: an unknown kernel module was dynamically loaded.
It is explained in the challenge’s description that a kernel module is dynamically loaded. We can infer that the pcap file contains a communication with the server.
This server receives an HTTP request with the TCP URG flag set. It is a furtivity technique used by attackers to communicate bytes. Those bytes form a key (up to a terminating NUL byte) which is then used as an argument to load a kernel module (.ko). The module applies transformations (XOR / encryption) and produces a final payload: a JPEG image that contains hidden data. The final extraction is performed with steghide using the author value (hinted by a ROT13 message in the module) as the passphrase.
1. Pcap Analysis
1.1. Goal
Identify TCP segments with the URG flag and extract the bytes carried by those segments.
The objective here is to retrieve how a key is transfered to the remote server. At first, the pcap seems to be a simple benign HTTP exchange. However, the URG flag is a bit odd for such a communication. This furtivity step is known as TCP Urgent Pointer. On each packet, an extra byte is added after the checksum which does not interfer with the original HTTP exchange. The same exchange is splitted in several pacets. As a result, the reconscruted HTTP request does not show these extra bytes.
1.2. Retrieving the key
$ tshark -r output.pcap -Y "tcp.flags.urg == 1" -T fields -e tcp.seq -e tcp.payload[0:1]
1 ba
22 da
42 55
62 c0
82 ff
102 ee
122 00
Concatenate payload values without 0x00 (end char) to build the key. Because the key is understandable and short, it could be visually reconstructed using wireshark too. The final key exchanged in the pcap is 0xbada55c0ffee.
2. Kernel module analysis
In the dmesg.txt file, one can read at the end of the file that the module named kern_http is odd. No verficiation is performed or signature is found. In addition, the module is loaded last and a print explains that a payload is written to /tmp/flag. This is the file to retrieve. After inspection either dynamic or static of the kernel module, it appears that the flag is ciphered. The key was retrieved in step 1. It is known that the expected file to retrieve is a JPG file as shown in the reverse of the kernel module. As a result, it is possible to retrieve the IV since the key, the ciphertext and the headers bytes are known (jpeg ones). Once you have retrieved the IV (which appear to be not initialized, therefore null), use the key 0xbada55c0ffee with the AES-CBC mode to retrieve the original picture.
3. Steganography
The module included a ROT13-encoded note in the module’s description field instructing the solver to use the author value as the passphrase for the final step. Such author is M&NTIS.
You can extract hidden data with steghide:
steghide extract -sf candidate.jpg -p "M&NTIS"
Learning from error
A communication has been intercepted containing encrypted data by the DOSEAL initiative (Defensive Operation for Satellite Encrypted Analysis Localization). It seems to be related to a KEM public key that our experts confirmed to be a post-quantum mechanism, and the corresponding public key has been found. The secret key is nowhere to be found.
However, our informant has provided the DOSEAL key generation source code and a memory dump of one generation.
Can the secret document of the DOSEAL initiative be retrieved?
This challenge is based on FrodoKEM post-quantum mechanism where a KEM shared secret is used to encrypt a secret document (that contains the flag).
Five files are given:
- doseal_ciphertext.bin: a FrodoKEM-1344 ciphertext
- doseal_dump.bin: a memory dump of a FrodoKEM-1344 key generation
- doseal_teplay.pub: a FrodoKEM-1344 public key
- frodokem_keygen.c: source code of FrodoKEM-1344 key generation
- output.txt: a secret document encrypted with AES-256-CBC where the key is the secret key encapsulated in the file doseal_ciphertext.bin
The goal seems to be clear: the AES key is needed to decrypt the secret document, so the FrodoKEM-1344 private key must be reconstructed to execute a decapsulation.
1. Overview of FrodoKEM key generation
FrodoKEM is quite simple. Its key generation uses matrices:
- S: secret matrix of dimension 1344×8 (part of the secret key)
- E: error matrix of dimension 1344×8 (secret, but not kept after generation)
- A: public key of dimension 1344×1344
- B: public key calculatedd as B = AS + E
2. Source code analysis
The analysis of the source code shows that several values MUST be cleaned from memory according to the comments. It is the case for the buffer mat_e:
uint16_t mat_e[N*NBAR]; // must be CLEANED
However, it is not explicitely erased using the function memory_cleanse() contrary to the matrix mat_s which is the matrix of the secret key.
3. Dump analysis
Since the error matrix is not erased, it is highly probable to find it in the memory dump. In FrodoKEM, each coefficient of this matrix is sampled according to a distribution and is an integer between -6 and 6 (for FrodoKEM-1344). The coefficients are integers reduced modulo 216 so they are represented in memory as 16-bit integers.
As a consequence, the error matrix should be represented in memory as a buffer of bytes 0xFFFA, 0xFFFB, 0xFFFC, 0xFFFD, 0xFFFE, 0xFFFF, 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, which is easily located in a memory dump.
Indeed, a buffer of size 2 x 1344 x 8 bytes is present from offset 0x0820 which corresponds exactly to the number of bytes to represent this matrix.
Thus, the matrix E can be reconstructed.
4. Reconstructing the secret key
The matrix A is a square matrix and it might be possible to invert. Then, the secret matrix S can be retrived by calculating: S = A-1 (B – E).
One condition for the matrix A to be invertible is that its determinant must be coprime with the modulus 216, so it must be odd.
Fortunately, this is the case, then the KEM secret key can be reconstructed. It is the concatenation of the following elements (this can be found at the end of the key generation in the source code):
- s: used for rejection in case of decapsulation failure, not necessary here for a correct decapsulation in the challenge context
- public key: seed for matrix A and packed version of B
- ST: secret matrix transposed
- pkh: hash of the public key with SHAKE256
Then it can be used for a decapsulation.
5. Decapsulation and flag
For this part, an external implementation can be used. Since the source code points to https://frodokem.org, a reference implementation can be found there that contains a C version and a Python version: https://github.com/Microsoft/PQCrypto-LWEKE.
6. Source code of solution
A Sagemath solution using PARI for the matrix inversion (faster than the generic implementation of Sagemath) is given below. A much faster matrix inversion (1 second) is possible using a C implementation, available on the following link: https://github.com/AMOSSYS/challenges/tree/main/ECW-2025/Learning-from-error
from hashlib import shake_128, shake_256
from sage.all import Matrix, Zmod, pari, ZZ
import json
try:
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import unpad
except:
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
# challenge files
PUBLIC_KEY_PATH = "files/doseal_tepley.pub"
DUMP_PATH = "files/doseal_dump.bin"
CIPHERTEXT_PATH = "files/doseal_ciphertext.bin"
OUTPUT_PATH = "files/output.txt"
# FrodoKEM-1344 parameters
N = 1344
NBAR = 8
LEN_A = 16
LEN_SE = 64
LEN_SEC = 32
Zq = Zmod(2**16)
# ===================
# retrieve secret key
# ===================
# load public key
pk_raw = open(PUBLIC_KEY_PATH, 'rb').read()
pkh = shake_256(pk_raw).digest(32) # public key hash
seed_a = pk_raw[:LEN_A]
b = pk_raw[LEN_A:]
# generate matrix A
A = Matrix(Zq, N)
for i in range(N):
buffer = shake_128(bytes([i % 256, i >> 8]) + seed_a).digest(2*N)
for j in range(N):
A[i,j] = buffer[2*j] | (buffer[2*j + 1] << 8)
# unpack matrix B
B = Matrix(Zq, N, NBAR)
for i in range(N):
for j in range(NBAR):
B[i,j] = (b[2*(i*NBAR + j)] << 8) | b[2*(i*NBAR + j) + 1]
# get error matrix E from dump (present at offset 2080 of size 2*N*NBAR bytes)
data = open(DUMP_PATH, 'rb').read()[2080:2080 + 2*N*NBAR]
E = Matrix(Zq, N, NBAR)
for i in range(N):
for j in range(NBAR):
E[i,j] = data[2*(i*NBAR + j)] | (data[2*(i*NBAR + j) + 1] << 8)
# calculate A^-1 through PARI since it's faster
# A must be converted as an integer matrix to be used with pari.matinvmod
A_ZZ = A.change_ring(ZZ)
A_pari = pari(A_ZZ)
A_inv = pari.matinvmod(A_pari, 2**16).sage()
# calculate S = A^(-1)*(B - E)
S = A_inv*(B - E)
# =============
# decapsulation
# =============
# load ciphertext
ct = open(CIPHERTEXT_PATH, 'rb').read()
c1 = ct[:2*N*NBAR]
c2 = ct[2*N*NBAR:2*N*NBAR + 2*NBAR*NBAR]
salt = ct[2*N*NBAR + 2*NBAR*NBAR:]
# unpack matrix B'
Bp = Matrix(Zq, NBAR, N)
for i in range(NBAR):
for j in range(N):
Bp[i,j] = (c1[2*(i*N + j)] << 8) | c1[2*(i*N + j) + 1]
# unpack matrix C
C = Matrix(Zq, NBAR, NBAR)
for i in range(NBAR):
for j in range(NBAR):
C[i,j] = (c2[2*(i*NBAR + j)] << 8) | c2[2*(i*NBAR + j) + 1]
# calculate M = C - B'*S
M = C - Bp*S
# decode M by rounding the four msb of each coefficient into a 32-byte buffer
buffer = []
for i in range(NBAR):
for j in range(NBAR):
if j % 2 == 0:
# 4 lsb of a new byte
buffer.append(round(ZZ(M[i,j]) / 2**12))
else:
# 4 msb of current byte
buffer[-1] |= (round(ZZ(M[i,j]) / 2**12) << 4)
u = bytes(buffer)
# derive secret value k
buffer = shake_256(pkh + u + salt).digest(LEN_SE + LEN_SEC)
k = buffer[LEN_SE:]
# derive shared secret
ss = shake_256(ct + k).digest(LEN_SEC)
# ==========================
# secret document decryption
# ==========================
data = json.loads(open(OUTPUT_PATH, "r").read())
iv = bytes.fromhex(data['iv'])
ciphertext = bytes.fromhex(data['encrypted'])
cipher = AES.new(ss, AES.MODE_CBC, iv=iv)
secret_document = unpad(cipher.decrypt(ciphertext), 16)
print(secret_document.decode('utf-8'))