Here the write-ups for three challenges of the PicoCTF 2023, running from March 14 to March 28. These challenges are a series on side-channel power analysis. I have used estraces & scared packages publicly available on github.
PowerAnalysis: Warmup
Description
This encryption algorithm leaks a "bit" of data every time it does a computation. Use this to figure out the encryption key. Download the encryption program here encrypt.py. Access the running server with nc saturn.picoctf.net 56776. The flag will be of the format picoCTF{<encryption key>} where <encryption key> is 32 lowercase hex characters comprising the 16-byte encryption key being used by the program.
A quick look on the encrypt.py file shows that the server return a kind of side-channel leakage corresponding to intermediate values during the AES encryption of the given plaintext, using a secret key. The leakage is the number of LSB bits set to 1 after the operation SBOX[plaintext ^ key].
I first tried to collect some leakages and then deduce the key values by hand, but I'm a lazy guy. And the laziest solution was (for me) to:
- collect a dataset with plaintext and the corresponding leakage
- apply a side-channel attack on it
Communicate with the server
To communicate with the server, I use a small tool I made called NC based on sockets. See an example below:
from netcat import NC
import time
port = 51011
nc = NC('saturn.picoctf.net', port, timeout=1, debug=True)
nc.receive()
time.sleep(0.005)
resp = nc.query('000102030405060708090a0b0c0d0e0f', sleep=0.005)
<< Please provide 16 bytes of plaintext encoded as hex:
>> 000102030405060708090a0b0c0d0e0f
<< leakage result: 9
Sending a plaintext, the server returns a leakage number in the range [0, 16].
Collect the dataset
As explained, I'll collect several couples (plaintext, leakage) in order to perform a side-channel attack. The following takes a plaintext as input (a numpy array) and returns the corresponding leakage:
def encrypt_and_leak(plaintext):
plaintext = plaintext.tobytes().hex()
nc = NC('saturn.picoctf.net', port, timeout=1, debug=False)
nc.receive()
time.sleep(0.005)
resp = nc.query(plaintext, sleep=0.005)
return int(resp.strip().split(': ')[1].strip())
Use it in a loop to build a dataset of 500 traces, i.e. couples (plaintext, leakage):
import numpy as np
from tqdm.notebook import tqdm
from estraces import read_ths_from_ram
plaintexts = []
leaks = []
for _ in tqdm(range(500)):
plaintext = np.random.randint(0, 256, 16, dtype='uint8')
plaintexts.append(plaintext)
leak = encrypt_and_leak(plaintext)
leaks.append([leak, leak]) # A trace must have at least two samples to be processed by our side-channel framework
ths = read_ths_from_ram(samples=np.array(leaks), plaintext=np.array(plaintexts))
ths
Trace Header Set:
Name.............: RAM Format THS
Reader...........: RAM reader with 500 traces. Samples shape: (500, 2) and metadatas: ['plaintext']
plaintext........: uint8
Attack
Once it's done (it takes about 3 minutes), we can perform the side-channel attack. We will target the LSB bit of the output of the SubBytes operation with a Correlation Power Analysis (CPA):
import scared
attack = scared.CPAAttack(selection_function=scared.aes.selection_functions.encrypt.FirstSubBytes(),
model=scared.Monobit(0),
discriminant=scared.nanmax, # Positive correlation expected
convergence_step=50)
attack.run(scared.Container(ths))
An optional step is to plot the results for the first byte:
import matplotlib.pyplot as plt
def plot_attack(attack, byte):
"""Plot attack results for the given byte."""
fig, axes = plt.subplots(1, 2, figsize=(20, 3))
axes[0].plot(attack.results[:, byte].T)
axes[0].set_title('CPA results', loc='left')
axes[1].plot(attack.convergence_traces[:, byte].T)
axes[1].set_title('Scores convergence', loc='right')
plt.suptitle(f'Attack results for byte {byte}')
plt.show()
plot_attack(attack, 0)

We can see that for the first byte, a key value is clearly correlating more than the others. A quick check confirms that is the case for all bytes.
Key extraction and flag conversion
Then the correct key should be the one with the highest score for each byte. Let's extract it:
found_key = np.nanargmax(attack.scores, axis=0).astype('uint8')
print(f'picoCTF{{{found_key.tobytes().hex()}}}')
picoCTF{9dac9d914154db052d7ce1a110fbb3aa}
PowerAnalysis: Part 1
This embedded system allows you to measure the power consumption of the CPU while it is running an AES encryption algorithm. Use this information to leak the key via dynamic power analysis. Access the running server with nc saturn.picoctf.net 62527. It will encrypt any buffer you provide it, and output a trace of the CPU's power consumption during the operation. The flag will be of the format picoCTF{<encryption key>}where <encryption key> is 32 lowercase hex characters comprising the 16-byte encryption key being used by the program.
This challenge is similar to the previous one except that this time a CPU power consumption trace is returned by the server:
<< Please provide 16 bytes of plaintext encoded as hex:
>> 000102030405060708090a0b0c0d0e0f
<< power measurement result: [101, 79, 46, 4, ..., 42]
But the attack process is the same: collect a dataset and then attack it.
Collect the dataset
First, let's define a function to collect and parse a single trace:
port = 58296
def get_trace(plaintext):
nc = NC('saturn.picoctf.net', port, timeout=1, debug=False)
nc.receive()
time.sleep(0.005)
plaintext = plaintext = plaintext.tobytes().hex()
resp = nc.query(plaintext, sleep=0.1)
while ']\n' not in resp:
time.sleep(0.001)
resp = resp + nc.receive()
trace = np.fromstring(resp[28:-2], dtype='uint8', sep=', ')
return trace
and then use it to collect a dataset of 500 traces:
error_counter = 0
plaintexts = []
samples = []
for _ in tqdm(range(500)):
plaintext = np.random.randint(0, 256, 16, dtype='uint8')
try:
samples.append(get_trace(plaintext))
plaintexts.append(plaintext)
except Exception:
error_counter += 1
if error_counter > 10:
break
ths = read_ths_from_ram(samples=np.array(samples), plaintext=np.array(plaintexts))
ths
Trace Header Set:
Name.............: RAM Format THS
Reader...........: RAM reader with 500 traces. Samples shape: (500, 2666) and metadatas: ['plaintext']
plaintext........: uint8
é
Attack
The hint claims that the power consumption is correlated with the Hamming weight of the processed bits. Therefore, we will attack with a CPA the output of the SubBytes operation (an efficient and classical selection function), using the Hamming weight model:
attack = scared.CPAAttack(selection_function=scared.aes.selection_functions.encrypt.FirstSubBytes(),
model=scared.HammingWeight(),
discriminant=scared.maxabs, # Works with positive or negative correlations
convergence_step=50)
attack.run(scared.Container(ths))
plot_attack(attack, 0)

We can see that for the first byte, a key value is clearly correlating more than the others. A quick check confirms that is the case for all bytes.
Key extraction and flag conversion
Again the correct key should be the one with the highest score for each byte:
found_key = np.nanargmax(attack.scores, axis=0).astype('uint8')
print(f'picoCTF{{{found_key.tobytes().hex()}}}')
picoCTF{65cce0eab280e39d12625c7315b03fa1}
PowerAnalysis: Part 2
This embedded system allows you to measure the power consumption of the CPU while it is running an AES encryption algorithm. However, this time you have access to only a very limited number of measurements. Download the power-consumption traces for a sample of encryptions traces.zip The flag will be of the format picoCTF{<encryption key>} where <encryption key> is 32 lowercase hex characters comprising the 16-byte encryption key being used by the program.
Again the same, but this time the dataset is provided.
Read and parse dataset
The traces.zip archive contains 100 .txt files with the following content:
Plaintext: 6bb487e863faab956e3d7ede01fdd0a0
Power trace: [75, 61, 94, 134, 127, 134, 138, 139, ...., 42]
The plaintext is given as an hexadecimal string while the trace samples are given as a list of integer values.
Let's parse each trace file and build a dataset. First, we can define a function to parse a single trace:
import numpy as np
def read_file(path):
with open(path, 'r') as fid:
lines = fid.readlines()
plaintext = lines[0]
plaintext = plaintext.split(': ')[1].strip()
plaintext = np.frombuffer(bytes.fromhex(plaintext), dtype='uint8')
samples = lines[1]
samples = samples.split(': [')[1].split(']')[0].strip()
samples = np.fromstring(samples, dtype='uint8', sep=', ')
return plaintext, samples
read_file('traces/trace00.txt')
(array([107, 180, 135, 232, 99, 250, 171, 149, 110, 61, 126, 222, 1,
253, 208, 160], dtype=uint8),
array([ 75, 61, 94, ..., 12, 250, 212], dtype=uint8))
And then build a dataset containing all the traces:
import glob
from estraces import read_ths_from_ram
files = list(glob.glob('traces/*'))
plaintexts = []
samples = []
for file in files:
plaintext, trace = read_file(file)
plaintexts.append(plaintext)
samples.append(trace)
ths = read_ths_from_ram(samples=np.array(samples), plaintext=np.array(plaintexts))
ths
Trace Header Set:
Name.............: RAM Format THS
Reader...........: RAM reader with 100 traces. Samples shape: (100, 2666) and metadatas: ['plaintext']
plaintext........: uint8
Attack
The hint is the same, then we can repeat the previous attack:
attack = scared.CPAAttack(selection_function=scared.aes.selection_functions.encrypt.FirstSubBytes(),
model=scared.HammingWeight(),
discriminant=scared.maxabs,
convergence_step=10)
attack.run(scared.Container(ths))
plot_attack(attack, 0)

Key extraction and flag conversion
found_key = np.nanargmax(attack.scores, axis=0).astype('uint8')
print(f'picoCTF{{{found_key.tobytes().hex()}}}')
picoCTF{dde6d2ba7d0e35a99eeedf882dcae7d1}
Thanks to
the PicoCTF team for providing this nice CTF. I learned a lot in many domains I'm not familiar with. I can't wait to read the write-up for the challenges I haven't solved.

