Search     or:     and:
 LINUX 
 Language 
 Kernel 
 Package 
 Book 
 Test 
 OS 
 Forum 
 iakovlev.org 
 Kernels
 Boot 
 Memory 
 File system
 0.01
 1.0 
 2.0 
 2.4 
 2.6 
 3.x 
 4.x 
 Интервью 
 Kernel
 HOW-TO 1
=> Ptrace
 Kernel-Rebuild-HOWTO
 Runlevel
 Linux daemons
 FAQ
NEWS
Последние статьи :
  Rust 07.11   
  Go 25.12   
  EXT4 10.11   
  FS benchmark 15.09   
  Сетунь 23.07   
  Trees 25.06   
  Apache 03.02   
  SQL 30.07   
  JFS 10.06   
  B-trees 01.06   
 
TOP 20
 Go Web ...554 
 Steve Pate 3...452 
 Rodriguez 6...423 
 Trees...407 
 TCP 3...385 
 Rodriguez 2...372 
 Rubni-Corbet -> Глав...358 
 Steve Pate 1...348 
 B.Blunden 1...344 
 Daniel Bovet 3...343 
 Стивенс 9...339 
 UML 3...339 
 Robert Love 3...338 
 Robert Love 2...333 
 Максвелл 1...331 
 Максвелл 1...331 
 Mod_perl 2...330 
 Daniel Bovet 4...330 
 Robbins 1...327 
 C++ Templates 1...324 
 
  01.04.2017 : 2166403 посещений 

iakovlev.org

Трассировка процессов с помощью Ptrace -- Часть 1.

Автор: Sandeep S
Перевод: Андрей Киселев
Источник: http://gazette.linux.ru.net

Системный вызов ptrace является основой основ для программ-отладчиков, таких как gdb, но все же принципы работы с этим системным вызовом недостаточно хорошо освещены в документации, если не считать, что самой лучшей документацией являются исходные тексты ядра! Я постараюсь продемонстрировать основные приемы при работе с ptrace и некоторые его функциональные возможности, доступные в инструментальных средствах подобных gdb.

1. Введение

ptrace() -- это системный вызов, который дает возможность одному процессу управлять исполнением другого. Он так же позволяет изменять содержимое памяти трассируемого процесса. Трассируемый процесс ведет себя как обычно до тех пор пока не получит сигнал. Когда это происходит, процесс переходит в состояние останова, а процесс-трассировщик информируется об этом вызовом wait(). После этого процесс-трассировщик, через вызовы ptrace, определяет реакцию трассируемого процесса. Исключение составляет сигнал SIGKILL, который само собой разумеется, уничтожает процесс.

Кроме того, можно задать переход трассируемого процесса в состояние останова по определенному событию, которое возникло в ходе его исполнения. Это происходит только в том случае, если процесс-трассировщик установил какие-либо флаги событий в контексте трассируемого процесса. Трассировщик может даже завершить трассируемый процесс, установив при этом код его завершения. После выполнения каких-либо действий трассировщик может завершить отлаживаемый п ениях request, PTRACE_POKETEXT и PTRACE_POKEDATA, производится запись значения аргумента data по адресу addr в пространстве трассируемого процесса. Оба эти значения приводят к одинаковым результатам.

В случае PTRACE_POKEUSER, производится запись в структуру типа user, соответствующей трассируемому процессу. Следует быть очень осторожным при работе с этим параметром, поскольку в данном случае мы вторгаемся в область ядра. После выполнения большого количества проверок, ptrace выполняет запись в указанную позицию структуры, при этом доступными для записи оказываются далеко не все элементы структуры. Аргумент addr в данном случае определяет смещение относительно начала структуры.

PTRACE_SYSCALL, PTRACE_CONT:

Обе эти команды активируют трассируемый процесс. В случае PTRACE_SYSCALL дочернему процессу предписывается остановиться на следующем системном вызове. PTRACE_CONT -- просто возобновляет работу трассируемого процесса. И в том и в другом случае, если аргумент data не равен нулю или SIGSTOP, ptrace() передает его процессу как сигнал, который необходимо обработать. При этом ptrace() сбрасывает бит пошаговой трассировки и устанавливает/сбрасывает бит трассировки системных вызовов. Аргумент addr игнорируется.

PTRACE_SINGLESTEP:

Имеет тот же смысл, что и PTRACE_SYSCALL, за исключением того, что трассируемый процесс останавливается после исполнения каждой инструкции. Устанавливает бит пошаговой трассировки. Как и выше, аргумент data содержит код завершения для трассируемого процесса. Аргумент addr игнорируется.

PTRACE_KILL:

Используется для завершения трассируемого процесса. Завершение производится следующим образом. Ptrace() проверяет -- "жив" ли трассируемый процесс, затем устанавливает код завершения дочернего процесса в значение sigkill, сбрасывает бит пошаговой трассировки и активирует дочерний процесс, который в соответствии с кодом завершения прекращает свою работу.

2.2 Аппаратно-зависимые значения для аргумента request

Описанные выше значения аргумента request являются аппаратно-независимыми. Значения, описываемые ниже, позволяют читать/изменять регистры процессора дочернего процесса, а потому очень тесно связаны с аппаратной реализацией системы. Набор доступных регистров включает в себя регистры общего назначения и регистры FPU (арифметического сопроцессора).

PTRACE_GETREGS, PTRACE_GETFPREGS, PTRACE_GETFPXREGS:

При этих значениях request, после обычной проверки прав доступа, производится копирование значений регистров общего назначения, регистров с плавающей точкой, дополнительных регистров с плавающей точкой дочернего процесса в переменную родительского процесса data. Копирование выполняется с помощью функций getreg() и __put_user(), аргумент addr игнорируется.

PTRACE_SETREGS, PTRACE_SETFPREGS, PTRACE_SETFPXREGS:

При этих значениях аргумента request выполняется запись в регистры процессора трассируемого процесса. В данном случае доступ к отдельным регистрам ограничивается. Значения регистров берутся из аргумента data. Аргумент addr игнорируется.

2.3 Возвращаемые значения системного вызова ptrace()

В случае успеха ptrace() возвращает ноль. В случае возникновения ошибки -- возвращается значение -1, а код ошибки -- в переменной errno. Поскольку при выполнении операций PEEKDATA/PEEKTEXT, даже в случае успеха может быть возвращено значение -1, то лучше выполнять проверку на наличие ошибки по переменной errno. Коды ошибок могут быть следующими

EPERM : Отсутствие прав доступа.

ESRCH : Требуемый процесс не найден или уже трассируется.

EIO : Недопустимый код запроса (request) или задан недопустимый адрес памяти для чтения/записи.

EFAULT : Была сделана попытка записи информации в область памяти, но скорее всего эта память не существует или недоступна.

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

3. Небольшой пример.

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

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

Тестовая программа выводит содержимое текущего каталога и подсчитывает количество затраченных машинных инструкций.



 #include <stdio.h>
 #include <stdlib.h>
 #include <signal.h>
 #include <syscall.h>
 #include <sys/ptrace.h>
 
 #include <sys/types.h>
 #include <sys/wait.h>
 #include <unistd.h>
 #include <errno.h>
 
 
 int main(void)
 {
         long long counter = 0;  /*  Счетчик машинных инструкций     */
         int wait_val;           /*  значение, возвращаемое потомком */
         int pid;                /*  pid потомка                     */
 
         puts("Минутку терпения");
 
         switch (pid = fork()) {
         case -1:
                 perror("fork");
                 break;
         case 0: /*  запуск дочернего процесса        */
                 ptrace(PTRACE_TRACEME, 0, 0, 0);
                 /* 
                  *  необходимо, чтобы передать
                  *  управление дочернему процессу
                  */ 
                 execl("/bin/ls", "ls", NULL);
                 /*
                  *  выполнить программу и заставить 
                  *  потомка остановиться и передать сигнал
                  *  родителю, теперь родитель 
                  *  сможет перейти в PTRACE_SINGLESTEP   
                  */ 
                 break;
                 /*  завершение дочернего процесса  */
         default:/*  запуск родительского процесса  */
                 wait(&wait_val); 
                 /*   
                  *   родитель ожидает, пока потомок не остановится 
                  *   на следующей инструкции (execl()) 
                  */
                 while (wait_val == 1407 ) {
                         counter++;
                         if (ptrace(PTRACE_SINGLESTEP, pid, 0, 0) != 0)
                                 perror("ptrace");
                         /* 
                          *   переход в пошаговый режим
                          *   и активация потомка
                          */
                         wait(&wait_val);
                         /*   ожидание выполнения следующей инструкции  */
                 }
                 /*
                  * цикл продолжается до тех пор, пока
                  * потомок не завершит работу; wait_val != 1407
                  * младший байт = 0177L и старший = 05 (SIGTRAP)
                  */
         }
         printf("Количество машинных инструкций : %lld\n", counter);
         return 0;
 }
 
 
 

Скопируйте текст программы в текстовый редактор, сохраните ее в файл file.c и дайте команды на выполнение:

cc file.c

./a.out

В результате работы программы, на экран будут выведены содержимое текущего каталога и количество затраченных машинных инструкций. Теперь попробуйте перейти в другой каталог и запустить программу оттуда. Сравните полученные результаты. (Обратите внимание, если у вас медленная машина, то вывод может занять довольно продолжительное время). (На P4 1.7 ГГц на это ушло около 7 секунд. Прим.ред.)

4. Заключение

Ptrace() -- это средство отладки программ. Он может использоваться и для трассировки системных вызовов. Родительский процесс может начать трассировку, вызвав сначала функцию fork(2), для запуска дочернего процесса, а затем дочерний процесс может выполнить PTRACE_TRACEME, за которым (как правило) следует выполнение exec(3) (в примере выше -- это программа "ls"). Затем, после выполнения каждой инструкции, родитель может просматривать значения регистров потомка, данные в памяти и влиять на протекание процесса исполнения. В следующей части статьи я приведу пример программы, которая использует различные особенности ptrace().

Трассировка процессов с помощью Ptrace -- Часть 2


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

Обратите внимание: Пусть вас не смущает такое вступление. Эта часть статьи, вне всякого сомнения, рассказывает о ptrace, а не об ELF. Но знание формата ELF определенно необходимо, чтобы уметь обращаться к памяти трассируемого процесса. Итак, приступим.

1. Что такое ELF?

ELF -- это Executable and Linking Format (Формат Исполняемых и Связываемых файлов). Он определяет формат двоичных исполняемых файлов, объектных файлов, разделяемых объектов (библиотек), а так же файлов core dump. Формат ELF используется как компоновщиками (linkers), так и загрузчиками программ, хотя каждый из них интерпретирует ELF-файлы по-своему.

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

2. Заголовки ELF

Любой ELF-файл имеет ELF-заголовок. Заголовок всегда размещается в самом начале файла. Он содержит описание двоичного файла, определяя таким образом порядок интерпретации файла.

Структура заголовка приведена ниже (см. /usr/src/include/linux/elf.h) (путь меняется в зависимости от дистрибутива -- прим.ред.)


#define EI_NIDENT       16
 
 typedef struct elf32_hdr{
   unsigned char e_ident[EI_NIDENT];
   Elf32_Half    e_type;
   Elf32_Half    e_machine;
   Elf32_Word    e_version;
   Elf32_Addr    e_entry;  /* Точка входа */
   Elf32_Off     e_phoff;
   Elf32_Off     e_shoff;
   Elf32_Word    e_flags;
   Elf32_Half    e_ehsize;
   Elf32_Half    e_phentsize;
   Elf32_Half    e_phnum;
   Elf32_Half    e_shentsize;
   Elf32_Half    e_shnum;
   Elf32_Half    e_shstrndx;
 } Elf32_Ehdr;
 
 

Кратко опишу поля структуры

  1. e_ident : Сигнатура и прочая информация. Зависит от аппаратной платформы.

  2. e_type : Содержит информацию о типе файла. Тип может быть одним из следующих: "объектный", "исполняемый", "разделяемый" (shared object) и "core".

  3. e_machine : Вы наверняка уже догадались, что это поле определяет аппаратную архитектуру -- Intel 386, Alpha, Sparc и т.п.

  4. e_version : Версия объектного файла.

  5. e_phoff : Смещение до первого программного заголовка.

  6. e_shoff : Смещение до первого заголовка секции.

  7. e_flags : Флаги процессора. Не используется для i386

  8. e_ehsize : Размер ELF-заголовка в байтах.

  9. e_phentsize & e_shentsize : Размер программного заголовка и заголовка секции, в таблицах программных заголовков и заголовков секций соответственно.

  10. e_phnum & e_shnum : Количество программных заголовков и заголовков секций в соответствующих таблицах.

  11. e_shstrndx : В таблице заголовков секций есть секция, которая содержит имена других секций. Это индекс такой секции в таблице. (см. ниже)

3. Секции и Сегменты

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

3.1 Секции и заголовки секций

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

Таблица заголовков секций представляет из себя массив заголовков. Нулевой элемент массива всегда пуст и не соответствует ни одной из секций. Каждый заголовок секции имеет следующий формат (см. /usr/src/include/linux/elf.h):


typedef struct elf32_shdr {
   Elf32_Word sh_name;           /* Имя секции, индекс в таблице строк (Elf32) */
   Elf32_Word sh_type;           /* Тип секции (Elf32) */
   Elf32_Word sh_flags;          /* Различные атрибуты секции */
   Elf32_Addr sh_addr;           /* Виртуальный адрес секции */
   Elf32_Off sh_offset;          /* Смещение от начала файла */
   Elf32_Word sh_size;           /* Размер секции в байтах */
   Elf32_Word sh_link;           /* Индекс следующей секции (Elf32) */
   Elf32_Word sh_info;           /* Дополнительные сведения о секции (Elf32) */
   Elf32_Word sh_addralign;      /* Выравнивание секции */
   Elf32_Word sh_entsize;        /* Размер записи в таблице */
 } Elf32_Shdr;
 
 

Теперь о полях структуры более подробно.

  1. sh_name : Индекс строки в секции, содержащей таблицу строк e_shstrndx. Указывает на начало строки, завершающейся нулевым (0x00) символом, которая используется в качестве имени секции.

    • .text -- Эта секция содержит инструкции, исполняемые процессором
    • .data -- В этой секции находятся инициализированные данные программы.
    • .init -- Эта секция содержит инструкции, исполняемые процессором при запуске программы.
  2. sh_type : Тип секции, например, данные, таблица символов, таблица строк и т.п..

  3. sh_flags : Содержит вспомогательную информацию, определяющую порядок интерпретации содержимого секции.

  4. sh_addralign : Содержит размер выравнивания для секции, обычно 0, 1 (оба означают отсутствие выравнивания) или 4.

Смысл назначения остальных полей структуры достаточно прозрачно следует из их названий.

3.2 Сегменты и программные заголовки.

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


typedef struct
 {
   Elf32_Word    p_type;                 /* Тип сегмента */
   Elf32_Off     p_offset;               /* Смещение от начала файла */
   Elf32_Addr    p_vaddr;                /* Виртуальный адрес сегмента */
   Elf32_Addr    p_paddr;                /* Физический адрес сегмента */
   Elf32_Word    p_filesz;               /* Размер сегмента в файле */
   Elf32_Word    p_memsz;                /* Размер сегмента в памяти */
   Elf32_Word    p_flags;                /* Флаги сегмента */
   Elf32_Word    p_align;                /* Выравнивание сегмента */
 } Elf32_Phdr;
 
 

  1. p_type : Определяет тип сегмента, т.е. задает порядок его интерпретации, например:

    • неиспользуемый (unused)
    • загружаемый (loadable)
    • Информация для динамического связывания
    • зарезервировано (reserved)

    и т.п..

  2. p_vaddr : относительный виртуальный адрес загрузки сегмента.

  3. p_paddr : физический адрес загрузки сегмента.

  4. p_flags : Содержит флаги прав доступа -- чтение/запись/исполнение

  5. p_align : Выравнивание сегмента в памяти. Если сегмент имеет тип "загружаемый" (loadable), то он выравнивается по границе страницы памяти.

Смысл назначения остальных полей структуры понятен из их названий.

4. Загрузка ELF-файла

Мы уже имеем представление о структуре ELF-файла. Теперь перейдем к рассмотрению порядка загрузки файла. Обычно, для того чтобы запустить программу мы набираем ее имя в командной строке. На самом деле, после того как мы нажмем на клавишу RETURN (или, если хотите -- ENTER), происходит масса интересных вещей.

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

Программа загружается по адресу 0x08048000 (см. /proc/pid/maps), а стек начинается с адреса 0xBFFFFFFF (стек "растет" в сторону меньших адресов).

5. Внедрение кода

Теперь, когда процесс загружен в память и нам известно его адресное пространство, мы можем выполнять трассировку этого процесса (при наличии прав доступа) и просматривать/изменять данные в памяти процесса. Однако сказать легко, сделать -- сложнее. Тем не менее, почему бы не попробовать?

Прежде всего, давайте попробуем создать программу, которая могла бы читать/писать в регистры процессора другой программы. Для аргумента request мы будем использовать следующие значения.

  • PTRACE_ATTACH : Начать трассировку существующего процесса с заданным pid.
  • PTRACE_DETACH : Завершить трассировку процесса с заданным pid.

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

  • PTRACE_GETREGS : Скопировать содержимое регистров процессора в структуру, адрес которой передается в аргументе data (аргумент addr игнорируется). Эта структура (struct user_regs_struct) определена в файле asm/user.h.

    struct user_regs_struct {
                     long ebx, ecx, edx, esi, edi, ebp, eax;
                     unsigned short ds, __ds, es, __es;
                     unsigned short fs, __fs, gs, __gs;
                     long orig_eax, eip;
                     unsigned short cs, __cs;
                     long eflags, esp;
                     unsigned short ss, __ss;
             };
     
     

  • PTRACE_SETREGS : Скопировать данные из структуры, адрес которой передается в аргументе data, в регистры процессора.
  • PTRACE_POKETEXT : Скопировать 32-битное слово из адреса, который передается в аргументе data, в область памяти трассируемого процесса, адресуемой аргументом addr.

Теперь вставим некоторый код в тело трассируемого процесса и заставим процесс исполнить его, изменив содержимое регистра eip (instruction pointer).

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

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

Файлы с исходными текстами

  • Tracer.c
  •  #include < stdio.h>
     #include < sys/ptrace.h>
     #include < linux/user.h>
     #include < signal.h>
     #include < stdlib.h>
     #include < unistd.h>
     #include < string.h>
     #include < sys/types.h>
     #include < sys/wait.h>
     
     void injected_shellcode();
     char *shellcode;
     char *mesg =
         "\x31\xc0\xb0\x04\xeb\x0f\x31\xdb\x43\x59"
         "\x31\xd2\xb2\x0d\xcd\x80\xa1\x78\x56\x34"
         "\x12\xe8\xec\xff\xff\xff\x09\xf7\xcf"
         "\x21\x20\xf0\xcf\xca\xcd\xc1"
         "\xcc\xc9\x21\x09\x0a";
     
     int Tracer(pid_t pid)
     {
     	int 	error, ptr, begin, i = 0;
     	struct 	user_regs_struct data;
     
     	if ((error = ptrace(PTRACE_ATTACH, pid, NULL, NULL))){
     		perror("Attach");
     		exit(1);
     	}
     	waitpid(pid, NULL, 0);
     
     	if ((error = ptrace(PTRACE_GETREGS, pid, NULL, &data)))
     		perror("Getregs");
     	printf("%%eip : 0x%.8lx\n", data.eip);
     	printf("%%esp : 0x%.8lx\n", data.esp);
     
     	ptr = begin = data.esp - 512;
     
     	printf("лПД ЧУФБЧМЕО РП БДТЕУХ %.8lx\n", (long)begin);
     	data.eip = (long) begin;
     
     	ptrace(PTRACE_SETREGS, pid, NULL, &data);
     
     	while (i < strlen(shellcode)) {
     		ptrace(PTRACE_POKETEXT, pid, ptr,
     		       (int) *(int *) (shellcode + i));
     		i += 4;
     		ptr += 4;
     	}
     	ptrace(PTRACE_DETACH, pid, NULL, NULL);
     
     	return 0;
     }
     
     int main(int argc, char **argv)
     {
     	pid_t pid;					/* Process Id        */
     
     	if (argc < 2)
     		return puts("рПТСДПЛ ЙУРПМШЪПЧБОЙС: ./catch pid");
     
     	pid = atoi(argv[1]);
     
     	shellcode = malloc(strlen((char *) injected_shellcode) +
     			   strlen(mesg) + 4);
     	strcpy(shellcode, (char *) injected_shellcode);
     	strcat(shellcode, (char *) mesg);
     
     printf("чОЙНБОЙЕ : РПРЩФЛБ ЪБРХУФЙФШ ЛПД, ЧУФБЧМЕООЩК Ч РТПГЕУУ %d\n", pid);
     
     	sleep(1);
     	Tracer(pid);
     	usleep(1);
     	kill(pid, 9);
     	wait(NULL);
     	return 0;
     }
     
     
         
  • Code.S
  • .globl injected_shellcode
     injected_shellcode:
     	nop
     	nop
     	pusha
     	xor %eax,%eax
     	mov $0x02,%al
     	int $0x80
     	xor %ebx,%ebx
     	cmp %eax,%ebx
     	je  son
     
     father:
     	popa
     	ret
     
     son:
     	.string ""
     
  • Sample.c
  •  #include < stdio.h>
     char str[] = "с -  ";
     int main()
     {
     	int i = 0;
     	while(1){
     		write(1, &str[i++%18], 1);
     		usleep(200);
     	}
     }
     

Скомпилируем файлы.


#cc Sample.c -o loop
 #cc Tracer.c Code.S -o catch
 
 

Перейдите в другую консоль и запустите программу loop:


#./loop
 
 

Вернитесь обратно и запустите трассировщик:


#./catch `ps ax | grep "loop" | cut -f 2 -d ' '`
 
 

Теперь вернитесь в консоль, в которой была запущена программа 'loop' и увидите что произошло! Итак! Ваши игры с ptrace начались!

От переводчика: В программу Tracer.c мною были внесены изменения. В оригинальном варианте программа loop при исполнении внедренного кода выводила сообщение "Oh, Caught!". Я взял на себя смелость заменить его текстом "Во! Поймали!". Однако текст "зашит" в кодировке koi8-r, поэтому, если у вас локаль настроена на иную кодировку, то вы увидите это сообщение в искаженном виде. Оригинальный вариант файла Tracer.c находится здесь .

6. Забегая вперед

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



Трассировка процессов с помощью Ptrace -- Часть 3

В первой части мы с вами рассмотрели основы работы с ptrace. Во второй части был приведен пример небольшой программы, которая изменяла содержимое регистров процессора в другом приложении, заставляя его выполнить внедренный код. На этот раз мы будем учиться обращаться к памяти трассируемого процесса. Цель данной части состоит в том, чтобы продемонстрировать методику доступа к идентификаторам процесса во время исполнения. Область применения этой методики настолько широка, что ограничивается лишь вашей фантазией.


1. Введение.

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

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

Объявление структуры link_map (см. /usr/include/link.h) выглядит так:

struct link_map
   {
     ElfW(Addr) l_addr;      /* Базовый адрес загруженного объекта.  */
     char *l_name;           /* Полное имя файла объекта.  */
     ElfW(Dyn) *l_ld;        /* Динамическая секция разделяемого объекта.  */
     struct link_map *l_next, *l_prev; /* Ссылки на загруженные объекты.  */
   };
 
 
 

Краткое описание полей структуры.

  • l_addr: Базовый адрес памяти, куда был загружен объект. Это значение можно найти в /proc/<pid>/maps
  • l_name: указатель на имя библиотеки в таблице строк
  • l_ld :указатель на динамическую (DT_*) секцию разделяемой библиотеки
  • l_next: указатель на следующий элемент списка
  • l_prev: указатель на предыдущий элемент списка

Link-map -- это двусвязный список, каждый элемент которого имеет ссылку на загруженную библиотеку. Все что нам нужно -- это пройти по списку и отыскать требуемый идентификатор. Теперь мы подошли к вопросу: "И где же взять этот link_map?"

Для каждого объектного файла создается Глобальная Таблица Смещений (global offset table -- GOT). Второй элемент этой таблицы как раз и отвечает за link_map. Так что нам остается лишь забрать адрес link_map из GOT[1] и найти искомый идентификатор.

2. Пример кода.

Теперь мы владеем основными сведениями, которые нам понадобятся, можно начинать. Прежде всего начнем трассировку процесса 'pid', а затем отыщем link_map. В файле с исходным текстом примера вы найдете ряд вспомогательных функций, таких как read_data, read_str и пр., которые значительно облегчают жизнь программиста при работе с ptrace. Назначение функций очевидно из их названий.

Функция для поиска link_map :

struct link_map *locate_linkmap(int pid)
 {
     Elf32_Ehdr *ehdr = malloc(sizeof(Elf32_Ehdr));
     Elf32_Phdr *phdr = malloc(sizeof(Elf32_Phdr));
     Elf32_Dyn *dyn = malloc(sizeof(Elf32_Dyn));
     Elf32_Word got;
     struct link_map *l = malloc(sizeof(struct link_map));
     unsigned long phdr_addr, dyn_addr, map_addr;
 
     read_data(pid, 0x08048000, ehdr, sizeof(Elf32_Ehdr));
     phdr_addr = 0x08048000 + ehdr->e_phoff;
     printf("program header at %p\n", phdr_addr);
     read_data(pid, phdr_addr, phdr, sizeof(Elf32_Phdr));
 
     while (phdr->p_type != PT_DYNAMIC) {
         read_data(pid, phdr_addr += sizeof(Elf32_Phdr), phdr,
                              sizeof(Elf32_Phdr));
     }
 
     read_data(pid, phdr->p_vaddr, dyn, sizeof(Elf32_Dyn));
     dyn_addr = phdr->p_vaddr;
 
     while (dyn->d_tag != DT_PLTGOT) {
         read_data(pid, dyn_addr += sizeof(Elf32_Dyn), dyn, sizeof(Elf32_Dyn));
     }
 
     got = (Elf32_Word) dyn->d_un.d_ptr;
     got += 4;           /* помните? второй элемент таблицы GOT. */
 
     read_data(pid, (unsigned long) got, &map_addr, 4);
     read_data(pid, map_addr, l, sizeof(struct link_map));
     free(phdr);
     free(ehdr);
     free(dyn);
     return l;
 }
 
 

Поиск начинается с адреса 0x08048000, где размещен ELF-заголовок трассируемого процесса. По содержимому ELF-заголовка определяется местоположение программного заголовка. (Структура заголовков обсуждалась во второй части статьи.) После того как будет получен программный заголовок, выполняется поиск заголовка с информацией о динамическом связывании. Затем по полученной информации выполняется поиск базового адреса глобальной таблицы смещений.

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

Мы получили в свое распоряжение struct link_map, а теперь надо получить таблицу символов (symtab) и таблицу строк (strtab). Для этого обратимся к полю l_ld структуры link_map и пройдемся по набору динамических секций, пока не обнаружим секции DT_SYMTAB и DT_STRTAB, в этих секциях мы как раз и попытаемся обнаружить искомые идентификаторы.

Функция поиска таблицы символов и таблицы строк:

void resolv_tables(int pid, struct link_map *map)
 {
     Elf32_Dyn *dyn = malloc(sizeof(Elf32_Dyn));
     unsigned long addr;
     addr = (unsigned long) map->l_ld;
     read_data(pid, addr, dyn, sizeof(Elf32_Dyn));
     while (dyn->d_tag) {
         switch (dyn->d_tag) {
         case DT_HASH:
             read_data(pid, dyn->d_un.d_ptr + map->l_addr + 4, 
                        &nchains, sizeof(nchains));
             break;
         case DT_STRTAB:
             strtab = dyn->d_un.d_ptr;
             break;
         case DT_SYMTAB:
             symtab = dyn->d_un.d_ptr;
             break;
         default:
             break;
         }
         addr += sizeof(Elf32_Dyn);
         read_data(pid, addr, dyn, sizeof(Elf32_Dyn));
     }
     free(dyn);
 }
 
 
 

Эта функция проходит по динамическим секциям, проверяя каждую -- не содержит ли она признак DT_STRTAB или DT_SYMTAB. Если секция имеет один из этих признаков, то адрес таблицы запоминается в соответствующем указателе strtab или symtab. Цикл завершается после того, как будут просмотрены все динамические секции.

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

Теперь мы получили значение идентификатора, что собственно и требовалось. Как его можно использовать? Ответ на этот вопрос зависит уже от вас, уважаемый читатель. Как и все в этом мире, его можно использовать как во благо так и во вред.

У вас может сложиться впечатление, что на этом мы закончили -- но это не так. Мы забыли выполнить еще один обязательный шаг -- "отпустить" приложение, т.е. закончить трассировку. Если этого не сделать, то трассируемое приложение останется в состоянии останова "на веки вечные", последствия этого мы частично рассматривали в части I. Так что прежде чем завершить работу мы завершаем трассировку.

Пример программы вы найдете в файле Ptrace.c

Соберите программу командой

#cc Ptrace.c -o symtrace
 
 

А теперь протестируем ее. Запустите какое либо приложение (в другом терминале) и подайте следующую команду. (Здесь я хочу уточнить, что в качестве "подопытного" приложения я запускал emacs и искал идентификатор strcpy). Вы можете выбрать для экспериментов любое другое приложение и попытаться отыскать в нем любой другой идентификатор.

#./symtrace `ps ax | grep 'emacs' | cut -f 2 -d " "` strcpy
 
 

От переводчика: на моей системе этот вариант команды работает некорректно. Я использовал следующую команду:

#./symtrace `ps -e | grep 'emacs' | cut -f 1 -d " "` strcpy

От редактора: объясняется это тем, что ключ '-e' команды ps выводит список процессов без параметров, с которыми они были вызваны, что снижает (по крайней мере, исключает сам grep из результатов), но не исключает вероятность дублирования информации (слегка утрируя):

#ps -e | grep 'log'
 942 ?        00:00:00 syslogd
 960 ?        00:00:00 klogd
 

3. Заключение.

Итак, мы подошли к концу последней части статьи, посвященной основам работы с ptrace. Как только вы окончательно поймете основную концепцию, то для вас не составит труда двинуться дальше. Более подробные сведения о ptrace и об ELF вы найдете на www.phrack.org. Еще я хотел бы заметить, что мы подошли к концу последней части ни разу не упомянув об одной важной особенности ptrace -- взаимодействии с системными вызовами. В User Mode Linux эта особенность используется очень широко. Сейчас я занят своей учебой и работой над курсовым проектом, но обещаю, что как только позволит время я вернусь к этой теме и мы продолжим рассмотрение особенностей ptrace.

Copyright (C) 2002, Sandeep S. Copying license http://www.linuxgazette.com/copying.html
Published in Issue 85 of Linux Gazette, December 2002

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

Ваше имя:
Комментарий:
Оба поля являются обязательными

 Автор  Комментарий к данной статье