Вопрос или проблема
Когда системный вызов 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, вероятно, является сочетанием исторических, архитектурных и соображений безопасности, выработанных опытом. Эти байты могут играть роль "защитного барьера", минимизируя риск ошибок доступа к памяти и обеспечивая непрерывность и надежность работы операционной системы.