Description
Full AES-128 key recovery via Correlation Power Analysis (CPA) on noisier power traces, building on PowerAnalysis Part 1. You receive only 100 pre-collected traces (fewer than Part 1 allowed you to gather) and additional signal processing is needed to extract the key from this noisier, limited dataset.
Download and unzip the challenge files.
Install NumPy, SciPy, and optionally tqdm for progress tracking.
wget https://artifacts.picoctf.net/c/502/PowerAnalysis_Part2.zip && unzip PowerAnalysis_Part2.zippip3 install numpy scipy tqdmSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Assess the noise level in the tracesObservationI noticed the challenge description warned that Part 2 uses fewer traces (100) and a noisier dataset than Part 1, which suggested that quantifying the signal-to-noise ratio first was essential before running any attack, since the required trace count scales quadratically with noise.Plot a few traces and compute their signal-to-noise ratio to understand how much harder this is than Part 1.pythonpython3 -c " import numpy as np import matplotlib.pyplot as plt traces = np.load('traces.npy') plt.plot(traces[0]); plt.title('Sample trace'); plt.show() print('SNR estimate:', traces.mean(axis=0).max() / traces.std(axis=0).max()) "What didn't work first
Tried: Use the same 100-trace corpus from Part 1 without scaling, expecting it to be enough for Part 2.
Part 2 uses a noisier trace set, so 100 traces fall short of the statistical threshold where the correct key byte's correlation separates from the noise floor. The Pearson correlation converges as 1/sqrt(N), meaning a 3x increase in noise requires roughly 9x more traces. Running CPA on only 100 noisy traces produces every key byte showing similar peak correlations, making it impossible to identify the correct guess.
Tried: Estimate SNR by dividing the global trace maximum by the global standard deviation rather than comparing per-sample mean to per-sample std.
Using global (scalar) statistics conflates high-amplitude but non-informative time samples with the actual leakage region. A single high-amplitude noise spike can dominate the numerator and produce a misleadingly optimistic SNR. The correct approach computes the SNR per time sample - mean(axis=0) divided by std(axis=0) - so you see which specific samples actually carry secret-dependent signal.
Learn more
Part 2 adds significantly more noise to the simulated power traces, modeling realistic measurement conditions where electromagnetic interference, power supply fluctuations, and jitter obscure the actual computation signal. A low signal-to-noise ratio (SNR) means many more traces are needed to achieve the same confidence in the correlation.
The relationship between noise and required traces is roughly quadratic: doubling the noise standard deviation requires 4x more traces to achieve the same correlation separation. The reason is that the Pearson correlation of a noisy signal converges as
1/sqrt(N): the standard error of the sample correlation shrinks with the square root of the sample size. To keep the CPA peak detectable above the noise floor when sigma doubles, you need to shrink the standard error by half, which means quadrupling N. If Part 1 needed ~512 traces, Part 2 with 3x the noise needs roughly9 * 512 = 4608traces.Step 2
Apply trace averaging and pre-processingObservationI noticed that the SNR assessment revealed a very low signal-to-noise ratio across the 100 traces, which suggested that subtracting the mean trace and windowing around the known point of interest from Part 1 would reduce noise integration and make the correlation attack viable without needing many more traces.Average traces with the same plaintext byte value to reduce noise, and optionally apply a bandpass filter around the known POI (point of interest).pythonpython3 -c " import numpy as np traces = np.load('traces.npy') plaintexts = np.load('plaintexts.npy') # Average across all traces to get mean trace mean_trace = traces.mean(axis=0) # Subtract mean to center data centered = traces - mean_trace print('Pre-processing complete, shape:', centered.shape) "What didn't work first
Tried: Average all 100 traces together into a single mean trace and run CPA on just that single vector.
Averaging all traces into one eliminates the per-trace plaintext variation that CPA depends on. The attack works by correlating 256 predicted Hamming-weight values (one per trace, derived from the plaintext and a key guess) against the measured power at each time sample. Collapsing all traces into one mean destroys the per-trace dimension, leaving no matrix to correlate against the hypothesis vector, so you either get a dimension mismatch or a flat correlation of zero for every key guess.
Tried: Skip mean subtraction and go straight to CPA, reasoning that Pearson correlation is already zero-mean by definition.
While the Pearson formula internally centers each variable, skipping explicit mean removal leaves a large DC offset that inflates sample variance estimates across the full trace array. More importantly, without per-sample centering you miss the windowing step: Part 1's identified point of interest should be used to slice the traces down to just the leakage region, which reduces noise integration by orders of magnitude. Skipping pre-processing means the attack must average out noise over the entire trace length instead of focusing on the informative samples.
Learn more
Signal processing techniques can improve CPA effectiveness on noisy data:
- Per-trace centering (
traces - mean_trace) removes the DC bias and any fixed offset that every trace shares. The CPA correlation only cares about the variance that depends on the secret, so subtracting the mean trace strips out everything that is constant across the dataset and tightens the estimate. - Windowing (POI selection) reuses Part 1's findings: the time sample where the correct key byte's correlation peaked in Part 1 is the same point of interest now. Take the bandpass window (or just the index) around that sample and ignore the rest of the trace; this drastically reduces the search space for CPA and the noise it has to integrate over.
- Averaging across multiple traces for the same plaintext byte reduces random noise (assumes the signal is deterministic).
- Sum of Absolute Differences (SAD) is an alternative to Pearson correlation that can be more robust in certain noise models.
- Per-trace centering (
Step 3
Run full CPA over all 16 key bytesObservationI noticed that after pre-processing the centered and windowed traces still covered 16 independent key bytes, which suggested running the same Pearson-correlation distinguisher from Part 1 byte-by-byte across the full corpus and monitoring convergence to confirm enough traces are available for each byte.Execute the same correlation attack from Part 1 but with the full (larger) trace set and any pre-processing applied.pythonpython3 cpa_part2.pyWhat didn't work first
Tried: Reuse the Part 1 script unchanged (same trace count, no pre-processing) and expect CPA to converge on the noisier Part 2 dataset.
The Part 2 traces have a lower SNR, so the same trace count that sufficed in Part 1 leaves the correct key byte's correlation peak indistinguishable from the noise floor. Without the mean subtraction and POI windowing added in the previous step, the correlation values for correct and incorrect guesses cluster together. The output will show several key bytes where 2-3 candidate guesses have nearly identical peak correlations, and the recovered key will fail to decrypt any known ciphertext.
Tried: Use Sum of Absolute Differences (SAD) as the distinguisher instead of Pearson correlation, then compare results byte by byte without a convergence plot.
SAD can work for power analysis but its statistical behavior differs from Pearson correlation and it is more sensitive to amplitude scaling between traces. More critically, skipping the convergence plot (running maximum correlation vs. trace count per hypothesis) means you have no way to tell whether the attack is converging or just producing a random winner from noise. A correct CPA run shows one hypothesis line breaking away from the noise floor as N grows; without that diagnostic you might record a wrong key byte from an unconverged run and not realize it until decryption fails.
Learn more
The fundamental attack algorithm is identical to Part 1. The improvements are quantitative (more traces) and qualitative (signal pre-processing). With enough traces, the Pearson correlation for the correct key byte will eventually dominate the correlation for all incorrect guesses, regardless of noise level - this is the statistical power of the CPA attack.
Monitor convergence by plotting the maximum correlation for each of the 256 key guesses as a function of N (the number of traces used so far). The correct key byte's line climbs steadily and plateaus at a clearly elevated peak, while the 255 incorrect guesses fluctuate near the noise floor (~
1/sqrt(N)) without ever separating. This convergence plot is the cleanest visual confirmation that you have enough traces and that your model is right; if no line breaks away from the rest after the full corpus, increase N or revisit the POI selection.See the AES for CTF guide for the round-by-round structure that the attack model assumes.
Step 4
Verify and format the recovered key as the flagObservationI noticed the CPA output yielded 16 candidate key bytes, which suggested decrypting a known ciphertext first to confirm the recovered key is correct before formatting it as a lowercase hex string inside picoCTF{}.Decrypt a known ciphertext with the recovered key to verify, then format the hex key as the flag.pythonpython3 -c " from Crypto.Cipher import AES key = bytes([<K0>, <K1>, ..., <K15>]) cipher = AES.new(key, AES.MODE_ECB) print(cipher.decrypt(bytes.fromhex('<CT>'))) print('Flag: picoCTF{' + key.hex() + '}') "Expected output
picoCTF{...}What didn't work first
Tried: Format the flag by joining the recovered key bytes as decimal integers separated by underscores rather than as a contiguous hex string.
The flag format for this challenge is picoCTF{} wrapping the lowercase hex representation of the 16-byte AES key, e.g. picoCTF{0a1b2c...}. Using decimal values or adding separators produces a string that does not match the expected submission format. The key.hex() call on a Python bytes object produces the correct 32-character lowercase hex string with no separators.
Learn more
Completing Part 2 demonstrates the full power analysis attack pipeline against a realistically noisy target. In actual hardware security evaluations, researchers use oscilloscopes and current probes to capture real power traces from microcontrollers, then apply the same statistical correlation methodology to recover keys from production devices.
Countermeasures in certified products include: first-order masking (XOR intermediates with random values to break the Hamming-weight model), higher-order masking (resists DPA of any fixed order), shuffling (random byte processing order), and hardware-level noise injection. These add cost and complexity but are required for EAL5+ and FIPS 140-3 Level 3 certifications.
Flag
Reveal flag
picoCTF{...}
This challenge was not solved during the competition. Follow the steps above to reproduce the solution.