«Зачем вообще писать программу, меняющую код в процессе выполнения? Это же ужасная идея!»

Да, всё так и есть. Но это и хороший опыт. Такое делают только тогда, когда хотят что-то исследовать, или из любопытства.

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

Предупреждение: в этом посте активно используется язык ассемблера 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, потому что по основанию 10 0x1 = 1. -0x4(%rbp) — это адрес памяти, к которому нужно прибавить значение. Здесь он означает, что нужно прибавить его к текущему адресу указателя базового стека, смещённому на 4 байта. Именно в этом месте стека находится наша переменная i.
Теперь, когда мы разобрались в человекочитаемом виде команды, давайте разберём машинную команду. Все команды x86_64 имеют следующий формат:


На этом моменте 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.
Как я определил, что означает байт ModR/M? В документации есть удобная таблица, объясняющая, что означает каждый байт ModR/M. Эта таблица также есть в руководстве Intel (Table 2-2 в разделе 2-5 тома 2A или на странице 445 файла PDF).

Итак, теперь мы можем поменять команду и знаем, что нужно менять; нам лишь нужно знать, как её менять.

Напомню, что мы хотим изменить байт 01 в команде addl $0x1,-0x4(%rbp).

Для этого нам нужно получить адрес этого байта. Получение адреса foo() в среде выполнения — это тривиальная задача, поскольку нам нужно лишь найти смещение этого байта от начала foo(). Это можно сделать двумя способами:

  1. Использовать дизассемблированный ранее objdump код, чтобы подсчитать количество байтов между началом функции и нужным нам байтом.
  2. Написать функцию, выводящую команды 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[]
Важно отметить, что значения этих регистров должны быть указателями на адрес памяти соответствующих значений. Это значит, что мы должны записать все значения в стек, а затем скопировать нужные адреса стека в показанные выше регистры. (Наверно, вы никогда не подумаете: «Ох, как же я скучаю по простоте работы с указателями на C».)

Полный список системных вызовов можно найти в 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
\x6e\x2f\x73\x68\x00
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 ?

Комментарии (45)


  1. vadimr
    28.05.2025 13:06

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


  1. Shaman_RSHU
    28.05.2025 13:06

    Статья понравилась, раньше было модно писать полиморфные вирусы.


    1. morty45
      28.05.2025 13:06

      вообщето есть такой тип программирования когда пишется программа которая пишет и модернизирует программу пока не будет достигнута определённая первой программой цель/задача


  1. Godless
    28.05.2025 13:06

    ASPack / ASProtect применял много прикольных техник - упаковка, полиморфизм (в памяти), интиотладочные циклы / проверки. С оптикой в квартире и винтами на террабайты как-то перестали заботиться о размере исполняемых файлов... а зря.

    Спасибо за статью.


    1. perfect_genius
      28.05.2025 13:06

      А как размер бинарника относится к способам защиты?


      1. 8street
        28.05.2025 13:06

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


        1. perfect_genius
          28.05.2025 13:06

          Т.е. до текущего времени упаковка бинарников была не просто ещё одной защитой, но и вынужденной мерой из-за ограниченности памяти? А сейчас защитники перестали упаковывать, облегчив взломщикам задачу?

          Что-то сомнительно - обычно используют всё, что возможно, чтобы затруднить взлом. Тут явно другая причина.


          1. 8street
            28.05.2025 13:06

            Упаковщики не помогали против ограниченности памяти, они точно так же самораспаковывали бинарник при выполнении программы.

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

            Сейчас есть что-то наподобие упаковщика - это Денуво, но там скорее не упаковщик, а "запутывальщик". Ну еще, в корпоративном сегменте сейчас разные электронные ключи популярны, типа Гуарданта.

            UPD. Я тут подумал, проги, защищенные электронными ключами тоже могут быть упакованными, и без ключа вы бинарник не восстановите.


  1. ermouth
    28.05.2025 13:06

    Такое делают только тогда, когда хотят что-то исследовать

    Я лет 30 назад на асме для z80 писал самомодифицирующий код вынимания значений из ram по смещению – потому что такой код быстрее работал, чем обращение через индексный регистр. И выигрыш по тактам был существенный, чуть не кратный, насколько я помню.

    То-есть из вполне практических соображений.

    Вполне могу представить себе такое и в современных реалиях при низкоуровневой разработке для слабых микроконтроллеров например.


    1. alliumnsk
      28.05.2025 13:06

      В микроконтроллере код обычно во флэше хранится...


      1. ermouth
        28.05.2025 13:06

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

        Какой-нибудь RP2040 например позволяет копировать из ROM в SRAM и исполнять оттуда, и куда шустрее работает.


        1. NetBUG
          28.05.2025 13:06

          Помню, как в 2007 (блин, больше 15 лет назад!) разбирался с процессом старта тогдашних популярных устройств на Windows Mobile и узнал про XIP...


      1. Vcoderlab
        28.05.2025 13:06

        НИИЭТ-овский К1921ВГ015 позволяет загружать отладчиком программу в ОЗУ и запускать оттуда. Отличное решение чтобы не дёргать лишний раз flash при отладке.


    1. nckma
      28.05.2025 13:06

      30 лет назад на асме почти все так делали.

      Помню писал игру теннис на Правец 8а

      Там у процессора были только аккумулятор и регистры X и Y. Остальное все в памяти, все переменные. У меня координаты мяча были в двух байтах и экран текстовый.

      Пока мяч летит вперед выполняем команду inc [ptr] а чтобы лететь назад меняем эту команду на dec [ptr]. Как-то так.


      1. ermouth
        28.05.2025 13:06

        Ну, 6502 вообще в смысле регистров зверь ) Я сталкивался на советском Агате-8. На 6502 я единственный раз в жизни абьюзал SP как регистр условно-общего назначения, как shadow для регистра X.


        1. alliumnsk
          28.05.2025 13:06

          эх, насколько лучше были бы программы для х86, если бы убрали бесполезные инструкции с SP в качестве регистра общего назначения и сделали бы еще один регистр


        1. unreal_undead2
          28.05.2025 13:06

          Там же по сути zero page вместо регистрового файла )


        1. vadimr
          28.05.2025 13:06

          Любопытно, мне такое не приходило в голову. Хотя я задумывался, зачем нужны команды TXS и TSX. 6502 неисчерпаем.


          1. ermouth
            28.05.2025 13:06

            Мне бы нынешнему тоже не пришло. Я тогда был школотой и мне ещё никто не сказал, что указатель стека не для этого.


            1. vadimr
              28.05.2025 13:06

              А как Вы изучали ассемблер? Не по Мореру?


              1. ermouth
                28.05.2025 13:06

                Что вы. Ассемблер 6502 – я даже не знал тогда, что процессор так называется – я изучал по подшитым ротокопиям, которые совместно образовывали «Инструкцию по эксплуатации ЭВМ Агат».

                Там было что-то в районе 10 томов, каждая страница оформлена по ЕСКД, и в целом инструкции были чудовищно низкой пробы. Вот по ним и разбирался, любопытную школоту разве такой ерундой остановишь )

                Это в районе 1987 было. Может, 1988.


                1. vadimr
                  28.05.2025 13:06

                  Круто. А я в 1988 или 1989 ездил Морера конспектировать в городскую детскую библиотеку.


                  1. ermouth
                    28.05.2025 13:06

                    Детской, поразительно…

                    У меня этот период случился года на 2–3 позже, я только конспектировал Нортона, Брябрина и пр, меня для этого записали в читальный зал местной научной библиотеки. Местная детская библиотека была, как бы помягче, от компьютеров бесконечно далека )

                    А z80 я уже изучал по Ларченко/Родионову, абсолютно великая книжка которую можно было купить даже в нашей провинции https://zxpress.ru/book.php?id=116


    1. arteast
      28.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)


      1. ermouth
        28.05.2025 13:06

        Спасибо, отличный коммент!


  1. kale
    28.05.2025 13:06

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


  1. ya_ne_znau
    28.05.2025 13:06

    О дивный новый мир, в котором можно порадоваться статье, если первый дисклеймер говорит о том, что автор изучил материал, а не использовал "ии"!


    1. cruiseranonymous
      28.05.2025 13:06

      Старый мир, увы. Исходная статья 2013 года

      Но да, авторское обоснование "так это ж интересно изучить" радует


  1. sappience
    28.05.2025 13:06

    Единственный разумный сценарий применения самоизменяемых программа в реальном мире — это механизм маскировки зловредного ПО от антивирусов.

    Вообще-то любой JIT-компилятор это самомодифицирующаяся программа. Она на лету создает в своей памяти новый исполняемый код и тут же его исполняет.


    1. perfect_genius
      28.05.2025 13:06

      Но не модифицирует сам себя.


  1. perfect_genius
    28.05.2025 13:06

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


  1. ahdenchik
    28.05.2025 13:06

    Единственный разумный сценарий применения самоизменяемых программа в реальном мире — это механизм маскировки зловредного ПО от антивирусов

    Ещё защита программ (от пиратства, например) может на это опираться


  1. firehacker
    28.05.2025 13:06

    Вот бы на Хабре вместо бесполезного переключателя «Светлая тема»/«Тёмная тема» был переключатель «Синтаксис Intel»/«Синтаксис AT&T».


    1. checkpoint
      28.05.2025 13:06

      Самомодифицирующийся код в JS на странице Хабра. ;)


  1. Panzerschrek
    28.05.2025 13:06

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

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


    1. alliumnsk
      28.05.2025 13:06

      наверное не в Doom, а в Wolf 3D?


      1. Panzerschrek
        28.05.2025 13:06

        В Doom: https://github.com/id-Software/DOOM/blob/a77dfb96cb91780ca334d0d4cfd86957558007e0/linuxdoom-1.10/README.asm#L184.
        В Wolf3D возможно такое тоже есть, его я не проверял.


  1. paruntik
    28.05.2025 13:06

    Техник полиморфизма - несколько. Автор рассмотрел одну из них - простая замена. Есть еще разбавление кода нейтральными командами, которые каждый раз меняются. В этих игрищах главное не забыть, что есть кэш команд процессора, и если вы меняете код, следующий далее по ветке исполнения, то есть вероятность, что в ОЗУ он изменится, но это не повлияет никак на исполнение кода процессором, потому что он исполняет то. что засосал в свой кэш команд. Помню, на этой особенности строилась одна из защит редактора "Слово и дело". Под отладчиком - ветка исполнения была одна - завершение программы (сбрасывался кэш из-за прерываний отладки), а без отладчика ветка исполнения была другая - запускался редактор (так как в кэше сохранялся немодифицированный код).


    1. unreal_undead2
      28.05.2025 13:06

      есть кэш команд процессора

      На x86 проблем нет, он явно поддерживает самомодифицирующийся код без дополнительных приседаний. Для других процессоров есть __builtin_clear_cache().

      Под отладчиком - ветка исполнения была одна

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


  1. romancelover
    28.05.2025 13:06

    А как это согласуется с виртуальной памятью в современных ОС? там для загрузки бинарников используется mmap, секция с кодом mmap'ится как read-only, и если у системы мало памяти, она может выгрузить секцию из памяти и загрузить повторно из файла с бинарником при необходимости. А если секция с кодом стала изменяемой? файл с бинарником тогда остаётся как есть, а страница памяти кода с изменениями выгружается уже не в никуда, а в своп, как страница с данными?
    А если файл и так находится в памяти (например, в tmpfs), тогда при обычном запуске он из этой же памяти и запускается, а при модификации кода создаётся копия? или как-то по-другому?


    1. firehacker
      28.05.2025 13:06

      Read-only с возможностью изменить на copy-on-write.


  1. Hemml
    28.05.2025 13:06

    В Лиспе самомодицикация кода не то, что допустимая практика, а вообще обычная и постоянно используемая. Более того, разработка и отладка программы на Лиспе как раз состоит в модификации кода в памяти, пока он не заработает, после чего образ сбрасывается на диск в виде исполнимого файла. Но и после загрузки этого файла программа вполне может самомодифицироваться, если логика ее работы этого требует. По этой причине для айфона нет нормального лиспа, например. Там самомодификация жестко запрещена.


    1. unreal_undead2
      28.05.2025 13:06

      Там всё таки не модификация бинарного кода функции, а генерация нового при изменении исходника, больше похоже на JIT.


    1. vadimr
      28.05.2025 13:06

      С точки зрения айфона это же не самомодификация, а просто изменение данных интерпретатора (поскольку код модифицируется в исходном представлении, т.е. в S-выражениях, а не в машкодах). Есть для айфона и Common Lisp, и Gambit Scheme. Я ими периодически пользуюсь.


  1. pShee
    28.05.2025 13:06

    В расчете адреса символа 1 в команде addl ошибка - не 40055a, а 40054a