I Stole an AES Key by Watching a Chip's Power Consumption


:high_voltage: Side-Channel Power Analysis Series - Part 7 of 7 (Final)


← Previous: Part 6: Compiling Firmware and Getting Your First Power Trace     :tada: You’ve completed the full Side-Channel Power Analysis series!

:backhand_index_pointing_right: Start from the beginning: The Mirai Botnet Series - From Zero to Source Code

Part of the Side-Channel Power Analysis series on IoTSec.in


So we are finally here. This is the blog I was most excited about and also the one that took the longest to actually understand. Blog 5B was all about capturing the traces. We connected the ChipWhisperer, flashed the firmware, ran 50 AES encryptions, and saved two files. That’s it. Hardware disconnected. Job done.

Blog 5C is where the magic happens. No hardware needed. Just those two saved files, Python, and some math that honestly blew my mind when I saw it work.

By the end of this blog, we will have recovered a full 128-bit AES key byte by byte — purely by analysing power consumption data. Let’s go.


What We Have Before Starting

From Blog 5B, we saved two files in ~/iotsec/chipwhisperer/:

  • traces.npy - shape (50, 5000)
  • plaintexts.npy - shape (50, 16)

Before touching any code, let me explain what’s actually inside these two files because this confused me a lot at first.

traces.npy - shape (50, 5000)

50 rows. One row per encryption. Each row has 5000 numbers tiny voltage values like 0.052, -0.208, 0.047. These are the power readings the ChipWhisperer captured from the shunt resistor while the XMEGA was running AES. 5000 readings per encryption, 50 encryptions total.

plaintexts.npy - shape (50, 16)

50 rows. One row per encryption. Each row has 16 numbers. Those 16 numbers are the 16 bytes of plaintext we sent to the chip in that particular encryption round. AES always works on 16-byte blocks, so each row is one full input.

What about the key?

The key is NOT in either file. That’s literally the whole point. The key was hardcoded in the firmware and never left the chip. We are going to mathematically figure out the key by studying the relationship between the plaintext we sent and the power the chip consumed. That’s CPA.


A Quick Recap - Why Does Power Tell Us Anything?

Before jumping into code, I need to explain one physical fact that makes this entire attack possible.

When a transistor switches from 0 to 1, it consumes power. When it stays at 0, it consumes almost nothing. So a byte with more 1-bits in it causes more transistors to switch, which means more power consumed.

This is called Hamming Weight - just count the number of 1-bits in a byte.

Examples:

  • 0x00 = 00000000 → 0 ones → Hamming Weight = 0 → low power
  • 0xFF = 11111111 → 8 ones → Hamming Weight = 8 → high power
  • 0x2A = 00101010 → 3 ones → Hamming Weight = 3 → medium power

More 1-bits = more transistors switching = more current = higher voltage on shunt resistor = higher value in our trace.

Simple. And this is the entire physical foundation of power analysis.


The CPA Idea in One Paragraph

AES round 1 does this as its very first operation:

Sbox( plaintext_byte XOR key_byte ) → output byte

The power consumed at that exact moment depends on the Hamming Weight of that output byte. We know the plaintext (we saved it). We don’t know the key byte. So we guess the key byte, calculate what the Sbox output should be, calculate its Hamming Weight, and check if that matches the real power trace. We do this for all 256 possible guesses. The guess that matches best that’s the real key byte.

We do this 16 times once per key byte and we get the full 128-bit key.


Step 1 - Load the Files

We are continuing in the same IotSec.ipynb notebook. Hardware is disconnected. Just add new cells at the bottom.

import numpy as np

traces = np.load("traces.npy")
plaintexts = np.load("plaintexts.npy")

print("Traces shape:", traces.shape)
print("Plaintexts shape:", plaintexts.shape)
print("First plaintext:", plaintexts[0])
print("First trace (first 10 samples):", traces[0][:10])

Output:

Traces shape: (50, 5000)
Plaintexts shape: (50, 16)
First plaintext: [160 122  38  21 124 197 216 171  45 251 205 254 194  39   2  16]
First trace (first 10 samples): [ 0.05273438 -0.20898438 -0.20703125 -0.14160156 -0.0234375  -0.359375
 -0.33007812 -0.22753906 -0.0859375  -0.17773438]

Everything looks healthy. The plaintext values are raw bytes numbers from 0 to 255. Not readable text like “Hello.” Just random numbers. The trace values are tiny voltages some positive, some negative recorded as the chip was doing AES.


Step 2 - Look at Byte 0 Across All 50 Traces

CPA attacks one key byte at a time. To attack key byte 0, we only need plaintext byte 0 from each of the 50 encryptions.

print("Byte 0 across all 50 traces:")
print(plaintexts[:, 0])

Output:

Byte 0 across all 50 traces:
[160  25 155 157  44  81 245 106 172  81  88 182  16  50  90  49 137 161
 143  20  57  90   7 246  84 243  25  15 213  77  57  88 215   1  53 216
  37  51 111 148 102  57  19 113  58 209 186 116 143  93]

plaintexts[:, 0] means every row, only column 0. So we get the first byte from each of the 50 plaintexts. 50 different values, all random. This is what we’ll use to predict power consumption.


Step 3 - Predict Hamming Weights for One Guess

Now the fun part. Let’s manually do what CPA does but for just one guess first, so we can see what’s happening.

We guess that key byte 0 is 0x2B. We calculate Sbox(plaintext_byte_0 XOR 0x2B) for all 50 encryptions, then calculate the Hamming Weight of each result.

guess = 0x2B

sbox = [
    0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
    0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
    0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
    0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
    0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
    0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
    0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
    0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
    0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
    0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
    0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
    0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
    0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
    0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
    0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
    0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16
]

hw = []
for i in range(50):
    sbox_out = sbox[plaintexts[i][0] ^ guess]
    hamming = bin(sbox_out).count('1')
    hw.append(hamming)

print("Predicted Hamming Weights for guess 0x2B:")
print(hw)

Output:

Predicted Hamming Weights for guess 0x2B:
[5, 3, 6, 4, 4, 5, 4, 3, 4, 5, 5, 5, 4, 4, 4, 3, 4, 6, 3, 5, 4, 4, 4, 3, 4, 3, 3, 4, 6, 4, 4, 5, 3, 5, 4, 3, 5, 5, 4, 1, 5, 4, 3, 6, 2, 4, 2, 6, 3, 3]

Let me break down what just happened line by line:

sbox - This is a fixed lookup table that is part of the AES standard itself. Every AES implementation in the world uses these exact 256 values. We copied it here so we can simulate what the chip does internally.

plaintexts[i][0] ^ guess - XOR the first byte of plaintext with our guess. This is exactly what AES does in round 1 XOR plaintext with key before the Sbox lookup.

sbox[...] - Use the XOR result as an index into the Sbox table. Get the output byte.

bin(sbox_out).count('1') - bin() converts a number to its binary string. Like bin(5) gives '0b101'. Then .count('1') counts the 1-bits. That’s the Hamming Weight. Simple.

So now we have 50 predicted power values one per encryption assuming the key byte is 0x2B.


Step 4 - Check If the Prediction Matches Reality

We have our predictions. Now let’s check one sample point in the real traces and compare.

real_power = traces[:, 0]  # sample 0 from all 50 traces

print("Predicted HW:  ", hw[:10])
print("Real power:    ", [round(x, 3) for x in real_power[:10]])

Output:

Predicted HW:   [5, 3, 6, 4, 4, 5, 4, 3, 4, 5]
Real power:     [0.053, 0.052, 0.055, 0.055, 0.053, 0.052, 0.05, 0.048, 0.047, 0.055]

Look at the real power values they are almost identical. Barely any difference. That means sample 0 is NOT where the Sbox operation is happening. The chip was doing something else at that clock tick.

This is the problem we can’t just pick a random sample and hope it works. We need to search all 5000 sample points and find which one correlates best with our predictions.


Step 5 - Correlation Across All 5000 Samples

This is the core of CPA. For every sample point, we calculate how well our predicted Hamming Weights match the real power values. The sample point with the strongest match is where the Sbox operation is running.

correlations = []
hw_array = np.array(hw)

for sample in range(5000):
    real = traces[:, sample]
    corr = np.corrcoef(hw_array, real)[0, 1]
    correlations.append(corr)

correlations = np.array(correlations)
print("Max correlation:", np.max(np.abs(correlations)))
print("At sample:", np.argmax(np.abs(correlations)))

Output:

Max correlation: 0.7684902843520461
At sample: 3762

What is np.corrcoef?

It takes two arrays and checks when one goes up, does the other also go up? When one goes down, does the other go down? It returns a value between 0 and 1. Zero means no relationship. One means perfect match.

It actually returns a 2x2 matrix like this:

[[1.0,  0.76],
 [0.76, 1.0 ]]

The diagonal values (1.0) are each array compared with itself always perfect, not useful. The [0,1] value is the actual correlation between our two arrays. That’s what we want.

What does 0.768 mean?

It means our prediction is strongly matching the real power at sample 3762. Out of 5000 sample points, this is the exact clock tick where the chip was running Sbox(plaintext XOR key). We found it without knowing anything about the chip internals just math.


Step 6 - Try All 256 Guesses, Find the Winner

We cheated slightly in step 3 we already knew the key was 0x2B and guessed correctly. Real CPA tries all 256 possible values and picks the one with highest correlation.

best_corr = 0
best_guess = 0

for guess in range(256):
    hw_guess = []
    for i in range(50):
        sbox_out = sbox[plaintexts[i][0] ^ guess]
        hamming = bin(sbox_out).count('1')
        hw_guess.append(hamming)
    
    hw_guess = np.array(hw_guess)
    
    for sample in range(5000):
        real = traces[:, sample]
        corr = abs(np.corrcoef(hw_guess, real)[0, 1])
        if corr > best_corr:
            best_corr = corr
            best_guess = guess

print(f"Best guess: 0x{best_guess:02X}")
print(f"Correlation: {best_corr:.4f}")

Output:

Best guess: 0x2B
Correlation: 0.7685

This loop tries every value from 0x00 to 0xFF all 256 possibilities. For each guess, it calculates predicted Hamming Weights for all 50 traces, then checks correlation against all 5000 sample points. The guess that gives the highest correlation anywhere in the trace that’s the real key byte.

255 wrong guesses gave weak correlation. Only 0x2B gave 0.7685. That spike is the key byte leaving its fingerprint in the power traces.


Step 7 - Recover All 16 Key Bytes

Same logic, 16 times. We just change the byte index from 0 to 1, 2, 3… all the way to 15.

recovered_key = []

for byte_idx in range(16):
    best_corr = 0
    best_guess = 0
    
    for guess in range(256):
        hw_guess = []
        for i in range(50):
            sbox_out = sbox[plaintexts[i][byte_idx] ^ guess]
            hamming = bin(sbox_out).count('1')
            hw_guess.append(hamming)
        
        hw_guess = np.array(hw_guess)
        
        for sample in range(5000):
            real = traces[:, sample]
            corr = abs(np.corrcoef(hw_guess, real)[0, 1])
            if corr > best_corr:
                best_corr = corr
                best_guess = guess
    
    recovered_key.append(best_guess)
    print(f"Byte {byte_idx:2d}: 0x{best_guess:02X} (correlation: {best_corr:.4f})")

print("\nFull recovered key:")
print(' '.join([f'{b:02x}' for b in recovered_key]))

Output:

Byte  0: 0x2B (correlation: 0.7685)
Byte  1: 0x7E (correlation: 0.9317)
Byte  2: 0x15 (correlation: 0.7102)
Byte  3: 0x16 (correlation: 0.8597)
Byte  4: 0x28 (correlation: 0.8130)
Byte  5: 0xAE (correlation: 0.9584)
Byte  6: 0xD2 (correlation: 0.8309)
Byte  7: 0xA6 (correlation: 0.8653)
Byte  8: 0xAB (correlation: 0.8693)
Byte  9: 0xF7 (correlation: 0.9587)
Byte 10: 0x15 (correlation: 0.8788)
Byte 11: 0x88 (correlation: 0.9490)
Byte 12: 0x09 (correlation: 0.7781)
Byte 13: 0xCF (correlation: 0.8248)
Byte 14: 0x4F (correlation: 0.7865)
Byte 15: 0x3C (correlation: 0.9237)

Full recovered key:
2b 7e 15 16 28 ae d2 a6 ab f7 15 88 09 cf 4f 3c

Full AES-128 key. All 16 bytes. Correct.

Notice the correlations vary some bytes gave 0.93, some gave 0.71. Even the weakest was enough to identify the correct guess. That’s the power of using 50 traces even noisy bytes come through clearly enough.


What Just Happened The Full Picture

Let me say this simply because it took me a while to fully get it.

The chip had a secret 128-bit key hardcoded in its firmware. We never connected a debugger. We never read the firmware. We never saw the key transmitted anywhere. We just watched the power consumption 50 times while the chip was encrypting random data.

Then we tried 256 possible values for each of the 16 key bytes. For each guess, we predicted what the power should look like if that guess was correct. We compared the prediction against reality. The guess that matched best that was the real key byte.

The chip was leaking its secret through the power line the whole time. It had no idea.


About the Code

If you’re a beginner reading this and some of the code went over your head don’t worry. The important thing is the flow:

  1. Load the saved traces and plaintexts
  2. For each key byte position, try all 256 possible values
  3. For each guess, predict power consumption using Hamming Weight of Sbox output
  4. Compare prediction against real traces using correlation
  5. Highest correlation = correct key byte
  6. Repeat 16 times = full key recovered

The code skeleton is reusable. You don’t need to rewrite it from scratch for every attack. Understand the flow, use the skeleton, and you’re good.

The notebook used in this blog is the same IotSec.ipynb from Blog 5B hardware cells at the top, analysis cells added at the bottom. No separate file needed.


What We Learned

Hamming Weight - The number of 1-bits in a byte. A byte with more 1-bits causes more transistors to switch, which means more power consumed. This is the physical foundation of power analysis.

Sbox - A fixed 256-value lookup table that is part of the AES standard. Every AES implementation uses the same table. In round 1 of AES, the chip computes Sbox(plaintext XOR key) for each byte.

CPA (Correlation Power Analysis) - An attack where we guess key bytes, predict what power consumption should look like for each guess, and compare with real measurements. The guess with the strongest match is the real key byte.

Correlation - A mathematical measure of how well two sets of numbers match each other. Value between 0 and 1. Close to 1 means strong match. We used np.corrcoef to calculate this.

np.corrcoef - A numpy function that calculates correlation between two arrays. Returns a 2x2 matrix. We take the [0,1] value which is the correlation between the two arrays we passed in.

Block cipher - AES works on fixed 16-byte (128-bit) blocks. Input shorter than 16 bytes gets padded. Input longer than 16 bytes gets split into chunks.

plaintexts[:, 0] - Numpy slicing syntax. Colon means all rows, 0 means only column 0. Used to extract one byte position across all 50 encryptions.

bin(x).count('1') - Python trick to count 1-bits in a number. bin() converts to binary string, .count('1') counts the ones.


*That’s it for Blog 5C. Thanks for following along on IoTSec.in. Take care