В этой статье я расскажу о том, что происходит внутри ядра 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, и… вуаля… они пропадают из списка процессов.

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


  1. naquad
    14.09.2025 10:24

    s/id.so/ld.so/


  1. AnthonyDS
    14.09.2025 10:24

    Интересно бы прочитать про сравнение структуры программ под Windows/Linux/Mac


    1. gudvinr
      14.09.2025 10:24

      1. AnthonyDS
        14.09.2025 10:24

        Вот что дала нейронка.

        Рассмотрим простой пример Hello World-программы на ассемблере для Linux и Windows, чтобы наглядно показать разницу в синтаксисе и способах взаимодействия с системой.

        Код на Assembler для Linux (ELF):

        section .data
            msg db 'Hello, Linux!', 0xa   ; Строка приветствия с символом новой строки
            len equ $-msg                 ; Длина строки
        
        section .text
        global _start                    ; Точка входа
        
        _start:
            mov eax, 4                   ; Системный вызов write()
            mov ebx, 1                   ; Стандартный вывод (stdout)
            mov ecx, msg                 ; Адрес начала строки
            mov edx, len                 ; Длина строки
            int 0x80                     ; Выполнение системного вызова
        
            mov eax, 1                   ; Системный вызов exit()
            xor ebx, ebx                 ; Выход с кодом 0
            int 0x80                     ; Завершение процесса
        

        Код на Assembler для Windows (PE):

        .model flat, stdcall           ; Используем плоскую модель памяти и соглашение stdcall
        option casemap:none          ; Отключаем автоматическое преобразование регистров
        
        include windows.inc            ; Включаем стандартные макросы и константы
        include kernel32.inc
        includefile kernel32.lib      ; Импортируем необходимые библиотеки
        
        .data
            Msg db 'Hello, Windows!', 0     ; Строка приветствия с нулевым байтом окончания
        
        .code
        main proc
            invoke MessageBoxA, NULL, addr Msg, NULL, MB_OK       ; Вывод окна с сообщением
            invoke ExitProcess, 0                                 ; Завершаем процесс
        main endp
        
        end main                      ; Конец программы
        

        Основные различия:

        1. Формат системных вызовов:

          • В Linux используются номера системных вызовов и прямой доступ к ядру через инструкцию int 0x80.

          • В Windows применяется библиотека kernel32.dll, которая обеспечивает интерфейс к функциональности ОС посредством функций вроде MessageBoxA() и ExitProcess().

        2. Обработка строк:

          • В Linux сообщение оканчивается символом новой строки (\n или 0xa), тогда как в Windows принято использование завершающего нуля (NULL или 0).

        3. Разделители блоков:

          • В Linux традиционно применяются директивы типа section, global, тогда как в Windows часто используют конструкции вида .model, .code, .data.

        4. Библиотечные зависимости:

          • Для Linux характерно прямое взаимодействие с системными функциями, в то время как в Windows большинство операций реализуются через импортируемые библиотеки (kernel32.dll и др.).

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