Линуксовый IoT под прицелом: пишем шелл-код под *nix x64
IoT — самый настоящий тренд последнего времени. Почти везде в нем используется ядро Linux. Однако статей по вирусописательству и шелл-кодингу под эту платформу сравнительно мало. Думаешь, писать шелл-код под Linux — только для избранных? Давай выясним, так ли это!
Что нужно для работы?
Для компиляции шелл-кода нам понадобится компилятор и линковщик. Мы будем использовать nasm и ld . Для проверки работы шелл-кода мы напишем небольшую программку на С. Для ее компиляции нам понадобится gcc . Для некоторых проверок будет нужен rasm2 (часть фреймворка radare2). Для написания вспомогательных функций мы будем использовать Python.
Что нового в x64?
x64 является расширением архитектуры IA-32. Основная отличительная ее особенность — поддержка 64-битных регистров общего назначения, 64-битных арифметических и логических операций над целыми числами и 64-битных виртуальных адресов.
Если говорить более конкретно, то все 32-битные регистры общего назначения сохраняются, добавляются их расширенные версии ( rax , rbx , rcx , rdx , rsi , rdi , rbp , rsp ) и несколько новых регистров общего назначения ( r8 , r9 , r10 , r11 , r12 , r13 , r14 , r15 ).
Появляется новое соглашение о вызовах (в отличие от архитектуры x86, оно только одно). Согласно ему, при вызове функции каждый регистр используется для определенных целей, а именно:
- первые четыре целочисленных аргумента функции передаются через регистры rcx , rdx , r8 и r9 и через регистры xmm0 — xmm3 для типов с плавающей точкой;
- остальные параметры передаются через стек;
- для параметров, передаваемых через регистры, все равно резервируется место в стеке;
- результат работы функции возвращается через регистр rax для целочисленных типов или через регистр xmm0 для типов с плавающей точкой;
- rbp содержит указатель на базу стека, то есть место (адрес), где начинается стек;
- rsp содержит указатель на вершину стека, то есть на место (адрес), куда будет помещено новое значение;
- rsi , rdi используются в syscall .
Немного о стеке: так как адреса теперь 64-битные, значения в стеке могут иметь размер 8 байт.
Syscall. Что? Как? Зачем?
Syscall — это способ, посредством которого user-mode взаимодействует с ядром в Linux. Он используется для различных задач: операции ввода-вывода, запись и чтение файлов, открытие и закрытие программ, работа с памятью и сетью и так далее. Для того чтобы выполнить syscall, необходимо:
- загрузить соответствующий номер функции в регистр rax ;
- загрузить входные параметры в остальные регистры;
- вызвать прерывание под номером 0x80 (начиная с версии ядра 2.6 это делается через вызов syscall ).
В отличие от Windows, где нужно еще найти адрес необходимой функции, здесь все довольно просто и лаконично.
Номера нужных syscall-функций можно найти, например, здесь.
execve()
Если мы посмотрим на готовые шелл-коды, то многие из них используют функцию execve() .
execve() имеет следующий прототип:
Она вызывает программу filename . Программа filename может быть либо исполняемым бинарником, либо скриптом, который начинается со строки #! interpreter [optional-arg] .
argv[] является указателем на массив, по сути, это тот самый argv[] , который мы видим, например, в C или Python.
envp[] — указатель на массив, описывающий окружение. В нашем случае не используется, будет иметь значение null .
Основные требования к шелл-коду
Существует такое понятие, как position-independent code. Это код, который будет выполняться независимо от того, по какому адресу он загружен. Чтобы наш шелл-код мог выполняться в любом месте программы, он должен быть позиционно-независимым.
Чаще всего шелл-код загружается функциями вроде strcpy() . Подобные функции используют байты 0x00 , 0x0A , 0x0D как разделители (зависит от платформы и функции). Поэтому лучше такие значения не использовать. В противном случае функция может скопировать шелл-код не полностью. Рассмотрим следующий пример:
Как видно, код push 0x00 скомпилируется в следующие байты 6a 00 . Если бы мы использовали такой код, наш шелл-код бы не сработал. Функция скопировала бы все, что находится до байта со значением 0x00 .
В шелл-коде нельзя использовать «захардкоженные» адреса, потому что мы заранее эти самые адреса не знаем. По этой причине все строки в шелл-коде получаются динамически и хранятся в стеке.
Вот вроде бы и все.
Just do it!
Если ты дочитал до этого места, то уже должна сложиться картина, как будет работать наш шелл-код.
Первым делом необходимо подготовить параметры для функции execve() и затем правильно расположить их на стеке. Функция будет выглядеть следующим образом:
Второй параметр представляет собой массив argv[] . Первый элемент этого массива содержит путь к исполняемому файлу.
Третий параметр представляет собой информацию об окружении, нам он не нужен, поэтому будет иметь значение null .
Сначала получим нулевой байт. Мы не можем использовать структуру вида mov eax, 0x00 , поскольку это приведет к появлению null-байтов в коде, так что мы будем использовать следующую инструкцию:
Оставим это значение в регистре rdx — оно еще понадобится в качестве символа конца строки и значения третьего параметра (которое будет null ).
Так как стек растет от старших адресов к младшим, а функция execve() будет читать входные параметры от младших к старшим (то есть стек работает с памятью в обратном порядке), то на стек мы будем класть перевернутые значения.
Для того чтобы перевернуть строку и перевести ее в hex, можно использовать следующую функцию на Python:
Вызовем эту функцию для /bin/sh :
Получили строку длиной 7 байт. Теперь рассмотрим, что произойдет, если мы попробуем положить ее в стек:
Мы получили нулевой байт (второй байт с конца), который сломает наш шелл-код. Чтобы этого не произошло, воспользуемся тем, что Linux игнорирует последовательные слеши (то есть /bin/sh и /bin//sh — это одно и то же).
Теперь у нас строка длиной 8 байт. Посмотрим, что будет, если положить ее в стек:
Никаких нулевых байтов!
Затем на сайте ищем информацию о функции execve() . Смотрим номер функции, который положим в rax , — 59. Смотрим, какие регистры используются:
- rdi — хранит адрес строки filename ;
- rsi — хранит адрес строки argv ;
- rdx — хранит адрес строки envp .
Собираем все воедино
Кладем в стек символ конца строки (помним, что все делается в обратном порядке):
Кладем в стек строку /bin//sh :
Получаем адрес строки /bin//sh в стеке и сразу помещаем его в rdi :
В rsi необходимо положить указатель на массив строк. В нашем случае этот массив будет содержать только путь до исполняемого файла, поэтому достаточно положить туда адрес, который ссылается на память, где лежит адрес строки (на языке С указатель на указатель). Адрес строки у нас уже есть, он находится в регистре rdi . Массив argv должен заканчиваться null-байтом, который у нас находится в регистре rdx :
Теперь rsi указывает на адрес в стеке, в котором лежит указатель на строку /bin//sh .
Кладем в rax номер функции execve() :
В итоге получили такой файл:
Компилируем и линкуем под x64. Для этого:
Теперь можем использовать objdump -d example для того, чтобы посмотреть получившийся файл:
Чтобы получить шелл-код вида \x11\x22. из бинарника, можем воспользоваться следующим кодом:
В результате получаем:
Тестируем шелл-код
Для теста используем следующую программу на С (вместо SHELLCODE нужно вставить получившийся шелл-код):
В результате получаем программу shellcode_test . Запускаем программу и попадаем в интерпретатор sh . Для выхода вводим exit .
Заключение
Вот мы и написали свой первый шелл-код под Linux x64. На первый взгляд — ничего сложного, труднее всего сократить размеры шелл-кода. И нельзя забывать, что это лишь «проба пера», наш шелл-код не справится с DEP и ASLR, но полученные навыки пригодятся для написания более сложных вещей.