четверг, 1 января 2015 г.

Парад планет

В фильме «2012» парад планет 
оказывает негативное влияние на Солнце,
 что приводит к ужасным катаклизмам на нашей планете.
 (выдержка из Wikipedia)

Был на работе недавно довольно эпичный баг. Сформулирован он был примерно следующим образом: после развертывания компоненты FDE (Full disk encryption) Windows загружается но на экране ничего не отображается (черный экран). Разбирательство с этим багом у нас заняло несколько месяцев (естественно, не фулл-тайм), и вот, наконец, мы докопались до сути.

Итак, в этом баге встретились несколько особенностей: во-первых, особенности реализации  эмулятора 16-ти битного кода в ядре Windows, во-вторых, особенности BIOS конкретной машины, ну и в-третьих, наша пребутовая компонента, которая перехватывает некоторые прерывания BIOS для своих нужд.


Часть первая, теоретическая
Пребутовая компонента FDE
Итак, давайте начнем с того, о чем мы знаем более менее наверняка - с нашей пребутовой компоненты. Мы перехватываем несколько прерываний BIOS: INT13h и INT15h, первое прерывание отвечает за дисковый ввод-вывод, мы перехватываем запросы на чтение/запись и осуществляем расшифровку данных - это необходимо делать в случае зашифрованного диска, чтобы клиент мог видеть актуальные данные. Эта компонента работает до того момента, как будет загружен драйвер в винде. Второе прерывание отвечает за актуальную карту памяти (в ней помечены занятые и свободные области), грубо говоря, эта карта памяти используется дальше виндой для того чтобы размещать свои компоненты и не пересекаться с занятыми областями. Мы перехватываем INT15h прерывание для того, чтобы пометить в карте памяти занятые нами области - чтобы винда случайно не перезатерла наши данные в процессе загрузки.
Опять же повторюсь, эти хуки нам нужны только для того, чтобы загрузить винду и стартовать наш драйвер, после этого наш драйвер берет на себя управление запросами к диску и все, 16-ти битный код больше не нужен.

Теперь давайте поговорим о том, как размещается наш пребутовый код в памяти. Мы вынуждены находиться ниже первого мегабайта: по адресам от 0x00000000 до 0x000FFFFF, потому-что реальный режим (real mode) процессора и все такое :). К слову, в BIOS есть два механизма получения информации о свободной памяти. Первый - это с помощью прерывания INT15h, и второй - через BDA (Bios Data Area).Мы располагаем наш код в свободной памяти, модифицируем значение в BDA, учитывая наше расположение и ставим свой обработчик INT15h, чтобы он тоже возвращал актуальную карту памяти.
В BDA записано значение верхней границы доступной памяти, нижняя граница начинается примерно с 0x00000500. Таким образом, у нас есть диапазон от 0x00000500 до некого X, который обычно меньше  0x000A0000 (ниже 640 килобайт). Более детально про карту памяти BIOS можно почитать здесь, нас же пока интересует именно этот свободный диапазон, так как в нем будет происходить вся работа до старта. Вот как упрощенно выглядит эта память:
Итак, все это выглядит довольно запутанным, но на самом деле про тот же BIOS есть много статей на osdev.org, и вообще есть довольно много документации и примеров исходного кода.

Подсистема эмулятора в HAL.DLL
Теперь мы переходим в область недокументированных возможностей ядра Windows - эмулятор 16-ти битного кода в ядре Windows. Во всех версиях 64-битных Windows нет поддержки 16-ти битного кода, однако, в ядре Windows он необходим как минимум одному из драйверов - а именно, драйверу видео подсистемы VIDEOPRT.SYS. Этот драйвер использует INT10h BIOS для того чтобы получить список видеорежимов и установить один из них. Для того чтобы этот драйвер мог работать, в ядре Windows был сделан полностью софтверный эмулятор 16-ти битного режима.
Сам эмулятор реализован в HAL.DLL - эта компонента стартует на раннем этапе загрузки системы - сразу после ntoskrnl. Подробно про эмулятор можно почитать здесь и здесь. На самом деле, эти статьи описывают эмулятор для Windows xp 64 и Windows Vista 64, в нашем случае была Windows 7 64, и в подсистеме эмулятора были небольшие изменения, но это не принципиально.


Инициализация эмулятора
 
Инициализация эмулятора происходит в функции hal!HalInitializeBios. В этой функции мапятся и копируются в память ядра первые 0x2000 байт первого мегабайта BIOS, там располагается IVT, также настраиваются указатели на разные области памяти из первого мегабайта. Упрощенно память для эмулятора выглядит примерно так:

 Светло-серым отмечены области, которые скопированы из соответствующих областей первого мегабайта. Темно-серым отмечена область VGA memory, единственное отличие - эта область опциональна. Если ее не задать, то область будет помечена как scratch memory. Кроме того эта область может быть замаплена на реальное расположение VGA memory (что обычно и делается). Белым обозначена область, которая является неиспользуемой с точки зрения эмулятора, перед каждым обращением к этой области она принудительно зануляется.
Доступ к памяти эмулятора осуществляется с помощью функции hal!x86TranslateAddress, вот ее сигнатура (восстановлена вручную из ассемблерного листинга):
void* x86TranslateAddress(USHORT segment, USHORT offset); 
Собственно, приведенная выше карта памяти эмулятора как раз и восстановлена из ассемблерного листинга функции.
Важное замечание: область scratch memory - это фиктивная область данных, при попадании адреса в этот диапазон всегда вовзвращается один и тот же фиксированный адрес.


Работа эмулятора 

Теперь давайте рассмотрим функцию hal!x86CallBios, ее сигнатура следующая: 
void x86CallBios(ULONG intNo, BIOS_REGS* registries);
Эта функция позволяет вызвать программное прерывание (параметр intNo), в качестве параметров прерыванию передается структура BIOS_REGS - эта структура, которая представляет регистры процессора, соответственно, после выхода из функции вызывающая сторона анализирует значение нужных регистров и определяет, что прерывание выполнилось успешно (или неуспешно). Собственно драйвер VIDEOPRT.SYS как раз и вызывает эту функцию, для получения и установки видеорежимов.
Как эта функция работает:
  1. По номеру прерывания из IVT мы получаем сегмент и оффсет точки входа в обработчик прерывания, эта пара значений устанавливается как текущий instruction pointer
  2. С помощью функции x86TranslateAddress получаем адрес где располагается начало инструкции
  3. Берем один байт из полученного адреса, пытаемся декодировать его как инструкцию
    • Так как длина инструкции не фиксирована, то по мере декодирования инструкции и ее аргументов эмулятор несколько раз зовет функцию x86TranslateAddress для получения последующих значений.
  4. После того как инструкция декодирована, вызывается соответствующая функция эмулятора, которая реализует ту или иную инструкцию.
  5. Увеличивается instruction pointer и эмулятор переходит к декодированию следующей инструкции (пункт 2).
Выполнение эмулятора завершается после выхода из обработчика прерывания, либо при любой возникшей ошибке (например, invalid instruction и т.д.).
Важное замечание: Все обращения к памяти (чтение, запись) идут через функцию x86TranslateAddress


Особенности системы

Теперь давайте рассмотрим, какие особенности системы привели к возникновению бага. На самом деле особенности здесь две: 
  1. Граница свободной памяти в BDA указывает на память чуть выше адреса 0x00090000, конкретно на этой машине это было значение 0x0009BC00
  2. При вызове INT10h - а именно, установка видео режима - зовется прерывание INT15h

 
Собираем все вместе
 
Собственно, перечисленные выше особенности системы ничем не примечательны. Но теперь давайте соберем все вместе:
  1. Наш код располагается ниже границы 0x0009BC00, например, один из наших билдов  располагается в диапазоне 0x0008BD00 - 0x0009BC00, то есть часть наших компонент располагается ниже адреса 0x00090000.
  2. Стартует ядро, инициализирует эмулятор. При этом собирает карту памяти для эмулятора по приведенной выше картинке, принудительно обрезая всю память ниже адреса 0x00090000
  3. Драйвер VIDEOPRT.SYS вызывает функцию hal!x86CallBios, эмулятор начинает выполнение INT10h, в процессе своего выполнения код BIOS зовет INT15h, эмулятор получает сегмент и оффсет начала функции INT15h, и этот адрес получается ниже границы 0x00090000, и попадает в регион scratch memory, считывает значение 0x00, но это значение, как назло, является валидным опкодом инструкции ADD (один из вариантов этой инструкции). Эмулятор выполняет код из scratch memory, увеличивая instruction pointer, и в конечном итоге, доходит до адреса 0x00090000, начинает его исполнять и завершает работу эмулятора(по всей видимости, из-за invalid instruction).
  4. Драйвер VIDEOPRT.SYS делает две попытки установки видео режима, после чего сдается ипродолжает работу дальше, винда загружается, но экран остается полностью черный.

Понятно, что если исключить хотя бы одну из компонент, то баг перестает воспроизводиться: например, сделать билд поменьше - чтобы он влезал выше границы 0x00090000, либо отключить хук прерывания INT15h, баг тоже перестает воспроизводиться - потому что в этом случае адрес обработчика прерывания INT15h находится выше адреса 0x00090000. Аналогично, если бы эмулятор копировал весь первый мегабайт, то проблемы тоже бы не было.




Часть вторая, практическая
Итак, теперь немного о том, как баг был пойман: сначала экспериментальным путем было выяснено, что отключение хука прерывания INT15h решает проблему. Также, экспериментальным путем было выяснено, что если код помещается выше адреса 0x00090000, то проблема не воспроизводится. Потом выяснили, что в Windows есть такая подсистема, как эмулятор и начали дебажить ее. Ключевой момент в этом - это конечно получение того факта, что в винде есть эмулятор 16-ти битного кода, после этого основное подозрение пало именно на него.




Отладка эмулятора

Отлаживать эмулятор пришлось около недели. С помощью conditional breakpoints в windbg сделали примитивный трассировщик аргументов функции hal!x86BiosCall:
bu hal!x86BiosCall ".echo {; r rdx; r rcx; r $t0 = rdx; dd @rcx L8; gu; r rax; dd @$t0 L8; .echo }; g"
Этот трассировщик выплевывает в лог отладчика все входные и выходные аргументы указанной функции, и кроме того, выводит содержимое второго аргумента (указателя) функции. С помощью этого трассировщика мы получили аргументы, с которыми вызывается INT10h когда происходит ошибка.
Затем написали синтетический тест: до старта винды (без эмулятора) сами позвали INT10h с указанными параметрами и внезапно, в логе увидели, что позвался наш обработчик INT15h. После этого стало понятно, что при установке видео режима зовется INT15h - это объясняло то, что при отключении INT15h баг не воспроизводился.
Затем, начали отлаживать и дизассемблировать функцию hal!x86BiosCall, нашли функцию x86TranslateAddress - дизассемблировали ее и по ней восстановили карту памяти для эмулятора. После этого сделали пару тестовых запусков под отладчиком, убедились, что действительно в эмуляторе вызывается INT15h, увидели, что адрес обработчика лежит ниже границы 0x00090000, и увидели, что эмулятор пошел исполнять "код" из scratch memory.


Послесловие
Баг был найден, но к сожалению, нельзя сделать такой фикс, который бы на 100% решал эту проблему и не привносил новых. Одно из самых очевидных (и довольно безопасных) решений - проверять, что наш код помещается выше адреса 0x00090000, в принципе, мы даже можем перенести весь критичный код как можно выше (поколдовав с параметрами линкера). Но все равно, потенциально могут существовать BIOS, в которых граница свободной памяти может меняться от запуска к запуску (самый простой вариант - поменяли какую-то настройку в BIOS - граница памяти изменилась). Есть и другие, более экзотические решения - например, попытаться стартовать свой драйвер до HAL.DLL и в нем отключать наш хук INT15h, и уже потом только грузить HAL.DLL. Но проблема в том, что написать такой драйвер, и тем более стартовать его до HAL.DLL довольно проблематично, хотя и возможно (теоретически). В принципе, можно опубликовать баг в Microsoft и попытаться решить эту проблему хотя бы для новых версий ОС или апдейтов.

2 комментария:

  1. Если до границы 90000 остается хотябы десяток байт, то можно сделать проверку на то что наш код ниже 90000 не нули и если так, то сразу вызвать оригинальный обработчик int15.

    ОтветитьУдалить
    Ответы
    1. Это все полумеры, нет гарантии, что код проверки влезет. Самое надежное решение на данный момент - объявить данный ноутбук как неподдерживаемый или частично поддерживаемый. Все остальные решения - костыли в том или ином виде.

      Удалить