Внедрение кода в Linux-процесс без использования ptrace(2)

Внедрение кода в Linux-процесс без использования ptrace(2)

В рамках стандартных настроек прав доступа в большинстве Linux-дистрибутивов возможно внедрение кода в процесс без использования ptrace.

Автор: Rory McNamara

В рамках стандартных настроек прав доступа в большинстве Linux-дистрибутивов возможно внедрение кода в процесс без использования ptrace. Поскольку в этом методе системные вызовы не требуются, внедрение кода можно сделать при помощи простого и популярного скриптового языка наподобие Bash, что позволяет выполнять произвольный собственный код, когда доступен только Bash и основные утилиты. При помощи этой техники будет показано, как можно обойти ограничения флага noexec, устанавливаемого для монтируемых директорий, используя полезную нагрузку, которая будет выполнять бинарный файл из памяти.

Файловая система /proc позволяет выполнять интроспекцию запущенных процессов в Linux. Каждый процесс имеет свою собственную директорию в файловой системе, которая содержит детальную информацию о процессе в целом и внутреннем устройстве. Особое внимание следует обрати на два псевдо-файла в этой директории: maps и mem. Файл maps содержит карту всех областей памяти, выделенных для бинарного файла, а также перечень всех динамических библиотек. Эта информация относительно конфиденциальна, поскольку смещения для местонахождения каждой библиотеки рандомизируются при помощи технологии ASLR. В файле mem хранится информация об отдельных участках всей памяти, используемых процессом. В сочетании со смещениями, полученными из файла maps, информация из файла mem может быть использована для чтения и записи напрямую в память процесса. Если смещения ошибочные или файл считывается последовательно с самого начала, будет возвращена ошибка чтения/записи, поскольку эта операция схожа с операцией чтения невыделенной памяти, которая не доступна.

Права на чтение/запись файлов в этих директориях определяются в файле ptrace_scope, который находится в директории /proc/sys/kernel/yama. Предполагается, что другие средства ограничения доступа, например, SELinux или AppArmor, не используются. В документации для Linux-ядра описываются различные значения, которые можно использовать для настройки прав доступа. Пониженный уровень безопасности (значения 0 и 1) позволяет любому процессу под тем же uid, или только родительскому процессу, соответственно, записывать в файлы процессов /proc/${PID}/mem. Оба этих значения (0 или 1) позволяют выполнять инъекцию кода. Более строгий уровень безопасности (значения 2 и 3) позволяет, соответственно, записывать либо только админам, либо вообще блокирует доступ. В большинстве операционных систем в качестве значения по умолчанию используется «1», что позволяет запись только родительскому процессу в файл /proc/${PID}/mem.

В методе внедрения кода, описываемого в этой статье, используются вышеуказанные файлы и тот факт, что стек процесса находится в стандартной области памяти, что можно обнаружить внутри файла maps для конкретного процесса:

$ grep stack /proc/self/maps
7ffd3574b000-7ffd3576c000 rw-p 00000000 00:00 0                          [stack]

Среди прочего в стеке хранится адрес возврата (в архитектурах наподобие ARM, где не используется регистр связи, для хранения адреса возврата), и функция после завершения знает, куда нужно переходить для продолжения выполнения. Зачастую, в атаках, связанных с переполнением буфера, стек перезаписывается, и далее используется техника возвратно-ориентированного программирования (ROP) для перехвата управления целевым процессом. При использовании этой техники происходит замена оригинального адреса возврата на тот, который контролируется злоумышленником, что позволяет вызывать нужные функции или системные вызовы посредством управления потоком выполнения каждый раз, когда запускается инструкция ret.

При инъекции кода не используется переполнение буфера, но мы будем использовать ROP-цепь. Учитывая имеющийся у нас уровень доступа, мы можем напрямую перезаписать стек на базе сведений из файла /proc/${PID}/mem.
Таким образом, в методе используется файл /proc/self/maps для нахождения случайных ASLR-смещений, на базе которых мы можем найти функции внутри целевого процесса. После нахождения адресов функций мы можем заменить оригинальные адреса возврата в стеке и получить контроль над процессом. Чтобы убедить в том, что при перезаписи стека процесс находится в нужном состоянии, используется команда sleep в качестве дочернего процесса, который мы перезаписываем. Команда sleep использует системный вызов nanosleep. Сей факт означает, что команда sleep будет находиться внутри той же функции практически все время жизни (за исключением периодов подготовки к работе и завершении выполнения). Подобный расклад событий предоставляет богатые возможности для перезаписи стека перед возвратом системного вызова. К этому моменту мы начнем контролировать процесс при помощи цепи из ROP-гаджетов. Чтобы указатель стека находился в нужном месте на момент выполнения системного вызова, перед полезной нагрузкой мы добавляем инструкции NOP. В итоге после возврата функции указатель стека просто увеличится, и наша полезная нагрузка выполнится.

Практическую реализацию описанной концепции можно найти здесь https://github.com/GDSSecurity/Cexigua. Внешние зависимости, используемые в скрипте, были ограничены, поскольку в некоторых средах нужные бинарные файлы могут быть недоступны. Текущий список зависимостей выглядит так:

  • GNU grep (должен поддерживать -Fao --byte-offset)
  • dd (требуется для чтения/записи по абсолютному смещению внутри файла)
  • Bash (для вычисления и других продвинутых возможностей, используемых скриптом)

Общий алгоритм работы скрипта выглядит так:

Запускается копия команды sleep в фоновом режиме и запоминается PID процесса. Как было сказано выше, команда sleep является идеальным кандидатом для внедрения, поскольку использует только одну функцию, и мы не столкнемся с непредсказуемым состоянием процесса при перезаписи стека. Мы используем этот процесс для выяснения, какие библиотеки загружены при инициации процесса.

В файле /proc/${PID}/maps мы пытаемся найти все нужные гаджеты. Если в автоматически загруженных библиотеках нужный гаджет не находится, начинаем искать гаджеты в системных библиотеках из папки /usr/lib. При нахождении гаджета в любой другой библиотеке мы можем загрузить эту библиотеку внутри подчиненного процесса при помощи переменной окружения LD_PRELOAD. Этот трюк позволяет восполнить наличие недостающих гаджетов в нашей полезной нагрузке. Мы также проверяем, что гаджеты, которые мы ищем (при помощи утилиты «grep»), находятся в секции .text внутри библиотеки. В случае отсутствия гаджета в секции .text существует риск, что гаджет не загрузится и возникнет аварийное завершение при попытке обращения к гаджету. Желательно, чтобы в стадии «предварительной загрузки» список библиотек, содержащих гаджеты, которые не входят в список стандартных загружаемых библиотек, был пустым.

Как только мы удостоверились, что все гаджеты доступны, запускаем процесс sleep и, если необходимо, подгружаем дополнительные библиотеки при помощи переменной LD_PRELOAD. Далее повторно ищем гаджеты в библиотеках и меняем местоположение на основе базового ASLR-адреса. Теперь мы знаем местонахождение гаджетов в памяти, а не в бинарном файле на диске. Как было сказано выше, перед использованием гаджет проверяется на предмет доступности в области памяти с правами доступа на выполнение.

Перечень нужных нам гаджетов относительно небольшой. Нам нужен гаджет NOP, который будет использоваться перед полезной нагрузкой. Гаджеты POP для заполнение всех регистров, используемых во время вызова функции, а также гаджет для системного вызова и гаджет для вызова стандартной функции. Найденная комбинация инструкций позволяет нам выполнять любую функцию или системный вызов, но не позволяет реализовать любую логику. Как только нужные гаджеты найдены, мы можем конвертировать псевдо-инструкции из файла описания полезной нагрузки в ROP-цепь. Например, для 64-битной системы, строка «syscall 60 0» будет сконвертирована в ROP-гаджеты для загрузки «60» в регистр RAX, «0» в регистр RDI и гаджет системного вызова. Мы должны получить 40 байт информации: 3 адреса и 2 константы (каждый элемент занимает 8 байт). В итоге будет выполнен вызов exit(0).

Мы также можем вызывать функции из таблицы связывания процедур (Procedure Linkage Table; PLT), которая включает функции, импортируемые из внешних библиотек, в том числе glibc. Чтобы найти смещения для этих функций, вызываемых по указателю, а не номеру системного вызова, вначале нам нужно распарсить заголовки ELF-секций в целевой библиотеке и найти смещение нужной функции. Как только смещение найдено, мы можем выполнить перемещение, как и в случае с гаджетами, и добавить эти функции в полезную нагрузку.

Строковые аргументы также обрабатываются. Поскольку мы знаем местонахождение стека в памяти, то можем добавить строки в нашу полезную нагрузку вместе с указателями, если необходимо. Например, в системном вызове fexecve требуется char** для массива аргументов. Мы можем сгенерировать массив указателей перед инъекцией внутри нашей полезной нагрузки и во время выполнения указатель в стеке на массив указателей может использоваться, как и char** размещенный в обычном стеке.

Как только наша полезная нагрузка стала полностью сериализованной, мы может перезаписать стек внутри процесса при помощи утилиты dd и смещение стека, полученное из файла /proc/${PID}/maps. Чтобы не столкнуться с проблемами, связанными с правами доступа, в конце скрипта нужно добавить строку «exec dd», которая заменяет процесс bash на процесс dd. Таким образом, происходит передача родительского владения над программой sleep от bash к dd.
После того как стек перезаписан, мы можем дождаться возврата системного вызова в бинарном файле sleep, и далее наша ROP-цепь получит контроль над приложением и полезная нагрузка выполнится.
Полезная нагрузка, внедряемая как ROP-цепь, может быть практически любой, если не требуется динамическая логика. Текущая полезная нагрузка довольно проста и работает на базе функций open/memfd_create/sendfile/fexecve. Сначала целевой бинарный файл разделяется с флагом noexec, после чего происходит выполнение в памяти. Поскольку файл sleep во время выполнения переводится в фоновый режим утилитой bash, становится невозможен запуск бинарного файла, поскольку отсутствует родитель после завершения работы dd. Чтобы обойти это ограничение, можно использовать один из примеров из libfuse (предполагается, что интерфейс fuse присутствует в целевой системе): полезная нагрузка passthrough создаст зеркальное подключение файловой системы суперпользователя в назначенной директории. Новое подключение не имеет флага noexec, и, соответственно, через призму этого подключения можно добраться до бинарного файла и выполнить запуск.

На видео ниже показана полезная нагрузка passthrough, при помощи которой можно запускать бинарные файлы в текущей директории в качестве стандартного дочернего процесса шелла:

Мысли по улучшению производительности:

Чтобы ускорить выполнение, будет полезно закешировать смещение гаджета на основе базового ASLR-адреса, между предварительной загрузкой и основным запуском. Эту задачу можно решить посредством выгрузки ассоциативного массива на диск при помощи declare –p. Однако задействование жесткого диска не всегда уместно. Альтернативный вариант: переделка архитектуры скрипта для запуска полезной нагрузки в контексте основного процесса bash, а не подчиненного процесса, который запускается при помощи $(). Этот трюк позволяет обмениваться переменными окружения в обоих направлениях.

Кроме того, можно ограничить внешние зависимости, если убрать требование на использование GNU grep. Ранее подобная попытка уже предпринималась, но гаджеты стали искаться слишком медленно. Возможно, если выполнить оптимизацию кода, скорость поиска повысится.

Очевидная стратегия по противодействию этой технике – установка более высокого уровня безопасности, а конкретно – в параметр ptrace_scope значения 2 и выше. Следует сделать так, чтобы ptrace обычный пользователь использовать не смог. Это значение можно добавить в файле /etc/sysctl.conf:

kernel.yama.ptrace_scope=2

Другие стратегии включают использование Seccomp, SELinux или Apparmor для ограничения прав доступа на файлы /proc/${PID}/maps или /proc/${PID}/mem.

Код, реализующий концепцию, описанную в этой статье, и генератор Bash ROP можно найти на странице https://github.com/GDSSecurity/Cexigua.

Цифровые следы - ваша слабость, и хакеры это знают.

Подпишитесь и узнайте, как их замести!