
«Зачем вообще писать программу, меняющую код в процессе выполнения? Это же ужасная идея!»
Да, всё так и есть. Но это и хороший опыт. Такое делают только тогда, когда хотят что-то исследовать, или из любопытства.
Самоизменяемые/самомодифицируемые программы не обладают особой полезностью. Они усложняют отладку, программа становится зависимой от оборудования, а изучение кода превращается в очень утомительный и запутанный процесс, если только вы не опытный разработчик на ассемблере. Единственный разумный сценарий применения самоизменяемых программа в реальном мире — это механизм маскировки зловредного ПО от антивирусов. Моя цель исключительно научна, поэтому ничем подобным я заниматься не буду.
Предупреждение: в этом посте активно используется язык ассемблера x86_64, в котором я ни в коем случае не являюсь специалистом. Для написания статьи мне пришлось изучать приличный объём материалов, и, возможно (почти наверняка), в ней есть ошибки.
Первый этап написания самоизменяемой программы — обеспечение возможности изменения кода в среде выполнения. Программисты давно уже поняли, что это плохая идея, поэтому были добавлены меры защиты, предотвращающие изменение кода программы в среде выполнения. Для начала нам нужно понять, где находятся команды при выполнении программы. Когда программа готовится к выполнению, загрузчик загружает всю программу в память. Затем программа выполняется внутри пространства виртуальной памяти, которым управляет ядро. Это адресное пространство разбито на сегменты, показанные ниже.

В данном случае нас интересует лишь текстовый сегмент (Text segment). В нём хранятся команды процесса. За кулисами адресного пространства находятся страницы, с которыми работает ядро. Эти страницы отображаются на физическую память компьютера. Ядро управляет разрешениями для каждой из этих страниц. По умолчанию страницы текстового сегмента имеют разрешения на чтение и выполнение. Мы не можем выполнять в них запись. Для того, чтобы получить возможность менять команды в среде выполнения, нам нужно изменить разрешения страниц текстового сегмента так, чтобы можно было выполнять запись в них.
Менять разрешения страницы можно при помощи функции
mprotect(). Здесь стоит учитывать, что при работе с mprotect() передаваемый ей указатель должен быть выровнен по границе страницы. Ниже показана функция, которая перемещает переданный ей указатель на границу страницы, а затем меняет разрешения страницы на чтение, запись и выполнение.int change_page_permissions_of_address(void *addr) {
    int page_size = getpagesize();
    addr -= (unsigned long)addr % page_size;
    if(mprotect(addr, page_size, PROT_READ | PROT_WRITE | PROT_EXEC) == -1) {
        return -1;
    }
    return 0;
}Если мы передадим этой функции указатель, который указывает на адрес в текстовом сегменте, то в страницу в текстовом сегменте можно будет выполнять запись. Важно отметить, что операционная система может отказывать в праве на запись в текстовый сегмент. Я работаю в Linux, который позволяет выполнять запись в текстовый сегмент. Если у вас другая операционная система, то проверяйте возвращаемое значение, чтобы понять, не было ли выполнение
mprotect() неудачным. В показанных ниже примерах мы предполагаем, что функция, которую будем изменять, полностью умещается в одну страницу. В случае длинных функций это может быть не так.Теперь, когда мы можем выполнять запись в текстовый сегмент, возникает следующий вопрос: что именно мы будем записывать?
Давайте начнём с чего-то простого. Допустим, у меня есть следующая функция:
void foo(void) {
    int i=0;
    i++;
    printf("i: %d\n", i);
}foo() создаёт и инициализирует локальную переменную i значением 0, а затем выполняет её инкремент на 1 и выводит её в stdout. Давайте посмотрим, сможем ли мы изменить значение, на которое выполняется инкремент i.Для решения этой задачи нам нужно изучить не только команды, в которые компилируется
foo(), но и сам машинный код, в который собирается foo(). Давайте поместим foo() в программу, чтобы это было проще сделать.#include <stdio.h>
void foo(void);
int main(void) {
    return 0;
}
void foo(void) {
    int i=0;
    i++;
    printf("i: %d\n", i);
}Теперь
foo() находится в готовой программе на C, поэтому можно её скомпилировать. Сделать это можно так:$ gcc -o foo foo.cИ вот тут всё начинает становиться интереснее. Нам нужно дизассемблировать созданный GCC двоичный файл, чтобы увидеть команды, из которых состоит
foo(). Это можно сделать при помощи утилиты objdump:$ objdump -d foo > foo.disЕсли открыть
foo.dis в текстовом редакторе, то примерно в строке 128 (в зависимости от используемой версии GCC команды foo могут немного различаться) вы должны увидеть дизассемблированную функцию foo(). Она выглядит так:0000000000400538 <foo>
  400538:	55                   	push   %rbp
  400539:	48 89 e5             	mov    %rsp,%rbp
  40053c:	48 83 ec 10          	sub    $0x10,%rsp
  400540:	c7 45 fc 00 00 00 00 	movl   $0x0,-0x4(%rbp)
  400547:	83 45 fc 01          	addl   $0x1,-0x4(%rbp)
  40054b:	8b 45 fc             	mov    -0x4(%rbp),%eax
  40054e:	89 c6                	mov    %eax,%esi
  400550:	bf 14 06 40 00       	mov    $0x400614,%edi
  400555:	b8 00 00 00 00       	mov    $0x0,%eax
  40055a:	e8 b1 fe ff ff       	callq  400410
<printf@plt>
  40055f:	c9                   	leaveq
  400560:	c3                   	retq
  400561:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
  400568:	00 00 00
  40056b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)Если раньше вы никогда не работали с кодом x86_64, то это может выглядеть непонятно. По сути, здесь мы опускаем стек на 4 байта (размер integer в моей системе), чтобы использовать его как место хранения переменной
i. Затем мы инициализируем эти 4 байта значением 0 и прибавляем к этому значению 1. Всё остальное после этого (40054b) копирует значения для подготовки к вызову функции printf().Таким образом, если мы хотим изменить значение, на которое увеличивается
i, нам нужно изменить следующую команду:400547:	83 45 fc 01          	addl   $0x1,-0x4(%rbp)Прежде, чем двигаться дальше, давайте разберём эту команду.
| 400547 | 83 45 fc 01 | addl $0x1,-0x4(%rbp) | 
|---|---|---|
| Первый столбец — это адрес памяти этой команды. | Второй столбец — это машинный код команды. Это байты, которые считает CPU и на которые он будет реагировать. | Третий столбец — это человекочитаемый (для людей, уже имеющих соответствующие знания) дизассемблированный машинный код из второго столбца. | 
| addl | $0x1 | -0x4(%rbp) | 
|---|---|---|
| addl— это команда. В наборе команд x86_64 есть несколько команд сложения (add). Конкретно эта означает прибавление 8-битного значения к регистру или адресу памяти. | $0x1— это непосредственное значение. Символ доллара обозначает непосредственные значения, а префикс0x— что за ним идёт шестнадцатеричное число. В данном случае число просто равно 1, потому что по основанию 100x1 = 1. | -0x4(%rbp)— это адрес памяти, к которому нужно прибавить значение. Здесь он означает, что нужно прибавить его к текущему адресу указателя базового стека, смещённому на 4 байта. Именно в этом месте стека находится наша переменнаяi. | 

На этом моменте x86_64 становится по-настоящему сложным. Команды x86_64 имеют переменную длину, поэтому для незнакомых с ними декодирование команд вручную может быть запутанным и долгим процессом. Чтобы упростить его, существуют различные источники документации. На x86ref.net есть отличная документация, например, справка по команде addl. Если осмелитесь, можете изучить также Intel 64 and IA-32 Architectures Developer’s Manual: Combined Vols. 1, 2, and 3 (предупреждаю, это PDF на три тысячи страниц).
В нашем случае эти байты означают следующее:
| 83 | 45 | fc | 01 | 
|---|---|---|---|
| 83— это опкод командыaddl. Все команды имеют опкод, сообщающий процессору, какую команду выполнять. | 45— байт ModR/M. Согласно документации Intel,0x45 = [RBP/EBP]+disp8. Это значит, что0x45, обозначающий регистр%rbp— это регистр назначения, а следующий за ним байт (в данном случае0xfc) — байт смещения. | fc— это байт смещения.0xfc = 0b11111100. Байт смещения дополняется знаком, то есть это значение просто равно0b100, или -4. | 01— это непосредственное значение, которое будет прибавлено к указанному адресу памяти. Именно это значение нужно поменять, чтобы изменить значение, на которое увеличиваетсяi. | 
Итак, теперь мы можем поменять команду и знаем, что нужно менять; нам лишь нужно знать, как её менять.
Напомню, что мы хотим изменить байт 01 в команде
addl $0x1,-0x4(%rbp).Для этого нам нужно получить адрес этого байта. Получение адреса
foo() в среде выполнения — это тривиальная задача, поскольку нам нужно лишь найти смещение этого байта от начала foo(). Это можно сделать двумя способами:- Использовать дизассемблированный ранее objdumpкод, чтобы подсчитать количество байтов между началом функции и нужным нам байтом.
- Написать функцию, выводящую команды foo()и их смещение от начала функции.
А почему бы не использовать оба способа?
Давайте для начала рассмотрим способ с
objdump. Дизассемблированный код foo() до интересующей нас команды addl выглядит так:0000000000400538 <foo>:
  400538:	55                   	push   %rbp
  400539:	48 89 e5             	mov    %rsp,%rbp
  40053c:	48 83 ec 10          	sub    $0x10,%rsp
  400540:	c7 45 fc 00 00 00 00 	movl   $0x0,-0x4(%rbp)
  400547:	83 45 fc 01          	addl   $0x1,-0x4(%rbp)Функция начинается с
400538, а интересующий нас байт находится в 40055a (400547 + 3) (помните, что это шестнадцатеричные значения!), то есть смещение равно 40055a - 400538 = 12. Так как это шестнадцатеричное (hex) значение, то при вычислении нужных нам смещений нужно использовать hex-значения или преобразовывать их в десятеричный вид. Последнее проще, поэтому нам нужно смещение 0x12 = 18.В этом можно убедиться, написав короткую функцию, которая выводит команды переданной функции. Вот приведённая выше программа с внесёнными изменениями:
#include <stdio.h>
void foo(void);
void bar(void);
void print_function_instructions(void *func_ptr, size_t func_len);
int main(void) {
    void *foo_addr = (void*)foo;
    void *bar_addr = (void*)bar;
    print_function_instructions(foo_addr, bar_addr - foo_addr);
    return 0;
}
void foo(void) {
    int i=0;
    i++;
    printf("i: %d\n", i);
}
void bar(void) {}
void print_function_instructions(void *func_ptr, size_t func_len) {
    for(unsigned char i=0; i<func_len; i++) {
        unsigned char *instruction = (unsigned char*)func_ptr+i;
        printf("%p (%2u): %x\n", func_ptr+i, i, *instruction);
    }
}Обратите внимание, что для определения длины
foo() мы добавили пустую функцию bar(), которая идёт сразу за foo(). Вычтя адрес bar() из адреса foo(), можно определить длину foo() в байтах. Разумеется, при этом предполагается, что bar() следует непосредственно за foo().Результат выполнения программы будет выглядеть так:
$  ./foo
0x40056c ( 0): 55
0x40056d ( 1): 48
0x40056e ( 2): 89
0x40056f ( 3): e5
0x400570 ( 4): 48
0x400571 ( 5): 83
0x400572 ( 6): ec
0x400573 ( 7): 10
0x400574 ( 8): c7
0x400575 ( 9): 45
0x400576 (10): fc
0x400577 (11): 0
0x400578 (12): 0
0x400579 (13): 0
0x40057a (14): 0
0x40057b (15): 83
0x40057c (16): 45
0x40057d (17): fc
0x40057e (18): 1           <-- Вот нужный нам байт!
0x40057f (19): 8b
0x400580 (20): 45
0x400581 (21): fc
0x400582 (22): 89
0x400583 (23): c6
0x400584 (24): bf
0x400585 (25): b4
0x400586 (26): 6
0x400587 (27): 40
0x400588 (28): 0
0x400589 (29): b8
0x40058a (30): 0
0x40058b (31): 0
0x40058c (32): 0
0x40058d (33): 0
0x40058e (34): e8
0x40058f (35): 7d
0x400590 (36): fe
0x400591 (37): ff
0x400592 (38): ff
0x400593 (39): c9
0x400594 (40): c3По адресу
0x40057e находится наш байт 0x1. Как видите, смещение и в самом деле равно 18.Мы наконец-то готовы приступать к изменению кода! Имея указатель на
foo(), можно создать беззнаковый указатель char на конкретный байт, который мы хотим изменить:unsigned char *instruction = (unsigned char*)foo_addr + 18;
*instruction = 0x2A;Если мы всё сделали правильно, то этот код поменяет непосредственное значение в команде
addl на 0x2A, или 42. Теперь при вызове foo() она вместо 1 выведет 42.Соединим всё вместе:
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
void foo(void);
int change_page_permissions_of_address(void *addr);
int main(void) {
    void *foo_addr = (void*)foo;
    // Меняем разрешения страницы, содержащей foo(), на чтение, запись и выполнение
    // Предполагается, что foo() полностью умещается в одну страницу
    if(change_page_permissions_of_address(foo_addr) == -1) {
        fprintf(stderr, "Error while changing page permissions of foo(): %s\n", strerror(errno));
        return 1;
    }
    // Вызываем первоначальную foo()
    puts("Calling foo...");
    foo();
    // Меняем непосредственное значение в команде addl функции foo() на 42
    unsigned char *instruction = (unsigned char*)foo_addr + 18;
    *instruction = 0x2A;
    // Вызываем изменённую foo()
    puts("Calling foo...");
    foo();
    return 0;
}
void foo(void) {
    int i=0;
    i++;
    printf("i: %d\n", i);
}
int change_page_permissions_of_address(void *addr) {
    // Перемещаем указатель к границе страницы
    int page_size = getpagesize();
    addr -= (unsigned long)addr % page_size;
    if(mprotect(addr, page_size, PROT_READ | PROT_WRITE | PROT_EXEC) == -1) {
        return -1;
    }
    return 0;
}Скомпилируем код:
$ gcc -std=c99 -D_BSD_SOURCE -o foo foo.cВывод будет таким:
$ ./foo
Calling foo...
i: 1
Calling foo...
i: 42Победа! При первом вызове
foo() она выводит 1, потому что так ей говорит исходный код. После изменения она выводит 42.Итак, теперь у нас есть самоизменяемая программа на C. Однако она довольно скучная, потому что меняет только одно число. Будет гораздо интереснее изменить
foo() так, чтобы она делала нечто совершенно другое. Например, выполняла exec() оболочки!Но как нам запускать оболочку при вызове
foo()? Логично будет использовать системный вызов execve, но это гораздо сложнее, чем просто поменять один байт.Если мы собираемся изменить
foo() так, чтобы она выполняла exec оболочки, то нам понадобятся для этого команды. К счастью для нас, участники сообщества исследователей безопасности любят использовать машинный код для выполнения exec оболочки, поэтому нам будет просто найти такие команды. Поискав «x86_64 shellcode», мы обнаружим команды для выполнения этой задачи. Они имеют следующий вид:char shellcode[] =
    "\x48\x31\xd2"                              // xor    %rdx, %rdx
    "\x48\x31\xc0"                              // xor    %rax, %rax
    "\x48\xbb\x2f\x62\x69\x6e\x2f\x73\x68\x00"  // mov    $0x68732f6e69622f, %rbx
    "\x53"                                      // push   %rbx
    "\x48\x89\xe7"                              // mov    %rsp, %rdi
    "\x50"                                      // push   %rax
    "\x57"                                      // push   %rdi
    "\x48\x89\xe6"                              // mov    %rsp, %rsi
    "\xb0\x3b"                                  // mov    $0x3b, %al
    "\x0f\x05";                                 // syscallЭтот код взят из http://www.exploit-db.com/exploits/13691/ с двумя моими изменениями, о которых мы поговорим ниже.
- Я добавил xor %rax, %rax, чтобы регистр%raxбыл обнулён. В противном случае он мог быть ненулевым, что вызвало бы segfault.
- Я изменил непосредственное значение $0x68732f6e69622f2fна$0x68732f6e69622f00. Это позволило мне избавиться от команды сдвига, благодаря чему общая длина составила 30 байтов. Обычно подобный шеллкод инъецируется через переполнения буфера или с помощью других зловредных атак, использующих недочёты в обработке строк программой. C-строки завершаются символом NUL, имеющим значение 0, поэтому большинство функцийstring.hвыполняет возврат при считывании байта NUL. По этой причине люди, работающие в сфере безопасности, стараются избегать NUL. В данном случае с символами NUL всё в порядке, поэтому мы можем просто заменить дополнительный0x2fна0x00и отказаться от команды сдвига. Посмотрите по ссылке выше изначальный код, чтобы разобраться, чем отличаются от него мои изменения.
Прежде, чем двигаться дальше, давайте разберёмся, что делает приведённый выше шеллкод. Для начала нам нужно понять, как работает системный вызов. Системный вызов (syscall, system call) — это вызов ядра функцией, позволяющий попросить ядро выполнить какое-то действие. Это может быть что-то, разрешения на что есть только у ядра, поэтому мы и вынуждены просить его. В данном случае системный вызов
execve сообщает ядру, что мы хотим запустить другой процесс и заменить адресное пространство нашего процесса адресным пространством нового процесса. Это означает, что в случае успешного выполнения execve наш процесс, по сути, завершит выполнение.Для выполнения системного вызова в x86_64 мы должны подготовиться к нему, записав нужные значения в нужные регистры, а затем выполнив команду
syscall. Нужные значения и регистры уникальны для каждой операционной системы. Я работаю в Linux, поэтому давайте взглянем на его документацию по системному вызову execve:| %rax | Syscall | %rdi | %rsi | %rdx | 
|---|---|---|---|---|
| 59 | sys_execve | const char *filename | const char *const argv[] | const char *const envp[] | 
Полный список системных вызовов можно найти в http://blog.rchapman.org/post/36801038863/linux-system-call-table-for-x86-64.
Если вы знакомы с прототипом функции
execve() на C (для справки она представлена ниже), то можете заметить, насколько похожа подготовка системного вызова на вызов функции из программы на C.int execve(const char *filename, char *const argv[], char *const envp[]);Если вы незнакомы с x86, то важно отметить, что процедура системного вызова сильно различается в x86 и x86_64. В наборе команд x86 команда системного вызова отсутствует. В x86 системные вызовы выполняются срабатыванием прерывания. Кроме того, в Linux номер системного вызова
execve различается в x86 и x86_64. (11 в x86; 59 в x86_64).Теперь, когда мы знаем, как подготовить системный вызов, давайте объясним каждый этап шеллкода.
| Машинный код | Команда | Объяснение | 
|---|---|---|
| \x48\x31\xd2 | xor %rdx, %rdx | Обнуление регистра %rdx | 
| \x48\x31\xc0 | xor %rax, %rax | Обнуление регистра %rax. Позже мы используем его для значенийNULL, так что он должен быть обнулён. | 
| \x48\xbb\x2f\x62\x69 | mov $0x68732f6e69622f, %rbx | Присваиваем регистру %rbxзначениеhs/nib/. Процессоры Intel имеют формат little endian, поэтому строка должна идти в обратном порядке. Проще всего сделать это на Python при помощи'/bin/sh'[::-1].encode('hex'). Удобно, что "/bin/sh" 64-битный, поэтому умещается в один регистр. Что-то более длинное потребовало бы хитростей для конкатенации длинных строк. | 
| \x53 | push %rbx | Записываем в стек строку /bin/sh(которая пока находилась в регистре%rbx). Командаpushсамостоятельно изменит указатель стека. | 
| \x48\x89\xe7 | mov %rsp, %rdi | Согласно документации по системным вызовам, регистр %rdiдолжен указывать на адрес памяти программы, который нужно выполнить. Указатель стека (регистр%rsp) сейчас указывает на эту строку, поэтому копируем указатель стека в%rdi. | 
| \x50 | push %rax | Второй аргумент функции execve()— это массивargv. Этот массив должен завершаться NULL. Процессоры Intel имеют формат little endian, поэтому нам сначала нужно записать в стек значение NULL, чтобы обозначить конец массива. Помните, что ранее мы обнулили%rax, поэтому для получения значенияNULLнам достаточно лишь записать этот регистр в стек. | 
| \x57 | push %rdi | По соглашению первый аргумент в массиве argv— это имя программы. Помните, что массивargvна самом деле является указателем на массив указателей на строки. В данном случае единственным значением в массиве будет имя программы. Также стоит помнить о том, что регистр%rdiтеперь содержит адрес памяти строки/bin/shв стеке. Если мы запишем этот адрес в стек, то получим массив указателей на строки, составляющий массивargv. | 
| \x48\x89\xe6 | mov %rsp, %rsi | Согласно документации по системным вызовам, регистр %rsiдолжен указывать на адрес массиваargvв памяти. Так как мы только что записали массивargvв стек, указатель стека указывает на первый элементargv. Нам достаточно лишь скопировать указатель стека в регистр%rsi. | 
| \xb0\x3b | mov $0x3b, %al | Последним этапом будет запись номера системного вызова ( 59 = 0x3b) в регистр%rax. Здесь%alозначает первый байт регистра%rax. Так мы записываем 59 в первый байт регистра%rax. Все остальные биты в%raxпо-прежнему равны нулю. | 
| \x0f\x05 | syscall | Завершив всё это, мы готовы передать команду системного вызова, после чего всю работу возьмёт на себя ядро. Скрестим пальцы на удачу! | 
foo() так, чтобы она исполнила этот шеллкод. Вместо того, чтобы, как раньше, менять один байт в foo(), мы хотим полностью заменить foo(). Похоже, это работа для memcpy(). Имея указатель на начало foo() и указатель на наш шеллкод, мы можем скопировать шеллкод по адресу foo() следующим образом:    void *foo_addr = (void*)foo;
    // http://www.exploit-db.com/exploits/13691/
    char shellcode[] =
        "\x48\x31\xd2"                              // xor    %rdx, %rdx
        "\x48\x31\xc0"                              // xor    %rax, %rax
        "\x48\xbb\x2f\x62\x69\x6e\x2f\x73\x68\x00"  // mov    $0x68732f6e69622f2f, %rbx
        "\x53"                                      // push   %rbx
        "\x48\x89\xe7"                              // mov    %rsp, %rdi
        "\x50"                                      // push   %rax
        "\x57"                                      // push   %rdi
        "\x48\x89\xe6"                              // mov    %rsp, %rsi
        "\xb0\x3b"                                  // mov    $0x3b, %al
        "\x0f\x05";                                 // syscall
    // Будьте внимательны здесь с длиной шеллкода, учитывайте то, что находится после foo
    memcpy(foo_addr, shellcode, sizeof(shellcode)-1);Единственное, с чем нам нужно быть внимательными — это запись после конца
foo(). В данном случае мы в безопасности, потому что foo() имеет длину 41 байта, а шеллкод — 29 байта. Стоит отметить, что поскольку шеллкод — это C-строка, в конце неё находится символ NUL. Мы хотим скопировать только байты самого шеллкода, поэтому вычитаем 1 из sizeof шеллкода в аргументе длины memcpy.Отлично! Давайте теперь соберём всё это в готовую программу.
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
void foo(void);
int change_page_permissions_of_address(void *addr);
int main(void) {
    void *foo_addr = (void*)foo;
    // Меняем разрешения страницы, содержащей foo(), на чтение, запись и выполнение
    // Предполагается, что foo() полностью умещается в одну страницу
    if(change_page_permissions_of_address(foo_addr) == -1) {
        fprintf(stderr, "Error while changing page permissions of foo(): %s\n", strerror(errno));
        return 1;
    }
    puts("Calling foo");
    foo();
    // http://www.exploit-db.com/exploits/13691/
    char shellcode[] =
        "\x48\x31\xd2"                              // xor    %rdx, %rdx
        "\x48\x31\xc0"                              // xor    %rax, %rax
        "\x48\xbb\x2f\x62\x69\x6e\x2f\x73\x68\x00"  // mov    $0x68732f6e69622f2f, %rbx
        "\x53"                                      // push   %rbx
        "\x48\x89\xe7"                              // mov    %rsp, %rdi
        "\x50"                                      // push   %rax
        "\x57"                                      // push   %rdi
        "\x48\x89\xe6"                              // mov    %rsp, %rsi
        "\xb0\x3b"                                  // mov    $0x3b, %al
        "\x0f\x05";                                 // syscall
    // Будьте внимательны здесь с длиной шеллкода, учитывайте то, что находится после foo
    memcpy(foo_addr, shellcode, sizeof(shellcode)-1);
    puts("Calling foo");
    foo();
    return 0;
}
void foo(void) {
    int i=0;
    i++;
    printf("i: %d\n", i);
}
int change_page_permissions_of_address(void *addr) {
    // Перемещаем указатель на границу страницы
    int page_size = getpagesize();
    addr -= (unsigned long)addr % page_size;
    if(mprotect(addr, page_size, PROT_READ | PROT_WRITE | PROT_EXEC) == -1) {
        return -1;
    }
    return 0;
}Скомпилируем программу:
$ gcc -o mutate mutate.cНастал момент испытать удачу и выполнить этот код:
$ ./mutate
Calling foo
i: 1
Calling foo
$ echo "it works! we exec'd a shell!"
it works! we exec'd a shell!Вот и всё, мы получили самоизменяемую программу на C!
Telegram-канал со скидками, розыгрышами призов и новостями IT ?
 
Комментарии (51)
 - Shaman_RSHU28.05.2025 13:06- Статья понравилась, раньше было модно писать полиморфные вирусы.  - morty4528.05.2025 13:06- вообщето есть такой тип программирования когда пишется программа которая пишет и модернизирует программу пока не будет достигнута определённая первой программой цель/задача 
 
 - Godless28.05.2025 13:06- ASPack / ASProtect применял много прикольных техник - упаковка, полиморфизм (в памяти), интиотладочные циклы / проверки. С оптикой в квартире и винтами на террабайты как-то перестали заботиться о размере исполняемых файлов... а зря. - Спасибо за статью.  - perfect_genius28.05.2025 13:06- А как размер бинарника относится к способам защиты?  - 8street28.05.2025 13:06- Чтобы программу взломать, надо дизассемблировать. А дизассемблировав упакованный бинарник вы получите белиберду. Чтобы его распаковать, надо знать как, а это не всегда тривиально.  - perfect_genius28.05.2025 13:06- Т.е. до текущего времени упаковка бинарников была не просто ещё одной защитой, но и вынужденной мерой из-за ограниченности памяти? А сейчас защитники перестали упаковывать, облегчив взломщикам задачу? - Что-то сомнительно - обычно используют всё, что возможно, чтобы затруднить взлом. Тут явно другая причина.  - 8street28.05.2025 13:06- Упаковщики не помогали против ограниченности памяти, они точно так же самораспаковывали бинарник при выполнении программы. - Сейчас действительно не применяются, т.к. рано или поздно узнается алгоритм пакования и написать распаковщик это дело, чаще всего, одного дня. Поэтому их никто и не пишет - не выгодно. - Сейчас есть что-то наподобие упаковщика - это Денуво, но там скорее не упаковщик, а "запутывальщик". Ну еще, в корпоративном сегменте сейчас разные электронные ключи популярны, типа Гуарданта. - UPD. Я тут подумал, проги, защищенные электронными ключами тоже могут быть упакованными, и без ключа вы бинарник не восстановите. 
 
 
 
 
 - ermouth28.05.2025 13:06- Такое делают только тогда, когда хотят что-то исследовать - Я лет 30 назад на асме для z80 писал самомодифицирующий код вынимания значений из ram по смещению – потому что такой код быстрее работал, чем обращение через индексный регистр. И выигрыш по тактам был существенный, чуть не кратный, насколько я помню. - То-есть из вполне практических соображений. - Вполне могу представить себе такое и в современных реалиях при низкоуровневой разработке для слабых микроконтроллеров например.  - alliumnsk28.05.2025 13:06- В микроконтроллере код обычно во флэше хранится...  - ermouth28.05.2025 13:06- Ну, фон-неймановской архитектуры тоже бывают, я бы даже сказал их больше. Понятно что всякие 8051 или там Пики – гарвардская схема, но остальное то, хоть чуть пожирнее, уже как минимум гибридное. - Какой-нибудь RP2040 например позволяет копировать из ROM в SRAM и исполнять оттуда, и куда шустрее работает.  - NetBUG28.05.2025 13:06- Помню, как в 2007 (блин, больше 15 лет назад!) разбирался с процессом старта тогдашних популярных устройств на Windows Mobile и узнал про XIP... 
 
  - Vcoderlab28.05.2025 13:06- НИИЭТ-овский К1921ВГ015 позволяет загружать отладчиком программу в ОЗУ и запускать оттуда. Отличное решение чтобы не дёргать лишний раз flash при отладке. 
 
  - nckma28.05.2025 13:06- 30 лет назад на асме почти все так делали. - Помню писал игру теннис на Правец 8а - Там у процессора были только аккумулятор и регистры X и Y. Остальное все в памяти, все переменные. У меня координаты мяча были в двух байтах и экран текстовый. - Пока мяч летит вперед выполняем команду inc [ptr] а чтобы лететь назад меняем эту команду на dec [ptr]. Как-то так.  - ermouth28.05.2025 13:06- Ну, 6502 вообще в смысле регистров зверь ) Я сталкивался на советском Агате-8. На 6502 я единственный раз в жизни абьюзал SP как регистр условно-общего назначения, как shadow для регистра X.  - alliumnsk28.05.2025 13:06- эх, насколько лучше были бы программы для х86, если бы убрали бесполезные инструкции с SP в качестве регистра общего назначения и сделали бы еще один регистр 
  - vadimr28.05.2025 13:06- Любопытно, мне такое не приходило в голову. Хотя я задумывался, зачем нужны команды TXS и TSX. 6502 неисчерпаем.  - ermouth28.05.2025 13:06- Мне бы нынешнему тоже не пришло. Я тогда был школотой и мне ещё никто не сказал, что указатель стека не для этого.  - vadimr28.05.2025 13:06- А как Вы изучали ассемблер? Не по Мореру?  - ermouth28.05.2025 13:06- Что вы. Ассемблер 6502 – я даже не знал тогда, что процессор так называется – я изучал по подшитым ротокопиям, которые совместно образовывали «Инструкцию по эксплуатации ЭВМ Агат». - Там было что-то в районе 10 томов, каждая страница оформлена по ЕСКД, и в целом инструкции были чудовищно низкой пробы. Вот по ним и разбирался, любопытную школоту разве такой ерундой остановишь ) - Это в районе 1987 было. Может, 1988.  - vadimr28.05.2025 13:06- Круто. А я в 1988 или 1989 ездил Морера конспектировать в городскую детскую библиотеку.  - ermouth28.05.2025 13:06- Детской, поразительно… - У меня этот период случился года на 2–3 позже, я только конспектировал Нортона, Брябрина и пр, меня для этого записали в читальный зал местной научной библиотеки. Местная детская библиотека была, как бы помягче, от компьютеров бесконечно далека ) - А z80 я уже изучал по Ларченко/Родионову, абсолютно великая книжка которую можно было купить даже в нашей провинции https://zxpress.ru/book.php?id=116 
 
 
 
 
 
 
 
  - arteast28.05.2025 13:06- Во времена z80 это было вообще нормой. Например: - патчили jmp для изменения поведения функции вместо того, чтобы проверять флажок-переменную в этой функции 
- патчили адрес вызываемой функции в call вместо чтения адреса функции из данных (indirect call -> direct call) 
- патчили загрузку константы в регистр вместо того, чтобы загружать значение из переменной (т.е. по факту переменная была не отдельно, а как часть команды) 
- и тп и тд 
 - В условиях, когда самомодификации ничего не мешает (кеш, пайплайны и прочая), а скорость работы очень зависит от количества прочитанных с памяти байт, это было очень полезно. - В современных условиях такие хаки уже не актуальны. Код, как ниже заметили, в микрухах обычно во флеше хранится, даже если архитектура позволяет иначе (хотя и есть подход с копированием важных для скорости функций в zero-wait state RAM); SRAM маленький; выхлоп от таких "мелких" изменений особого смысла обычно не дает; а патчи делать довольно дорого в условиях пайплайновой суперскалярной архитектуры (а это даже мелочь типа Cortex-M), потому что надо очищать пайплайн после патча. - Сейчас кмк из подобных извращений SMC код бывает исчезающе редко, а вот JIT генерация - более-менее часто: VM, включая и простенькие интерпретаторы с "шитым" кодом, генерация каких-нибудь DSP ядер и подобные извраты. Именно из SMC я навскидку могу указать только на антиотладочные/антианализные хаки, и... ядро Linux (механизм альтернатив, ftracer/perf) 
 
 - kale28.05.2025 13:06- Берете любой форт (а лучше пишете сами), и получаете возможность поиграть с самомодификацией кода досыта.) 
 - ya_ne_znau28.05.2025 13:06- О дивный новый мир, в котором можно порадоваться статье, если первый дисклеймер говорит о том, что автор изучил материал, а не использовал "ии"!  - cruiseranonymous28.05.2025 13:06- Старый мир, увы. Исходная статья 2013 года - Но да, авторское обоснование "так это ж интересно изучить" радует 
 
 - sappience28.05.2025 13:06- Единственный разумный сценарий применения самоизменяемых программа в реальном мире — это механизм маскировки зловредного ПО от антивирусов. - Вообще-то любой JIT-компилятор это самомодифицирующаяся программа. Она на лету создает в своей памяти новый исполняемый код и тут же его исполняет. 
 - perfect_genius28.05.2025 13:06- А если у меня несколько одинаковых функций, отличающиеся входящими типами - почему бы не ужать их все до одного и просто не менять типы в памяти? Может остаться больше места в кэше, но само изменение кода инвалидирует же данные в нём и поэтому такая попытка оптимизации оборачивается доступом к памяти? 
 - ahdenchik28.05.2025 13:06- Единственный разумный сценарий применения самоизменяемых программа в реальном мире — это механизм маскировки зловредного ПО от антивирусов - Ещё защита программ (от пиратства, например) может на это опираться 
 - firehacker28.05.2025 13:06- Вот бы на Хабре вместо бесполезного переключателя «Светлая тема»/«Тёмная тема» был переключатель «Синтаксис Intel»/«Синтаксис AT&T». 
 - Panzerschrek28.05.2025 13:06- В коде оригинального Doom есть несколько мест с самомодификацией. Насколько я помню, там в коде отрисовки что-то модифицировали, вроде каких-то значений, записывающихся прямо в код, ибо так быстрее было, чем их из регистра читать. 
 Также стоит отметить, что этот код был написан на языке ассемблера. Модифицировать выхлоп компилятора Си, как делает автор данной статьи, очень плохая затея, ибо этот выхлоп может быть различным для различных версий компилятора, а уж если ещё и оптимизация вмешается, то самомодификация и вовсе будет невозможна. - alliumnsk28.05.2025 13:06- наверное не в Doom, а в Wolf 3D?  - Panzerschrek28.05.2025 13:06- В Doom: https://github.com/id-Software/DOOM/blob/a77dfb96cb91780ca334d0d4cfd86957558007e0/linuxdoom-1.10/README.asm#L184. 
 В Wolf3D возможно такое тоже есть, его я не проверял.
 
 
 - paruntik28.05.2025 13:06- Техник полиморфизма - несколько. Автор рассмотрел одну из них - простая замена. Есть еще разбавление кода нейтральными командами, которые каждый раз меняются. В этих игрищах главное не забыть, что есть кэш команд процессора, и если вы меняете код, следующий далее по ветке исполнения, то есть вероятность, что в ОЗУ он изменится, но это не повлияет никак на исполнение кода процессором, потому что он исполняет то. что засосал в свой кэш команд. Помню, на этой особенности строилась одна из защит редактора "Слово и дело". Под отладчиком - ветка исполнения была одна - завершение программы (сбрасывался кэш из-за прерываний отладки), а без отладчика ветка исполнения была другая - запускался редактор (так как в кэше сохранялся немодифицированный код).  - unreal_undead228.05.2025 13:06- есть кэш команд процессора - На x86 проблем нет, он явно поддерживает самомодифицирующийся код без дополнительных приседаний. Для других процессоров есть __builtin_clear_cache(). - Под отладчиком - ветка исполнения была одна - Скорее всего правилась следующая инструкция, которую процессор уже зачитал для декодирования - для этого есть отдельный буфер, это не кеш инструкций. 
 
 - romancelover28.05.2025 13:06- А как это согласуется с виртуальной памятью в современных ОС? там для загрузки бинарников используется mmap, секция с кодом mmap'ится как read-only, и если у системы мало памяти, она может выгрузить секцию из памяти и загрузить повторно из файла с бинарником при необходимости. А если секция с кодом стала изменяемой? файл с бинарником тогда остаётся как есть, а страница памяти кода с изменениями выгружается уже не в никуда, а в своп, как страница с данными? 
 А если файл и так находится в памяти (например, в tmpfs), тогда при обычном запуске он из этой же памяти и запускается, а при модификации кода создаётся копия? или как-то по-другому?
 - Hemml28.05.2025 13:06- В Лиспе самомодицикация кода не то, что допустимая практика, а вообще обычная и постоянно используемая. Более того, разработка и отладка программы на Лиспе как раз состоит в модификации кода в памяти, пока он не заработает, после чего образ сбрасывается на диск в виде исполнимого файла. Но и после загрузки этого файла программа вполне может самомодифицироваться, если логика ее работы этого требует. По этой причине для айфона нет нормального лиспа, например. Там самомодификация жестко запрещена.  - unreal_undead228.05.2025 13:06- Там всё таки не модификация бинарного кода функции, а генерация нового при изменении исходника, больше похоже на JIT.  - Hemml28.05.2025 13:06- В основном, да. Но можно еще модифицировать содержимое backquote-выражения, например. Как правило, это получается ненамеренно :) Наверняка есть возможность и напрямую в код залезть, но это уже сильно машинно-зависимо будет, конечно.  - vadimr28.05.2025 13:06- Модифицировать значения литералов, в том числе и квазицитат, в Лиспе и производных от него языках обычно запрещено (в стандарте CL сказано "you shouldn't"). Но Вы правы, что это самое неприятное место Лиспа, и проблемы тут в практической жизни могут возникать. - К примеру, компилятор Gambit и без Apple шизеет от такого. 
  - unreal_undead228.05.2025 13:06- В Фортране в своё время можно было значения целочисленных литеральных констант менять )  - vadimr28.05.2025 13:06- Формально можно и сейчас, только на процессоре с MMU программа грохнется по нарушению защиты памяти, так как константы находятся в ro секции. 
 
 
 
  - vadimr28.05.2025 13:06- С точки зрения айфона это же не самомодификация, а просто изменение данных интерпретатора (поскольку код модифицируется в исходном представлении, т.е. в S-выражениях, а не в машкодах). Есть для айфона и Common Lisp, и Gambit Scheme. Я ими периодически пользуюсь.  - Hemml28.05.2025 13:06- Ну вот SBCL на айфон нельзя портировать по этой причине, например. Так-то интерпретатору ничего не мешает, конечно. 
 
 
 
           
 

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