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) :
= np.fromfile('challenge.iq', np.complex64)
iqs 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.
= plt.subplots(2, 2, sharex=True)
fig, 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))
axs[ 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 :
= np.abs(iqs) > 0.75 discrete
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
:
= [(value, len(list(group)))
signals 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 :
= Counter(l for v, l in signals if v)
sizesT = Counter(l for v, l in signals if not v)
sizesF 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 :
= ''.join(('.' if l < 300 else '-') if v else (' ' if l > 2000 else '')
m 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 :
= np.array(np.round(10 * np.angle(iqs) / np.pi + 0.5), int)
discrete 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 :
= (discrete[1:] - discrete[:-1] - 1) % 20
differences '.-')
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 :
= [(value, len(list(group)))
signals 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.
= [0]
data = 2
i while i < len(signals):
if signals[i][1] > 20:
1 - data[-1])
data.append(+= 2
i else:
-1])
data.append(data[+= 4
i = ''.join(str(x) for x in data)
msg print(msg[100:200])
# 10110011100101100001011000100011001101100011001101
On a la suite de bits, qu’il faut maintenant transformer en texte :
= bytes(int(msg[8*i:8*i+8], 2) for i in range(len(msg)//8))
s = s.decode('utf8', errors='ignore')
text print(s)
# ƞɝƞƙʙʛƙȝΜϙȚϞʛǂ
Hmm. Ça n’a pas l’air d’être ça. Essayons d’inverser les 0 et les 1 :
= ''.join(str(1-x) for x in data)
msg = bytes(int(msg[8*i:8*i+8], 2) for i in range(len(msg)//8))
s = s.decode('utf8', errors='ignore')
text 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 :
= (np.range(len(iqs)) * np.pi / 500) % (2*np.pi) - np.pi
refs - refs)
plt.plot(np.angle(iqs) 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 :
= (np.angle(iqs) - refs)[3::8]
subsample = np.array(np.round(2 * subsample / np.pi), int) % 4
discrete 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.
= ['00', '01', '10', '11']
decode for perm in it.permutations(decode):
= ''.join(perm[x] for x in discrete)
msg = bytes(int(msg[8*i:8*i+8], 2) for i in range(len(msg)//8))
s = s.decode('utf8', errors='ignore')
text 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.
Footnotes
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.↩︎