В 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) которое содержит данные «обьекта». Эти состояния:
enum
{
PARSER_BEGIN = 0,
PARSER_CHECK_CONTROL_WORD = 2,
PARSER_PARSE_CONTROL_WORD = 3,
PARSER_PARSE_CONTROL_WORD_NUM_PARAMETER = 4,
PARSER_PARSE_HEX_DATA = 5,
PARSER_PARSE_HEX_NUM_MSB = 7,
PARSER_PARSE_HEX_NUM_LSB = 8,
PARSER_PROCESS_CMD = 0xE,
PARSER_END_GROUP = 0x10,
// …
};
Microsoft Office поставляется без отладочных символов, поэтому я не смог восстановить оригинальные имена. Но верю, что выбрал наиболее подходящие по их функциональности.
Первое состояние, которое выполняется на открытом RTF-файле — PARSER_BEGIN. В большинстве случаев оно также выставляется после обработки очередного контрольного слова. Главная цель состояния – определить следующее состояние, исходя из встреченных символов, установленного назначения, и других значений, содержащихся в ‘this’ структуре и установленных обработчиками контрольных слов. По умолчанию следующее установленное состояние это PARSER_CHECK_CONTROL_WORD.
case PARSER_BEGIN:
// … — checks that we dont need
while (data.pos != data.end)
{
byte = *(uint8_t*)data.pos;
data.pos++;
if (this->bin_size > 0)
{
goto unexpected_char;
}
// …
if (byte == 9)
{
// …
continue;
}
if (byte == 0xA || byte == 0xD)
{
// …
break;
}
if (byte == ‘\’)
{
uint8_t byte1 = *(uint8_t*)data.pos;
if (byte1 == »’)
{
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)
goto unexpected_char;
state = PARSER_CHECK_CONTROL_WORD;
// …
break;
}
if (byte1 == ‘u’)
{
// …
break;
}
state = PARSER_CHECK_CONTROL_WORD;
// …
break;
}
if (byte == ‘{‘)
{
create_new_group();
// …
break;
}
if (byte == ‘}’)
{
state = PARSER_END_GROUP;
break;
}
unexpected_char:
// 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)
{
state = PARSER_PARSE_HEX_DATA;
data.pos—;
break;
}
// …
break;
}
break;
PARSER_CHECK_CONTROL_WORD проверит, является ли следующий символ началом контрольного слова или же это контрольный символ (control symbol), после чего установит следующее состояние соответственно результату проверки.
case PARSER_CHECK_CONTROL_WORD:
byte = *(uint8_t*)data.pos;
if ((byte >= ‘a’ && byte <= ‘z’) || (byte == ‘ ‘) || (byte >= ‘A’ && byte <= ‘Z’))
{
state = PARSER_PARSE_CONTROL_WORD;
this->cmd_len = 0;
}
else
{
data.pos++;
this->temp[0] = 1;
this->temp[1] = byte;
this->temp[2] = 0;
state = PARSER_PROCESS_CMD;
this->cmd_len = 1;
break;
}
Состояния PARSER_PARSE_CONTROL_WORD и PARSER_PARSE_CONTROL_WORD_NUM_PARAMETER сохранят Null-терминированное контрольное слово, состоящее из латинских символов, и Null-терминированный числовой параметр (если он имеется) во временный буфер фиксированного размера.
case PARSER_PARSE_CONTROL_WORD:
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 ((byte == ‘-‘) || (byte >= ‘0’ && byte <= ‘9’))
{
//if parsed == temp_end
// goto raise_exception
*parsed = 0;
parsed++;
pos = parsed;
if (parsed >= temp_end)
{
parsed = temp_end — 1;
*parsed = 0;
state = PARSER_PROCESS_CMD;
this->cmd_len = pos — (this->temp + 1);
break;
}
data.pos++;
this->cmd_len = pos — (this->temp + 1);
*parsed = byte;
parsed++;
pos = parsed;
state = PARSER_PARSE_CONTROL_WORD_NUM_PARAMETER;
break;
}
if (byte == ‘ ‘)
{
data.pos++;
if (parsed >= temp_end)
{
parsed = temp_end — 1;
}
*parsed = 0;
state = PARSER_PROCESS_CMD;
this->cmd_len = pos — (this->temp + 1);
break;
}
if ((byte >= ‘a’ && byte <= ‘z’) || (byte >= ‘A’ && byte <= ‘Z’))
{
if (parsed — this->temp >= 0xFF)
{
if (parsed >= temp_end)
{
parsed = temp_end — 1;
}
*parsed = 0;
state = PARSER_PROCESS_CMD;
this->cmd_len = pos — (this->temp + 1);
break;
}
//if parsed == temp_end
// goto raise_exception
*parsed = byte;
parsed++;
pos = parsed;
data.pos++;
}
else
{
if (parsed >= temp_end)
{
parsed = temp_end — 1;
}
*parsed = 0;
state = PARSER_PROCESS_CMD;
this->cmd_len = pos — (this->temp + 1);
break;
}
}
break;
case PARSER_PARSE_CONTROL_WORD_NUM_PARAMETER:
while (data.pos != data.end)
{
byte = *(uint8_t*)data.pos;
// length of null-terminated strings cmd + num should be <= 0xFF
if (byte == ‘ ‘)
{
data.pos++;
if (parsed >= temp_end)
{
parsed = temp_end — 1;
}
*parsed = 0;
state = PARSER_PROCESS_CMD;
break;
}
if (byte >= ‘0’ && byte <= ‘9’)
{
if (parsed — this->temp >= 0xFF)
{
if (parsed >= temp_end)
{
parsed = temp_end — 1;
}
*parsed = 0;
state = PARSER_PROCESS_CMD;
break;
}
//if parsed == temp_end
// goto raise_exception
*parsed = byte;
*parsed++;
data.pos++;
}
else
{
if (parsed >= temp_end)
{
parsed = temp_end — 1;
}
*parsed = 0;
state = PARSER_PROCESS_CMD;
break;
}
}
break;
case PARSER_PROCESS_CMD:
case PARSER_SKIP_DATA:
case PARSER_END_GROUP:
case PARSER_SKIP_DATA_CHECK_B:
case PARSER_SKIP_DATA_CHECK_I:
case PARSER_SKIP_DATA_CHECK_N:
case PARSER_SKIP_DATA_GET_BIN_VAL:
case PARSER_SKIP_DATA_INNER_DATA:
this->state = state;
cmd_parser(&data);
state = this->state;
break;
Этот буфер обрабатывается в состоянии PARSER_PROCESS_CMD, которое вызывает другую функцию ответственную за обработку контрольных слов и контрольных символов. Эта функция учитывает текущее состояние и устанавливает следующее.
В коде есть несколько состояний, ответственных за парсинг шестнадцатеричных данных. Для нас наиболее интересно PARSER_PARSE_HEX_DATA – как можно увидеть, это состояние устанавливается в PARSER_BEGIN, если установлено назначение objdata.
case PARSER_PARSE_HEX_DATA:
parsed_data = this->temp;
if (this->bin_size <= 0)
{
while (data.pos != data.end)
{
byte = *(uint8_t*)data.pos;
if (byte == ‘{‘ || byte == ‘}’ || byte == ‘\’)
{
state = PARSER_BEGIN;
if (parsed_data != this->temp)
{
push_data(parsed_data — this->temp);
parsed_data = this->temp;
}
break;
}
if (this->flag & 0x4000)
{
data.pos++;
continue;
}
if (byte >= ‘0’ && byte <= ‘9’)
{
val = byte — 0x30;
}
else if (byte >= ‘a’ && byte <= ‘f’)
{
val = byte — 0x57;
}
else if (byte >= ‘A’ && byte <= ‘F’)
{
val = byte — 0x37;
}
else if (byte == 9 || byte == 0xA || byte == 0xD || byte == 0x20)
{
data.pos++;
continue;
}
else
{
// show message that there are not enough memory
this->flag |= 0x4000;
data.pos++;
continue;
}
if (this->flag & 0x8000)
{
this->hex_data_byte = val << 4;
this->flag &= 0x7FFF;
}
else
{
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;
parsed_data++;
this->flag |= 0x8000;
}
data.pos++;
}
}
else
{
if (this->flag & 0x4000)
{
uint32_t size;
if (this->bin_size <= data.end — data.pos)
{
size = this->bin_size;
}
else
{
size = data.end — data.pos;
}
this->bin_size -= size;
data.pos += size;
}
else
{
while (this->bin_size > 0)
{
if (parsed_data == temp_end)
{
push_data(sizeof(this->temp));
parsed_data = this->temp;
}
byte = *(uint8_t*)data.pos;
*parsed_data = byte;
parsed_data++;
data.pos++;
this->bin_size—;
}
}
}
if (parsed_data != this->temp)
{
push_data(parsed_data — this->temp);
parsed_data = this->temp;
}
break;
Это состояние распарсит шестнадцатеричные и бинарные данные, если они были объявлены.
Состояния PARSER_PARSE_HEX_NUM_MSB и PARSER_PARSE_HEX_NUM_LSB используются в паре для парисинга шестнадцатеричных значений (данные контрольного слова panose и контрольного символа ‘).
case PARSER_PARSE_HEX_NUM_MSB:
this->flag |= 0x8000;
this->hex_num_byte = 0;
state = PARSER_PARSE_HEX_NUM_LSB;
case PARSER_PARSE_HEX_NUM_LSB:
// …
byte = *(uint8_t*)data.pos;
data.pos++;
val = 0;
if (byte — ‘0’ <= 9)
{
val = byte — 0x30;
}
else if (byte — ‘a’ <= 5)
{
val = byte — 0x57;
}
else if (byte — ‘A’ <= 5)
{
val = byte — 0x37;
}
this->hex_num_byte |= val << ((this->flag >> 0xF) << 2);
this->flag = ((~this->flag ^ this->flag) & 0x7FFF) ^ ~this->flag;
if (this->flag & 0x8000)
{
// …
state = PARSER_BEGIN;
}
else
{
break;
}
break;
Сброс состояния
Внимательно изучив код состояний 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 вставить {aftncn FF}, то FF не попадут в данные objdata, т.к. FF теперь относятся к назначению aftncn и будут обработаны согласно его логике.
В противном случае, если вставить {aftnnalc FF}, то FF попадут в objdata т.к. назначение все еще равно objdata.
Также стоит отметить, что {objdata FF} все-еще нельзя использовать т.к. буфер не будет восстановлен.
Точный список назначений был составлен благодаря простому фаззеру.
Фиксированный буфер
Другая техника обфускации, которая приходит в голову при взгляде на код 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, а число встреченных открывающих скобок ({) на 1.
enum
{
// …
PARSER_SKIP_DATA = 0xF,
// …
PARSER_SKIP_DATA_CHECK_B = 0x13,
PARSER_SKIP_DATA_CHECK_I = 0x14,
PARSER_SKIP_DATA_CHECK_N = 0x15,
PARSER_SKIP_DATA_GET_BIN_VAL = 0x16,
PARSER_SKIP_DATA_INNER_DATA = 0x17,
// …
};
Немного магии
Во время анализа состояний из группы PARSER_SKIP_DATA открылись факты, которые идут не только в разрез со спецификацией, но и в разрез с основным кодом парсера.
В поисках контрольного слова bin эти состояния будут пропускать байты, изменяя количество встреченных открывающих и закрывающих скобок, пока их количество не будет равно 0. Западня кроется в том, как обрабатывается числовой параметр в PARSER_SKIP_DATA_GET_BIN_VAL.
Во-первых, максимально допустимая длина числового параметра увеличена до 0xFF байт, длина контрольного слова не учитывается.
Во-вторых, числовой параметр теперь вовсе не числовой! Парсер позволяет передать не только числа, но и латинские буквы. Потом этот параметр передается в кастомную функцию strtol, тем самым позволяя задать длину данных, которые должны быть пропущены, без учета открывающих и закрывающих скобок как шестнадцатеричное число.
Благодаря этим примитивам возможны новые способы обфускации, которые еще не встречались in the wild.
Заключение
Реверс-инжиниринг показал себя как наиболее эффективный способ создания парсера, и в случае с RTF, скорее всего, не удалось бы добиться желаемого результата иначе.
Точный парсинг больше зависит от малых деталей в имплементации и алгоритмических багах, чем от спецификации, которая может быть не точной либо утверждать вещи, противоположные правде.
Продукты «Лаборатории Касперского» распознают все виды RTF-обфускаций и выполняют наиболее корректную обработку документов формата RTF, обеспечивая максимальную защиту для наших пользователей.
Исчезнувшие байты: реверс-инжиниринг RTF-парсера в MS Office