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

chameleon

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

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

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

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

Первый шаг к написанию самоизменяющейся программы — получение возможности изменять код во время выполнения. Когда-то давно программисты поняли, что это плохая идея и с тех пор добавили защиту на изменение выполняемого кода. Для начала разберемся, где хранятся инструкции, когда программа выполняется. Перед выполнением загрузчик помещает всю программу целиком в память. Затем программа запускается в виртуальном адресном пространстве, управляемом ядром ОС. Адресное пространство разбито на различные области, как показано на рисунке.

Области памяти

В нашем случае, нас интересует только область инструкций (text segment). Именно туда загружаются команды процессора. Адресное пространство размечено на страницы, которыми и управляет ядро. Страницы соответствуют физической памяти компьютера. Ядро также управляет правами доступа к страницам. По умолчанию область инструкций можно читать и выполнять оттуда команды. Писать в нее нельзя. Для того, чтобы изменять инструкции в этой области памяти, мы должны изменить права доступа.

Изменить права доступа можно функцией mprotect() (man mprotect). Единственная сложность в том, что указатель, передаваемый в функцию, должен быть выровнен по началу страницы памяти. Вот функция, которая по данному ей указателю находит адрес начала страницы и устанавливает права на чтение, запись и выполнение на ней.

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

Итак, мы можем писать в область инструкций. Следующий вопрос — что мы будет туда записывать?

Давайте начнем с простого. Допусти, у меня есть такая функция:

foo() создает локальную переменную i и присваивает ей значение 0, затем инкрементирует это значение и выводит в stdout. Посмотрим, сможем ли мы поменять число, которое прибавляется к i.

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

Теперь, когда у нас есть программа, использующая foo(), мы можем ее скомпилировать.

И вот тут начинается самое интересное. Нам надо дизассемблировать бинарник, созданный gcc и посмотреть, какие инструкции соответствуют foo(). Мы можем это сделать с помощью утилиты objdump.

Если вы откроете файл foo.dis в текстовом редакторе, примерно на 128 строке (в зависимости от версии компилятора, foo() может быть собрана с помощью разных инструкций) вы увидите дизассемблированную функцию foo(). Выглядит она примерно так:

Если вы раньше не сталкивались с ассемблерным кодом под x86_64, вы можете потеряться. Вот что здесь происходит: мы смещаем стек на 4 байта (размер int на моей машине) для того, чтобы освободить место для переменной i. Затем инициализируем эти 4 байта нулями и прибавляем 1 к этому значению. Все, что после адреса 40054b — это подготовка к вызову функции printf().

Итак, если мы хотим изменить число, прибавляемое к i, надо поменять вот эту инструкцию:

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

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() со смещением относительно начала функции

Почему бы не сделать и то, и другое?

Посмотрим сначала на дизассемблированную функцию:

Функция начинается с адреса 400538, а байт, который нас интересует — на 400550 (400547 + 3), следовательно, смещение будет равно 400550 — 400538 = 18.

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

Заметьте, что для того, чтобы определить длину функции foo(), я добавил пустую функцию bar() после foo(). Теперь, когда сразу после foo() расположена bar(), мы можем определить длину foo() вычитанием адреса bar() из адреса foo().

Вот что выводит эта программа:

Наш байт 0x1 расположен по адресу 0x40057e. Можем убедиться, что смещение действительно 18.

Примечание переводчика: На текущей версии gcc этот код требует для компиляции специальных опций. Поскольку в редакции использовалась система, отличная от той, что у автора статьи, мы приводим код, который работает у нас: http://ideone.com/Ufvzba

Теперь все готово для изменения кода во время выполнения. Имея указатель на foo() мы можем получить указатель типа unsigned char на байт, которым хотим поменять.

Если мы все сделали правильно, эта строка поменяет аргумент команды addl на 0x2A или 42. Теперь, когда мы вызовем foo(), она напечатает 42 вместо 1.

Теперь соберем все вместе:

Скомпилируем это:

И запустим:

Ура! Первый вызов foo() выводит 1, как и указано в коде. Затем, после того, как мы его изменили, она выводит 42.

Теперь у нас есть самоизменяющаяся программа! Однако, она довольно скучная. Все что мы сделали — поменяли 1 на 42. Было бы круто, если бы мы смогли заставить foo() делать что-то совсем другое. Например вызывать шелл код с помощью exec().

Итак, как мы можем заставить foo() вызывать шелл код? Самый простой способ — использовать системный вызов execve. Но это уже сложнее, чем просто замена байта.

Если мы хотим подменить foo() системным вызовом, нам потребуются код для этого вызова. К счастью для нас, специалисты по безопасности любят использовать машинный код для запуска шелла, так что мы може этим воспользоваться. Поиск по «x86_64 shellcode» дал вот такой результат:

Я взял этот код отсюда и немного изменил:

  • Я добавил 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 похож на вызов функции.

Стоит также заметить, что вызов системной процедуры отличается в 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() так:

Нужно быть осторожным, чтобы не зайти за пределы foo(). В нашем случае foo() занимает 41 байт, а шелл код — 29 байт, так что все в порядке. Заметьте, что наш шелл код это строка, и в ее конце стоит нулевой байт. Так как мы хотим скопировать только код, то мы вычитаем единицу из sizeof(shellcode) когда передаем аргумент в memcpy()

Отлично! Теперь соберем все вместе:

И скомпилируем:

Пора скрестить пальцы и запустить.

Вот! Теперь у нас есть самоизменяющаяся программа на C.

Перевод статьи «Writing a Self-Mutating x86_64 C Program»