Год назад я купил макбук. Полгода назад macOS на нем сказала "ой, все", и он окирпичился. Я решил не переустанавливать систему, а попробовать Asahi Linux, и пока что не пожалел об этом. Хотя одна вещь все же раздражала — не работали Netflix и официальное приложение Spotify.
Если честно, Netflix мне не очень-то и нужен — у BitTorrent сейчас намного лучше UX. Но к Spotify я очень привязался, и предпочитаю интерфейс именно у официального клиента, хотя многим это и покажется странным. Но официального клиента Spotify для Linux на архитектуре aarch64 пока что не существует.
Есть, конечно, web-версия. Вернее, была бы, если бы не ошибка:
Playback of protected content is not enabled.
«Воспроизведение защищенного контента отключено», а конкрентее — не установлен модуль Widevine DRM. По этой же причине не работает и Netflix.
Итак, мы начинаем наш челлендж попробуй не нарушить DMCA 2023! Наша задача — понять, как смотреть Netflix на Asahi Linux, не обходя и не ломая DRM. (Без этого условия решения уместится в 280 знаков).
Установка Widevine
К сожалению, нельзя так просто взять и установить Widevine. Единственный официально поддерживаемый способ запустить Widevine — Chrome + Linux + x86_64. Внимательный читатель, конечно, сразу задаст вопросы: почему оно тогда работает и в Firefox? Почему работает на Android, это же тоже Linux на aarch64? Почему работает на Raspberry Pi?
Давайте разберем по порядку.
Почему работает в Firefox + Linux + x86_64?
Веб-страницы получают доступ к модулям DRM через API Encrypted Media Extensions. В самом Chrome DRM не реализован, он делегирует это одной из библиотек CDM, или Content Decryption Module. В случае Chrome + Linux + x86_64, это библиотека libwedevinecdm.so
— проприетарный блоб, заглядывать в который нам запрещено.
К счастью, мы знаем, как с этим блобом общаться: заголовочные файлы для C++ доступны в рамках проекта Chromium. Это позволяет Firefox использовать у себя ровно ту же проприетарную libwidevinecdm.so
, взятую в бинарном виде непосредственно из Chrome. К сожалению, для Asahi Linux нельзя сделать так же — готовой библиотеки для Chrome + Linux + aarch64 не существует.
Почему работает в Android + aarch64?
Если вкратце, DRM на Android в целом работает по-другому. API сильно различаются, взять скомпилированный модуль Widevine для Android просто так не получится, а разобрать его мешает DMCA.
Почему работает на Raspberry Pi?
Как я уже сказал, связка Chrome + Linux + aarch64 официально не поддерживается.
Я солгал.
Хромбуки. В хромбуках работает Chrome, установлен плюс-минус Linux, и многие из них на aarch64. Рано или поздно люди это осознали, и написали утилиту, чтобы вытащить libwidevinecdm.so
из recovery-образов для хромбуков. Raspberry Pi, насколько я знаю, достает реализацию Widevine именно так, даже упаковывая в .deb
-пакет.
К сожалению, есть загвоздка. Хотя в хромбуках есть aarch64
-процессоры и aarch64
-ядра Linux, весь их userspace все еще скомпилирован для 32-битной armv7l
. Для Raspberry Pi это не проблема, но Apple Silicon не способен переварить 32-битный код. Проблема...
...не проблема! Точнее, уже не проблема.
Несколько месяцев назад, когда я только занялся Widevine для Asahi, все было так. Но пару недель назад где-то в Google таки наступил 21 век, и на новых хромбуках userspace компилируется для aarch64
. Значит, libwidevinecdm.so
для Linux + aarch64 теперь можно вытащить из recovery-образов ChromeOS, что Pi Foundation уже успела сделать.
Итак, все готово для...
Widevine для Arch Linux на ARM
Конечно, не все так просто. ChromeOS — это не совсем Linux; кроме прочего, в его glibc
есть не совместимые с Linux патчи. Если просто взять и загрузить libwidevinecdm.so
, получим segfault где-то в недрах glibc
.
Эту проблему решает пакет glibc-widevine. Он патчит glibc
специально для совместимости с Widevine. Похожие патчи есть и в glibc
для Raspbian, разве что там они идут из коробки.
Также нужно пересобрать Chromium с поддержкой Widevine — на Linux + aarch64 это официально не поддерживается, поэтому при стандартной сборке он отключен. Для этого также есть патч.
Итого:
Google публикует образ ChromeOS для aarch64 (включая userspace);
Pi Foundation, или кто-то еще, с помощью скрипта извлекают оттуда блоб с Widevine;
блоб упаковывают в
.deb
-пакет для Raspbian;на
glibc
накатываются патчи для совместимостисобирается патченный Chromium для ARM со включенным Widevine
???
Проблема
Asahi Linux собирается с поддержкой страниц памяти по 16K. Блоб Widevine поддерживает только 4K. Пересобрать ядро под другой размер страниц, конечно, можно, но сейчас для этого нужны костыли и много времени. А просто дизассемблировать и поправить проприетарный блоб нельзя.
Чтобы понять, в чем именно проблема, посмотрим на то, как libwidevinecdm.so
загружается в память. Как и другие .so
-библиотеки, внутри это ELF — Executable and Linkable Format — который разбирается загрузчиком — ядром или ld.so
— и сообщает ему, как именно загрузить код и данные в память и подготовить их к исполнению.
Внутри файлов ELF есть Program Header Table — таблица заголовков, описывающих сегменты программы. Для сегментов с типом LOAD
там описано, как загрузить этот сегмент в память, и разрешить ли эту память читать/писать/исполнять.
С выравниванием этих сегментов и есть проблема. Они загружаются в память вызовами mmap()
, который требует:
чтобы смещение сегмента от начала файла было кратно размеру страницы памяти;
чтобы адрес в памяти, куда загружается сегмент, был выровнен по границе страницы.
Загрузчик проверяет эти ограничения:
case PT_LOAD:
/* A load command tells us to map in part of the file.
We record the load commands and process them all later. */
if (__glibc_unlikely (((ph->p_vaddr - ph->p_offset)
& (GLRO(dl_pagesize) - 1)) != 0))
{
errstring
= N_("ELF load command address/offset not page-aligned");
goto lose;
}
Чтобы не стать goto loser
ом, необходимо убедиться, что (vaddr - offset) % pagesize == 0
, где vaddr
— Virtual (memory) Address — адрес в памяти, куда загрузить сегмент, а offset
— смещение данных в файле библиотеки.
Вот Program Header Table для моей копии libwidevinecdm.so
:
Type Offset VAddr FileSize MemSize Align Prot
PT_PHDR 0x00000040 0x00000040 0x00000230 0x00000230 0x00000008 r--
PT_LOAD 0x00000000 0x00000000 0x00904290 0x00904290 0x00001000 r-x
PT_LOAD 0x00904290 0x00905290 0x00007500 0x00007500 0x00001000 rw-
PT_LOAD 0x0090b790 0x0090d790 0x00000df0 0x00c36698 0x00001000 rw-
PT_TLS 0x00904290 0x00905290 0x00000018 0x00000018 0x00000008 r--
PT_DYNAMIC 0x00909618 0x0090a618 0x00000220 0x00000220 0x00000008 rw-
PT_GNU_RELRO 0x00904290 0x00905290 0x00007500 0x00007d70 0x00000001 r--
PT_GNU_EH_FRAME 0x00524a24 0x00524a24 0x000010fc 0x000010fc 0x00000004 r--
PT_GNU_STACK 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 rw-
PT_NOTE 0x00000270 0x00000270 0x00000024 0x00000024 0x00000004 r--
Я выделил пустыми строками три сегмента PT_LOAD
.
Если pagesize == 0x1000 (4 КБ), то ограничения соблюдаются для всех сегментов. Но стоит увеличить pagesize до 0x4000 (16 КБ), как в Asahi Linux, как второй и третий сегменты PT_LOAD
станут его нарушать. Для других сегментов это не так важно — они не загружаются напрямую через mmap()
.
Решение
Менять положение сегментов относительно друг друга в памяти нельзя — это сломает в коде относительные смещения из одного сегмента в другой. Более того, это библиотека DRM — она злится на любые изменения себя в памяти. А на копание в коде этой библиотеки злится DMCA.
Посмотрим еще раз на наше злополучное условие (vaddr - offset) % pagesize == 0
. Менять vaddr
мы не можем по причинам выше. Но мы можем поменять offset
, если переместим сегменты в самом файле библиотеки.
Для первого PT_LOAD
ничего делать не нужно, но вот для второго получаем vaddr - offset 0x00905290 - 0x00904290 = 0x1000
. Исправим это, добавив 0x1000
байт паддинга между первым и вторым сегментами в файле, не забыв поправить offset
. Теперь vaddr - offset == 0x00904290 - 0x00904290 == 0
. С третьим сегментом поступим аналогично.
При добавлении паддинга в ELF нужно поправить и некоторые другие поля. Но мы меняем только сам ELF-файл — загруженный в память код будет идентичным оригиналу, запущенному на системе со страницами памяти по 4K. Поэтому библиотека при самопроврке ничего не заподозрит и не разозлится.
Гранулярность разрешений
В системах с 4K-страницами каждые 4КБ памяти могут иметь свой набор разрешений на чтение/запись/исполнение. Библиотка была скомпилирована с учетом этой возможности. Но на системах с 16K-страницами гранулярность таких разрешений 16КБ. Это порождает две проблемы.
Во-первых, некоторые секции .text
— исполняемый код — и секции .data
— данные в памяти — теперь имеют общие страницы. Первым нужен доступ на исполнение, вторым — на чтение и запись. Дать и то, и другое можно, но это потенциальная дыра в безопасности. Пока что я не нашел способ этого избежать.
Во-вторых, по той же причине пришлось отключить RELRO — Relocation Read-Only — еще одну меру безопасности, которая отмечает некоторые секции как read-only после загрузки.
Само по себе это — не уязвимости, но это ослабление защиты против потенциально уже существующих. Злоумышленник, теоретически, может записать в такую страницу произвольный код, а затем исполнить его. На практике, ему нужно для начала будет найти уязвимость в браузере. Если это вас пугает, можно использовать для Netflix отдельный браузер.
Патчинг ELF
Сначала я пытался использовать LIEF, но то ли из-за багов, то ли из-за кривости рук у меня не вышло. В конце концов, я в кофеиновом трансе расчехлил hexedit
и поправил все руками. К моему удивлению, это сработало!
Не уверен, что я могу легально распространять патченый ELF, но я написал скрипт на питоне, которым вы можете пропатчить его сами. Достаточно запустить этот скрипт, и вот у вас уже есть libwedevinecdm.so
, которую может загрузить Firefox под Asahi Linux!
Финальные штрихи
Из-за странностей в glibc
на ChromeOS, о которых я говорил ранее, мне пришлось написать библиотечку с функциями __aarch64_ldadd4_acq_rel
и __aarch64_swp4_acq_rel
, которую я загружал через LD_PRELOAD
. Это выглядело не очень красиво, и я стал думать, как можно добавить эти функции в сам libwidevinecdm.so
.
Еще помните, что нам нужно было добавить туда 0x1000 байтов для выравнивания? Они попадают в исполняемую память, поэтому я засунул эти функции туда! Я боялся, что библиотеке это не понравится, но, кажется, она их не заметила при своих проверках. Программа получает их адреса через Global Offset Table — таблицу смещений функций, которая заполняется загрузчиком из данных в самом ELF. Я заменил эти данные так, чтобы они указывали на место, куда я дописал новые функции.
Все это я включил в свой Python-скрипт, который, с одобрения мейнтейнера, добавил в пакет widevine-aarch64
. Теперь достаточно установить widevine-aarch64
из AUR, и с Widevine на Asahi Linux будет готов к работе!
Особенности Netflix
Spotify у меня заработал, но Netflix все равно отказывался что-либо показывать. Дело было в проверке User-Agent. В конце-концов, я поменял его на взятый из ChromeOS:
Mozilla/5.0 (X11; CrOS aarch64 15236.80.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.125 Safari/537.36
Версия Widevine, которую мы получили, называется L3
— наименее защищенный уровень. Более высокие уровни защиты требуют аппаратной поддержки. На Apple Silicon есть нужные чипы, но библиотека не родная, и их поддержка там нет.
Большинство сервисов не дают смотреть 4K-контент на таком уровне защиты — максимум 1080p. Но Netflix и тут отличился: по умолчанию он отдает таким клиентам 720p, а 1080p включает, только если попросить его особым образом на уровне самого протокола. Для этого есть браузерные расширения. Не уверен, зачем они так сделали; возможно, у кого-то из клиентов были проблемы из-за отсутствия поддержки L3
-версией "железного" декодирования видео?
Заключение
Меня забавляет, что все это я делал не чтобы обойти DRM, а наоборот, чтобы оно наконец заработало нормально. Это неправильно! Из того, что я смог легально посмотреть контент, за который я заплатил, в нормальном мире не должно получаться детективной статьи!
Дорогой Гугл, пожалуйста, добавь в матрицу сборки хотя бы Ubuntu на aarch64. Я знаю, тебе не сложно.