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()
En zoomant un peu, ça ressemble à un signal
En zoomant un peu, ça ressemble à un signal.

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 c'est un peu moins clair
Cette fois-ci c'est un peu moins clair.

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()
Ouah ! Alors ça c'est joli
Ouah ! Alors ça c'est joli.

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()
Et voilà le signal
Et voilà le signal.

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.

L'argument semble intéressant
L'argument semble intéressant.

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()
On voit mieux les valeurs discrétisées
On voit mieux les valeurs discrétisées.

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.

Pas si discret que ça
Pas si discret que ça.

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 :

Ça semble correct
Ça semble correct.

On dézoome un peu pour vérifier que ça marche toujours :

Ça commence à ressembler à quelque chose
Ça commence à ressembler à quelque chose.

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.

1

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.