Русская Википедия:Control-flow integrity

Материал из Онлайн справочника
Перейти к навигацииПерейти к поиску

Control-flow integrity (CFI) — общее название методов в компьютерной безопасности, направленных на то, чтобы ограничить возможные пути исполнения программы в пределах заранее предсказанного графа потока управления для повышения её безопасности[1]. CFI усложняет для злоумышленника захват контроля над исполнением программы, делая невозможными некоторые способы переиспользования уже существующих частей машинного кода. К похожим техникам относятся code-pointer separation (CPS) и code-pointer integrity (CPI)[2][3].

Поддержка CFI присутствует в компиляторах Clang[4] и GCC[5], а также в виде Control Flow Guard[6] и Return Flow Guard[7] от Microsoft и Reuse Attack Protector[8] от PaX Team.

История

Изобретение способов защиты от исполнения произвольного кода, таких как Data Execution Prevention и NX-бит, привело к возникновению новых методов, позволяющих получить контроль над программой (например, возвратно-ориентированное программирование)[8]. В 2003 году PaX Team опубликовала документ с описанием возможных ситуаций, приводящих к взлому программы, и идей по защите от них[8][9]. В 2005 году группа исследователей из Microsoft формализовала эти идеи и ввела термин Control-flow Integrity для обозначения методов защиты от изменения изначального потока управления программы. В дополнение к этому авторы предложили метод инструментации уже скомпилированного машинного кода[1].

Впоследствии исследователи, основываясь на идее CFI, предложили множество различных способов, позволяющих повысить устойчивость программы к атакам. Описанные подходы не получили широкого распространения по причинам, включающим большое замедление программы или необходимость дополнительной информации (например, полученной с помощью профилирования)[10].

В 2014 году команда исследователей из Google опубликовала работу, в которой рассматривалась реализация CFI для промышленных компиляторов GCC и LLVM для инструментации программ на C++. Официальная поддержка CFI была добавлена в 2014 году в GCC 4.9.0[5][11] и в 2015 году в Clang 3.7[12][13]. Компания Microsoft выпустила Control Flow Guard в 2014 году для Windows 8.1, добавив поддержку со стороны операционной системы и в Visual Studio 2015[6].

Описание

Файл:Control-flow transfers.png
На схеме показано, как изменяется управление при вызове подпрограмм и возврате из них. Инструкции call и icall соответствуют прямым переходам, инструкция retn — обратному.
Файл:Call graph.png
Граф вызовов. Слева показан наивный подход к построению — по указателю может быть вызвана любая функция. Справа — анализ с учётом типов.

При наличии в коде программы косвенных переходов потенциально возникает возможность передать управление на любой адрес, по которому может располагаться команда (например, на x86 это будет любой возможный адрес, так как минимальная длина команды равна одному байту[14]). Если злоумышленник сможет каким-либо образом модифицировать значение, по которому передаётся управление при выполнении инструкции перехода, то он сможет переиспользовать существующий программный код для своих нужд.

В реальных программах нелокальные переходы обычно ведут к началу функций (например, если используется команда вызова процедуры) или же к инструкции, следующей за инструкцией вызова (возврат из процедуры). Первый тип переходов является прямым (англ. forward-edge) переходом, так как на графе потока управления он будет обозначаться прямой дугой. Второй тип называется обратным (англ. back-edge) переходом, по аналогии с первым — дуга, соответствующая переходу, будет обратной[15].

Прямые переходы

Для прямых переходов количество возможных адресов, на которые может быть передано управление, будет соответствовать количеству функций в программе. Также при учёте системы типов и семантики языка программирования, на котором написан исходный код, возможны дополнительные ограничения[16]. Например, в языке C++ в корректной программе указатель на функцию, используемый при косвенном вызове, должен содержать адрес функции с таким же типом, как и у самого указателя[17].

Один из способов реализации control-flow integrity для прямых переходов заключается в том, что можно проанализировать программу и определить множество легальных адресов для различных инструкций перехода[1]. Для построения такого множества обычно применяется статический анализ кода на каком-либо уровне абстракции (на уровне исходного кода, внутреннего представления анализатора или машинного кода[1][10]). Затем с помощью полученной информации рядом с инструкциями косвенного перехода вставляется код для проверки, соответствует ли адрес, полученный во время исполнения, вычисленному статически. При расхождении программа, обычно, аварийно завершается, хотя реализации позволяют настроить поведение в случае нарушения предсказанного потока управления[18][19]. Таким образом, граф потока управления ограничивается только теми рёбрами (вызовами функций) и вершинами (точками входа в функции)[1][16][20], которые вычисляются во время статического анализа, поэтому при попытке модифицировать указатель, использующийся для косвенного перехода, злоумышленник потерпит неудачу.

Данный способ позволяет предотвратить jump-oriented programming[21] и call-oriented programming[22], так как последние активно используют прямые косвенные переходы.

Обратные переходы

Для обратных переходов возможно несколько подходов к реализации CFI[8].

Первый подход основывается на тех же предположениях, что и CFI для прямых переходов, то есть на возможности вычислить адреса возврата из функции[23].

Второй подход заключается в особом обращении с адресом возврата. Помимо того, чтобы просто сохранять его на стек, он сохраняется, возможно с некоторыми модификациями, ещё и в специально выделенное для него место (например, в один из регистров процессора). Также перед инструкцией возврата добавляется код, восстанавливающий адрес возврата и сверяющий его с тем, который лежит на стеке[8].

Третий подход требует дополнительную поддержку от аппаратной части. Совместно с CFI используется теневой стек (англ. shadow stack) — специальная недоступная злоумышленнику область памяти, в которую сохраняются адреса возврата при вызове функций[24].

При реализации схем CFI для обратных переходов возможно предотвратить атаку возврата в библиотеку и возвратно-ориентированное программирование (англ. return-oriented programming), основанные на изменении адреса возврата на стеке[23].

Примеры

В данном разделе будут рассмотрены примеры реализаций control-flow integrity.

Clang Indirect Function Call Checking

Indirect Function Call Checking (IFCC) включает в себя проверки косвенных переходов в программе за исключением некоторых «особенных» переходов, таких как вызовы виртуальных функций. При построении множества адресов, по которым может произойти переход, учитывается тип функции. Благодаря этому возможно предотвратить не только использование неправильных значений, указывающих не на начало функции, но и неверное приведение типов в исходном коде. Для включения проверок в компиляторе есть опция -fsanitize=cfi-icall[4].

// clang-ifcc.c
#include <stdio.h>

int sum(int x, int y) {
  return x + y;
}

int dbl(int x) {
  return x + x;
}

void call_fn(int (*fn)(int)) {
  printf("Result value: %d\n", (*fn)(42));
}

void erase_type(void *fn) {
  // Поведение не определено, если динамический тип fn не совпадает с int (*)(int).
  call_fn(fn);
}

int main() {
  // При вызове erase_type теряется статическая информация от типе.
  erase_type(sum);
  return 0;
}

Программа без проверок компилируется без каких-либо сообщений об ошибках и отрабатывает, выдавая неопределённый результат, меняющийся от запуска к запуску:

$ clang -Wall -Wextra clang-ifcc.c
$ ./a.out
Result value: 1388327490

После компиляции со следующими опциями получается программа, прерывающая исполнение при вызове call_fn.

$ clang -flto -fvisibility=hidden -fsanitize=cfi -fno-sanitize-trap=all clang-ifcc.c
$ ./a.out
clang-ifcc.c:12:32: runtime error: control flow integrity check for type 'int (int)' failed during indirect function call
(./a.out+0x427a20): note: (unknown) defined here

Clang Forward-Edge CFI for Virtual Calls

Этот метод направлен на контроль целостности виртуальных вызовов в языке C++. Для каждой иерархии классов, в которой присутствуют виртуальные функции, строятся битовые массивы, показывающие, какие функции могут быть вызваны для каждого статического типа. Если при исполнении в программе таблица виртуальных функций какого-либо объекта будет испорчена (например, неправильное приведение типа вниз по иерархии или просто порча памяти злоумышленником), то динамический тип объекта не будет совпадать ни с одним из предсказанных статически[10][25].

// virtual-calls.cpp
#include <cstdio>

struct B {
  virtual void foo() = 0;
  virtual ~B() {}
};

struct D : public B {
  void foo() override {
    printf("Right function\n");
  }
};

struct Bad : public B {
  void foo() override {
    printf("Wrong function\n");
  }
};

int main() {
  Bad bad;                          // Стандарт C++ позволяет делать приведение типов по следующей схеме:
  B &b = static_cast<B&>(bad);      // Derived1 -> Base -> Derived2.
  D &normal = static_cast<D&>(b);   // В результате динамический тип объекта normal
  normal.foo();                     // будет bad и вызовется неправильная функция.
  return 0;
}

После компиляции без включенных проверок:

$ clang++ -std=c++11 virtual-calls.cpp
$ ./a.out
Wrong function

В программе вместо реализации foo класса D была вызвана foo из класса Bad. Данная проблема будет поймана, если скомпилировать программу с -fsanitize=cfi-vcall:

$ clang++ -std=c++11 -Wall -flto -fvisibility=hidden -fsanitize=cfi-vcall -fno-sanitize-trap=all virtual-calls.cpp
$ ./a.out
virtual-calls.cpp:24:3: runtime error: control flow integrity check for type 'D' failed during virtual call (vtable address 0x000000431ce0)
0x000000431ce0: note: vtable is of type 'Bad'
 00 00 00 00  30 a2 42 00 00 00 00 00  e0 a1 42 00 00 00 00 00  60 a2 42 00 00 00 00 00  00 00 00 00
              ^

Примечания

Шаблон:Примечания

Литература

Книги
Статьи

Ссылки