PowerAnalysis: Warmup picoCTF 2023 Solution

Published: April 26, 2023

Description

A simulated power analysis attack against AES. You are given power traces captured during AES encryption. Apply Hamming-weight correlation analysis on the SubBytes step to recover a single byte of the AES key.

Download and unzip the challenge files. Inside you get a NumPy .npy array of power traces plus the matching plaintext bytes.

Install NumPy. SciPy is optional for this warmup since np.corrcoef is enough.

bash
wget https://artifacts.picoctf.net/c/500/PowerAnalysis_Warmup.zip && unzip PowerAnalysis_Warmup.zip
bash
pip3 install numpy
For the deeper background on AES rounds and SubBytes, see the AES for CTF guide.
  1. Step 1Understand the attack model
    Power consumption tracks the Hamming weight of intermediate values. Target SBox[plaintext XOR key] for one key byte and the right guess shows the strongest correlation against the traces.
    Learn more

    AES encryption begins with AddRoundKey (XOR plaintext with key) then SubBytes (S-box lookup). The first-round intermediate the attack targets is v = SBox[p[i] XOR k[i]], where p is the known plaintext byte and k is the unknown key byte. CMOS power consumption rises roughly linearly with the number of 1-bits set on the data bus, i.e. the Hamming weight HW(v).

    The attack hinges on Pearson correlation between the predicted Hamming weight (one number per trace, for a fixed key guess) and the actual trace samples (one number per trace, per time index):

    Toy Pearson sketch (5 traces, ignore time axis):
      plaintext bytes p:    [0x12, 0xFE, 0x33, 0xA0, 0x77]
      WRONG guess k = 0x42 -> SBox[p^k] -> v_wrong, HW(v_wrong) random
      RIGHT guess k = 0x2B -> SBox[p^k] -> v_right, HW(v_right) tracks the real leakage
    
      Measured trace at the leak-time sample t*:
        power[t*] = [3.4, 5.1, 2.0, 6.7, 4.8]
    
      Compute corr(HW_guess, power[t*]) for every guess in 0..255.
      Wrong guesses: |corr| ~ 0.05 (noise-level, no relationship)
      Correct guess: |corr| ~ 0.6+ (linear relationship dominates)
    
    argmax over guesses -> recovers key byte.

    Pearson is invariant to scale and offset, which is exactly what we want: real power is in millivolts and predicted HW is a small integer, but only the linear relationship matters. Gaussian zero-mean noise weakens the signal slowly: more traces drive sample correlation toward the population value.

  2. Step 2Load traces and build the prediction matrix
    Build a 256 x N matrix: rows are key guesses 0..255, columns are traces. Cell [k][i] is the Hamming weight of SBox[plaintext[i] XOR k]. Do not transpose this orientation later.
    python
    python3 cpa_attack.py
    Learn more

    The AES S-Box is a fixed 256-byte lookup table. Hardcode it once at the top of the script:

    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,
    ]

    For each candidate byte k (0 to 255) and each trace i, compute predictions[k, i] = popcount(SBOX[plaintext[i] ^ k]). Final shape is (256, N): 256 rows (one per key guess), N columns (one per trace). Keep this orientation. Many CPA bugs come from accidentally transposing the matrix.

    See the canonical S-Box reference in the Rijndael S-box article if you want to double-check the table.

  3. Step 3Correlate predictions against the trace samples
    For each key guess, compute the Pearson correlation between its prediction row and every time sample of the traces. The guess with the largest |corr| anywhere wins.
    python
    python3 -c "
    import numpy as np
    # predictions shape: (256, N), traces shape: (N, T)
    corrs = np.array([
        np.corrcoef(predictions[k], traces.T)[0, 1:]
        for k in range(256)
    ])  # shape (256, T)
    best_k = int(np.argmax(np.max(np.abs(corrs), axis=1)))
    poi_sample = int(np.argmax(np.abs(corrs[best_k])))
    peak = float(np.abs(corrs[best_k, poi_sample]))
    print(f'Key byte: {best_k:#04x}, POI sample: {poi_sample}, |corr|: {peak:.3f}')
    assert peak > 0.3, 'No clear peak. Attack did not converge - check trace alignment, plaintext mapping, or S-box.'
    "
    Learn more

    Unpacking np.corrcoef(predictions[k], traces.T)[0, 1:]: traces.T has shape (T, N) so each row is one time sample across all traces. np.corrcoef stacks the prediction row on top of the T sample rows, builds a (T+1, T+1) correlation matrix, and we slice [0, 1:] to read the prediction's correlation against each of the T sample rows. The result is a length-T vector of correlations per key guess.

    poi_sample = np.argmax(np.abs(corrs[best_k])) recovers the point of interest: the time index where the SBox computation actually leaks. Confirming a real POI (rather than a flat noise floor) is the sanity check that the model fits.

    Noise sentinel: if no key guess produces a peak with |corr| > 0.3 the attack has not converged. Common causes are a wrong S-box, traces and plaintexts in different orders, misaligned traces (jitter), or simply too few traces for the SNR. The warmup is clean enough that a few hundred traces show peaks well above 0.5 for the right guess.

  4. Step 4Submit the recovered key byte
    Format the recovered byte as the challenge expects (hex, decimal, or wrapped) and submit.
    Learn more

    Power analysis attacks were first published by Paul Kocher in 1999 and remain a serious threat to hardware implementations of cryptography today. Smart cards, HSMs, and embedded cryptographic modules must employ countermeasures like randomized masking (adding a random value to intermediates to break the Hamming-weight correlation) and power supply filtering to resist CPA attacks.

    This warmup covers the essential CPA workflow (prediction model, correlation, key recovery) which scales directly to recovering full AES keys in the harder Power Analysis challenges by repeating the attack per key byte.

Flag

picoCTF{...}

This challenge was not solved during the competition. Follow the steps above to reproduce the solution.

Want more picoCTF 2023 writeups?

Useful tools for Cryptography

Related reading

What to try next