Category: Forensics
Flag: texsaw{d1ffer3nti&!_p0w3r_@n4!y51s}
Challenge Description
You’ve captured 500 power traces from a hardware AES encryption device while it processed known plaintexts with an unknown secret key.
Analysis
The provided files were exactly what the description promised: a set of known AES plaintext blocks, a matching set of power traces, and a separate encrypted blob to decrypt once the key was recovered. The useful clue was the array layout: plaintexts.npy held 500 rows of 16 bytes, and traces.npy held 500 rows of 100 floating-point samples, which is a very natural shape for a first-round AES side-channel attack.
Solution
# solve.py
import numpy as np
from pathlib import Path
pt = np.load('work/plaintexts.npy')
tr = np.load('work/traces.npy')
print('plaintexts', pt.shape, pt.dtype)
print('traces', tr.shape, tr.dtype)
# AES S-box
sbox = np.array([
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
], dtype=np.uint8)
hw = np.array([bin(x).count('1') for x in range(256)], dtype=np.uint8)
n_traces, n_samples = tr.shape
key = np.zeros(16, dtype=np.uint8)
tr_centered = tr - tr.mean(axis=0)
for byte in range(16):
pt_byte = pt[:, byte]
hyp = np.empty((256, n_traces), dtype=np.float64)
for k in range(256):
hyp[k] = hw[sbox[np.bitwise_xor(pt_byte, k)]]
hyp = hyp - hyp.mean(axis=1, keepdims=True)
cov = hyp @ tr_centered / (n_traces - 1)
std_h = np.sqrt((hyp**2).sum(axis=1) / (n_traces - 1))
std_t = np.sqrt((tr_centered**2).sum(axis=0) / (n_traces - 1))
corr = cov / (std_h[:, None] * std_t[None, :])
max_corr = np.max(np.abs(corr), axis=1)
best_k = int(np.argmax(max_corr))
key[byte] = best_k
print(f'byte {byte:02d}: key={best_k:02x} max_corr={max_corr[best_k]:.4f}')
key_bytes = bytes(key.tolist())
print('key hex:', key_bytes.hex())
Path('results/aes_key.bin').write_bytes(key_bytes)
ct = Path('work/encrypted_flag.bin').read_bytes()
print('ciphertext len', len(ct))
from Crypto.Cipher import AES
aes = AES.new(key_bytes, AES.MODE_ECB)
pt_ecb = aes.decrypt(ct)
Path('results/decrypted_ecb.bin').write_bytes(pt_ecb)
print('ECB plaintext:', pt_ecb)
aes = AES.new(key_bytes, AES.MODE_CBC, iv=b'\x00'*16)
pt_cbc = aes.decrypt(ct)
Path('results/decrypted_cbc_zeroiv.bin').write_bytes(pt_cbc)
print('CBC0 plaintext:', pt_cbc)Running the attack script immediately confirmed the intended leakage model. The attack used classic correlation power analysis with the Hamming weight of the AES S-box output for each first-round state byte, which is the standard model when a device leaks roughly in proportion to the number of set bits being handled. Each key byte was chosen by taking the hypothesis with the largest absolute Pearson correlation over all 100 time samples.
python solve.pyplaintexts (500, 16) uint8
traces (500, 100) float64
byte 00: key=66 max_corr=0.8061
byte 01: key=dc max_corr=0.7295
byte 02: key=e1 max_corr=0.7035
byte 03: key=5f max_corr=0.7074
byte 04: key=b3 max_corr=0.7046
byte 05: key=3d max_corr=0.7074
byte 06: key=ea max_corr=0.7045
byte 07: key=cb max_corr=0.6851
byte 08: key=5c max_corr=0.7390
byte 09: key=03 max_corr=0.7295
byte 10: key=62 max_corr=0.7315
byte 11: key=f3 max_corr=0.7275
byte 12: key=0e max_corr=0.7151
byte 13: key=95 max_corr=0.7339
byte 14: key=f5 max_corr=0.7182
byte 15: key=2e max_corr=0.7851
key hex: 66dce15fb33deacb5c0362f30e95f52e
ciphertext len 64
ECB plaintext: b'\xe9\xc8\xfaS\x85\x15\x94\xf9\x19\x1akY\xdf\xc4\x9dz\xb4b\xe1\x17\x0f\x05\x82\x85&5\xe2*\xbaB\x90kZ\xb7\xc2le\xaa\x17\xf2e\x85>=\x96\x98C\x91}\xd8\x11\x14\x9a8\x1b8\xda0\x1bOYtQ\xcc'
CBC0 plaintext: b'\xe9\xc8\xfaS\x85\x15\x94\xf9\x19\x1akY\xdf\xc4\x9dztexsaw{d1ffer3nti&!_p0w3r_@n4!y51s}\r\r\r\r\r\r\r\r\r\r\r\r\r'That output gave the full AES-128 key as 66dce15fb33deacb5c0362f30e95f52e. Decrypting the 64-byte blob under ECB produced garbage, which was a good sign that the key recovery was right but the mode guess was wrong. Trying CBC with an all-zero IV was the winning pivot, and the decrypted plaintext clearly contained the flag with PKCS#7-style padding at the end.