Данный документ освещает теоретические аспекты работающих во время выполнения шифровщиков и описывает эталонную реализацию Portable Executables (PE) [1]: файлового формата динамических библиотек (DLL), объектных файлов и обычных исполняемых файлов Windows.
Автор: Christian Ammann
Рисунок 1: краткий обзор потока работ PE-шифровщика
1 Введение
Шифровщик, работающий во время выполнения (runtime-crypter), получает на вход исполняемые файлы и преобразует их в шифрованную версию (сохраняя их первоначальное поведение). Зашифрованный файл расшифровывает себя при запуске и запускает свое исходное содержимое. Данный подход позволяет разворачивать вредоносные исполняемые файлы в защищенных средах: основанные на шаблонах антивирусные решения обнаруживают сигнатуры подозрительных файлов и блокируют их выполнение. Зашифрованная же копия не содержит известной сигнатуры, ее содержимое нельзя подвергнуть эвристическому анализу, поэтому она нормально выполняется без вмешательства антивирусного сканера. Другие использования шифровщика – защита двоичных файлов от реверсинга и уменьшение размера исполняемых файлов (при замене шифрования сжатием).
Данный документ освещает теоретические аспекты работающих во время выполнения шифровщиков и описывает эталонную реализацию Portable Executables (PE) [1]: файлового формата динамических библиотек (DLL), объектных файлов и обычных исполняемых файлов Windows. Реализация шифрования исполняемых файлов Windows требует общего понимания следующих аспектов:
- Структура PE: PE-заголовок, заголовки секций, записи data directory.
- Загрузчик PE: Как и куда загружаются и выполняются в виртуальной памяти образы процессов.
Мы представим ориентированное на новичков введение в эти две важные темы в разделе 2. Затем в разделе 3 мы рассмотрим и объясним эталонную реализацию PE-шифровщика Гиперион для 32-битных исполняемых файлов, которая может быть разделена на две части (см. рисунок 1): шифровщик и контейнер. Шифровщик (более подробно рассмотрен в разделе 3.1) получает на входе двоичный файл в формате PE, копирует его целиком в память, вычисляет его контрольную сумму и дописывает ее в начало файла. Затем генерируется случайный ключ, который используется для шифрования контрольной суммы и входного файла алгоритмом AES-128 [2]. Наконец, зашифрованный результат копируется в секцию данных контейнера.
Контейнер (более подробно описанный в разделе 3.2) действует как дешифровщик и загрузчик PE: он копирует содержимое ранее зашифрованного файла в память, расшифровывает его и запускает. Ключ шифрования в контейнере отсутствует, поэтому контейнеру приходится подбирать его из ограниченного множества возможных ключей, проверяя правильность ключа с помощью контрольной суммы. На первый взгляд это кажется недостатком, поскольку зашифрованный исполняемый файл нуждается в дополнительном времени на дешифровку при запуске. С другой стороны, это хорошая защита от статического и динамического анализа.
Поток работ шифровщика является еще одним преимуществом нашего подхода: контейнер реализован в виде исходного кода на языке FASM (Flat assembler) [3]. Зашифрованный исполняемый файл представляется в виде исходного кода на языке FASM (например, "db 0x4d, 0x5a, 0x00, 0x00, ...") и сохраняется в файле input.asm. Этот файл копируется в папку с исходным кодом контейнера и включается в код контейнера директивой include (например, include "input.asm"). Наконец, шифровщик вызывает ассемблер, который генерирует соответствующий двоичный файл.
Для сравнения, внедрение зашифрованного входного файла в двоичную форму контейнера (container.exe) потребует исправления больших фрагментов контейнера вручную (базы образа, размера секций и т. д.). Наш подход перекладывает эти задачи на компилятор, который уменьшает сложность шифровщика и упрощает его расширение и поддержку.
Некоторые аспекты вроде полиморфизма и антиэвристики в нашей реализации все еще отсутствуют. Поэтому мы представим и обсудим дальнейшие направления работы в разделе 4.
2 Структура PE-файлов и загрузчик Windows PE
Данный раздел описывает формат PE-файлов Windows и то, как они загружаются в память. Существует много работ по данной теме, и мы полагаем, что читатель имеет по крайней мере некоторое базовое представление о таких принципах работы современных операционных систем, как виртуальная память, системные вызовы и т. д. Поэтому мы дадим лишь краткое введение в важные элементы exe-файлов Windows в следующей таблице:
Наименование | Содержание |
---|
MZ-заглушка | MSDOS-заголовок, MSDOS-заглушка, указатель на Image File header |
Магическое PE-значение | Сигнатура |
Image File Header | Размер опционального заголовка, количество секций |
Image Optional Header | Адрес точки входа, база образа, размер образа |
Data Directories | Указатели на таблицы импорта и экспорта |
Таблица секций | Список заголовков секций |
Секции | секции .code, .data и т. д. |
Представленная таблица показывает структуру PE-образа, но не структуру PE-файла, загружаемого в память. Каждый исполняемый файл windows начинается с MZ-заглушки. Данная заглушка – это программа MSDOS, которая отображает сообщение "You can not run this program in DOS mode" (эту программу нельзя запустить в режиме DOS) или нечто подобное. Таким образом, при запуске в среде MSDOS загрузчик исполняемых файлов распознает DOS-заголовок, отобразит сообщение и завершит работу приложения.
Заголовок MZ-заглушки содержит дополнительный (и последний) элемент – указатель на заголовок Windows PE, начинающийся с магического значения "P, E, 0x0, 0x0", за которым следует заголовок файла образа (image file header). Заголовок файла образа имеет фиксированный размер и содержит информацию о поддерживаемых типах машин (например, архитектуры x86 [4] или ARM [5]), флаги (указывающие на то, является ли файл динамической библиотекой) и т. п. Важными полями для реализации PE-шифровщика являются общее количество секций и размер опционального заголовка. Они необходимы для разбора PE-заголовка, поскольку общее количество секций и количество записей в data directory не фиксировано.
За заголовком файла образа следует опциональный заголовок (который, несмотря на название, не является необязательным). Он содержит различную информацию вроде размера исполняемого кода, размера данных и т. д. (см. [1] для подробностей). Важными полями в опциональном заголовке являются база образа и размер образа. Мы уже упоминали, что контейнер PE-шифровщика действует как дешифровщик и PE-загрузчик. Раз так, контейнеру нужно выделять память по базовому адресу образа (использование базового адреса необязательно, если входной файл имеет таблицу релокаций – relocation table) в размере, равном значению поля «размер образа». Далее расшифрованный файл копируется в зарезервированную область и запускается.
Data directory – также часть опционального заголовка образа. По существу это список, который содержит адреса и размеры таблицы релокаций, таблиц экспорта и импорта и т. д. Наиболее важной записью для PE-шифровщика является указатель на таблицу импорта. Таблица импорта содержит список имен API-функций (Application Programming Interface). API-функции находятся в DLL и используются приложениями для взаимодействия с операционной системой (например, приложение, которое хочет отобразить message box должно вызвать API-функцию MessageBox() из user32.dll). Таблица импорта по сути содержит имена DLL, имена API-функций и пустой список указателей на функции. Контейнеру нужно разобрать таблицу импорта расшифрованного входного файла, загрузить соответствующие DLL, получить адреса нужных API-функций и записать их в список указателей на функции.
Следующая часть PE-заголовка – список заголовков секций. Секции содержат данные и код, и каждая секция имеет соответствующий заголовок. Заголовок секции содержит ее имя, некоторые флаги (чтение, запись, выполнение и т. д.), адрес секции и ее размер. Размер секции состоит из размера сырых данных и виртуального размера. Размер сырых данных представляет размер секции в PE-образе (например, на жестком диске), а виртуальный размер – это размер секции после загрузки в память. Поле адреса также состоит из двух значений: виртуального адреса и указателя на сырые данные. Опять же, указатель на сырые данные – это адрес секции в рамках PE-образа, а виртуальный адрес – это адрес секции после загрузки в память. За последним заголовком секции следуют сами секции.
Мы описали структуру PE-файлов и теперь рассмотрим различные задачи, стоящие перед PE-загрузчиком. Прежде, чем мы сможем описать PE-загрузчик, нам нужно обсудить механизмы адресации в PE-файле: 32-битные и 64-битные исполняемые файлы Windows имеют почти одинаковые PE-заголовки. Есть лишь одно отличие: в зависимости от архитектуры некоторые адреса (например, точка входа) имеют размер 32 или 64 бита. Шифровщик, который описан в данном документе, поддерживает только 32-битные испольняемые файлы и, потому, мы остановимся на 32-битном PE-заголовке.
Другой важный аспект – абсолютная и относительная адресации: большинство записей в PE-заголовке являются относительными виртуальными адресами (RVA). С другой стороны, код в исполняемом файле Windows может использовать абсолютную адресацию и полагать, что PE-файл загружается по базовому адресу образа. Если PE-файл загружается в иное место памяти, приходится использовать таблицу релокаций (которая не является обязательной для обычных exe-файлов), чтобы исправить абсолютные адреса. Теперь мы опишем базовый принцип работы PE-загрузчика Windows:
- Количество памяти, заданное полем «размер образа» выделяется по базовому адресу образа.
- PE-заголовок целиком копируется по базовому адресу образа.
- Секции копируются по соответствующим виртуальным адресам.
- Считывается таблица импорта и загружаются соответствующие DLL. Адреса API-функций записываются в описанный выше список указателей на функции.
- Устанавливаются разрешения для секций (чтение, запись, выполнение).
- Выполнение передается PE-файлу, и загрузчик прыгает на точку входа.
Это лишь базовое и упрощенное описание для лучшего понимания следующих разделов. Некоторые продвинутые темы в нем были пропущены, и мы затронем их в разделе 4.
3 Гиперион
Гиперион можно разделить на две части: шифровщик и контейнер. Взаимодействие обоих компонентов показано на рисунке 2 и подробно описано в следующих двух разделах.
3.1 Шифровщик
Шифровщик – это консольное приложение написанное на C/C++. Оно шифрует входной файл и внедряет его в контейнер. Сперва мы использовали предварительно скомпилированный контейнер. Внедрить входной файл в двоичный контейнер довольно сложно, поскольку PE-заголовок контейнера приходится сильно модифицировать.
Рисунок 2: Детализированный поток выполнения PE-шифровщика
Более того, может оказаться, что входной файл не содержит таблицы релокаций и перед выполнением должен быть загружен по указанному базовому адресу образа. В данном случае контейнер должен быть загружен по базовому адресу входного файла и перезаписан входным файлом после шифрования. При этом приходится исправлять поле «база образа» PE-заголовка контейнера. Данная модификация базы образа влечет за собой обновление каждого элемента в контейнере (кода или данных), который использует абсолютную адресацию.
Однако, нам удалось избежать указанных проблем, и в данном документе представлен новый поток выполнения для PE-шифровщика, работающего при запуске: мы внедряем зашифрованный входной файл в исходный ассемблерный код контейнера. Затем вызывается FASM для генерации исполняемого файла контейнера. Преимуществом здесь является то, что больше не требуется модификации PE-заголовка, исправления абсолютных адресов и т. д. Все это теперь делает Fasm. Таким образом, шифровщик зависит от следующих компонентов:
- Ассемблерный исходный код контейнера.
- Приложение Fasm, вызываемое во время выполнения шифровщика.
- DLL, которая реализует 128-битное AES-шифрование.
Мы описали общую структуру шифровщика и теперь рассмотрим детали его реализации: шифровщик запускается пользователем и получает в качестве параметров пути до входного и выходного файлов. Входной файл копируется в память и проверяется:
- MZ-заголовок должен начинаться с магического MZ-значения.
- Указатель на PE-заголовок должен быть корректным.
- PE-заголовок должен начинаться с магического PE-значение.
- Входной файл должен быть 32-битным исполняемым файлом (64-битные файлы пока не поддерживаются).
Затем PE-заголовок анализируется и из него извлекаются поля «база образа» и «размер образа». Разбираются также и некоторые другие значения (например, заголовки секций), однако они не важны для потока работ шифровщика и просто выводятся на экран в качестве дополнительной информации для пользователя.
Следующий шаг – это шифрование входного файла. Сначала вычисляется его контрольная сумма размером 4 байта и добавляется в начало буфера памяти, вмещающего содержимое входного файла. AES – это блочный шифр, и каждый блок имеет размер 16 байт. Таким образом, размер входного буфера увеличивается до значения, кратного 16 (дополнительное пространство заполняется нулями). После модификации буфера входного файла генерируется случайный ключ шифрования. Гиперион использует алгоритм шифрования AES-128, размер ключа которого равен 16 байтам. Контейнеру приходится подбирать ключ шифрования, что потребовало бы большого количества времени, если бы использовалось все возможное пространство ключей. Поэтому пространство ключей уменьшено, и ключ генерируется с помощью следующего алгоритма:
Листинг 1: Алгоритм генерации ключа AES
1 unsigned char key [ AES KEY SIZE ];
2 for ( int i = 0; i < AES KEY SIZE; i++) {
3 if (i < KEY SIZE) key[i] = rand() % KEY RANGE;
4 else key [i] = 0;
5 }
В листинге использованы две важные константы, которые определены в исходном коде шифровщика: KEY SIZE и KEY RANGE. KEY SIZE определяет фактический размер ключа в байтах и может иметь значение от 0 до 15 (неиспользованные байты заполняются нулями). Максимальное значение каждого элемента ключа определяется константой KEY RANGE, которая может иметь значение в диапазоне от 0 до 255.
После генерации ключа входной файл шифруется. Мы используем реализацию AES для Fasm [6] и компилируем ее в виде DLL, чтобы сделать доступной для нашей реализации шифровщика на C/C++. Шифровщик загружает данную DLL, API-функцию aesEncrypt() и шифрует буфер с содержимым входного файла (содержащий контрольную сумму, сам файл и дополнение до размера, кратного 16) с помощью сгенерированного ключа. Зашифрованный файл конвертируется в следующее ASCII-представление:
Листинг 2: Зашифрованный входной файл, преобразованный в массив Fasm
1 db 0xf3 , 0x64 , 0x24 , 0xa , 0x3e , 0x7e , 0x4c , 0xa6 , 0xcd , 0x91 , \
2 0x47 , 0x2b , 0x5b , 0x3d , 0xd1 , 0x2a , 0xa2 , 0 x f f , 0x38 , 0x40 , \
3 0xe5 , 0x5b , 0xa6 , 0x8a , 0x44 , 0 x f f , 0xc , 0x47 , 0x6a , 0x7f , \
4 ; ...
Содержимое данного листинга совместимо с Fasm, хранится в файле input.asm и копируется в папку с исходным кодом контейнера. Затем база образа, размер образа, KEY SIZE и KEY RANGE также конвертируются в формат, совместимый с Fasm (семантика соответствующего Fasm-кода описана в разделе 3.2) и копируется в папку с исходным кодом контейнера, поскольку эти данные необходимы во время выполнения (для подробностей см. раздел 3.2).
База образа входного файла конвертируется в следующий Fasm-формат (предположим, что базовый адрес образа равен 0x1000000), сохраняется в файле imagebase.asm, который копируется в папку исходных кодов контейнера:
Листинг 3: База образа входного файла, записанная в синтаксисе Fasm
1 format PE GUI 4.0 at 0x1000000
Размер образа конвертируется в строку следующего формата (предположим, что его значение равно 0x8000) и хранится в файле sizeofimage.asm:
Листинг 4: Размер образа входного файла, записанный в синтаксисе Fasm
1 db 0x8000 dup ( ? )
Наконец, KEY SIZE и KEY RANGE конвертируются в следующий исходный код Fasm:
Листинг 5: Константы пространства ключей, конвертированные в синтаксис Fasm
1 REAL KEY SIZE equ 6
2 REAL KEY RANGE equ 4
Далее шифровщик вызывает исполняемый файл Fasm, компилирует исходный код контейнера и генерирует зашифрованную версию входного файла.
3.2 Контейнер
Контейнер по сути действует как дешифровщик и PE-загрузчик и написан на Fasm. Листинг 6 содержит часть исходного кода файла main.asm и демонстрирует общую структуру контейнера. Main.asm начинается с оператора include, который похож на соответствующую директиву препроцессора языка C и вставляет в код содержимое листинга 3. Вставленный фрагмент заставляет Fasm генерировать заданный формат PE-файла, который загружается по указанному базовому адресу. Благодаря этому действию мы можем убедиться, что контейнер всегда загружается по базовому адресу образа зашифрованного входного файла.
Строка 4 содержит оператор entry, который используется в fasm для достижения адреса точки входа. За ней следует подключение нескольких файлов, которые подобны заголовочным файлам C/C++ для важных API-функций и библиотек. Строка 7 подключает файл aes.inc, который является реализацией AES в Fasm [6]. Он используется в шифровщике для шифрования, а в контейнере для дешифрования.
Код между строками 13 и 20 создает секцию данных с именем .bss. Содержимое данной секции показано в листинге 4. Она состоит из пустого массива байтов, имеющего размер, равный размеру образа зашифрованного входного файла. Поэтому секция .bss имеет сырой размер, равный 0, виртуальный размер, равный размеру образа входного файла и располагается сразу после PE-заголовка контейнера.
Листинг 6: Main.asm
1 ; Hyperion 32 Bit container
2
3 include 'imagebase.asm'
4 entry start
5
6 include '..\..\Fasm\fasminclude\win32a.inc'
7 include '..\..\FasmAES-1.0\aes\aes.inc'
8 include 'hyperion.inc'
9 include 'createstrings.inc'
10 include 'pe.inc'
11 include 'keysize.inc'
12
13 ;---------------------------------------------------
14
15 ; empty data section with a size equal to image size
16 ; of the encrypted input file
17 section '.bss' data readable writeable
18
19 decryptedinfile : include 'sizeof image.asm'
20
21 ;---------------------------------------------------
22
23 ; data section which contains the encrypted exe
24 section '.data' data readable writeable
25
26 packedinfile : include 'infile.asm'
27
28 ;---------------------------------------------------
29
30 section '.text' code readable executable
31
32 start : stdcall MainMethod
33
34 proc MainMethod stdcall
35 ; decrypt input file
36 ; load input file
37 ; execute input file
38 ; ...
39 endp
Рисунок 3: Контейнер в памяти перед расшифровкой
Код между строками 21 и 27 создает еще одну секцию данных. Ее содержимое показано на рисунке 2 и по сути является байтовым массивом, содержащим зашифрованный входной файл. За секцией .data следует секция .code, которая расшифровывает и запускает содержимое секции .data.
Мы объяснили базовую структуру контейнера, а теперь проиллюстрируем, как контейнер располагается в памяти. Рисунок 3 показывает расположение контейнера в памяти сразу после его запуска и до начала дешифровки содержимого. Когда контейнер запускается, он динамически загружает некоторые недостающие библиотеки и выделяет адреса для дополнительных API-функций. Это необходимо, поскольку контейнер вызывает API-функции вроде MapViewOfFile() для поддержки логирования, но его таблица импорта содержит только адреса функций LoadLibrary, GetProcAdress и ExitProcess. Затем контейнер ищет смещение секции .data с помощью функции GetModuleHandle (которая возвращает его базу образа) и разбирает PE-заголовок. После нахождения секции .data применяется следующий алгоритм дешифровки:
- В памяти создается резервная копия зашифрованного файла.
- Делается догадка о значении ключа.
- Расшифровывается секция .data.
- Проверяется правильность догадки с помощью контрольной суммы входного файла.
- Если ключ неверен, то секция .data восстанавливается из резервной копии и делается переход на шаг 2.
После дешифрования PE-заголовок контейнера перезаписывается PE-заголовком входного файла. Кроме того, секции входного файла копируются в секцию .bss по их виртуальным адресам. Далее обрабатывается таблица импорта расшифрованного входного файла: каждая указанная в ней DLL загружается с помощью LoadLibrary. Смещения нужных API-функций находятся с помощью GetProcAdress и записываются в таблицу адресов. Наконец, контейнер передает выполнение входному файлу и прыгает на соответствующую точку входа. Расположение контейнера в памяти после шифрования показано на рисунке 4.
Рисунок 4: Контейнер в памяти после дешифровки
4 Заключение и направления дальнейшей работы
Данный документ описывает основные принципы Гипериона – PE-шифровщика, работающего во время выполнения. Полный исходный код будет опубликован на домашней странице Nullsecurity под открытой лицензией.
В реализации Гипериона все еще отсутствуют некоторые важные аспекты: не поддерживаются исполняемые файлы платформы .NET, а код контейнера должен быть переработан, чтобы обходить эвристику антивирусов. Кроме того, отсутствуют такие техники, как позднее связывание API. Наиболее важная часть, нуждающаяся в реализации, – это полиморфизм. Текущая реализация Гипериона лишь шифрует входной файл. Стоит шифровать контейнер целиком и генерировать небольшую заглушку-дешифровщик, используя полиморфизм.
5 Благодарности
Я хотел бы выразить благодарность всей команде Nullsecurity за поддержку данной работы. Особое спасибо Келси за корректировку ошибок и вдохновляющие беседы.
Лицензия
Гиперион, реализация PE-шифровщика Кристиана Амманна, распространяется под лицензией Creative Commons Attribution 3.0 Unported License. Подробности см. здесь: http://creativecommons.org/licenses/by/3.0/
Ссылки
[1] Microsoft Cooperation. Microsoft PE and COFF Specification. http://msdn.microsoft.com/en-us/windows/hardware/gg463119.aspx.
[2] Information Technology Laboratory (National Institute of Standards and Technology). Announcing the Advanced Encryption Standard (AES) [electronic resource]. Computer Security Division, Information Technology Laboratory, National Institute of Standards and Technology, Gaithersburg, MD :, 2001.
[3] Tomasz Grysztar. Flat Assembler. http://flatassembler.net/.
[4] R.C. Detmer. Introduction to 80x86 Assembly Language and Computer Architecture. Jones and Bartlett, 2001.
[5] David Seal. ARM Architecture Reference Manual. Addison-Wesley Longman Publishing Co., Inc., Boston, MA, USA, 2nd edition, 2000.
[6] Christian Ammann. AES Implementation for Flat Assembler. http://www.nullsecurity.net/tools/cryptography/fasmaes-1.0.tar.gz.