В этой статье я расскажу о том, что происходит внутри ядра Linux, когда процесс вызывает execve(), как ядро подготавливает стек и как затем передаёт управление пользовательскому процессу для выполнения.

Изучил я этот механизм, когда разрабатывал Zapper — инструмент Linux, удаляющий все параметры командной строки из любого процесса без прав root.

Основные этапы

  1. Ядро получает от пользовательской программы команду SYS_execve(), после чего…

  2. Считывает исполняемый файл (конкретные разделы) в определённые области памяти.

  3. Подготавливает стек, кучу, сигналы и прочее.

  4. Передаёт выполнение пользовательской программе.

Анализ исполняемого файла

Начнём с простой программы на C:

int main(int argc, char *argv[0]) {
        return 0;
}

Скомпилируем её с помощью gcc -static -o none none.c и выясним кое-какие детали:

$ readelf -h none
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - GNU
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x4014f0
  Start of program headers:          64 (bytes into file)
  Start of section headers:          760112 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         10
  Size of section headers:           64 (bytes)
  Number of section headers:         30
  Section header string table index: 29

Первые инструкции начинаются в «точке входа» по адресу 0x4014f0. Эти инструкции были созданы компилятором (gccgo и так далее). У каждого компилятора они свои.

Теперь загрузим бинарник в gdb и дизассемблируем эти инструкции командой disass 0x4014f0. Их задача — выполнить необходимую подготовку, после чего вызвать main() (или аналогичную функцию Go).

Установим брейкпоинт в точке входа (0x4014f0) и запустим приложение с двумя аргументами командной строки — firstarg и secondarg):

gdb ./none
pwndbg> disass 0x4014f0
pwndbg> br *0x4014f0
pwndbg> r firstarg secondarg
 ► 0x4014f0 <_start>       xor    ebp, ebp
   0x4014f2 <_start+2>     mov    r9, rdx
   0x4014f5 <_start+5>     pop    rsi
[...]
──────────────────────[ STACK ]──────────────────────
00:0000│ rsp 0x7ffca4229540 ◂— 0x3
01:0008│     0x7ffca4229548 —▸ 0x7ffca422a4b3 ◂— '/sec/root/none'
02:0010│     0x7ffca4229550 —▸ 0x7ffca422a4c2 ◂— 'firstarg'
03:0018│     0x7ffca4229558 —▸ 0x7ffca422a4cb ◂— 'secondarg'
04:0020│     0x7ffca4229560 ◂— 0x0
05:0028│     0x7ffca4229568 —▸ 0x7ffca422a4d5 ◂— 'BASH_ENV=/etc/shellrc'
[...]

(Если вы используете gdb без pwngdb, то вам может потребоваться выполнить x/64a $rsp для вывода первых 64 записей стека).

Указатель стека rsp находится в 0x7ffd4f48bd10. Теперь выполним grep -F '[stack]' /proc/$(pidof none)/maps, чтобы найти конец стека:

7ffd4f46c000-7ffd4f48d000 rw-p 00000000 00:00 0         [stack]

Ядро выделило под стек область памяти от адреса 0x7ffd4f46c000 до 0x7ffd4f48d000 — всего 132 КБ. Он будет расти динамически вплоть до 8 МБ (ulimit -s килобайт). Наша программа (пока смотрим регистр rsp) использует под стек только область от адреса rsp (то есть 0x7ffd4f48bd10) до того же конца 0x7ffd4f48d000 — всего 4 848 байт (echo $((0x7ffd4f48d000 - 0x7ffd4f48bd10)) == 4848).

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

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

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

Давайте сделаем дамп его содержимого:

pwndbg> dump binary memory stack.dat $rsp 0x7ffd4f48d000

Затем загрузим это содержимое в hd <stack.dat или xxd <stac.dat и просмотрим…

 03 00 00 00 00 00 00 00  b3 a4 22 a4 fc 7f 00 00  |..........".....|
 c2 a4 22 a4 fc 7f 00 00  cb a4 22 a4 fc 7f 00 00  |..".......".....|
 00 00 00 00 00 00 00 00  d5 a4 22 a4 fc 7f 00 00  |..........".....|
 eb a4 22 a4 fc 7f 00 00  11 a5 22 a4 fc 7f 00 00  |..".......".....|
 25 a5 22 a4 fc 7f 00 00  30 a5 22 a4 fc 7f 00 00  |%.".....0.".....|
[...]
 b4 5c 18 e0 ed f9 fb 0d  30 78 38 36 5f 36 34 00  |.\......0x86_64.|
 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
 00 00 00 2f 73 65 63 2f  72 6f 6f 74 2f 6e 6f 6e  |.../sec/root/non|
 65 00 66 69 72 73 74 61  72 67 00 73 65 63 6f 6e  |e.firstarg.secon|
 64 61 72 67 00 42 41 53  48 5f 45 4e 56 3d 2f 65  |darg.BASH_ENV=/e|
 74 63 2f 73 68 65 6c 6c  72 63 00 43 48 45 41 54  |tc/shellrc.CHEAT|
 5f 43 4f 4e 46 49 47 5f  50 41 54 48 3d 2f 65 74  |_CONFIG_PATH=/et|
[...]
 55 4d 4e 53 3d 31 31 38  00 2f 73 65 63 2f 72 6f  |UMNS=118./sec/ro|
 6f 74 2f 6e 6f 6e 65 00  00 00 00 00 00 00 00 00  |ot/none.........|

Куча указателей, строк, неизвестных.

Давайте проследим путь от вызова execve() до точки входа в программу.

Функция execve() вызывает ядро через системный вызов, который следом вызывает do_execve():

Завершается этот вызов в do_execveat_common(). Создаётся структура bprm, в которой прописывается всевозможная информация о программе (см. binfmts.h).

Интересующие нас имя файла программы, переменные среды и аргументы (argv) копируются из памяти ядра в стек процесса. Стек растёт в сторону уменьшения адресов, то есть первый помещённый в него элемент (имя файла программы bprm->filename) оказывается по самому старшему адресу стека (внизу) и над ним по уже меньшим адресам друг за другом идут envp и argv.

Теперь переходим к bprm_execve(), где перед вызовом exec_binprm() выполняется несколько проверок. Далее следуем в search_binary_handler(), где ядро проверяет, что перед ним — файл ELF, скрипт с шебангом (#!) или какой-то другой тип файла, зарегистрированный через модуль binfmt-misc. Выяснив это, оно вызывает подходящую функцию для его загрузки.

В этом случае у нас файл ELF, поэтому вызывается load_elf_binary(). Ядро выделяет память и отображает в неё разделы исполняемого файла. Оно вызывает begin_new_exec(), чтобы установить все учётные данные и разрешения для нового процесса.

Затем ядро проверяет, нужно ли загружать этот ELF с помощью интерпретатора (id.so):

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

В завершение вызывается create_elf_tables(). Именно здесь происходит вся интересующая нас магия стека.

Сначала функция arch_align_stack() добавляет в стек случайное количество нулей (выполняет рандомизацию), чтобы затруднить работу (некоторых) эксплойтов, направленных на переполнение буфера. Затем она выравнивает стек по границе 16 байт (устанавливает указатель на следующий меньший адрес, выровненный по 16 байтам с помощью & ~0xf).

Далее ядро помещает в стек x86_64\0 и добавляет поверх него 16 байт случайных данных (которые libc использует в PRNG в качестве начального значения):

После ядро создаёт вспомогательный вектор ELF — коллекцию пар (id, значение), несущих полезную информацию о выполняемой программе и её среде — который передаётся в пространство пользователя.

Их список заканчивается нулевым идентификатором и нулевым значением (то есть 16 байт 0х00). Всего в нём получается около 320 записей (320 байт).

Начинается вектор с макроса ARCH_DLINFO (который также включает AT_SYSINFO_EHDR и AT_MINSIGSTKSZ).

Эти «идентификаторы» определены в auxvec.h:

В gdb вспомогательный вектор ELF из стека нашей программы выглядит так (заметьте, что значения «идентификаторов» выше указаны в десятичной форме, а в gdb они представлены в шестнадцатеричной):

[... выше идут argc ...]
[... выше идут argvp ...]
[... выше идут envp ...]
0x7ffca42296a8: 0x21    0x7ffca4351000    <-- AT_SYSINFO_EDHR
0x7ffca42296b8: 0x33    0xd30             <-- AT_MINSIGSTKSZ
0x7ffca42296c8: 0x10    0x178bfbff        <-- AT_HWCAP
0x7ffca42296d8: 0x6     0x1000            <-- AT_PAGESZ
0x7ffca42296e8: 0x11    0x64
0x7ffca42296f8: 0x3     0x400040
0x7ffca4229708: 0x4     0x38
0x7ffca4229718: 0x5     0xa
0x7ffca4229728: 0x7     0x0
0x7ffca4229738: 0x8     0x0
0x7ffca4229748: 0x9     0x4014f0         <-- Наша точка входа
0x7ffca4229758: 0xb     0x0
0x7ffca4229768: 0xc     0x0
0x7ffca4229778: 0xd     0x0
0x7ffca4229788: 0xe     0x0
0x7ffca4229798: 0x17    0x0
0x7ffca42297a8: 0x19    0x7ffca42297f9   <-- Указатель на случайные данные
0x7ffca42297b8: 0x1a    0x2
0x7ffca42297c8: 0x1f    0x7ffca422afe9   <-- Указатель на имя файла
0x7ffca42297d8: 0xf     0x7ffca4229809   <-- Указатель на x86_64
0x7ffca42297e8: 0x0     0x0                  <-- NULL + NULL
[... Конец вектора ELF ...]
0x7ffca42297f8: 0xe8e8de3a49831f00      0xdfbf9ede0185cb4 <-- RND16
0x7ffca4229808: 0x34365f363878af        0x0  <-- "x86_64" + '\0'
[... ниже идёт пустое пространство, оставшееся после рандомизации стека ...]
[... ниже идут строки argv ...]
[... ниже идут строки env ...]
[... последним идёт имя файла (/root/none) ...]

Далее ядро выделяет память в стеке для хранения вспомогательного вектора ELF, указателей argv и env, а также значения argc (+1), и выравнивает вершину стека по границе 16 байт. (На этом этапе оно ещё не копирует вектор в стек. Это происходит позже):

...затем ядро помещает в стек указатели argc, argv и env:

...и копирует туда же вспомогательный вектор (elf_info в коде выше), выполняя выравнивание и размещая его под указателями env.

Внимательный читатель наверняка заметил, что RND16 не начинается с выровненного адреса 0x7ffca42297f9. Дело в том, что RND16 был помещён в стек до вызова STACK_ROUND(), отвечающего за выравнивание стека для размещения вектора ELF и указателей env/argv.

Теперь вернёмся в load_elf_binary(), где ядро устанавливает регистры, проводит небольшую чистку и, наконец (!), вызывает START_THREAD() для запуска программы.

Дополнение: мне тут указали на статью https://lwn.net/Articles/631631/. Пожалуй, псевдографика в ней получше моей ?. Там показана схема стека непосредственно перед выполнением (только автор изобразил её наоборот, начав с наибольшего адреса вверху и завершив наименьшим внизу):

Как работает Zapper

Увлекательный у нас вышел ликбез!

В Zapper мы выполняем в точке входа ptrace(), увеличиваем стек, чтобы сделать копию строк argv/env, направляем все указатели на «нашу» копию строк argv/env и обнуляем исходные строки (подставляя 0x00). Ничего об этом не зная, ядро продолжает ссылаться на обнулённые argv/env, и… вуаля… они пропадают из списка процессов.

Комментарии (1)


  1. naquad
    14.09.2025 10:24

    s/id.so/ld.so/