Версия для печати


Технология шифрования исполняемого кода
http://www.delphikingdom.com/asp/viewitem.asp?catalogID=775

Иван Равин
дата публикации 08-04-2003 15:09

Технология шифрования исполняемого кода

Введение

Теоретические основы защиты программ от копирования неоднократно приводились в рамках проекта "АКМ", поэтому здесь они подробно обсуждаться не будут.

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

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

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

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

Немного теории

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

Зашифруем "критические" участки кода таким образом, чтобы корректно расшифровать их могли только законные покупатели. Без ключа шифрования программа становится неработоспособной, либо работает только по trial веткам. Теперь алгоритм шифрования становится проверкой законного использования, наличие условного перехода if расшифровалось then … - лишь формальность, избавляющая от неминуемого access violation при исполнении неправильно расшифрованного кода.

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

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

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

Как это работает

Исполняемый код и область данных процесса находятся в одном виртуальном адресном пространстве, поэтому мы можем работать с кодом программы как с данными. Единственное ограничение, которое устанавливает загрузчик - это защита от записи областей памяти, содержащих код. Попытка записи на страницы, содержащие код, вызывает access violation. Но возможность изменить атрибут PAGE_EXECUTE на PAGE_EXECUTE_READWRITE существует - с помощью функции VirtualProtect.

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

Текст защищаемой процедуры/функции будет выглядеть примерно так:
После соответствующей обработки компилированного модуля код программы выглядит так:

Зашифрованный код просто обходим стороной. При расшифровке у нас имеется очевидный критерий проверки: если конечный участок расшифрованного кода совпадает с начальным маркером, то расшифровано правильно. В случае удачи заменяем длинный безусловный переход "коротким", как на первом рисунке - и критический участок кода выполняется.

На приведенных рисунках не показана процедура дешифровки до исполнения и возврата зашифрованного кода на место после. Защищенная программа может исполняться в соответствии со следующими диаграммами:

- последовательно
- прямой переход
- реверс

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

Как проставлять уникальные маркеры в коде программы? Данный вопрос решается с использованием встроенного ассемблера:

asm
  JMP @@1
  DB 'уникальный маркер'
  @@1:
end;   

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

asm
  DB $E9
  DD длина_маркера
  DB 'уникальный маркер'
end;

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

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

Конечно, желательно чтобы маркер выглядел как реальный машинный код, ни в коем случае это не должно быть "заметное" значение типа "FFFFFFFFFFF…" или "ТУТ ШИФРУЕМ". Я использую для их генерации датчик случайных чисел, решение не идеальное, но, по крайней мере, генерируемые значения не бросаются в глаза.

Итак, уникальные маркеры расставлены. Поиск их в готовом откомпилированном файле не сложен - открываем файл с начала и сканируем (чтобы получить файл, как указатель используем MapViewOfFile). А что сканировать во время выполнения? Ответ прост: переменная Hinstance на самом деле представляет собой адрес, по которому загрузилась наша программа/библиотека. Начиная с этого адреса и надо сканировать.

Delphi предоставляет функцию сканирования StrPos, но нам она не подойдет, поскольку и сам код программы, и искомый маркер могут содержать символ #0. Небольшая переделка ассемблерного кода - и у нас в распоряжении функция StrPosLen с двумя новыми аргументами, обозначающими длины буферов и не зависящая от концевых #0. С длиной маркера все понятно, а вот длина области сканирования тоже определяется по-разному. В первом случае это просто размер файла, во втором нам помогут функции из tlhelp32, на основе которых написана функция ModuleSize.

Блок-схема

Вот блок схема действий, производимых в процедуре/функции, часть кода которой шифруется:

…
получить значение маркера;
try
  if <маркер в программе найден> then begin
    сделать копию N байт после маркера;
    расшифровать N байт после маркера; 
    if <последние байты совпали с маркером> then
      заменить длинный переход перед маркером на короткий;
  end

  МАРКЕР

  КОД, КОТОРЫЙ БУДЕТ ЗАШИФРОВАН

  МАРКЕР

finally
  if <есть непустая копия> then
    вернуть копию и длинный переход на место;
end

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

Вот тут на помощь и приходит прекомпилятор, позволяющий автоматизировать весь процесс. Опция {$I FileName.INC} включает в указанное место программы дополнительный код, заданный в файле FileName.INC, а в самом INC-файле можно организовать условную вставку. Создадим include - файл со следующим содержанием:

Code1.Inc

{$IFNDEF CODEPASS0}
{$DEFINE CODEPASS0}
var
  Объявление рабочих переменных для шифрования
{$ELSE}
{$IFNDEF CODEPASS1}
{$DEFINE CODEPASS1}
  Все действия по расшифровке;
  в конце - первый маркер
{$ELSE}
  В начале - второй маркер
  Дальше - код "зачистки"
{$UNDEF CODEPASS1}
{$UNDEF CODEPASS0}
{$ENDIF} // CODEPASS1
{$ENDIF} // CODEPASS0

Теперь в защищаемой процедуре / функции достаточно трижды вставить опцию $I и прекомпилятор сделает все за нас:

procedure MySecretProcedure; 
var
  … 
{$I Code1.Inc} 
begin
 … 
{$I Code1.Inc}
 защищаемый код 
{$I Code1.Inc}
 … 
end;

Первый и второй include (если считать с нуля:) должны стоять на "одном" уровне, то есть если первый был до входа в блок begin..end или try..end, то второй нельзя ставить внутрь, и наоборот. Впрочем, компилятор сам отследит. Еще осторожнее следует относиться к безусловным и условным переходам - тут компилятор может и не заметить ошибки.

Более универсальная схема получается с использованием пяти include: объявление переменных; расшифровка; маркер1; маркер2; зачистка. Вставки № 2-3 и 4-5 могут идти подряд. Именно такая схема реализована в прилагаемом примере (файлы testXX.inc). Вот рабочий прототип 5-проходного файла вставки:

//InitialPassword=39D04FB19F47D48F
//LabelValue=6A6CEED3A7242FF6
{$IFDEF CODING}
{$IFNDEF CODEPASS0}
{$DEFINE CODEPASS0}
var xxLBuffer,yyLBuffer:array [0..llen-1] of byte;
    xxLen,xxLLen,xxOldProtect:dword;
    xxCopy,xxStart:pointer;
{$ELSE}
{$IFNDEF CODEPASS1}
{$DEFINE CODEPASS1}
  fillchar(xxLBuffer,SizeOf(xxLBuffer),#0);
  xxLLen:=LLen;
  xxStart:=nil;
  xxCopy:=nil;
  // задаем начальные значения буфера и с помощью аппаратного ключа получим маркер
  xxLBuffer[0]:=$39;
  xxLBuffer[1]:=$D0;
  xxLBuffer[2]:=$4F;
  xxLBuffer[3]:=$B1;
  xxLBuffer[4]:=$9F;
  xxLBuffer[5]:=$47;
  xxLBuffer[6]:=$D4;
  xxLBuffer[7]:=$8F;
  move(xxLBuffer,yyLBuffer,LLen);
  try
    // получаем тот самый маркер
    if GetMarker(xxLBuffer) then begin
      xxStart:=StrPosLen(Pointer(HInstance), @xxLBuffer, ModuleSize(HInstance), LLen);
      if xxStart<>nil then begin
        Move(pointer(dword(xxStart)-4)^,xxLen,4);
        if virtualprotect(pointer(dword(xxStart)-4),xxLen+4,
                          PAGE_EXECUTE_READWRITE,@xxOldProtect) 
        then
        begin
          GetMem(xxCopy,xxLen);
          Move(xxStart^,xxCopy^,xxLen);
        // аппаратно расшифровываем; xxLBuffer и yyLBuffer используем в качестве ключа
          if UnprotectBuffer(xxStart,xxLen,xxLBuffer,yyLBuffer) then begin
            Move(xxLLen,pointer(dword(xxStart)-4)^,4);
            virtualprotect(pointer(dword(xxStart)-4),xxLen+4,
                           PAGE_EXECUTE,@xxOldProtect);
          end;
        end;
      end;
    end;
{$ELSE}
{$IFNDEF CODEPASS2}
{$DEFINE CODEPASS2}
  asm
    DB $E9
    DD LLen
    DB $6A,$6C,$EE,$D3,$A7,$24,$2F,$F6
  end;
{$ELSE}
{$IFNDEF CODEPASS3}
{$DEFINE CODEPASS3}
  asm
    JMP @@1
    DB $6A,$6C,$EE,$D3,$A7,$24,$2F,$F6
    @@1:
  end;
{$ELSE}
  finally
    // зачистка
    if xxCopy<>nil then begin
      virtualprotect(pointer(dword(xxStart)-4),xxLen+4,
                     PAGE_EXECUTE_READWRITE,@xxOldProtect);
      Move(xxLen,pointer(dword(xxStart)-4)^,4);
      Move(xxCopy^,xxStart^,xxLen);
      virtualprotect(pointer(dword(xxStart)-4),xxLen+4,PAGE_EXECUTE,@xxOldProtect);
      FreeMem(xxCopy);
      xxStart:=nil;
    end;
  end;
{$UNDEF CODEPASS3}
{$UNDEF CODEPASS2}
{$UNDEF CODEPASS1}
{$UNDEF CODEPASS0}
{$ENDIF} // CODEPASS3
{$ENDIF} // CODEPASS2
{$ENDIF} // CODEPASS1
{$ENDIF} // CODEPASS0
{$ENDIF} // CODING

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

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

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

Прилагаемый архив содержит исходные тексты программы генерации исходных текстов и последующей обработки скомпилированных модулей, а также программу-пример, иллюстрирующий очевидность процедуры установки защиты. Это - простейшая версия, но каждый может добавить в генерируемые файлы строки с незначащими операторами, борьбу с отладчиками, дизассемблерами и прочее. В данном случае демонстрируется лишь ТЕХНОЛОГИЯ, применение которой будет различаться в конечных реализациях.

Способ применения

Каждый защищаемый pas-модуль (unit) в секции implementation должен содержать:

Файл codeutil.inc, в принципе, для каждого модуля может отличаться, делая защиту еще более разнообразной.

Программе-кодеру необходимо задать названия файлов, содержащих:

После ввода и редактирования списков возможна автоматическая генерация исходных кодов, компиляция и шифрование, как по шагам, так и всё сразу.

Кроме того, генерируемые исходные файлы содержат опцию условной компиляции {$IFDEF CODING}, и таким образом, если в DEFINE проекта не задана опция CODING, то в него включаются "пустые" файлы и при компиляции в IDE получаются незащищенные (retail) версии модулей, что облегчает "чистую" отладку кода разработчиком. Сама программа-кодер всегда компилирует с опцией {$DEFINE CODING}.

В файл конфигурации dcc32.cfg необходимо добавить все необходимые пути (опция -uPath) , их можно скопировать из Library Path в Environment Options.

Когда программа не будет работать и как этого избежать

Если защищенный модуль не смог загрузиться по адресу Image Base (Project Options -> Linker), то выполнение защищенного кода с вероятностью 100% вызовет Access Violation. Это связано с тем, что при невозможности загрузки по указанному в Image base адресу, модуль загружается в другое место, а адреса всех переменных внутри кода с помощью Relocation Table изменяются на соответствующую дельту. Но в зашифрованном коде они остались прежними, и любое обращение к ним окажется некорректным.

Поэтому лучше не рисковать, а позаботиться о загрузке в нужное место.

Во всех 32-разрядных Windows доступное адресное пространство процесса составляет диапазон от $00400000 ($00010000 в NT) до $7FFEFFFF ($BFFEFFFF в Win2000/3gb). Соответственно только по этим адресам и может быть загружен пользовательский модуль (программа или библиотека). Загрузчик вначале пытается выделить модулю непрерывное адресное пространство нужного размера, начиная с адреса Image Base, и, если это не удается и в модуле присутствует Relocation Table, то модулю выделяется первое свободное место, а адреса вызовов внутри него пересчитываются. Это вызывает неоправданно высокую нагрузку на процессор и страничный файл, а также влияет на CRC загруженного модуля.

Чтобы избежать такой перезагрузки нужно задать модулю такой предпочтительный адрес, где в момент загрузки "будет свободно". Такая загрузка происходит намного быстрее, именно поэтому все системные библиотеки имеют уникальный предпочтительный адрес загрузки, а задание таких адресов в пользовательских библиотеках - одно из правил "хорошего тона". Кроме того, можно удалить Relocation Table - модуль станет немного меньше и вообще не сможет загрузиться по чужому адресу. Но в Delphi эта опция отсутствует.

По какому правилу выбирать адреса Image base?

Адрес по умолчанию, задаваемый в Delphi - $00400000. По этому адресу без проблем загрузится исполняемый модуль (если только ему не помешает какой-нибудь недобросовестный Hook). Но если речь идет о библиотеке, то адрес с вероятностью 100% уже занят исполняемым модулем, подгружающим данную библиотеку (и ранее подгруженными библиотеками). Более того, кроме страниц занятых кодом, почти наверняка окажется занят кусок памяти, распределенный под кучу и локальные переменные, так что вычислить заранее первое свободное место практически невозможно.

Поэтому для библиотек лучше всего искать место вниз от самого верхнего адреса ($7FFEFFFF). Но, как правило, там тоже уже занято (крайние верхние пользовательские адреса, в частности, используют OLE32.DLL, COMDLG32.DLL, SHELL32.DLL и т.д.). Более того, если речь идет о пакетах (BPL), то начиная с адреса $40000000 место занимают "системные" пакеты Borland (VCL50.BPL, VCLX50.BPL и т.д.).

Достаточно свободного места "вокруг" $50000000 и $60000000, "ниже" $70000000 и $40000000. В общем, если состав модулей используемых программой заранее известен, то все ее защищаемые модули без проблем размещаются по уникальным адресам. Если же пишется плагин, который будет работать неизвестно в каком составе, остается выбрать адрес наудачу или иметь несколько вариантов.

Следует также учитывать размер, занимаемый модулем - он немного больше размера файла, его можно посмотреть с помощью прилагаемой утилиты. При установке защиты размер несколько увеличится - в зависимости от сложности API шифрования (для GUARDANT это около 40 кб), и количества защищаемых мест в модуле.

И последнее правило: адрес загрузки кратен 64кб, то есть это число с 4 шестнадцатеричными нулями в конце.
Где задаются предпочтительные адреса загрузки?

Если проект компилируется в IDE, то адрес загрузки берется из опций проекта. Компилятор DCC32.EXE этих опций не использует, и если явно не задать в тексте проекта, этот адрес окажется равным $00010000. Поэтому в коде проекта всех защищаемых модулей надо явно указать нужный адрес, а именно: в файле проекта .DPR вставить строку вида {$IMAGEBASE $00400000} для приложений и {$IMAGEBASE $3FFA0000} (или другой уникальный адрес) для библиотек.

Многопоточные приложения, рекурсия.

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

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

Что еще?

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

Использование рассмотренного метода "полиморфной" защиты особенно хорошо в условиях, когда программа развивается, выходят постоянно новые версии. В этом случае большое количество генерируемых include-файлов, а также ложные вставки и постоянное их изменение должны существенно осложнить работу взломщика.

Выводы

Итак, представлена технология шифрования кода в Delphi, позволяющая создавать защищенные от копирования программы, без внесения существенных изменений в их исходные тексты. Технология основывается на следующих идеях:

Кратко технологию можно назвать так: полиморфное Шифрование с Уникальными Маркерами, или просто ШУМ :)

Скачать проект: Coding.zip (27 K)

Иван Равин
Специально для Королевства Delphi



Литература