Side-Channel Power Analysis Series - Part 6 of 7
| Part | Title | Status |
|---|---|---|
| 1 | The Gap Between Mathematical Security and Physical Reality | |
| 2 | Why Does Power Consumption Leak Secrets? | |
| 3 | AES - Understanding What We Are Attacking | |
| 4 | SPA, DPA and CPA - The Attack Ladder | |
| 5 | Hardware Setup and First Connection — Let’s Actually Do This | |
| 6 - You are here | Compiling Firmware, Setting Up Capture and Getting Your First Real Power Trace | |
| 7 | I Stole an AES Key by Watching a Chip’s Power Consumption |
← Previous: Part 5: Hardware Setup and First Connection | Next → Part 7: I Stole an AES Key by Watching a Chip’s Power Consumption
Part of the Side-Channel Power Analysis series on IoTSec.in
Okay. So we left Blog 5A with both boards connected, Python talking to CW1173, and everything confirmed working. That was the setup part.
This blog is where things get real.
We are going to compile actual firmware, flash it onto the XMEGA, capture a real power trace, and save 50 traces to disk for the CPA attack in Blog 5C.
And I will be honest with you - I hit errors. Real ones. I am going to show you exactly what went wrong and how I fixed it. Because that is what actually helps beginners. Not a clean tutorial that pretends everything worked first try.
Quick Recap - What We Are Doing and Why
Before we touch any code, let me be clear about the plan.
We are doing CPA - Correlation Power Analysis.
There were three attack types we covered in Blog 4:
- SPA - Simple Power Analysis. Read one trace directly. Works on RSA. Fails on AES because AES is too complex to read from one trace.
- DPA - Differential Power Analysis. Collect many traces, split into two groups, subtract them, look for the difference spike.
- CPA - Correlation Power Analysis. Collect many traces, make mathematical predictions about what the power should look like for each possible key guess, compare predictions against real measurements, highest correlation = correct key byte.
CPA is the most powerful and efficient of the three. ChipWhisperer is built specifically for CPA. That is what we are doing.
The plan for these two blogs:
- Blog 5B (this one) - compile firmware, flash it, capture 50 real traces, save them
- Blog 5C (next one) - load those saved traces, run CPA, recover the AES key
The Same Jupyter Notebook From Blog 5A
I am continuing in the exact same Jupyter notebook I used in Blog 5A. Nothing has been tampered with. Whatever errors I hit, whatever fixes I applied - all of it is visible in the notebook exactly as it happened.
I am making this a separate blog - Blog 5B - because combining setup and capture in one blog would make it too long and too overwhelming for a beginner. Each blog should have one clear goal. This one’s goal is: get 50 real traces saved to disk.
Download the Jupyter notebook here: IoTSec_Blog5B.ipynb
(You can follow along with the exact cells I ran, in order, with all outputs preserved)
Step 1 — Reconnecting to the Hardware
First thing every session - reconnect to both boards. CW1173 and CW303 don’t maintain connection between Jupyter sessions. Every time you open a fresh notebook or restart the kernel, you reconnect.
import chipwhisperer as cw
scope = cw.scope()
target = cw.target(scope, cw.targets.SimpleSerial)
print(scope)
When I ran this, I got a warning I had not seen before:
(ChipWhisperer NAEUSB WARNING|File naeusb.py:826) Your firmware (0.64.0)
is outdated - latest is 0.65.0
This is a firmware version warning for the CW1173 capture board itself not the XMEGA target. The CW1173 has its own internal firmware that can be updated separately. Version 0.64.0 still works fine for everything we are doing. I did not update it and everything worked. If you see this warning, you can safely ignore it for now.
Then I ran default_setup():
scope.default_setup()
This one command configures everything automatically:
- Sets
gainto 30 amplifies the signal so we can see it - Sets
samplesto 5000 - 5000 voltage snapshots per capture - Sets
adc_srctoclkgen_x4synchronized clock capture - Locks the ADC clock
adc_locked = True - Sets
clkgen_freqto ~7.38 MHz — correct clock speed for XMEGA - Fixes TIO1 and TIO2 pin directions for serial communication
You can see every change it makes because it prints them out:
scope.gain.mode changed from low to high
scope.gain.gain changed from 0 to 30
scope.adc.samples changed from 24400 to 5000
scope.clock.adc_locked changed from False to True
scope.clock.clkgen_freq changed from 192000000.0 to 7384615.384615385
adc_locked = True is the important one. If this stays False, the clock is not stable and captures will be garbage. Always check this after default_setup().
Step 2 — Understanding the Firmware Situation
Here is something that confused me a lot when I started.
The XMEGA on the CW303 is just a blank chip. It has a processor, memory, everything. But it does not know what to do. It is exactly like an ESP32 development board that has never been flashed. The hardware is there but there is no software running on it.
We need to flash specific firmware onto it before we can do anything. And not just any firmware we need ChipWhisperer’s AES firmware that does three specific things:
- Accepts a 16-byte plaintext over serial
- Runs AES encryption on it
- Raises the trigger pin (TIO4) at exactly the right moment when AES starts
That third point is everything. Without the trigger firing at the right moment, CW1173 doesn’t know when to start capturing. The whole attack falls apart.
Now here is the thing that surprised me ChipWhisperer does not give you a pre-compiled hex file for the XMEGA in version 6.0.0. You have to compile it yourself from source code.
I did not know this at first. I ran:
find ~/iotsec/chipwhisperer -name "*.hex" 2>/dev/null
And found that the only relevant hex files were for STM32, SAM4S, and other platforms not for CWLITEXMEGA. The source code is there but it needs to be compiled.
The Firmware Folder Structure
The firmware lives here:
~/iotsec/chipwhisperer/firmware/mcu/simpleserial-aes/
Inside that folder:
simpleserial-aes.c ← main AES firmware source code
simpleserial-aes.cpp ← C++ version
Makefile ← build instructions
makefile
Makefile.platform
ide_projects/ ← IDE project files
objdir/ ← compiled output goes here
We are not touching any of these files manually. The Makefile handles everything. We just call make with the right arguments and it compiles everything for our specific platform.
Why do we need to compile? Because the same firmware source code supports many different target boards XMEGA, STM32, SAM4S, and more. The compiler takes the source and produces a binary specific to whichever chip we tell it to target.
Step 3 - Compiling the Firmware From Inside Jupyter
Here is something useful — you can run terminal commands directly from inside a Jupyter notebook using %%bash at the top of a cell. You do not need to switch between terminal and notebook.
First, set the platform variables in a Python cell:
PLATFORM = "CWLITEXMEGA"
CRYPTO_TARGET = "TINYAES128C"
SS_VER = "SS_VER_2_1"
What these mean:
PLATFORM = "CWLITEXMEGA"we are targeting the CW-Lite XMEGA, which is exactly our CW303 boardCRYPTO_TARGET = "TINYAES128C"use the TinyAES128 implementation. This is a lightweight AES library that works on all platformsSS_VER = "SS_VER_2_1"SimpleSerial version 2.1, the communication protocol between Python and the target
Then the compilation cell:
%%bash
cd ~/iotsec/chipwhisperer/firmware/mcu/simpleserial-aes
make PLATFORM=CWLITEXMEGA CRYPTO_TARGET=TINYAES128C SS_VER=SS_VER_2_1 -j
What I Found Confusing — The %%bash Variable Problem
My first attempt used -s to pass Python variables into bash:
%%bash -s "$PLATFORM" "$CRYPTO_TARGET" "$SS_VER"
cd ~/iotsec/chipwhisperer/firmware/mcu/simpleserial-aes
make PLATFORM=$1 CRYPTO_TARGET=$2 SS_VER=$3 -j
This failed with:
Building for platform with CRYPTO_TARGET=
SS_VER set to
Invalid or empty PLATFORM
All three variables came through empty. The %%bash -s magic did not pass them correctly in this version of Jupyter.
The fix is simple just hardcode the values directly in the bash cell instead of trying to pass Python variables:
%%bash
cd ~/iotsec/chipwhisperer/firmware/mcu/simpleserial-aes
make PLATFORM=CWLITEXMEGA CRYPTO_TARGET=TINYAES128C SS_VER=SS_VER_2_1 -j
This worked. Important note: the
cd command inside %%bash only changes directory for that one cell. It does not affect Python’s working directory. Keep this in mind it matters in the next step.
When compilation succeeds you see:
Building for platform CWLITEXMEGA with CRYPTO_TARGET=TINYAES128C
SS_VER set to SS_VER_2_1
avr-gcc (GCC) 5.4.0
...
Creating load file for Flash: simpleserial-aes-CWLITEXMEGA.hex
...
Built for platform CW-Lite XMEGA with:
CRYPTO_TARGET = TINYAES128C
CRYPTO_OPTIONS = AES128C
The important line is Creating load file for Flash: simpleserial-aes-CWLITEXMEGA.hex. That hex file is the compiled firmware ready to be flashed onto the XMEGA.
Step 4 — Flashing the Firmware onto the XMEGA
Now we program the XMEGA. CW1173 pushes the firmware through the 20-pin ribbon cable directly — no external programmer needed.
cw.program_target(
scope,
cw.programmers.XMEGAProgrammer,
"../../firmware/mcu/simpleserial-aes/simpleserial-aes-CWLITEXMEGA.hex"
)
The Error I Hit Here
My first attempt used a wrong path and failed with:
FileNotFoundError: [Errno 2] No such file or directory:
'../../firmware/mcu/simpleserial-aes/simpleserial-aes-CWLITEXMEGA.hex'
Here is why. The %%bash cell compiled the firmware after doing cd into the firmware folder. But that cd only applied inside that bash cell. Python’s working directory was still the Jupyter notebook location which is inside ~/iotsec/chipwhisperer/jupyter/.
So going ../../ from there takes you to the wrong place. The correct path goes forward from the ChipWhisperer root into firmware:
cw.program_target(
scope,
cw.programmers.XMEGAProgrammer,
"../../firmware/mcu/simpleserial-aes/simpleserial-aes-CWLITEXMEGA.hex"
)
Wait actually the correct relative path depends on where your notebook lives. The easiest fix is to use the full absolute path:
cw.program_target(
scope,
cw.programmers.XMEGAProgrammer,
"/firmware/mcu/simpleserial-aes/simpleserial-aes-CWLITEXMEGA.hex"
)
When it works:
XMEGA Programming flash...
XMEGA Reading flash...
Verified flash OK, 4381 bytes
Verified flash OK means the firmware was written and verified successfully. The XMEGA now has AES firmware running on it.
Step 5 - Capturing One Single Trace
Before capturing 50 traces, let’s capture just one and look at it.
First - matplotlib. I got this error when trying to plot:
ModuleNotFoundError: No module named 'matplotlib'
Fixed it by running this in a Jupyter cell:
import sys
!{sys.executable} -m pip install matplotlib
Always install inside the active virtual environment using sys.executable. Do not use pip install directly in a bash cell it might install into the wrong Python environment.
Now the capture. ktp.Basic() is ChipWhisperer’s key-text pair generator. It automatically generates random plaintexts and a fixed key for us. We do not need to manually create 50 random plaintexts ktp.next() handles that:
import numpy as np
ktp = cw.ktp.Basic()
key, text = ktp.next()
target.set_key(key)
scope.arm()
target.simpleserial_write('p', text)
ret = scope.capture()
if ret:
print("Timeout - target did not respond")
else:
trace = scope.get_last_trace()
print("Trace captured successfully")
print("Trace length:", len(trace))
print("First 5 values:", trace[:5])
What each line does:
ktp = cw.ktp.Basic()— creates the key-text pair generatorkey, text = ktp.next()— generates one key and one plaintexttarget.set_key(key)— sends the key to the XMEGA. It stores it internallyscope.arm()— tells CW1173 “get ready, trigger is coming”target.simpleserial_write('p', text)— sends the plaintext. XMEGA starts AES, fires trigger, CW1173 capturesscope.capture()— waits for capture to complete. Returns True if timeoutscope.get_last_trace()— returns the 5000 voltage readings as a numpy array
Output:
Trace captured successfully
Trace length: 5000
First 5 values: [ 0.0546875 -0.21191406 -0.21191406 -0.14355469 -0.02050781]
Those five numbers are real voltage readings from your XMEGA doing AES. Positive numbers mean voltage went up more current drawn. Negative means voltage dropped below baseline. This is the power consumption of the chip, measured 5000 times in rapid succession from the moment the trigger fired.
Step 6 - Plotting the Real Trace
import matplotlib.pyplot as plt
plt.figure(figsize=(12, 4))
plt.plot(trace)
plt.xlabel("Sample number")
plt.ylabel("Voltage")
plt.title("My First Real Power Trace")
plt.grid(True, alpha=0.3)
plt.show()
And this is where I had a moment.
Because it looked like this:
My honest first reaction — I panicked. It looks nothing like the clean diagrams from Blog 2. It is messy, spiky, all over the place. I genuinely thought something had gone wrong.
It had not. This is exactly correct.
Why Does It Look So Messy?
The trace is not just AES. It is AES plus everything else happening at the same time:
Wire noise - every wire acts like a tiny antenna. It picks up electromagnetic interference from the environment. WiFi signals, the laptop, the USB cable itself, even nearby electronics.
The XMEGA doing other things - even during AES, the chip is running a timer in the background, managing serial communication, doing internal housekeeping. All of that draws power too. All of it shows up mixed into the trace.
ADC noise - the analog to digital converter inside CW1173 is not perfect. Every single reading has a tiny measurement error. This is unavoidable.
USB power ripple - the power coming through USB is not a perfectly flat line. Tiny fluctuations show up in every measurement.
So the trace is: AES signal + wire noise + chip housekeeping + ADC error + power ripple, all mixed together. The AES signal is buried inside all of that.
But Is AES Actually In There?
Yes. Look at the trace carefully. It is not completely random. Around samples 0 to 900 you can see a dense rhythmic pattern - that is the AES rounds happening. The structure changes around sample 900. Then again around 2500. There is repeating structure buried in the noise.
You just cannot see it clearly with one trace because the noise is too strong.
Why Does CPA Need 50 Traces Then?
Here is the key insight.
The noise is random. It is different every single capture. Different electrical interference, different ADC errors, different USB fluctuations every time.
The AES signal is consistent. Same key, same operation, same transistors switching, same power pattern. Every single capture. Physics does not change.
So across 50 traces:
Trace 1: AES_signal + random_noise_A
Trace 2: AES_signal + random_noise_B
Trace 3: AES_signal + random_noise_C
...
Trace 50: AES_signal + random_noise_Z
When CPA does its correlation math across all 50 traces - the random noise cancels out because it is different every time. The AES signal survives and gets stronger because it is the same every time.
One trace = AES signal buried under noise.
50 traces = noise cancels, AES signal emerges, CPA finds the key.
The messy ugly graph is not wrong. It is exactly what we need.
Step 7 - Capturing 50 Traces
Now we capture 50 traces in a loop. Each one uses a different random plaintext — this is important because CPA needs variation across traces to find the correlation. If every plaintext was the same, the math would not work.
from tqdm.notebook import trange
import numpy as np
ktp = cw.ktp.Basic()
trace_array = []
textin_array = []
key, text = ktp.next()
target.set_key(key)
for i in trange(50, desc='Capturing traces'):
scope.arm()
target.simpleserial_write('p', text)
ret = scope.capture()
if ret:
print("Timeout!")
continue
trace_array.append(scope.get_last_trace())
textin_array.append(text)
key, text = ktp.next()
trace_array = np.array(trace_array)
print("Done. Shape:", trace_array.shape)
What the loop does:
scope.arm()- arms CW1173, tells it to watch for triggertarget.simpleserial_write('p', text)- sends plaintext, XMEGA runs AES, fires trigger, CW1173 capturesscope.capture()- waits for capture. If it returns True, the target timed out — we skip that tracescope.get_last_trace()- gets the 5000 voltage readingstextin_array.append(text)- saves the plaintext so we know what was sent for each tracekey, text = ktp.next()- gets the next random plaintext for the next iteration
trange is just range with a progress bar. It shows you how many traces are done.
Output:
Capturing traces: 100%|████████████| 50/50 [00:12<00:00, 4.1it/s]
Done. Shape: (50, 5000)
(50, 5000) means 50 rows and 5000 columns.
Each row is one complete trace 5000 voltage readings in order across time. One row per plaintext we sent. 50 plaintexts, 50 rows.
Each column is one specific moment in time - sample number 1, sample number 2, and so on up to 5000. Same moment across all 50 traces.
CPA works column by column. It looks at sample number 100 across all 50 traces. Then sample 101 across all 50 traces. At each moment it asks does the power at this exact moment correlate with my key prediction? When it finds the moment that correlates strongest, that is where the key fingerprint is leaking.
Step 8 - Saving the Traces
These 50 traces are sitting in memory right now. The moment we close Jupyter they are gone forever. We need to save them to disk so Blog 5C can load them and run CPA.
np.save("traces.npy", trace_array)
np.save("plaintexts.npy", np.array(textin_array))
print("Saved successfully")
print("traces.npy shape:", np.load("traces.npy").shape)
print("plaintexts.npy shape:", np.load("plaintexts.npy").shape)
np.save saves a numpy array to disk in .npy format. Think of it like putting food in the fridge — Blog 5C takes it out and uses it.
np.array(textin_array) - textin_array was a regular Python list. We convert it to a numpy array before saving.
After saving we immediately reload and check the shapes to confirm it saved correctly.
Output:
Saved successfully
traces.npy shape: (50, 5000)
plaintexts.npy shape: (50, 16)
plaintexts.npy is (50, 16) - 50 rows, 16 columns. Makes sense. AES plaintext is always 16 bytes. One row per capture, 16 bytes per row.
Both files are now on disk in your Jupyter working directory.
Step 9 - Disconnecting Cleanly
Always disconnect properly when done. This releases the USB connection so you can reconnect cleanly next time.
scope.dis()
target.dis()
print("Disconnected cleanly")

What We Learned - Glossary
Firmware - software that lives permanently on a microcontroller. The XMEGA needs specific AES firmware flashed onto it before it can participate in the attack.
CWLITEXMEGA - the platform name ChipWhisperer uses to identify our CW303 XMEGA target board.
SS_VER_2_1 - SimpleSerial version 2.1, the communication protocol between Python and the target chip.
TINYAES128C - a lightweight AES implementation that compiles and runs on small microcontrollers like the XMEGA.
%%bash - Jupyter magic command that lets you run terminal commands directly from a notebook cell. The cd inside a %%bash cell only applies to that cell — it does not change Python’s working directory.
ktp.Basic() - ChipWhisperer’s key-text pair generator. Automatically produces a fixed key and random plaintexts so we do not have to generate them manually.
scope.arm() - tells CW1173 to watch the trigger pin and start capturing the moment it fires.
scope.get_last_trace() - returns the captured trace as a numpy array of voltage readings.
.npy file - numpy’s native file format for saving arrays to disk. Fast to save, fast to load.
(50, 5000) array - 50 traces, each with 5000 voltage readings. 50 rows, 5000 columns.
What Comes Next
We have 50 real power traces saved. We have the 50 plaintexts that produced them. We know the key was set by ktp.Basic().
Blog 5C loads these files and runs the actual CPA attack. It will go through all 256 possible values for each key byte, calculate Hamming Weight predictions, correlate against real measurements, and find which guess matches best.
The key will come out byte by byte.
→ Continue to Blog 5C: Running the CPA Attack and Recovering the AES Key



