どんな複雑なソフトウェアにもバグは存在し、大規模なプロジェクトに携わっていると定期的に遭遇する。より印象的で興味深いバグは、単純な原因でありながら、普通ではない、時には奇妙な形で現れるものだ。このバグは、1本の動画が原因でプレーヤーがクラッシュしたことから始まった。それ自体はそれほど珍しい報告ではないが、次のような詳細が際立っていた:
- 特定のビデオ1本で起きた
- アンドロイドでのみ発生(ただしアンドロイドの特定のバージョンでのみ発生)
- いつも再生開始19秒後にクラッシュする
動画自体は、m3uプレイリストフォーマットに基づく断片化された動画配信のためのAppleの標準規格であるHTTP Live Streamingを使用してAndroidデバイスに配信されていた。コンテンツ保護には、PKCS#7パディング付きのAES-128が使用されていた。これは一般にHLS Encryption(略して "HLSe")と呼ばれている。MPEG-DASHなど、コンテンツに利用可能な他のフォーマットではこの問題は発生せず、FairPlay DRM付きのHLSも(あるいはDRMなしも)発生しなかった。問題は、"このビデオ "よりもさらに具体的で、"HLSeを使ったこのビデオ "だった。
幸いなことに、このバグはGoogleのExoPlayerデモアプリで簡単に再現することができ、テスト用のデバイスもそれなりに揃っていたので、様々な主要Androidバージョンのエラーコードとスタックトレースをlogcatから取得するのに時間はかからなかった。具体的なエラーの内容は、デバイスのAndroidのバージョンによって異なるが、それらはすべて共通のテーマを持っていた。主な例外と、スタックトレースのさらに下にある最も興味深い "Caused by "例外は、すべてこの2つの亜種だった(テストしたAndroidのバージョンによって表現は異なる):
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)
コード的に深く掘り下げると、ちょっとしたウサギの穴になる。核となる暗号コードはCで書かれており、慣れていないとかなり気後れしてしまう。幸運なことに、この "causeed by "には読みやすい型がある。 javax.crypto.BadPaddingException
問題はパディングにあります。アップル社の仕様によると、HLSeで使用されているパディング・アルゴリズムはPKCS#7で、以下のように記述されています。 RFC-5652 第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.
もしあなたが私のようなものなら、この説明を頭に思い浮かべるのは少し難しいだろう。そこで、いくつかの例を紹介しよう(すべて16バイトのブロックサイズにパディングしている):
88 7c 46 66 9a 2f a2 59 4d 1e
で埋められる。06 06 06 06 06 06
63
で埋められる。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
で埋められる。01
わかりやすく言えば、パディングされるバイト数は、使用されるブロックサイズまでのバイト数である。
パディングがどのように機能すべきかを理解し、不正なパディングを示すエラーを理解した上で、次のステップとして、動画のパディングを処理するコードをチェックした。問題のサービスはGoで書かれており、Goの標準ライブラリにはPKCS#7の実装がないため、サービスは独自の実装を持っている。これがそれだ:
PKCS7Pad(in []byte) []byte {
padding := 16 - (len(in) % 16)
for i := 0; i < padding; i++ {
in = append(in, byte(padding))
}
return in
}
短くシンプルで...明らかな間違いもない。私たちが期待している通りの振る舞いをするユニットテストもある。だから、もう少し手がかりが必要なのかもしれない。この時点で、私たちのコードが生成しているものを他の実装と比較するために回り道をした。幸運なことに、HLSe セグメントに適用される AES-128 暗号化は、他のコンテンツ保護スキームで 起こりうるような、ファイルコンテンツの一部分に対してではなく、セグメントファイル全体に対して適用される。暗号化された代替セグメントを作成するのは、OpenSSLで簡単にできましたし、ダイナミックメディアパッケージャーを使って、暗号化するセグメントの保護されていないコピーを入手することもできました。平文でダウンロードされたセグメントと、AES-128 で暗号化されたセグメントを使って、以下のコマンドで暗号化された代替セグメントを生成した:
openssl aes-128-cbc -K $KEY -iv $IV -in clear.ts -out alt_protected.ts
OpenSSLからの暗号化の出力は、私たちのGoアプリからの出力(つまり1ブロック)よりちょうど16バイト大きかったのです。おそらく問題はパディングにあるのではなく、最後のブロックが追加されていない奇妙なエッジケースにあるのだろう。この新しい情報を念頭に置いてGoコードをもう一度見直すと、以下の条件が怪しく見えてきた:
// If this block does not match the AES block size we need to pad it out
if bytesRead != aes.BlockSize {
額面通り、このアルゴリズムは、入力バッファからブロックサイズに等しい長さのチャンクでデータを読み取るだけである。その後、いくつのブロックが読み込まれたかをチェックし、必要であれば16バイトにパッドする。最後に16バイトのバッファを暗号に渡し、出力を出力バッファにストリーミングする。現実的には、この条件がトリガーされるのは最後のブロックだけである。最終ブロックの長さがブロック・サイズと等しい場合はどうなるか?Goのコードは単に何もせず、パディングは必要ないと主張する。念のため、もう一度仕様を確認してみよう:
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.
だから、もし k
はブロックサイズであり lth
は入力の長さである。 16-(0 mod 16)
バイトのパディング。 16-(0 mod 16)
イコール 16
...だから実際には、これは最後にパディングのブロック全体があり、パディングされた各バイトの値は16に設定されているはずだ!最後に、リード!我々のGoコードは仕様に準拠していない!
問題を提示している同じコンテンツに対してパッチをテストすることで、多少前方へ飛ばし、ビデオの19秒を過ぎたあたりでようやく正常に再生できるようになった!最終的には、何バイト読み込まれたかに関係なく最後のブロックをパッドするように、欠陥のある条件を書き換えることで解決しました。これで問題は一挙に解決した!
要約すると、このバグ報告の奇妙な特徴はすべて、私たちのシステムでテストしたすべてのコンテンツの中で、ビデオ(断片化されたMP4として保存され、トランスポートストリームセグメントにmuxされた)が16で完全に割り切れる長さ(バイト単位)を持つセグメントを持った初めてのものであるという、ありそうもない現実に起因している。なぜ19秒なのか?セグメントの長さは10秒で、パディングが正しくないのは3番目のセグメントだからです。なぜクラッシュはアンドロイドのいくつかのバージョンでのみ発生し、他のデバイスでは発生しなかったのでしょうか?大半の実装はこのミスに強いようです。アンドロイドの古いバージョンは影響を受けやすかったが、最近のバージョンでは対処できた。
すべての疑問が解決し、バグも修正されたので、あとはいくつかの教訓を残すのみである:
- 可能な限り、標準化されたアルゴリズムの、戦場でテストされた既存の実装を使用する。
- もし自分で実装するのであれば、仕様に忠実でなければならない。エッジケースを見逃すと損をする!
- 本当に楽しくて奇妙なバグでさえ、退屈な解決策がある。