Еще пара слов об устройстве NVRAM в UEFI-совместимых прошивках (про Dell DVAR)

Здравствуй, читатель. С моих прошлых статей про NVRAM прошло некоторое количество времени (за эти почти 10 лет мало что изменилось, и все эти форматы до сих пор с нами практически без модификаций), а моя работа на одну фруктовую компанию не позволяла мне писать статьи, тесты и посты без одобрения кучей непонятных людей, но теперь эта работа осталась в прошлом, а желание писать так и не пропало.
Эта статья — практическая реализация этого желания, а поговорим мы в ней о формате Dell DVAR, и немного о декларативном языке для написания парсеров Kaitai Struct, на котором я недавно переписал парсеры всех известных UEFITool NE форматов NVRAM.
Идея придумать свой собственный формат для хранения переменных, нужных только для функционирования самой прошивке, вообще говоря, здравая, потому что и сам формат можно сделать проще, и нет необходимости тащить с собой груз совместимости с интерфейсом GetVariable/SetVariable, а главное, станет сильно сложнее перепутать «системные» переменные с «пользовательскими» при разработке прошивки.
В итоге Dell где-то приблизительно в 2018 году (самый ранний дамп, в котором я видел эти новые переменные был от середины 2019, но скорее всего сам формат разработали немного раньше) решила, что надо бы последовать лучшим практикам, и придумала удивительный, в каком-то смысле, формат Dell DVAR (названный мной так по сигнатуре).
Самое раннее упоминание в сети о существовании парсеров этого формата, которые я смог найти — вот оно. Если его перевести с китайского автоматическим переводчиком (прости, читатель, за мое незнание китайского языка), получим примерно следующее:
Индекс (NameId: NamespaceId) | Название переменной | Описание переменной |
---|---|---|
492:1 | «PPID» | Серийный номер материнской платы |
429:1 | «FanCtrlOvrd» | Передача управления вентиляторами от EC драйверу FanControlSmm |
42A:1 | «ChassisPolicy» | Выбор между типами шасси, использующими одну и ту же прошивку |
2618:1 | «Service Tag» | Сервис-тег |
617:1 | «Asset Tag» | Ассет-тег |
E30:1 | «ProductName» | Имя продукта |
E31:1 | «Sku» | Номер SKU |
E78:1 | «System Map» | Карта файла BIOS |
2503:3 | «FirstPowerOnDate» | Дата первого включения |
2502:3 | «MfgDate» | Дата производства |
62B:1 | «May Man Mode» | Возможно, признак заводского режима |
1:2 | «May Man Mode1» | Возможно, другой признак заводского режима |
445:1 | «AcPwrRcvry» | Стратегия после пропадания питания |
478:1 | «WakeOnLan5» | Конфигурация Wake on Lan |
Код самого парсера там тоже есть, но он получен как результат «some data experience and guesswork», и потому хоть и работает в некоторых редких случаях, но все равно никуда не годен.
После еще одного раунда поисков оказалось, что намного более функциональный и полноценный парсер есть внутри утилиты UefiBiosEditor (новые версии которой автор загружает вот сюда), но у нее есть два фатальных недостатка — она проприетарная и для Windows. Зато ее можно использовать для кросс-чека с моим собственным кодом, который я добавил в UEFITool NE A71.
Обратная разработка формата Dell DVAR
На первый взгляд из hex-редактора, область с переменными DVAR выглядит вот так:

Кроме хорошо заметной сигнатуры, давшей название формату, все остальное выглядит неприятно — никаких очевидных полей, вроде размера хранилища, флагов, типов данных и т.п. невооруженным взглядом не видно, зато хорошо заметен повторяющийся паттерн AA Fp Fq Fr Fs, где p, q, r, s — шестнадцатеричные цифры, близкие к F.
Если немного помедитировать над этой картиной (и поиграться с уже разобранными переменными в UefiBiosEditor), то внезапно приходит понимание, что инженеры Dell, помня о том, что на NOR flash можно «бесплатно» установить любой бит в 0, но чтобы установить уже установленный в 0 бит обратно в 1, нужно стереть и записать весь блок целиком (а он бывает и 4 кб, в хорошем случае, и 64 кб, в не очень хорошем), придумали хранить все метаданные (заголовки, флаги, и т.п.) в формате »0 — это 1, а 1 — это 0», т.е. AA FD FB F8 FE — это, на самом деле, 55 02 04 07 01, что уже намного больше похоже на набор флагов, идентификаторов, и размеров данных.
После того, как главный трюк становится понятен, все остальное — не слишком сложная после многих лет опыта работа по реверс-инженирингу бинарного формата, который не пытались обфусцировать специально. Зато с опытом также пришло понимание, что не обязательно все делать вручную (даже если хочется иногда угореть по хардкору, как в старые добрые времена), и для этого теперь есть хорошие инструменты, а именно — декларативный язык описания форматов Kaitai Struct, и его Web IDE.
Загружаем туда наш дамп, и пишем примерно следующее:
Сначала в области meta у нас описание самого формата, которое на парсинг влияет мало, но нужно будет позже. Выше на скриншоте редактора видно, что повторяющиеся записи АА Fp Fq Fr Fs начинаются через 5 байт после сигнатуры, поэтому логично предположить, что 4 из них — это размер хранилища, а оставшийся — какие-то флаги или что-то подобное. Так и запишем, не забыв, что реальные значения у нас отличаются от того, что в файле записано, и понадобятся потом именно они. В итоге доброе IDE показывает нам все, что на текущий момент уже распарсилось:

Дальше надо разобрать формат отдельной переменной, с учетом того, что переменные бывают разные, и в каких-то хранится больше метаданных и данных, чем в других.
После пары часов активной любви вприсядку получается примерно следующее:
seq:
- id: signature
size: 4
- id: len_store_c
type: u4
- id: flags_c
type: u1
- id: entries
type: dvar_entry
repeat: until
repeat-until: _.state_c == 0xFF
instances:
len_store:
value: 0xFFFFFFFF - len_store_c
flags:
value: 0xFF - flags_c
types:
dvar_entry:
seq:
- id: state_c
type: u1
- id: flags_c
type: u1
if: state_c != 0xFF
- id: types_c
type: u1
if: state_c != 0xFF
- id: attributes_c
type: u1
if: state_c != 0xFF
- id: namespace_id_c
type: u1
if: state_c != 0xFF and (flags == 2 or flags == 6)
- id: namespace_guid
size: 16
if: state_c != 0xFF and flags == 6
- id: name_id_8_c
type: u1
if: state_c != 0xFF and types == 0
- id: name_id_16_c
type: u2
if: state_c != 0xFF and (types == 4 or types == 5)
- id: len_data_8_c
type: u1
if: state_c != 0xFF and (types == 0 or types == 4)
- id: len_data_16_c
type: u2
if: state_c != 0xFF and types == 5
- id: data_8
size: len_data_8
if: state_c != 0xFF and (types == 0 or types == 4)
- id: data_16
size: len_data_16
if: state_c != 0xFF and types == 5
instances:
state:
value: 0xFF - state_c
flags:
value: 0xFF - flags_c
types:
value: 0xFF - types_c
attributes:
value: 0xFF - attributes_c
namespace_id:
value: 0xFF - namespace_id_c
name_id_8:
value: 0xFF - name_id_8_c
name_id_16:
value: 0xFFFF - name_id_16_c
len_data_8:
value: 0xFF - len_data_8_c
len_data_16:
value: 0xFFFF - len_data_16_c
Переменная DVAR состоит из заголовка (который присутствует у всех переменных), опциональных полей (только у некоторых), и собственно данных:
typedef struct _DVAR_ENTRY_HEADER {
UINT8 StateC;
UINT8 FlagsC;
UINT8 TypeC;
UINT8 AttributesC;
UINT8 NamespaceIdC;
// Наличие или отстутствие нижеследующего зависит от Flags и Type
// EFI_GUID NamespaceGuid;
// UINT8 | UINT16 NameId;
// UINT8 | UINT16 DataSize;
// UINT8 Data[DataSize];
} DVAR_ENTRY_HEADER;
#define DVAR_ENTRY_STATE_STORING 0x01 // Запись в переменную начата
#define DVAR_ENTRY_STATE_STORED 0x05 // Запись закончена, переменная валидна
#define DVAR_ENTRY_STATE_DELETING 0x15 // Удаление переменной начато
#define DVAR_ENTRY_STATE_DELETED 0x55 // Удаление переменной закончено
#define DVAR_ENTRY_FLAG_NAME_ID 0x02 // Переменная с NameId
#define DVAR_ENTRY_FLAG_NAMESPACE_GUID 0x04 // Переменная с NamepaceGuid
#define DVAR_ENTRY_TYPE_NAME_ID_8_DATA_SIZE_8 0x00
#define DVAR_ENTRY_TYPE_NAME_ID_16_DATA_SIZE_8 0x04
#define DVAR_ENTRY_TYPE_NAME_ID_16_DATA_SIZE_16 0x05
Итого, конкретная переменная однозначно идентифицируется парой NamespaceId (идентификатор области видимости) и NameId (собственно идентификатор переменной), и областей видимости может быть до 255, а уникальных переменных внутри одной области — до 65535. Некоторые переменные (обычно это первое вхождение переменной с не встречавшимся до этого NamespaceId) также хранят в метаданных GUID для их NamespaceId. Если переменная с NamespaceGuid помечена удаленной, заново этого GUID не сохраняют, оставляя его в этой «удаленной» переменной.
В теории, у переменных DVAR так же может быть и отдельное имя в формате UTF8, по пока что ни одного дампа с такими переменными я не видел, и потому будем считать, что этих единорогов пока что не существует.



Все переменные, состояние которых не 0×05, прошивка игнорирует. При установке нового значения старая запись помечается как удаленная (состояние 0×55), а в конце хранилища создается новое. Парсинг заканчивается при нахождении свободной области после последней переменной. В нашем случае переменных в хранилище оказалось аж 1532 штуки, из которых удаленных более 90%. Если хранилище заполнится до отказа, прошивка произведет сборку мусора, для чего у нее есть вторая копия хранилища, на которую можно затем переключиться, изменив флаги в его заголовке.
Самое замечательное в использовании Kaitai Struct для разбора бинарных форматов в том, что по декларативному описанию формата его компилятор может сгенерировать готовый парсер на многих популярных ЯП, в том числе на C++, на котором написан UEFITool. Остается только красиво вывести результаты парсинга в окно Structure, и можно считать, что дело в шляпе.


Вместо заключения
На Хабре уже было несколько статей про Kaitai Struct, мне запомнились вот эти две, если вам интересны другие примеры его применения — их там есть.
Оказалось также, что на самых новых на данный момент машинах Dell (на 2025 год) отказались от использования этого формата, и теперь снова валят все в одно «стандартное» хранилище в формате VSS2, вот так:

Будем считать, что формат уже стал древним легаси, и больше мы его на новых машинах не увидим. Скатертью по жопе, в общем то, не очень то и хотелось.
Спасибо за внимание, читатель, будут вопросы — с удовольствием отвечу в комментариях, если вдруг найдется файл с DVAR, который не парсится — issue-tracker есть на GitHub.