[C++] противодействие перехвату функций
От: gear nuke  
Дата: 05.03.10 01:54
Оценка: 63 (6)
В свете последних споров на тему C vs C++ в этом подфоруме, решил реанимировать старую попытку написать "статью". На примере использование библиотеки ontl в решении практической задачи рассмотрим что такое и зачем нужны функторы, а так же одно из применений шаблонов. Расчитано больше на С-шников, C++ для понимания, надеюсь, не потребуется, непотяные куски кода можно пропускать, объяснения будут дальше.

Disclaimer. Приведённый ниже код не компилировался, он может не работать в силу изменений в ntl.


Поиск импортов и функторы

Один из методов перехвата API функций заключается в модификации массива AddressOfFunctions таблицы экспорта системных dll. После этого, функции подобные GetProcAddress будут возвращать указатель на функцию-перехватчик. Если загрузчик ОС загрузит исполняемый файл и свяжет импорт с такими dll, исполняемый файл окажется жертвой разновидности MITM атаки "man in the browser", или по-простому, руткита.

Для начала, посмострим как это работает. Вместо исходного кода загрузчика ОС приведён отдалённый аналог из Native Template Library.

Итак, мы загрузили PE image в память по адресу img_ptr. Традиционный подход связывазывания импортов состоит из 2х циклов разбора директории импорта: внешний ищет экспортирующую dll, а внутренний выполняет поиск связывемых с ней функций. В NTL эту задачу решает следующая функция-член класса pe::image

      template<typename DllFinder>
      bool bind_import(const DllFinder & find_dll)
      {
        for ( import_descriptor * import_entry = get_first_import_entry();
          import_entry && !import_entry->is_terminating();
          ++import_entry )
        {
          if ( ! import_entry->Name ) return false;
          const image * const dll = find_dll(va<const char*>(import_entry->Name));
          if ( ! dll ) return false;
          void ** iat = va<void**>(import_entry->FirstThunk);
          for ( intptr_t * hint_name = va<intptr_t*>(import_entry->OriginalFirstThunk);
            *hint_name;
            ++hint_name, ++iat )
          {
            *iat = *hint_name < 0
              ? dll->find_export(static_cast<uint16_t>(*hint_name))
              : dll->find_export(va<const char*>(*hint_name) + 2, find_dll);
            if ( !*iat ) return false;
          }
        }
        return true;
      }

Класс pe::image спроектирован так, что не содержит каких-либо внутренних данных, а получает их непосредственно из образа в памяти. Можно просто считать, что все его функции-члены принимают неявный параметр — адрес имиджа. С этой точки зрения они ничем не отличаются от обычного "процедурного" подхода, традиционного для C (или assembler).

С использованием NTL связывание импортов будет выглядеть так:
pe::bind(img_ptr)->bind_import();

pe::bind(img_ptr) фактически кастит указатель img_ptr к типу pe::image*, то есть можно было б так написать:
((pe::image*)img_ptr)->bind_import(); // С-каст - моветон в С++

Рассмотрим детальнее bind_import(). Первый вариант функции не содержит кода для поиска экспортирующих dll. Вместо этого он вызывает внешнюю функцию, как будто find_dll — указатель с типом:
const image * find_dll_t(const char * dll_name);

То есть можно передать, например, адрес LoadLibraryA. Можно передать адрес другой функции-аналога в ядре. Это даёт bind_import гибкость.

Однако тип у аргумента bind_import отличен от find_dll_t. С точки зрения C тип параметра find_dll неясен. И в самом деле, этот тип не определён на момент определения функции. Ключевое слово C++ template указывет компилятору, что определяется шаблон — семейство функций, тип параметра которых компилятор выводит в момент вызова. Шаблоны C++ в некотором роде похожи на макросы C, но гораздо мощнее и лишены их недостатков.

Например, если dll уже загружена в адресное пространство процесса, нет возможности использовать LoadLibraryA, но есть адрес PEB, можно написать так:
img_ptr->bind_import(nt::peb::find_dll());

Это аналогично вызову перегруженной bind_import() без аргументов, она делает тоже самое:
// 1
     bool bind_import() { return bind_import(nt::peb::find_dll()); }

В качестве аргумента передаётся экземпляр класса find_dll. В С нет ссылок и это бы выглядело както-так:
bool bind_import() { 
  find_dll f;
  return bind_import(&f); 
}

Сам класс имеет такой вид:
  struct find_dll
  {
    find_dll(nt::peb * peb = &peb::instance()) : peb(peb) {} // конструктор инициализирует член данные переданным аргументом

    nt::peb * peb;

    const pe::image * operator()(const char name[]) const
    {
      if ( !peb ) return 0;
      nt::ldr_data_table_entry::find_dll find_dll(&peb->Ldr->InLoadOrderModuleList);
      return find_dll(name);
    }
  };

В данном случае для объявления класса используется ключевое слово struct — отличается от class только тем, что все члены по умолчанию доступны из клиентского (внешнего) кода.

Особенность этого класса — функция-член c именем operator(). Операторы C++ можно вызывать без указания ключевого слова operator. В данном случае так:

const nt::peb::find_dll dll_finder(); // по умолчанию адрес PEB получается вызовом peb::instance();
dll_finder("my.dll");

В первой строке конструируется объект (переменная) dll_finder. Вторая строка выглядит как обычный вызов функции, отсюда и название — функтор. В отличие от обычных фукций, функтор позволяет хранить состояние в своих членах-данных. Альтернатива — статические переменные с отдельной инициализацией — не очень удобна и потоконебезопасна. (кстати operator() создаёт другой объект-функтор nt::ldr_data_table_entry::find_dll, котрый непосредственно реализует поиск)

Зпись из 2х строк выше — это не предел компактности. C++ позволяет конструировать временные обекты неявно при вызове функций, поэтому код в примере //1 уклыдывается в одну строку. Стоит заметить, что неявное конструирование возможно т.к. bind_import принимает ссылку (&) на константный параметр (const DllFinder); для константных экземпляров класса вызываются константные функции-члены, поэтому после operator()(const char name[]) указано const.


На данном этапе мы можем резолвить импорт используя NTL, но не можем избежать перехвата, если экспорт dll модифицирован в памяти.

Что бы избежать атаки, нужно получать "чистые" директории экспорта (например, из файла на диске) и использовать их для поиска импортируемых функций. Для этого можно добавить код в bind_import. Этот код далеко не всегда нужен и приведёт к утяжелению библиотеки. Писать же различные варианты этой функций под все случаи жизни — вряд ли лучшее решение. Тем более, что можно раелизовать функтор, возвращающий адрес "чистого" PE image:

template<typename DllFinder>
struct claen_dll_finder
{
  claen_dll_finder(DllFinder find_dll) : find_dll(find_dll) {}

  DllFinder find_dll;
  mutable raw_data  img_cache;

  const pe::image * operator()(const char name[]) const
  {
    const pe::image * in_mem = find_dll(name);
    if ( !in_mem ) return 0;

    // load a clean copy
    std::wstring full_name = L"\\SystemRoot\\System32\\";
    for ( unsigned i = 0; name[i]; ++i ) full_name += std::wstring::value_type(name[i]);
    if ( !load_image(const_unicode_string(full_name), img_cache) )
      return 0;

    // fix up the export directory to point to the actual image
    using namespace pe;
    image & clean = *image::bind(img_cache.begin());
    const image::data_directory * const export_table = clean.get_data_directory(image::data_directory::export_table);
    if ( ! export_table || ! export_table->VirtualAddress ) return 0;
    image::export_directory * const exports = clean.va<image::export_directory*>(export_table->VirtualAddress);
    uintptr_t * aof = clean.va<uintptr_t*>(exports->AddressOfFunctions);
    for ( uint32_t ord = exports->NumberOfFunctions; ord; --ord, ++aof )
      if ( !image::in_range(uintptr_t(exports),
                            uintptr_t(exports) + export_table->Size,
                            clean.va<void*>(*aof)) )
        *aof += uintptr_t(in_mem) - uintptr_t(img_cache.begin());
    return &clean;
  }
};

Объект этого типа принимает при конструировании функтор для поиска (или загрузки) dll в памяти, подгружает с диска (код load_image не включен в NTL, можно взять в исходниках ZenADriver) "чистый" образ. operator() возвращает адрес имиджа в bind_import для дальнейшей обработки. Этот адрес отличается от адреса реальной dll, поэтому экспорт копии предварительно настраивается так, что бы при парсинге указывать на адреса используемого имиджа.

Член img_cache (тип его на самом деле std::vector) служит для увеличения производительности. От реализации std::vector не требуется освобождать память при "очистке", поэтому при загрузке следующей dll (а их обычно несколько) есть неплохая вероятность, что не потребуется аллоцировать память заново, как было бы при "традиционном подходе" (хотя, на практике выигрыш вряд ли будет заметен. так как читать с диска — намного дольше).

Можно бы этим пользоваться, но не совсем удобно, так как компилятор не сможет сам вывести тип шаблона выше и его придётся указывать явно. Для удобства пишется простая обёртка в виде функции (которая на самом деле не порождает никакого кода) —
template<typename DllFinder>
inline
claen_dll_finder<DllFinder>
  find_claen_dll(DllFinder find_dll)
{
  return claen_dll_finder<DllFinder>(find_dll);
}


Теперь можно написать опять всего одну строчку:
img_ptr->bind_import(find_claen_dll(nt::peb::find_dll()));


Кстати, аналогично с DllFinder можно бы поступить и с load_image, параметризовав claen_dll_finder еще одним параметром.


Язык С не имеет средств для создания близких аналогов этому решению, не считая препроцессора. Интересно, что в качестве бонуса за использование подхода получилась "бесплатная" алгоритмическая оптимизация — закешировать аллоцированную память для чистых образов.
People who are more than casually interested in computers should have at least some idea of what the underlying hardware is like. Otherwise the programs they write will be pretty weird (c) D.Knuth
 
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.