Немного о багах в BIOS/UEFI ноутбуков Lenovo/Fujitsu/Toshiba/HP/Dell
Статья Анатолия Михайлова, руководителя группы разработки Secret Disk Linux компании "Аладдин Р.Д."
В этой статье я приведу описание багов в BIOS/UEFI ноутбуков, с которыми приходилось работать и для которых приходилось адаптировать загрузчики. В первую очередь речь пойдёт о багах, которые не видны пользователю, но которые могут помешать работе загрузчика даже при условии, что все было сделано правильно. Баги были выявлены как в интерфейсах соответствующих сред исполнения, так и в коде режима SMM процессоров Intel. Приводимый материал основывается на накопленном опыте, который растянут на достаточно большой период времени. Поэтому к моменту написания список конкретных моделей был утрачен. Тем не менее, сохранился список фирм-производителей, на ноутбуках которых возникали проблемы. Баги будут описаны последовательно, начиная с простых и заканчивая самыми сложными. Также по ходу описания будет приведён способ их обхода.
Прежде, чем мы начнём
Для того чтобы было полное понимание, при каких обстоятельствах приходилось сталкиваться с описываемыми проблемами, я кратко расскажу, какого сорта работу приходится выполнять. Существует продукт, шифрующий системный диск. Поэтому на этапе запуска ПК требуется расшифровывать диски, чтобы ОС могла запуститься. Поэтому был разработан загрузчик, который и выполняет эту роль. После установки всех своих перехватчиков этот загрузчик передаёт управление оригинальному загрузчику ОС. Далее в процессе описания термин "загрузчик" будет использоваться для обозначения нашего загрузчика. А термин "загрузчик ОС" будет использоваться для обозначения загрузчика, который мы подменяем.
Проблемы запуска загрузчика (Lenovo, UEFI)
Известно, что UEFI реализует глобальные переменные. В том числе существуют глобальные переменные, каждая из которых описывает вариант запуска ПК (load option entry). Также существует глобальная переменная BootOrder, которая описывает порядок вызова этих вариантов. Таким образом, загрузчик записывался на системный раздел UEFI, и для него создавалась новая запись, когда в BootOrder этот загрузчик ставился первым в очереди. Однако при запуске ПК вызывался загрузчик Windows. Выяснилось, что UEFI начисто игнорировал значение BootOrder и загружал всегда Windows, если находил его запись.
Проблему удалось обойти посредством подмены загрузчика самой Windows. Это, конечно, добавляет работы, т.к. теперь подменный файл надо защищать в самой операционной системе.
Проблемы при посылке USB-команд (HP, UEFI)
Загрузчик работает с USB-устройствами. А именно с CCID-ридерами. Для работы с USB-устройствами использовался предусмотренный для этих целей протокол — EFI_USB_IO_PROTOCOL. Проблема заключалась в том, что запущенный загрузчик не определял ни одного USB-устройства, когда на других ПК этот же загрузчик определял их. На первый взгляд могло показаться, что это полностью нефункционирующие драйвера USB, но при работе с ноутбуком я не мог обойти стороной тот факт, что ноутбук успешно запускался с флэшки. Далее выяснилось, что проблема возникает при посылке команд через управляющий канал (control transfer pipe) при помощи функции UsbControlTransfer протокола EFI_USB_IO_PROTOCOL. Прототип функции изображён ниже.
typedef EFI_STATUS (EFIAPI *EFI_USB_IO_CONTROL_TRANSFER) (
IN EFI_USB_IO_PROTOCOL* This,
IN EFI_USB_DEVICE_REQUEST* Request,
IN EFI_USB_DATA_DIRECTION Direction,
IN UINT32 Timeout,
IN OUT VOID* Data OPTIONAL,
IN UINTN DataLength OPTIONAL,
OUT UINT32* Status
);
Функция всегда возвращалась с ошибкой EFI_USB_ERR_TIMEOUT. Оказалось, что тип EFI_USB_DATA_DIRECTION был реализован разработчиками не в соответствии с UEFI спецификацией. Определение самого типа из спецификации приведено ниже.
typedef enum {
EfiUsbDataIn,
EfiUsbDataOut,
EfiUsbNoData
} EFI_USB_DATA_DIRECTION;
Ошибка в реализации типа заключалась в том, что на соответствующем ноутбуке EfiUsbDataIn и EfiUsbDataOut были перепутаны местами. Следовательно, когда загрузчик вызывал функцию UsbControlTransfer с третьим параметром равным EfiUsbDataOut, то в действительности происходила не запись в устройство, а чтение с него. И наоборот. Поскольку EfiUsbDataOut в коде приложения встречается первым, то получалось, что драйвер USB пытался прочитать данные с устройства, которых при посылаемых запросах быть не может. Соответственно, функция завершалась по таймауту.
Решение проблемы крайне некрасивое. При запуске загрузчик проверял, содержит ли поле FirmwareRevision структуры EFI_SYSTEM_TABLE строку «HPQ», и если содержит, проверялось, чтобы поле FirmwareRevision содержало значение 0x10000001. Если оба условия соблюдались, тогда при вызове соответствующих функций мы намерено меняли значения EfiUsbDataIn и EfiUsbDataOut на противоположные.
Проблемы при получении USB-ответов (Fujitsu LifeBook E743, UEFI)
Внешне проблема проявлялась в том, что не все CCID-устройства работали в загрузчике. Старые семейства работали безотказно, новые нет. Выяснилось, что проблема возникает при вызове функции UsbBulkTransfer протокола EFI_USB_IO_PROTOCOL. Функция всегда возвращала ошибку EFI_DEVICE_ERROR.
Известно, что USB хост контроллер обменивается данными с устройствами пакетами фиксированной длины. Также разработчиками USB допускается, что устройство может вернуть короткий пакет. В таком случае хост контроллер вернёт состояние завершения передачи не "Success" а " Short Packet". И драйвером USB этот ответ интерпретировался как ошибка. Т.е. функция UsbBulkTransfer всегда возвращала EFI_DEVICE_ERROR в случае если устройство ответило коротким пакетом.
Так и получилось, что старые семейства CCID всегда отвечали длинными пакетами, когда новые – короткими. Проблему удалось обойти посредством анализа выходного буфера. Ниже на рисунке представлен формат RDR_to_PC_DataBlock пакетов CCID-устройств. Этот пакет устройство возвращает на такие команды, как PC_to_RDR_IccPowerOn, PC_to_RDR_Secure и PC_to_RDR_XfrBlock.
#pragma pack( push, 1 )
struct RDR_to_PC_DataBlock {
UINT8 bMessageType;
UINT32 dwLength;
UINT8 bSlot;
UINT8 bSeq;
UINT8 bStatus;
UINT8 bError;
UINT8 bChainParameter;
UINT8* abData[0];
};
#pragma pack( pop )
Поле bMessageType идентифицирует тип пакета, и для RDR_to_PC_DataBlock пакета оно всегда равно 0x80. Поэтому перед получением ответа от устройства в буфере это поле предварительно обнулялось. Если функция UsbBulkTransfer возвращала ошибку, тогда проверялось значение этого поля, и если оно было равно 0x80, тогда считалось, что устройство на самом деле ответило корректно. В таком случае поле dwLength использовалось для вычисления размера ответа, и этот размер уже возвращался изначальному запросчику.
Проблемы при работе с картой памяти (Toshiba Satellite U200, BIOS)
Внешне проблема проявлялась в том, что загрузчик отказывался работать, т.к. не мог найти участок памяти, в котором он бы мог разместиться. Анализ выявил проблемы во время сканирования карты памяти. В процессе этого сканирования часть диапазонов пропускалась и не подвергалась анализу.
Речь идёт о сервисе 0xe820 прерывания int 15h. Т.к. загрузчик оставлял часть кода резидентно, требовалось выделять память и размещать его код в этом участке. Со своей стороны это требовало модификации карты памяти, чтобы операционная система во время своего запуска не использовала выделенный нами участок. Соответственно, во время запуска вся карта считывалась, должным образом модифицировалась и подменялась через перехват int 15h.
Ниже приведены входные и выходные параметры функции получения карты памяти.
Входные параметры:
- EAX – код функции, всегда равен 0xe820;
- EBX – продолжение, при первом вызове значение должно быть равно 0, при последующих значение должно быть равно значению, возвращённое функцией после вызова. Данный регистр указывает функции, с какой записи продолжить получение карты памяти;
- ES:DI – указатель буфера, куда возвращается запись, описывающая конкретный диапазон памяти;
- ECX – размер буфера, должно быть не меньше 20, т.к. первые ревизии этой функции возвращали записи размером 20 байт. На современных системах размер записи составляет 24 байта;
- EDX – сигнатура, всегда равно 'SMAP'. Используется для верификации запросчика.
Выходные параметры:
- CF – ошибка, если 0, значит ошибки нет;
- EAX — сигнатура, всегда равно 'SMAP'. Используется для верификации BIOS;
- ES:DI – указатель буфера, то же, что и на входе;
- ECX – размер записи, который вернула функция;
- EBX – значение, которое следует подать на вход функции для получения следующей записи. Также не следует делать предположения о самом значении, т.к. это может быть смещение, индекс или любая другая сущность во внутреннем представлении самой функции.
Посредством этой функции загрузчик в цикле вычитывает всю карту памяти. И загрузчик был разработан таким образом, чтобы обеспечить прямую совместимость с будущими версиями BIOS. Т.е. на входе регистр ECX содержал 64. Как следует из описания самой функции, функция в регистре ECX вернёт размер записи, которая была записана в буфер. Поскольку на текущий момент максимальный размер записи 24, то больше, чем это значение, в регистре оказаться не могло. Также функция всегда должна возвращать ровно одну запись.
Однако на конкретном ноутбуке оказалось, что функция интерпретирует значение ECX несколько иначе. Т.е. она используется не для того чтобы определить размер записи, которую поддерживает запросчик, а для того чтобы определить, сколько функция может вернуть вообще записей за один вызов. Так и получилось, что при вызове функции загрузчик считывал не по одной записи, а по две. И, следовательно, одна из них всегда игнорировалась загрузчиком. Это и привело к тому, что загрузчик не смог найти участок памяти, в котором он мог бы разместить резидентный код.
Проблема была решена посредством передачи в ECX значения 24. Т.е. от идеи прямой совместимости пришлось отказаться. Были мысли о том, как определить размер записи, но, понимая стабильность разных версий BIOS, есть риск, что алгоритм из-за этого также не будет стабильно работать.
Проблемы при остановке USB 3.0 и переинициализации PIC контроллеров (HP, BIOS)
Визуально проблема выглядела так: после того, как пользователь успешно подключил смарт-карту и ввел ПИН, экран темнел, выводилось сообщение о том, что идёт загрузка ОС, и все останавливалось именно на этом сообщении. ПК зависал намертво.
Поскольку загрузчик BIOS разработан на базе RTOS, сама пользовательская оболочка работает в защищённом режиме процессора, что, безусловно, требовало переинициализации классического PIC контроллера. Соответственно, при передаче управления загрузчику ОС процессор возвращался в реальный режим. И это в свою очередь требовало возврат PIC контроллера в исходное состояние.
Предварительный анализ выявил, что процессор возвращался в реальный режим, но далее происходило повисание ПК. Далее выяснилось, что проблема возникала только в том случае, если загрузчик инициализировал USB хост контроллеры. Перед возвратом в реальный режим и перед возвратом PIC контроллера в исходное состояние также останавливались USB хост контроллеры.
USB 3.0 хост контроллер может иметь регистр USBLEGSUP. Данный регистр позволяет передавать управление контроллером от BIOS к ОС и наоборот. В первую очередь, это может понадобиться, например, для эмуляции классических клавиатурных портов ввода/вывода, дабы обеспечить совместимость со старым ПО. Т.е. при обращении к этим портам произойдёт SMI прерывание, а все остальное уже сделает обработчик этого прерывания. А на современных машинах все чаще и чаще используются только USB-клавиатуры. Формат регистра описан ниже.
- Capability ID (Биты 0-7) – идентификатор функциональности. Для данного регистра поле равно 1
- Next Capability Pointer (Биты 8-15) – указатель на следующий capability регистр
- HC BIOS Owned Semaphore (Бит 16) – если установлен, значит BIOS управляет хост контроллером
- Зарезервировано (Биты 17-23)
- HC OS Owned Semaphore (Бит 24) – перед использованием хост контроллера операционная система должна установить этот бит, в ответ на это BIOS сбросит бит 16, после чего можно использовать хост контроллер
- Зарезервировано (Биты 25-31)
RTOS при остановке хост контроллера также сбрасывает бит 24 регистра USBLEGSUP. Таким образом, она возвращает управление над ним к BIOS. Далее RTOS выполняет возврат PIC контроллера в исходное состояние. Также известно, что PIC контроллер аппаратно уже не существует, и он также эмулируется посредствам SMM-режима. Следовательно, когда выполнялся возврат PIC контроллера в исходное состояние, при работе с его регистрами происходило SMI прерывание. Анализ выявил, что поскольку RTOS не дожидается установки бита 16 в регистре USBLEGSUP и поскольку сразу после установки бита 24 этого регистра выполнялся возврат PIC контроллера в исходное состояние, код режима SMM возвращал управление над хост контроллером, а PIC контроллер, который, по сути, породил SMI прерывание, не обрабатывался вообще. Поскольку инициализация PIC выполняется в несколько этапов, контроллер остался частично в непроинициализированном состоянии. Из-за этого ломалась доставка прерываний. Сразу после возврата процессора в реальный режим при первом прерывании процессор вставал на невалидный вектор, из-за чего он начинал выполнять бессмысленный поток инструкций.
Проблему удалось обойти посредством ожидания установки бита 16 в регистре USBLEGSUP перед возвратом PIC в исходное состояние.
Проблемы доставки прерываний от PIC контроллера (Dell Latitude E7240, BIOS)
Внешне проблема выглядела так: когда загрузчик запустился и вывел приглашение к подключению смарт-карты, загрузчик зависал намертво. При этом проблема возникала исключительно при перезагрузке ПК, при включении все работало нормально.
Предварительный анализ выявил, что процессор сваливался в page fault. Последующее изучение проблемы показало, что RTOS для каждого прерывания использует отдельные стеки, размер которых очень маленький (256 байт). Все эти стеки располагаются смежно, как это отражено на рисунке ниже.
Также удалось выяснить, что page fault происходил на странице памяти, которая следовала сразу перед страницей со стеками прерываний. Поэтому последующий анализ выполнялся уже на этом уровне.
RTOS при инициализации USB хост контроллера также включает доставку прерываний PIC с линии, на которой располагается контроллер. Обработчик прерывания при вызове разрешает все прерывания на процессоре, после чего последовательно вызывает зарегистрированные обработчики для этой линии. После вызова всех зарегистрированных обработчиков обработчик прерывания посылает команду завершения прерывания (EOI) PIC контроллеру.
Известно, что PIC контроллер имеет ISR регистр. Этот регистр используется для того, чтобы определить, какие прерывания в настоящий момент обрабатывает процессор, а какие нет. И если процессор обрабатывает конкретное прерывание, то даже если на соответствующей линии присутствует запрос, оно доставлено не будет. До тех пор, пока процессор не выдаст EOI команду PIC контроллеру, после чего PIC возобновит доставку этого прерывания.
Последующий анализ выявил, что в процессе вызова зарегистрированных обработчиков PIC контроллер доставлял прерывание повторно, даже несмотря на то, что команда EOI ещё не была послана PIC. Безусловно, это ошибка эмуляции PIC контроллера. Это приводило к тому, что сначала переполнялся стек соответствующего прерывания, затем портился стек других прерываний, и, в конечном счёте, доступ выполнялся к неотображённой странице памяти. И это приводило к page fault, обработчик которого останавливает работу загрузчика.
Проблему удалось обойти посредством запрета доставки соответствующего прерывания на PIC контроллере до вызова зарегистрированных обработчиков и его разрешением после их вызова.
Заключение
Приведённый список багов является далеко не полным. Описаны только те случаи, которые удалось вспомнить. Хуже всего то, что радикального решения проблемы стабильности придумать до сих пор не удалось. Удалось только добиться стабильности лишь в отдельных моментах. Все равно попадаются экземпляры с ошибками, которые опытному разработчику придётся выдумывать. И ещё хуже, тратить по три дня на анализ и устранение проблемы. А некоторые случаи бывают далеко не лёгкими. Три дня на устранение проблемы это, конечно, не так много, но когда проблем с десяток, это уже хорошо выбивает из рабочего графика.
Понимание реальности вынудило на реверс-инжиниринг загрузчика Windows с целью понять, какие механизмы он использует. Для меня это означает, что я также могу ими пользоваться безопасно. Если отойти от этих правил, тогда работа загрузчика гарантированной быть не может.
После ещё пары проблем с USB в UEFI я пришёл к тому, чтобы в загрузчик поместить свои драйвера хост контроллеров. Для этого приходится останавливать те драйвера, что работают в самом UEFI, и загружать свои. Добавлять так называемые "костыли" мне никогда не импонировало. К тому же, такой код со временем станет тяжело развивать из-за нагромождённости.
А что касается своих драйверов, в этом есть большой смысл, т.к. есть FastBoot режим, который не гарантирует загрузку USB-драйверов. Это не баг, но камень в сторону самого UEFI как стандарта, который не предоставляет механизма догрузки незагруженных драйверов.
В завершение описания проблем хотелось бы отметить следующее: складывается впечатление, что нынешние BIOS/UEFI разрабатываются в отрыве от полного понимания принципов работы этих систем, либо тестирование не проводится должным образом. По опыту, и то и другое имеет место. Достаточно того, чтобы на произведённом ПК запускался Windows и Linux. Все остальное — издержки производства. А кого будет обвинять клиент, думаю, рассказывать не надо.
Исходя из опыта работы, BIOS и UEFI являются самыми нестабильными средами исполнения. В особенности, EFI MacBook является особым исключением, и работать с ним тяжелее всего. Но это уже другая история.