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 1Understand the server protocol
ObservationThe server encrypts chosen plaintexts with AES and returns a power trace. Probe it by hand first to establish the exact input format and the shape of the trace output, 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 wants exactly 32 hex characters, representing 16 bytes. Send raw ASCII, or fewer characters, and it rejects the input and closes the connection. The prompt asks for 16 bytes in hex, two digits each, so a 16-character ASCII string is only 8 bytes and produces no trace.
Tried: Try to replay the same plaintext multiple times to see if the traces are identical and skip random sampling.
The key is fixed, so identical plaintexts return identical traces and zero variance in the plaintext bytes, which leaves the correlation matrix undefined at those positions. CPA needs varying plaintexts so the Hamming weight of the S-Box output moves 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 2Collect traces using pwntools and the scared library
ObservationCorrelation power analysis needs many traces against varying plaintexts before the statistics mean anything. Automate collection with pwntools, gather around 512 random-plaintext samples, and save them for offline analysis.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 too few traces the correlation is dominated by noise and most key byte guesses score alike, so the attack returns wrong bytes or a flat matrix with no clear winner. Around 512 traces gives enough statistical power to separate the correct byte from the 255 others, which matters most when the trace is long and the signal is spread thin.
Tried: Parse the trace response with a simple split() instead of a regex, assuming a clean space-separated format.
The response wraps its numbers in square brackets and may use scientific notation. Splitting on spaces leaves bracket characters stuck to the first and last values, so float() raises a ValueError. A regex matching both plain and scientific-notation floats handles them whatever punctuation surrounds them.
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
CPAAttackclass runs over a trace header set built from your plaintext and trace arrays, against a selection function that names the AES intermediate you are targeting (here the first-round SubBytes).Step 3Run the CPA attack with scared
ObservationThe scared library ships a CPA attack with a first-round SubBytes selection function. That round's S-Box output depends on exactly one plaintext byte and one key byte, so the 16-byte key comes back one byte at a time, each from the guess with the highest 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. # attack.scores is the discriminated (256 guesses, 16 positions) array; # attack.results still carries the time axis, so use scores, not results. key = np.argmax(attack.scores, axis=0).astype(np.uint8).tobytes() 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.
A last-round CPA is a real technique, but it predicts from the ciphertext (inverse S-Box of ciphertext byte XOR last-round-key byte), and this server hands back only a trace, never the ciphertext. With no ciphertext there is nothing for that selection function to key off. FirstSubBytes predicts from the plaintext you chose, which you always know, and its output depends on exactly one plaintext byte and one key byte. It also yields the original key directly, where a last-round attack recovers round key 10 and needs the key schedule run backwards afterwards.
Tried: Use np.argmax(attack.scores, axis=1) instead of axis=0 to extract the best key guess.
After the discriminant collapses the time dimension, the scores array is 256 guesses by 16 key positions. Taking the argmax over the guess dimension gives one best guess per byte. Taking it over the other axis returns a 256-element array of position indices, which is not a key at all.
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. Themaxabsdiscriminant then collapses the time axis by taking the largest absolute correlation per guess, so the key byte with the highest score 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.
Interactive tools
- Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.
- Frequency AnalysisAnalyze letter frequencies in a substitution cipher and interactively build the decryption mapping with auto-filled guesses.
Flag
Reveal flag
picoCTF{...}
The flag is the 16-byte AES key recovered by CPA, hex-encoded and wrapped in the standard format.