Magazine

Test négatif d'une vérification de signature ou d'un contrôle d'intégrité

Publié le 24 janvier 2010 par Pbernard

Les signatures jouent un rôle important dans la sécurité des systèmes. Dans la carte à puce par exemple, la présentation d'une signature permettra de charger une nouvelle application ou de lire des données sensibles. Les contrôles d'intégrité ne sont pas en reste. Un problème d'intégrité peut être à l'origine d'une erreur : un fichier qui ne pourra plus être lu, un programme qui ne pourra plus être exécuté... Une défaillance dans l'un ou l'autre de ces domaines peut provoquer une faille de sécurité, une erreur impardonnable pour un système sensible.

Signature et contrôle d'intégrité

De quoi parle-t-on ? Pas d'algorithme spécifique. Dans ce post, point de spécificités de RSA, SHA-1 ou autre. Les signatures et les contrôles ne sont vus que par ce qu'ils ont en commun : des données à protéger, un algorithme quelconque et un sceau, qu'il soit authentifiant ou non.

Algorithme de signature ou de contrôle d'intégrité

Quel que soit l'algorithme utilisé, le principe même d'une signature ou d'un contrôle est que le sceau dépend étroitement des données qu'il protège. A deux données proches mais distinctes correspondent deux sceaux complètement différents.

La signature et le contrôle d'intégrité comportent deux phases :

  • Génération : Les données à protéger sont traitées par l'algorithme et un sceau est généré.
  • Vérification : Le système reçoit les données à vérifier ainsi que le sceau correspondant. Le système régénère le sceau à partir des données et le compare au sceau qu'il a reçu. S'ils diffèrent, il y a un problème quelque part... et les données doivent être rejetées.

Comment tester la vérification ?

L'importance du test négatif

Bien souvent, on attend avant tout d'un système qu'il gère les cas nominaux :

  • Le système interprète correctement la quantité saisie dans le champ "Quantité".
  • L'impression d'un document fonctionne convenablement.
  • ...

Viennent ensuite les cas d'erreur :

  • Le système affiche un message d'erreur lorsque la quantité saisie est négative.
  • L'option d'impression affiche un message spécial lorsqu'il n'y a pas d'imprimante.
  • ...

Ce n'est pas que les cas d'erreur ne soient pas importants, mais à choisir, il vaut mieux qu'on puisse choisir une quantité et imprimer.

Dans le cas d'une vérification d'intégrité ou de signature, le cas d'erreur est aussi important que le cas nominal :

  • Cas nominal : Le système vérifie des données valides. Si cette vérification ne fonctionne pas (dans le sens où elle est buggée), le système va déclarer de faux négatifs, c'est à dire qu'il va rejeter des données pourtant valides. Un problème à ce niveau est bloquant : il y a de bonnes chances pour que rien ne fonctionne, puisque la vérification d'intégrité ou d'authenticité est souvent un préalable à tout autre traitement.
  • Cas d'erreur : Le système vérifie des données invalides. Si la vérification est buggée à ce niveau, le système va déclarer des faux positifs, en acceptant des données corrompues comme si elles étaient valides. Dans cette situation, la vérification échoue dans sa mission de détecter les erreurs. Plus que cela, c'est certainement tout le système qui est compromis, spécialement dans le cas d'une signature. Pour moi qui exerce dans le domaine des cartes à puce, c'est l'un des bugs les plus graves qui puisse survenir.

De plus, le cas nominal est généralement testé par effet de bord. Si le système exige une authentification avant toute autre action, alors la majorité des tests enverront des signatures correctes de sorte à atteindre "l'étape suivante". Pas de seconde chance en revanche pour le cas négatif : rien de viendra remplacer un test explicite.

Assurer que la vérification est faite

Le bug de vérification le plus grossier est... l'absence de vérification. Aussi gros que ce bug puisse être, il est assez plausible :

...
dataToProcess = receiveData();
// TODO: restaurer cette ligne quand l'authentification
// marchera !
//if (!authenticate(dataToProcess)) {
//  throw Exception("Authentication failed!");
//}
processData(dataToProcess);
...

Ou carrément :

...
dataToProcess = receiveData();
processData(dataToProcess);
...

(il n'y a rien à voir : l'authentification a été purement et simplement oubliée)

Non seulement le bug est possible, mais il peut tout à fait passer inaperçu. La vérification d'intégrité ou d'authenticité fait partie de ces fonctionnalités qui ne se manifestent normalement pas, sauf en cas de problème. De ce fait on peut tout à fait passer à côté de ce bug critique.

En soit, tester la vérification est simple. Il suffit d'envoyer un mauvais sceau au système :

data = {1, 2, 3};
seal = generateCorrectSeal(data);
// Ce sceau est correct et accepté par le système
assertTrue(verifySeal(data, seal));
// On corrompt le sceau en inversant tous les bits
for (int i = 0; i < seal.length; i++) {
  seal[i] = seal[i] ^ 0xFF;
}
// Ce sceau corrompu devrait être rejeté
assertFalse(verifySeal(data, seal));

Ce test fait l'affaire. Mais attention à ne pas écrire un test faux !

Dans cet exemple, le test est écrit comme un test unitaire, qui cible un point d'entrée du système, verifySeal. En réalité, ce test est typiquement un candidat pour les tests système. Afin d'éviter le bug de la vérification qui n'est pas faite, on doit faire l'essai sur le système entier et non sur une de ses parties, qui peut justement être présente mais non-sollicitée.

Assurer que la vérification est complète

Un second bug peut affecter la vérification : elle peut être incomplète. Par exemple :

public boolean verifySeal(data, seal) {
  expecedSeal = generateSeal(data);
  // Le sceau est un hash SHA-1
  return compare(expectedSeal, seal, 16);
}

verifySeal génère le sceau qui devrait normalement accompagner les données et le compare au sceau reçu. Le bug se situe dans la longueur de la comparaison. Si le sceau est le résultat d'un hash SHA-1, alors sa longueur est de 20 octets et non 16. La conséquence : les 4 derniers octets du sceau ne sont pas contrôlés. Cela affaiblit la vérification. Dans le cas d'une signature, le bug facilite une attaque force brute.

Le test :

data = {1, 2, 3};
seal = generateCorrectSeal(data);
// Ce sceau est correct et accepté par le système
assertTrue(verifySeal(data, seal));
// On corrompt chaque octet du sceau
for (int i = 0; i < 20; i++) {
  // On corrompt chaque bit de l'octet
  for (int bit = 0; bit < 8; bit++) {
    // On inverse le bit testé
    seal[i] = seal[i] ^ (1 << bit);
    // Ce sceau corrompu devrait être rejeté
    assertFalse(verifySeal(data, seal));
    // On restaure le bit testé
    seal[i] = seal[i] ^ (1 << bit);
  }
}
// Afin de d'éviter un bug de test, on vérifie qu'en
// fin de test seal contient à nouveau le sceau
// attendu
assertTrue(verifySeal(data, seal));

Assurer que toutes les données sont vérifiées

La vérification peut être incomplète en ne portant que sur une partie du sceau. On peut imaginer une bug similaire sur les données elles-même. Il suffit d'une erreur de bornes pour qu'une partie des données ne soient pas impliquées dans la génération du sceau.

Un tel bug sera détecté sans qu'un test spécifique soit nécessaire. D'un côté, un test prouve déjà que la vérification est effectuée. De l'autre, il y a forcément de nombreux cas positifs engendrés par d'autres tests qui veulent simplement "passer" la phase de vérification pour atteindre d'autres fonctionnalités du système. La combinaison de ces deux éléments permettent de s'assurer que les sceaux sont correctement calculés par le système. La nature de ces sceaux, qu'ils soient CRC, hash ou signatures, fait qu'on sait que toutes les données sont prises en compte dans la vérification : si, disons, le dernier octet des données était systématiquement ignoré, le sceau généré serait très différent de celui attendu et généré par les tests.


Retour à La Une de Logo Paperblog