NX-бит на ARM: разработчица полгода ловила баг, который вешал гипервизор на телефоне

NX-бит на ARM: разработчица полгода ловила баг, который вешал гипервизор на телефоне

Автор поста Соня (гостевой пост в блоге разработчика purplesyringa.moe) несколько месяцев отлаживала bare-metal гипервизор на ARM64 для проекта postmarketOS. Как только она включала перехват регистра CTR_EL0, а без этого перехвата весь гипервизор терял смысл, телефон случайным образом зависал, и через несколько секунд срабатывал watchdog, перезагружая систему.

Первая гипотеза, неправильная эмуляция инструкций MRS/MSR, не подтвердилась: обработчик исключений и стек оказались в порядке, пошаговая отладка в QEMU проблем не выявила. Дальше нашёлся реальный, но отдельный баг: массив обработчиков регистров сортировался прямо во время выполнения, а поскольку ARM не гарантирует когерентность инструкционного и данных кэшей, отсортированный код выполнялся по устаревшим данным. Сортировку перенесли на этап сборки, но зависания остались.

Вторая гипотеза, нестандартное поведение конкретного чипа (в телефоне стоял MediaTek MT6735 на ядрах Cortex-A53). Вместе с коллегой по имени Алиса Соня проверила список известных ошибок (errata) для Cortex-A53, совпадений не нашлось. Через objdump она обнаружила 22 обращения к CTR_EL0 в ядре Android, вручную пропатчила бинарник, это сузило проблему до нескольких «горячих» инструкций.

Решающим стал бинарный бисект: Соня свела баг к двум версиям одной и той же функции на ассемблере, семантически идентичным. Версия с прямым статическим вызовом (bl get_ctr_el0) работала; версия с косвенным вызовом через регистр (blr x0), вешала систему. Разница между ними: статический переход всегда ведёт по известному адресу, а косвенный (динамический) вызов процессор пытается предсказать заранее через branch prediction, и может ошибиться. Соня предположила, что при неверном предсказании процессор спекулятивно пытался выполнить инструкцию по адресу 0x0, а в её 1:1-маппинге памяти по этому адресу лежал bootrom, заблокированный после инициализации загрузчика. Именно спекулятивное чтение инструкций из этой области и приводило к зависанию.

Решение, пометить страницы памяти как неисполняемые (non-executable, NX). После этого гипервизор впервые за полгода загрузил телефон до Android без единого патча ядра. Поиск объяснения в документации ARM подтвердил догадку: пометка региона как "Device" (MMIO) блокирует только спекулятивные обращения к данным, а спекулятивные выборки инструкций она не трогает, от них защищает исключительно бит non-executable. Чтобы полностью исключить спекулятивные обращения к региону памяти, его нужно пометить одновременно и как Device, и как non-executable.

Вывод автора: она всегда считала DEP (Data Execution Prevention, тот же принцип, что и бит NX) чисто защитой от атак через переполнение стека, и поэтому не реализовывала его в гипервизоре, не предназначенном для боевой эксплуатации. Но на архитектуре ARM этот бит, не только про безопасность: без него нельзя гарантировать корректность работы с MMIO-памятью, потому что процессор способен спекулятивно "забрести" инструкцией куда угодно. Заодно, реализовав поддержку неисполняемой памяти, Соня пометила так всю память гипервизора, кроме самого исполняемого кода, и получила защиту в глубину бонусом к починенному багу.

Ключевые факты

  • Включение перехвата регистра CTR_EL0 в bare-metal ARM64-гипервизоре для postmarketOS вызывало случайные зависания телефона на MediaTek MT6735 (Cortex-A53) с последующим сбросом по watchdog
  • Первые гипотезы, ошибка эмуляции регистра и рассинхронизация Icache/Dcache при самомодифицирующемся коде, не объяснили баг до конца; отладка заняла несколько месяцев
  • Бинарный бисект свёл проблему к разнице между статическим вызовом функции (bl) и косвенным вызовом через регистр (blr): только динамический переход ломал систему
  • Причина, спекулятивная выборка инструкций процессором при неверном предсказании перехода: она уходила по адресу 0x0, где в 1:1-маппинге лежал заблокированный bootrom
  • Фикс, пометить память как non-executable (бит NX); документация ARM подтвердила, что от спекулятивных выборок инструкций защищает только этот бит, а не пометка региона как Device (MMIO)

Почему это важно

История переворачивает привычное представление о бите non-executable (NX/DEP): его обычно ставят рядом со стеком, чтобы блокировать атаки через переполнение буфера. Здесь тот же бит оказался единственным способом остановить спекулятивную выборку инструкций процессором по случайному адресу, то есть вопросом корректности работы низкоуровневого кода, а не только защиты от атак.

Кому это важно

В первую очередь разработчикам гипервизоров, загрузчиков и другого bare-metal-кода на ARM64, где принято делать 1:1-маппинг физической памяти без разграничения прав доступа. Полезно и разработчикам ядер и драйверов, работающих с MMIO-регионами напрямую.

Как это применить

При разработке низкоуровневого ARM64-кода с прямым маппингом физической памяти помечать как non-executable всё, что не является исполняемым кодом, в том числе MMIO/Device-регионы: пометки "Device" самой по себе недостаточно, она блокирует только спекулятивные обращения к данным, но не к инструкциям. Обходной путь через отключение Icache на время выполнения кода из Device-памяти документация ARM прямо называет некорректным использованием, хоть и не запрещённым технически.

Можно ли доверять

Материал, подробный технический разбор от практикующего разработчика с полным логом отладки: код на ассемблере, дизассемблированные версии рабочей и сломанной функций, вывод objdump, конкретная модель SoC и цитата из официальной документации ARM, подтвердившая гипотезу постфактум. Это гостевой пост в блоге другого разработчика, а не рецензируемая публикация, но фактура проверяема и внутренне непротиворечива.

Риски и подводные камни

Находка описана на одном конкретном SoC (MediaTek MT6735, ядра Cortex-A53); источник не утверждает, что спекулятивная выборка инструкций в незамапленную/заблокированную Device-память ведёт себя одинаково на всех ARM-чипах, общее объяснение автор выводит из документации архитектуры, а не из чужих errata. Также не сказано, попал ли фикс в основной репозиторий postmarketOS или остался локальным патчем автора.

«Здесь легко упустить тонкое различие. Пометка региона как Device предотвращает только спекулятивные обращения к данным. Пометка региона как non-executable предотвращает спекулятивные обращения к инструкциям. Значит, чтобы исключить любые спекулятивные обращения, регион нужно пометить одновременно и как Device, и как non-executable.»

— официальная документация ARM