CreateRemoteThread для Linux

Мицуха несёт новые потокиВ WinAPI есть функция CreateRemoteThread, позволяющая запустить новый поток в адресном пространстве другого процесса. Её можно использовать для разнообразных DLL-инъекций как с нехорошими целями (читы в играх, кража паролей, и т. д.), так и для того, чтобы на лету исправить баг в работающей программе, или добавить плагины туда, где они не были предусмотрены.

В целом эта функция обладает сомнительной прикладной полезностью, поэтому не удивительно, что в Linux готового аналога CreateRemoteThread нет. Однако, мне было интересно, как он может быть реализован. Изучение темы вылилось в неплохое приключение.

Я подробно расскажу о том, как с помощью спецификации ELF, некоторого знания архитектуры x86_64 и системных вызовов Linux написать свой маленький кусочек отладчика, способный загрузить и исполнить произвольный код в уже запущенном и работающем процессе.

Для понимания текста потребуются базовые знания о системном программировании под Linux: язык Си, написание и отладка программ на нём, осознание роли машинного кода и памяти в работе компьютера, понятие системных вызовов, знакомство с основными библиотеками, навык чтения документации.

В итоге у меня получилось «добавить» возможность предпросмотра паролей в Gnome Control Center:

демонстрация инъекции в Gnome Control Center


Основные идеи

Если бы в требованиях не было пункта о загрузке кода в уже работающий процесс, то решение было бы предельно простым: LD_PRELOAD. Эта переменная окружения позволяет подгрузить вместе с приложением произвольную библиотеку. В разделяемых библиотеках можно определять функции-конструкторы, исполняемые при загрузке библиотеки.

Вместе LD_PRELOAD и конструкторы позволяют выполнить произвольный код в любом процессе, использующем динамический загрузчик. Это относительно широко известная возможность, часто используемая для отладки. Например, вместе с приложением можно загрузить свою библиотеку, определяющую функции malloc () и free (), которая бы могла помочь отловить утечки памяти.

К сожалению, LD_PRELOAD работает только в процессе запуска процесса. С её помощью нельзя загрузить библиотеку в уже запущенный процесс. Для загрузки библиотек во время работы процесса существует функция dlopen (), но, очевидно, процесс сам должен её вызывать для загрузки плагинов.


О статических исполняемых файлах

LD_PRELOAD работает только с программами, использующими динамический загрузчик. Если программа собиралась с ключом -static, то она включает в себя все необходимые библиотеки. В этом случае разрешение зависимостей в библиотеках выполняется во время сборки и программа обычно не готова и не способна динамически загружать библиотеки после сборки, во время исполнения.

В статически собранные программы можно внедрять код во время исполнения, но это следует делать чуть-чуть по-другому. И это не вполне безопасно, так как программа может быть не готова к такому повороту.

В общем, готового удобного решения нет, придётся писать свой велосипед. Иначе вы бы не читали этот текст :)

Концептуально, чтобы заставить чужой процесс выполнить какой-то код, надо произвести следующие действия:


  1. Получить управление в целевом процессе.
  2. Загрузить код в память целевого процесса.
  3. Подготовить загруженный код к исполнению в целевом процессе.
  4. Организовать исполнение загруженного кода целевым процессом.

Поехали…


Получение управления в процессе

Прежде всего нам потребуется подчинить целевой процесс своей воле. Ведь обычно процессы исполняют только свой собственный код, или код загруженных библиотек, или результаты JIT-компиляции. Но точно не наш код.

Один из вариантов — это использовать какую-нибудь уязвимость в процессе, позволяющую перехватить управление. Классический пример из учебников: переполнение буфера, позволяющее переписать адрес возврата на стеке. Это весело, иногда даже работает, но не подходит для общего случая.

Мы будем использовать другой, честный способ получить управление: отладочные системные вызовы. Интерактивные отладчики отлично умеют останавливать сторонние процессы, вычислять выражения, и многие другие вещи. Могут они — сможем и мы.

В Linux основным отладочным системным вызовом является ptrace (). Он позволяет подключаться к процессам, исследовать их состояние, управлять ходом их исполнения. ptrace () вполне прилично задокументирован сам по себе, но детали его использования становятся ясны только на практике.


Загрузка кода в память процесса

В случае с переполнением буфера полезная нагрузка (шелл-код) обычно включается в содержимое, переполняющее тот самый буфер. При использовании отладчика нужный код можно записать в память процесса напрямую. В WinAPI для этого есть специальная функция WriteProcessMemory. Linux же для этих целей соблюдает UNIX way: для каждого процесса в системе есть файл /proc/$pid/mem, отображающий память этого процесса. Записать чего-нибудь в память процессу можно с помощью обычного ввода-вывода.


Подготовка кода к исполнению

Просто записать код в память мало. Его ещё надо записать в исполняемую память. В случае записи через уязвимость с этим есть нетривиальные сложности, но так как мы можем полностью контролировать целевой процесс, то для нас не будет проблемой найти или выделить себе «правильной» памяти.

Другой важный момент подготовки — это сам шелл-код. В нём мы наверняка захотим использовать какие-нибудь функции из библиотек, вроде ввода-вывода, графических примитивов, и так далее. Однако, записывать нам придётся голый машинный код, который сам по себе понятия не имеет об адресах всех этих классных функций в библиотеках. Откуда же их взять?

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

При нормальном запуске процесса за определение точных адресов библиотек отвечает загрузчик, выполняющий релокации. Однако, он отрабатывает только один раз на старте. Если процесс позволяет динамическую загрузку библиотек, то в нём присутствует динамический загрузчик, умеющий делать то же самое во время работы процесса. Однако, адрес динамического загрузчика тоже не фиксированный.

В общем, с библиотеками можно выделить четыре варианта действий:


  • не использовать библиотеки вовсе, всё делать на чистых системных вызовах
  • вкладывать в шелл-код копии всех нужных библиотек
  • выполнить работу динамического загрузчика самостоятельно
  • найти динамический загрузчик и заставить его загрузить наши библиотеки

Мы выберем последний, так как библиотеки хочется, а писать свой полноценный загрузчик долго. Это не самый скрытный способ, и не самый интересный, зато наиболее простой, мощный и надёжный.


Передача управления коду

ptrace () позволяет изменять регистры процессора, так что проблем с передачей управления загруженному и подготовленному коду возникнуть не должно: просто записываем в регистр %rip адрес нашего кода — и вуаля! Однако, на деле всё не так просто. Сложности связаны с тем, что отлаживаемый процесс вообще-то никуда не делся и у него тоже есть какой-то код, который исполнялся и будет исполняться дальше.


Эскиз решения

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


  1. Подключаемся к целевому процессу для отладки.
  2. Находим в памяти нужные библиотеки:
    • libdl — для загрузки новой библиотеки
    • libpthread — для запуска нового потока
  3. Находим в библиотеках нужные функции:
    • libdl: dlopen (), dlsym ()
    • libpthread: pthread_create (), pthread_detach ()
  4. Внедряем в память целевого процесса шелл-код:

    void shellcode(void)
    {
        void *payload = dlopen("/path/to/payload.so", RTLD_LAZY);
        void *entry = dlsym(payload, "entry_point");
    
        pthread_t thread;
        pthread_create(&thread, NULL, entry, NULL);
        pthread_detach(thread);
    }

  5. Даём шелл-коду исполниться.

В результате для нас всё правильно сделают библиотеки: загрузят в память нашу библиотеку с нужным нам кодом и запустят новый поток, исполняющий этот код.


Ограничения

Описанный выше подход накладывает определённые ограничения:


  • У загрузчика должны быть достаточные права для отладки целевого процесса.
  • Процесс должен использовать libdl (готов к динамической загрузке модулей).
  • Процесс должен использовать libpthread (готов к многопоточности).
  • Не поддерживаются приложения, собранные статически.

Кроме того, лично мне лень заморачиваться с поддержкой всех-всех архитектур, так что мы ограничимся только x86_64. (Даже 32-битная x86 была бы сложнее.)

Как можно заметить, всё это ставит крест на скрытном применении с вредоносными целями. Однако, задача всё ещё сохраняет исследовательский интерес и даже оставляет слабую возможность для промышленного использования.


Отступление: об использовании libdl и libpthread

Опытный читатель-специалист может задаться вопросом: зачем требовать наличие libdl, если в glibc уже встроены внутренние функции __libc_dlopen_mode () и __libc_dlsym (), а libdl — это просто обёртка над ними? Аналогично, зачем требовать libpthread, если новый поток можно легко создать с помощью системного вызова clone ()?

Ведь в интернетах есть далеко не один пример того, как ими пользуются:

Они даже упоминаются в популярной хакерской литературе:


  • Learning Linux Binary Analysis
  • The Art of Memory Forensics

Так почему нет? Ну, как минимум потому, что мы пишем не вредоносный код, где подойдёт решение, опускающее 90% проверок, занимающее в 20 раз меньше места, но и работающее в 80% случаев. Кроме того, я хотел попробовать всё своими руками.

Действительно, libdl не является необходимостью для загрузки библиотеки в случае glibc. Её использование процессом говорит о том, что он явно готов к динамической загрузке кода. Не смотря на это, в принципе, от использования libdl можно отказаться (учитывая, что нам потом всё равно понадобится искать и glibc).


Зачем вообще dlopen () внутри glibc?

Это по своему интересный вопрос. Короткий ответ: детали реализации.

Дело в name service switch (NSS) — одной из частей glibc, обеспечивающей трансляцию разнообразных имён: имён машин, протоколов, пользователей, почтовых серверов, и т. д. Именно она ответственна за такие функции как getaddrinfo () для получения IP-адресов по доменному имени и getpwuid () для получения информации о пользователе по его числовому идентификатору.

У NSS модульная архитектура и модули загружаются динамически. Собственно, для этого в glibc и потребовались механизмы для динамической загрузки библиотек. Именно поэтому, когда вы пытаетесь использовать getaddrinfo () в статически собранном приложении, линкер печатает «непонятное» предупреждение:


/tmp/build/socket.o: In function `Socket::bind':
socket.o:(.text+0x374): warning: Using 'getaddrinfo' in statically linked
applications requires at runtime the shared libraries from glibc version
used for linking

Что касается потоков, то поток — это обычно не только стек и исполняемый код, а ещё и глобальные данные, хранимые в thread-local storage (TLS). Корректная инициализация нового потока требует координированной работы ядра ОС, загрузчика бинарного кода и рантайма языка программирования. Поэтому простого вызова clone () достаточно для создания потока, способного записать в файл «Hello world!», но это может не сработать для более сложного кода, которому нужен доступ к TLS и прочим интересным штукам, скрытым от взора прикладного программиста.

Ещё один момент, связанный с многопоточностью — это однопоточные процессы. Что будет, если мы создадим новый поток в процессе, который не задумывался как многопоточный? Правильно, неопределённое поведение. Ведь в процессе отсутствует какая-либо синхронизация работы между потоками, что рано или поздно приведёт к порче данных. Если же мы потребуем, чтобы приложение использовало libpthread, то мы можем быть уверены в том, что оно готово к работе в многопоточном окружении (по крайней мере, должно быть готово).


Шаг 1. Подключение к процессу

Для начала нам потребуется подключиться к целевому процессу для отладки, а позже — отключиться от него обратно. Здесь в дело вступает системный вызов ptrace().


Первый контакт с ptrace ()

В документации на ptrace () можно найти почти всю необходимую информацию:

  Attaching and detaching
       A thread can be attached to the tracer using the call

           ptrace(PTRACE_ATTACH, pid, 0, 0);

       or

           ptrace(PTRACE_SEIZE, pid, 0, PTRACE_O_flags);

       PTRACE_ATTACH sends SIGSTOP to this thread.  If the tracer wants this
       SIGSTOP to have  no effect,  it needs  to suppress it.   Note that if
       other signals are concurrently sent to this thread during attach, the
       tracer may see the tracee enter signal-delivery-stop  with other sig‐
       nal(s) first!   The usual practice is to reinject these signals until
       SIGSTOP  is seen,  then suppress  SIGSTOP injection.  The design  bug
       here is that a ptrace attach and a concurrently delivered SIGSTOP may
       race and the concurrent SIGSTOP may be lost.

Так что первый шаг — это использовать PTRACE_ATTACH:

int ptrace_attach(pid_t pid)
{
        /* Подключаемся к целевому процессу */
        if (ptrace(PTRACE_ATTACH, pid, 0, 0) < 0)
                return -1;

        /* Дожидаемся его остановки */
        if (wait_for_process_stop(pid, SIGSTOP) < 0)
                return -1;

        return 0;
}

После ptrace () целевой процесс ещё не совсем готов к отладке. Мы подключились к нему, но для интерактивного исследования состояния процесса он должен быть остановлен. ptrace () отправляет процессу сигнал SIGSTOP, но нам ещё нужно дождаться собственно остановки процесса.

Для ожидания следует использовать системный вызов waitpid(). При этом стоит отметить несколько интересных граничных случаев. Во-первых, процесс может попросту завершиться или умереть, так и не получив SIGSTOP. В этом случае мы ничего не можем поделать. Во-вторых, процессу может быть ранее отправлен какой-нибудь другой сигнал. В этом случае нам следует дать процессу его обработать (с помощью PTRACE_CONT), а самим — продолжить дальше ждать наш SIGSTOP:

static int wait_for_process_stop(pid_t pid, int expected_signal)
{
        for (;;) {
                int status = 0;

                /* Ждём, пока с процессом что-то произойдёт */
                if (waitpid(pid, &status, 0) < 0)
                        return -1;

                /* Если процесс умер или завершился — ну ок */
                if (WIFSIGNALED(status) || WIFEXITED(status))
                        return -1;

                /* Если процесс остановлен, то надо проверить причину */
                if (WIFSTOPPED(status)) {
                        /*
                         * Макрос WSTOPSIG() напрямую использовать нельзя,
                         * так как ptrace() возвращает дополнительную
                         * информацию в старших байтах статуса.
                         */
                        int stop_signal = status >> 8;

                        /* Если это наш сигнал, то мы приехали */
                        if (stop_signal == expected_signal)
                                break;

                        /* Иначе продолжаем исполнение процесса и ждём дальше */
                        if (ptrace(PTRACE_CONT, pid, 0, stop_signal) < 0)
                                return -1;

                        continue;
                }

                /* Всё остальное — непонятные ошибки */
                return -1;
        }

        return 0;
}


Отключение от процесса

Прекратить отлаживать процесс значительно проще: достаточно использовать PTRACE_DETACH:

int ptrace_detach(pid_t pid)
{
        if (ptrace(PTRACE_DETACH, pid, 0, 0) < 0)
                return -1;

        return 0;
}

Строго говоря, явное отключение отладчика необходимо не всегда. Когда процесс отладчика завершается, он автоматически отключается от всех отлаживаемых процессов, а сами процессы возобновляют работу, если они были остановлены ptrace (). Однако, если отлаживаемый процесс был явно остановлен отладчиком с помощью сигнала SIGSTOP без использования ptrace (), то он не проснётся без соответствующего сигнала SIGCONT или PTRACE_DETACH. Поэтому лучше всё же отключаться от процессов культурно.


Настройка ptrace_scope

Отладчик обладает полным контролем над отлаживаемым процессом. Если бы кто попало мог отлаживать что попало, то какое бы раздолье было для вредоносного кода! Очевидно, что интерактивная отладка — это достаточно специфичная деятельность, обычно необходимая только разработчикам. При нормальной эксплуатации системы чаще всего надобности в отладке процессов нет.

Из этих соображений, ради безопасности в системах обычно по умолчанию отключается возможность отлаживать какие попало процессы. За это отвечает модуль безопасности Yama, управляемый через файл /proc/sys/kernel/yama/ptrace_scope. Он предоставляет четыре модели поведения:


  • 0 — пользователь может отлаживать любые процессы, которые он запустил
  • 1 — режим по умолчанию, можно отлаживать только процессы, запущенные отладчиком
  • 2 — только администратор системы с правами root может отлаживать процессы
  • 3 — отладка запрещена вообще всем, режим не отключается до перезагрузки системы

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

$ sudo sh -c 'echo 0 > /proc/sys/kernel/yama/ptrace_scope'

или же запускать отладчик от имени администратора:

$ sudo ./inject-thread ...


Результаты первого шага

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

Целевой процесс будет остановлен и мы сможем убедиться, что операционная система действительно видит нас как отладчик:

$ sudo ./inject-thread --target $(pgrep docker)

$ cat /proc/$(pgrep docker)/status | head
Name:   docker
State:  t (tracing stop)                <--- внимание на статус
Tgid:   31330
Ngid:   0
Pid:    31330
PPid:   1
TracerPid:      2789                    <--- PID процесса отладчика
Uid:    0       0       0       0
Gid:    0       0       0       0
FDSize: 64

$ ps a | grep [2]789
 2789 pts/5    S+     0:00 ./inject-thread --target 31330


Шаг 2. Поиск библиотек в памяти

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


Файл /proc/$pid/maps

В этом нам поможет специальный файл, через который ядро рассказывает о том, что и где у процесса в памяти расположено. Как известно, в директории /proc для каждого процесса есть поддиректория. А в ней есть файл, описывающий карту памяти процесса:

$ cat /proc/self/maps
00400000-0040c000 r-xp 00000000 fe:01 1044592                            /bin/cat
0060b000-0060c000 r--p 0000b000 fe:01 1044592                            /bin/cat
0060c000-0060d000 rw-p 0000c000 fe:01 1044592                            /bin/cat
013d5000-013f6000 rw-p 00000000 00:00 0                                  [heap]
7f9920bd1000-7f9920d72000 r-xp 00000000 fe:01 920019                     /lib/x86_64-linux-gnu/libc-2.19.so
7f9920d72000-7f9920f72000 ---p 001a1000 fe:01 920019                     /lib/x86_64-linux-gnu/libc-2.19.so
7f9920f72000-7f9920f76000 r--p 001a1000 fe:01 920019                     /lib/x86_64-linux-gnu/libc-2.19.so
7f9920f76000-7f9920f78000 rw-p 001a5000 fe:01 920019                     /lib/x86_64-linux-gnu/libc-2.19.so
7fc3f8381000-7fc3f8385000 rw-p 00000000 00:00 0
7fc3f8385000-7fc3f83a6000 r-xp 00000000 fe:01 920012                     /lib/x86_64-linux-gnu/ld-2.19.so
7fc3f83ec000-7fc3f840e000 rw-p 00000000 00:00 0
7fc3f840e000-7fc3f8597000 r--p 00000000 fe:01 657286                     /usr/lib/locale/locale-archive
7fc3f8597000-7fc3f859a000 rw-p 00000000 00:00 0
7fc3f85a3000-7fc3f85a5000 rw-p 00000000 00:00 0
7fc3f85a5000-7fc3f85a6000 r--p 00020000 fe:01 920012                     /lib/x86_64-linux-gnu/ld-2.19.so
7fc3f85a6000-7fc3f85a7000 rw-p 00021000 fe:01 920012                     /lib/x86_64-linux-gnu/ld-2.19.so
7fc3f85a7000-7fc3f85a8000 rw-p 00000000 00:00 0
7ffdb6f0e000-7ffdb6f2f000 rw-p 00000000 00:00 0                          [stack]
7ffdb6f7f000-7ffdb6f81000 r-xp 00000000 00:00 0                          [vdso]
7ffdb6f81000-7ffdb6f83000 r--p 00000000 00:00 0                          [vvar]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

Содержимое этого файла генерируется ядром операционной системы на лету из внутренних структур, описывающих регионы памяти интересующего нас процесса, и содержит следующую информацию:


  • диапазон адресов, выделенный региону
  • права доступа на регион
    • r/-: чтение
    • w/-: запись
    • x/-: исполнение
    • p/s: разделение памяти с другими процессами
  • смещение в файле (если есть)
  • код устройства, где расположен отображаемый файл
  • номер inode отображаемого файла (если есть)
  • путь к отображаемому файлу (если есть)

Некоторые регионы памяти отображаются на файлы: когда процесс читает такую память, то он на самом деле считывает данные из соответствующих файлов по определённому смещению. Если в регион можно писать, то изменения в памяти могут быть либо видимы только самому процессу (механизм copy-on-write, режим p — private), так и синхронизироваться с диском (режим s — shared).

Другие регионы являются анонимными — эта память не соответствует никакому файлу. Операционная система просто выдаёт процессу кусочек физической памяти, которым он пользуется. Такие регионы используются, например, для «обычной» памяти процесса: стека и кучи. Анонимные регионы могут быть как личными для процесса, так и разделяться между несколькими процессами (механизм shared memory).

Кроме того, в памяти есть несколько специальных регионов, отмеченные псевдоименами [vdso] и [vsyscall]. Они используются для оптимизации некоторых системных вызовов.

Нас интересуют регионы, куда отображается содержимое файлов с библиотеками. Если мы прочитаем карту памяти и отфильтруем в ней записи по имени отображаемого файла, то мы найдём все адреса, занимаемые нужными нам библиотеками. Формат карты памяти специально сделан удобным для программной обработки и элементарно разбирается с помощью функций семейства scanf ():

static bool read_proc_line(const char *line, const char *library,
                struct memory_region *region)
{
        unsigned long vaddr_low = 0;
        unsigned long vaddr_high = 0;
        char read = 0;
        char write = 0;
        char execute = 0;
        int path_offset = 0;

        /* Разбираем одну строку /proc/$pid/maps */
        sscanf(line, "%lx-%lx %c%c%c%*c %*lx %*x:%*x %*d %n",
                &vaddr_low, &vaddr_high, &read, &write, &execute,
                &path_offset);

        /* Проверяем, совпадает ли имя файла с искомым */
        if (!strstr(line + path_offset, library))
                return false;

        /* Запоминаем нужную нам информацию о диапазоне адресов и правах доступа */
        if (region) {
                region->vaddr_low = vaddr_low;
                region->vaddr_high = vaddr_high;
                region->readable = (read == 'r');
                region->writeable = (write == 'w');
                region->executable = (execute == 'x');
                region->content = NULL;
        }

        return true;
}


Тайна третьей планеты

Если обратить внимание на диапазоны памяти, используемые libc-2.19.so, то можно заметить странную вещь:

дырка в libc-2.19.so

Что вот это за пустой регион на 2 мегабайта без каких-либо прав доступа к нему? Зона 51? Призрак Денниса Ричи? Сокровища нибелунгов?

Оказывается, это особенность реализации компилятора, который таким образом старается оптимизировать использование физической памяти в системе.

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

Операционная система управляет памятью не побайтово, а страницами (обычно по 4 КБ каждая). Память выдаётся процессам страницами, права доступа устанавливаются постранично и только целые страницы можно разделять между процессами.

Компилятор хочет, чтобы исполняемый код и данные библиотеки находились на отдельных страницах памяти. В этом случае неизменяемый код библиотеки — её наибольшую часть — можно будет разделять между всеми процессами. Именно для этого компилятор и вставляет между кодом и данными 2 мегабайта пустого места — максимальный размер страницы, с которыми скорее всего придётся иметь дело (архитектура x86_64 поддерживает страницы размером 4 КБ, 2 МБ, 1 ГБ). Тогда при загрузке библиотеки код и данные никогда не будут находиться на одной и той же странице памяти.


Результаты второго шага

Проанализировав карту памяти целевого процесса, мы получаем адреса загрузки библиотек с необходимыми для нас функциями:


  • библиотека libdl: dlopen () и dlsym ()
  • библиотека libpthread: pthread_create () и pthread_detach ()

Это достаточно важная информация, так как она необходима для использования функций, содержащихся в библиотеках. Чтобы усложнить жизнь вредоносному коду Linux обычно загружает библиотеки по случайным адресам (address space layout randomization, ASLR). Случайный адрес загрузки не мешает самим программам (загрузчик-то знает, куда и что он загружал), но сторонние процессы вынуждены будут самостоятельно искать базовый адрес во время исполнения — вместо простого использования какой-нибудь константы.

Если нам и другим отладчикам это позволительно, то у внедряемого вредоносного кода обычно очень жёсткие ограничения на размер, которые не позволяют втиснуть туда открытие, чтение и разбор файла /proc/$pid/maps. Не говоря уже о том, что факт чтения этого файла позволяет тривиально обнаруживать подозрительную активность.


Шаг 3. Разбор ELF-образов библиотек

Теперь, когда мы знаем адреса загрузки библиотек, в этих библиотеках хорошо бы найти сами функции, которые нам нужны.

Можно поступить прагматично:

$ nm -D /lib/x86_64-linux-gnu/libdl-2.19.so | grep dlopen
0000000000001090 T dlopen

Утилита nm разбирает таблицу символов из файла с библиотекой и выдаёт смещения интересующих нас функций. Прибавляя смещение к базовому адресу исполняемого региона библиотеки мы получаем собственно адрес функции в конкретном процессе.

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


Чтение памяти целевого процесса

Первый шаг — это собственно прочитать ELF-образ, загруженный в целевой процесс. С этим нам опять поможет файловая система procfs. В лучших традициях UNIX way, память отлаживаемого процесса можно прочитать из специального файла /proc/$pid/mem, смещение в котором — это виртуальный адрес в процессе (который мы знаем из файла /proc/$pid/maps).

Давным давно Linux позволял отобразить этот файл в память с помощью системного вызова mmap (), но это приводило к проблемам с правами доступа (отображающий процесс мог читать память, недоступную её хозяину). Поэтому придется копировать все нужные регионы памяти целевого процесса в свою память:

static int map_region(pid_t pid, struct memory_region *region)
{
        size_t length = region->vaddr_high - region->vaddr_low;
        off_t offset = region->vaddr_low;

        char path[32] = {0};
        snprintf(path, sizeof(path), "/proc/%d/mem", pid);

        /* Открываем память целевого процесса */
        int fd = open(path, O_RDONLY);
        if (fd < 0)
                goto error;

        /* Выделяем буфер под свою копию */
        void *buffer = malloc(length);
        if (!buffer)
                goto error_close_file;

        /* Читаем память */
        if (read_region(fd, offset, buffer, length) < 0)
                goto error_free_buffer;

        region->content = buffer;

        close(fd);

        return 0;

error_free_buffer:
        free(buffer);
error_close_file:
        close(fd);
error:
        return -1;
}

static int read_region(int fd, off_t offset, void *buffer, size_t length)
{
        /* Смещаемся до нужного виртуального адреса */
        if (lseek(fd, offset, SEEK_SET) < 0)
                return -1;

        size_t remaining = length;
        char *ptr = buffer;

        /*
         * Читаем всю интересующую нас память. Обязательно в цикле,
         * потому что ядро может отдавать столько, сколько хочет.
         */
        while (remaining > 0) {
                ssize_t count = read(fd, ptr, remaining);

                if (count < 0)
                        return -1;

                remaining -= count;
                ptr += count;
        }

        return 0;
}

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


Двуликий ELF

Вот сейчас наконец нам потребуется открыть спецификацию на формат ELF — наиболее популярный формат исполнимых файлов и библиотек в Linux. Она поможет нам разобраться с тем, что искать в образе, который был получен на предыдущем этапе.

Одним из главных понятий в ELF является двойственность представления. Во время компиляции ELF представляется как набор секций с кодом или данными. Во время загрузки — как набор сегментов, которые надо загрузить в память перед работой программы. Секции описывают регионы файла на диске, сегменты — регионы образа в памяти. В начале файла или образа всегда располагается ELF-заголовок.

Например, моя библиотека libdl-2.19.so выглядит так:

секции и сегменты libdl-2.19.so

(Эту информацию можно получить в текстовом виде с помощью команды readelf --headers.)

Как можно заметить, секций в библиотеке больше, чем сегментов (29 против 9). Компиляция и сборка — это достаточно сложный процесс, так что компилятор в файле для себя всё раскладывает по полочкам, отсюда и большое количество секций. Загрузка ELF — это сравнительно простая работа, большую часть которой загрузчик выполняет самостоятельно. Ядру Linux, например, важны только сегменты LOAD, а остальными пользуется уже загрузчик (отрабатывающий в самом начале исполнения программы).

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

Некоторые сегменты занимают в памяти больше меньше места, чем на диске. «Лишнее» место загрузчик заполняет нулями. Именно так реализуется секция .bss, хранящая глобальные переменные, которые в программе инициализированы нулями (значение по умолчанию в Си).

В общем, устройство формата ELF и работа с ним — это отдельная, очень весёлая и интересная тема. Но нас сейчас интересует лишь один вопрос…


Где лежит таблица символов?

В каждой библиотеке хранится таблица соответствия между именами объектов (символами) и их адресами. Этой таблицей пользуется загрузчик во время загрузки программы, а также функции вроде dlsym (), чтобы искать в библиотеке функции по именам. Вот она-то нам и нужна.

Механизм загрузки программ описывается во второй части спецификации ELF (стр. 2–10). Оттуда можно узнать, что информация для загрузчика хранится в секции .dynamic, которой соответствует загружаемый сегмент DYNAMIC. В секции .dynamic хранятся ссылки на другие секции, необходимые загрузчику:


  • .dynsym — собственно таблица символов с адресами;
  • .dynstr — массив строк с именами символов;
  • .hash — хеш-таблица, ускоряющая поиск символов.

Сегменты описываются таблицей сегментов, обычно расположенной в начале образа, а расположение таблицы сегментов определяется заголовком ELF:

поиск сегмента DYNAMIC

Начинаем мы с заголовка ELF, в котором находим таблицу сегментов (1), в которой находим нужный сегмент (2), в котором находим нужные секции (3), в которых находим нужные функции (4) в доме, который построил Джек.


Заголовок ELF → таблица сегментов

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

Любой ELF-файл начинается с заголовка (структура Elf64_Ehdr). В нём нас интересуют координаты и размеры таблицы сегментов (program headers), хранимые в полях e_phoff и e_phnum:

заголовок ELF

Остальные поля — магическое число в начале, целевую архитектуру процессора, тип ELF-файла и прочие — есть смысл проверить на согласованность, чтобы убедиться в том, что мы действительно попали туда, куда надо, и наши предположения оправдываются.

В итоге мы получаем смещение таблицы сегментов e_phoff, по которому легко вычислить её виртуальный адрес, прибавив к смещению адрес загрузки библиотеки. Таблица сегментов содержит e_phnum записей размером e_phentsize байтов каждая.

В нашем случае (как обычно и бывает), таблица сегментов расположена сразу же после заголовка ELF — по смещению в 64 байта.


Таблица сегментов → сегмент DYNAMIC

Теперь надо найти нужный нам сегмент. Таблица сегментов — это просто массив структур типа Elf64_Phdr (для 64-битных ELF-файлов), описывающих заголовки сегментов. Нужный нам заголовок содержит значение PT_DYNAMIC в поле p_type:

таблица сегментов ELF

Другие поля заголовка содержат информацию о размере и расположении сегмента:


  • p_vaddr — смещение виртуального адреса, куда сегмент будет загружен;
  • p_memsz — размер сегмента в памяти в байтах.

В нашем случае секция .dynamic располагается в файле по смещению 0×2D88 (можете сравнить с картой файла выше). В память же она загружается как сегмент DYNAMIC на два мегабайта дальше — по смещению 0×202D88. Секция имеет длину 0×210 (8448) байтов. Прибавив смещение к базовому адресу загрузки библиотеки мы получим виртуальный адрес сегмента в памяти целевого процесса.


Сегмент DYNAMIC → секции .dynsym, .dynstr, .hash

Секция .dynamic, загружаемая в сегмент DYNAMIC, содержит информацию для динамического загрузчика библиотек. Она хранится в виде массива структур Elf64_Dyn, описывающих разнообразные вещи:

теги секции DYNAMIC

Каждая структура несёт 8 байтов информации в поле d_val или d_ptr, а также 8-байтовую метку d_tag, которая определяет, как эту информацию интерпретировать. Для нас будут интересны следующие метки:


  • DT_HASH (4) — виртуальный адрес секции .hash (в d_ptr)
  • DT_STRTAB (5) — виртуальный адрес секции .dynstr (в d_ptr)
  • DT_SYMTAB (6) — виртуальный адрес секции .dynsym (в d_ptr)
  • DT_STRSZ (10) — размер в байтах секции .dynstr (в d_val)
  • DT_NULL (0) — последняя структура в списке

Все эти записи обязательны для разделяемых библиотек. Кроме этих записей секция .dynamic содержит и другие интересные вещи: имя библиотеки, имена её зависимостей, информацию для выполнения релокаций, адреса конструкторов и деструкторов.

Обратите внимание на то, что сегмент DYNAMIC располагается в изменяемом регионе памяти и соответствующие записи в нём содержат не смещения, а абсолютные виртуальные адреса. В файле хранятся как раз смещения, но загрузчик во время загрузки вписывает в память уже готовые виртуальные адреса, ведь он-то знает, куда загрузил библиотеку.

После разбора секции .dynamic у нас на руках будут адреса всех других секций, которые необходимы для поиска адресов интересующих нас функций в библиотеке. Постойте-ка

© Habrahabr.ru