четверг, 25 августа 2011 г.

Как работают обфускаторы. Изменения внутренней логики


Введение


В прошлой статье я рассказывал о самых базовых возможностях инструментов обфускации: переименовании типов и их составных частей и шифровании строк и ресурсов программы. Такие приемы могут быть эффективными и сами по себе в простейших случаях, однако они не способны защитить секретные алгоритмы и важные части программы, поскольку весь порядок следования инструкций в коде остался такой же как и был ранее. Да, если обыкновенный неподготовленый зевака откроет обфусцированную программу рефлектором или дизассемблером, то он ничего не сможет разобрать и почти наверняка слегка растроившись прекратит свои попытки понять, как там все внутри устроено. Но если же это более подготовленый человек, которому уж очень нужно разобраться с вашим кодом и он готов потратить на это свое время, то ему не составить труда проследить порядок выполнения инструкций вашей программы.
Сегодня мы пойдем дальше и постараемся разобраться с более сложными приемами, которые своей основной целью ставят изменение логики программы. После подбных преобразований разобраться с вашим кодом будет подсилу разве что матерым проффесионалам. Да и им уже придется изрядно попотеть. К слову, защитить свою программу на 100% абсолютно ото всех – это увы, задача нереализуемая, так как еще не существует такого решения, ни для .NET, ни для неуправляемого кода, которое позволило бы полностю обезопасить прогрммные продукты от взлома и несанкционированного доступа к коду. В наше время ломается все, другой вопрос – какой ценой.

Обфускация данных


Обфускация данных подразумевает изменения структуры переменных используемых в программе а также способов их использования.
Первым вариантом обфускации данных может служить изменение видимости переменной. Так, локальная переменная используемая в коде, может быть преобразована в глобальную переменную, либо поле класса, содержащего данный метод, либо вовсе в публичное поле отдельного вспомогательного класса, который может быть сгенерирован в процессе обфускации программы. В таком случае, для того что бы проследить использование данной переменной в коде, необходимо будет потратить больше времени на определение места объявления этой переменной. Если же переменная будет представлена в виде публичного поля отдельного класса, то в процессе реверс-инжиниринга необходимо будет дополнительно осуществлять проверку где оно еще используется в пределах всей программы.
Более продвинутым методом обфускации является изменение структуры переменных. Этот метод подразумевает к примеру преобразование двумерного массива к одномерному где это допустимо и наоборот. Такие преобразования усложняют восприятие кода и затрудняют реверс инжиниринг. Также можно осуществить разбивку скалярной переменной на две или более. Допустим и обратный процесс – склеивание нескольких переменных в одну. Для большей ясности можно рассмотреть пример разбивки переменной на несколько. На листинге 1 представлен некоторый метод, который в цикле выводит значения от 1 до 10.
Листинг 1.
            char[] array = ...;
            int i = 1;
            int k = 10;
            while (i < k)
            {
              Console.WriteLine(array[i]);
              i ++;
            }

Для того что бы затруднить восприятие логики программы, достаточно перменную i разделить на две переменные i и j, каждая из которых будет инкрементироваться в своем цикле. После подобных преобразований получим код, представленный на листинге 2.
Листинг 2.
            char[] array = ...;
            int i = 0;
            int k = 5;

            while(i < 2)
            {
                int j = 1;
                while(j <= k)
                {
                    Console.WriteLine(array[i * k + j]);
                    j++;
                }
                i++;
            }

В случае использования переменных в качестве счетчика для цикла можно использовать так называемое кодирование данных, которое добавляет дополнительные инструкции для вычисления значения счетчика. Можно рассмотреть данный метод обфускации на примере. В качестве исходного кода возьмем фрагмент представленный на листинге 1, на листинге 3 – код, полученный в результате преобразований.
Листинг 3.
            char[] array = ...;
            int i=11;
            int k = 83;
            while (i < k)
            {
                Console.WriteLine(array[(i - 3) / 8]);
              i += 8;
            }

Обфускация кода

Помимо обфускации данных, существует обфускация кода или обфускация следования управляющих инструкций (control flow obfuscation). Данный вид преобразований предназначен для усложнения восприятия кода защищаемого метода. Как правило все преобразования данного типа подразумевают добавление либо инструкций переходов, предназначенных на запутывание порядка выполнения инструкций, либо всевозможных вспомогательных методов и типов, которые получают управление в процессе выполнения программы. Рассмотрим наиболее распространенные преобразования.
Добавление запутывающих ветвлений программы. Принцип данных преобразований основан на том, что в оригинальный код добавляются инструкции переходов. Рассмотрим ситуацию на примере. После осуществления преобразований кода из листинга 1 получим код, представленный на Листинге 4.
Листинг 4.
            char[] array = ...;
            int i = 1;
            int k = 10;
            while (i < k)
            {
                int j = 10 - i;

                if(j > k)
                {
                    Console.WriteLine("error");
                    i = 1;
                }
                else
                {
                    Console.WriteLine(array[k - j]);
                    i++;  
                }
            }

Если внимательно посмотреть на этот фрагмент, то можно обратить внимание, что выражение if(j > k) всегда будет ложно и фрагмент кода, относящийся к этому условию, никогда не выполнится. Такие блоки кода часто называют мертвым кодом (Dead code). Основное их предназначение заключается в отвлечении внимания, человека, занимающегося реверс-инжинирингом. Другая ветвь ничем не отличается от исходной из листинг 1, за тем исключением, что вместо i используется k j. Если посмотреть внимательно на код, то несложно прийти к выводу, что это выражение всегда будет эквивалентно i. Итак, после преобразований был получен код, имеющий на первый взгляд мало общего с исходным кодом, однако выполняющего в точности ту же самую работу.

Добавление классов-оберток для сокрытия вызовов методов стандартных типов. Даже после преобразований выполненых ранее, код по прежему остается читаемым. Причиной этому является использование метода Console.WriteLine, который переименовать как-то нету никакой возможности, так как это стандартный класс. Для того, что бы запутать код еще больше, можно создать прокси-класс, который иначе называется Dynamic Proxy. Он будет инкапсулировать в себе вызовы метода Console.WriteLine. Листинг 5 содержит реализацию класса.
Листинг 5.
    internal class llI
    {
        public static void Il(string s)
        {
            Console.WriteLine(s);
        }

        public static void Il(char c)
        {
            Console.WriteLine(c);
        }
    }

Теперь можно везде, где необходимо вызов метода Console.WriteLine заменить на вызов метода прокси llI.Il. В результате фрагмент из листинга 4, превратиться в фрагмент представленный на листинге 6.
Листинг 6.
            char[] array = ar;
            int i = 1;
            int k = 10;
            while (i < k)
            {
                int j = 10 - i;

                if(j > k)
                {
                    llI.Il("0xFF");
                    i = 1;
                }
                else
                {
                    llI.Il(array[k - j]);
                    i++;  
                }
            }

Подобные прокси отлично скрывают «настоящие» вызовы. Они могут использоваться не только со стандартными типами, но и с пользовательскими, или даже с другими прокси-объектами, создавая иерархии прокси-вызовов. Используя в комбинации приемы прокси-объектов и ветвлений можно добится невероятных результатов обфускации кода. Код полученный в результате будет очень сложен для восприятия, особенно учитывая тот факт, что названия всех методов и классов также будут в преобразованы к нечитаемому виду. Главное в этом деле не переусердствовать и вовремя остановится, так как необходимо помнить что с введением каждого нового прокси-объекта, защищаемая нами программа теряет в производительности, так как увеличивается количество избыточных вызовов и используемая глубина стека выполнения программы.

Добавление новых типов для сокрытия данных используемых в программе. Данный тип преобразований сочетает в себе обфускацию и кода и данных. Его предназначение – запутать некоторый код, который по входным данным получает в результате новые данные, которые необходимы для дальнейшего выполнения прграммы. Для ясности, рассмотрим пример, который изображен на листинге 7.
Листинг 7.
            switch(c)
            {
                case 0x00FF0000:
                    Console.WriteLine("Red Color");
                    break;
                case 0x0000FF00:
                    Console.WriteLine("Green Color");
                    break;
                case 0x000000FF:
                    Console.WriteLine("Blue Color");
                    break;
                case 0x00FFFFFF:
                    Console.WriteLine("White Color");
                    break;
            }

Этот код по числовому значению c определяет текстовое описание цвета и печатает его на консоль. Суть рассматриваемого преобразования заключается в создании нового типа, основной задачей которого будет для каждого значения входных данных ( в нашем случае это переменная c) получить соответствующие выходные данные (в нашем случае – текстовое название цвета). Реализация нового типа представлена на Листинге 8.
Листинг 8.
    internal class IIl
    {
        private static readonly uint[] _I = new uint[] { 0x00FF0000, 0x0000FF00, 0x000000FF, 0x00FFFFFF};
        private static readonly string[] _l = new[] { "Red", "Green", "Blue", "White" };

        public static string ll(uint c)
        {
            var i = Array.IndexOf(_I, c);
            return i >= 0 ? _l[i] : string.Empty;
        }
    }

Теперь, используя данный клас можно переписать код, как показано на Листинге 9.
Листинге 9.
            var I = IIl.ll(c);

            if(!string.IsNullOrEmpty(I))
                llI.Il(I + " Color");

Обращаю внимание, что в данном примере мы воспользовались Dynamic Proxy из листинга 5 для сокрытия вызова метода Console.WriteLine. В качестве дальнейшего запутывания кода можно добавить аналогичный Dynamic Proxy для вызова метода string.IsNullOrEmpty, а также осуществить переименование всех переменных и шифтрацию строк. После этого уж наверняка будет не очевидно что на самом деле он реализует функцуиональность кода из листинга 7.

Способы отпимизации

После всех этих преобразований, код действительно становится намного запутаннее, чем это было сразу после переименования полей и шифрования строк. Что бы в нем разобраться, теперь придется изрядно потрудится. К сожалению, платой за добавленную сложность стало увеличение размера кода, которое произошло за счет добавления новых типов, дополнительных инструкций переходов и мертвого кода и падения скорости приложения ввиду добавления необходмости вызовов прокси-методов.
В целях возмещения подобных издержек в большинстве обфускаторов применяется ряд преобразований направленных на оптимизацию размера и скорости выполнения кода.
Так, в целях повышения скорости работы программы часто применяется подход, называемый запечатыванием типов. Он сводится к тому, что любой тип, который не имеет наследников помечается ключевым словом sealed. Это может способствовать повышению производительности за счет уменьшения издержек при вызове методов такого типа.
Так же, одним из способов уменьшения размера программы может служить удаление неиспользуемых фрагрментов кода. В процессе выполнения преобразований обфускатор может проанализировать всевозможные ветви выполнения кода и найти фрагменты, которые никогда не получат управление. Соответственно, они могут быть безболезненно удалены из программы. Это положительно отразится на размере программы.

Заключение

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