Cet article fait suite à Introduction à l’analyse de consommation de puissance avec le ChipWhisperer, dans lequel nous avons capturé des traces de consommation et identifié visuellement les dix rounds de l’AES.
Nous savons désormais que la consommation du microcontrôleur dépend des données qu’il manipule. Reste à transformer cette observation en attaque : retrouver la clé de chiffrement à partir des seules traces, sans accès au code ni au matériel. C’est l’objet de la DPA (Differential Power Analysis), introduite en 1999 par Paul Kocher, Joshua Jaffe et Benjamin Jun. Le principe : à partir d’une hypothèse sur un octet de clé, on prédit un bit intermédiaire du chiffrement, on l’utilise pour répartir les traces en deux groupes, puis on calcule la différence des moyennes. Si l’hypothèse est correcte, un pic apparaît ; sinon, la différence reste plate.
C’est ce mécanisme que nous allons détailler et mettre en œuvre dans la suite.
L’algorithme de la DPA (simplifié ici à des fins de simulation) repose sur une idée simple : la consommation électrique mesurée à un instant donné mélange deux choses. Une partie dépend de la donnée que la puce est en train de manipuler, c’est l’information qui nous intéresse. L’autre partie est du bruit, c’est-à-dire tout ce qui parasite la mesure sans rapport avec cette donnée (bruit électronique, perturbations électromagnétiques, activité du reste du circuit). Toute l’attaque consiste à isoler la première en neutralisant la seconde.
Pour ce faire, on va s’appuyer sur une propriété du bruit : il est aléatoire et centré, c’est-à-dire qu’il tire la consommation tantôt vers le haut, tantôt vers le bas, sans direction privilégiée. Sur une seule trace, il peut être plus fort que le signal et masquer complètement l’information. Mais si on additionne beaucoup de traces et qu’on en fait la moyenne, ces écarts aléatoires se compensent et finissent par disparaître. Le signal, lui, ne s’annule pas : comme il dépend toujours de la même donnée, il s’accumule. Le moyennage agit donc comme un filtre qui efface le hasard et laisse ressortir la part utile.
Exemple illustré en python :
import random
random.seed(0)
# modèle de fuite : la consommation dépend de la valeur du bit
def consommation(bit):
if bit == 1:
return 1 + random.gauss(0, 1) # manipuler un 1 coûte un peu plus
if bit == 0:
return 0 + random.gauss(0, 1) # manipuler un 0 coûte un peu moins
bits = []
consos = []
for _ in range(1000):
bit = random.randint(0, 1)
bits.append(bit)
consos.append(consommation(bit))
def difference(hypothese):
groupe1 = [consos[i] for i in range(1000) if hypothese[i] == 1]
groupe0 = [consos[i] for i in range(1000) if hypothese[i] == 0]
return sum(groupe1)/len(groupe1) - sum(groupe0)/len(groupe0)
hasard = [random.randint(0, 1) for _ in range(1000)]
print("Bonne hypothèse :", round(difference(bits), 3))
# 1.01
print("Mauvaise hypothèse :", round(difference(hasard), 3))
# -0.074Reste à savoir comment se servir de cette part utile pour retrouver la clé. L’idée est de faire un pari sur la donnée manipulée, puis de trier les traces en deux groupes selon ce pari : d’un côté celles où l’on suppose un bit à 1, de l’autre celles où on le suppose à 0. On calcule la consommation moyenne de chaque groupe, puis on regarde l’écart entre les deux. Si notre pari était juste, les deux groupes correspondent à des consommations réellement différentes et l’écart est net : c’est le pic. S’il était faux, le tri est fait au hasard, les deux groupes se ressemblent et l’écart s’effondre vers zéro. Il ne reste plus qu’à essayer tous les paris possibles : celui qui produit le pic révèle la bonne valeur, donc la clé.

L’algorithme AES décompose chaque tour en quatre opérations (AddRoundKey, SubBytes, ShiftRows et MixColumns), toutes linéaires sauf la S-box (SubBytes). Une opération linéaire signifie qu’un changement de l’entrée a un effet prévisible sur la sortie. Avec la S-box, non linéaire, deux hypothèses voisines donnent des prédictions complètement différentes.
Il n’est pas nécessaire de cibler plusieurs octets : on garde le même octet de sortie et le même bit. Comme les plaintexts varient, ce bit prend tantôt la valeur 0, tantôt 1 selon les traces, ce qui suffit à remplir les deux groupes du partitionnement.
Exemple d’illustration en python :
import random
import numpy as np
random.seed(0)
# 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)
def hamming(x):
return bin(x).count("1")
KEY = 0x2B # the key byte to recover (unknown to the attacker)
N = 4000
# --- Device simulation ---
# the attacker knows the plaintexts; power consumption leaks the S-box output
plaintexts = [random.randint(0, 255) for _ in range(N)]
power = [hamming(sbox[p ^ KEY]) + random.gauss(0, 1) for p in plaintexts]
# --- Attack: try all 256 possible key bytes ---
def difference(guess):
# for each trace, PREDICT one bit of the S-box output under hypothesis 'guess'
predicted_bit = [(sbox[plaintexts[i] ^ guess] & 1) for i in range(N)]
group1 = [power[i] for i in range(N) if predicted_bit[i] == 1]
group0 = [power[i] for i in range(N) if predicted_bit[i] == 0]
return sum(group1)/len(group1) - sum(group0)/len(group0)
diffs = [abs(difference(g)) for g in range(256)]
found = max(range(256), key=lambda g: diffs[g])
print(f"Actual key : 0x{KEY:02X}")
print(f"Recovered key : 0x{found:02X}")En reprenant le setup de l’exercice précédent, le notebook se découpe en deux cellules : d’abord la capture des traces, ensuite l’attaque.
Les deux cellules sont volontairement séparées. La capture est longue ; une fois les traces en mémoire, on peut relancer l’attaque seule en ajustant les paramètres sans repasser par le matériel.
Cellule 1 : capture des traces
import chipwhisperer as cw
import chipwhisperer.analyzer as cwa
import numpy as np
N = 4000
ktp = cw.ktp.Basic()
project = cw.create_project("sca101_dpa", overwrite=True)
for i in range(N):
key, text = ktp.next()
trace = cw.capture_trace(scope, target, text, key)
if trace is not None:
project.traces.append(trace)
if i % 100 == 0:
print(f"{i}/{N}")Cellule 2 : attaque DPA sur les 16 octets
On n’utilise que M = 2500 traces sur les 4000 capturées. M est un paramètre de test : en le faisant varier sans recapturer, on détermine empiriquement combien de traces sont nécessaires pour retrouver la clé. Après plusieurs essais, en dessous de 2000 traces des erreurs apparaissent sur certains octets ; au-dessus, l’attaque converge systématiquement.
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)
textins = np.array([t.textin for t in project.traces], dtype=np.uint8)
traces = np.array([t.wave for t in project.traces])
M = 2500
textins = textins[:M]
traces = traces[:M]
real_key = np.array(project.traces[0].key, dtype=np.uint8)
def diff(guess, byte=0):
inter = sbox[textins[:, byte] ^ guess]
bit = inter & 1
mean1 = traces[bit == 1].mean(axis=0)
mean0 = traces[bit == 0].mean(axis=0)
return mean1 - mean0
def attack_byte(byte=0):
peaks = []
for guess in range(256):
d = diff(guess, byte)
peaks.append(np.max(np.abs(d)))
return int(np.argmax(peaks))
key = []
for b in range(16):
guess = attack_byte(b)
key.append(guess)
ok = "OK" if guess == real_key[b] else "X"
print(f"byte {b:2d} : guess = 0x{guess:02X} real = 0x{real_key[b]:02X} {ok}")KeyboardInterrupt Traceback (most recent call last)
Cell In[30], line 44
42 key = []
43 for b in range(16):
---> 44 guess = attack_byte(b)
Cell In[30], line 38, in attack_byte(byte)
36 peaks = []
37 for guess in range(256):
---> 38 d = diff(guess, byte)
Cell In[30], line 32, in diff(guess, byte)
29 inter = sbox[textins[:, byte] ^ guess]
30 bit = inter & 1
31 mean1 = traces[bit == 1].mean(axis=0)
---> 32 mean0 = traces[bit == 0].mean(axis=0)En pratique cette version est trop lente : pour chaque octet, on parcourt les 256 hypothèses en recalculant deux moyennes sur 2500 traces à chaque fois, et ce pour 16 octets, soit 16 × 256 = 4096 passages sur l’ensemble des données. La boucle Python ne suit pas.
La solution est de vectoriser : plutôt que de traiter une hypothèse à la fois, on calcule les 256 en une seule opération matricielle. NumPy n’exécute plus du Python à chaque itération mais un unique produit matriciel optimisé.
def attack_byte_fast(byte=0):
pt = textins[:, byte] # (M,)
guesses = np.arange(256, dtype=np.uint8)
inter = sbox[np.bitwise_xor(pt[None, :], guesses[:, None])] # (256, M)
bits = (inter & 1).astype(np.float32) # (256, M)
counts1 = bits.sum(axis=1, keepdims=True) # (256, 1)
counts0 = M - counts1
mean1 = (bits @ traces) / counts1 # (256, T)
mean0 = ((1 - bits) @ traces) / counts0
peaks = np.abs(mean1 - mean0).max(axis=1) # (256,)
return int(np.argmax(peaks))
key = []
for b in range(16):
guess = attack_byte_fast(b)
key.append(guess)
ok = "OK" if guess == real_key[b] else "X"
print(f"byte {b:2d} : guess = 0x{guess:02X} real = 0x{real_key[b]:02X} {ok}")byte 0 : guess = 0x2B real = 0x2B OK
byte 1 : guess = 0x7E real = 0x7E OK
byte 2 : guess = 0x15 real = 0x15 OK
byte 3 : guess = 0x16 real = 0x16 OK
byte 4 : guess = 0x28 real = 0x28 OK
byte 5 : guess = 0xAE real = 0xAE OK
byte 6 : guess = 0xD2 real = 0xD2 OK
byte 7 : guess = 0xA6 real = 0xA6 OK
byte 8 : guess = 0xAB real = 0xAB OK
byte 9 : guess = 0xF7 real = 0xF7 OK
byte 10 : guess = 0x15 real = 0x15 OK
byte 11 : guess = 0x88 real = 0x88 OK
byte 12 : guess = 0x09 real = 0x09 OK
byte 13 : guess = 0xCF real = 0xCF OK
byte 14 : guess = 0x4F real = 0x4F OK
byte 15 : guess = 0x3C real = 0x3C OKLes 16 octets sont retrouvés correctement. La clé complète 2b7e151628aed2a6abf7158809cf4f3c correspond à la clé AES utilisée par le firmware.
L’implémentation présentée ici n’est pas la plus optimisée : la vectorisation numpy aide, mais la DPA reste intrinsèquement coûteuse. Pour chaque octet, on calcule une différence de moyennes sur l’ensemble des traces, pour les 256 hypothèses possibles, et on répète ça 16 fois. Sur du matériel réel avec des milliers de traces, ça se ressent.
Ce coût est cependant le prix de la pédagogie : la DPA est l’attaque la plus simple à comprendre conceptuellement. Elle pose les bases des analyses de canal auxiliaire sans hypothèse sur le modèle de fuite au-delà du bit de sortie de la S-box. Comprendre pourquoi elle fonctionne, c’est comprendre pourquoi toutes les attaques statistiques suivantes fonctionnent.
Le prochain article introduit la CPA (Correlation Power Analysis), qui remplace la différence des moyennes par une corrélation de Pearson et un modèle de fuite en poids de Hamming. En pratique, la CPA converge avec beaucoup moins de traces et se calcule bien plus rapidement.