BEI EINEM VIDEO STÜRZT DER PLAYER NACH 19 SEKUNDEN AB

Bild von JESS R
JESS R
blog-platzhalter bild

In jeder komplexen Software gibt es Bugs, und wenn man an einem großen Projekt arbeitet, stößt man regelmäßig auf sie. Am einprägsamsten und interessantesten sind die, die eine einfache Ursache haben und sich auf ungewöhnliche und manchmal bizarre Weise zeigen. Dieser Fehler begann mit einem einzigen Video, das einen Spieler zum Absturz brachte. Dies ist an sich kein besonders seltener Bericht, aber die folgenden Details stachen definitiv hervor:

  • Es passierte nur bei einem einzigen bestimmten Video
  • Das Problem trat nur unter Android auf (aber nur bei bestimmten Android-Versionen)
  • Der Player stürzte immer genau 19 Sekunden nach Beginn der Wiedergabe ab.

Das Video selbst wurde über HTTP Live Streaming, Apples Standard für die fragmentierte Bereitstellung von Videos auf der Grundlage des m3u-Playlist-Formats, an Android-Geräte übertragen. Zum Schutz der Inhalte wurde AES-128 mit PKCS#7-Padding verwendet. Dies wird allgemein als HLS-Verschlüsselung (oder kurz "HLSe") bezeichnet. Andere Formate, die für die Inhalte zur Verfügung stehen, wie z. B. MPEG-DASH, stellten kein Problem dar, ebenso wenig wie HLS mit FairPlay DRM (oder sogar ohne DRM). Das Problem war sogar noch spezifischer als "dieses Video", es war "dieses Video mit HLSe".

Glücklicherweise war dieser Fehler in Googles ExoPlayer-Demo-App leicht zu reproduzieren, und mit einer respektablen Auswahl an Testgeräten dauerte es nicht lange, um Fehlercodes und Stack Traces aus logcat für verschiedene wichtige Android-Versionen zu erhalten. Die spezifischen Fehler variierten je nach Android-Version auf dem Gerät, aber sie hatten alle ein gemeinsames Thema, nämlich dass es ein Problem bei der Entschlüsselung der Medien in einem der Videosegmente gab. Die primäre Ausnahme und die interessanteste "Verursacht durch"-Ausnahme weiter unten im Stack-Trace waren alle Varianten dieser beiden (mit unterschiedlichem Wortlaut bei den getesteten Android-Versionen):

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)

Wenn man hier tiefer in den Code eintaucht, ist das ein bisschen wie ein Kaninchenbau. Der Kern des kryptographischen Codes ist in C geschrieben und ist ziemlich einschüchternd, wenn Sie nicht daran gewöhnt sind. Glücklicherweise hat das "verursacht durch" einen gut lesbaren Typ, nämlich javax.crypto.BadPaddingExceptionDas hilft uns, das Problem weiter einzugrenzen; das Problem liegt beim Padding. Gemäß der Spezifikation von Apple ist der in HLSe verwendete Auffüllalgorithmus PKCS#7, der beschrieben wird in RFC-5652 Abschnitt 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.

Wenn Sie so sind wie ich, können Sie sich diese Beschreibung nur schwer vorstellen. Deshalb hier ein paar Beispiele (alle mit einer Blockgröße von 16 Byte):

  • 88 7c 46 66 9a 2f a2 59 4d 1e würde aufgefüllt werden mit 06 06 06 06 06 06
  • 63 würde aufgefüllt werden mit 0f 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 würde aufgefüllt werden mit 01

Ganz einfach: Der Wert der aufgefüllten Bytes ist die Anzahl der aufgefüllten Bytes, bis zur verwendeten Blockgröße.

Mit dem Wissen, wie das Auffüllen funktionieren sollte, und einem Fehler, der auf ein falsches Auffüllen hinweist, war der nächste Schritt, den Code zu überprüfen, der das Auffüllen für das Video handhabt. Der betreffende Dienst ist in Go geschrieben, das keine Implementierung von PKCS#7 in seiner Standardbibliothek hat, so dass der Dienst eine eigene Implementierung hat. Hier ist sie:

PKCS7Pad(in []byte) []byte {
    padding := 16 - (len(in) % 16)
    for i := 0; i < padding; i++ {
        in = append(in, byte(padding))
    }
    return in
}

Kurz und einfach... und keine offensichtlichen Fehler. Es gibt sogar Unit-Tests, die genau das Verhalten zu bestätigen scheinen, das wir erwarten. Vielleicht brauchen wir also noch ein paar weitere Anhaltspunkte. An diesem Punkt wurde ein Umweg gemacht, um zu vergleichen, was unser Code im Vergleich zu anderen Implementierungen produziert. Glücklicherweise wird die AES-128-Verschlüsselung, die auf HLSe-Segmente angewendet wird, auf die gesamte Segmentdatei angewendet und nicht nur auf Teile des Dateiinhalts, wie es bei anderen Inhaltsschutzverfahren der Fall sein kann. Die Erstellung alternativer verschlüsselter Segmente war mit OpenSSL einfach, und wir konnten unseren Dynamic Media Packager verwenden, um eine nicht geschützte Kopie der zu verschlüsselnden Segmente zu erhalten. Mit einem Segment, das sowohl im Klartext als auch mit AES-128 verschlüsselt heruntergeladen wurde, wurde mit dem folgenden Befehl ein alternatives verschlüsseltes Segment erzeugt:

openssl aes-128-cbc -K $KEY -iv $IV -in clear.ts -out alt_protected.ts

Dabei ergab sich eine neue und interessante Information: Die Ausgabe der Verschlüsselung von OpenSSL war genau 16 Byte größer als die Ausgabe unserer Go-App (d. h. ein Block). Vielleicht lag das Problem nicht in der Auffüllung, sondern in einem seltsamen Randfall, bei dem ein letzter Block nicht hinzugefügt wurde. Bei der erneuten Überprüfung des Go-Codes mit dieser neuen Information im Hinterkopf begann die folgende Bedingung verdächtig auszusehen:

// If this block does not match the AES block size we need to pad it out
if bytesRead != aes.BlockSize {

Oberflächlich betrachtet liest der Algorithmus einfach Daten aus dem Eingabepuffer in Blöcken, deren Länge der Blockgröße entspricht. Anschließend wird geprüft, wie viele Blöcke gelesen wurden, und gegebenenfalls auf 16 Byte aufgefüllt. Abschließend wird der 16-Byte-Puffer in die Verschlüsselung eingegeben und die Ausgabe in einen Ausgabepuffer gestreamt. Realistischerweise erwarten wir also, dass diese Bedingung nur beim letzten Block ausgelöst wird, da nur dieser aufgefüllt werden muss. Nach einigem Hin und Her wurde etwas offensichtlich: Es gibt ein Szenario, das nicht explizit behandelt wird.... Was ist, wenn der letzte Block gleich lang ist wie die Blockgröße? Der Go-Code wird einfach nichts tun und behaupten, dass keine Auffüllung erforderlich ist. Um sicher zu gehen, sollten wir die Spezifikation noch einmal überprüfen:

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.

Wenn also k ist die Blockgröße, und lth die Länge der Eingabe ist, dann würde ein Block der Länge 16 benötigen 16-(0 mod 16) Bytes zum Auffüllen. 16-(0 mod 16) ist gleich 16... Eigentlich sollte also ein ganzer Block von Auffüllungen am Ende stehen, wobei der Wert jedes aufgefüllten Bytes auf 16 gesetzt ist! Endlich, eine Spur! Unser Go-Code ist nicht spezifikationskonform!

Wir springen ein wenig vorwärts, indem wir einen Patch mit demselben Inhalt testen, bei dem das Problem auftritt, und erhalten schließlich eine funktionierende Wiedergabe nach der 19-Sekunden-Marke im Video! Letztendlich bestand die Lösung darin, die fehlerhafte Bedingung umzuschreiben und stattdessen den letzten Block aufzufüllen, unabhängig davon, wie viele Bytes gelesen wurden. Damit war das Problem ein für alle Mal behoben!

Zusammenfassend lässt sich sagen, dass alle merkwürdigen Merkmale dieses Fehlerberichts auf die unwahrscheinliche Tatsache zurückzuführen sind, dass von allen Inhalten, die wir mit unserem System getestet haben, dies das erste Mal ist, dass ein Video (das als fragmentiertes mp4 gespeichert und in Transportstromsegmente gemuxed wurde) ein Segment mit einer Länge (in Bytes) ergab, die perfekt durch 16 teilbar war. Warum 19 Sekunden im Video? Nun, die Segmente sind 10 Sekunden lang und es war das dritte Segment mit dem falschen Padding. Warum trat der Absturz nur bei einigen Android-Versionen auf und scheinbar nicht bei anderen Geräten? Nun, es scheint, dass die meisten Implementierungen für diesen Fehler anfällig sind. Die älteren Versionen von Android waren anfällig, aber modernere Versionen konnten damit umgehen.

Nachdem alle Fragen beantwortet sind und der Fehler behoben wurde, gibt es nichts mehr zu berichten, außer ein paar gelernten Lektionen:

  • Verwenden Sie nach Möglichkeit bestehende, erprobte Implementierungen standardisierter Algorithmen.
  • Wenn Sie ein solches System selbst implementieren wollen, müssen Sie bei den Spezifikationen pedantisch sein. Wenn Sie einen Grenzfall übersehen, wird Sie das teuer zu stehen kommen!
  • Selbst für die wirklich lustigen und seltsamen Bugs gibt es langweilige Lösungen.

Teilen Sie

Brightcove unterstützte den bekanntesten Automobilmarktplatz bei der Verwaltung seiner umfangreichen, älteren Videobibliothek und deren Monetarisierung...
Um die Markenintegrität zu wahren, benötigen Einzelhandelsmarken anpassbare Videoplayer, die es ihnen ermöglichen, die Farben, die Schriftart...
Savoir média bietet seinem Publikum einzigartige Videoinhalte

SIND SIE BEREIT, LOSZULEGEN?

Setzen Sie sich mit uns in Verbindung, um zu erfahren, wie wir Ihre Videomarketing-Bemühungen verbessern und Ihnen dabei helfen können, die gewünschten Ergebnisse und den gewünschten ROI zu erzielen.