Pull to refresh

Самомодифицирующийся код

Reading time 12 min
Views 29K
Original author: Giovanni Tropeano
В статье подробно рассказано о самомодифицирующимся коде (СМК), и о том, как его использовать в своих программах. Примеры написаны на C++ с использованием встроенного ассемблера. Ещё я расскажу о том, как выполнять код на стеке, что является существенным козырем при написании и выполнении СМК.



1. Вступление


Ну что ж, поехали. Статья обещает быть длинной, так как мне хочется написать её такой, чтобы у вас не возникло никаких вопросов. На тему СМК уже существует миллион статей, но здесь представлено моё видение проблемы – после сотен часов написания СМК… Я попытаюсь впихнуть все свои труды сюда. Всё, хватайте томатный сок (или что вы там предпочитаете пить), делайте музыку громче и готовьтесь узнать, как избавить своё приложение от начинающих кракеров! Попутно, я расскажу вам о памяти Windows и некоторых других вещах, о которых вы даже и не подозреваете.


2. Краткая история самомодифицирующегося кода


Совсем ещё недавно программисты имели роскошь использовать самомодифицирующийся код где душе угодно. 10-20 лет назад все более или менее серьёзные попытки защиты программ использовали СМК (самомодифицирующийся код). Даже некоторые компиляторы использовали СМК, оперируя кодом в памяти.

Затем в середине 90-х кое-что произошло. Это кое-что называлось Windows 95/NT. Внезапно нам, программистам, дали понять, что всё, что мы делали раньше – это фуфло, и мы должны осваивать новую платформу. Все ранее придуманные уловки можно было забыть, поскольку теперь нам уже было нельзя без спросу играть с памятью, железом и операционной системой. У большинства людей закрались мысли, что написание СМК далее не будет возможным без использования VxD, для которого, что характерно для Windows, отсутствовала более или менее грамотная документация. Спустя некоторое время было обнаружено, что мы всё-таки МОЖЕМ использовать СМК в своих программах. Один из способов – это использование функции WriteProcessMemory, экспортируемой библиотекой Kernel32, другой – размещение кода на стеке с последующей его модификацией.

Остаток статьи в основном посвящён Microsoft Visual C++ и 32-х разрядной подсистеме.


3. Память Windows как она есть


Создать СМК в Windows не так просто, как хотелось бы. Здесь придётся столкнуться с некоторыми подводными камнями, заботливо разложенными создателями Windows. Почему? Да потому что это Microsoft.

Как вы знаете, Windows отводит 4 гигабайта виртуальной памяти для каждого процесса. Для адресации этой памяти, в Windows задействованы два селектора. Один загружается в сегментный регистр CS, а другой бросили в регистры DS, SS и ES. Все они используют один и тот же базовый адрес (равный 0) и ограничены пространством в 4 гигабайта.

В программе может быть только ОДИН сегмент, содержащий и код и данные, также как и ОДИН стек процесса. Вы можете использовать БЛИЖНИЙ вызов процедуры или переход на управляющий код расположенный на стеке. В последнем случае вы не должны использовать SS для обращения к стеку. Хотя значение регистра CS не совпадает с DS, SS и ES, команды MOV dest, CS:[src], MOV dest, DS:[src] и MOV dest, SS:[src] обращаются к одному и тому же участку памяти.

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

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


4. WriteProcessMemory – новый лучший друг


Проще всего изменить несколько байтов в процессе (на мой взгляд) можно воспользовавшись функцией WriteProcessMemory (если не установлены флаги защиты).

Первое, что нужно сделать для этого – получить доступ к загруженному в память процессу, при помощи функции OpenProcess с атрибутами доступа PROCESS_VM_OPERATION и PROCESS_VM_WRITE. Ниже приведён пример простейшего СМК, о котором мы и поговорим. На C++ для реализации данного механизма, нам потребуются некоторые встроенные возможности языка. Само собой, всё это можно проделать и на других языках, но только об этом поговорим как-нибудь в другой раз. Кроме того, на других языках всё это выглядит гораздо сложнее.

Листинг 1. WriteProcessMemory на службе СМК
int WriteMe(void *addr, int wb)
{
	HANDLE
	h=OpenProcess(PROCESS_VM_OPERATION|
	PROCESS_VM_WRITE,
	true, GetCurrentProcessId());
	return WriteProcessMemory(h, addr, &wb, 1, NULL);
}
int main(int argc, char* argv[])
{
	_asm {
		push 0x74 ; JMP >> JZ
		push offset Here
		call WriteMe
		add esp, 8
Here: JMP short Here
	}

	printf("Holy Sh^& OsIX, it worked! #JMP SHORT $2
			was changed to JZ $2n");
	return 0;
}

Как видите, программа заменяет бесконечный цикл простым переходом JZ. Это позволяет программе перейти к следующей инструкции, и мы видим сообщение, которое подтверждает факт замены. Здорово, да? Бьюсь об заклад, теперь вы думаете… хмм, интересно, а я мог бы сделать что-то подобное? Скорее всего, да!

Вместе с тем, у такого способа (использования WriteProcessMemory) есть ряд уязвимостей. Прежде всего, опытный кракер БУДЕТ анализировать таблицу импорта и обнаружит подозрительную функцию. Он, скорее всего, поставит несколько бряков на этот вызов, проанализирует рядом стоящий код и найдёт то, что ему нужно. Потому что использование WriteProcessMemory характерно только для компиляторов, которые собирают код в памяти, или для распаковщиков исполняемых файлов. Вместе с тем, таким трюком вы свободно можете поставить в тупик начинающего кракера. Я часто в своих программах использую такой приём.

Ещё один сакс WriteProcessMemory – невозможность создания в памяти новых страниц. Трюк с этой функцией работает только на существующих страницах. Поэтому, хотя и существуют несколько способов довести применение этой функции до ума, мы обратим своё внимание к выполнению кода на стеке.


5. Размещение кода на стеке, и его исполнение!


Размещать код на стеке не только допустимо, но иногда даже и необходимо. В частности, это облегчает жизнь компиляторам, позволяя им генерировать код на лету. Но не поставят ли такие вольности со стеком под угрозу безопасность системы? Само собой, – они могут навлечь неприятности на вашу задницу. Кроме того, это не самая лучшая технология для ваших программ, поскольку установка патча, запрещающего исполнение кода на стеке, парализует работу большинства ваших творений. С другой стороны, хотя такой патч есть, — в частности для Linux, для Solaris, — и хотя он весьма полезен, я думаю, что его устанавливают только два человека (сами авторы, хи-хи).

Вы ещё помните об упомянутых выше уязвимостях WriteProcessMemory? Трюк с размещением исполняемого кода на стеке дарит нам две приятные возможности для их устранения. Во-первых, инструкции модифицирующие код, располагаются в неизвестном участке памяти, и поэтому кракеру практически невозможно отследить их. Для анализа защищённого кода, ему придётся пилить дерево нашей программы под самый комель, поэтому скорее всего его работа не увенчается большим успехом! Другой аргумент в пользу исполнения кода на стеке – программа в любой момент может выделить себе столько памяти, сколько нужно, и в любой момент может освободить её. По умолчанию операционная система выделяет для стека 1 Мб памяти. Если же выполняемая задача требует большей памяти, программа может запросить дополнительную квоту.

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


6. Почему перемещаемый код может быть вреден для вашего здоровья


Вам должно быть известно, что Windows 9x, Windows NT и Windows 2к располагают стек в разных местах. Поэтому для того чтобы ваша программа была кроссплатформенной, важно использовать относительную адресацию. Реализовать это требование не так уж и сложно, для этого всего лишь нужно следовать нескольким простым правилам – будь они прокляты, эти правила!

К нашей великой радости, в мире 80x86 все «шорт-джампы» и «нир-калы» – относительные. Это значит, что не надо использовать линейные адреса, но надо использовать разницу между целевым адресом и адресом следующей программной инструкции. Такая относительная адресация существенно упростит нашу жизнь, но даже она имеет свои ограничения.

Например, что произойдёт, если функцию void OSIXDemo() {printf(«Hi from OSIXn»);} скопировать в стек и затем вызвать её? Такой вызов скорее всего приведёт к ошибке, поскольку адрес printf изменился.

На ассемблере, посредством регистра адресации, мы можем легко устранить эту проблему. Перемещаемый вызов функции printf можно реализовать очень просто, например, LEA EAX, printfNCALL EAX. Теперь АБСОЛЮТНЫЙ линейный адрес, – не относительный, – размещён в регистре EAX. Поэтому не имеет никакого значения, откуда вызывается функция printf – она будет работать корректно.

Для воспроизводства подобных трюков, необходимо, чтобы ваш компилятор поддерживал ассемблерные вставки. Я знаю, если вы не интересуетесь низкоуровневым программированием, для вас это полный сакс, но точно то же самое МОЖНО проделать ограничившись арсеналом, предоставляемым языками высокого уровня. Вот простой пример:

Листинг 2. Как скопировать функцию в стек и запустить её там
void Demo(int (*_printf) (const char *,...))
{
	_printf("Hello, OSIX!n");
	return;
}

int main(int argc, char* argv[])
{
	char buff[1000];
	int (*_printf) (const char *,...);
	int (*_main) (int, char **);
	void (*_Demo) (int (*) (const char *,...));

	_printf=printf;
	int func_len = (unsigned int) _main ­ (unsigned int)
	_Demo;

	for (int a=0; a<func_len; a++)
		buff[a] = ((char *) _Demo)[a];
	_Demo = (void (*) (int (*) (const char *,...))) &buff[0];
	_Demo(_printf);
return 0;
}

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


7. Начинаем оптимизацию прямо сейчас!


Если вы планируете писать СМК или пользоваться кодом выполняемом на стеке, то вам нужно серьёзно подойти к выбору компилятора, и изучить особенности его работы. Скорее всего ваш код ОБВАЛИТСЯ ОШИБКОЙ при первом же обращении к нему из программы, в особенности если ваш компилятор установлен в режим «оптимизации».

Почему так происходит? Потому что в таких чисто высокоуровневых языках программирования как Си или Паскаль, архи-чертовски сложно скопировать код функции в стек или куда-либо ещё. У программиста есть возможность получить указатель на функцию, но вместе с тем, нет никаких правил, стандартизирующих его использование. В среде программистов это называется «магическое число», о котором известно только компилятору.

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

Давайте ещё раз взглянем на Листинг 2. Мы справедливо предполагаем, что указатель на нашу функцию Demo() совпадает с её началом, и что тело функции расположено сразу же за началом этой функции. Большинство компиляторов придерживаются такого «здравого смысла компилирования», но не рассчитывайте, что все из них следуют этому. Хорошо хоть, большие парни (VC++, Borland и т.д.) всё-таки придерживаются этого правила. Поэтому если вы не используете какой-то неизвестный или новый компилятор, не беспокойтесь об отсутствии «здравого смысла компилирования». Одно замечание относительно VC++: если вы работаете в режиме отладки, компилятор вставляет некий «адаптер» и размещает функцию в другом месте. Чёртов Microsoft. Но не беспокойтесь, просто убедитесь, что в настройках установлен флаг «Link Incrementally», который вынудит ваш компилятор генерировать хороший код. Если же у вашего компилятора нет такой опции, вы можете либо не использовать СМК, или же использовать другой компилятор!

Другая проблема заключается в определении длины функции. Для этого есть простой и надёжный трюк. В C++ инструкция sizeof возвращает размер указателя на функцию, а не размер самой функции. Вместе с тем, как правило компиляторы выделяют память под объекты, в соответствии с порядком их появления в исходном коде. Итак… размер функции – это разность между указателем на функцию и указателем на функцию, следующей за ней. Очень просто! Запомните этот трюк, он вам пригодится, даже несмотря на то, что оптимизирующие компиляторы НЕ БУДУТ следовать этим правилам, а следовательно и метод, который я только что описал, не будет работать. Видите, почему оптимизирующие компиляторы так вредны для вашего здоровья, если вы пишите СМК?!?!?

Ещё одна вещь, которую делают оптимизирующие компиляторы, это удаление переменных, которые, как они ДУМАЮТ, не используются. Возвращаясь к нашему примеру из листинга 2, мы увидим, что в буфер buff записывается какое-то значение, но ничего оттуда НЕ ЧИТАЕТСЯ. Большинство компиляторов не способно распознать факт передачи управления в буфер, поэтому они удаляют инструкции, копирующие код в буфер. Ублюдки! Вот почему управление передаётся на неинициализированный буфер, и затем… бум. Крах. Если такая проблема имеет место быть, снимите флажок с «Global optimization», и всё у вас будет в порядке.

Если же ваша программа всё ещё не работает, не сдавайтесь. Вероятная причина в том, что компилятор в конце каждой функции вставляет вызовы подпрограмм, контролирующие стек. Так поступает Microsoft VC++. Она добавляет в отлаживаемых проектах вызовы функции __chkesp. Не утруждайте себя поиском описания этой функции, в документации его нет! Этот вызов является относительным, и нет никакого способа исключить его. Однако, в финальном проекте VC++ проверяет состояние стека при выходе из функции, поэтому ваша программа будет работать как часы.


8. СМК в Ваших собственных программах


Итак, наконец пришло время для того, чего вы все давно ждали. Если вы проделали весь этот большой путь, описанный в статье, я приветствую вас. (бурные аплодисменты)

Хорошо, теперь вы можете спросить себя (или спросить меня) «Каковы преимущества выполнения кода (функции) на стеке?» И ответ таков (пожалуйста включите барабанную дробь…) Код функции, размещённой на стеке, может быть изменён на лету, например дешифратором. В ответ на это толпа говорит: Ахххххххххххх.

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

Например, простейший алгоритм шифрования, последовательно применяющий к каждой строке кода операцию исключающего ИЛИ (XOR) и который при повторном использовании восстанавливает исходный код!

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

Листинг 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);
}

Результат шифрования помещается в строковою переменную. Теперь функцию Demo() можно удалить из исходного кода. В последствии, когда она нам потребуется, её можно будет дешифровать, скопировать в локальный буфер и вызвать для выполнения. Пинок под зад, да?

Вот пример реализации данного алгоритма:

Листинг 4. Зашифрованная программа
int main(int argc, char* argv[])
{
	char buff[1000];
	int (*_printf) (const char *,...);
	void (*_Demo) (int (*) (const char *,...));
	char code[]="x22xFCx9BxF4x9Bx67xB1x32x87
		x3FxB1x32x86x12xB1x32x85x1BxB1
		x32x84x1BxB1x32x83x18xB1x32x82
		x5BxB1x32x81x57xB1x32x80x20xB1
		x32x8Fx18xB1x32x8Ex05xB1x32x8D
		x1BxB1x32x8Cx13xB1x32x8Bx56xB1
		x32x8Ax7DxB1x32x89x77xFAx32x87
		x27x88x22x7FxF4xB3x73xFCx92x2A
		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;
}  

Обратите внимание, что функция printf() отображает приветствие. При беглом взгляде вы не заметите ничего необычного, но вы посмотрите, где находится строка «Hello, OSIX!». Ей не место в сегменте кода (хотя Borland по каким-то своим причинам размещает там строки), проверив сегмент данных, вы убедитесь, что она там, где и должна быть.

Теперь, даже если перед глазами кракера будет исходный код, для него наша программа по-прежнему останется одной из адских головоломок. Я использую этот метод для сокрытия «секретной» информации (серийные номера и ключи для моих программ, и т.д.).

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

Запомните, при реализации СМК, вам нужно знать ТОЧНОЕ расположение байтов, которые вы собираетесь изменять. Поэтому вместо языков высокого уровня, следует использовать ассемблер. Давай, оставайся со мной, мы почти закончили!

При использовании ассемблера в реализации вышеописанного метода, существует одна проблема. Для изменения какого-либо байта посредством инструкции MOV, необходимо в качестве параметра передать АБСОЛЮТНЫЙ линейный адрес (который, как вы наверно догадались, до компиляции НЕИЗВЕСТЕН). НО… мы можем получить эту информацию в ходе выполнения программы. CALL $+5/POP REG/MOV [reg+relative_address], xx – код, пользующийся огромной популярностью среди меня. Он работает следующим образом. В результате выполнения инструкции CALL на стеке остаётся адрес (или абсолютный адрес этой инструкции). Этот адрес используется в качестве базового для адресации кода стековой функции.

А вот пример верификации серийного номера, который я обещал вам…

Листинг 5. Генерация серийного номера и выполнение на стеке
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

; Значение счётчика, который обрабатывает 1 байт стоки должно быть
; выбрано таким образом, чтобы все биты были перемешаны, но чётность
; (нечётность) обеспечивается за счёт преобразований, выполняемых операцией 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		; Результат работы (сер.ном) в EAX
pop edx		; Восстановление регистров
pop ecx
pop ebx
pop esi
retn			; Возврат из функции

Этот код выглядит довольно-таки странным, поскольку повторные его вызовы, при передаче тех же самых аргументов, на выходе дают либо что-то одинаковое, либо совершенно различные результаты! Это зависит от длины имени пользователя. Если она нечётная, XOR при выходе из функции заменяется ADC. Иначе же, ничего подобного не происходит!

Ну вот и всё пока. Я надеюсь, что данная статья была хоть чем-то полезна вам. Её печать заняла у меня целых два часа! Обратная связь всегда приветствуется.

Английский первоисточник: Giovanni Tropeano. Self modifying code // CodeBreakers Journal. Vol. 1, No. 2, 2006.
Tags:
Hubs:
+5
Comments 13
Comments Comments 13

Articles