Фундаментальные основы хакерства

Самомодифицирующийся код как средство защиты приложений


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

Шифрованный код чрезвычайно затрудняет дизассемблирование и усиливает стойкость защиты, а какой разработчик не хочет уберечь свою программу от хакеров? Разумеется, одна лишь шифровка кода – не очень-то серьезное препятствие для взломщика, снабженного отладчиком или продвинутым дизассемблером, наподобие IDA Pro, но антиотладочные приемы (а они существуют и притом в изобилии) – тема отдельного разговора, выходящего за рамки настоящей статьи.

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

Следующий пример (см. листинг 3) читает содержимое функции Demo, зашифровывает его и записывает полученный результат в файл.

void _bild()

{

FILE *f;

char buff[1000];

void (*_Demo) (int (*) (const char *,...));   

void (*_Bild) ();

_Demo=Demo;

_Bild=_bild;

int func_len = (unsigned int) _Bild - (unsigned int) _Demo;



f=fopen("Demo32.bin","wb");

for (int a=0;a<func_len;a++)

fputc(((int) buff[a]) ^ 0x77,f);

fclose(f);

}

Листинг 229 Шифрование функции Demo

Теперь из исходного текста программы функцию Demo

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

Обратите внимание, как функция printf

в листинге 2 выводит приветствие на экран. На первый взгляд ничего необычного, но, задумайтесь, где


размещена строка "Hello, World!". Разумеется, не в сегменте кода – там ей не место ( хотя некоторые компиляторы фирмы Borland помещают ее именно туда). Выходит, в сегменте данных, там, где ей и положено быть? Но если так, то одного лишь копирования тела функции окажется явно недостаточно – придется скопировать и саму строковую константу. А это – утомительно. Но существует и другой способ – создать локальный буфер и инициализировать его по ходу выполнения программы, например, так: …buf[666]; buff[0]='H'; buff[1]='e'; buff[2]='l'; buff[3]='l';buff[4]='o',… - не самый короткий, но, ввиду своей простоты, широко распространенный путь.

int main(int argc, char* argv[])

{

char buff[1000];

int (*_printf) (const char *,...);

void (*_Demo) (int (*) (const char *,...));

char code[]="\x22\xFC\x9B\xF4\x9B\x67\xB1\x32\x87\

\x3F\xB1\x32\x86\x12\xB1\x32\x85\x1B\xB1\

\x32\x84\x1B\xB1\x32\x83\x18\xB1\x32\x82\

\x5B\xB1\x32\x81\x57\xB1\x32\x80\x20\xB1\

\x32\x8F\x18\xB1\x32\x8E\x05\xB1\x32\x8D\

\x1B\xB1\x32\x8C\x13\xB1\x32\x8B\x56\xB1\

\x32\x8A\x7D\xB1\x32\x89\x77\xFA\x32\x87\

\x27\x88\x22\x7F\xF4\xB3\x73\xFC\x92\x2A\

\xB4";

_printf=printf;

int code_size=strlen(&code[0]);

strcpy(&buff[0],&code[0]);

for (int a=0;a<code_size;a++)

buff[a] = buff[a] ^ 0x77;

_Demo = (void (*) (int (*) (const char *,...)))  &buff[0];

_Demo(_printf);

return

0;

}

Листинг 230 Зашифрованная программа

Теперь (см. листинг 4) даже при наличии исходных текстов алгоритм работы функции Demo будет представлять загадку! Этим обстоятельством можно воспользоваться для сокрытия некоторой критической информации, например, процедуры генерации ключа или проверки серийного номера.

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

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



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

С этим связана одна проблема – чтобы модифицировать такой-то байт, инструкции mov требуется передать его абсолютный линейный адрес, а он, как было показано выше, заранее неизвестен. Однако его можно узнать непосредственно в ходе выполнения программы. Наибольшую популярность получила конструкция "CALL $+5\POP reg\mov [reg+relative_addres], xx" – т.е. вызова следующей инструкцией call

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

MyFunc:

push  esi             ; сохранение регистра esi

в стеке

mov   esi, [esp+8] ; ESI = &username[0]

push  ebx          ; сохранение прочих регистров в стеке

push  ecx

push  edx

xor   eax, eax     ; обнуление рабочих регистров

xor   edx, edx

RepeatString:                    ; цикл обработки строки байт-за-байтом

lodsb               ; читаем очередной байт в AL

test  al, al       ; ?достигнут конец строки

jz    short Exit

; Значение счетчика для обработки одного байта строки.

; Значение счетчика следует выбирать так, чтобы с одной стороны все биты

; полностью перемешались, а с другой - была обеспечена четность (нечтность)

; преобразований операции xor

mov   ecx, 21h

RepeatChar:

xor   edx, eax

    ; циклически меняется с xor

на adc

ror   eax, 3

rol   edx, 5

call  $+5          ; ebx = eip

pop   ebx             ; /

xor   byte ptr [ebx-0Dh], 26h; Эта команда обеспечивает цикл.

; изменение инструкции xor

на adc

loop  RepeatChar

jmp   short RepeatString

Exit:

xchg  eax, edx        ; результат

работы (ser.num) в eax

pop   edx          ; восстановление регистров

pop   ecx

pop   ebx

pop   esi

retn                ; возврат из функции



Листинг 231 Процедура генерации серийного номера, предназначенная для выполнения в стеке

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

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

Но назначение статьи состоит не в том, чтобы предложить готовую к употреблению защиту (да и, зачем? чтобы хакерам ее было бы легче изучать?), а доказать (и показать!) принципиальную возможность создания самомодифицирующегося кода под управлением Windows 95/Windows NT/Windows 2000. Как именно предоставленной возможностью можно воспользоваться – надлежит решать читателю.


Содержание раздела