Исчезнувшие байты: реверс-инжиниринг RTF-парсера в MS Office
В 2017 году пакет Microsoft Office был популярной целью для атак хакеров. Вместе с большим числом найденных уязвимостей и опубликованных proof-of-concept эксплойтов у авторов вредоносного ПО появилась необходимость в предотвращении детекта новых модификаций эксплойтов антивирусными средствами. Также стало ясно, что использования легитимных особенностей RTF-парсера более недостаточно для эффективного избегания анализа и обнаружения. Вместе с увеличением атак посредством MS Office, когда файл формата RTF используется как контейнер для эксплойта, мы нашли большое количество сэмплов, которые «эксплуатировали» RTF-парсер в Microsoft Word, чтобы «сломать» все другие имплементации RTF-парсера, включая те, которые используются в антивирусном программном обеспечении.
Для того, чтобы достичь точного парсинга, как в MS Office, нам было необходимо провести реверс-инжиниринг. Работа была начата с MS Office 2010 т.к. когда дело касается парсинга, лучше начать с более старой имплементации, а затем сравнили находки с тем, что удалось обнаружить в более новых версиях.
RTF-парсер представляет собой машину состояний (state machine) с 37 состояниями, 22 из которых уникальны:
Мы рассмотрим самые главные состояния и те, которые относятся к парсингу objdata – контрольного слова (control word), обозначающего назначение (destination) которое содержит данные «обьекта». Эти состояния:
PARSER _ BEGIN = 0,
Microsoft Office поставляется без отладочных символов, поэтому я не смог восстановить оригинальные имена. Но верю, что выбрал наиболее подходящие по их функциональности.
Первое состояние, которое выполняется на открытом RTF-файле — PARSER_BEGIN. В большинстве случаев оно также выставляется после обработки очередного контрольного слова. Главная цель состояния – определить следующее состояние, исходя из встреченных символов, установленного назначения, и других значений, содержащихся в ‘this’ структуре и установленных обработчиками контрольных слов. По умолчанию следующее установленное состояние это PARSER_CHECK_CONTROL_WORD.
// … — checks that we dont need
while (data.pos != data.end)
byte = *( uint8_t *)data.pos;
if ( this ->bin_size > 0)
if (byte == 0xA || byte == 0xD)
uint8_t byte1 = *( uint8_t *)data.pos;
if ( this ->destination == listname ||
this ->destination == fonttbl ||
this ->destination == revtbl ||
this ->destination == falt ||
this ->destination == leveltext ||
this ->destination == levelnumbers ||
this ->destination == liststylename ||
this ->destination == protusertbl ||
this ->destination == lsdlockedexcept )
// it will set next state depending on destination / or go to unexpected_cmd to do more checks and magic
if ( this ->destination == pict ||
this ->destination == objdata ||
this ->destination == objalias ||
this ->destination == objsect ||
this ->destination == datafield ||
this ->destination == fontemb ||
this ->destination == svb ||
this ->destination == macro ||
this ->destination == tci ||
this ->destination == datastore ||
this ->destination == mmconnectstrdata ||
this ->destination == mmodsoudldata ||
this ->destination == macrosig )
PARSER_CHECK_CONTROL_WORD проверит, является ли следующий символ началом контрольного слова или же это контрольный символ ( control symbol), после чего установит следующее состояние соответственно результату проверки.
byte = *( uint8_t *)data.pos;
this ->cmd_len = 0;
this ->temp[1] = byte;
this ->cmd_len = 1;
Состояния PARSER_PARSE_CONTROL_WORD и PARSER_PARSE_CONTROL_WORD_NUM_PARAMETER сохранят Null-терминированное контрольное слово, состоящее из латинских символов, и Null-терминированный числовой параметр (если он имеется) во временный буфер фиксированного размера.
pos = this ->temp + 1;
parsed = this ->temp + 1;
while (data.pos != data.end)
byte = *( uint8_t *)data.pos;
// length of null-terminated strings cmd + num should be <= 0xFF
//if parsed == temp_end
if (parsed >= temp_end)
parsed = temp_end — 1;
this ->cmd_len = pos — ( this ->temp + 1);
this ->cmd_len = pos — ( this ->temp + 1);
if (parsed >= temp_end)
parsed = temp_end — 1;
this ->cmd_len = pos — ( this ->temp + 1);
if (parsed — this ->temp >= 0xFF)
if (parsed >= temp_end)
parsed = temp_end — 1;
this ->cmd_len = pos — ( this ->temp + 1);
//if parsed == temp_end
if (parsed >= temp_end)
parsed = temp_end — 1;
this ->cmd_len = pos — ( this ->temp + 1);
while (data.pos != data.end)
byte = *( uint8_t *)data.pos;
// length of null-terminated strings cmd + num should be <= 0xFF
if (parsed >= temp_end)
parsed = temp_end — 1;
if (parsed — this ->temp >= 0xFF)
if (parsed >= temp_end)
parsed = temp_end — 1;
//if parsed == temp_end
if (parsed >= temp_end)
parsed = temp_end — 1;
this ->state = state;
state = this ->state;
Этот буфер обрабатывается в состоянии PARSER_PROCESS_CMD, которое вызывает другую функцию ответственную за обработку контрольных слов и контрольных символов. Эта функция учитывает текущее состояние и устанавливает следующее.
В коде есть несколько состояний, ответственных за парсинг шестнадцатеричных данных. Для нас наиболее интересно PARSER_PARSE_HEX_DATA – как можно увидеть, это состояние устанавливается в PARSER_BEGIN, если установлено назначение objdata.
parsed_data = this ->temp;
if ( this ->bin_size <= 0)
while (data.pos != data.end)
byte = *( uint8_t *)data.pos;
if (parsed_data != this ->temp)
push_data(parsed_data — this ->temp);
parsed_data = this ->temp;
if ( this ->flag & 0x4000)
val = byte — 0x30;
val = byte — 0x57;
val = byte — 0x37;
else if (byte == 9 || byte == 0xA || byte == 0xD || byte == 0x20)
// show message that there are not enough memory
this ->flag |= 0x4000;
if ( this ->flag & 0x8000)
this ->hex_data_byte = val << 4;
this ->flag &= 0x7FFF;
if (parsed_data == temp_end)
push_data( sizeof ( this ->temp));
parsed_data = this ->temp;
this ->hex_data_byte |= val;
*parsed_data = this ->hex_data_byte;
this ->flag |= 0x8000;
if ( this ->flag & 0x4000)
if ( this ->bin_size <= data.end — data.pos)
size = this ->bin_size;
size = data.end — data.pos;
this ->bin_size -= size;
while ( this ->bin_size > 0)
if (parsed_data == temp_end)
push_data( sizeof ( this ->temp));
parsed_data = this ->temp;
byte = *( uint8_t *)data.pos;
if (parsed_data != this ->temp)
push_data(parsed_data — this ->temp);
parsed_data = this ->temp;
Это состояние распарсит шестнадцатеричные и бинарные данные, если они были объявлены.
Состояния PARSER_PARSE_HEX_NUM_MSB и PARSER_PARSE_HEX_NUM_LSB используются в паре для парисинга шестнадцатеричных значений (данные контрольного слова panose и контрольного символа ‘).
this ->flag |= 0x8000;
this ->hex_num_byte = 0;
byte = *( uint8_t *)data.pos;
val = byte — 0x30;
val = byte — 0x57;
val = byte — 0x37;
this ->hex_num_byte |= val << (( this ->flag >> 0xF) << 2);
this ->flag ^ this ->flag) & 0x7FFF) ^
if ( this ->flag & 0x8000)
Сброс состоянияВнимательно изучив код состояний PARSER_PARSE_HEX_NUM_MSB, PARSER_PARSE_HEX_NUM_LSB и PARSER_PARSE_HEX_DATA, легко заметить баг. Несмотря на то, что они используют разные переменные для декодированных шестнадцатеричных значений, состояния используют одинаковый бит для определения какой ниббл (полубайт) декодируется сейчас – верхний (MSB) или нижний (LSB). И PARSER_PARSE_HEX_NUM_MSB всегда сбрасывает этот бит на MSB.
Тем самым можно заставить байты исчезнуть из финального результата декодирования, если в контексте PARSER_PARSE_HEX_DATA вынудить смену состояния на PARSER_PARSE_HEX_NUM_MSB.
Для этого достаточно вставить ‘XX в данные, которые идут после контрольного слова objdata. В этом случае, когда данные будут обрабатываться в PARSER_PARSE_HEX_DATA, парсер встретит и вернется в PARSER_BEGIN, а в конце концов попадет в состояние PARSER_PROCESS_CMD. Логика для контрольного символа ‘ устроена так, что назначение не будет изменено, но будет выставлено состояние PARSER_PARSE_HEX_NUM_MSB. После PARSER_PARSE_HEX_NUM_MSB и PARSER_PARSE_HEX_NUM_LSB управление снова перейдет в PARSER_BEGIN и PARSER_PARSE_HEX_DATA, т.к. назначение все еще равно objdata. После этого снова будет декодироваться верхний ниббл.
Также примечательно, что PARSER_PARSE_HEX_NUM_LSB не проверяет валидность значений, т.е. после ‘ могут идти два абсолютно любых байта.
Это можно наблюдать в следующем примере:
Когда исполнение в первый раз передается состоянию PARSER_PARSE_HEX_DATA, после того, как контрольное слово objdata обработано, MSB бит уже установлен. Давайте рассмотрим, как это происходит и как следующий пример будет обработан парсером:
Проведя некоторое время над функцией ответственной за обработку контрольных слов и контрольных символов, я нашел список всех контрольных слов и их структуры.
С этой информацией мы можем найти и взглянуть на конструктор objdata:
Как можно заметить, конструктор устанавливает бит MSB, выделяет новый буфер и заменяет указатель на старый. Таким образом, данные, декодированные между двумя контрольными словами objdata, не используются.
«d0cf11e0a1b11ae1» будут удалены
Пункт назначения
Мы знаем, что если вставить ‘ и objdata, то это изменит выходные данные. Но что по поводу других контрольных слов и символов? Ведь их в парсере больше 1500!
По большей части ничего.
Т.к. часть контрольных слов обозначают назначение, они не могут применяться – результатом будет изменение objdata на назначение контрольного слова, а для декодирования данных нам нужно назначение objdata.
Часть других контрольных слов не имеют эффекта на назначение objdata.
Единственный способ задать другое назначение таким образом, чтобы можно было вернуть назначение objdata без потери декодированных данных, это воспользоваться особыми символами – открывающая фигурная скобка (). Эти символы обозначают начало и конец группы.
После того, как парсер встретит конец группы в PARSER_BEGIN, назначение, которое было установлено перед началом группы, будет восстановлено.
Таким образом, если после objdata вставить , то FF не попадут в данные objdata, т.к. FF теперь относятся к назначению aftncn и будут обработаны согласно его логике.
В противном случае, если вставить , то FF попадут в objdata т.к. назначение все еще равно objdata.
Также стоит отметить, что все-еще нельзя использовать т.к. буфер не будет восстановлен.
Точный список назначений был составлен благодаря простому фаззеру.
Фиксированный буферДругая техника обфускации, которая приходит в голову при взгляде на код RTF-парсера, не относится к этому «MSB» багу, но также может использоваться для удаления шестнадцатеричных данных. Техника относится к размеру временного буфера и к тому, как контрольные слова и числовые параметры парсятся в состояниях PARSER_PARSE_CONTROL_WORD и PARSER_PARSE_CONTROL_WORD_NUM_PARAMETER.
Пример злоупотребления представлен на следующей картинке:
В этом примере размер данных, которые будут удалены как часть числового параметра, считается по формуле: 0xFF (размер временного буфера) — 0xB (размер контрольного слова «oldlinewrap») — 2 (null-терминаторы) = 0xF2.
Ненужные данныеОписанные ранее техники относятся к основам парсинга формата RTF, но дополнительная путаница скрывается в обработке особых контрольных слов.
Согласно спецификации, если контрольный символ * находился перед неизвестным контрольным словом или контрольным символом, то считается, что это неизвестное назначение и все данные перед скобкой (>), которая закрывает эту группу, должны быть выкинуты. В MS Office содержатся контрольные слова, которые не упоминаются в спецификации, и вызывает опасение то, что список контрольных слов будет изменен в будущем, оказывая влияние на парсинг документа в различных версиях MS Office. Когда функция, ответственная за обработку контрольных слов и контрольных символов, встретит такой случай или одно из особых контрольных слов (такие как comment, generator, nonshppict и др.), состояние будет установлено на PARSER_SKIP_DATA, а число встреченных открывающих скобок (