PHP et la cryptographie
Dans cet article, je vais vous faire part d’un retour d’expérience sur une situation que j’ai vécue au travail, il y a quelques jours seulement, en binôme avec Matthieu ANCERET.
Il sera question de PHP, de cryptographie AES-256-CBC, de C# et de WTF

C’est parti !
Mise en situation
Nous intervenons sur un système pour lequel nous devons créer une API spécifique, en .Net Core, qui va devoir effectuer ses propres actions métiers, puis effectuer des insertions dans une base de données MySQL déjà utilisée par un site en PHP.
De son côté, le site PHP pourra lire et afficher les données insérées par notre API, afin de satisfaire son propre usage.
Point important à savoir : Les données étant sensibles (données personnelles), elles sont stockées de manière sécurisée en base de données, via un chiffrement géré par le site PHP.
Note : Le contexte client décrit ici est simplifié, voire détourné, pour plus facilement décrire la problématique à résoudre.
Nous avons donc :
- D’un côté un site en PHP qui lit et écrit des données chiffrées dans une base de données MySQL,
- Et de l’autre, une API en .Net Core chargée uniquement d’écrire des données dans cette base de données, avec le même chiffrement.

Qu’est-ce qui pourrait aller de travers ? 😈
À la recherche des informations de chiffrement
Si l’on décompose de manière logique le problème exposé précédemment, il faut d’abord répondre à ces questions pour que notre API puisse fonctionner :
- Quelle méthode de chiffrement est utilisée sur les données par le site PHP ?
- Comment accéder aux clés de chiffrement, utilisées depuis le site PHP, depuis notre API ?
- Comment accéder à la base de données MySQL depuis notre API ?
Le dernier point étant un sujet d’infrastructure, nous ne nous attarderons pas sur celui-ci dans cet article.
En revanche, nous allons nous concentrer sur les 2 autres, à savoir, « Quel est l’algorithme de chiffrement ? » et « Comment accéder aux clés de chiffrement ? ».
Et le PHP fut !
En réponse à ces questions, les développeurs du site PHP ont eu l’immense amabilité de nous transmettre le code source de la méthode de chiffrement utilisée dans le site :
|
|
Alors, mettons tout de suite au clair certains éléments :
- Non : Il ne s’agit pas exactement du code source reçu :
- Je l’ai indenté ➡️➡️
- Je l’ai nettoyé 🧹🧼
- J’y ai ajouté des commentaires pour plus de clarté sur son contenu 👓
- Mais en dehors de ça…
- Oui : La clé de chiffrement est en dur dans le code
- Non : Ce n’est pas la clé de chiffrement originale ! … je ne suis pas fou non plus 😅
- Oui : Les développeurs actuels ne sont pas à l’aise avec ça, mais ils n’ont pas la main sur les éléments d’infrastructure (afin d’ajouter un Vault par exemple), et reçoivent d’autres priorités de réalisation au quotidien
Attention : Même si la conception de cette méthode n’est pas optimale, je ne cherche pas à blâmer les développeurs originaux du site : je ne connais pas le contexte dans lequel il a été produit.
Et je ne blâme pas non plus l’équipe qui a récupéré le projet en TMA, puisqu’elle n’est pas maître des priorisations sur la TMA.
Enfin, les développeurs actuels déplorent eux aussi certains points dans la base de code, tels que la clé de chiffrement en dur…
Pour être tout à fait franc, je ne suis pas un expert en PHP.
J’en ai fait par le passé, mais je me considère plus comme un novice que comme un expert. C’est pour ça que je ne me suis pas alarmé quand j’ai vu ce code :
|
|
Il me semblait plus logique de recevoir un tableau d’octet en sortie de la fonction hash(...)
qu’une chaîne de caractère, mais de mémoire, PHP étant faiblement typé, je me suis dit que la méthode substr
permettait peut-être aussi de redimensionner un tableau d’octets ¯\_(ツ)_/¯
Rentrons dans le dur
En possession des informations qu’il nous fallait, Matthieu et moi avons alors conçu un POC nous permettant de confirmer que l’on pouvait obtenir le même résultat cryptographique en PHP et en .Net, lorsque l’on utilisait un algorithme standard (ici AES-256-CBC).
Faisons parler PHP
Nous nous sommes alors servis du site https://onlinephp.io pour tester le code reçu.
En premier lieu, nous avons rajouté ce morceau de code avant la fin du fichier, afin d’appeler la méthode de chiffrement et voir le résultat final :
|
|
Nous avons exécuté le code et obtenu le résultat suivant :
|
|
Le code fonctionne, et comme prévu, le texte final est encodé en base64.
Il ne nous restait plus qu’à faire la même chose en C# et obtenir le même résultat à partir des données entrantes.
Passons à .Net maintenant
Matthieu a alors créé une application de type console, depuis son IDE (VS 2022).
Après recherche des classes permettant de manipuler un cryptage AES en .Net, il a produit un seul fichier Program.cs
contenant le code suivant :
|
|
Après exécution du code, via un dotnet run
, on obtenait le résultat suivant :
|
|
Aïe !
En PHP, le résultat du chiffrement est N1U3eUVCTEVib1drd3ovR3NlTEloUjl4TDgxMW9tTG4rSHZuMy9LTCtPdz0=
.
En C#, le résultat du chiffrement est 44gVGt+090qlgHXpbA/bIe5rIEqKZM6/TUYIhMza1uM=
.
Deux résultats bien distincts…
Mais pourquoi donc ?
Nous arrivions en fin de journée. J’ai alors dit à Matthieu de me faire suivre tout ce qu’il avait en sa possession pour que je puisse continuer notre investigation, seul, le soir même à la maison.
C’est parti pour une session de débogage
Le meilleur moyen de trouver l’origine de cette différence est de contrôler les valeurs des éléments tout au long de l’exécution du code.
Étant convaincu que l’origine du problème devait être bénigne, je me suis rabattu vers la solution de débogage la plus simple qui soit : l’émission de logs !
Affichage des clés et vecteurs d’initialisation
J’ai donc commencé par modifier le code PHP, pour faire apparaître les valeurs des empreintes numériques du secret et du vecteur d’initialisation :
|
|
Puis j’ai appliqué le même principe sur le code C#.
En premier lieu, j’ai ajouté une méthode permettant d’afficher le contenu d’un tableau sous sa forme hexadécimale dans la console :
|
|
Ensuite, j’ai ajouté de logs pour afficher les 2 secrets :
|
|
Affichage des résultats cryptographiques
Enfin, dans le but d’avoir le plus d’informations possible pour déboguer cette situation, j’ai décidé d’afficher le résultat brut en sortie du chiffrement.
J’ai donc ajouté le log suivant dans le code PHP :
|
|
Et j’ai rajouté un log équivalent dans le code C# :
|
|
Résultats d’exécutions
J’ai exécuté le code C#, et j’ai obtenu le résultat textuel suivant :
|
|
J’ai exécuté le code PHP, et j’ai obtenu le résultat textuel suivant :
|
|
J’ai donc très rapidement émis les constats suivant :
- 🤩 PHP et C# indique la même valeur pour l’empreinte numérique de la clé secrète :
185c7ca3b9eb236810862bae4d3f9290b5866c53d4e8c40aa1c9a7c3f2ff8487
- 🤨 Pourquoi les vecteurs d’initialisations en PHP et C# ne font pas la même taille, alors qu’ils commencent tous les 2 par
a97110e8dc1a0d0e
? - 🤯 Pourquoi le résultat brut de chiffrement en PHP est déjà encodé en base64 ?
Mais qu’est-ce qui ne va pas ?
À partir de là, il me semblait clair que ma compréhension du code PHP était erronée.
Il devenait important de prendre du recul et d’effectuer un RCA.
Premier mystère : Pourquoi le résultat de la méthode openssl_encrypt
est en base64 ?
Pour y répondre, j’ai consulté la documentation de PHP, et plus particulièrement le fonctionnement de la méthode openssl_encrypt
: https://www.php.net/manual/fr/function.openssl-encrypt

Ce paramètre étant passé à 0 dans notre code, je me suis douté qu’aucune transformation n’était faite sur le résultat, et que le flag OPENSSL_RAW_DATA
n’était pas appliqué. En revanche, je ne trouvais aucune information explicite indiquant que le résultat était en hexadécimale par défaut.
Je me suis donc plongé dans le code source de PHP, écrit en langage C, et je suis tombé sur ces 2 conditions :
|
|
|
|
Et voilà !
J’obtenais ainsi la confirmation que la sortie en base64 était native avec les paramètres que nous passions à la méthode openssl_encrypt
.
Le code PHP effectue donc un double encodage en base64 qui n’est pas nécessaire, mais je me devais de le respecter dans le code C# pour être sûr de pouvoir insérer des données lisibles pour PHP.
J’ai donc modifié mon code C# de cette manière :
|
|
En définitive, l’affichage hexadécimal dans les logs émis via echo
en PHP, n’était pas lié à un comportement « magique » de PHP, qui aurait nativement converti un tableau d’octet en chaîne de caractères hexadécimale par commodité, mais lié au fait que la méthode hash
renvoi une représentation hexadécimale de l’empreinte numérique.
Note : Vous trouverez plus de détails sur les flags openssl ici
Deuxième mystère : Pourquoi le vecteur d’initialisation commence par la même séquence et plus court en PHP qu’en C# ?
Là encore, j’ai cherché dans la documentation de PHP, afin de comprendre le fonctionnement de la méthode hash
: https://www.php.net/manual/fr/function.hash.php

Il s’avère que contrairement à ce que je pensais, dans notre cas cette méthode renvoie une chaîne de caractères, et non un tableau d’octets comme en C#. En effet, notre code PHP ne passe pas le paramètre binary
, valorisé à true
, à la méthode lors de son appel.
En conséquence, mon interprétation de l’appel à la méthode substr
mentionné précédemment était erronée.
En effet, si dans le code C# la valeur du vecteur d’initialisation est affichée sur 32 caractères, c’est parce que la représentation hexadécimale d’un octet se fait sur 2 caractères hexadécimaux.
Et 16 octets * 2 caractères par octet = 32 caractères affichés.
Or, ici, le développeur du code PHP n’a pas cherché à obtenir un tableau contenant 16 octets, mais bien une chaîne de caractères contenant 16 caractères.
Ce qui m’a amené à cette formidable interrogation :
Mais pourquoi ?
Un peu de code C
Cette décision me perturbait vraiment !
Je n’arriverai pas à comprendre la logique qui avait poussé le développeur à tronquer la chaîne de caractères de l’empreinte numérique pour qu’elle ne contienne que 16 caractères.
Bien décidé à le comprendre, j’ai continué à explorer le code source de PHP sur GitHub et après quelques instants, j’ai compris ce qui m’échappait en voyant cette signature de méthode :
|
|
Mon incompréhension venait du mot-clé char
.
Oh ! Je sais parfaitement ce qu’est un char
en C, mais je n’avais pas percuté qu’un char
est encodé sur 1 octet en C, et que la chaîne de caractères passée à la méthode openssl_encrypt
utilisait l’encodage ASCII, pour lequel 1 caractère = 1 octet.
EURÊKA !
Si le développeur original a tronqué la chaîne à 16 caractères, c’est pour qu’elle ne contienne que les 16 octets obligatoires pour le vecteur d’initialisation !
Il ne me restait plus qu’à contrôler que l’encodage de la chaîne de caractères était bien en ASCII pour que mon raisonnement soit validé :
|
|
Et le résultat m’a donné raison, c’était bien de l’ASCII :
|
|
Attends ! T’as dit quoi là ?
Je vous la refais pour que vous compreniez le traitement que subissent les données afin d’être interprétées par openssl lors du chiffrement :
Le vecteur d’initialisation est instancié avec cette valeur :
Format | Valeur |
---|---|
Brut | i6qwQgZS&Q@CfPfb/.P2E |
L’empreinte numérique est calculée, puis retournée dans une chaîne de caractères au format hexadécimale :
Format | Valeur |
---|---|
Hexadécimale | a97110e8dc1a0d0e647e6b34c8cec055e6c0f6529d0776509eaf5de5ead82ba2 |
En réalité, sous sa forme brute constituée de nombres entiers, l’empreinte numérique a ces valeurs-là :
Format | #1 | #2 | #3 | #4 | #5 | #6 | #7 | #8 | #9 | #10 | #11 | #12 | #13 | #14 | #15 | #16 | #17 | #18 | #19 | #20 | #21 | #22 | #23 | #24 | #25 | #26 | #27 | #28 | #29 | #30 | #31 | #32 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Hexadécimale | a9 | 71 | 10 | e8 | dc | 1a | 0d | 0e | 64 | 7e | 6b | 34 | c8 | ce | c0 | 55 | e6 | c0 | f6 | 52 | 9d | 7 | 76 | 50 | 9e | af | 5d | e5 | ea | d8 | 2b | a2 |
Nombre entier | 169 | 113 | 16 | 232 | 220 | 26 | 13 | 14 | 100 | 126 | 107 | 52 | 200 | 206 | 192 | 85 | 230 | 192 | 246 | 82 | 157 | 7 | 118 | 80 | 158 | 175 | 93 | 229 | 234 | 216 | 43 | 162 |
L’empreinte numérique est ensuite tronquée à 16 caractères :
Format | Valeur |
---|---|
Hexadécimale tronquée | a97110e8dc1a0d0e |
En tronquant la chaîne de caractères à 16 caractères, si on garde la logique de conversion « hexadécimal vers nombre entier », on obtient une empreinte numérique de seulement 8 octets :
Format | #1 | #2 | #3 | #4 | #5 | #6 | #7 | #8 |
---|---|---|---|---|---|---|---|---|
Hexadécimale | a9 | 71 | 10 | e8 | dc | 1a | 0d | 0e |
Nombre entier | 169 | 113 | 16 | 232 | 220 | 26 | 13 | 14 |
Or, l’algorithme de chiffrement impose un vecteur d’initialisation de 16 octets !
Donc le code PHP ne peut pas fonctionner ! N’est-ce pas ? (ㆆ _ ㆆ)
Et bien c’est là que la partie WTF du traitement commence.
Chaque caractère de l’empreinte numérique tronquée est alors considéré comme un octet à part entière, grâce à la table des caractères ASCII.
Le code PHP n’en a alors plus rien à faire de la forme hexadécimale obtenue à la suite du calcul de l’empreinte numérique, et on obtient le tableau de conversion suivant :
Format | #1 | #2 | #3 | #4 | #5 | #6 | #7 | #8 | #9 | #10 | #11 | #12 | #13 | #14 | #15 | #16 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Caractère ASCII | a | 9 | 7 | 1 | 1 | 0 | e | 8 | d | c | 1 | a | 0 | d | 0 | e |
Valeur entière ASCII | 97 | 57 | 55 | 49 | 49 | 48 | 101 | 56 | 100 | 99 | 49 | 97 | 48 | 100 | 48 | 101 |
Ce qui nous permet de retomber sur un tableau de 16 octets (-‸ლ)

Pour confirmer mon idée, j’ai implémenté une fonction capable de m’indiquer les nombres entiers associés à chaque caractère :
|
|
Puis je l’ai utilisé pour afficher un log me permettant de voir la suite de valeurs :
|
|
Après exécution, le résultat était :
|
|
Mettons à jour le code C#
Une fois cette mécanique comprise, la modification du code C# a alors été rapide :
|
|
Après exécution, le résultat était :
|
|

La valeur numérique du vecteur d’initialisation était identique en PHP et en C# :
97-57-55-49-49-48-101-56-100-99-49-97-48-100-48-101
Mais le résultat du chiffrement était différent :
- En PHP, il valait
N1U3eUVCTEVib1drd3ovR3NlTEloUjl4TDgxMW9tTG4rSHZuMy9LTCtPdz0=
- En C#, il valait
QlVtOGFDYlJzQ3JySEFTRzIzY3ZiUjRmLzZRVFI5cFFYS2ZuSkZSREUxYz0=
Si la taille du texte semble mieux correspondre, les deux résultats restent bien distincts…
Dernier mystère : Mais où est Charlie se situe la divergence entre les 2 codes ?
En voyant ça, je savais désormais que le seul élément qui variait entre les 2 implémentations ne pouvait être que la clé de chiffrement.
Mon intuition m’a dit de retourner à l’endroit où j’avais trouvé le plus de réponses : le code source de PHP.
Et elle était bonne.
J’ai trouvé cette condition et ce code ici :
|
|
Ce morceau de code redimensionne en mémoire la clé de chiffrement si celle-ci dépasse la taille de clé autorisée par l’algorithme de chiffrement.
Bingo !
L’algorithme AES-256-CBC utilise une clé de 256 bits (soit 32 octets) pour encoder les données.
Et compte tenu que les mêmes types de données en C sont utilisés pour la clé de chiffrement et le vecteur d’initialisation, je suis parti du principe que le même procédé était appliqué à la clé de chiffrement et au vecteur d’initialisation : Conversion WTF via la table des caractères ASCII.
Il ne me restait donc plus qu’à appliquer le même principe de conversion que pour le vecteur d’initialisation, et de limiter la taille de la clé à 32 octets :
|
|
Après exécution, le résultat était :
|
|
🎉 YOUHOU !!!
Les résultats sont enfin équivalents !
Conclusion
Que doit-on retenir de tout ça ?
Et bien que malgré la facilité de lecture du code PHP initialement reçu par les développeurs du site, le processus et les méthodes de cryptographies mises à disposition par PHP ne sont pas forcément les plus adéquats pour obtenir un code facilement transposable à d’autres langages/technologies.
Que l’on s’entende bien, je ne blâme PHP pour le plaisir de le blâmer.
PHP fait super bien le travail de manière globale, et en particulier sur le site où il est utilisé dans cet article. À tel point que choisir une autre technologie n’aurait peut-être pas réellement eu de pertinence dans le contexte courant.
En revanche, malgré sa simplicité apparente qui tend de nombreuses personnes à l’exploiter, PHP reste, comme toutes les autres technos, soumis à des choix de conceptions qui peuvent s’avérer compliqué à comprendre et maîtriser dès l’instant où l’interopérabilité est nécessaire.
Mais comment aurait-on pu éviter tout cela ?
Et bien simplement en utilisant les paramètres binary
des méthodes hash
et openssl_encrypt
:
|
|
En exécutant ce code, on obtient le résultat :
|
|
Le résultat brut du chiffrement en PHP correspond exactement à ce que la première implémentation en C# ressortait : 44gVGt+090qlgHXpbA/bIe5rIEqKZM6/TUYIhMza1uM=
.
Le mot de la fin
Pour clore cet article, je tenais à adresser mes félicitations à la communauté PHP pour 2 raisons :
- Bravo et merci de fournir une documentation très claire et, en prime, profitant d’une traduction française de qualité
- Bravo pour le code source clair et bien rédigé (à minima pour la partie openssl que j’ai consulté), qui m’a grandement aidé à comprendre le fonctionnement interne du framework sur cet aspect
Et un grand merci à Matthieu pour m’avoir laissé chercher la solution à ce problème, c’était fun 😁
Merci pour votre lecture, j’espère que ce retour d’expérience vous aura plu.
À bientôt !
Sources
Si vous souhaitez consulter les fichiers dans leur intégralité, dirigez-vous vers ce Gist GitHub : https://gist.github.com/XREvo/f25fcec6918fb9b38b5b5f7bd5a74a62.
Vous retrouvez les bacs à sable PHP à ces adresses :
- Code initial avec logs : https://onlinephp.io/c/d4fe2
- Code utilisant les paramètres
binary
: https://onlinephp.io/c/a1abf