Добрый день, Хабр! 

В данной заметке я постараюсь сравнить два разных подхода к задаче запутывания машинного кода – это протектор и обфускатор, построенный на базе LLVM-фреймворка. Нам пришлось с этим столкнуться, когда возникла задача защиты библиотек Guardant под разные операционные системы и разные ARM-архитектуры. Для x86/x64 мы используем протектор Guardant Armor, который является полностью нашей разработкой. В случае ARM-архитектуры было принято решение параллельно посмотреть в сторону открытых обфускаторов на базе LLVM с целью их модификации и использования для защиты своих продуктов.

Безопасность через неизвестность 

«Насколько выполнение запутанного кода безопасно? Что нужно защищать?» — одни из самых распространённых вопросов, которые приходится слышать разработчикам ПО.

Для начала кратко о том, что же такое запутывание или обфускация кода. Это преобразование программы в совершенно другой код, который сложно разобрать и понять, но при этом он имеет функциональность как у оригинала.  Если немного подумать, то получается, что мы преобразуем оригинальный код приложения в некий черный ящик и считаем его выполнение безопасным. Но на самом деле запутанный исходный код не является реальным исходным кодом и никак не может считаться таковым. Всегда нужно помнить, что выполнение такого кода может отличаться от оригинала. И это именно тот самый случай, когда каждый должен решать сам — доверять такому коду или нет.

Основным методом взлома является анализ ассемблерного кода, полученного в результате работы дизассемблера. Поэтому основная задача обфускации состоит в том, чтобы максимально усложнить анализ ассемблерного кода и противодействовать изменениям в местах принятия решений. Для этого нужно, чтобы разработчик не просто защищал случайные функции приложения, а защищал именно те функции, в которых программа принимает значимые решения.

LLVM-обфускатор     

Итак, что из себя представляет LLVM-обфускатор. LLVM-обфускатор является частью LLVM- фреймворка, т.е. фактически частью компилятора.  Поэтому LLVM-обфускатор защищает исходный код на этапе компиляции. Обфускация может состоять из нескольких запутывающих проходов. Под проходом мы подразумеваем некий метод структурирования, используемый компилятором для преобразования и оптимизации исходного кода. В данном случае мы используем преобразования для запутывания кода. В основе LLVM лежит байт-код (LLVM intermediate representation), над которым фактически и производятся преобразования. Сам байт-код похож на независимый от платформы ассемблер, из которого генерируется оптимизированный машинный код для целого ряда платформ как статически, так и динамически (JIT-компиляция).

LLVM-обфускация может не зависеть от фронтенда и бэкенда, т.е. от языка программирования и платформы выполнения, т.к. все преобразования выполняются на байт-коде LLVM. У обфускатора есть доступ к реальным переменным приложения. Безусловно наличие фреймворка сильно снижает порог вхождения, а перечисленные свойства являются неоспоримыми плюсами LLVM-обфускатора.

Но на практике для построения качественного обфускатора недостаточно преобразования на уровне байт-кода. Специфичные для процессора, т.е. зависящие от платформы трюки, не могут быть реализованы в общем виде. Плюс ко всему далеко не вся информация доступна на уровне байт-кода LLVM. 

Протектор 

Протектор защищает уже скомпилированный исполняемый модуль и вынужден работать на уровне инструкций процессора. Он не может работать с реальными переменными, т.к. в общем случае задача обратной декомпиляции на 100 % нерешаема. Часть информации безвозвратно утеряна в процессе работы оптимизатора. Невозможно определить и размер переменных, отличить переменные от массива и т.д. Протектору необходимо уметь декомпилировать файл отдельно для каждой платформы и стараться максимально качественно распознавать функции внутри исполняемого модуля. Алгоритмы обфускации в протекторе можно применять непосредственно к машинному коду. Или, что гораздо интереснее, переводить машинные инструкции в байт-код для виртуальной машины и применять алгоритмы обфускации уже непосредственно к байт-коду виртуальной машины.

Как ни крути, но протектор всё равно вынужден преимущественно оперировать машинными инструкциями. Добавлять ложные переменные в исходный код приложения не получится. Но это вовсе не означает, что их не будет совсем. Просто это будет реализовано через виртуальную машину или банальным добавлением данных на стеке.

Также все привыкли к тому, что практически в любом протекторе есть шифрование, контроль целостности и антиотладочные приёмы. 

Алгоритмы обфускации на примере O-LLVM 

Какие алгоритмы могут быть реализованы в общем виде и применяться непосредственно к байт-коду LLVM? Обратимся к самому популярному открытому решению – обфускатору «O-LLVM». В нём используются наиболее известные алгоритмы: алгоритм замены инструкций, алгоритм создания ложных потоков управления с использованием непрозрачных предикатов и алгоритм сглаживания потока управления. Не стоит забывать, что разрабатываемый алгоритм обфускации должен быть устойчив к LLVM-оптимизациям. В противном случае такой алгоритм обфускации будет просто убран оптимизатором. Первым в LLVM выполняется классическая оптимизация, затем применяется обфускация и уже затем выполняются алгоритмы пост-оптимизации.  

Замена инструкций 

Данный алгоритм заменяет простые арифметически и логические операции более сложными, но эквивалентными. Выражает логические операции через базисы. Заменяет сложение вычитанием и наоборот. В коде приложения создаются избыточные переменные, случайные значения которых вычисляются во время компиляции. Данный алгоритм можно применять несколько раз, и в целом он достаточно неплохо позволяет запутать исходный код приложения.

Ложные потоки управления 

Следующий алгоритм позволяет создавать ложные потоки управления. Для того чтобы создать ложную ветку, используется так называемый непрозрачный предикат. Данный термин достаточно устойчиво используется применительно к запутыванию кода и означает некоторое выражение, результат выполнения которого заранее определён логическим значением (истина или ложь). За непрозрачным предикатом следует условный переход на исходный блок или на мусорный блок инструкций. На практике не стоит ограничиваться использованием какого-либо одного непрозрачного предиката, а использовать разные варианты и лучше c разной вероятностью.

Сглаживание потока управления 

И третий алгоритм, про который стоит рассказать, называется сглаживание потока управления. Тело функции состоит из множества блоков инструкций. Алгоритм меняет последовательность вызовов блоков инструкций, помещая их на один уровень в зацикленном switch-операторе.  Для того чтобы реализовать такой алгоритм, в код приложения добавляются дополнительные переменные, которые фактически управляют порядком выполнения. Все три алгоритма являются независимыми от фронтенда и бэкенда, т.е. могут применяться к байт-коду в общем виде.  

Статический и динамический анализ 

Вышеприведенные алгоритмы неплохо справляются со своей задачей. Но недостаток в том, что обфусцированный таким образом код можно анализировать статически. А это значит, что можно найти все возможные варианты замен, обнаружить все ложные блоки и предикаты и с помощью обратной подстановки раскрутить всё назад. Причём именно статически, т.е. не в момент выполнения приложения, а анализируя файл на диске. И далее сделать скрипт, который будет полностью снимать обфускацию.

И здесь мы переходим к тому, о чём уже упоминалось. Одной только обфускации на уровне байт- кода LLVM может оказаться недостаточно. Необходимо сделать так, чтобы защищённый код было сложно анализировать статически. 

Защита от статического анализа        

Защита от статического анализа требует индивидуальной реализации под конкретную платформу и архитектуру. Придётся столкнуться с проблемой обработки исключений и ограничениями, которые присутствую в байт-коде LLVM.

Инструкции переходов нужно будет заменить на машинные инструкции ветвления, но уже на основе регистров на поддерживаемом бэкенде.

Придётся добавить контроль целостности, который должен срабатывать случайным образом или по каким-либо событиям. Оптимальным вариантом будет подсчёт контрольной суммы для каждой функции индивидуально. В остальных случаях это либо легко снимается, либо несёт за собой потерю производительности. В байт-коде LLVM-IR нет объекта, который бы указывал на конец функции и нет гарантии того, что код функции является непрерывным. Поэтому какой-то ограниченный вариант можно реализовать, но универсальное решение вряд ли получится сделать.

Ещё одна идея – это использование JIT-компилятора для генерации кода во время выполнения. Тогда функция превращается в массив данных. Легко контролировать её целостность. Часть функций будет возникать в памяти только в момент исполнения. На практике на LLVM это тяжело реализуемо. Фреймы исключений потребуется регистрировать вручную. Могут возникнуть проблемы с защитой страниц от исполнения.   На Youtube-канале LLVM есть хорошая презентация, в которой рассказывается о проблемах, которые могут возникнуть при разработке обфускатора на базе LLVM-фреймворка.

Итоги 

Складывается ощущение, что обфускация на базе LLVM возникла как побочный эффект. Все знают, что так можно делать, но глубоко все тонкости API никто не продумывал. Удивительно, но при наличии исходного кода, возникают сложности с определением окончания функций. Аналогичную проблему приходится решать в протекторе.  Но протектор защищает уже скомпилированный модуль. На практике что-то простое действительно работает хорошо, но когда начинаешь углубляться, то понимаешь, что ограничен рамками фреймворка.

В протекторе таких проблем не возникает. Всё приходится реализовывать самостоятельно. Это требует больших усилий и существенно увеличивает сроки и стоимость разработки с одной стороны, а с другой позволяет уйти от ограничений фреймворка и реализовать максимально возможную защиту.

Ссылки на актуальные версии открытых решений 

Obfuscator-LLVM:

  1. https://github.com/obfuscator-llvm/obfuscator/wiki/Installation

  2. https://github.com/heroims/obfuscator/wiki/Installation

HikariObfuscator:

  1. https://github.com/HikariObfuscator/Hikari/tree/release_80

  2. https://github.com/61bcdefg/Hikari-LLVM15/tree/llvm-15.0.2rel

Наш ТГ-канал Guardant TechClub – https://t.me/guardant_techclub
Здесь обсуждаем вопросы защиты и лицензирования ПО.

Комментарии (2)


  1. dprotopopov
    12.12.2023 13:06

    Можно и другое что-то предложить - я тоже когда-то пытался что-то осмыслить https://habr.com/ru/articles/270443/

    Ну а обфуцировать отдельное пользовательское стандалон приложение очень-очень давно как не актуально - уже всё давно вынесено в сеть - пусть покупатель просто пользуется web-севисом/приложением как услугой только через браузер/тонкого клиента - соответственно и к коду приложения не получит доступа


  1. mikhazloy Автор
    12.12.2023 13:06

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