PowerAnalysis: Part 1 picoCTF 2023 Solution

Published: April 26, 2023

Description

A live remote system encrypts plaintext you send and returns the CPU power trace for each encryption. Use the scared side-channel analysis library to collect traces, build a chosen-plaintext CPA attack on the first AES round S-Box, and recover the full 16-byte key.

Connect to the challenge server to see the trace format. Send 32 hex characters and receive a power trace array.

Install pwntools, numpy, and the scared library.

bash
nc saturn.picoctf.net <PORT_FROM_INSTANCE>
bash
pip3 install pwntools numpy scared

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Understand the server protocol
    Observation
    I noticed the challenge provides a remote server that performs AES encryption on chosen plaintexts and returns a power trace, which suggested manually probing the server first to confirm the exact input format and trace output structure before writing any attack code.
    The server prompts for 16 bytes of plaintext (32 hex characters). It encrypts them with a fixed AES key and returns a power trace as a bracketed array of numbers. Each number correlates with the Hamming weight of bits processed during that AES clock cycle.
    bash
    nc saturn.picoctf.net <PORT_FROM_INSTANCE>
    bash
    # Server says: '16 bytes of plaintext (hex):'
    bash
    # Send: 00000000000000000000000000000000
    bash
    # Receive: [0.123, 0.456, ...]
    What didn't work first

    Tried: Send ASCII text instead of hex to the server to see if it accepts plaintext directly.

    The server expects exactly 32 hex characters representing 16 bytes. Sending raw ASCII or fewer characters causes the server to reject the input and close the connection. The prompt explicitly says '16 bytes of plaintext (hex)' - each byte must be two hex digits, so a 16-character ASCII string encodes only 8 bytes and the server will not produce a trace.

    Tried: Try to replay the same plaintext multiple times to see if the traces are identical and skip random sampling.

    The server returns deterministic traces for the same plaintext (the key is fixed), but identical plaintexts produce zero variance in the plaintext bytes, so the CPA correlation matrix is undefined for those byte positions. CPA requires varying plaintexts so that the Hamming weight of SBox[pt XOR k] varies across traces - without variance there is nothing to correlate against.

    Learn more

    Correlation Power Analysis (CPA) works by measuring the correlation between predicted power consumption and actual measured power. For AES, the first-round S-Box lookup SBox[plaintext XOR key] depends on one byte of plaintext and one byte of the key. By choosing different plaintexts and measuring the resulting power traces, you can correlate predicted Hamming weights against actual measurements to identify the correct key byte.

    The challenge leaks power information correlated with Hamming weight of processed values. This is the standard side-channel leakage model for software AES on a microcontroller.

  2. Step 2
    Collect traces using pwntools and the scared library
    Observation
    I noticed that CPA requires correlating many power traces against varying plaintext inputs to produce statistically significant results, which suggested automating trace collection with pwntools to gather around 512 random-plaintext samples and saving them for the offline analysis step.
    Write a script that connects to the server, sends random plaintexts, and captures the returned power traces. Build a ScaRed trace set from the collected data. About 512 traces is enough for a clean attack.
    python
    python3 - <<'PY'
    import numpy as np
    from pwn import remote
    import re, random
    
    HOST = "saturn.picoctf.net"
    PORT = 0  # replace with your port
    
    def get_trace(r, plaintext_bytes):
        r.recvuntil(b":")
        r.sendline(plaintext_bytes.hex().encode())
        response = r.recvline().decode()
        # Parse the bracketed array
        nums = re.findall(r"[-\d.]+(?:e[-+]?\d+)?", response)
        return np.array([float(x) for x in nums])
    
    r = remote(HOST, PORT)
    
    N = 512
    plaintexts = np.zeros((N, 16), dtype=np.uint8)
    traces_list = []
    
    for i in range(N):
        pt = bytes(random.randrange(256) for _ in range(16))
        plaintexts[i] = list(pt)
        trace = get_trace(r, pt)
        traces_list.append(trace)
        if i % 50 == 0:
            print(f"Collected {i}/{N} traces")
    
    traces = np.array(traces_list)
    np.save("plaintexts.npy", plaintexts)
    np.save("traces.npy", traces)
    print("Saved plaintexts.npy and traces.npy")
    PY
    What didn't work first

    Tried: Collect only 32 or 64 traces to save time and then run the attack.

    With very few traces the Pearson correlation is dominated by noise and most key byte guesses show similar scores. The attack may return wrong key bytes or produce a flat results matrix with no clear winner. Around 512 traces gives the CPA enough statistical power to separate the correct key from the 255 wrong guesses, especially when the trace length is long and the signal is spread across many samples.

    Tried: Parse the trace response with a simple split() instead of a regex, assuming a clean space-separated format.

    The server response wraps numbers in square brackets and may include scientific notation like '1.23e-04'. A naive split(' ') leaves bracket characters attached to the first and last numbers, causing float() to throw a ValueError. The regex r'[-\d.]+(?:e[-+]?\d+)?' matches both regular and scientific-notation floats regardless of surrounding punctuation.

    Learn more

    The scared library (from eShard) provides a high-level API for side-channel analysis. It handles the correlation math and returns the most likely key byte per position. The ChosenPlainTextAttack class from scared accepts plaintext arrays and trace arrays, and runs CPA against a target function (like AES SubBytes).

  3. Step 3
    Run the CPA attack with scared
    Observation
    I noticed the scared library provides a CPAAttack class with a FirstSubBytes selection function, which targets the first-round S-Box where each output byte depends on exactly one plaintext byte and one key byte, making it possible to recover the 16-byte key one byte at a time by finding the guess with the highest Pearson correlation.
    Build a scared TraceHeaderSet from the collected data, set up a chosen-plaintext CPA attack targeting the first AES SubBytes, run it, and extract the key bytes with the highest correlation scores.
    python
    python3 - <<'PY'
    import numpy as np
    import scared
    
    plaintexts = np.load("plaintexts.npy")
    traces = np.load("traces.npy")
    
    # Build trace header set
    ths = scared.traces.formats.read_ths_from_ram(
        samples=traces,
        plaintext=plaintexts,
    )
    
    # Attack: first SubBytes, Hamming weight leakage model
    attack = scared.CPAAttack(
        selection_function=scared.aes.selection_functions.encrypt.FirstSubBytes(),
        model=scared.HammingWeight(),
        discriminant=scared.maxabs,
    )
    
    attack.run(ths)
    
    # Extract the best key guess per byte position
    key = bytes(np.argmax(attack.results, axis=0))
    print("Recovered key:", key.hex())
    print("Flag: picoCTF{" + key.hex() + "}")
    PY
    What didn't work first

    Tried: Use scared.LastSubBytes() as the selection function instead of FirstSubBytes() to target the final AES round.

    The server returns the trace for the entire encryption, but the last-round SubBytes output depends on all 16 key bytes simultaneously (the final AES round omits MixColumns, but nine prior rounds of MixColumns and key mixing have fully diffused all key and plaintext bytes into the state). To isolate a single key byte you need a point where only one key byte influences the intermediate value - that is exactly the first-round SBox output SBox[plaintext[i] XOR key[i]], which depends only on plaintext byte i and key byte i. Targeting the last round would require knowledge of 10 key bytes at once, making an exhaustive search over 256^10 candidates infeasible.

    Tried: Use np.argmax(attack.results, axis=1) instead of axis=0 to extract the best key guess.

    attack.results has shape (256 key_guesses, 16 key_positions) after the attack collapses the time dimension via maxabs. axis=0 takes the argmax over the 256 guess dimension for each of the 16 positions, giving one best guess per byte. axis=1 would instead pick the best position index for each of the 256 guesses, producing a 256-element array of time indices that has no meaning as a key.

    Learn more

    The scared CPAAttack computes Pearson correlation between the predicted Hamming weight of SBox[plaintext[i] XOR k] for every key guess k in 0..255, and the actual trace samples. The key byte with the highest maximum absolute correlation across all time samples is the correct guess. Running this for all 16 byte positions recovers the full AES key.

    The format of the flag is the recovered key hex-encoded and wrapped in the picoCTF format. For background on the AES round function and S-Box see the AES for CTF guide.

Flag

Reveal flag

picoCTF{...}

The flag is the 16-byte AES key recovered by CPA, hex-encoded and wrapped in the standard format.

Key takeaway

Power analysis attacks exploit the fact that a CPU consumes measurably different amounts of current depending on the data it processes, and this leaks key-dependent information without any mathematical weakness in the cipher itself. Correlation Power Analysis correlates observed power traces against a predicted leakage model (typically Hamming weight of intermediate values) to statistically isolate the correct key byte one position at a time. This class of attack breaks otherwise-secure cryptographic implementations in smart cards, embedded devices, and hardware security modules, which is why countermeasures such as masking, shuffling, and constant-time implementations are required for any hardware that handles secret keys.

Related reading

Want more picoCTF 2023 writeups?

Useful tools for Cryptography

What to try next