Самоизменяющаяся программа на C под x86_64

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

Да, да, я знаю. И все-таки, зачем? Ну, например, это хороший учебный пример. Но главная причина — потому что могу.

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

Дисклеймер: в этой статье очень много ассемблерного кода под 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)

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

40054783 45 fc 01addl $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 страниц.

В нашем случае эти байты означают следующее:

8345fc01
Опкод инструкции 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).

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

  1. Посмотреть на дизассемблированный код и посчитать число байт между началом функции и интересующим байтом.
  2. Написать функцию, которая последовательно напечатает инструкции 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.

%raxSyscall%rdi%rsi%rdx
59sys_execveconst char *filenameconst 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\xd2xor %rdx, %rdxОбнулить регистр %rdx
\x48\x31\xc0xor %rax, %raxОбнулить регистр %rax. Нам он будет нужен позже.
\x48\xbb\x2f
\x62\x69\x6e
\x2f\x73\x68
\x00
mov $0x68732f \
6e69622f, %rbx
Записать в регистр %rbx значение \”hs/nib/\”. Процессоры Intel используют обратный порядок байт, поэтому строка должна быть задом наперед. Ее можно бытро получить с помощью Python: '/bin/sh'[::-1].encode('hex'). Хорошо, что строка "/bin/sh" занимает 64 бита и помещается в один регистр. Более длинная строка потребует использования трюков с конкатенацией.
\x53push %rbxПоложить строку /bin/sh на стек. Это установит указатель стека на нашу строку.
\x48\x89\xe7mov %rsp, %rdiСогласно документации, регистр %rdi должен содержать указатель на вызываемую программу. Указатель стека (регистр %rsp) указывает на строку, так что копируем его в %rdi.
\x50push %raxВторой аргумент execve() — массив argv. Массив должен завершаться нулевым байтом. Так как процессоры Intel используют обратный порядок байт, мы кладем на стек нулевой байт. Помните, что мы обнулили %rax ранее, так что мы просто пушим его на стек.
\x57push %rdiПо соглашению, первый элемент массива argv это имя программы. Помните, что массив argv на самом деле массив указателей на массив указателей на строки. В нашем случае имя программы — единственным элемент массива. Регистр %rdi содержит указатель на строку “/bin/sh”. Если мы положим его на стек, там окажется сформированный массив argv.
\x48\x89\xe6mov %rsp, %rsiПо документации к системным вызовам, регистр %rsi должен указывать на расположение массива argv в памяти. Поскольму мы только что положили его на стек, указатель стека находится на первом элементе argv. Все, что нам осталось сделать – скопировать указатель стека в %rsi.
\xb0\x3bmov $0x3b, %alПоследний шаг — номер системного вызова (59 = 0x3b) в %rax. %al указывает, что мы кладем значение 59 в первый байт %rax. Остальные биты %rax все еще обнулены.
\x0f\x05syscallТеперь, когда все готово, вызываем 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»