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


Защита от несанкционированного использования программ, написанных на Delphi
http://www.delphikingdom.com/asp/viewitem.asp?catalogID=846

Якимов Иван
дата публикации 16-09-2003 10:29

Защита от несанкционированного использования программ, написанных на Delphi

Эта статья написана для людей, знающих основные подходы к реализации защиты программ от несанкционированного использования. Поэтому здесь я не буду обсуждать, почему необходима та или иная процедура. Существует множество статей, описывающих, как именно действует хакер, пытаясь взломать вашу программу. Если вы ознакомились, с этими работами, то знаете, что существенно повысить защищенность вашего программного продукта могут две вещи: шифрация кода и вычисление контрольных сумм критических участков кода, т. е. фактически тех участков, в которых происходит решение о легальности копии. Как я уже говорил, необходимость этих действий обсуждаться здесь не будет. Желающие могут обратиться к проекту "Анти крэковые мучения" на сайте "Королевство Delphi". Здесь мы обсудим реализацию этих процедур на Delphi. Знающим язык Assembler эта статья может показаться излишней, однако большие программы пишутся как раз на языках высокого уровня. Итак начнем.

Существует замечательная на мой взгляд статья (http://www.gks.wsnet.ru/Jurnalz/Izone/izone347/pub/izone12.htm), от которой мы и будем отталкиваться. Позволю себе процитировать некоторую ее часть:

"Рассмотрим пример шифрования простой операцией исключающего ИЛИ. Допустим, у Вас есть процедура CheckTrial, которую Вы хотите зашифровать. В нешифрованном виде она выглядит так: procedure CheckTrial;
begin
 ShowMessage('Trial period has expired.');
end;

procedure EndCheckTrial; {Пустая процедура, означающая конец CheckTrial}
begin
end;
В тексте программы процедура EndCheckTrial обязательно должна следовать сразу после процедуры CheckTrial. Она нам нужна, чтобы знать адрес конца кода процедуры CheckTrial. Итак, зашифруем процедуру CheckTrial, а результат шифрования выведем в TMemo:
var
 ptrAddr: Pointer; {Адрес процедуры CheckTrial}
 aByte: Byte; {Байт процедуры}
 dwOldProtect: DWORD;
begin
 ptrAddr := @CheckTrial;
 VirtualProtect(@CheckTrial, 4096, PAGE_READWRITE, @dwOldProtect);
 while ptrAddr <> @EndCheckTrial do
 begin
  Byte(ptrAddr^) := Byte(ptrAddr^) xor $25;
  aByte := Byte(ptrAddr^);
  Memo1.Lines.Add('0' + IntToHex(aByte, 2) + 'h');
  inc(Integer(ptrAddr));
 end;
end;
Если все сделано правильно, то в TMemo появился текст зашифрованной процедуры CheckTrial. Мы аккуратно вставим этот зашифрованный вариант процедуры в исходный код программы вместо ее нешифрованного варианта. Получится примерно следующее:
procedure CheckTrial;
begin
 asm
  DB 09Dh, 03Dh, 04Ah, 061h, 025h, 0CDh, 0C7h, 0DFh
  DB 0DAh, 0DAh, 0E6h, 025h, 0DAh, 0DAh, 0DAh, 0DAh
  DB 03Ch, 025h, 025h, 025h, 071h, 057h, 04Ch, 044h
  DB 049h, 005h, 055h, 040h, 057h, 04Ch, 04Ah, 041h
  DB 005h, 04Dh, 044h, 056h, 005h, 040h, 05Dh, 055h
  DB 04Ch, 057h, 040h, 041h, 00Bh, 025h, 025h, 025h
 end;
end;

procedure EndCheckTrial; {Пустая процедура, означающая конец CheckTrial}
begin
end;
Моя зашифрованная процедура, скорее всего, будет отличаться от Вашей зашифрованной процедуры, так как ее вид зависит от компилятора и множества других факторов. Если теперь попробовать вызвать процедуру CheckTrial, то произойдет ошибка. Чтобы ошибки не было, надо сначала расшифровать процедуру, а потом вызывать ее. Напишем процедуру, которая расшифровывает CheckTrial и запускает ее:
var
 ptrAddr: Pointer; {Адрес процедуры CheckTrial}
 dwOldProtect: DWORD;
begin
 ptrAddr := @CheckTrial;
 VirtualProtect(@CheckTrial, 4096, PAGE_READWRITE, @dwOldProtect);
 while ptrAddr <> @EndCheckTrial do
 begin
  Byte(ptrAddr^) := Byte(ptrAddr^) xor $25;
  inc(Integer(ptrAddr));
 end;

 CheckTrial;
end;
"


Описанный здесь подход совершенно прозрачен для понимания и радует сердце программиста на Delphi. Тем сильнее разочарование оттого, что данный подход не работает. Все дело в смещении ссылок. Строка Trial period has expired, используемая при вызове ShowMessage хранится в памяти по определенному адресу. Фактически в функции ShowMessage используется только этот адрес. Теперь представим, что вы зашифровали указанную процедуру и подставили полученное байтовое представление между asm и end. Внутри этого зашифрованного кода находится тот адрес строки, по которому она была раньше. Если после этого вы изменили текст программы, то после компиляции адреса наверняка изменятся. В результате по тому адресу будет лежать уже не ваша строка, а что-либо иное. То же самое может произойти и с любыми другими адресами в шифруемом коде. В результате расшифрованная процедура не работает. Можно сказать следующее: "Давайте добавлять защиту в самую последнюю очередь и после этого не менять текст программы". Конечно, это не удобно. Любая попытка сопровождения такой программы будет сопровождаться мучениями по перестановке защиты. Но самое главное, что даже в этом случае код работать не будет. Дело в том, что исходная процедура CheckTrial и процедура с asm. Не эквивалентны в смысле получаемого кода. Компилятор автоматически подставляет после asm … end код возврата из процедуры. А если вы передаете ей какие-либо параметры, то и перед asm … end добавляется определенный код. Все это приводит к уже описанному смещению адресов, и код в результате не работает.

Где же выход? Я предлагаю следующий подход. Давайте будем шифровать файл внешней программой. Т. е. сначала вы создаете обычную программу, в которой нет зашифрованных процедур. В ней вы предусматриваете код расшифрации. Естественно, что после расшифрации незашифрованного кода он работать не будет. Поэтому до поры до времени вы закоментариваете код расшифрации и разрабатываете программу как обычно. Когда разработка завершена, вы раскоментариваете код расшифрации и компилируете проект. Как я уже сказал, программа работать не будет, т. к. после расшифрации незашифрованного кода он не работает. После этого вы запускаете внешнюю утилиту, которая в уже скомпилированном файле (exe-шнике) шифрует нужные участки кода. После этого ваша программа становится полностью функциональной.

Итак, концепция ясна. Осталось выяснить, как ее реализовать. Основной вопрос: "Как внешняя утилита узнает, какой участок кода нужно шифровать?". С помощью меток. Рассмотрим следующий код:

procedure StartPointProc;
begin
 asm
  DB 000h, 034h, 0F4h, 02Ah, 000h, 03Dh, 0FFh, 0CAh
 end;
end;

procedure ProtectedProc;
begin
 ShowMessage('Trial period has expired');
end;

procedure EndPointProc;
begin
 asm
  DB 001h, 035h, 0F5h, 02Bh, 001h, 03Eh, 000h, 0CBh
 end;
end;

Здесь DB 000h, 034h, 0F4h, 02Ah, 000h, 03Dh, 0FFh, 0CAh и DB 001h, 035h, 0F5h, 02Bh, 001h, 03Eh, 000h, 0CBh представляют собой метки. Внешняя утилита ищет в exe-файле эти последовательности байт и шифрует все между ними. Важный момент: сами метки шифроваться не должны, поскольку они используются вашей программой для поиска того участка памяти, который должен быть расшифрован. Может возникнуть вопрос, а что за код представляет собой DB 000h, 034h, 0F4h, 02Ah, 000h, 03Dh, 0FFh, 0CAh, что произойдет при запуске процедур StartPointProc и EndPointProc? Скорее всего, произойдет ошибка. Что делает этот код - неясно. Более того, эти байты нужно подбирать так, чтобы они такая последовательность не встречалась в обычном коде. Иначе может произойти неправильное определение точек начала и конца шифрации. Именно поэтому данные процедуры никогда не должны вызываться из текста вашей программы.

Может возникнуть наивный вопрос, где взять ту самую внешнюю утилиту? Ее вам придется написать самому. Это даст вам возможность применять любые процедуры шифрации. Далее для примера приведен текст функции, которая ищет метку в файле:

function CheckFileLabel(FileName: string; strlabel: string; var point: LongInt): boolean;
var
 F: file;
 currarray: array [1..8] of byte;
 lookarray: array [1..8] of byte;
 i: integer;
begin
 Result := false;
 point := -1;
 //Load labels
 for i := 1 to 8 do
 begin
  lookarray[i] := HexToByte(strlabel[pos('H',UpperCase(strlabel)) - 2] + 
                  strlabel[pos('H',UpperCase(strlabel)) - 1]);
  currarray[i] := 0;
  Delete(strlabel,1,pos('H',UpperCase(strlabel)));
 end;
 //Look for label
 AssignFile(F,FileName);
 FileMode := fmOpenRead;
 Reset(F,1);
 Seek(F,0);
 while not EOF(F) do
 begin
  for i := 1 to 7 do
   currarray[i] := currarray[i + 1];
  BlockRead(F,currarray[8],1);
  Result := true;
  for i := 1 to 8 do
   Result := Result and (currarray[i] = lookarray[i]);
  if Result then
  begin
   //point points to the last byte of label
   point := FilePos(F) - 1;
   break;
  end;
 end;
 CloseFile(F);
end;

Я думаю, ее код достаточно прозрачен. Она возвращает true, если метка найдена. Функция HexToByte тоже нестандартная, но ее реализация проста, и я ее приводить не буду. В качестве метки можно передавать сразу строковую константу 'DB 000h, 034h, 0F4h, 02Ah, 000h, 03Dh, 0FFh, 0CAh'.

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

…
 exit;
 StartPointProc;
 EndPointProc;
end;

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

function CheckMemLabel(ptrStart,ptrEnd: pointer; strlabel: string; var point: pointer): boolean;
 var
  ptrCurrent: pointer;
  currarray: array [1..8] of byte;
  lookarray: array [1..8] of byte;
  i: integer;
begin
 Result := false;
 point := ptrStart;
 //Load labels
 for i := 1 to 8 do
 begin
  lookarray[i] := HexToByte(strlabel[pos('H',UpperCase(strlabel)) - 2] + 
                  strlabel[pos('H',UpperCase(strlabel)) - 1]);
  currarray[i] := 0;
  Delete(strlabel,1,pos('H',UpperCase(strlabel)));
 end;
 //Look for label
 ptrCurrent := ptrStart;
 while ptrCurrent <> ptrEnd do
 begin
  for i := 1 to 7 do
   currarray[i] := currarray[i + 1];
  currarray[8] := Byte(ptrCurrent^);
  Result := true;
  for i := 1 to 8 do
   Result := Result and (currarray[i] = lookarray[i]);
  if Result then
  begin
   //point points to the last byte of label
   point := ptrCurrent;
   break;
  end;
  Inc(Integer(ptrCurrent));
 end; 
end;

В результате выполнения этой функции point указывает на последний байт метки. Strlabel имеет тот же смысл, что и при поиске в файле. Что же такое ptrStart и ptrEnd? Это адреса с которого и по который проводится поиск метки. Как их определить? Вот пара вариантов:

CheckMemLabel(@StartPointProc,@EndPointProc,strlabel,point);
CheckMemLabel(@StartPointProc,Pointer(Integer(@StartPointProc) + 1000),strlabel,point);

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

Теперь ваш код зашифрован. Но хакер может вообще убрать из кода вашей программы вызов защищенной процедуры. Чтобы он не мог этого сделать следует защитить вызывающий код с помощью CRC или какого-то его аналога. Как это сделать? Рассмотрим следующий код:

goto lab1;
asm
 DB 000h, 034h, 0F4h, 02Ah, 000h, 03Dh, 0FFh, 0CAh
end;
lab1:
<защищаемый код>
goto lab2;
asm
 DB 001h, 035h, 0F5h, 02Bh, 001h, 03Eh, 000h, 0CBh
end;
lab2:

Как видно, код, для которого вычисляется CRC находится между двумя метками. Внешняя утилита ищет их и вычисляет CRC для защищаемого кода. Куда его сохранить? Можно прямо в тело программы. Для этого в текст добавляется еще одна процедура:

procedure CRCContainer;
begin
 asm
  DB 0CBh, 001h, 035h, 0F5h, 02Bh, 001h, 03Eh, 000h
  DB 000h, 000h, 000h, 000h
 end;
end;

Внешняя утилита ищет метку DB 0CBh, 001h, 035h, 0F5h, 02Bh, 001h, 03Eh, 000h и в следующие 4 байта записывает CRC. Ваша программа ищет в памяти метки DB 000h, 034h, 0F4h, 02Ah, 000h, 03Dh, 0FFh, 0CAh и DB 001h, 035h, 0F5h, 02Bh, 001h, 03Eh, 000h, 0CBh, вычисляет CRC кода между ними, ищет метку DB 0CBh, 001h, 035h, 0F5h, 02Bh, 001h, 03Eh, 000h, считывает правильный CRC из следующих 4 байт и сравнивает полученные CRC. Таким образом осуществляется проверка того, модифицирован код программы или нет. Таким же образом можно сохранять в теле программы и специфическую информацию, которая собственно и используется для определения легальности копии.

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

Якимов И. М.
сентябрь 2003г.