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
import numpy as npfrom 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-boxsbox = 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.shapekey = 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) uint8traces (500, 100) float64byte 00: key=66 max_corr=0.8061byte 01: key=dc max_corr=0.7295byte 02: key=e1 max_corr=0.7035byte 03: key=5f max_corr=0.7074byte 04: key=b3 max_corr=0.7046byte 05: key=3d max_corr=0.7074byte 06: key=ea max_corr=0.7045byte 07: key=cb max_corr=0.6851byte 08: key=5c max_corr=0.7390byte 09: key=03 max_corr=0.7295byte 10: key=62 max_corr=0.7315byte 11: key=f3 max_corr=0.7275byte 12: key=0e max_corr=0.7151byte 13: key=95 max_corr=0.7339byte 14: key=f5 max_corr=0.7182byte 15: key=2e max_corr=0.7851key hex: 66dce15fb33deacb5c0362f30e95f52eciphertext len 64ECB 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.