При создании нового процесса почему ядро Linux тратит последние 8 байтов стека?

Вопрос или проблема

Когда системный вызов execve() создает новый процесс, он выделяет новую VMA для стека и копирует предоставленные пользователем argv и envp в новый стек. Он не записывает эти строки прямо с конца стека. Вместо этого он оставляет последние 8 байт последней страницы. В текущей версии ядра (6.13.6) это происходит в функции __bprm_mm_init из fs/exec.c:

static int __bprm_mm_init(struct linux_binprm *bprm)
{
    int err;
    struct vm_area_struct *vma = NULL;
    struct mm_struct *mm = bprm->mm;

    bprm->vma = vma = vm_area_alloc(mm);
    if (!vma)
        return -ENOMEM;
    vma_set_anonymous(vma);

    if (mmap_write_lock_killable(mm)) {
        err = -EINTR;
        goto err_free;
    }

    /*
     * Need to be called with mmap write lock
     * held, to avoid race with ksmd.
     */
    err = ksm_execve(mm);
    if (err)
        goto err_ksm;

    /*
     * Place the stack at the largest stack address the architecture
     * supports. Later, we'll move this to an appropriate place. We don't
     * use STACK_TOP because that can depend on attributes which aren't
     * configured yet.
     */
    BUILD_BUG_ON(VM_STACK_FLAGS & VM_STACK_INCOMPLETE_SETUP);
    vma->vm_end = STACK_TOP_MAX;
    vma->vm_start = vma->vm_end - PAGE_SIZE;
    vm_flags_init(vma, VM_SOFTDIRTY | VM_STACK_FLAGS | VM_STACK_INCOMPLETE_SETUP);
    vma->vm_page_prot = vm_get_page_prot(vma->vm_flags);

    err = insert_vm_struct(mm, vma);
    if (err)
        goto err;

    mm->stack_vm = mm->total_vm = 1;
    mmap_write_unlock(mm);
    bprm->p = vma->vm_end - sizeof(void *); /* ЗДЕСЬ: 8 байт пропускаются */
    return 0;
err:
    ksm_exit(mm);
err_ksm:
    mmap_write_unlock(mm);
err_free:
    bprm->vma = NULL;
    vm_area_free(vma);
    return err;
}

Это поведение можно проследить до самой первой версии Linux. Вот код из Linux 0.01:

/*
 * 'do_execve()' исполняет новую программу.
 */
int do_execve(unsigned long * eip,long tmp,char * filename,
    char ** argv, char ** envp)
{
    struct m_inode * inode;
    struct buffer_head * bh;
    struct exec ex;
    unsigned long page[MAX_ARG_PAGES];
    int i,argc,envc;
    unsigned long p;

    if ((0xffff & eip[1]) != 0x000f)
        panic("execve вызван из режима супервизора");
    for (i=0 ; i<MAX_ARG_PAGES ; i++)   /* очистить таблицу страниц */
        page[i]=0;
    if (!(inode=namei(filename)))       /* получить узел файла исполняемого */
        return -ENOENT;
    if (!S_ISREG(inode->i_mode)) {  /* должен быть обычным файлом */
        iput(inode);
        return -EACCES;
    }
    i = inode->i_mode;
    if (current->uid && current->euid) {
        if (current->euid == inode->i_uid)
            i >>= 6;
        else if (current->egid == inode->i_gid)
            i >>= 3;
    } else if (i & 0111)
        i=1;
    if (!(i & 1)) {
        iput(inode);
        return -ENOEXEC;
    }
    if (!(bh = bread(inode->i_dev,inode->i_zone[0]))) {
        iput(inode);
        return -EACCES;
    }
    ex = *((struct exec *) bh->b_data); /* читать заголовок exec */
    brelse(bh);
    if (N_MAGIC(ex) != ZMAGIC || ex.a_trsize || ex.a_drsize ||
        ex.a_text+ex.a_data+ex.a_bss>0x3000000 ||
        inode->i_size < ex.a_text+ex.a_data+ex.a_syms+N_TXTOFF(ex)) {
        iput(inode);
        return -ENOEXEC;
    }
    if (N_TXTOFF(ex) != BLOCK_SIZE)
        panic("N_TXTOFF != BLOCK_SIZE. См. a.out.h.");
    argc = count(argv);
    envc = count(envp);
    p = copy_strings(envc,envp,page,PAGE_SIZE*MAX_ARG_PAGES-4); /* ЗДЕСЬ: 4 байта пропускаются */
    /* В Linux 1.3.2, константа 4 была изменена на sizeof(void *) */
    p = copy_strings(argc,argv,page,p);
    if (!p) {
        for (i=0 ; i<MAX_ARG_PAGES ; i++)
            free_page(page[i]);
        iput(inode);
        return -1;
    }
/* ОК, это точка невозврата */
    for (i=0 ; i<32 ; i++)
        current->sig_fn[i] = NULL;
    for (i=0 ; i<NR_OPEN ; i++)
        if ((current->close_on_exec>>i)&1)
            sys_close(i);
    current->close_on_exec = 0;
    free_page_tables(get_base(current->ldt[1]),get_limit(0x0f));
    free_page_tables(get_base(current->ldt[2]),get_limit(0x17));
    if (last_task_used_math == current)
        last_task_used_math = NULL;
    current->used_math = 0;
    p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE;
    p = (unsigned long) create_tables((char *)p,argc,envc);
    current->brk = ex.a_bss +
        (current->end_data = ex.a_data +
        (current->end_code = ex.a_text));
    current->start_stack = p & 0xfffff000;
    i = read_area(inode,ex.a_text+ex.a_data);
    iput(inode);
    if (i<0)
        sys_exit(-1);
    i = ex.a_text+ex.a_data;
    while (i&0xfff)
        put_fs_byte(0,(char *) (i++));
    eip[0] = ex.a_entry;        /* eip, магия происходит :-) */
    eip[3] = p;         /* указатель стека */
    return 0;
}

Я экспериментировал с удалением этого пропуска конца стека, и, кажется, это не оказывает никакого эффекта. Известна ли причина, по которой Linux пропускает эти 8 байт?

Ответ или решение

В процессе создания нового процесса в ядре Linux, когда вызывается системный вызов execve(), происходит выделение новой области виртуальной памяти (VMA) для стека и копирование пользовательских параметров командной строки, таких как argv и envp, в новый стек. Однако, обратите внимание на то, что ядро не записывает данные прямо до самого конца последней страницы стека. Она оставляет последние 8 байтов бесплатными. Такое поведение встречается в функции __bprm_mm_init в файле fs/exec.c в текущей версии ядра (6.13.6).

Теория

Основной вопрос, который возник у многих экспертов, заключается в том, почему ядро Linux оставляет последние 8 байтов незаполненными, и какова причина такой реализации. Эта практика восходит к самым первым версиям Linux, что предполагает наличие некой глубокой технической причины для такого решения. Чтобы понять это, необходимо рассмотреть фундаментальные аспекты организации стека и управления памятью в системах на базе архитектуры x86 и x86_64.

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

Пример

В первой версии ядра Linux, в функции do_execve, аналогичное поведение можно было наблюдать, когда последние 4 байта страницы стека оставлялись неиспользованными. Это было изменено в версиях, начиная с Linux 1.3.2, чтобы соответствовать размеру указателя (sizeof(void *)), который стал равен 8 байтам на 64-битных системах. Эта коррекция была уже в соответствии с подходом к выравниванию данных по размеру указателя, что обеспечивает корректное обращение к данным и эффективное использование процессора.

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

Применение

Хотя с первого взгляда кажется, что 8 байтов – это пренебрежимо малая часть памяти, их значимость проявляется в контексте безопасности и стабильности выполнения современных высоконагруженных приложений. Учитывая многочисленные аспекты безопасности, возможно, что предотвращение переполнения стека и обеспечение корректного выполнения программ имеет важное значение.

В современных системах такие архитектурные особенности в управлении памятью и выравнивании стека позволяют ядру Linux сохранять плюсы в гибкости, безопасности и совместимости исполнения программ. Отказ от этого механизма может нарушить работу старого кода, основанного на предпосылках прошлого, или сделать систему более уязвимой к атакам, связанным с переполнением буфера.

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

Оцените материал
Добавить комментарий

Капча загружается...