Dans tout logiciel complexe, il existe des bogues, et lorsque l'on travaille sur un projet de grande envergure, on en rencontre régulièrement. Les plus mémorables et les plus intéressants sont ceux qui ont une cause simple, mais qui se manifestent de manière inhabituelle et parfois bizarre. Ce bogue a commencé avec une seule vidéo qui a fait planter un lecteur. En soi, il ne s'agit pas d'un rapport très rare, mais les détails suivants ont été particulièrement remarquables :
- Cela ne s'est produit qu'avec une seule vidéo spécifique
- Cela ne s'est produit que sur Android (mais seulement sur certaines versions d'Android).
- Le lecteur s'arrête toujours exactement 19 secondes après le début de la lecture.
La vidéo elle-même était diffusée sur les appareils Android à l'aide de HTTP Live Streaming, la norme d'Apple pour la diffusion de vidéos fragmentées basée sur le format de liste de lecture m3u. Pour la protection du contenu, la norme AES-128 avec PKCS#7 a été utilisée. Cette méthode est généralement appelée HLS Encryption (ou "HLSe" en abrégé). D'autres formats disponibles pour le contenu, tels que MPEG-DASH, ne posaient pas de problème, pas plus que le HLS avec DRM FairPlay (ou même sans DRM). Le problème était encore plus spécifique que "cette vidéo", c'était "cette vidéo, avec HLSe"
Heureusement, pour les besoins des tests, ce bogue était facile à reproduire dans l'application de démonstration ExoPlayer de Google, et avec une sélection respectable d'appareils de test, il n'a pas fallu longtemps pour obtenir des codes d'erreur et des traces de pile à partir de logcat pour diverses versions majeures d'Android. Les erreurs spécifiques variaient en fonction de la version d'Android sur l'appareil, mais elles partageaient toutes un thème commun ; notamment qu'il y avait un problème de décryptage du média dans l'un des segments vidéo. L'exception principale et l'exception "Caused by" la plus intéressante, plus loin dans la trace de pile, étaient toutes des variantes de ces deux exceptions (dont la formulation différait selon les versions d'Android testées) :
java.io.IOException: Error while finalizing cipher
at javax.crypto.CipherInputStream.fillBuffer(CipherInputStream.java:104)
at javax.crypto.CipherInputStream.read(CipherInputStream.java:155)
at com.google.android.exoplayer2.source.hls.Aes128DataSource.read(Aes128DataSource.java:96)
Caused by: javax.crypto.BadPaddingException: error:1e06b065:Cipher functions:EVP_DecryptFinal_ex:BAD_DECRYPT
at com.android.org.conscrypt.NativeCrypto.EVP_CipherFinal_ex(Native Method)
at com.android.org.conscrypt.OpenSSLCipher$EVP_CIPHER.doFinalInternal(OpenSSLCipher.java:568)
at com.android.org.conscrypt.OpenSSLCipher.engineDoFinal(OpenSSLCipher.java:385)
at javax.crypto.Cipher.doFinal(Cipher.java:1476)
Plonger plus profondément ici, en termes de code, est un peu un trou de lapin. Le code cryptographique de base est écrit en C et est assez intimidant si vous n'y êtes pas habitué. Heureusement, ce "causé par" a un type lisible, notamment javax.crypto.BadPaddingException
Cela nous permet de mieux cerner le problème : il s'agit d'un problème de remplissage. Selon la spécification d'Apple, l'algorithme de remplissage utilisé dans HLSe est PKCS#7, qui est décrit dans le document suivant RFC-5652 Section 6.3:
6.3. Content-encryption Process
The content-encryption key for the desired content-encryption
algorithm is randomly generated. The data to be protected is padded
as described below, then the padded data is encrypted using the
content-encryption key. The encryption operation maps an arbitrary
string of octets (the data) to another string of octets (the
ciphertext) under control of a content-encryption key. The encrypted
data is included in the EnvelopedData encryptedContentInfo
encryptedContent OCTET STRING.
Some content-encryption algorithms assume the input length is a
multiple of k octets, where k is greater than one. For such
algorithms, the input shall be padded at the trailing end with
k-(lth mod k) octets all having value k-(lth mod k), where lth is
the length of the input. In other words, the input is padded at
the trailing end with one of the following strings:
01 -- if lth mod k = k-1
02 02 -- if lth mod k = k-2
.
.
.
k k ... k k -- if lth mod k = 0
The padding can be removed unambiguously since all input is padded,
including input values that are already a multiple of the block size,
and no padding string is a suffix of another. This padding method is
well defined if and only if k is less than 256.
Si vous êtes comme moi, cette description est un peu difficile à imaginer dans votre tête. Voici donc quelques exemples (avec une taille de bloc de 16 octets) :
88 7c 46 66 9a 2f a2 59 4d 1e
serait complétée par06 06 06 06 06 06
63
serait complétée par0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f
f5 5c 6b 13 cf 54 f8 45 c1 ca 67 ec 50 20 12
serait complétée par01
En clair, la valeur des octets ajoutés est le nombre d'octets ajoutés, jusqu'à la taille du bloc utilisé.
Armé d'une compréhension de la façon dont le padding devrait fonctionner, et d'une erreur indiquant un padding incorrect, l'étape suivante consistait à vérifier le code gérant le padding pour la vidéo. Le service en question est écrit en Go, qui n'a pas d'implémentation de PKCS#7 dans sa bibliothèque standard, donc le service a sa propre implémentation. La voici :
PKCS7Pad(in []byte) []byte {
padding := 16 - (len(in) % 16)
for i := 0; i < padding; i++ {
in = append(in, byte(padding))
}
return in
}
Court et simple... et pas d'erreurs évidentes. Il y a même des tests unitaires qui semblent affirmer exactement le comportement que nous attendons. Peut-être avons-nous donc besoin de quelques indices supplémentaires. C'est à ce moment-là que nous avons fait un détour pour comparer ce que notre code produisait avec d'autres implémentations. Heureusement, le cryptage AES-128 appliqué aux segments HLSe est appliqué à l'ensemble du fichier segment, et pas seulement à des parties du contenu du fichier, comme cela peut se produire avec d'autres systèmes de protection du contenu. La production de segments cryptés alternatifs a été facilement réalisée avec OpenSSL, et nous avons pu utiliser notre packager média dynamique pour obtenir une copie non protégée des segments à crypter. Avec un segment téléchargé à la fois en clair et crypté en AES-128, un segment crypté alternatif a été généré à l'aide de la commande suivante :
openssl aes-128-cbc -K $KEY -iv $IV -in clear.ts -out alt_protected.ts
Ce faisant, nous avons découvert une nouvelle information intéressante : la sortie du chiffrement d'OpenSSL était exactement 16 octets plus grande que la sortie de notre application Go (c'est-à-dire un bloc). Peut-être que le problème n'était pas dans le padding, mais dans un cas étrange où un bloc final n'était pas ajouté. En revoyant le code Go avec ce nouvel élément d'information à l'esprit, le conditionnel suivant a commencé à paraître suspect :
// If this block does not match the AES block size we need to pad it out
if bytesRead != aes.BlockSize {
À première vue, l'algorithme lit simplement les données de la mémoire tampon d'entrée par morceaux d'une longueur égale à la taille du bloc. Il vérifie ensuite combien de blocs ont été lus et les ramène à 16 octets si nécessaire. Enfin, il passe le tampon de 16 octets dans le chiffrement et transmet la sortie à un tampon de sortie. De manière réaliste, nous nous attendons à ce que cette conditionnelle ne se déclenche que sur le dernier bloc, parce que c'est le seul qui aura besoin d'un tampon. Après quelques échanges, une évidence s'est imposée : il existe un scénario qui n'est pas explicitement traité.... que se passe-t-il si la longueur du bloc final est égale à la taille du bloc ? Le code Go ne fera tout simplement rien, affirmant qu'aucun remplissage n'est nécessaire. Pour nous en assurer, vérifions à nouveau la spécification :
the input shall be padded at the trailing end with k-(lth mod k) octets all having value k-(lth mod k), where lth is the length of the input.
Ainsi, si k
est la taille du bloc, et lth
est la longueur de l'entrée, alors un bloc de longueur 16 nécessiterait 16-(0 mod 16)
octets de remplissage. 16-(0 mod 16)
égaux 16
... Donc en fait, c'est censé avoir un bloc entier de padding à la fin, avec la valeur de chaque octet paddé fixée à 16 ! Enfin, une piste ! Notre code Go n'est pas conforme à la spécification !
Après avoir testé un correctif sur le même contenu que celui qui posait problème, nous avons finalement obtenu une lecture fonctionnelle après la marque de 19 secondes dans la vidéo ! En fin de compte, la solution a consisté à réécrire le conditionnel défectueux pour qu'il remplisse le dernier bloc, quel que soit le nombre d'octets lus. Cela a permis de résoudre le problème une fois pour toutes !
En résumé, toutes les caractéristiques étranges de ce rapport de bogue découlent d'une réalité improbable : parmi tous les contenus que nous avons testés avec notre système, c'est la première fois qu'une vidéo (stockée sous la forme d'un mp4 fragmenté, divisé en segments de flux de transport) a donné lieu à un segment dont la longueur (en octets) était parfaitement divisible par 16. Pourquoi 19 secondes dans la vidéo ? Les segments ont une longueur de 10 secondes et c'était le troisième segment avec un remplissage incorrect. Pourquoi le crash ne s'est-il produit que sur certaines versions d'Android, et apparemment sur aucun autre appareil ? Il semble que la majorité des implémentations résistent à cette erreur. Les anciennes versions d'Android étaient sensibles, mais les versions plus modernes pouvaient y faire face.
Après avoir répondu à toutes les questions et corrigé le bogue, il ne reste plus qu'à tirer quelques leçons de l'expérience :
- Utiliser autant que possible des implémentations existantes et éprouvées d'algorithmes standardisés.
- Si vous décidez d'en mettre un en œuvre vous-même, vous devez faire preuve de rigueur dans les spécifications. L'omission d'un cas limite vous coûtera cher !
- Même les bugs vraiment amusants et bizarres peuvent avoir des solutions ennuyeuses.