Введение
Представьте ситуацию: вы открываете программу в IDA, видите в графе потока управления две ветки, но декомпилятор показывает лишь одну и вообще без условия. Резонно думаете: «ну, Ида‑то умная, за меня обфускацию решила». Но при запуске процессор почему‑то выполняет совершенно другую ветку кода. И делает он это не потому, что код сам себя модифицирует в памяти, а потому, что мы заставили штатные механизмы Windows работать против аналитика.
В этой статье я покажу концепт новой техники анти‑анализа. До меня её публично никто не описывал. Мы возьмём таблицу релокаций и ASLR — технологии, созданные для защиты и корректной загрузки программ — и превратим их в инструмент, который ломает популярные статические анализаторы.
Как статические анализаторы видят код
Любой статический анализатор работает с PE-файлом так, как он лежит на диске. Он парсит заголовки, находит точку входа, строит граф потока управления и пытается определить, какие ветки условных переходов могут быть достигнуты.
Рассмотрим простой фрагмент:
mov eax, 0xDEADC0DEmov ecx, 0xDEADC0DExor eax, ecxtest eax, eaxjnz real_pathjz dead_path
Что видит анализатор? Два одинаковых значения загружаются в регистры, XOR даёт ноль, test eax, eax устанавливает ZF=1, jnz не срабатывает, jz ведёт в dead_path. Анализатор (и реверсер, который ему доверяет) пойдёт разбирать dead_path, где мы можем разместить мусорный код, ложные строки, фейковые вызовы API — всё, что угодно, чтобы увести анализ в сторону.
Но что, если к моменту исполнения один из операндов 0xDEADC0DE магическим образом изменится?
Таблица релокаций
Отступление: ASLR
Предположим, мы придумали как заставить программу выполнять наш код. Если бы мы знали по какому адресу находятся требуемые нам данные, то можно было бы просто их прочитать по захардкоженному адресу. Microsoft просекли это уже очень давно и придумали ASLR — технологию, которая рандомизирует базовый адрес, тем самым даже если каким‑то образом у нас выйдет заставить выполнить программу наш код, то мы просто не будем знать адреса нужных нам данных (да, есть способы даже в этих условиях прочитать что надо, но не суть). Однако эта штука делает буквально невозможным хранение глобальных адресов в коде (например глобальных переменных), для чего была придумана таблица релокаций.
Пример проблемы
Представьте глобальный указатель:
const char* g_message = "Hello, World!";
Компилятор размещает строку «Hello, World!» в секции .rdata, а указатель g_message в секции .data. Значение этого указателя — это абсолютный виртуальный адрес строки. На этапе компоновки линкер знает только предпочтительный базовый адрес образа, допустим, он равен 0x140000000. Строка оказалась по смещению 0x3000 от начала образа, значит, линкер записывает в g_message значение 0x140003000.
Однако из-за злополучного ASLR программа, скорее всего, не будет загружена по желаемому базовому адресу, что тогда делать?
Решение: таблица релокаций
Дабы решить эту проблему была добавлена таблица релокаций в формат PE‑файла.
Давайте разберёмся, что вообще такое «релокация». По сути, это запись, указывающая на место в программе, где захардкожен адрес (например, глобальный указатель), который нужно подправить, если реальный адрес загрузки программы отличается от желаемого.
Эта таблица содержит в себе адрес того, где нужно применить релокацию, и её тип.
В итоге процесс применения релокаций выглядит так: программа записывается в память, Windows проходит по таблице и делает вот такую простую математику:
delta = actual_load_address - ImageBase
Здесь actual_load_address — тот адрес, по которому программа реально загружается; ImageBase — желаемый базовый адрес, тот, что линкер записал в PE‑заголовок, а Delta — разница между ними. Полученную Delta загрузчик прибавляет к значению по указанному смещению, и всё становится хорошо.
На x64 основным типом такой релокации является IMAGE_REL_BASED_DIR64 (тип 0xA). Для загрузчика эта запись означает простую команду: «возьми 8 байт по этому смещению, прибавь к ним дельту и запиши обратно». Именно этот тип мы и будем использовать для дальнейшего трюка из‑за его популярности
Критическая деталь
Загрузчик Windows применяет эту коррекцию ко всем указанным секциям, даже если это исполняемый код и даже если прав на запись нет. ОС временно снимает защиту страницы, патчит нужные байты и возвращает права обратно.
И вот здесь кроется важная деталь. Загрузчик делает свою работу абсолютно вслепую. Он не проверяет, действительно ли по указанному смещению находится осмысленный указатель, а не код или что‑то ещё.
Если мы вручную добавим в.reloc фейковую запись, указывающую прямо внутрь непосредственного операнда нашей условной инструкции, загрузчик послушно прибавит к нему дельту. По итогу код, выполняемый на процессоре, будет отличаться от того, что на диске.
Идея атаки
Вернёмся к тому куску ассемблера из начала, вот он, чтоб вы не мотали страничку:
mov eax, 0xDEADC0DEmov ecx, 0xDEADC0DExor eax, ecxtest eax, eaxjnz real_pathjz dead_path
Исходя из предыдущих абзацев, можно подумать: а давайте добавим релокацию на какой‑нибудь из 0xDEADC0DE, у нас применится релокация, по итогу это уже будет другое значение и процессор выполнит другую ветку. Как бы да, однако это работало только бы на mov с 64-битным операндом, но если бы наложили в лоб эту 8-байтную релокацию на mov с 32-битным операндом (как в примере), то у нас бы заделась следующая инструкция и всё бы сломалось. Но мы хотим больше свободы, мы хотим применять этот трюк не только на том единственном mov’е, существует куча инструкций с 32-битными операндами.
Формализуем нашу проблему: у нас рандомизируются 64 бита, мы хотим минимизировать это количество бит, но при этом оставить его больше нуля.
А такой ли IMAGE_REL_BASED_DIR64 64-битный?
Вспомним нашу формулу: Delta = actual_load_address — ImageBase. Мы контролируем ImageBase, давайте думать что с ним можно сделать.
Как Windows загружает программу в память
Чтобы понять, какой ImageBase нам выбрать, нужно немного залезть под капот подсистемы памяти Windows.
Виртуальная память делится на страницы по 4 КБ. Однако, когда Windows загружает PE‑файл или резервирует большие куски памяти, она оперирует другим понятием — гранулярностью выделения памяти. В Windows эта гранулярность строго равна 64 КБ (0×10000). Вы физически не можете попросить ОС загрузить файл по адресу, который не кратен 64 КБ.
Кроме того, в Windows есть встроенная защита от разыменования нулевого указателя. Самые первые 64 КБ виртуального адресного пространства (адреса от 0×00000000 до 0×0000FFFF) зарезервированы системой и недоступны. Любая попытка прочитать или записать туда данные вызывает мгновенный краш программы. Это сделано специально, чтобы обращение к nullptr и другим малым числовым значениям не приводило к непредсказуемым последствиям.
Собираем эти два факта вместе: самый первый доступный блок памяти начинается ровно с адреса 0x10000. Это абсолютный минимум, по которому Windows теоретически может загрузить нашу программу.
Что будет, если мы в настройках линкера зададим ImageBase = 0x10000?
Начинается настоящая математическая магия:
-
Отсутствие отрицательных чисел. Так как actual_load_address никогда не будет меньше 0x10000, наша Delta всегда будет строго положительной. Это критически важно! Если бы Delta была отрицательной, в памяти старшие байты числа заполнились бы значениями FF FF FF…, а нам старшие байты нужны нулевыми.
-
Младшие байты всегда нули. Так как и реальный адрес загрузки, и наш ImageBase всегда кратны 64 КБ (0x10000), их разница тоже всегда кратна 64 КБ. Если число кратно 0x10000, это значит, что его младшие 16 бит (ровно 2 байта) всегда гарантированно равны 00 00.
-
Старшие байты всегда нули. Не забываем, что мы на x64. Пользовательское адресное пространство ограничено 47 битами (максимальный адрес 0x7FFFFFFFFFFF). Значит, самые старшие 17 бит нашей Дельты тоже физически не могут содержать ничего, кроме нулей.
Давайте посмотрим, как наша 8-байтная (64-битная) Дельта выглядит в памяти компьютера, учитывая порядок байт little-endian:
Байт: [0] [1] [2] [3] [4] [5] [6] [7] 00 00 XX XX XX XX 00 00 ------- ----------------- ------- всегда нули только эти байты всегда нули (выравнивание реально меняются (ограничение по 64 КБ) 47 бит юзерспейса)
Заметили? Наша релокация 8-байтовая только на бумаге, по факту можно заставить её изменять только 4 байта.
Получается, чтобы использовать этот тип релокации на 32-битном числе, в таблицу нужно записать смещение, которое на 2 байта меньше адреса самого операнда.
Выходит что мы можем использовать 64-битный тип релокаций для 32-битных чисел!
Применяем нашу находку наглядно
Вспомним инструкцию, операнд в который мы хотим зарандомить: mov eax, 0xDEADC0DE. Вот так эта инструкция выглядит в байтах: B8 DE C0 AD DE.
Мы указываем в таблице релокаций смещение на 2 байта левее нашего числа. Вот что выходит:
Байты в памяти: [предыдущий код] B8 DE C0 AD DE [следующий код] | | | | | |Релокация (8 байт): [+0][+1][+2][+3][+4][+5][+6][+7]Наша Дельта: 00 00 XX XX XX XX 00 00
Ну и вот, должно работать. Давайте переходить к тому, как готовые инструменты с этим обходятся.
Результаты
Разработку инструментов для добавления этого трюка я не буду показывать, это скучно, да и завайбить несложно.
Напишем вот такой код на Rust и добавим трюк:
use std::arch::asm;fn main() { unsafe { asm!( "push rax", "push rcx", // mov eax, 0xDEADC0DE ".byte 0xB8, 0xDE, 0xC0, 0xAD, 0xDE", // релоцируем // mov ecx, 0xDEADC0DE ".byte 0xB9, 0xDE, 0xC0, 0xAD, 0xDE", "xor eax, ecx", "test eax, eax", "pop rcx", "pop rax", "jnz 2f", "jz 3f", "3:", "ret", "2:", ); } println!("runnin'");}
Декомпиляторы
Имеем вот такой граф:
Я применил наш трюк к операнду инструкции по адресу 0x00011021.
А вот такая декомпиляция:
А ведь эта функция выводит строку в консоль.
В IDA декомпиляция похожая:
Кстати, если бы мы не придумывали трюк с 32-битной релокацией и остались на mov с 64-битным операндом, то IDA подсветила бы операнд с релокацией ярко-красным:
А с нашим трюком такого не происходит.
Заключение
Хоть и идея использования релокаций для обфускации не нова, но предыдущие техники основывались на разных уязвимостях Windows, позволявших контролировать адрес загрузки, которые уже починили и соответственно старые техники уже просто не работают. Идеи не бороться с ASLR, а использовать его под Windows я ещё нигде не видел, так что, насколько я знаю, это первая публичная запись о таком методе.
У вас мог закрасться вопрос: а что, если Windows вдруг загрузит нашу программу по желаемому базовому адресу? Тогда Delta будет равна нулю, и наш предикат сломается, а программа пойдёт по мусорной ветке.
Отвечаю:
-
Во-первых, это крайне маловероятно.
-
Во-вторых, по умолчанию современная Windows всегда загружает программы с включенным ASLR. Если ASLR отключён или рандомизация не происходит по какой-то другой причине, это почти всегда означает, что наша программа запускается в песочнице или примитивном эмуляторе. В таком случае выполнение мусорной ветки играет нам только на руку — мы получаем отличный, встроенный в архитектуру метод защиты от песочниц!
Я описал реализацию этого способа для платформы x64 (на которой релокации, указывающие прямо в.text, встречаются реже). Однако реализовать аналогичный подход на x86 не составит труда.
Как это анализировать
-
Просто сдампить до выполнения первого TLS коллбека, или если их нет, то до OEP.
-
Написать скрипт, который смотрит на таблицу релокаций и находит релокации на.text секцию (это будет реально работать только на x64, на x86 релокации в.text — норма).
Что с этим можно ещё придумать
Наверное, можно натравить релокации на PE‑заголовок и точечно его ломать, усложнив анализ через дамп (так он по‑идее будет сломан), при этом вообще не используя подозрительные API, так что современные инструменты этого тоже не заметят. Я не проверял эту идею и не знаю насколько она сильно помешает дампу, может потом об этом напишу.
Исходники всего этого и готовый бинарник доступны на моём гитхабе.
ссылка на оригинал статьи https://habr.com/ru/articles/1043458/