Fuzzing
Lecture 6 min. âą
Table des matiĂšres
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
fn is_hello(data: &str) -> bool {
let data = data.as_bytes();
if data[0] == b'h' &&
data[1] == b'e' &&
data[2] == b'l' &&
data[3] == b'l' &&
data[4] == b'o' {
return true;
}
false
}Et voici les test cases associés
#[test]
fn right() {
assert!(is_hello("hello"))
}
#[test]
fn wrong() {
assert!(!is_hello("pas hello"))
}Bon câest vert, câest cool đ
Mais voici que vient cette valeur en production.
hellVous vous dites, câest insignifiant.
Et bien essayons ^^
#[test]
fn wrong2() {
assert!(!is_hello("hell"))
}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.
fn is_hello(data: &str) -> bool {
let data = data.as_bytes();
if data[0] != b'h' {
return false
}
if data[1] != b'e' {
return false
}
if data[2] != b'l' {
return false
}
if data[3] != b'l' {
return false
}
if data[4] != b'o' {
return false
}
true
}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.
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;
}
if data[1] != b'e' {
*counter += 1;
return false;
}
if data[2] != b'l' {
*counter += 1;
return false;
}
if data[3] != b'l' {
*counter += 1;
return false;
}
if data[4] != b'o' {
*counter += 1;
return false;
}
*counter+=1;
true
}Si on se fait une petite fonction de test
fn format(data: &str) {
let mut counter = 0;
is_hello(data, &mut counter);
println!("{data} => {counter}");
}
#[test]
fn test() {
format("toto");
format("pas hello");
format("hallo");
format("helro");
format("hella");
format("hello");
}On obtient
toto => 2
pas hello => 2
hallo => 3
helro => 5
hella => 6
hello => 7On 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 randfn random_data(mut rng: &mut ThreadRng) -> usize {
let chars = 'a'..='z';
let data = chars
.choose(&mut rng)
.expect("Range is not empty")
.to_string();
let mut counter = 0;
is_hello(&data, &mut counter);
println!("{data} => {counter}");
counter
}En fonction des lancé de dés, vous aurez soit une erreur de panic soit un succÚs
#[test]
fn random() {
let mut rng = rand::thread_rng();
for i in 0..10 {
random_data(&mut rng);
}
}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_chachafn random_data<R: RngCore>(mut rng: &mut R) -> usize {
let chars = 'a'..='z';
let data = chars
.choose(&mut rng)
.expect("Range is not empty")
.to_string();
println!("data : {data}");
let mut counter = 0;
is_hello(&data, &mut counter);
println!("{data} => {counter}");
counter
}
#[test]
fn random() {
let seed = 1;
let mut rng = ChaCha20Rng::seed_from_u64(seed);
for i in 0..10 {
random_data(&mut rng);
}
}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 => 2La seed 5, une panic :
data : q
q => 2
data : h
index out of bounds: the len is 1 but the index is 1Il 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 => 2Ce 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 => 2LĂ Ă©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 {
println!("seed : {seed}");
let mut rng = ChaCha20Rng::seed_from_u64(seed);
random_data(&mut rng);
}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-fuzzCela fourni une extension à cargo permettant de gérer toute la partie fuzzing.
Dont un initialiseur
cargo fuzz initCeci 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.tomlNotre projet ressemble grosso-modo à ça.
Dans le fichier root/fuzz/fuzz_targets, nous avons.
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
// fuzzed code goes here
});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_1Et lĂ problĂšme âŠ
error: the option `Z` is only accepted on the nightly compilerBon quâĂ cela ne tienne
rustup default nightlyRIP .. â
"--target" "x86_64-pc-windows-msvc"
error: address sanitizer is not supported for this targetBon, 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: 625MbEt on comprend rien đ€Ł
Essayons de voir ce que data vaut:
fuzz_target!(|data: &[u8]| {
println!("data : {}", String::from_utf8_lossy(data));
});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 fuzz_lab::is_hello;
fuzz_target!(|data: &[u8]| {
// on converti les bytes en caractĂšres
let data_tested = String::from_utf8_lossy(data);
println!("data : {}", data_tested);
let mut counter = 0;
is_hello(&data_tested, &mut counter);
println!("{data_tested} => {counter}");
});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 0Et 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.tomlDedans 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 => 3Et 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 :
hellIl nous donne une commande pour reproduire le bug:
cargo fuzz run fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-a5cec7af5f7aab769cf0d4aa440e01c7bfc371b2Et une autre pour minimiser celui-ci.
cargo fuzz tmin fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-a5cec7af5f7aab769cf0d4aa440e01c7bfc371b2MĂȘ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), exitingCar 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.
pub fn is_hello(data: &str) -> bool {
let data = data.as_bytes();
if data.is_empty() || data[0] != b'h' {
return false;
}
if data.len() < 2 || data[1] != b'e' {
return false;
}
if data.len() < 3 || data[2] != b'l' {
return false;
}
if data.len() < 4 || data[3] != b'l' {
return false;
}
if data[4] != b'o' {
return false;
}
true
}Simplifier notre fuzzer
fuzz_target!(|data: &[u8]| {
let data_tested = String::from_utf8_lossy(data);
is_hello(&data_tested);
});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 :
enum Color {
Blue,
Red,
Green
}
struct Data<'a>' {
color: Color,
sentence: &'a str,
}
pub fn is_hello_red(data: Data) -> bool {
if let Color::Red = data.color {
return is_hello(data.sentence);
}
false
}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
[[bin]]
name = "fuzz_target_2"
path = "fuzz_targets/fuzz_target_2.rs"
test = false
doc = falseDans le fuzz_target_2.rs, écrire
#![no_main]
use fuzz_lab::{is_hello_red, Color, Data};
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let data_tested = String::from_utf8_lossy(data);
let data = Data {
color: Color::Red,
sentence: &data_tested,
};
is_hello_red(data);
});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 deriveEt ensuite on dĂ©core notre code đ
use arbitrary::Arbitrary;
#[derive(Arbitrary, Debug)]
pub enum Color {
Blue,
Red,
Green,
}
#[derive(Arbitrary, Debug)]
pub struct Data<'a> {
pub color: Color,
pub sentence: &'a str,
}On modifie le fuzzer:
fuzz_target!(|data: Data| {
is_hello_red(data);
});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 â€ïž
