복잡한 소프트웨어에는 버그가 존재하며 대규모 프로젝트에서 작업할 때 정기적으로 버그를 만나게 됩니다. 더 기억에 남고 흥미로운 버그는 원인이 단순하고 특이하고 때로는 기괴한 방식으로 나타나는 버그입니다. 이 버그는 하나의 동영상에서 플레이어가 충돌하는 것으로 시작되었습니다. 그 자체로는 그리 드문 신고는 아니지만, 다음과 같은 세부 사항이 눈에 띄었습니다:
- 특정 동영상 하나에서만 발생했습니다.
- Android에서만 발생했습니다(특정 버전의 Android에서만).
- 재생 후 정확히 19초가 지나면 항상 플레이어가 충돌했습니다.
동영상 자체는 m3u 재생 목록 형식에 기반한 조각화된 동영상 전송을 위한 Apple의 표준인 HTTP 라이브 스트리밍을 사용하여 Android 디바이스에 전송되었습니다. 콘텐츠 보호를 위해 PKCS#7 패딩이 포함된 AES-128이 사용되었습니다. 이를 일반적으로 HLS 암호화 (또는 줄여서 'HLSe'라고 함)라고 합니다. MPEG-DASH와 같은 콘텐츠에 사용할 수 있는 다른 형식은 문제가 발생하지 않았으며, FairPlay DRM이 포함된(또는 DRM이 없는) HLS도 마찬가지였습니다. 문제는 "이 동영상"보다 훨씬 더 구체적인 "이 동영상, HLSe 포함"이었습니다.
다행히도 테스트 목적으로 이 버그는 Google의 ExoPlayer 데모 앱에서 쉽게 재현할 수 있었고, 다양한 테스트 기기를 준비했기 때문에 다양한 주요 Android 버전에 대한 로그캣에서 오류 코드와 스택 추적을 얻는 데 그리 오랜 시간이 걸리지 않았습니다. 구체적인 오류는 디바이스의 Android 버전에 따라 달랐지만, 비디오 세그먼트 중 하나에서 미디어를 해독하는 데 문제가 있다는 공통점이 있었습니다. 주요 예외와 스택 추적에서 가장 흥미로운 '원인' 예외는 모두 이 두 가지 예외의 변형이었습니다(테스트한 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로 작성되어 있어 익숙하지 않다면 꽤 어렵게 느껴질 수 있습니다. 다행히도 "원인"은 다음과 같이 읽기 쉬운 유형으로 되어 있습니다. javax.crypto.BadPaddingException
를 사용하여 문제를 더 좁히는 데 도움이 됩니다. 문제는 패딩에 있습니다. Apple의 사양에 따르면 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
간단히 설명하자면, 패딩된 바이트의 값은 사용 중인 블록 크기까지 패딩되는 바이트 수입니다.
패딩이 작동하는 방식과 잘못된 패딩을 나타내는 오류를 이해한 다음 단계는 비디오의 패딩을 처리하는 코드를 확인하는 것이었습니다. 문제의 서비스는 표준 라이브러리에 PKCS#7이 구현되어 있지 않은 Go로 작성되었기 때문에 자체적으로 구현했습니다. 여기 있습니다:
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 앱의 출력보다 정확히 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의 블록에는 다음이 필요합니다. 16-(0 mod 16)
바이트의 패딩을 제공합니다. 16-(0 mod 16)
같음 16
... 실제로 이것은 마지막에 전체 패딩 블록이 있어야 하며 패딩된 각 바이트의 값은 16으로 설정되어 있습니다! 드디어 단서를 찾았습니다! 우리의 Go 코드는 사양을 준수하지 않습니다!
문제를 일으키는 동일한 콘텐츠에 대한 패치를 테스트하는 과정에서 어느 정도 진척이 있었고, 마침내 동영상에서 19초가 지나면 정상 재생이 가능해졌습니다! 결국 결함이 있는 조건문을 다시 작성하여 읽은 바이트 수에 관계없이 마지막 블록을 채우도록 수정했습니다. 이렇게 해서 문제가 완전히 해결되었습니다!
요약하자면, 이 버그 보고서의 모든 이상한 특징은 시스템에서 테스트한 모든 콘텐츠 중에서 (조각화된 MP4로 저장되어 전송 스트림 세그먼트로 믹싱된) 비디오의 길이(바이트 단위)가 16으로 완벽하게 나눌 수 있는 세그먼트가 발생한 것은 이번이 처음이라는 희한한 현실에서 비롯되었습니다. 왜 동영상에 19초가 포함되나요? 세그먼트의 길이가 10초이고 패딩이 잘못된 세 번째 세그먼트였기 때문입니다. 왜 일부 버전의 안드로이드에서만 충돌이 발생하고 다른 기기에서는 발생하지 않나요? 대부분의 구현은 이 실수에 대해 회복력이 있는 것 같습니다. 이전 버전의 Android는 취약했지만 최신 버전에서는 이를 처리할 수 있습니다.
모든 질문에 대한 답변이 제공되고 버그가 수정되었으므로 몇 가지 교훈을 얻은 것 외에는 더 이상 다룰 내용이 없습니다:
- 가능한 한 기존의 검증된 표준화된 알고리즘 구현을 사용하세요.
- 직접 구현하게 된다면 사양에 대해 현학적이어야 합니다. 엣지 케이스를 놓치면 대가를 치르게 됩니다!
- 정말 재미있고 이상한 버그도 지루한 해결책이 있을 수 있습니다.