web-dev-qa-db-fra.com

Comment puis-je empêcher les attaques par canal latéral contre l'authentification?

Après avoir lu this excellente réponse, j'ai appris l'existence des attaques par canal latéral.

À partir de l'exemple de code fourni, il est possible de déterminer le mot de passe correct en chronométrant le code lorsqu'il reçoit diverses entrées.

for (i = 0; i < n; i++) {
  if (password[i] != input[i]) {
    return EFAIL;
  }
}

Que puis-je faire pour m'assurer que mon code n'est pas vulnérable à de telles attaques de synchronisation? J'ai délibérément laissé cette question ouverte pour permettre aux réponses de fournir des exemples et les meilleures pratiques pour une variété de configurations logicielles courantes.

15
dalearn

À partir de l'exemple de code fourni, il est possible de déterminer le mot de passe correct en chronométrant le code lorsqu'il reçoit diverses entrées.

Tout d'abord, vous ne devriez pas réellement examiner le mot de passe directement! Au niveau au moins, vous devez hacher le mot de passe avec un hachage de mot de passe comme Argon2id en premier, et comparez le hachage de mot de passe de l'entrée avec le hachage de mot de passe que vous avez stocké lors de l'inscription de l'utilisateur (ou lorsque l'utilisateur a modifié son mot de passe pour la dernière fois).

Encore mieux, vous devriez utiliser un protocole d'accord de clé authentifié par mot de passe comme OPAQUE, mais ceux-ci peuvent dépasser votre niveau de rémunération pour le moment jusqu'à ce qu'ils voient l'adoption et la mise en œuvre plus répandues.

Que puis-je faire pour m'assurer que mon code n'est pas vulnérable à de telles attaques de synchronisation?

La meilleure façon de commencer est d'utiliser une routine de bibliothèque ou une primitive que quelqu'un d'autre a déjà écrit et a une raison de maintenir. Par exemple, dans NaCl/libsodium, vous pouvez utiliser crypto_verify_32 Pour comparer deux chaînes de 32 octets, comme deux hachages Argon2id ou deux codes d'authentification de message HMAC-SHA256. Ensuite, l'effort pour répondre à cette question peut être concentré sur un seul endroit qui recevra beaucoup d'attention et d'examen et suivra les progrès.

Mais disons que vous n'avez pas crypto_verify_32, Ou que vous voulez l'implémenter vous-même. Que pouvez-vous faire?

Pour commencer, vous devez comprendre quelles opérations ont des canaux secondaires. C'est tentant pour dire — comme d'autres réponses l'ont fait— que le canal latéral survient uniquement à cause d'un abandon précoce. Mais ce n'est pas toute l'histoire. En général, il y a de nombreuses opérations (ici écrit en C pour illustration) qui peut prendre un certain temps qui dépend des valeurs des entrées - nous appeler ces opérations opérations à temps variable , contrairement à à temps constant *:

  • for (i = 0; i < n; i++) if (x[i] == y[i]) return EFAIL; prend évidemment moins d'itérations de boucle il est donc pratiquement garanti de fonctionner en temps variable selon les valeurs secrètes de x[i] et y[i] .

  • Une simple fonction conditionnelle dépendante du secret for (i = 0; i < n; i++) if (x[i]) bad++;, si x[i] Est secret, peut également s'exécuter en temps variable même si la boucle n'abandonne pas tôt. Pourquoi?

    • Voici une approximation grossière. Les instructions de la machine que le CPU peut exécuter ressemblent à ceci:

      0:      tmp := x[i]
              branch to 1 if tmp is zero
              bad := bad + 1
      1:      i := i + 1
              branch to 0 if i < n
      

      Le nombre d'instructions exécuté dépend de la valeur de x[i] À chaque itération: nous sautons bad := bad + 1 Sur certaines itérations. Ceci est un bon modèle pour la façon dont les premières attaques temporelles sur, - par exemple, RSA a fonctionné comme dans l'article séminal de Kocher sur les attaques temporelles : la boucle d'exponentiation modulaire principale calcule un (disons ) Modulation quadratique 2048 bits inconditionnelle, mais calcule une multiplication modulaire 2048 bits conditionnellement selon la valeur de l'exposant secret. Ignorer la multiplication modifie considérablement le temps pris par toute l'opération.

    • Il y a une autre raison, cependant, et cela a à voir avec prédiction de branche , un élément clé de la conception qui fait que les processeurs modernes fonctionnent si rapidement sur de nombreuses charges de travail, même si vous écrivez la même quantité de code (par exemple, même nombre d'instructions machine, et vous garantissez en quelque sorte qu'elles prennent le même nombre de cycles à calculer) dans chaque branche d'un conditionnel, le temps qu'il faut pour exécuter peut dépendre de la façon dont la condition s'est déroulée.

    • En général, les CPU ne savent pas garder quelles instructions ont été exécutées secrètes, donc ne faites pas dépendre les choix des instructions des secrets.

  • Les recherches de table/tableau peuvent prendre un temps différent selon la mémoire qui a été mise en cache dans le cache du processeur. Par conséquent, si l'emplacement dans le tableau à partir duquel vous lisez dépend d'un secret, le temps qu'il faut peut dépendre du secret, qui a été exploité pour récupérer les clés AES par synchronisation du cache .

    (Cela rend AES une conception plutôt discutable rétrospectivement, avec son utilisation intentionnelle des recherches de table dépendantes de clés! NIST justification publiée ( §3.6.2, Attaques sur les implémentations: le rôle des opérations) ) curieusement, les recherches dans les tableaux ne sont "pas vulnérables aux attaques de synchronisation" malgré les nombreuses attaques de ce type signalées depuis.)

  • Le décalage à distance variable comme x = y << z Peut prendre plus de temps sur certains processeurs si z est plus grand et moins de temps s'il est plus petit.

    (Cela rend RC5 et le finaliste AES RC6 un design plutôt discutable rétrospectivement, avec leur utilisation intentionnelle des distances de rotation dépendantes des touches!)

  • Sur certains processeurs, la multiplication peut s'exécuter plus rapidement ou plus lentement selon que la moitié supérieure des entrées est nulle ou non.

  • L'ajout d'entiers 64 bits sur les processeurs 32 bits peut en principe prendre plus de temps selon qu'il y a un report. En effet, lorsque x, y et z sont des entiers 64 bits, la logique x = y + z Pourrait ressembler à quelque chose de plus:

    int carry = 0;
    x[0] = y[0] + z[0];
    if (the previous addition overflowed)
      carry = 1;
    x[1] = y[1] + z[1] + carry;
    

    Par conséquent, le temps qu'il faut peut dépendre de la présence ou non d'un report de la somme des moitiés basses 32 bits à la somme des moitiés hautes 32 bits. (Dans la pratique, cela ne concerne généralement que les processeurs exotiques ou d'autres types de canaux secondaires, comme l'analyse de l'alimentation, qui concernent davantage les cartes à puce que les ordinateurs portables et les téléphones.)

Cela peut sembler un peu écrasant. Que pouvons-nous faire?

Il existe certaines opérations qui le font généralement exécutées en temps constant sur la plupart des CPU. Ce sont:

  • Opérations au niveau du bit : x & y, x | y, x ^ y, ~x Et d'autres qui n'apparaissent pas en C comme ET avec complément.
  • Distance constante changements et rotations comme le décalage x << 3 Ou la rotation x <<< 3 (pas C standard mais commun en cryptographie; cela signifie (x << 3) | (x >> (32 - 3)), si x est 32 bits).
  • Souvent addition et soustraction d'entiers : x + y, x - y, Lorsque x et y sont (disons) des entiers 32 bits non signés sur un processeur 32 bits, et souvent même des entiers 64 bits sur un processeur 32 bits à l'aide des instructions ADD-with-carry .
  • Parfois multiplication entière , mais l'histoire de la multiplication est compliquée = , ce qui est regrettable pour la cryptographie car la multiplication mélange assez bien les bits et a des propriétés algébriques utiles.

Pour être clair: je ne veux pas dire que un compilateur C garantit que ces opérations s'exécutent en temps constant si vous les utilisez dans un programme C; J'utilise simplement la notation C pour les opérations que CPU exécutent généralement en temps constant. (Plus d'informations sur cette mise en garde dans un instant.)

"Mais attendez, protestez-vous, comment puis-je éventuellement écrire un programme utile à partir de ces opérations? Pas de conditions? Pas de boucles? Pas de tableaux? "

Tout d'abord, vous n'avez pas à éviter les conditions, les boucles ou les tableaux tout à fait. Ils ne peuvent tout simplement pas dépendre des secrets. Par exemple, for (i = 0; i < 32; i++) ... x[i] ... est très bien. Mais for (i = 0; i < m[0]; i++) ... ne va pas si m[0] Est censé être secret, et for (i = 0; i < m[0]; i++) ... tab[x[i]] ... ne va pas si x[i] Est censé être secret.

Deuxièmement, vous pouvez toujours construire ces choses! C'est juste un peu plus compliqué. Par exemple, supposons que b est un uint32_t qui est soit 0 ou 1. Alors b - 1 Est soit -1 = 0xffffffff ou 0, respectivement, donc

x = ((b - 1) & z) | (~(b - 1) & y);

provoque x = y si b est 1, ou x = z si b est 0 — un peu comme x = (b ? y : z), mais sans branche. Évidemment, cela nécessite de calculer les deuxy et z, donc il y a un impact sur les performances! De même, vous pouvez rechercher un élément d'une table en recherchant tous éléments de la table et en sélectionnant celui que vous voulez avec des opérations au niveau du bit comme celle-ci. Pas aussi vite que x[i], Mais pas aussi non étanche.

En général, vous pouvez convertir un programme avec des conditions en un circuit logique sans conditions, même si vous ne le faites pas voulez to pour des raisons de performances. Il existe plusieurs autres astuces similaires que vous pouvez effectuer. Vous pouvez rédiger une routine d'égalité de mémoire à temps constant telle que crypto_verify_32 Comme ceci, en supposant que x et y sont des tableaux uint8_t:

uint32_t result = 0;
for (i = 0; i < 32; i++)
  result |= x[i] ^ y[i];
return ((result - 1) >> 8) & 1;

Exercice: cela renvoie-t-il 0 pour égal et 1 pour inégal, ou 0 pour inégal et 1 pour égal?

Écrire des programmes comme celui-ci et adopter des cryptosystèmes tels que X25519 qui encouragent implémentations qui ressemblent à ceci, au lieu de cryptosystèmes tels que RSA ou AES qui encouragent implémentations impliquant impliquent les branches dépendantes du secret ou les recherches de table dépendantes du secret - est un bon début pour brancher les canaux côté synchronisation.

Mais, il y a un hic! Vous vous souvenez quand j'ai dit que le compilateur C ne garantit pas un temps constant? Un compilateur C intelligent comme Clang/LLVM pourrait reconnaître que l'intelligent crypto_verify_32 la boucle ci-dessus peut être exécutée plus efficacement en la faisant abandonner tôt, et pourrait annuler le travail acharné que vous avez fait pour la réécrire comme un circuit logique qui s'exécute en temps constant. (Dans d'autres circonstances, cela peut vous aider, par exemple en convertissant x = (b ? y : z); en une instruction de déplacement conditionnel, CMOV, sans branches, mais vous ne pouvez généralement pas compter sur la bonne volonté du compilateur C.)

Il y a quelques astuces que vous pouvez faire pour contrecarrer cela, comme un fragment d'assemblage en ligne qui amène le compilateur à supprimer à peu près toutes les hypothèses d'optimisation:

uint32_t result = 0;
for (i = 0; i < 32; i++)
  result |= x[i] ^ y[i];
asm volatile ("" ::: "memory");
return ((result - 1) >> 8) & 1;

Cela peut ou non fonctionner avec votre compilateur. Pour être sûr, vous devez vraiment examiner le code machine généré par le compilateur - et même alors, un compilateur peut effectuer des optimisations juste à temps qui - réécrire le code machine selon l'analyse de profilage, en particulier dans langages de niveau supérieur comme Java. Donc, vous voudrez peut-être vraiment écrire la logique dans Assembly (ou dans un langage de programmation comme qhasm qui peut générer l'assembly affiné de manière plus fiable qu'un compilateur C), et l'appeler simplement depuis C .

Peut-être qu'un jour, les compilateurs C adopteront un qualificatif secret, comme const ou volatile, qui force le compilateur à générer uniquement des instructions machine connues - dans certains modèles de CPU ! - pour s'exécuter en temps constant lorsqu'il fonctionne sur l'objet, et empêche le compilateur de prendre des raccourcis comme des abandons anticipés dépendants du secret d'une boucle. Mais ce jour n'est pas encore arrivé.

Il y a aussi la question de savoir quelles instructions machine s'exécutent réellement en temps constant sur un processeur, ce qui est parfois documenté et parfois fiable. Donc, en plus de faire ingénierie pour construire vos programmes à partir de circuits logiques, vous devez également faire science pour déterminer quelles opérations sont réellement sûres à utiliser le CPU.

Cela nous ramène au point d'origine: vous voulez vraiment concentrer l'effort de maintenir cela dans une routine de bibliothèque, afin que chaque programmeur n'ait pas à suivre les aléas des compilateurs (et des conceptions de CPU!) Dans le code généré et le timing par eux-mêmes, et peut le laisser à notre sympathique ours de quartier .


Y a-t-il d'autres contre-mesures que la logique à temps constant? Parfois oui.

  • Vous pouvez injecter un bruit aléatoire dans votre logique, dans l'espoir que cela perturbe les mesures de l'attaquant. Mais il y a déjà du bruit dans leurs mesures, comme la programmation dans le système d'exploitation, donc ils ont juste à prendre plus d'échantillons - et il s'avère que le bruit est pas une contre-mesure de canal latéral très efficace .

    Plus précisément, le bruit artificiel augmente les coûts de l'attaquant d'au plus environ le carré du rapport du bruit artificiel au bruit réel, ce qui est bien inférieur à ce qui est généralement considéré comme un écart acceptable pour la sécurité en cryptographie. Cela vous coûte donc beaucoup de temps de ne rien faire.

  • Vous pouvez utiliser les propriétés algébriques du cryptosystème pour le randomiser, parfois appelé "aveuglant". Par exemple, au lieu de calculer y^d mod nd est un exposant secret dans RSA, vous pouvez choisir r au hasard, calculer s := r^e mod ne*d ≡ 1 (mod ????(n)), multiplier y par s pour obtenir (y * r^e) mod n, calculez (y * r^e)^d mod n = (r * y^d) mod n, puis divisez r.

    De nombreuses implémentations, telles qu'OpenSSL, utilisent cette approche car c'est un moyen facile de moderniser une implémentation existante d'un cryptosystème comme RSA qui a la structure algébrique nécessaire. Ce n'est pas une mauvaise idée comme le bruit aléatoire, mais cela a des coûts: vous devez faire le travail supplémentaire pour la randomisation, vous devez avoir une logique de division ou d'inversion modulaire - et les canaux latéraux peuvent toujours divulguer des informations sur r et d. Par exemple, même l'exponentiation modulaire en aveugle entraînera une fuite du poids de Hamming de d à moins que vous ne preniez des contre-mesures supplémentaires comme l'ajout d'un multiple aléatoire de ????(n) à d en premier - ce qui peut exposer d'autres canaux latéraux, etc.

  • Pour le cas spécifique de la comparaison de deux chaînes d'octets pour l'égalité (par exemple, deux codes d'authentification de message), une option raisonnable consiste à les hacher avec une famille de fonctions pseudo-aléatoires comme HMAC-SHA256 sous un heure clé secrète k, et vérifiez si HMAC-SHA256_k(x) == HMAC-SHA256_k(y).

    La probabilité d'un faux positif est de 1/2256, ce qui est une probabilité plus faible que jamais. Vous pouvez utiliser en toute sécurité l'égalité de temps variable pour le HMAC car si x est pas égal à y, alors le temps même dans le naivest routine d'égalité de chaîne d'octets (en supposant qu'elle ne se libère pas au premier octet zéro ou quelque chose de stupide comme ça!) sera indépendante des valeurs de x et y: il y a une probabilité de 255/256 que ça s'arrête après une itération, une probabilité de 65535/65536 après deux itérations, etc.

    Bien sûr, cela n'aide vraiment que si vous pouvez implémenter HMAC-SHA256 en temps constant! Heureusement, SHA-256 est conçu pour être facilement implémenté en tant que circuit logique à temps constant, donc les implémentations C tendance pour être raisonnablement résistantes aux canaux latéraux - mais, disons Python vous causera des ennuis à cause du petit cache entier si rien d'autre.


* La terminologie est malheureusement un peu déroutante. Ici temps constant signifie que le temps ne varie pas en fonction des entrées, et n'est pas le même que le asymptotique notion de "temps constant" en informatique, souvent écrite O (1), ce qui signifie simplement le temps peut varier en fonction des entrées mais est limité par une constante. Je suis désolé. Je n'ai pas inventé la terminologie. J'aurais peut-être choisi "temps fixe" vs. "temps variable" mais il est trop tard maintenant - "temps constant" est ancré dans la littérature.

18

Les attaques par canaux latéraux sont notoirement difficiles à détecter, car il existe de nombreux canaux secondaires qu'un attaquant pourrait rechercher. Cela comprend, mais sans s'y limiter:

  • Attaques chronométrées
  • Attaques de cache
  • Attaques de surveillance de l'alimentation
  • Cryptanalyse acoustique

Wikipedia a une excellente liste, dont ce n'est qu'un extrait. Puisqu'il y a tellement de canaux secondaires différents, chacun d'eux doit être traité indépendamment.

Qu'en est-il du chronométrage des attaques?

Votre code est vulnérable aux attaques temporelles, mais vous le saviez déjà. La question est, comment pouvez-vous y remédier? La solution serait de faire une comparaison à temps constant. Un exemple serait un code comme celui-ci:

difference = 0;
for (i = 0; i < n; i++) {
  difference |= (password[i] ^ input[i]);
}

return difference == 0 ? E_OK : E_FAIL;

Ce code suppose que le mot de passe et la saisie ont la même longueur, par ex. car ils sont la sortie d'une fonction de hachage. Le code accumulerait la différence de bits entre chaque paire d'éléments, puis renvoie un résultat basé sur si les différences sont nulles. Gardez également à l'esprit que votre compilateur C d'optimisation convivial est libre de repérer ce que cela fait et de générer l'assembly qu'il aurait généré pour votre code d'origine (cassé). Vous devez vérifier l'assembleur de génération réel (ou utiliser une fonction de bibliothèque conçue à cet effet).

Bien sûr, cela ne protégerait que contre un type d'attaque latérale, et pas contre d'autres.

Et les autres canaux secondaires?

Cela dépend entièrement du canal latéral sur lequel vous vous concentrez. Certains, tels que la consommation d'énergie, nécessitent un accès physique (ou d'autres moyens de mesurer la consommation), ils peuvent donc ne pas être un problème si l'attaquant est loin.

En général, pour vous défendre contre les attaques par canal latéral, vous devez:

  • Soyez conscient que le canal latéral existe
  • Vérifiez si ce canal latéral pourrait être un problème potentiel dans votre modèle de menace
  • Vérifiez quelles informations sont divulguées via ce canal latéral
  • Vérifiez comment éviter de divulguer ces informations
13
MechMK1

Je suppose que le code de la question n'est qu'un exemple intentionnellement banalisé pour illustration, car dans un système réel vous ne stockeriez jamais de mots de passe en texte clair . Mais si vous souhaitez remplacer ce code fictif par une implémentation qui n'est pas vulnérable aux attaques temporelles, vous vous assurerez que l'algorithme ne se termine pas sur le premier mauvais caractère mais fait toujours le même nombre de comparaisons:

bool isCorrect = true;
for (i = 0; i < PASSWORD_MAX_LENGTH; i++) {
    if (password[i] != input[i]) {
       isCorrect = false;
    }
}
return isCorrect;

Cependant, ce n'est pas non plus complètement à l'abri des attaques de synchronisation, car selon la façon dont le processeur traite ce code, cela peut encore prendre plus ou moins longtemps en cas d'échec. Une source possible de différence de synchronisation pourrait être prédiction de branche .

Grossièrement simplifié à l'extrême: lorsque le CPU remarque qu'il traite une condition if dans une boucle for et que cette condition if s'avère fausse la plupart du temps, le CPU s'optimise lui-même en supposant qu'il se révèle toujours faux. Cela lui permet de traiter cette boucle for beaucoup plus rapidement. Mais si cette instruction if se révèle vraie tout d'un coup, cela provoque un grand chaos dans le pipeline du processeur, ce qui prend quelques cycles d'horloge à nettoyer. Ainsi, les différences de synchronisation causées par des échecs de prédiction de branche peuvent être un autre canal latéral de synchronisation possible. C'est difficile à éviter, car c'est une caractéristique du CPU qui est complètement opaque pour le développeur et peut même dépendre du modèle exact de CPU. Pour plus d'informations, faites des recherches sur la vulnérabilité Spectre .

Mais il existe également une approche différente pour éviter les attaques de synchronisation qui est grossière et simple mais efficace: Ajoutez un retard aléatoire après chaque comparaison de mot de passe . Si la durée du retard provient d'un générateur de nombres pseudo-aléatoires sécurisé par cryptographie , alors cela ruine la précision des mesures de temps sur lesquelles l'attaquant s'appuie.

6
Philipp

Je vais essayer de répondre à l'énoncé du problème ci-dessus en considérant l'attaque du canal latéral ici comme basée sur le temps, c'est-à-dire.

l'attaque temporelle surveille le mouvement des données dans et hors du CPU ou de la mémoire sur le matériel exécutant le cryptosystème ou l'algorithme. En observant simplement les variations du temps nécessaire pour effectuer des opérations cryptographiques, il peut être possible de déterminer la clé secrète entière. De telles attaques impliquent une analyse statistique des mesures de synchronisation et ont été démontrées à travers les réseaux

Au lieu de vérifier l'entrée en tant que flux octet par octet et de répondre au contrôleur/écran/interface utilisateur sur lequel l'utilisateur peut vérifier si la sortie est correcte ou non, il doit utiliser les données comme un bloc, puis effectuer l'opération arithmétique égale sur les données d'entrée.

Excusez mon mauvais travail d'art. Feedback Operation

Cette attaque utilise une analyse statistique de la sortie qui peut être éliminée. Une façon d'effectuer une telle opération consiste à utiliser des hachages dans lesquels la longueur du mot de passe n'a pas d'importance, il générera toujours une sortie de longueur fixe.

1
avicoder

Avertissement: je suis un débutant dans ce domaine.

Pourquoi ne pas définir une durée prévue pour votre code de vérification et l'obliger à continuer à s'exécuter pendant au moins aussi longtemps?

DateTime endTime = DateTime.Now + TimeSpan.FromMilliseconds(10);

while (DateTime.Now < EndTime || passwordCheck.IsIncomplete) { 
    // password checking code here
}
0
William Jockusch