Fuzzing
Bonjour à toutes et à tous 😀
Le TDD est une excellente manière de créer du logiciel que l'on a envie de modifier.
Mais d'un autre côté c'est aussi un moyen de provoquer des erreurs en étant trop sûr de soi et de son code.
A la main
En effet le TDD et les test cases qui lui sont associés ne sont que le reflets de ce que l'on connaît ou croit connaître du comportement de notre logiciel.
En d'autres termes, le reflet de notre Ignorance. 🙈
Par exemple ce code
Et voici les test cases associés
Bon c'est vert, c'est cool 😁
Mais voici que vient cette valeur en production.
hell
Vous vous dites, c'est insignifiant.
Et bien essayons ^^
Boom 💥
index out of bounds: the len is 4 but the index is 4
thread 'wrong2' panicked at 'index out of bounds: the len is 4 but the index is 4'
Votre code panic, et oui, même avec du Rust, si on fait n'importe quoi, il arrive également nimporte quoi ^^
La question est donc : "Comment trouver les test cases qui font casser le code ?".
Et de préférence avant d'arriver en production ^^
Pour cela, nous allons réfléchir à l'envers.
Pour commencer, on découpe notre if en 5 if et on inverse la condition.
Ce code à le même comportement que l'autre, essayez vous verrez.
La deuxième chose que l'on va faire c'est mettre en place un second paramètre qui va compter le nombre de lignes exécutées.
Si on se fait une petite fonction de test
On obtient
toto => 2
pas hello => 2
hallo => 3
helro => 5
hella => 6
hello => 7
On voit que plus l'on se rapproche du "hello" plus on se rapproche également du "hell" 😈
Une heuristique peut donc de choisir aléatoirement un caractères et venir tester son counter
.
S'il augmente, c'est que nous sommes en train de "descendre" dans le code, comme nous avons deux objectifs:
- couvrir le plus de code possible
- détecter les entrées qui font bugger
Plus on descendra profondément dans le code, plus on sera à même de détecter ces bugs.
Donc c'est parti!
On choisi un caractère aléatoire et on lance.
Pour cela, je me suis créé une petite fonction utilitaire utilisant la crate rand
cargo add rand
En fonction des lancé de dés, vous aurez soit une erreur de panic soit un succès
Pour le rendre plus prédictible, nous allons seeder, c'est à dire rendre l'aléatoire moins aléatoire ^^
Et surtout permettre que vous ayez les mêmes exemples que moi. 😀
Installons un algo plus prédictible.
cargo add rand_chacha
La seed 1 donne:
data : i
i => 2
data : q
q => 2
data : q
q => 2
data : b
b => 2
data : o
o => 2
data : m
m => 2
data : l
l => 2
data : u
u => 2
data : e
e => 2
data : j
j => 2
La seed 5, une panic :
data : q
q => 2
data : h
index out of bounds: the len is 1 but the index is 1
Il est donc temps de corriger le code ^^
fn is_hello(data: &str, counter: &mut usize) -> bool {
let data = data.as_bytes();
*counter += 1;
if data[0] != b'h' {
*counter += 1;
return false;
}
*counter += 1;
- if data[1] != b'e' {
+ if data.len() < 2 || data[1] != b'e' {
*counter += 1;
return false;
}
*counter += 1;
// snip....
*counter += 1;
true
}
Ainsi cette fois-ci la seed 5 ne casse plus.
data : q
q => 2
data : h
h => 3
data : v
v => 2
data : x
x => 2
data : v
v => 2
data : n
n => 2
data : t
t => 2
data : p
p => 2
data : y
y => 2
data : r
r => 2
Ce que l'on observe c'est que seul la data='h'
atteint un compteur de $3$
Essayons de recasser notre code ^^
fn random_data<R: RngCore>(mut rng: &mut R) -> usize {
- let chars = 'a'..='z';
+ let chars = ('a'..='z').collect::<Vec<char>>();
- let data = chars
- .choose(&mut rng)
- .expect("Range is not empty")
- .to_string();
// on attribues un poids de 26 à h car on veut qu'il soit plus présent que les autres lettres
+ let weights = HashMap::from([('h', 100_f64)]);
+ let data = chars
// toutes les lettres qui ne sont pas des 'h' ont un poids de 1
+ .choose_multiple_weighted(&mut rng, 2, |x| *weights.get(x).unwrap_or(&1_f64))
+ .expect("Should get data")
+ .map(|x| x.to_string())
+ .collect::<Vec<String>>()
+ .join("");
// snip ....
}
Finalement si on execte ce nouveau code en seed 5, on aura:
data : bh
bh => 2
data : hg
hg => 3
data : hp
hp => 3
data : hx
hx => 3
data : mh
mh => 2
data : my
my => 2
data : hx
hx => 3
data : hz
hz => 3
data : hv
hv => 3
data : bh
bh => 2
Là également on observe que seule les data qui commencent par "h" ont un compteur de 3.
Trouvons une seed qui pète
for seed in 0..100
Cela nous donne la "seed=49".
Si on affiche la data en question, on tombe bien sur data="he"
.
Corrigions le code
fn is_hello(data: &str, counter: &mut usize) -> bool {
let data = data.as_bytes();
*counter += 1;
// snip ...
- if data[2] != b'l' {
+ if data.len() < 3 || data[2] != b'l' {
*counter += 1;
return false;
}
*counter += 1;
- if data[3] != b'l' {
+ if data.len() < 4 || data[3] != b'l' {
*counter += 1;
return false;
}
*counter += 1;
if data[4] != b'o' {
*counter += 1;
return false;
}
*counter += 1;
*counter += 1;
true
}
On a donc "corrigé" code maintenant la vrai question c'est comment faire avancer notre recherche d'erreur.
Alors oui, on pourrait le faire à la main mais de un, j'ai des choses plus palpitantes à faire comme ranger mes chaussettes par exemple, et de deux, nous allons retomber dans le travers du TDD.
S'appuyer sur ce qu'on connait pour déterminer les edge-cases.
Nan, on va automatiser tout ça et laisser la froide logique des robots décider si nous sommes bons développeurs/développeuses ou non. 😄
cargo-fuzzing
Pour cela, nous allons utiliser le Fuzzing, oui c'est le titre de l'article pour les personnes qui l'auraient oublié 🤣
Installons la CLI
cargo install cargo-fuzz
Cela fourni une extension à cargo permettant de gérer toute la partie fuzzing.
Dont un initialiseur
cargo fuzz init
Ceci a pour action de créer un dossier fuzz
à la racine du projet.
root/
├─ fuzz/
│ ├─ fuzz_targets/
│ │ ├─ fuzz_target_1.rs
│ ├─ Cargo.toml
├─ src/
│ ├─ lib.rs
├─ Cargo.toml
Notre projet ressemble grosso-modo à ça.
Dans le fichier root/fuzz/fuzz_targets
, nous avons.
use fuzz_target;
fuzz_target!;
Quasiment rien...
On a pas de main, ni de moyen d'exécuter quoi que ce soit.
A part par la CLI.
cargo fuzz run fuzz_target_1
Et là problème ...
error: the option `Z` is only accepted on the nightly compiler
Bon qu'à cela ne tienne
rustup default nightly
RIP .. ☠
"--target" "x86_64-pc-windows-msvc"
error: address sanitizer is not supported for this target
Bon, il n'aime pas windows.
Mais ce n'est pas un problème. On bascule en wsl ^^
Cette fois-ci ça se lance ! 😍
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 46791055
INFO: Loaded 1 modules (1854 inline 8-bit counters): 1854 [0x55ed69e7b3a0, 0x55ed69e7bade),
INFO: Loaded 1 PC tables (1854 PCs): 1854 [0x55ed69e7bae0,0x55ed69e82ec0),
INFO: 0 files found in /mnt/d/Lab/Programmation/lab/fuzz-lab/fuzz/corpus/fuzz_target_1
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: A corpus is not provided, starting from an empty corpus
#2 INITED cov: 14 ft: 15 corp: 1/1b exec/s: 0 rss: 40Mb
#3 NEW cov: 14 ft: 16 corp: 2/2b lim: 4 exec/s: 0 rss: 41Mb L: 1/1 MS: 1 ChangeBit-
#4194304 pulse cov: 14 ft: 16 corp: 2/2b lim: 4096 exec/s: 1398101 rss: 358Mb
#8388608 pulse cov: 14 ft: 16 corp: 2/2b lim: 4096 exec/s: 1398101 rss: 621Mb
#16777216 pulse cov: 14 ft: 16 corp: 2/2b lim: 4096 exec/s: 1290555 rss: 624Mb
#33554432 pulse cov: 14 ft: 16 corp: 2/2b lim: 4096 exec/s: 1342177 rss: 625Mb
Et on comprend rien 🤣
Essayons de voir ce que data
vaut:
fuzz_target!;
Et ça affiche:
// ...
data : 3
data : 3
data : 3
data : �3
data : D+
data : DD+
data : DD+
data : dD+
data : Dd+
data : �A
data : A�
data : Ar�
data : A�
data : A�
data : ��
data : ��
// ...
Des trucs aléatoires !
Ne pourrait-on pas mettre ça dans notre méthode is_hello
et voir ce qui se passe? ^^
use is_hello;
fuzz_target!;
Et bien ce fut rapide !
thread '<unnamed>' panicked at /mnt/d/Lab/Programmation/lab/fuzz-lab/src/lib.rs:5:8:
index out of bounds: the len is 0 but the index is 0
Et oui, il manque un check: la chaîne vide !
Il est d'ailleurs possible de voir la données en erreur.
Lors du fuzz, un dossier a été créé
fuzz/
├─ artifacts/
│ ├─ fuzz_target_1/
│ │ ├─ crash-<HASH>
├─ fuzz_targets/
│ ├─ fuzz_target_1.rs
├─ Cargo.toml
Dedans un fichier vide crash-<HASH>
, le hash peut varier.
Ceci est notre test-case en erreur.
Bien corrigeons le code de is_hello
.
pub fn is_hello(data: &str, counter: &mut usize) -> bool {
let data = data.as_bytes();
*counter += 1;
- if data[0] != b'h' {
+ if data.len() < 1 || data[0] != b'h' {
*counter += 1;
return false;
}
// ...
}
Et on relance !
On sent dans le mouvement qu'il est en train de converger ^^
data : hell �
hell => 6
data : hell �
hell => 6
data : hele �
hele => 5
data : hel
hel => 5
data : hel
hel => 5
data : hll
hll => 3
data : hll
hll => 3
Et finalement
data : hell
thread '<unnamed>' panicked at /mnt/d/Lab/Programmation/lab/fuzz-lab/src/lib.rs:29:8:
index out of bounds: the len is 4 but the index is 4
Failing input:
fuzz/artifacts/fuzz_target_1/crash-a5cec7af5f7aab769cf0d4aa440e01c7bfc371b2
Output of `std::fmt::Debug`:
[104, 101, 108, 108]
Reproduce with:
cargo fuzz run fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-a5cec7af5f7aab769cf0d4aa440e01c7bfc371b2
Minimize test case with:
cargo fuzz tmin fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-a5cec7af5f7aab769cf0d4aa440e01c7bfc371b2
Il a trouvé !
Et mieux que ça, il a mis le test-case dans un dossier de crash.
Et son contenu est :
hell
Il nous donne une commande pour reproduire le bug:
cargo fuzz run fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-a5cec7af5f7aab769cf0d4aa440e01c7bfc371b2
Et une autre pour minimiser celui-ci.
cargo fuzz tmin fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-a5cec7af5f7aab769cf0d4aa440e01c7bfc371b2
Même si ici le code minimal est déjà celui trouvé
CRASH_MIN: failed to minimize beyond fuzz/artifacts/fuzz_target_1/crash-a5cec7af5f7aab769cf0d4aa440e01c7bfc371b2 (4 bytes), exiting
Car le seul data de 4 caractères qui peut casser le code est "hell".
Et du coup comment ça marche ?
Et bien un peu comme le système à la main, sauf, qu'au lieu de compter à la main les lignes on utilise le coverage du code qui est bien plus efficace pour déterminer les chemins du code.
Ensuite la génération des données est un poil plus maline que la mienne et surtout bien plus adaptative. 😁
Ce qui implique que l'on peut simplifier notre méthode.
Simplifier notre fuzzer
fuzz_target!;
Donc voilà, vous savez tout du fuzzer ^^
L'idée est maintenant que dans vos CI en test de non régression, vous nourissiez vos assertions avec les edge-cases qui ont été trouvé par fuzzing.
Arbitrary
Ok, mais c'est pas un peu limité le fait que data : &[u8]
ne soit que des bytes.
On s'est bien démerdé avec la chaîne de caractères, mais qu'est ce qui se passe si par exemple nos données devienne :
Pour l'occasion, nous allons nous créer une seconde target.
Dans le dossier fuzz_targets
, créer un fichier fuzz_target_2.rs
.
Dans le Cargo.toml
, rajouter les lignes
[[]]
= "fuzz_target_2"
= "fuzz_targets/fuzz_target_2.rs"
= false
= false
Dans le fuzz_target_2.rs
, écrire
use ;
use fuzz_target;
fuzz_target!;
On force le Color::Red
, sinon ça ne crashera jamais ^^'
Il nous réaffiche "hell" comme erreur.
Mais nous on lui a demandé un Data
.
Or on continue à obtenir des bytes en test case.
C'est parce qu'il nous manque une pièce de puzzle.
Il s'agit de la crate arbitrary, elle permet de génrer tout ce qu'on désire ! 😎
Pour cela on l'installe, avec ses dérivations
cargo add arbitrary -F derive
Et ensuite on décore notre code 🙂
use Arbitrary;
On modifie le fuzzer:
fuzz_target!;
Et let's go !
Failing input:
fuzz/artifacts/fuzz_target_2/crash-5297375f02b2430ee140ee317afd53bbce8e0c64
Output of `std::fmt::Debug`:
Data {
color: Red,
sentence: "hell",
}
On a bien l'objet complet qui a foiré !
Conclusion
Le fuzzing c'est vraiment l'apprentissage de l'humilité.
Tout les codes sont buggés à moins d'avoir été suffisamment maltraités et ne pas avoir cassé.
La possibilité de pouvoir explorer toutes les possibilité de données d'un code est juste hallucinante !
Et comme on est en Rust, tout est d'une facilité et d'une commodité incroyable 🥰
Le fait de pouvoir tout rejouer est aussi épatant !
J'espère que vous avez apprécié cette petite introduction à ce concept qui m'était inconnu il n'y a pas 1 mois. ^^
Merci de votre lecture ❤️
Ce travail est sous licence CC BY-NC-SA 4.0.