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.
Setup
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.
nc saturn.picoctf.net <PORT_FROM_INSTANCE>pip3 install pwntools numpy scaredSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Understand the server protocolObservationI 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.bashnc saturn.picoctf.net <PORT_FROM_INSTANCE>bash# Server says: '16 bytes of plaintext (hex):'bash# Send: 00000000000000000000000000000000bash# 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.
Step 2
Collect traces using pwntools and the scared libraryObservationI 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.pythonpython3 - <<'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") PYWhat 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
ChosenPlainTextAttackclass from scared accepts plaintext arrays and trace arrays, and runs CPA against a target function (like AES SubBytes).Step 3
Run the CPA attack with scaredObservationI 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.pythonpython3 - <<'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() + "}") PYWhat 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
CPAAttackcomputes Pearson correlation between the predicted Hamming weight ofSBox[plaintext[i] XOR k]for every key guesskin 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.