Qiling을 사용해 Widevine L3를 에뮬레이션하고, 화이트박스 AES를 식별해 DFA로 키를 복구하며, 가상 머신 기반 난독화를 해제하는 과정을 다룹니다.
Widevine은 Google의 DRM 방식으로, 안전한 하드웨어 또는 난독화된 소프트웨어를 통해 최종 사용자에게 콘텐츠를 안전하게 전달하는 데 사용됩니다. 이 글에서는 Qiling 에뮬레이션 프레임워크를 사용하는 방법과 소프트웨어 전용 DRM을 깨기 위한 여러 기법을 적용하는 방법을 설명하겠습니다. 특히 Android 라이브러리를 Qiling에 로드하는 방법, 실제 표적에 대해 Differential Fault Analysis(DFA)를 적용하는 방법, 그리고 에뮬레이션이 코드 난독화 해제에 어떻게 도움이 되는지를 다룰 것입니다.
먼저 Widevine에 대한 간단한 개요부터 시작하겠습니다.
Widevine에는 세 개의 서버가 필요합니다: Provisioning Server, License Server, Content Server.
신뢰의 루트는 keybox라고 불립니다. 이것은 다음 구조를 가진 바이너리 블롭입니다:
| Keybox | 크기(바이트) |
|---|---|
| Device ID | 0x20 |
| Device Key | 0x10 |
| Data (버전별) | 0x48 |
| Magic (“kbox”) | 0x4 |
| Checksum | 0x4 |
| 합계 = 0x80 |
프로비저닝 모델은 세 가지가 있습니다:
Google은 Provisioning Server와 License Server를 운영하며, 디바이스 인증서의 생성과 관리를 담당합니다. 서드파티 라이선스 프록시는 License Server에 연결되어 클라이언트가 특정 콘텐츠에 대한 라이선스를 요청할 수 있는지 확인합니다. 이후 Content Server가 암호화된 콘텐츠를 제공합니다.
Widevine에는 L1, L2, L3의 세 가지 보안 수준이 있습니다. 각각 요구 사항이 다릅니다.
L1은 Widevine DRM 키와 복호화된 콘텐츠가 오직 안전한 하드웨어에서만 처리되도록 보장하여, 호스트 CPU에 노출되지 않게 합니다.
L2는 Widevine DRM 키만 안전한 하드웨어에 저장되도록 보장하며, 복호화된 콘텐츠는 호스트 CPU가 처리합니다.
L3는 모든 것을 호스트 CPU에서 실행하며, 키는 합리적인 수준으로 보호되어야 합니다.
콘텐츠 제공자는 보안 수준에 따라 특정 콘텐츠(예: 고화질 스트림)에 대한 접근을 제한할 수 있습니다.
제가 Widevine을 깨고 싶었던 이유는 대부분 호기심 때문이었습니다. 최근 몇 년간 Widevine L3는 많이 깨졌지만, 제가 알고 있던 프로젝트들은 모두 Frida hooks를 사용해 실행 중인 Widevine 세션에서 디바이스 키를 덤프하는 방식이었습니다. 이 방법은 Android 기기에 대한 루트 권한만 있으면 되지만, 더 적은 권한으로 가능한 다른 방법이 분명 있을 것 같았습니다. 저는 Exploring Widevine for Fun and Profit 논문을 읽고 큰 자극을 받았는데, 이 논문은 아키텍처가 어떻게 동작하는지 정말 훌륭하게 개괄해 줍니다. 논문에서는 keybox 자체는 사실상 제대로 보호되지 않아 메모리에서 그냥 덤프할 수 있다고 주장합니다. 또한 실제 암호 기술이 두 번 깨졌다고도 언급합니다. 한 번은 David Buchanan이 2019년에 Chrome의 Widevine 버전에서 keybox를 보호하는 데 사용된 화이트박스 AES를 Differential Fault Analysis로 깨뜨렸고, 1년 뒤에는 Tomer Hadad가 RSA를 사용하는 업데이트된 버전을 깨고 콘텐츠 복호화에 사용할 수 있는 Chrome 확장을 공개했습니다. 다만 누가 관여했는지는 이 글에 따르면 다소 논란의 여지가 있어 보입니다.
이 시점에서 제가 작업하려던 L3 버전의 내부 동작에 대해서는 거의 아무것도 모르는 상태였습니다. 저는 단지 블로그 글을 따라 Android Studio Emulator를 사용하고, 글에서 언급된 Frida 스크립트로 디바이스 키를 덤프해 흐름을 좀 더 잘 이해하고, 나중에 제 접근 방식이 제대로 동작하는지 검증하려 했습니다.
Android에서 DRM에 사용되는 구성 요소인 OEMCrypto가 어떻게 동작하는지 이해하기 위해, 저는 Frida를 사용해 동적 분석을 수행하면서 L3 인터페이스의 여러 함수가 어떻게 동작하는지, 그리고 어떤 인자를 필요로 하는지를 파악했습니다. Exploring Widevine for Fun and Profit 논문에는 리버스 엔지니어링된 심볼 매핑이 들어 있습니다. 예를 들어 oecc01은 DRM 컨텍스트를 초기화했고, _lccXX 함수들은 이에 대응하는 L3 함수들입니다. Frida는 강력한 동적 분석 도구이지만, 저는 이것저것 실험해 보기에는 더 고립되고 재현 가능한 환경을 선호합니다. CTF 문제를 풀고 작성하면서 Qiling에 익숙해졌는데, 이것은 내부적으로 Unicorn을 사용해 유저스페이스를 에뮬레이션하고 OS 계층을 재구현하는 Python 프로젝트입니다. 상당히 강력하고, 의도한 대로 동작하지 않을 때도 쉽게 수정할 수 있습니다(그런 일은 자주 일어나지만, 괜찮습니다). 그래서 제 첫 번째 목표는 Widevine을 Qiling 내부에서 실행하는 것이었습니다. 사실 프로젝트에서 가장 많은 시간을 쓴 부분도 여기였습니다.
Widevine을 실행할 수 있으려면 먼저 에뮬레이터에 로드해야 했습니다. 모든 Widevine L3 동작을 구현하는 라이브러리는 libwvhidl.so입니다. 저는 실제 Android 기기에서 사용되는 전체 프로그램(android.hardware.drm@1.1-service.widevine, Android DRM 서비스)이 아니라 이 라이브러리만 계측하고 싶었습니다. 그래서 로더를 속여 이 라이브러리가 실행 파일인 것처럼 생각하게 만들어, 의존하는 모든 라이브러리를 대신 로드하게 하려고 했습니다. 이런 작업은 몇 번 해본 적이 있고, 보통 LIEF를 사용하면 정말 쉽습니다. 심지어 공식 문서에도 방법이 짧게 나와 있습니다. 그런데 이상하게도 이 라이브러리는 손상된 상태가 되어 relocation을 적용할 때 문제가 생겼습니다. 몇 시간 동안 디버깅한 끝에, 다음과 같이 하면 동작하는 ELF를 만들 수 있다는 것을 알아냈습니다:
lib = lief.parse(src)lib[lief.ELF.DynamicEntry.TAG.from_value(0x6000000F)].value = ( lib[lief.ELF.DynamicEntry.TAG.from_value(0x6000000F)].value + 0x1000)lib.interpreter = b"/system/bin/linker"lib.write(dst)
로더는 기쁘게 라이브러리를 로드했고, 놀랍게도 그대로 동작했습니다 ¯\_(ツ)_/¯
이렇게 해서 저는 DRM을 초기화하는 _lcc01 함수를 수동으로 실행할 수 있었고, 그 결과 암호화된 keybox인 /data/vendor/mediadrm/IDM1013/L3/ay64.dat가 생성되었습니다. 모든 것이 의도대로 동작하는지 확인하기 위해, 저는 /data/vendor/mediadrm/의 모든 파일을 Qiling 루트 파일시스템으로 복사한 뒤 DRM을 초기화했습니다. 그런데 제 keybox를 “받아들이지” 않고 항상 새 것을 만들었기 때문에, 이것이 정상적인 키인지 아니면 “쓰레기” 키를 담고 있는지 확신할 수 없었습니다.
또 하나 눈에 띈 점은, 파일시스템을 실행 간마다 정리했는데도 여러 번 실행하면 암호화된 keybox의 내용이 바뀐다는 것이었습니다. 이때 저는 Qiling이 재현 가능하지 않다는 사실을 알게 되었습니다. /dev/random을 /dev/zero에 연결하고, getrandom 시스템 콜을 후킹해 항상 0을 쓰게 만들고, clock_gettime과 gettimeofday 시스템 콜이 고정된 시간을 반환하게 만든 뒤에야 적어도 Qiling 내부에서는 재현 가능한 결과를 얻을 수 있었습니다. 하지만 Android 에뮬레이터에서 Frida로 같은 함수들을 후킹한 뒤에도 여전히 서로 다른 암호화 keybox가 나왔습니다.
from math import floorfrom qiling import Qilingfrom qiling.os.linux.syscall import __get_timespec_structfrom qiling.const import *from qiling.os.const import *import randomrandom.seed(0)FAKE_TIME = 170000000def get_faketime(): tv_sec = floor(FAKE_TIME) tv_nsec = floor((FAKE_TIME - tv_sec) * 1e6) ts_cls = __get_timespec_struct(32) return ts_cls(tv_sec=tv_sec, tv_nsec=tv_nsec)def hook_clock_gettime(ql: Qiling, clock_id: int, tp: int): ql.mem.write(tp, b"\x00" * 8) return 0def hook_gettimeofday(ql: Qiling, tv: int, tz: int): if tv: ql.mem.write(tv, bytes(get_faketime())) if tz: ql.mem.write(tz, b"\x00" * 8) return 0def hook_getrandom(ql: Qiling, buf: int, buflen: int, flags: int): ql.mem.write(buf, b"\x00" * buflen) return buflendef make_deterministic(ql: Qiling): ql.os.set_syscall("getrandom", hook_getrandom, QL_INTERCEPT.CALL) ql.os.set_syscall("clock_gettime", hook_clock_gettime, QL_INTERCEPT.CALL) ql.os.set_syscall("gettimeofday", hook_gettimeofday, QL_INTERCEPT.CALL) ql.add_fs_mapper("/dev/urandom", "/dev/zero")
이 시점에서 저는 두 가지 가설을 세웠습니다. 하나는 여전히 어떤 시스템 콜 처리 차이가 존재한다는 것이고, 다른 하나는 난독화된 부분이 somehow 이것이 실제 기기가 아니라는 점을 감지한다는 것이었습니다. 저는 모든 시스템 콜을 추적했지만 수상한 점은 없었습니다. 이쯤 되자 뭔가 그럴듯한 탐지 로직이 있다는 확신이 들었고, DRM 초기화 이전에 그것을 수행하길 바랐습니다. 그래서 Frida에서 keybox 읽기를 후킹하고, 나중에 Qiling에 불러올 수 있도록 전체 메모리와 CPU 상태를 덤프하는 스크립트를 작성했습니다. 그런데 이유는 알 수 없지만, 이 후크는 프로세스가 매핑한 모든 메모리 영역을 읽어 오지 못했습니다. 그래서 procfs에서 외부적으로 덤프하기 위해 dd를 사용하는 셸 스크립트를 따로 만들었습니다. Frida의 CPU 상태에는 몇몇 레지스터가 빠져 있었지만 중요하지는 않았습니다. 간단한 로더를 작성하고, Frida가 후크를 위해 프로세스 메모리에 가한 수정 사항을 되돌린 뒤, 에뮬레이터 안에서 실행할 수 있게 만들었습니다. 그런데도 여전히 다른 키가 생성되었습니다. 그 시점에서 저는 좌절했고, 결국 실행된 모든 명령어를 추적해 diff하기 시작했습니다. 파일시스템으로부터 디바이스 속성을 에뮬레이터에 제공하고 있었지만, 그것들이 올바르게 해석되지 않았습니다. __system_property_get을 후킹한 뒤 Widevine이 ro.serialno를 확인한다는 점을 알아냈습니다. Qiling 기반 에뮬레이터에서 Android 에뮬레이터의 시리얼 번호를 반환하는 후크를 구현하자, 마침내 keybox를 받아들이고 더 이상 새 것을 만들지 않았습니다.
Qiling 에뮬레이터를 사용해, 논문에서 주장한 것처럼 munmap을 후킹하면서 메모리에서 kbox를 검색하면 keybox를 덤프할 수 있다는 점을 확인했습니다. 이 시점에서 남은 일은 keybox를 보호하는 데 사용된 암호를 깨는 것뿐이었습니다. Widevine을 초기화할 때 Logcat에 다음 메시지가 기록되었습니다: WVCdm : [(0):] Level3 Library 4464 Apr 20 2018 14:54:35. 저는 에뮬레이터를 켜고 Android 9를 선택했는데, Android 에뮬레이터 설정에 참고한 블로그 글이 그렇게 하라고 안내했기 때문입니다. Widevine 버전은 정말 오래된 것이었습니다. 하지만 이 정보를 바탕으로, keybox가 화이트박스 AES로 보호되고 있다는 확신이 들었습니다. 이는 David Buchanan이 이 트윗에서 언급한 바와도 일치합니다. DFA가 실제로 어떻게 동작하는지 전혀 몰랐기 때문에, 저는 Quarkslab의 2018년 훌륭한 블로그 글을 읽었습니다. 자세한 작동 원리는 그 글을 참고하시길 바랍니다. 우리의 목적상 해야 할 일은 다음과 같습니다:
음, 쉬워 보이네요(그리고 나중에 보시겠지만 실제로도 그렇습니다). 가장 어려운 부분은 사실 AES 연산을 식별하는 일입니다. Quarkslab은 TraceGraph라는 추적 프레임워크를 만들었는데, 실행 중 메모리 접근을 시각화합니다. 이 프레임워크는 Valgrind와 PIN에서는 동작하지만 Qiling에서는 동작하지 않습니다. 그래서 저는 그 아이디어를 복제하는 작은 Python 스크립트를 작성했습니다. 제 실행 결과로 이미지 하나를 만들었지만, 출력이 너무 커서 분석하기가 어려웠습니다. 그래서 해상도를 약간 낮췄고, 첫인상으로는 꽤 유망한 그림이 나왔습니다. 메모리 읽기와 쓰기를 모두 로깅함으로써, 메모리 안에서 암호화된 keybox와 복호화된 keybox를 식별할 수 있는 시점을 보고 기록해야 할 범위를 좁힐 수도 있었습니다. 그 결과 이미지는 다음과 같았습니다:

Qiling 내부에서 Widevine L3가 실행되는 모습: x축은 메모리 주소, y축은 시간입니다. 초록색 픽셀은 읽기, 파란색 픽셀은 쓰기, 검은색 픽셀은 실행된 바이트를 나타냅니다.
저는 곧 결과가 유망해 보이긴 하지만, 이미지로 작업하기는 정말 어렵다는 점을 깨달았습니다. 대부분의 뷰어는 아예 이미지를 열지도 못했고, GIMP는 열 수는 있었지만 이미지 크기 때문에 탐색이 매우 느렸습니다. 결국 저는 TraceGraph 호환 데이터베이스를 만들었습니다. TraceGraph는 큰 데이터베이스를 사용할 때 매우 느리고 버벅거립니다. 그래도 제 GIMP 방식보다는 훨씬 잘 동작했기 때문에 대안을 찾아봤습니다. 아예 새로 쓰는 것도 생각했지만, 그건 제가 투자하고 싶은 노력보다 더 큰 일이라고 결론지었습니다. 결국 저는 간단한 Python 모듈을 작성했습니다. 메모리 접근을 명령어와 연결해 해석하는 데 몇 가지 버그가 있지만, 패턴 인식에는 유용합니다.
L3 초기화의 추적을 로드한 뒤에는 시각적으로 검사할 수 있습니다. 다음은 제가 AES를 찾기 위해 사용한 접근입니다.

L3 초기화의 TraceGraph 시각화: x축(왼쪽➛오른쪽)은 메모리 주소, y축(위➛아래)은 시간입니다. 초록색은 읽기, 빨간색은 쓰기, 검은색 픽셀은 실행된 바이트를 나타냅니다.
코드가 난독화되어 있었음에도 불구하고 AES 연산은 놀라울 정도로 쉽게 찾을 수 있었습니다.

AES 연산이 강조된 L3 초기화의 TraceGraph 시각화: x축은 메모리 주소, y축은 시간입니다. 초록색은 읽기, 빨간색은 쓰기, 검은색 픽셀은 실행된 바이트를 나타냅니다.

읽기 연산의 큰 클러스터 네 개와 작은 클러스터 하나, 그리고 반복되는 실행 구조를 보여주는 TraceGraph 시각화
왼쪽의 네 개 박스는 lookup table처럼 보이고, 오른쪽의 하나는 키 관련 내용이 들어 있는 것처럼 보입니다(이 부분은 뒤에서 다시 다룹니다). 내부 상태는 스택에서 관리됩니다. 우선 코드 흐름을 조금 살펴보겠습니다.

이 이미지는 명령어가 세 그룹으로 실행되었음을 보여줍니다.

한 그룹을 확대해 보면 두 개의 뚜렷한 명령어 블록이 있습니다. 두 블록은 서로 비슷해 보입니다.
상위 수준의 AES 알고리즘은 다음과 같습니다:
KeyExpansionAddRoundKeyfor i in range(rounds-1): Substitution ShiftRows MixColumns AddRoundKeySubstitutionShiftRowsAddRoundKey
9개의 “동일한” 라운드가 있으므로, 전체 10라운드를 갖는 AES-128일 수 있습니다. 왜 홀수 라운드와 짝수 라운드에 서로 다른 코드를 사용하는지는 궁금하네요.
첫 번째 라운드는 몇 가지 추가 코드로 시작합니다.

앞부분에 추가 코드가 있는 첫 번째 블록의 시각화
마지막 라운드는 추가 코드로 끝납니다.

마지막 블록은 이전에는 사용되지 않았으며 끝에서 두 번째 블록 이후로 이어집니다.
저의 초기 가정은 다음과 같았습니다:
첫 번째 라운드는 추가적인
KeyExpansion과AddRoundKey로 시작해야 하므로 약간의 추가 코드가 있을 것이다. 마지막 라운드는MixColumns연산을 포함하지 않아야 한다. 이 연산이 다른 연산들 사이에 있기 때문에, 인라인된 구현에서는 이것만 “다른” 코드로 두는 것이 말이 된다.
하지만 나중에 제 CTF 팀의 한 플레이어가 이것이 t-table 구현처럼 보인다고 지적했습니다.
추적에서 이것을 어떻게 알아볼 수 있었는지 보겠습니다. 아까의 네 개 박스를 기억하시나요?

t-table을 보여주는 네 개의 블록
이 박스들이 T-Table입니다.
구현은 다음과 같았습니다:
# generate round keys (rk)# generate t-tables (T0-T3)s0 = rk[0] ^ IN[0:4]s1 = rk[1] ^ IN[4:8]s2 = rk[2] ^ IN[8:12]s3 = rk[3] ^ IN[12:16]for i in range(rounds-1): t0 = rk[(i*8)+ 4] ^ T3[s0 >> 0x18] ^ T2[s1 >> 0x10 & 0xff] ^ T1[s2 >> 8 & 0xff] ^ T0[s3 & 0xff] t1 = rk[(i*8)+ 5] ^ T3[s1 >> 0x18] ^ T2[s2 >> 0x10 & 0xff] ^ T1[s3 >> 8 & 0xff] ^ T0[s0 & 0xff] t2 = rk[(i*8)+ 6] ^ T3[s2 >> 0x18] ^ T2[s3 >> 0x10 & 0xff] ^ T1[s0 >> 8 & 0xff] ^ T0[s1 & 0xff] t3 = rk[(i*8)+ 7] ^ T3[s3 >> 0x18] ^ T2[s0 >> 0x10 & 0xff] ^ T1[s1 >> 8 & 0xff] ^ T0[s2 & 0xff] s0 = rk[(i*8)+ 8] ^ T3[t0 >> 0x18] ^ T2[t1 >> 0x10 & 0xff] ^ T1[t2 >> 8 & 0xff] ^ T0[t3 & 0xff] s1 = rk[(i*8)+ 9] ^ T3[t1 >> 0x18] ^ T2[t2 >> 0x10 & 0xff] ^ T1[t3 >> 8 & 0xff] ^ T0[t0 & 0xff] s2 = rk[(i*8)+10] ^ T3[t2 >> 0x18] ^ T2[t3 >> 0x10 & 0xff] ^ T1[t0 >> 8 & 0xff] ^ T0[t1 & 0xff] s3 = rk[(i*8)+11] ^ T3[t3 >> 0x18] ^ T2[t0 >> 0x10 & 0xff] ^ T1[t1 >> 8 & 0xff] ^ T0[t2 & 0xff]i+=1t0 = rk[(i*8)+ 4] ^ T3[s0 >> 0x18] ^ T2[s1 >> 0x10 & 0xff] ^ T1[s2 >> 8 & 0xff] ^ T0[s3 & 0xff]t1 = rk[(i*8)+ 5] ^ T3[s1 >> 0x18] ^ T2[s2 >> 0x10 & 0xff] ^ T1[s3 >> 8 & 0xff] ^ T0[s0 & 0xff]t2 = rk[(i*8)+ 6] ^ T3[s2 >> 0x18] ^ T2[s3 >> 0x10 & 0xff] ^ T1[s0 >> 8 & 0xff] ^ T0[s1 & 0xff]t3 = rk[(i*8)+ 7] ^ T3[s3 >> 0x18] ^ T2[s0 >> 0x10 & 0xff] ^ T1[s1 >> 8 & 0xff] ^ T0[s2 & 0xff]OUT[0:4] = rk[(i*8)+ 8] ^ T1[t0 >> 0x18] & 0xff000000 ^ T0[t1 >> 0x10 & 0xff] & 0xff0000 ^ T3[t2 >> 8 & 0xff] & 0xff00 ^ T2[t3 & 0xff] & 0xffOUT[4:8] = rk[(i*8)+ 9] ^ T1[t1 >> 0x18] & 0xff000000 ^ T0[t2 >> 0x10 & 0xff] & 0xff0000 ^ T3[t3 >> 8 & 0xff] & 0xff00 ^ T2[t0 & 0xff] & 0xffOUT[8:12] = rk[(i*8)+10] ^ T1[t2 >> 0x18] & 0xff000000 ^ T0[t3 >> 0x10 & 0xff] & 0xff0000 ^ T3[t0 >> 8 & 0xff] & 0xff00 ^ T2[t1 & 0xff] & 0xffOUT[12:16] = rk[(i*8)+11] ^ T1[t3 >> 0x18] & 0xff000000 ^ T0[t0 >> 0x10 & 0xff] & 0xff0000 ^ T3[t1 >> 8 & 0xff] & 0xff00 ^ T2[t2 & 0xff] & 0xff
이제 서로 다른 라운드를 식별했고 AES 키 크기도 알게 되었으니, 마지막 두 MixColumns 연산 사이에서 AES 상태의 단일 바이트를 손상시켜야 합니다. 이 코드 덩어리의 앞부분 어딘가면 충분할 것입니다.

마지막 두 MixColumns AES 연산 사이의 실행을 포함하는 끝에서 두 번째 블록
저는 단순히 명령어 하나를 건너뛰는 방식으로 fault를 도입하기로 했습니다. 다른 방법으로는 레지스터를 손상시키거나, 스택 위의 내부 상태에 직접 값을 쓰는 방법도 있을 수 있습니다.
저는 Qiling에 복호화 직전에 스냅샷을 찍도록 지시했고, 메모리 덤프에서 복호화 결과를 식별할 수 있는 첫 번째 명령어를 후킹했습니다.
그다음, 이 시간 구간 안에 들어 있다고 생각되는 영역에서 명령어들을 하나씩 건너뛰기 시작했고, 마침내 출력에서 정확히 네 바이트만 바뀌는 경우를 발견했습니다.

fault를 주입한 뒤 AES 출력 바이트 네 개가 바뀌었음을 보여주는 추적
이제 동작하는 설정을 확보했으니, 왜 이것이 작동하는지 역으로 살펴보겠습니다. 정확히 네 개의 출력 바이트가 다를 때 우리는 올바른 fault를 얻은 것입니다. t3를 손상시켰을 때 출력에 미치는 영향은 다음과 같습니다:
``t3_is the faulted variablet3OUT[0:4] = [...] ^ T2[t3_ & 0xff] & 0xffOUT[4:8] = [...] ^ T3[t3_ >> 8 & 0xff] & 0xff00OUT[8:12] = [...] ^ T0[t3_ >> 0x10 & 0xff] & 0xff0000OUT[12:16] = [...] ^ T1[t3_ >> 0x18] & 0xff000000
출력의 대부분은 그대로 유지되고, 각 dword마다 한 바이트씩 총 4바이트만 달라집니다.
그런데 어떻게 여기서 키를 복구할 수 있을까요?
라운드 키는 AES 키로부터 파생됩니다. 관련된 모든 연산은 가역적이므로, 어떤 라운드 키에서든 AES 키를 계산할 수 있습니다.
따라서 우리는 라운드 키만 somehow 복구하면 됩니다.
t3의 값을 안다고 가정해 보겠습니다. 그러면 첫 번째 출력 바이트는 다음과 같이 계산됩니다:
OUT[0] = rk[(i*8)+ 8] ^ T2[t3 & 0xff] & 0xff
T2는 비밀이 아니므로, OUT[0] ^ T2[t3 & 0xff] & 0xff를 계산하면 마지막 라운드 키의 한 바이트를 얻을 수 있습니다.
우리는 t3의 값을 모르지만, 어쩌면 추측할 수 있을지도 모릅니다! 사실 필요한 것은 t3 & 0xff 값뿐이며, 이것은 단일 바이트라서 가능한 경우는 256개뿐입니다. 그런데 올바른 값을 어떻게 찾을까요?
fault가 적용된 출력 바이트와 원래 값을 XOR하면(DFA에서 Differential이 바로 이것입니다), 다음을 얻습니다:
OUT[0] = rk[(i*8)+ 8] ^ T2[t3 & 0xff] & 0xffOUT_[0] = rk[(i*8)+ 8] ^ T2[t3_ & 0xff] & 0xffOUT[0] ^ OUT_[0] = T2[t3 & 0xff] & 0xff ^ T2[t3_ & 0xff] & 0xff
이제 마지막 식을 오라클로 사용할 수 있습니다. t3 & 0xff와 t3_ & 0xff의 모든 조합을 대입해 보고, 이 식이 참이 되는 값들만 기록하면 t3 & 0xff의 가능한 후보 목록을 얻습니다. 서로 다른 fault를 더 많이 사용할수록 이 목록은 점점 줄어들어 결국 하나의 값으로 수렴하고, 마지막 라운드 키의 첫 번째 바이트를 복구할 수 있습니다. 이것을 모든 t0-t3와 모든 출력 바이트에 대해 수행하면 전체 라운드 키를 얻을 수 있습니다.
~ Side Track END!
다행히도 이 모든 것을 직접 구현할 필요는 없습니다. phoenixAES는 무거운 작업을 대신 처리해 주는 Python 모듈입니다. 우리는 단지 몇 개의 fault를 생성하고 그것들을 담은 파일을 만들기만 하면 됩니다. 첫 번째 줄은 fault가 없는 출력이어야 합니다.
제 경우 파일은 다음과 같은 형태였습니다:
4f78655756565656516471744a7a4e67b878655756d85656516418744a7a4e0c...
import phoenixAESprint(phoenixAES.crack_file("tracefile", encrypt=False))
몇 개의 fault만 생성했는데도(10개 미만) 스크립트가 키의 일부를 출력하기 시작했습니다.

AES 키의 몇 바이트가 복구됨…
각 출력 바이트에 대해 충분한 fault를 생성하고 나면, 마침내 키를 계산할 수 있습니다.

전체 AES 키 복구 완료
그다음 저는 /data/vendor/mediadrm/IDM1013/L3/ay64.dat에 저장된 디바이스의 keybox를 복호화해 키가 올바른지 검증했습니다.
ROOT_KEY = bytes.fromhex("67B2963950E3ED2E3DC49D5740982BAC")with open("<rootfs>/data/vendor/mediadrm/IDM1013/L3/ay64.dat", "rb") as f: keybox = f.read()decrypted_keybox = AES.new(ROOT_KEY, AES.MODE_CBC, iv=b"\x00" * 16).decrypt(keybox)device_id, device_key, device_data, magic, crc = struct.unpack( "32s16s72s4s4s", decrypted_keybox)assert magic == b "kbox"print("Device id:", device_id.hex())print("Device key:", device_key.hex())print("Device data:", device_data.hex())

복구한 키로 복호화한 keybox
디바이스 키가 있으면, 이제 디바이스 인증서의 래핑된 RSA 키를 복호화하고, 이어서 key ladder를 따라 콘텐츠 키를 복호화할 수 있습니다. 그 콘텐츠 키는 다시 미디어를 복호화하는 데 사용할 수 있습니다. keybox는 새로운 디바이스 인증서를 요청하는 데에도 사용할 수 있습니다. 원래는 여기서 제 Widevine 여정을 끝낼 생각이었습니다. 그런데 이 블로그 글을 쓰는 도중 무언가에 완전히 꽂혀 버렸습니다. 추적 스크린샷을 찍다가 keybox 암호화 이전에 더 많은 AES 연산이 있다는 것을 알아챘기 때문입니다. 추적만 봐서는 그것들이 무엇에 쓰이는지 알아낼 수 없었기에, 이제는 난독화를 깨야 할 때라고 판단했습니다.
난독화를 깨기 위해, 저는 먼저 초기화 코드가 어떻게 동작하는지 이해하려 했습니다. 함수 이름과 전역 심볼은 난독화되어 있었습니다. 이 블로그 글에서는 같은 libwvhidl.so 버전을 따라가 볼 수 있도록 이름을 그대로 두었습니다.
초기화 함수는 _lcc01이라고 불립니다. Widevine 버전 문자열을 로그로 남긴 뒤, 정적이고 무작위처럼 보이는 값들로 16바이트 버퍼를 채우고 큰 배열 구조체(fgqcrnsl)를 구성합니다. 각 엔트리는 5개의 32비트 정수로 이루어져 있습니다. 구조체 정의는 다음과 같습니다:
struct wv_vm_entry { uint32_t offset; uint32_t size; uint32_t always_zero; uint32_t checksum; uint32_t static_def;};
다음은 디컴파일러 출력입니다:
*piVar2 = (int)(piVar2 + 1); pthread_mutex_lock((pthread_mutex_t *)(piVar2 + 3)); wvcdm::Log("","",0,2,"Level3 Library 4464 Apr 20 2018 14:54:35"); CHAR_ARRAY_0038b9e0[0] = 'M'; CHAR_ARRAY_0038b9e0[1] = -0x20; CHAR_ARRAY_0038b9e0[2] = '<'; CHAR_ARRAY_0038b9e0[3] = 'j'; CHAR_ARRAY_0038b9e0[4] = -0x75; CHAR_ARRAY_0038b9e0[5] = '\t'; CHAR_ARRAY_0038b9e0[6] = 'f'; CHAR_ARRAY_0038b9e0[7] = -0x5e; CHAR_ARRAY_0038b9e0[8] = -8; CHAR_ARRAY_0038b9e0[9] = -0x14; CHAR_ARRAY_0038b9e0[10] = 'W'; CHAR_ARRAY_0038b9e0[0xb] = -0x47; CHAR_ARRAY_0038b9e0[0xc] = -3; CHAR_ARRAY_0038b9e0[0xd] = -0x55; CHAR_ARRAY_0038b9e0[0xe] = '\0'; CHAR_ARRAY_0038b9e0[0xf] = '\"'; fgqcrnsl[0x2ed].offet = 0; fgqcrnsl[0x2ed].size = 0x214; fgqcrnsl[0x2ed].always_zero = 0; fgqcrnsl[0x2ed].checksum = 0; fgqcrnsl[0x2ed].always_static = uRam0038b9c0; fgqcrnsl[0x2cb].always_static = uRam0038b9c0; fgqcrnsl[0x2cb].offet = 0x214; fgqcrnsl[0x2cb].size = 0x214; fgqcrnsl[0x2cb].always_zero = 0; fgqcrnsl[0x2cb].checksum = 0; fgqcrnsl[0x21a].offet = 0x428; fgqcrnsl[0x21a].size = 0x214; fgqcrnsl[0x21a].always_zero = 0; fgqcrnsl[0x21a].checksum = 0; fgqcrnsl[0x21a].always_static = uRam0038b9c0; fgqcrnsl[0x363].offet = 0x63c; fgqcrnsl[0x363].size = 0x8dd; fgqcrnsl[0x363].always_zero = 0;
함수의 대부분은 fgqcrnsl 배열을 설정하는 코드였습니다. 나중에는 몇몇 함수가 전역 메모리에 할당됩니다. 이것들은 난독화된 코드를 실행하는 가상 머신의 핸들러/시스템 콜인 것으로 드러났습니다. Widevine은 여러 플랫폼에 배포되므로, 이런 추상화/인터페이스 계층이 있는 것은 말이 됩니다. 그러면 기기 벤더는 디바이스 ID 생성 같은 플랫폼 특화 코드를 작성할 수 있고, Widevine L3 로직 자체를 수정할 필요가 없습니다.
fgqcrnsl[0x259].always_static = uRam0038b9c0; fgqcrnsl[0x259].offet = 0xf9900; fgqcrnsl[0x259].size = 0x214; fgqcrnsl[0x259].always_zero = 0; fgqcrnsl[0x259].checksum = 0; DAT_0038bbb0 = FUN_0008c100(); piVar2 = (int *)DAT_0038bbc4; uRam0038b988 = 0; *(int *)((int)DAT_0038bbc4 + 0x10) = 0; DAT_0038b920 = &DAT_0038ba40; pcRam0038b990 = wvoec3::clear_cache_function; DAT_0038b8f8 = &DAT_0038ba40; pcRam0038baa8 = memcmp; uStack_1c = 0; pcRam0038baac = memset; _DAT_0038ba40 = cmiqoqlf; pcRam0038ba44 = rbovbloj; pcRam0038ba48 = edxbgfhs; pcRam0038ba4c = wvoec3::clear_cache_function; pcRam0038ba58 = adpveuve; pcRam0038ba50 = aykilcti; pcRam0038ba54 = rfdncxfe; pcRam0038ba5c = pnvgwxew; pcRam0038ba60 = htxvewae;
모든 초기화가 끝나면, 함수는 cwkfcplc 함수를 호출해 VM을 시작합니다.
VM_CONTEXT[0x2c] = clock_gettime; VM_CONTEXT[0x2d] = wvoec3::generate_entropy; VM_CONTEXT[0x2e] = tfdlgwrh; VM_CONTEXT[0x2f] = ogfecvcl; piVar2[4] = 0; cwkfcplc(0x18c,0x198,VM_CONTEXT,&uStack_1c); pthread_mutex_unlock((pthread_mutex_t *)((int)DAT_0038bbc4 + 0xc)); if (___stack_chk_guard == local_18) { return uStack_1c; } /* WARNING: Subroutine does not return */ __stack_chk_fail();
첫 번째 인자는 시작할 VM 내부 함수 ID이고, 두 번째 인자는 VM이 종료되어야 하는 VM 내부 함수 ID입니다. 세 번째 인자는 몇몇 VM 함수 할당의 목적지 근처를 가리킵니다. 저는 이것을 VM_CONTEXT라고 불렀습니다. 나머지 인자들은 가변적인 것으로 보입니다.
/* WARNING: Globals starting with '_' overlap smaller symbols at the same address */void cwkfcplc(int start_function_id,int end_function_id,undefined4 VM_CONTEXT,...){ char *pcVar1; int iVar2; int iVar3; int iVar4; undefined4 uVar5; int *piVar6; iVar2 = ___stack_chk_guard; uVar5 = 0x20e4d1; piVar6 = (int *)&__stack_chk_guard; if (start_function_id != end_function_id) { iVar4 = DAT_0038bbb8; do { // ... start_function_id = zmpczfhk(start_function_id,VM_CONTEXT,&stack0x00000010,uVar5,piVar6,&stack0x00000010); // ... } while (start_function_id != end_function_id); } if (*piVar6 == iVar2) { return; } /* WARNING: Subroutine does not return */ __stack_chk_fail();}
여기서 함수 호출 간접화가 실제로 어떻게 작동하는지 볼 수 있습니다. 항상 같은 함수 zmpczfhk를 호출하는 while 루프가 있습니다(아마 이 함수가 VM 코드를 실행하는 것 같습니다). 첫 번째 인자는 실행할 VM 함수를 가리키는 참조를 담고 있습니다. 반환값은 end_function_id와 비교됩니다. 일치하면 루프가 종료되고, 그렇지 않으면 새 start_function_id로 zmpczfhk를 다시 호출합니다. 이런 기법은 제어 흐름을 평탄화하는 데 자주 사용되며, 호출 스택이 없기 때문에 프로그램을 동적으로 분석하기 더 어렵게 만듭니다.
이제 zmpczfhk를 살펴보겠습니다.
/* WARNING: Globals starting with '_' overlap smaller symbols at the same address */undefined4 zmpczfhk(int function_id,undefined4 *VM_CONTEXT,undefined4 param_3){ byte bVar1; int iVar2; uint uVar3; code *pcVar4; undefined4 uVar5; int iVar6; uint uVar7; byte abStack_38 [16]; int local_28 [4]; int local_18 [2]; local_18[0] = ___stack_chk_guard; iVar2 = -0x10; iVar6 = function_id; do { bVar1 = *(byte *)((int)VM_CONTEXT + iVar2 + 0x58); iVar6 = iVar6 * 0x19660d + 0x3c6ef35f; *(char *)((int)local_28 + iVar2) = (char)((uint)iVar6 >> 8); *(byte *)((int)local_18 + iVar2) = bVar1 ^ (byte)iVar6; iVar2 = iVar2 + 1; } while (iVar2 != 0); uVar3 = (*(code *)VM_CONTEXT[4])(function_id); iVar6 = (*(code *)*VM_CONTEXT)(uVar3); iVar2 = (*(code *)VM_CONTEXT[6])(function_id); if (uVar3 != 0) { uVar7 = 0; do { local_28[0] = local_28[0] * 0x19660d + 0x3c6ef35f; *(byte *)(iVar6 + uVar7) = abStack_38[uVar7 & 0xf] ^ *(byte *)(iVar2 + uVar7) ^ (byte)((uint)local_28[0] >> 0x10); uVar7 = uVar7 + 1; } while (uVar3 != uVar7); } pcVar4 = (code *)(*(code *)VM_CONTEXT[7])(function_id,iVar6); (*(code *)VM_CONTEXT[2])(iVar6,uVar3,VM_CONTEXT); (*(code *)VM_CONTEXT[5])(function_id,iVar6,uVar3); uVar5 = (*pcVar4)(function_id,VM_CONTEXT,param_3); (*(code *)VM_CONTEXT[1])(iVar6,uVar3); if (___stack_chk_guard == local_18[0]) { return uVar5; } /* WARNING: Subroutine does not return */ __stack_chk_fail();}
이 코드는 function_id를 시드로 하는 선형 합동 생성기를 사용합니다. 이것은 16바이트 배열 local_28과 local_18을 채우는 데 사용됩니다. 저는 VM 컨텍스트를 구조체로 매핑하기 시작했습니다.
/* WARNING: Variable defined which should be unmapped: local_18 *//* WARNING: Globals starting with '_' overlap smaller symbols at the same address */undefined4 zmpczfhk(int function_id,VM_CTX_STRUCT *VM_CONTEXT,undefined4 param_3){ byte bVar1; int i; uint size; int iVar2; code *pcVar2; undefined4 uVar3; int iVar4; uint j; byte abStack_38 [16]; byte local_28 [16]; byte local_18 [16]; local_18._0_4_ = ___stack_chk_guard; i = -0x10; iVar4 = function_id; do { bVar1 = VM_CONTEXT->encryption_key[i + 0x10]; iVar4 = iVar4 * 0x19660d + 0x3c6ef35f; local_28[i] = (byte)((uint)iVar4 >> 8); local_18[i] = bVar1 ^ (byte)iVar4; i = i + 1; } while (i != 0); size = (*(code *)VM_CONTEXT->aykilcti)(function_id); iVar4 = (*(code *)VM_CONTEXT->cmiqoqlf)(size); iVar2 = (*(code *)VM_CONTEXT->adpveuve)(function_id); if (size != 0) { j = 0; do { local_28._0_4_ = local_28._0_4_ * 0x19660d + 0x3c6ef35f; *(byte *)(iVar4 + j) = abStack_38[j & 0xf] ^ *(byte *)(iVar2 + j) ^ (byte)((uint)local_28._0_4_ >> 0x10); j = j + 1; } while (size != j); } pcVar2 = (code *)(*(code *)VM_CONTEXT->pnvgwxew)(function_id,iVar4); (*(code *)VM_CONTEXT->edxbgfhs)(iVar4,size,VM_CONTEXT); (*(code *)VM_CONTEXT->rfdncxfe)(function_id,iVar4,size); uVar3 = (*pcVar2)(function_id,VM_CONTEXT,param_3); (*(code *)VM_CONTEXT->rbovbloj)(iVar4,size); if (___stack_chk_guard == local_18._0_4_) { return uVar3; } /* WARNING: Subroutine does not return */ __stack_chk_fail();}
이제 관련 VM 함수들을 살펴볼 수 있습니다.
uint aykilcti(int param_1){ DAT_0038b9d8 = fgqcrnsl; return fgqcrnsl[param_1].size;}
이 함수는 function_id 오프셋에 있는 fgqcrnsl 엔트리의 size 필드를 반환합니다. 즉 _lcc01에서 초기화된 큰 배열(fgqcrnsl)에는 VM 함수들에 대한 메타데이터가 들어 있다는 뜻입니다.
undefined * cmiqoqlf(uint param_1){ DAT_0038b688 = getpagesize(); DAT_0038b690 = (param_1 / DAT_0038b688 + 1) * DAT_0038b688; ltbgvbfc = ltbgvbfc | 1; DAT_0038b8d0 = 1; DAT_0038b698 = (undefined *)mmap((void *)0x0,DAT_0038b690,2,0x22,-1,0); ltbgvbfc = ltbgvbfc & 0xfe; DAT_0038b8e0 = DAT_0038b698 == (undefined *)0xffffffff; DAT_0038b8d8 = 1; if (!(bool)DAT_0038b8e0) { return DAT_0038b698; } /* WARNING: Subroutine does not return */ abort();}
이 함수는 첫 번째 인자로 지정된 길이를 수용할 수 있는 새 메모리 영역을 만듭니다. 보호 값 2는 PROT_WRITE입니다.
undefined * adpveuve(int param_1){ DAT_0038b8e8 = &DAT_00288124; DAT_0038b9d8 = fgqcrnsl; return &DAT_00288124 + fgqcrnsl[param_1].offset;}
이 함수는 fgqcrnsl에서 함수 ID에 해당하는 엔트리가 지정한 DAT_00288124 내부 위치에 대한 포인터를 반환합니다. DAT_00288124는 VM을 위한 암호화된 데이터를 담고 있는 것으로 보입니다.
undefined * pnvgwxew(int param_1,int param_2){ DAT_0038b604 = 0; DAT_0038b600 = param_2; DAT_0038b9d8 = fgqcrnsl; DAT_0038b64c = 0; DAT_0038b648 = fgqcrnsl[param_1].always_zero; DAT_0038b698 = (undefined *)(fgqcrnsl[param_1].always_zero + param_2); return DAT_0038b698;}
이 함수는 일부 전역 메모리를 설정합니다. always_zero 엔트리가 매핑된 메모리 영역에 더해집니다. ARM 같은 언어 환경에서는 이것을 이용해 thumb로 전환할 수 있을 것입니다.
void edxbgfhs(void *param_1,undefined4 param_2,int param_3){ longlong lVar1; longlong lVar2; DAT_0038b630 = 0; (**(code **)(param_3 + 0xc))(param_1,param_2); DAT_0038b600 = getpagesize(); DAT_0038b604 = DAT_0038b600 >> 0x1f; lVar1 = (longlong)DAT_0038b600; lVar2 = __udivdi3(param_2,0,DAT_0038b600,DAT_0038b604); lVar1 = (lVar2 + 1) * lVar1; DAT_0038b648 = (size_t)lVar1; DAT_0038b64c = (undefined4)((ulonglong)lVar1 >> 0x20); DAT_0038b8d0 = 4; ltbgvbfc = ltbgvbfc | 4; DAT_0038b630 = mprotect(param_1,DAT_0038b648,5); ltbgvbfc = ltbgvbfc & 0xfb; DAT_0038b868 = DAT_0038b630 != 0; DAT_0038b8d8 = 4; if (!(bool)DAT_0038b868) { return; } /* WARNING: Subroutine does not return */ abort();}
이 함수는 보호 값을 5로 설정하는데, 이는 PROT_READ | PROT_EXEC입니다.
void rfdncxfe(int param_1,int param_2,uint param_3){ bool bVar1; DAT_0038b8a8 = param_3 != 0; DAT_0038b938 = param_1; DAT_0038b9d8 = fgqcrnsl; DAT_0038b678 = fgqcrnsl[param_1].checksum; DAT_0038b8d0 = DAT_0038b678; DAT_0038b668 = 0; DAT_0038b700 = param_2; DAT_0038b61c = 0; DAT_0038b618 = 0; if ((bool)DAT_0038b8a8) { DAT_0038b668 = 0; DAT_0038b618 = 0; DAT_0038b61c = 0; do { DAT_0038b668 = DAT_0038b668 + *(byte *)(param_2 + DAT_0038b618); bVar1 = 0xfffffffe < DAT_0038b618; DAT_0038b618 = DAT_0038b618 + 1; DAT_0038b61c = DAT_0038b61c + bVar1; DAT_0038b8a8 = DAT_0038b61c < (DAT_0038b618 < param_3); } while ((bool)DAT_0038b8a8); } DAT_0038b870 = DAT_0038b678 != DAT_0038b668; if ((bool)DAT_0038b870) { wvcdm::Log("vendor/widevine/libwvdrmengine/level3/x86/libl3oemcrypto.cpp","rfdncxfe",0x152e7,0, "// XXX ERROR: checksum for %zd is %d not %d.\n",param_1,DAT_0038b668,DAT_0038b678); /* WARNING: Subroutine does not return */ exit(1); } return;}
로그 메시지가 이 함수가 하는 일을 거의 알려 줍니다. 메모리 영역의 체크섬을 계산한 뒤 fgqcrnsl 배열에 저장된 체크섬과 비교합니다. 체크섬은 단순히 모든 바이트의 합입니다.
void rbovbloj(void *param_1,undefined4 param_2){ longlong lVar1; longlong lVar2; DAT_0038b618 = getpagesize(); DAT_0038b61c = DAT_0038b618 >> 0x1f; lVar1 = (longlong)DAT_0038b618; lVar2 = __udivdi3(param_2,0,DAT_0038b618,DAT_0038b61c); lVar1 = (lVar2 + 1) * lVar1; DAT_0038b640 = (size_t)lVar1; DAT_0038b644 = (undefined4)((ulonglong)lVar1 >> 0x20); DAT_0038b8d0 = 2; ltbgvbfc = ltbgvbfc | 2; munmap(param_1,DAT_0038b640); ltbgvbfc = ltbgvbfc & 0xfd; DAT_0038b8d8 = 2; return;}
이 함수는 메모리 영역을 다시 unmap합니다.
상위 수준에서 보면 zmpczfhk 함수는 다음을 수행합니다:
function_id를 기반으로 암호화 키를 파생한다function_id를 반환한다)이 로직을 재구현해 복호화된 코드를 덤프할 수도 있고, 게으른 사람이라면 체크섬을 계산하는 함수(rfdncxfe)를 후킹해 실행 직전에 모든 복호화된 메모리를 덤프할 수도 있습니다. 함수를 후킹하는 방식은 실제로 사용되는 것만 복호화하고 제어 흐름도 바로 볼 수 있다는 장점이 있습니다.
다음은 제 에뮬레이터 출력입니다:
checksum function_id: 396checksum function_id: 876108 collapsed lineschecksum function_id: 16checksum function_id: 16checksum function_id: 354checksum function_id: 354checksum function_id: 353checksum function_id: 353checksum function_id: 456checksum function_id: 936checksum function_id: 452checksum function_id: 932checksum function_id: 15checksum function_id: 365checksum function_id: 845checksum function_id: 2checksum function_id: 482checksum function_id: 3checksum function_id: 483checksum function_id: 4checksum function_id: 484checksum function_id: 5checksum function_id: 485checksum function_id: 6checksum function_id: 486checksum function_id: 7checksum function_id: 487checksum function_id: 8checksum function_id: 488checksum function_id: 9checksum function_id: 489checksum function_id: 10checksum function_id: 490checksum function_id: 11checksum function_id: 491checksum function_id: 359checksum function_id: 839checksum function_id: 360checksum function_id: 840checksum function_id: 364checksum function_id: 844checksum function_id: 2checksum function_id: 3checksum function_id: 4checksum function_id: 5checksum function_id: 6checksum function_id: 7checksum function_id: 8checksum function_id: 9checksum function_id: 10checksum function_id: 11checksum function_id: 366checksum function_id: 846checksum function_id: 2checksum function_id: 482checksum function_id: 3checksum function_id: 483checksum function_id: 4checksum function_id: 484checksum function_id: 5checksum function_id: 485checksum function_id: 11checksum function_id: 491checksum function_id: 359checksum function_id: 839checksum function_id: 363checksum function_id: 843checksum function_id: 2checksum function_id: 3checksum function_id: 4checksum function_id: 5checksum function_id: 11checksum function_id: 15checksum function_id: 452checksum function_id: 17checksum function_id: 17checksum function_id: 18checksum function_id: 18checksum function_id: 19checksum function_id: 19checksum function_id: 20checksum function_id: 20checksum function_id: 21checksum function_id: 21checksum function_id: 22checksum function_id: 22checksum function_id: 23checksum function_id: 23checksum function_id: 24checksum function_id: 24checksum function_id: 25checksum function_id: 25checksum function_id: 26checksum function_id: 26checksum function_id: 27checksum function_id: 27checksum function_id: 28checksum function_id: 28checksum function_id: 29checksum function_id: 29checksum function_id: 30checksum function_id: 30checksum function_id: 31checksum function_id: 31checksum function_id: 32checksum function_id: 32checksum function_id: 397checksum function_id: 877checksum function_id: 15checksum function_id: 395checksum function_id: 87530 collapsed lineschecksum function_id: 15checksum function_id: 395checksum function_id: 400checksum function_id: 880checksum function_id: 15checksum function_id: 435checksum function_id: 915checksum function_id: 436checksum function_id: 916checksum function_id: 437checksum function_id: 917checksum function_id: 438checksum function_id: 918checksum function_id: 440checksum function_id: 920checksum function_id: 441checksum function_id: 921checksum function_id: 442checksum function_id: 922checksum function_id: 443checksum function_id: 923checksum function_id: 444checksum function_id: 924checksum function_id: 445checksum function_id: 925checksum function_id: 446checksum function_id: 926checksum function_id: 447checksum function_id: 927checksum function_id: 365checksum function_id: 845checksum function_id: 2checksum function_id: 482checksum function_id: 3checksum function_id: 483checksum function_id: 4checksum function_id: 484checksum function_id: 5checksum function_id: 485checksum function_id: 6checksum function_id: 486checksum function_id: 7checksum function_id: 487checksum function_id: 8checksum function_id: 488checksum function_id: 9checksum function_id: 489checksum function_id: 10checksum function_id: 490checksum function_id: 11checksum function_id: 491checksum function_id: 359checksum function_id: 839checksum function_id: 360checksum function_id: 840checksum function_id: 364checksum function_id: 844checksum function_id: 2checksum function_id: 3checksum function_id: 4checksum function_id: 5checksum function_id: 6checksum function_id: 7checksum function_id: 8checksum function_id: 9checksum function_id: 10checksum function_id: 11checksum function_id: 15checksum function_id: 402checksum function_id: 882checksum function_id: 15checksum function_id: 15
저는 모든 복호화된 함수를 각각 파일로 덤프했습니다. 875의 복호화된 내용은 그냥 ay64.dat였으므로, 암호화된 섹션 안에는 코드뿐 아니라 데이터도 들어 있습니다.
그다음 저는 다음 Python 스니펫을 사용해 모든 복호화된 함수를 포함하는 단일 바이너리를 만들었습니다:
from pathlib import Pathimport osprefix = ""suffix = ""dataset = Path("decrypted_functions")for p in dataset.glob("*.bin"): name = p.name.removesuffix(".bin") with open(p, "rb") as f: data = f.read() prefix += f".globl _{name}\n" suffix += f"""_{name}:.byte {", ".join(f"0x{x:02X}" for x in data)} """with open("out.S", "w") as f: f.write(prefix+suffix)os.system("as out.S --32 -o out")
이제 이 바이너리를 디컴파일러에서 열 수 있습니다. 다음은 첫 번째 함수의 출력입니다.
undefined4 _396(int function_id,VM_CTX_STRUCT *VM_CONTEXT,undefined4 param_3){ uint uVar1; int iVar2; byte *pbVar3; undefined *puVar4; int iVar5; code *pcVar6; undefined4 uVar7; uint local_14; int local_10; uVar1 = (*(code *)VM_CONTEXT->aykilcti)(function_id + 0x1e0); iVar2 = (*(code *)VM_CONTEXT->adpveuve)(function_id + 0x1e0); pbVar3 = (byte *)(*(code *)VM_CONTEXT->adpveuve)(0); puVar4 = (undefined *)(*(code *)VM_CONTEXT->adpveuve)(1); *pbVar3 = VM_CONTEXT->encryption_key[0] ^ 0xf9; *puVar4 = 0x72; pbVar3[1] = ~VM_CONTEXT->encryption_key[1] * -0x27 + 0x55; puVar4[1] = 0xa8; pbVar3[2] = VM_CONTEXT->encryption_key[2] ^ 0xc1; puVar4[2] = 0x1b; pbVar3[3] = VM_CONTEXT->encryption_key[3] & 0x28 | 0xc2; puVar4[3] = 0x1b; pbVar3[4] = VM_CONTEXT->encryption_key[4] ^ 0xb2; puVar4[4] = 9; pbVar3[5] = ~VM_CONTEXT->encryption_key[5] * -0x7d + 0x55; puVar4[5] = 0xe3; pbVar3[6] = 0xea; puVar4[6] = 0x68; pbVar3[7] = VM_CONTEXT->encryption_key[7] & 0x2a | 0x90; puVar4[7] = 2; pbVar3[8] = VM_CONTEXT->encryption_key[8] ^ 0xb1; puVar4[8] = 0xa2; pbVar3[9] = VM_CONTEXT->encryption_key[9] ^ 0x23; puVar4[9] = 0xef; pbVar3[10] = VM_CONTEXT->encryption_key[10] & 0x80 | 0x7a; puVar4[10] = 0x1f; pbVar3[0xb] = VM_CONTEXT->encryption_key[0xb] ^ 0x23; puVar4[0xb] = 99; pbVar3[0xc] = VM_CONTEXT->encryption_key[0xc] ^ 0x3d; puVar4[0xc] = 0x31; pbVar3[0xd] = ~VM_CONTEXT->encryption_key[0xd] * -0x49 + 0x55; puVar4[0xd] = 0x7e; pbVar3[0xe] = VM_CONTEXT->encryption_key[0xe] ^ 0xba; puVar4[0xe] = 1; pbVar3[0xf] = VM_CONTEXT->encryption_key[0xf] ^ 0xf9; puVar4[0xf] = 0xbe; iVar5 = (*(code *)VM_CONTEXT->cmiqoqlf)(uVar1); local_10 = *(int *)pbVar3; for (local_14 = 0; local_14 < uVar1; local_14 = local_14 + 1) { local_10 = local_10 * 0x19660d + 0x3c6ef35f; *(byte *)(local_14 + iVar5) = puVar4[local_14 & 0xf] ^ *(byte *)(local_14 + iVar2) ^ (byte)((uint)local_10 >> 0x10); } (*(code *)VM_CONTEXT->edxbgfhs)(iVar5,uVar1,VM_CONTEXT); (*(code *)VM_CONTEXT->rfdncxfe)(function_id + 0x1e0,iVar5,uVar1); pcVar6 = (code *)(*(code *)VM_CONTEXT->pnvgwxew)(function_id + 0x1e0,iVar5); uVar7 = (*pcVar6)(VM_CONTEXT,param_3); (*(code *)VM_CONTEXT->rbovbloj)(iVar5,uVar1); return uVar7;}
좋습니다. 또 다른 암호화 계층이군요! 하지만 rfdncxfe가 다시 호출되는 것을 볼 수 있으므로, 복호화된 함수는 이미 우리의 바이너리에 있어야 합니다. (396+0x1e0 = 876)
undefined4 _876(VM_CTX_STRUCT *VM_CONTTEXT,undefined4 *param_2){ undefined local_44 [4]; uint local_40; int local_3c; uint local_38; int local_34; uint *local_30; int local_2c; undefined4 *local_28; undefined4 *local_24; VM_CTX_STRUCT *local_20; uint local_1c; undefined local_15; uint local_14; uint local_10; local_20 = VM_CONTTEXT; local_24 = (undefined4 *)*param_2; *local_24 = 0x1c; local_28 = (undefined4 *)(*(code *)VM_CONTTEXT->rfowqsjn)(VM_CONTTEXT,0x10,0x6a4); *local_28 = 0; for (local_10 = 0; local_10 < 0x140; local_10 = local_10 + 1) { local_28[local_10 + 1] = local_10 + 1; *(undefined *)((int)local_28 + local_10 + 0x504) = 1; } local_28[0x191] = 0; for (local_14 = 0; local_14 < 0x10; local_14 = local_14 + 1) { local_28[local_14 + 0x192] = local_14 + 1; *(undefined *)((int)local_28 + local_14 + 0x688) = 1; } local_28[0x1a6] = 0; local_28[0x1a7] = 0; *(undefined *)(local_28 + 0x1a8) = 0; (*(code *)local_20->ydzgdvlz)(VM_CONTTEXT,local_28,0x10); local_2c = (*(code *)local_20->rfowqsjn)(VM_CONTTEXT,0x162,0xd); *(undefined *)(local_2c + 0xc) = 0; *(undefined4 *)(local_2c + 8) = 0; (*(code *)local_20->ydzgdvlz)(VM_CONTTEXT,local_2c,0x162); local_30 = (uint *)(*(code *)local_20->rfowqsjn)(VM_CONTTEXT,0x161,0x10); local_15 = 1; local_34 = (*(code *)local_20->clock_gettime)(1,local_44); local_38 = (*(code *)local_20->generate_entropy)(); if ((local_34 == -1) || (local_38 == 0)) { local_15 = 0; } *local_30 = local_40 ^ local_38; local_30[1] = (int)(local_40 ^ local_38) >> 0x1f; local_34 = (*(code *)local_20->clock_gettime)(1,local_44); local_38 = (*(code *)local_20->generate_entropy)(); if ((local_34 == -1) || (local_38 == 0)) { local_15 = 0; } local_30[2] = local_40 ^ local_38; local_30[3] = (int)(local_40 ^ local_38) >> 0x1f; *local_30 = *local_30 | 1; local_30[1] = local_30[1]; local_30[2] = local_30[2] | 1; local_30[3] = local_30[3]; (*(code *)local_20->ydzgdvlz)(VM_CONTTEXT,local_30,0x161); (*(code *)local_20->tfdlgwrh)(); for (local_1c = 0; local_1c < 0x10; local_1c = local_1c + 1) { local_3c = (*(code *)local_20->rfowqsjn)(VM_CONTTEXT,local_1c + 0x11,0xd40); *(undefined *)(local_3c + 0xd3c) = 0; (*(code *)local_20->ydzgdvlz)(VM_CONTTEXT,local_3c,local_1c + 0x11); } return 0x18d;}
이제는 로직처럼 보이는 부분이 나오기 시작하네요 :)
난독화 해제는 사실 여기까지가 전부입니다. 이제 GhidraFindcrypt를 사용해 AES 상수를 찾을 수 있습니다.

Ghidra에서의 AES inverse sbox
추적에서는 490의 사용 위치를 찾고, 그것을 사용하는 함수를 볼 수 있습니다. 그 전에 함수 10이 호출되며, 이 함수는 함수 845에서 호출되는 것으로 보입니다.
undefined4 _845(int param_1,undefined4 *param_2){ undefined local_140 [244]; undefined4 local_4c; undefined4 local_48; undefined4 local_44; undefined4 local_40; undefined4 local_3c; undefined4 local_38; undefined4 local_34; undefined4 local_30; undefined4 local_2c; undefined4 local_28; undefined4 local_24; undefined4 local_20; undefined4 local_1c; undefined4 local_18; undefined4 local_14; int local_10; local_10 = param_1; local_14 = *param_2; local_18 = param_2[1]; local_1c = param_2[2]; local_20 = param_2[3]; local_24 = param_2[4]; local_28 = (**(code **)(param_1 + 0x28))(param_1,2,0x400); local_2c = (**(code **)(local_10 + 0x28))(param_1,3,0x400); local_30 = (**(code **)(local_10 + 0x28))(param_1,4,0x400); local_34 = (**(code **)(local_10 + 0x28))(param_1,5,0x400); local_38 = (**(code **)(local_10 + 0x28))(param_1,6,0x400); local_3c = (**(code **)(local_10 + 0x28))(param_1,7,0x400); local_40 = (**(code **)(local_10 + 0x28))(param_1,8,0x400); local_44 = (**(code **)(local_10 + 0x28))(param_1,9,0x400); local_48 = (**(code **)(local_10 + 0x28))(param_1,10,0x100); local_4c = (**(code **)(local_10 + 0x28))(param_1,0xb,0x28); (**(code **)(local_10 + 0x24)) (0x167,0x198,local_10,local_140,local_14,local_28,local_2c,local_30,local_34,local_4c); (**(code **)(local_10 + 0x24)) (0x168,0x198,local_10,local_140,local_2c,local_38,local_3c,local_40,local_44); (**(code **)(local_10 + 0x24)) (0x16c,0x198,local_10,local_1c,local_24,local_20,local_140,local_18,local_38,local_3c, local_40,local_44,local_48); (**(code **)(local_10 + 0x2c))(param_1,local_28,2); (**(code **)(local_10 + 0x2c))(param_1,local_2c,3); (**(code **)(local_10 + 0x2c))(param_1,local_30,4); (**(code **)(local_10 + 0x2c))(param_1,local_34,5); (**(code **)(local_10 + 0x2c))(param_1,local_38,6); (**(code **)(local_10 + 0x2c))(param_1,local_3c,7); (**(code **)(local_10 + 0x2c))(param_1,local_40,8); (**(code **)(local_10 + 0x2c))(param_1,local_44,9); (**(code **)(local_10 + 0x2c))(param_1,local_48,10); (**(code **)(local_10 + 0x2c))(param_1,local_4c,0xb); return 0x198;}
이 코드는 여러 가지를 초기화합니다. 그 직후 844가 호출되는데, 이것이 암호화를 수행하는 함수처럼 보입니다.
리버스 엔지니어링을 어느 정도 진행한 뒤, 저는 이 로직을 Python으로 재구현했습니다(분명한 이유로 모든 비밀 값은 가렸습니다).
from Crypto.Hash import SHA1from Crypto.Cipher import AESfrom Crypto.Util import Paddingimport structimport base64from dataclasses import dataclass, fieldfrom typing import Optionalfrom functools import partialimport binasciistruct_keybox_data = ">II16s48s"struct_keybox = "<32s16s72s4s"@dataclassclass KeyboxData: version_major: Optional[int] = None level3_version: Optional[int] = None c: Optional[bytes] = None d: Optional[bytes] = None@dataclassclass Keybox: device_id: Optional[bytes] = None device_key: Optional[bytes] = None data: KeyboxData = field(default_factory=KeyboxData) magic: bytes = b "kbox" crc: Optional[int] = Nonerol = lambda v, n: (v << n | v >> (64 - n)) & ((1 << 64) - 1)def xorshift_next(state: list[int], length): s0 = state[0] s1 = state[1] a, b, c = 55, 14, 36 ret = b"" while length > 0: result = s1 + s0 result &= (1 << 64) - 1 s1 ^= s0 s0 = rol(s0, a) ^ s1 ^ (s1 << b) s0 &= (1 << 64) - 1 s1 = rol(s1, c) ret += struct.pack("<Q", result)[:length] length -= 8 state[0] = s0 state[1] = s1 return retdef prng_init(): return [1, 1]def encode_deviceid(device_id): return ( "".join( chr(ord("a") + x) if x < 0x1B else chr(ord("'") + x) for x in map(lambda x: x % 0x34, device_id[:-1]) ).encode() + b"\x00" )def create_table(): a = [] for i in range(256): k = i << 24 for _ in range(8): k = (k << 1) ^ 0x4C11DB7 if k & 0x80000000 else k << 1 a.append(k & 0xFFFFFFFF) return adef crc32_mpeg2(bytestream): crc_table = create_table() crc = 0xFFFFFFFF for byte in bytestream: lookup_index = ((crc >> 24) ^ byte) & 0xFF crc = ((crc & 0xFFFFFF) << 8) ^ crc_table[lookup_index] return crckey_mask = [ # redacted]vendor_key = [ # redacted]prng_state = prng_init()prng_next = partial(xorshift_next, prng_state)device_id = prng_next(0x20)keybox = Keybox()keybox.device_id = encode_deviceid(device_id)keybox.data.version_major = 2keybox.data.level3_version = 4464keybox.device_key = prng_next(0x10)keybox.data.c = prng_next(0x10)h = SHA1.new()h.update(keybox.device_key)device_seed = bytearray(0x30)device_seed[0:0x10] = keybox.device_keydigest = h.digest()device_seed[0x10 : 0x10 + len(digest)] = digestdevice_seed[0x24] = 3aes_key = bytes([x[0] ^ x[1] for x in zip(vendor_key[:0x10], key_mask[:0x10])])cipher = AES.new(aes_key, AES.MODE_CBC, iv=b"\x00" * 16)some_other_aes_key = cipher.decrypt(keybox.data.c)cipher = AES.new(some_other_aes_key, AES.MODE_CBC, iv=b"\x00" * 16)keybox.data.d = cipher.encrypt(device_seed)keybox_data = struct.pack( struct_keybox_data, keybox.data.version_major, keybox.data.level3_version, keybox.data.c, keybox.data.d,)data = struct.pack( struct_keybox, keybox.device_id, keybox.device_key, keybox_data, keybox.magic)data += struct.pack(">I", crc32_mpeg2(data))h = SHA1.new()h.update(b"0123456789abc") # wvoec3::getUniqueIDay64_encryption_key = h.digest()[:16]cipher = AES.new(ay64_encryption_key, AES.MODE_CBC, iv=b"\x00" * 16)ay64_dat = cipher.encrypt(data)
이제 keybox 암호화 키가 단지 디바이스 ID의 SHA1이라는 것을 알 수 있으며, 그것은 다음과 같이 계산됩니다:
undefined * wvoec3::getUniqueID(uint *param_1){ uint uVar1; uVar1 = property_get("ro.serialno",&DAT_0038bbc8,0); if (((int)uVar1 < 1) && (uVar1 = property_get("net.hostname",&DAT_0038bbc8,0), (int)uVar1 < 1)) { __strncpy_chk2(&DAT_0038bbc8,"0123456789abc",0x5c,0xb8,0xe); } *param_1 = uVar1; return &DAT_0038bbc8;}
keybox의 기반 암호 기술은 이후의 Widevine L3 개정판에서 바뀌었지만, keybox 암호화 자체는 여전히 동일한 것으로 보입니다(Android 16 에뮬레이터와도 대조해 확인해 봤습니다).
이 지식을 바탕으로 벤더 키만 확보할 수 있다면 서로 다른 벤더용 custom keybox를 만들 수 있습니다. 그런데 벤더 키를 얻는 데는 난독화 해제가 필요하지 않습니다. 이 문제에 대해 실질적인 완화책은 없습니다. 결국 언제든지 “그냥 코드를 들여다보는 것”으로 새 버전이 어떻게 동작하는지 알아낼 수 있기 때문입니다. 이것은 설계상의 특징이며, 현실적으로는 큰 문제가 되지도 않습니다. 이미 L3 미디어의 복호화 키를 건네주는 웹 서비스도 존재하므로, 이 연구를 공개한다고 해서 실질적인 영향이 생기지는 않습니다. 이론적으로는 Google이 이 오래된 L3 구현을 사용하는 모든 벤더 키를 폐기해야 하겠지만, 그렇게 하면 오래된 기기에서는 보호된 콘텐츠를 볼 수 없게 됩니다. 특정 keybox만 차단하는 것도 도움이 되지 않습니다. 우리가 새로운 keybox를 만들 수 있기 때문입니다. 개인적으로는 이것이 정말 재미있는 도전이었고, 많은 것을 배웠습니다. DFA 공격을 알아내는 과정에서 난독화가 이렇게 쉽게 깨질 줄은 예상하지 못했습니다. 대부분의 콘텐츠 제공자는 고품질 콘텐츠를 Widevine L1으로 제한하므로, 좋아하는 스트리밍 사이트에서 최신 시리즈를 불법 복제하려면 L3 keybox만으로는 충분하지 않습니다. 저는 TrustZone 연구를 하면서 Widevine L1 구현도 조금 살펴봤고, 실제로 L1 keybox를 얻는 데 성공했습니다. 다만 사람들이 생각할 법한 방식은 아니었습니다 ;)
다른 DRM도 연구해 보고 싶다면 Spotify의 PlayPlay DRM을 추천합니다. 버그 바운티 프로그램에도 포함되어 있습니다.
GitHub의 playground에서 Widevine L3를 자유롭게 실험해 보세요!