[Перевод] Поиск бага регистра, приводящего к вылету Chrome
«Эй ты, функция. Да, я к тебе обращаюсь. При очистке не забудь, пожалуйста, восстановить все мои регистры. Да, и этот тоже, ты что, думаешь, в Linux попала?»
Вот краткое описание проблемы, с которой я столкнулся. ABI (Application Binary Interface) платформы требует от функций, чтобы они сохраняли значения определённых регистров и восстанавливали их в случае использования, однако набор восстанавливаемых регистров зависит от платформы, и правила в Linux отличаются от правил в Windows. Возможно, поэтому я столкнулся с повреждением регистров Chrome в Windows. Но давайте начнём с самого начала.
Меня попросили изучить баг вылета в Chrome. Вылет чётко коррелировал с инъецированием сторонних DLL в процессы Chrome (а их мы не можем поддерживать и не поддерживаем), поэтому была высока вероятность того, что причиной стали эти сторонние DLL, но мне всё равно хотелось понять, что же происходит.
Мои коллеги исследовали этот баг ранее и добавили несколько дополнительных тестов, поэтому вылет был изолирован до этого псевдокода (настоящий код находится здесь):
while (StillRunning()) {
DoLotsOfStuff();
ImportantFunction(std::move(m_ptr));
CHECK(!m_ptr);
}
Показанный выше код представляет собой долго работающий цикл, выполняющий множество действий. В конце каждой итерации он вызывает функцию и перемещает умный указатель на параметры функции, что должно обнулять указатель. Вылет происходил, когда конструкция CHECK замечала, что указатель на источник на самом деле не был обнулён. В таком случае мы намеренно устраивали вылет, чтобы избежать повреждения памяти.
Изучить этот баг меня заставило любопытное поведение последних двух строк. Как мы можем обнулить указатель в одной строке кода, а потом в следующей строке обнаружить, что он ненулевой? Даже если виновниками являются сторонние DLL, как им удаётся это сделать? Я проверил наличие модификаций байтов кода рядом с вылетом и ничего не нашёл, так как же? Мне хотелось это понять.
Истина таится в дампе вылета
Как обычно, я скачал один из дампов вылетов и изучил ассемблерные команды, реализующие исходный код на C++. Ниже в смеси псевдокода и ассемблера показано то, во что транслировался код (подробности см. в комментарии 61 к багу):
xorps xmm7, xmm7 ; Zero register xmm7
while (StillRunning()) {
DoLotsOfStuff();
mov rax, QWORD PTR[rsp + 50h]
movaps QWORD PTR[rsp + 50h], xmm7 ; zero m_ptr
call ImportantFunction
CrashIfNonZero(rsp+50h);
}
Простите за перемешивание метафор; суть в том, что перед запуском цикла компилятор решил обнулить регистр XMM7 (один из регистров SSE). Затем в конце каждой итерации цикла он использует XMM7 для обнуления m_ptr (хранящегося по адресу rsp+50h). Компилятор ожидал, что XMM7 останется обнулённым, но это было не так.
Я изучил большое количество дампов вылетов, чтобы посмотреть, есть ли какой-то паттерн в значениях внутри XMM7. Вот четыре из найденных мной значений:
- 96 12 54 91 ca c8 18 ef 98 e8 77 c9 6e 5d ce ee
- c5 1e 15 13 00 a0 94 5b 37 a5 f3 55 a8 7e 8d 7d
- 54 39 1f 15 3e bf 13 3e 58 98 fd 6d 64 a3 5a 27
- 04 df 90 27 02 94 4c ed 73 65 1d 61 af da 33 36
Если в этих числах и есть паттерн, то я определённо его не вижу. Случайность — это ещё одна улика, ограничивающая список возможных источников проблем.
ABI — это важно
Функции DoLotsOfStuff и ImportantFunction, а также все функции, которые они вызывают, требуются Windows ABI для сохранения XMM7 (в случае Linux это не так). Если они используют его, то обязаны его восстановить. Но одна из них этого не делала (или повреждалось место в стеке, где они хранились, но это кажется менее вероятным). В большинстве вылетов в процессе Chrome присутствовали сторонние DLL. Предположительно, эти DLL должны выполнять перехват функций Chrome или операционной системы, а их инъецируемый код, предположительно, повреждал XMM7.
Я написал об этом твит, пытаясь узнать теории о том, как это могло происходить. Среди прочих ответов с рассуждениями об ISR, DPC и драйверах я увидел ответ от человека, с которым никогда раньше не общался. Если вкратце, он сказал: «А как насчёт этого кода Chromium?»
Я увидел этот твит с моего домашнего ноутбука, а когда пошёл проверить на рабочей машине, автор уже его удалил. Моё любопытство разыгралось, поэтому я написал ему в личку. Он ответил, что код показался ему подозрительным, но потом он понял, что проблему разработчики осознали и что этот код на самом деле не компилируется в Chrome в Windows. В этом и сложность поиска неверного использования XMM7 в исходном коде Chromium — ссылок слишком много (более 17 тысяч), и большинство из них к делу не относится.
Затем он сказал, что перешёл к анализу двоичного файла при помощи IDA Pro
и обнаружил пару функций, попавших в chrome.dll, но не восстанавливавших XMM7. После этого он отправил ссылки на исходный код, который действительно выглядел как реальные баги. Именно в таком случае анализировать двоичные файлы на самом деле проще, чем «читать исходники», потому что в машинном коде все макросы и #ifdef уже обработаны, и в нём видно именно то, что и есть на самом деле.
Я решил воспроизвести его работу при помощи dumpbin /disasm и простого кода на Python для сканирования вывода. Для каждой функции в Chrome (найденной поиском глобальных символов в дизассемблированном выводе) мой скрипт проверял, использовался ли XMM7 без сохранения. Изначально я проверял, записывался ли он относительно rsp перед его первым использованием, но выяснил, что он записывается относительно rax и rbp, поэтому ослабил требования эвристики. Мой скрипт всё равно выдавал ложноположительные срабатывания и мог также выдавать ложноотрицательные, но работал достаточно хорошо, чтобы быть полезным.
Несмотря на первоначальное предположение о том, что баг вызван сторонними разработчиками, мой простой скрипт нашёл множество подозрительных функций. Обнаружилось приблизительно три категории функций, в которых первое использование XMM7 не восстанавливало его:
- Функции наподобие
dav1d_iflipadst_16x8_internal_16bpc_sse4
(отсюда?), являющиеся функциями внутреннего использования для библиотеки dav1d. Все эти функции вызываются обёртками, сохраняющими и восстанавливающими XMM7, то есть с ними всё было в порядке. - Функции наподобие
__longjmp_internal
, которые по определению восстанавливали все долговременные регистры, чтобы они могли возвратиться к предыдущему состоянию выполнения. - Встроенный в Chromium забагованный код.
При помощи этой грубой методики анализа двоичных файлов я в конечном итоге смог найти те же самые забагованные функции в chrome.dll, которые обнаружил мой собеседник в Twitter.
Функция ScaleRowUp2_Bilinear_12_SSSE3 в WebRTC записывала в XMM7 константу 0×0008000800080008 без предварительного сохранения. Это баг, и он может вызывать вылеты, но я знал, что он не был причиной этого вылета, поскольку наблюдавшиеся мной значения XMM7 были сильно случайными. Я отправил отчёт о проблеме автору, он зарегистрировал баг и устранил его в течение 24 часов.
DyadicBilinearQuarterDownsampler_sse в openh264 тоже использовала XMM7 без его сохранения. Видеокодеки часто обрабатывают значения с высокой энтропией, поэтому возможно это могло создавать виденные мной случайные значения (спойлер: причина была не в этом) и это определённо было неправильно. Я зарегистрировал баг, а затем решил устранить его. Внедрение этого исправления вызвало пару сложностей:
- Баг находился в файле на языке ассемблера, использовавшем множество макросов для обеспечения кроссплатформенной корректности. Поэтому мне пришлось выяснять (изучая соседние функции) нужные заклинания для сохранения регистров при необходимости. Это было не так уж сложно, но всегда странно писать код на языке, который, по сути, я совершенно не знаю. Распознавание паттернов — наше всё. Как бы то ни было, исправление в две строки сработало.
- Устранение бага в openh264 не помогло Chrome сразу же, поскольку Chromium использует фиксированные копии сторонних библиотек. Поэтому мне нужно было «накатить» последнюю версию openh264. Иногда используется автоматическая утилита, выполняющая это с регулярными промежутками, но у openh264 её не было. Последний раз openh264 выкатывали шесть месяцев назад, а в промежутке кто-то переместил публичные файлы заголовков в новую папку. Так как Chromium и другой сторонний проект (WebRTC) включали в себя заголовки из этой переименованной папки, чтобы не сломать ничего в WebRTC или Chromium, требовался процесс из восьми этапов (один, два, три, четыре, пять, шесть, семь, восемь). По сути, методика заключалась в условных включениях в WebRTC и в ожидании автоматического накатывания WebRTC в Chromium и наоборот.
Проблемы WebRTC и openh264 были настоящими багами, а их устранение, вероятно, предотвратит будущие вылеты в Chromium, однако они никак не затрагивали исследуемый мной баг. Вылеты продолжались. По-прежнему наиболее вероятным объяснением было стороннее ПО.
Было множество намёков на то, какой тип стороннего ПО может быть проблемой. Это было нечто, создающее данные с высокой рандомизацией. Существовала очевидная корреляция со сторонним ПО шифрования диска. Один пользователь, с которым я исследовал вылеты, использовал сторонний продукт для шифрования диска, а Microsoft заметила корреляцию с задачами, заставляющими работать файловую систему. Были предприняты попытки связаться с поставщиком ПО.
Мы связались с поставщиком (McAfee/Trellix) и он выпустил исправление для продукта Drive Encryption.
Я рад, что первопричина была устранена, но ещё бы мне хотелось, чтобы разработчики, работающие над продуктом, в котором используется язык ассемблера, могли выполнять аудит своего кода, чтобы убедиться, что он соответствует требованиям Windows ABI. Это не первый случай такого класса багов и определённо не последний.
Моя мотивация
Я решил написать эту статью, потому что мне показалось, что это приключение было интересным, но ещё и потому, что оно ещё не закончено. Могут быть и другие регистры, которые неправильно сохраняются и восстанавливаются в Chromium. Могут существовать другие проекты, делающие эту ошибку, иногда незнакомые с различиями между ABI Linux и Windows. Любые правила ПО, которые не тестируются и не применяются принудительно, неизбежно будут сломаны, а мне не известно способов структурированного тестирования для выявления нарушений ABI. Похоже, появление новых багов этого типа неизбежно.
Итог
Эти вылеты начали происходить примерно с версии M91 браузера Chrome. Поначалу они выглядели как баг Chrome, но теперь кажется, что больше вероятность того, что компилятор или код Chromium изменился так, что стал уязвим к повреждению регистра XMM7, которое и так уже происходило в экосистеме. До M91 браузер Chrome вообще не использовал XMM7 в функции RunWorker (я проверял), а начиная с M91 генерация кода изменилась (смена компилятора?) и функция начала полагаться на то, что XMM7 часами оставался обнулённым. Поэтому пожалуйста, восстанавливайте регистры, завершив с ними работу.
И снова спасибо Dougall за демонстрацию проблемы и за то, что вдохновил меня изучить её глубже.