Зачем вообще может понадобиться писать программу, которая меняет свой код во время выполнения? Это ужасно!
Да, да, я знаю. И все-таки, зачем? Ну, например, это хороший учебный пример. Но главная причина — потому что могу.
Самоизменяющиеся программы сами по себе не очень полезны. Они сложны в отладке, платформо-зависимы, код ужасно нечитаем, если вы, конечно, не эксперт в ассемблере. Единственное хорошее применение им — скрытие вредоносного кода. Но мой интерес чисто академический, поэтому ничего такого тут не будет.
Дисклеймер: в этой статье очень много ассемблерного кода под x86_64, в котором я не эксперт. Многому я научился в процессе написания статьи, поэтому возможно (почти наверняка) в тексте есть ошибки. Если вы найдете ошибку, пришлите мне е-мейл, чтобы я мог ее исправить.
Первый шаг к написанию самоизменяющейся программы — получение возможности изменять код во время выполнения. Когда-то давно программисты поняли, что это плохая идея и с тех пор добавили защиту на изменение выполняемого кода. Для начала разберемся, где хранятся инструкции, когда программа выполняется. Перед выполнением загрузчик помещает всю программу целиком в память. Затем программа запускается в виртуальном адресном пространстве, управляемом ядром ОС. Адресное пространство разбито на различные области, как показано на рисунке.
В нашем случае, нас интересует только область инструкций (text segment). Именно туда загружаются команды процессора. Адресное пространство размечено на страницы, которыми и управляет ядро. Страницы соответствуют физической памяти компьютера. Ядро также управляет правами доступа к страницам. По умолчанию область инструкций можно читать и выполнять оттуда команды. Писать в нее нельзя. Для того, чтобы изменять инструкции в этой области памяти, мы должны изменить права доступа.
Изменить права доступа можно функцией mprotect()
(man 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, затем инкрементирует это значение и выводит в stdout. Посмотрим, сможем ли мы поменять число, которое прибавляется к i
.
Для того, чтобы это сделать, нам надо посмотреть не только инструкции, которые сгенерирует компилятор для 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()
, мы можем ее скомпилировать.
$ gcc -o foo foo.c
И вот тут начинается самое интересное. Нам надо дизассемблировать бинарник, созданный gcc и посмотреть, какие инструкции соответствуют foo()
. Мы можем это сделать с помощью утилиты objdump.
$ objdump -d foo > foo.dis
Если вы откроете файл foo.dis в текстовом редакторе, примерно на 128 строке (в зависимости от версии компилятора, 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 байта (размер int
на моей машине) для того, чтобы освободить место для переменной i
. Затем инициализируем эти 4 байта нулями и прибавляем 1 к этому значению. Все, что после адреса 40054b — это подготовка к вызову функции printf()
.
Итак, если мы хотим изменить число, прибавляемое к i
, надо поменять вот эту инструкцию:
400547: 83 45 fc 01 addl $0x1,-0x4(%rbp)
Прежде чем продолжать, давайте посмотрим, из чего состоит инструкция.
400547 |
83 45 fc 01 |
addl $0x1,-0x4(%rbp) |
---|---|---|
Адрес памяти, где находится инструкция. | Машинный код инструкции. Именно эти байты процессор будет читать и выполнять. | Человекочитаемый (для того, кто знает, как это читать), дизассемблированый машинный код из второй колонки. |
Рассмотрим саму инструкцию и ее операнды:
addl |
$0x1 |
-0x4(%rbp) |
---|---|---|
Сама инструкция. На x86_64 есть несколько инструкций для сложения. Эта прибавляет 8-битное значение к значению или адресу регистра. | Добавляемое число. Знак доллара означает, что это просто число, а 0x — что используется шестнадцатиричное число. В данном случае это 1, поскольку 0x1 = 1 в десятичной. | Адрес памяти, к которому прибавляется число. В данном случает мы прибавляем его к указателю на начало стека, смещенному на 4 байта. Именно там на стеке находится i . |
Теперь, когда мы разобрали человекочитаемую форму записи инструкции, давайте посмотрим на машинный код. Все инструкции для x86_64 имеют следующий формат:
Здесь возникают сложности. Дело в том, что инструкции для x86_64 могут иметь переменную длину, их разбор вручную сложен и занимает много времени. Задачу облегчают различные источники документации. На x86ref.net есть отличная документация. Для смелых есть также документация Intel в виде PDF на 3000 страниц.
В нашем случае эти байты означают следующее:
83 |
45 |
fc |
01 |
---|---|---|---|
Опкод инструкции addl . У всех инструкций есть опкод, понятный для процессора. |
Байт ModR/M. Согласно документации Intel’s, 0x45 = [RBP/EBP]+disp8 . 0x45 означает, что регистр %rbp это цель, а следующий за ним байт (в нашем случае 0xfc ) — расположение. |
Байт расположения. 0xfc = 0b11111100. Байт расположения учитывает знак, поэтому в нашем случае это расно 0b100, или 4. | Само значение, которое прибавляется к адресу. Мы получим адрес байта, который нам необходимо поменять. |
В документации есть полезная таблица, в которой объясняется значение каждого ModR/M
байта. Также эта таблица есть в руководстве, упомянутом выше, таблица 2-2 в разделе 2-5 тома 2A (455 страница PDF-файла).
Теперь мы знаем какой байт в какой инструкции небходимо поменять, осталось понять, как это сделать.
Итак, мы хотим поменять байт 01 в инструкции addl $0x1,-0x4(%rbp)
.
Для этого нам нужно знать адрес этого байта. Мы можем без затруднений найти адрес начала функции, так что нам нужно знать только смещение. Есть два способа это сделать:
- Посмотреть на дизассемблированный код и посчитать число байт между началом функции и интересующим байтом.
- Написать функцию, которая последовательно напечатает инструкции
foo()
со смещением относительно начала функции
Почему бы не сделать и то, и другое?
Посмотрим сначала на дизассемблированную функцию:
0000000000400538 :
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, а байт, который нас интересует — на 400550 (400547 + 3), следовательно, смещение будет равно 400550 — 400538 = 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()
. Теперь, когда сразу после foo()
расположена bar()
, мы можем определить длину 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 <-- Here's the byte we want!
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
Наш байт 0x1 расположен по адресу 0x40057e. Можем убедиться, что смещение действительно 18.
Примечание переводчика: На текущей версии gcc этот код требует для компиляции специальных опций. Поскольку в редакции использовалась система, отличная от той, что у автора статьи, мы приводим код, который работает у нас: http://ideone.com/Ufvzba
Теперь все готово для изменения кода во время выполнения. Имея указатель на foo()
мы можем получить указатель типа unsigned char
на байт, которым хотим поменять.
unsigned char *instruction = (unsigned char*)foo_addr + 18;
*instruction = 0x2A;
Если мы все сделали правильно, эта строка поменяет аргумент команды addl
на 0x2A или 42. Теперь, когда мы вызовем foo()
, она напечатает 42 вместо 1.
Теперь соберем все вместе:
#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;
// Change the permissions of the page that contains foo() to read, write, and execute
// This assumes that foo() is fully contained by a single page
if(change_page_permissions_of_address(foo_addr) == -1) {
fprintf(stderr, "Error while changing page permissions of foo(): %s\n", strerror(errno));
return 1;
}
// Call the unmodified foo()
puts("Calling foo...");
foo();
// Change the immediate value in the addl instruction in foo() to 42
unsigned char *instruction = (unsigned char*)foo_addr + 18;
*instruction = 0x2A;
// Call the modified 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) {
// Move the pointer to the page boundary
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.
Теперь у нас есть самоизменяющаяся программа! Однако, она довольно скучная. Все что мы сделали — поменяли 1 на 42. Было бы круто, если бы мы смогли заставить foo()
делать что-то совсем другое. Например вызывать шелл код с помощью exec()
.
Итак, как мы можем заставить foo()
вызывать шелл код? Самый простой способ — использовать системный вызов execve
. Но это уже сложнее, чем просто замена байта.
Если мы хотим подменить foo()
системным вызовом, нам потребуются код для этого вызова. К счастью для нас, специалисты по безопасности любят использовать машинный код для запуска шелла, так что мы може этим воспользоваться. Поиск по «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
Я взял этот код отсюда и немного изменил:
- Я добавил
xor %rax, %rax
для того, чтобы обнулить%rax
. Иначе он мог вызвать сегфолт. - Я заменил значение
$0x68732f6e69622f2f
на$0x68732f6e69622f00
. Таким образом я избавился от сдвига и сохранил размер кода в пределах 30 байт. Обычно шелл код вроде этого инжектируется через переполнение буфера или другими способами атаки. Строки в C заканчиваются нулевым байтом. Поэтому большинство функций для работы со строками из стандартной библиотеки возвращают результат когда встречают нулевой байт. Специалисты по безопасности избегают использования нуль-терминатора по этой причине, но в нашем случае в этом нет ничего страшного, поэтому мы заменяем0x2f
на0x00
и избавляемся от команды сдвига. См. оригинал кода по ссылке.
Прежде чем продолжать, давайте разберемся, как работает вышеприведенный код. Сначала рассмотрим системный вызов. Системный вызов — это вызов функции ядра ОС с запросом о каком-либо действии. Это может быть что-то, на что есть права доступа только у ядра, поэтому мы к нему и обращаемся. В нашем случае системный вызов execve
говорит ядру, что мы хотим запустить другой процесс и заменить адресное пространства нашего процесса адресным пространством нового. Это значит, что при успешном вызове execve
наш процесс завершится.
Для того, чтобы вызвать системную функцию на x86_64, нам необходимо его подготовить, поместив в правильные регистры определенные значения, а затем, собственно, вызвать системную функцию. Значения и регистры зависят от конкретной системы. Я пишу под Linux, поэтому привожу документацию для execve
в Linux.
%rax |
Syscall | %rdi |
%rsi |
%rdx |
---|---|---|---|---|
59 |
sys_execve |
const char *filename |
const char *const argv[] |
const char *const envp[] |
Важно помнить, что в регистрах должны быть указатели на реальные значения в памяти. Это значит, что мы должны положить корректные значения в стек, а затем поместить их адреса в стеке в регистры. Самое время сказать, что скучаешь по простоте указателей в C.
Полный список системных вызовов можно найти здесь.
Если вы знакомы с прототипом execve()
в C (я привел его ниже для справки), вы заметите, насколько системный вызов в C похож на вызов функции.
int execve(const char *filename, char *const argv[], char *const envp[]);
Стоит также заметить, что вызов системной процедуры отличается в x86 и x86_64. Отдельной инструкции для системного вызова на 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 . Нам он будет нужен позже. |
\x48\xbb\x2f |
mov $0x68732f \ |
Записать в регистр %rbx значение \»hs/nib/\». Процессоры Intel используют обратный порядок байт, поэтому строка должна быть задом наперед. Ее можно бытро получить с помощью Python: '/bin/sh'[::-1].encode('hex') . Хорошо, что строка "/bin/sh" занимает 64 бита и помещается в один регистр. Более длинная строка потребует использования трюков с конкатенацией. |
\x53 |
push %rbx |
Положить строку /bin/sh на стек. Это установит указатель стека на нашу строку. |
\x48\x89\xe7 |
mov %rsp, %rdi |
Согласно документации, регистр %rdi должен содержать указатель на вызываемую программу. Указатель стека (регистр %rsp ) указывает на строку, так что копируем его в %rdi . |
\x50 |
push %rax |
Второй аргумент execve() — массив argv . Массив должен завершаться нулевым байтом. Так как процессоры Intel используют обратный порядок байт, мы кладем на стек нулевой байт. Помните, что мы обнулили %rax ранее, так что мы просто пушим его на стек. |
\x57 |
push %rdi |
По соглашению, первый элемент массива argv это имя программы. Помните, что массив argv на самом деле массив указателей на массив указателей на строки. В нашем случае имя программы — единственным элемент массива. Регистр %rdi содержит указатель на строку «/bin/sh». Если мы положим его на стек, там окажется сформированный массив argv . |
\x48\x89\xe6 |
mov %rsp, %rsi |
По документации к системным вызовам, регистр %rsi должен указывать на расположение массива argv в памяти. Поскольму мы только что положили его на стек, указатель стека находится на первом элементе argv . Все, что нам осталось сделать – скопировать указатель стека в %rsi . |
\xb0\x3b |
mov $0x3b, %al |
Последний шаг — номер системного вызова (59 = 0x3b ) в %rax . %al указывает, что мы кладем значение 59 в первый байт %rax . Остальные биты %rax все еще обнулены. |
\x0f\x05 |
syscall |
Теперь, когда все готово, вызываем syscall и ядро ОС ее выполнит. Удачи! |
Ух! Тут много всего. Но зато теперь мы можем заменить 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
// Careful with the length of the shellcode here depending on what is after foo
memcpy(foo_addr, shellcode, sizeof(shellcode)-1);
Нужно быть осторожным, чтобы не зайти за пределы foo()
. В нашем случае foo()
занимает 41 байт, а шелл код — 29 байт, так что все в порядке. Заметьте, что наш шелл код это строка, и в ее конце стоит нулевой байт. Так как мы хотим скопировать только код, то мы вычитаем единицу из sizeof(shellcode)
когда передаем аргумент в 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;
// Change the permissions of the page that contains foo() to read, write, and execute
// This assumes that foo() is fully contained by a single page
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
// Careful with the length of the shellcode here depending on what is after 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) {
// Move the pointer to the page boundary
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.
Перевод статьи «Writing a Self-Mutating x86_64 C Program»