J'ai eu le plaisir de participer pour la deuxième année au France Cybersecurity Challenge organisé par l'ANSSI, l'agence de cybersécurité française. Parmi les épreuves proposées, une des catégories qui m'attirait le plus cette année était la catégorie "hardware", dédiée à l'analyse de signaux radio. Le truc, c'est que je n'y connais absolument rien, et je n'avais pas le temps d'apprendre à me servir de GNURadio, le logiciel de référence du domaine. J'ai quand même réussi à être la première personne à valider B.A. BA (137 résolutions / 1732 joueurs), le deuxième à valider Phase à phase (20 résolutions), et à avoir presque résolu Zodiaque (6 résolutions), tout ça en une soirée.
Malgré son titre, ce post n'est pas une explication de la radio logicielle, parce que je n'y connais toujours rien, c'est juste un writeup de ces trois épreuves sans autre prérequis que d'être à l'aise en Python.
B.A. BA
On a un fichier challenge.iq
à analyser. IQ, qu'est-ce que
c'est que ça ? Google donne une page de la doc de
PySDR, qui dit que c'est des
valeurs complexes qui décrivent le signal. Admettons. Ils disent
aussi comment les lire avec numpy
(tant mieux parce qu'on n'a pas le
temps d'apprendre PySDR non plus) :
iqs = np.fromfile('challenge.iq', np.complex64)
print(iqs.shape) # (3675100,)
Bon, faisons un graphe. Ce sont des valeurs complexes donc on va tracer la partie réelle, imaginaire, le module et l'argument au cas où certaines représentations seraient plus intéressantes que d'autres.
fig, axs = plt.subplots(2, 2, sharex=True)
axs[0, 0].plot(np.real(iqs))
axs[1, 0].plot(np.imag(iqs))
axs[0, 1].plot(np.abs(iqs))
axs[1, 1].plot(np.angle(iqs))
plt.show()
Donc il semblerait que ce soient des nombres soit grands, soit petits. Le module semble le plus pratique pour décider de ça. On va choisir de dire qu'au-dessus de 0.75 c'est grand, et en dessous c'est petit :
discrete = np.abs(iqs) > 0.75
Maintenant, on a un tableau rempli de True
et de False
, et sur
le graphe précédent ça a l'air de passer de l'un à l'autre quand
même assez rarement. On va calculer les longueurs des groupes
consécutifs de True
et False
avec groupby
, une fonction du
package itertools
:
signals = [(value, len(list(group)))
for value, group in it.groupby(discrete)]
print(signals[:5])
# [(False, 4994), (True, 150), (False, 1000), (True, 500), (False, 999)]
Regardons quelles sont les durées les plus fréquentes, pour les deux valeurs :
sizesT = Counter(l for v, l in signals if v)
sizesF = Counter(l for v, l in signals if not v)
print(sizesT)
# Counter({150: 820, 500: 506, 499: 161, 149: 114, 151: 19, 501: 2})
print(sizesF)
# Counter({1000: 518, 999: 475, 3498: 472, 3499: 154, 3497: 2, 4994: 1, 3489: 1})
Donc les valeurs True
sont là en paquets de 150±1 et 500±1, et
les valeurs False
en paquets de 1000±1 et 3500±2. Le sujet parle
de Morse, donc probablement 150 c'est un point, 500 un trait, 1000 ça
doit être la séparation entre les symboles, et 3500 c'est entre deux
lettres. Il reste donc à traduire ça en texte :
m = ''.join(('.' if l < 300 else '-') if v else (' ' if l > 2000 else '')
for v, l in signals)
print(m) # .-.. . -.-. --- -.. . -- --- etc.
On copie-colle ça dans dcode et on a le premier flag.
Phase à phase
Encore un fichier challenge.iq
. Cette fois, on sait déjà le lire
et faire un premier graphe.
Cette fois-ci, le module semble constant, c'est l'argument qui fait des choses étranges. Même son de cloche pour les parties réelle et imaginaire qui semblent décrire une sinusoïde qui change de phase brutalement, et assez souvent. Comme c'est le titre de l'épreuve, j'imagine que les données sont encodées ici dans ces changements de phase.
En tout cas on n'est pas encore très avancé. La page de tout à l'heure suggérait une autre représentation, en "constellation". Allez, essayons ça :
plt.plot(np.real(iqs), np.imag(iqs), '.')
plt.show()
Ok ! On voit bien le module constant, et là on découvre que l'argument est discrétisé, et ne peut prendre que 20 valeurs. Ça doit être parce que la fréquence d'échantillonnage est un multiple de la fréquence d'émission. Bon, il est évident qu'il faut discrétiser ça :
discrete = np.array(np.round(10 * np.angle(iqs) / np.pi + 0.5), int)
print(discrete[:20])
# [ 3 4 5 6 7 8 9 10 -9 -8 -7 -6 -5 6 7 8 9 10 -9 -8]
C'est une suite qui a l'air d'être incrémentée de 1 en général, et parfois de nombres plus grands. Analysons donc les différences successives.
print(Counter(discrete[1:] - discrete[:-1]))
# Counter({1: 18228, -19: 1014, 11: 490, -9: 483})
Comme il semblait, la plupart du temps on a un incrément de 1. De
temps en temps on a un saut de -19 = 1 - 20
à cause du modulo, et
parfois on a des sauts de 11 = 1 + 10
ou -9 = 1 - 10
, qui sont
visiblement des inversions de phase. A priori, une différence de
11
ou de -9
, c'est la même chose modulo 20. Donc reprenons ça
proprement :
differences = (discrete[1:] - discrete[:-1] - 1) % 20
plt.plot(differences, '.-')
plt.show()
Bon, on a des périodes longues ou courtes en bas, et apparemment en
haut c'est toujours un seul point. On peut vérifier avec un groupby
ici aussi :
signals = [(value, len(list(group)))
for value, group in it.groupby(differences)]
print(Counter(signals))
# Counter({(10, 1): 973, (0, 15): 682, (0, 31): 290, (0, 12): 1, (0, 10): 1})
C'est correct. On pourrait être tenté de décoder ça avec un 0 pour les courtes (15) et un 1 pour les longues (31), ou inversement, mais là il faut lire l'énoncé, qui donne un indice : Manchester. On voit sur la page qu'en effet, un code Manchester est constitué de périodes courtes (une unité), et des longues (deux unités). On dirait qu'on est sur la bonne voie !
En réfléchissant un peu avec la figure de Wikipédia, je me rends compte qu'une période longue signifie qu'on change de symbole (0 à 1, ou 1 à 0), et deux périodes courtes (qui viennent toujours par deux) signifient qu'on ne change pas de symbole (si on était à 0, on reste à 0, et pareil pour 1). Avec cette technique, on ne sait pas de quel symbole on démarre, mais ce n'est pas grave : il n'y en a que deux (0 et 1), on peut essayer les deux si besoin.
data = [0]
i = 2
while i < len(signals):
if signals[i][1] > 20:
data.append(1 - data[-1])
i += 2
else:
data.append(data[-1])
i += 4
msg = ''.join(str(x) for x in data)
print(msg[100:200])
# 10110011100101100001011000100011001101100011001101
On a la suite de bits, qu'il faut maintenant transformer en texte :
s = bytes(int(msg[8*i:8*i+8], 2) for i in range(len(msg)//8))
text = s.decode('utf8', errors='ignore')
print(s)
# ƞɝƞƙʙʛƙȝΜϙȚϞʛǂ
Hmm. Ça n'a pas l'air d'être ça. Essayons d'inverser les 0 et les 1 :
msg = ''.join(str(1-x) for x in data)
s = bytes(int(msg[8*i:8*i+8], 2) for i in range(len(msg)//8))
text = s.decode('utf8', errors='ignore')
print(s)
# FCSC{9ab3c6bc...}
Et voilà un deuxième flag !
Zodiaque
C'était l'épreuve la plus difficile de cette catégorie. Je n'ai pas
réussi à la terminer pendant la compétition, mais en lisant des
writeups après coup je me suis rendu compte que j'étais vraiment très
proche, et j'ai juste eu à changer un seul nombre dans mon code pour
obtenir le flag. Même principe, on démarre encore avec un fichier
challenge.iq
.
Alors là, les parties réelle et imaginaires ne sont d'aucun
secours, le module est un peu bizarre, mais l'argument est clairement
intéressant. Il est discrétisé en semble-t-il quatre valeurs, qui
varient linéairement dans le temps. Je vois une discrétisation dans
un signal analogique, je suis content parce que ça sent le signal
numérique. Commençons par retirer cette dérive. On dérive de
2pi
en 1000 points de temps, donc on peut construire une fonction
linéaire qui a cette dérivée, modulo 2pi
, et la soustraire de
l'argument pour avoir des valeurs stables :
refs = (np.range(len(iqs)) * np.pi / 500) % (2*np.pi) - np.pi
plt.plot(np.angle(iqs) - refs)
plt.show()
C'est un peu moche parce qu'on a pris un modulo, mais c'est mieux pour discrétiser. En zoomant encore un peu, on se rend compte que c'est moins discret que ça en a l'air, et il y a en fait des points partout.
Il va falloir sous-échantillonner. Les transitions brutales semblent être des artefacts du modulo. Les maximums et minimums "doux" semblent plus fiables. On compte les points entre deux maximums ou minimums : c'est toujours un multiple de 8. Ça semble donc bien de prendre un point sur 8. Un peu de tâtonnement à la main plus tard pour trouver le bon offset :
On dézoome un peu pour vérifier que ça marche toujours :
Donc reprenons proprement :
subsample = (np.angle(iqs) - refs)[3::8]
discrete = np.array(np.round(2 * subsample / np.pi), int) % 4
print(discrete[:20])
# [2 1 0 2 2 0 1 1 3 3 3 2 0 0 3 0 2 0 1 2]
print(Counter(discrete))
# Counter({0: 4159, 1: 3053, 2: 2911, 3: 1976})
Il y a un peu plus de 0 et un peu moins de 3 que le reste, mais ce n'est
pas déraisonnable. J'ai envie de décoder ces quatre valeurs avec 00, 01, 10, 11
mais dans quel ordre ? Il n'y a que 24 possibilités,
essayons-les toutes. 1 On sait qu'il y a FCSC
dans le texte
donc servons-nous en.
En fait, il faudrait aussi idéalement itérer sur l'offset (4 valeurs possibles) parce qu'on ne sait pas si le premier symbole que l'on reçoit est au début d'un octet ou au milieu.
decode = ['00', '01', '10', '11']
for perm in it.permutations(decode):
msg = ''.join(perm[x] for x in discrete)
s = bytes(int(msg[8*i:8*i+8], 2) for i in range(len(msg)//8))
text = s.decode('utf8', errors='ignore')
if text.find('FCSC') != -1:
print(text)
Et voilà comment on obtient un troisième flag. C'était l'étape de sous-échantillonnage dont je n'avais pas réussi à trouver les paramètres pendant le concours.
Coda
J'ai eu de la chance que 15 lignes de Python suffisent pour ces épreuves cette année, mais pour en savoir plus je recommande fortement la lecture de writeups plus sérieux (exemple) où on apprend à faire les choses correctement.