> [!abstract] Introduction > 유닉스 기반 시스템의 프로세스 생성 방식은 독특합니다. 대부분의 운영체제는 새로운 주소 공간에 새 프로세스를 만들고, 실행 파일을 읽은 다음 그 코드를 실행하는 모든 과정을 하나의 함수 안에서 수행합니다. 반면 유닉스는 이 과정을 [[fork()|`fork()`]]와 `exec()`라는 두 함수로 분리했습니다. 이 글에서는 프로세스 로딩의 핵심이 되는 `exec()` 함수 군을 살펴보고, 리눅스 커널 내부에서 이들이 어떻게 구현되었는지 파헤칩니다. 관련 배경 지식은 [[Process|프로세스]]를 참고하시기 바랍니다. ## `exec()` 함수 군 `exec()`이라는 이름을 가진 함수는 사실 존재하지 않는다. 여기서 말하는 `exec()` 함수란 사실 `execve()`라는 시스템 호출을 중심으로 하는 함수 군을 일컫는 말이다. ```C #include <unistd.h> int execl(const char *pathname, const char *arg0, ... /* (char *)0 */ ); int execv(const char *pathname, char *const argv[]); int execle(const char *pathname, const char *arg0, ... /* (char *)0, char *const envp[] */ ); int execve(const char *pathname, char *const argv[], char *const envp[]); int execlp(const char *filename, const char *arg0, ... /* (char *)0 */ ); int execvp(const char *filename, char *const argv[]); int fexecve(int fd, char *const argv[], char *const envp[]); ``` `exec()` 함수 군은 위와 같이 총 7개의 함수로 구성되어 있으며 이들의 관계는 아래와 같다. ![[relationshipOfTheSevenExecFunction.png]] 사용자는 주어진 상황에 맞게 이 7개의 함수 중 하나를 선택해서 사용한다. 그런데 이 함수들은 도대체 무슨 일을 하는 것일까? ## 로딩 `exec()` 함수 군에 속한 함수들이 무슨 역할을 수행하는지 이해하기 위해서는 로딩*Loading*이라는 개념을 알아야 한다. 로딩이란, 프로세스에 프로그램을 불러오는 과정을 의미한다. 이제 막 생긴 새로운 프로세스는 프로그램이 없어서 아무것도 실행하고 있지 않는데, 여기에 프로그램을 실행하기 위한 코드를 불러와야 한다. 커널에서는 어떤 프로그램을 불러올 때 다른 시스템 호출처럼 핸들러를 호출하고, 디스크에 저장되어 있는 프로그램을 주 메모리(보통 RAM)로 불러오기 위해 로더 프로그램*Loader Program*을 사용한다. ![[loaderMap.png]] 로더 프로그램은 다음과 같은 작업을 수행한다. - 실행 맥락과 실행을 요청한 사용자의 권한을 확인한다. - 메인 메모리로부터 새 프로세스에 대한 메모리를 할당한다. - 실행 파일의 이진 파일 내용을 할당된 메모리로 복사한다. - 이는 대부분 데이터 및 텍스트 세그먼트를 포함한다. - 스택 세그먼트에 메모리 영역을 할당하고 초기 메모리 매핑을 준비한다. - 메인 스레드[^main-thread-definition] 및 스택 메모리 영역이 생성된다. - 커맨드 라인 인수를 메인 스레드의 스택 영역 최상단에 스택 프레임으로 복제한다. - 실행에 필요한 필수 레지스터를 초기화한다. - 프로그램의 진입점에 대한 첫번째 명령어를 실행한다. [^main-thread-definition]: 멀티스레드 프로그램에서 가장 중요한 스레드다. 실행 흐름의 측면에서 가장 처음 실행되고 가장 마지막으로 종료되는 스레드이며, 프로세스의 다른 모든 스레드가 시작되는 기원이다. 리눅스에서 바로 이 로딩을 담당하는 함수가 `exec()` 함수 군이다. 이제 이들이 어떻게 작동하는지 뜯어보자. ## `exec()`의 구현 앞서 살펴봤던 것처럼 `exec()` 함수들은 `execve()` 함수를 중심으로 이루어져 있으며, `execve()`를 제외한 나머지 모든 함수는 `execve()`를 포장한 래퍼 함수*Wrapper Function*이다. 이들의 역할은 새로운 실행파일을 주소 공간에 불러오고 이를 실행하는 것이다. ![[relationshipOfTheSevenExecFunction.png]] 우선 이들이 어떻게 `execve()` 함수를 호출하도록 연결되어 있는지 살펴보자. `exec()` 함수 군의 함수들은 GNU C 라이브러리(glibc)에 의해 제공되며, 이들은 최종적으로 `execve` 시스템 호출을 수행한다.[^glibc-execve] 커널에 진입하면 `do_execveat_common()` 함수가 호출되며, 이 함수가 `exec()` 계열 함수들이 해야 할 일을 처리하는 핵심 함수다. `do_execveat_common()` 함수는 `fs/exec.c`에 다음과 같이 정의되어 있다. ```C static int do_execveat_common(int fd, struct filename *filename, struct user_arg_ptr argv, struct user_arg_ptr envp, int flags) { struct linux_binprm *bprm; int retval; if (IS_ERR(filename)) return PTR_ERR(filename); /* * We move the actual failure in case of RLIMIT_NPROC excess from * set*uid() to execve() because too many poorly written programs * don't check setuid() return code. Here we additionally recheck * whether NPROC limit is still exceeded. */ if ((current->flags & PF_NPROC_EXCEEDED) && is_rlimit_overlimit(current_ucounts(), UCOUNT_RLIMIT_NPROC, rlimit(RLIMIT_NPROC))) { retval = -EAGAIN; goto out_ret; } /* We're below the limit (still or again), so we don't want to make * further execve() calls fail. */ current->flags &= ~PF_NPROC_EXCEEDED; bprm = alloc_bprm(fd, filename, flags); if (IS_ERR(bprm)) { retval = PTR_ERR(bprm); goto out_ret; } retval = count(argv, MAX_ARG_STRINGS); if (retval < 0) goto out_free; bprm->argc = retval; retval = count(envp, MAX_ARG_STRINGS); if (retval < 0) goto out_free; bprm->envc = retval; retval = bprm_stack_limits(bprm); if (retval < 0) goto out_free; retval = copy_string_kernel(bprm->filename, bprm); if (retval < 0) goto out_free; bprm->exec = bprm->p; retval = copy_strings(bprm->envc, envp, bprm); if (retval < 0) goto out_free; retval = copy_strings(bprm->argc, argv, bprm); if (retval < 0) goto out_free; /* * When argv is empty, add an empty string ("") as argv[0] to * ensure confused userspace programs that start processing * from argv[1] won't end up walking envp. See also * bprm_stack_limits(). */ if (bprm->argc == 0) { retval = copy_string_kernel("", bprm); if (retval < 0) goto out_free; bprm->argc = 1; pr_warn_once("process '%s' launched '%s' with NULL argv: empty string added\n", current->comm, bprm->filename); } retval = bprm_execve(bprm); out_free: free_bprm(bprm); out_ret: putname(filename); return retval; } ``` 이 함수는 실행할 파일의 이름, 인자 배열(`argv`), 환경 변수 배열(`envp`), 그리고 각종 플래그(`flags`)를 받아 새로운 프로세스 이미지를 준비한다. 먼저 전달받은 파일 이름이 유효한지 검사하고, 에러라면 즉시 반환한다. 그 다음, 프로세스 수의 제한(`RLIMIT_NPROC`)을 초과했는지 확인하고 만약 제한을 초과했다면 더 이상 `execve()` 호출이 실패하지 않도록 플래그를 초기화하고, 에러를 반환한다. 이후 프로그램 실행에 필요한 정보를 담을 `linux_binprm` 구조체를 할당한 다음, 인자와 환경 변수의 개수를 각각 세고, 정해진 한도를 넘거나 에러가 발생하면 할당된 자원을 해제하고 반환한다. 이때, `bprm_stack_limits()` 함수를 통해 스택 크기 제한도 확인한다. 시스템 콜 진입 시, 커널 스택의 최상단에는 사용자 공간에서의 레지스터 상태를 보존하기 위한 `pt_regs` (트랩 프레임)가 저장된다. 이후 커널은 메모리 디스크립터를 생성하고 새로운 프로세스를 위한 각종 메모리 영역(스택, 힙 등)을 초기화하는 방대한 작업을 수행한다. 이 과정에서 가장 핵심적인 역할을 담당하는 데이터 구조체는 `linux_binprm`이며, 이 구조체는 실행될 파일의 속성, 프로세스 자격 증명, 가상 메모리 매핑 정보, 그리고 프로세스에 전달될 명령줄 인수 및 환경 변수를 총망라하여 관리한다. ### 인자 배열과 환경 변수 배열 그렇다면 인자 배열은 무엇일까? 인자 배열에는 프로세스가 실행할 프로그램의 이름과 함께 프로그램에 전달되어야 하는 변수들이 들어간다. 아래 그림은 인자 배열에 들어가는 값들과 그 순서를 나타내고 있다. 가장 먼저 프로그램의 이름(`ls` : 파일 탐색 프로그램)이 들어가고, 그 다음으로 추가되어야 할 인자(`-lt` : 탐색 결과를 수정한 시간 순서대로 나열, `/user/include` : 탐색할 경로)들이 들어간다. 환경 변수 배열에는 사용자가 직접 전달하는 변수들 외에도 실행 환경을 알맞게 설정하기 위한 변수들이 들어간다. 예를 들어, `PWD=/usr/droh`는 현재 위치한 경로를 의미하며, `USER=droh`는 현재 사용자를 의미한다. ![[argumentListOrganization.png]] 이제 실행 파일 이름을 커널 공간으로 복사하고, 환경 변수와 인자 문자열도 커널로 복사한다. 만약 인자 배열이 비어 있다면, `argv[0]`에 빈 문자열을 추가해 일부 사용자 프로그램이 오동작하지 않도록 보완한다. 준비된 정보를 바탕으로 실제 실행(`bprm_execve`)을 실행하게 되는데, 이 함수는 프로그램을 실행하기에 앞서 `check_unsafe_exec()` 등의 함수를 호출하여 안전한 실행 환경을 만드는 데 집중한다. 그리고 프로그램 실행에 성공했을 때 자원 정리나 프로세스 추적 등 여러가지 뒤처리 과정을 수행한다. ```C static int bprm_execve(struct linux_binprm *bprm) { int retval; retval = prepare_bprm_creds(bprm); if (retval) return retval; /* * Check for unsafe execution states before exec_binprm(), which * will call back into begin_new_exec(), into bprm_creds_from_file(), * where setuid-ness is evaluated. */ check_unsafe_exec(bprm); current->in_execve = 1; sched_mm_cid_before_execve(current); sched_exec(); /* Set the unchanging part of bprm->cred */ retval = security_bprm_creds_for_exec(bprm); if (retval || bprm->is_check) goto out; retval = exec_binprm(bprm); if (retval < 0) goto out; sched_mm_cid_after_execve(current); rseq_execve(current); /* execve succeeded */ current->in_execve = 0; user_events_execve(current); acct_update_integrals(current); task_numa_free(current, false); return retval; out: /* * If past the point of no return ensure the code never * returns to the userspace process. Use an existing fatal * signal if present otherwise terminate the process with * SIGSEGV. */ if (bprm->point_of_no_return && !fatal_signal_pending(current)) force_fatal_sig(SIGSEGV); sched_mm_cid_after_execve(current); rseq_set_notify_resume(current); current->in_execve = 0; return retval; } ``` 여기서 실제로 프로그램을 실행시키는 함수는 `exec_binprm()` 함수다. 이 함수는 아래에 나와 있듯이 `search_binary_handler()` 함수를 불러 파일의 헤더 정보를 바탕으로 해당 바이너리를 파싱하고 메모리에 적재할 수 있는 적절한 핸들러를 찾는다. ```C /* binfmt handlers will call back into begin_new_exec() on success. */ static int exec_binprm(struct linux_binprm *bprm) { pid_t old_pid, old_vpid; int ret, depth; /* Need to fetch pid before load_binary changes it */ old_pid = current->pid; rcu_read_lock(); old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent)); rcu_read_unlock(); /* This allows 4 levels of binfmt rewrites before failing hard. */ for (depth = 0;; depth++) { struct file *exec; if (depth > 5) return -ELOOP; ret = search_binary_handler(bprm); if (ret < 0) return ret; if (!bprm->interpreter) break; exec = bprm->file; bprm->file = bprm->interpreter; bprm->interpreter = NULL; exe_file_allow_write_access(exec); if (unlikely(bprm->have_execfd)) { if (bprm->executable) { fput(exec); return -ENOEXEC; } bprm->executable = exec; } else fput(exec); } audit_bprm(bprm); trace_sched_process_exec(current, old_pid, bprm); ptrace_event(PTRACE_EVENT_EXEC, old_vpid); proc_exec_connector(current); return 0; } ``` 아래에 나와있는 함수가 바로 `search_binary_handler()` 함수다. `search_binary_handler` 함수는 등록된 `linux_binfmt` 구조체 목록을 순회하며, 파일의 헤더 정보를 바탕으로 해당 바이너리를 파싱하고 메모리에 적재할 수 있는 적절한 핸들러를 찾는다. ```C /* * cycle the list of binary formats handler, until one recognizes the image */ static int search_binary_handler(struct linux_binprm *bprm) { struct linux_binfmt *fmt; int retval; retval = prepare_binprm(bprm); if (retval < 0) return retval; retval = security_bprm_check(bprm); if (retval) return retval; read_lock(&binfmt_lock); list_for_each_entry(fmt, &formats, lh) { if (!try_module_get(fmt->module)) continue; read_unlock(&binfmt_lock); retval = fmt->load_binary(bprm); read_lock(&binfmt_lock); put_binfmt(fmt); if (bprm->point_of_no_return || (retval != -ENOEXEC)) { read_unlock(&binfmt_lock); return retval; } } read_unlock(&binfmt_lock); return -ENOEXEC; } ``` 과거에는 `a.out` 포맷 등 다양한 바이너리가 사용되었으나, 현대 리눅스 시스템에서 대다수의 실행 파일, 공유 라이브러리, 커널 모듈, 심지어 커널 이미지 자체(`vmlinux`)까지 모두 ELF(Executable and Linkable Format) 포맷을 채택하고 있다. 따라서 이 순회 과정은 거의 항상 `fs/binfmt_elf.c` 파일에 정의된 `load_elf_binary()` 함수의 호출로 귀결된다. 이 함수에서는 새로운 프로그램을 불러오는, `exec()` 계열 함수에서 가장 핵심이 되는 작업을 수행한다. ### `load_elf_binary()` 다음은 `load_elf_binary()` 함수의 주요 로직을 나타낸 의사코드다. 실제 커널 코드는 500줄이 넘지만, 핵심적인 흐름만 추출하여 정리했다. ```C static int load_elf_binary(struct linux_binprm *bprm) { struct file *interpreter = NULL; unsigned long load_bias = 0, elf_entry, e_entry; struct elf_phdr *elf_phdata; struct elfhdr *elf_ex = (struct elfhdr *)bprm->buf; int retval, i; /* 1. Validate ELF header */ if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0) goto out; /* Not an ELF file */ if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN) goto out; /* Not executable or dynamic */ if (!elf_check_arch(elf_ex)) goto out; /* Wrong architecture */ /* 2. Load program headers */ elf_phdata = load_elf_phdrs(elf_ex, bprm->file); if (!elf_phdata) goto out; /* 3. Find and load interpreter (dynamic linker) */ for (i = 0; i < elf_ex->e_phnum; i++) { if (elf_phdata[i].p_type == PT_INTERP) { /* Read interpreter path (e.g., /lib/ld-linux.so) */ char *elf_interpreter = read_interp_path(elf_phdata[i]); interpreter = open_exec(elf_interpreter); break; } } /* 4. Check for stack execute permission */ int executable_stack = EXSTACK_DEFAULT; for (i = 0; i < elf_ex->e_phnum; i++) { if (elf_phdata[i].p_type == PT_GNU_STACK) { if (elf_phdata[i].p_flags & PF_X) executable_stack = EXSTACK_ENABLE_X; else executable_stack = EXSTACK_DISABLE_X; } } /* ============ POINT OF NO RETURN ============ */ /* After this, the old process memory is destroyed */ retval = begin_new_exec(bprm); /* Flush old process */ if (retval) goto out_free_dentry; setup_new_exec(bprm); /* Setup new process environment */ /* 5. Setup stack for argv, envp, auxv */ retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP), executable_stack); if (retval < 0) goto out_free_dentry; /* 6. Map ELF segments into memory */ unsigned long start_code = ~0UL, end_code = 0; unsigned long start_data = 0, end_data = 0; unsigned long elf_brk = 0; for (i = 0; i < elf_ex->e_phnum; i++) { if (elf_phdata[i].p_type != PT_LOAD) continue; unsigned long vaddr = elf_phdata[i].p_vaddr; /* First LOAD segment - determine load_bias for PIE/ASLR */ if (i == 0 && elf_ex->e_type == ET_DYN) { if (interpreter) { /* PIE binary with interpreter */ load_bias = ELF_ET_DYN_BASE; if (current->flags & PF_RANDOMIZE) load_bias += arch_mmap_rnd(); /* ASLR */ } else { /* Static PIE or shared library */ load_bias = 0; /* Let mmap choose address */ } load_bias = ELF_PAGESTART(load_bias - vaddr); } /* Map the segment */ error = elf_load(bprm->file, load_bias + vaddr, &elf_phdata[i]); if (BAD_ADDR(error)) goto out_free_dentry; /* Track code and data boundaries */ unsigned long k = elf_phdata[i].p_vaddr; if (elf_phdata[i].p_flags & PF_X) { /* Executable */ if (k < start_code) start_code = k; if (k + elf_phdata[i].p_filesz > end_code) end_code = k + elf_phdata[i].p_filesz; } else { /* Data */ if (k < start_data) start_data = k; if (k + elf_phdata[i].p_filesz > end_data) end_data = k + elf_phdata[i].p_filesz; } /* Track heap start (brk) */ k = elf_phdata[i].p_vaddr + elf_phdata[i].p_memsz; if (k > elf_brk) elf_brk = k; } /* Apply load_bias to all addresses */ e_entry = elf_ex->e_entry + load_bias; start_code += load_bias; end_code += load_bias; start_data += load_bias; end_data += load_bias; elf_brk += load_bias; /* 7. Load interpreter if present */ if (interpreter) { elf_entry = load_elf_interp(interpreter); /* Entry point is now the interpreter (ld.so) */ } else { elf_entry = e_entry; /* Direct entry to program */ } /* 8. Create ELF auxiliary vectors on stack */ retval = create_elf_tables(bprm, elf_ex); /* Stack now contains: argc, argv[], envp[], auxv[] */ /* 9. Update process memory map info */ struct mm_struct *mm = current->mm; mm->start_code = start_code; mm->end_code = end_code; mm->start_data = start_data; mm->end_data = end_data; mm->start_brk = mm->brk = ELF_PAGEALIGN(elf_brk); mm->start_stack = bprm->p; /* 10. Start the new program */ struct pt_regs *regs = current_pt_regs(); START_THREAD(elf_ex, regs, elf_entry, bprm->p); /* For ARM64: * - PC = elf_entry (interpreter or program entry) * - SP = bprm->p (stack pointer) * - Return to user mode */ retval = 0; out: return retval; out_free_dentry: /* Cleanup on error */ if (interpreter) fput(interpreter); kfree(elf_phdata); goto out; } ``` 이제 이 함수가 어떤 흐름으로 실행되는지 하나씩 뜯어보자. #### `linux_binrpm` `load_elf_binary()` 함수가 원활하게 동작하기 위해서는 그 기반이 되는 데이터 구조가 사전에 철저히 준비되어야 한다. 커널은 `execve` 호출 직후 실행 파일과 관련된 메타데이터를 캡슐화하기 위해 `linux_binprm` 구조체를 인스턴스화하고 채워 넣는다. 이 구조체는 프로세스 실행에 필요한 모든 컨텍스트를 담고 있으며, `fs/binfmt_elf.c` 내부의 주요 함수들 사이에서 정보 전달의 매개체로 작용한다. ##### 핵심 필드와 그 역할 | **필드명** | **데이터 타입** | **설명 및 커널 내 역할** | | --------------- | ------------------------- | --------------------------------------------------------------------------------- | | `buf` | `char` | 실행 파일의 첫 128바이트를 읽어 임시 저장하는 버퍼. 바이너리 포맷의 매직 넘버를 확인하고 식별하는 데 사용된다. | | `vma` | `struct vm_area_struct *` | 새로운 프로세스의 초기 스택 영역을 나타내는 가상 메모리 영역 구조체. (MMU 활성화 시 존재) | | `mm` | `struct mm_struct *` | 새로운 프로세스의 전체 가상 주소 공간을 기술하는 메모리 디스크립터. | | `file` | `struct file *` | 실행할 대상 바이너리 파일에 대한 파일 구조체 포인터. 디스크립터 연산 및 메모리 매핑(`mmap`)에 참조된다. | | `cred` | `struct cred *` | SUID/SGID 등 권한 상승을 포함하여 프로세스에 새롭게 부여될 자격 증명 구조체. `prepare_exec_creds()`로 초기화된다. | | `argc` / `envc` | `int` | 사용자 공간에서 전달된 명령줄 인수(Arguments) 및 환경 변수(Environment Variables)의 총개수. | | `filename` | `const char *` | 커널 및 사용자(procps, ps 명령어 등)가 인식하는 대상 바이너리의 원래 이름. | | `interp` | `const char *` | 실제로 실행되는 바이너리의 이름. 대부분 `filename`과 동일하지만, 스크립트 실행 등에서 실제 인터프리터 경로를 담기 위해 분리되어 있다. | | `p` | `unsigned long` | 구축 중인 스택의 최상단(Top of memory space)을 가리키는 포인터. 스택이 아래로 자람에 따라 감소한다. | 커널은 실행 초기 단계에서 `bprm_mm_init()` (또는 이와 동등한 내부 초기화 루틴)을 호출하여 `linux_binprm`의 `p` 값을 가상 메모리 공간의 끝부분으로 설정한다. 이후 `install_exec_creds()` 또는 `prepare_exec_creds()` 함수를 통해 호출 프로세스의 자격 증명을 기반으로 새로운 권한 구조체를 생성하여 `cred` 필드에 할당한다. 이 구조체가 완전히 채워지면 `search_binary_handler()` 루틴을 통해 등록된 여러 `linux_binfmt` (예: `binfmt_elf`, `binfmt_flat`, `binfmt_script`) 객체를 순회하며, 바이너리 형식을 지원하는 핸들러의 `load_binary()` 함수 포인터를 실행하게 된다. 이때 발견되는 포맷이 없는 경우 커널은 첫 4바이트의 16진수 값을 기반으로 `binfmt-XXXX` 형태의 동적 커널 모듈 적재를 시도하기도 한다. ```C /* * This structure defines the functions that are used to load the binary formats that * linux accepts. */ struct linux_binfmt { struct list_head lh; struct module *module; int (*load_binary)(struct linux_binprm *); #ifdef CONFIG_COREDUMP int (*core_dump)(struct coredump_params *cprm); unsigned long min_coredump; /* minimal dump size */ #endif } __randomize_layout; ``` 실제로 `linux_binfmt` 구조체는 `include/linux/binfmts.h`에 위와 같이 정의되어 있으며, 이 구조체가 바로 리눅스 커널이 수용하는 바이너리 형식을 로드하기 위한 함수들을 정의하고 있다. #### 초기 검증 및 ELF 헤더 파싱 커널이 `fs/binfmt_elf.c`에 진입하여 `load_elf_binary()` 함수의 실행을 시작하면 가장 먼저 수행하는 작업은 전달된 파일이 실행 가능한 유효한 ELF 파일인지 검증하는 것이다. 함수는 `linux_binprm` 버퍼의 데이터를 `elfhdr` 구조체로 캐스팅하여 구조체의 포인터를 확보한다. 그 후 `memcmp()` 함수를 사용하여 구조체의 `e_ident` 필드(첫 16바이트)의 앞부분이 ELF 파일의 고유한 매직 넘버(`\x7fELF`)와 일치하는지 비교한다. 만약 매직 넘버가 일치하지 않는다면, 이 파일은 처리 대상이 아니므로 함수는 상태 코드 `-ENOEXEC`을 반환하며 조기에 종료된다. 이 오류 코드는 `search_binary_handler()`로 전달되어 커널이 다음 바이너리 핸들러를 시도하도록 유도한다. ```C loc->elf_ex = *((struct elfhdr *)bprm->buf); retval = -ENOEXEC; /* First of all, some simple consistency checks */ if (memcmp(loc->elf_ex.e_ident, ELFMAG, SELFMAG)!= 0) goto out; ``` 매직 넘버 검증을 통과한 후에는 아키텍처 호환성에 대한 보다 심도 있는 검증이 이어진다. 커널은 `elf_check_arch`매크로를 통해 ELF 헤더의 `e_machine` 필드를 검사하여 해당 바이너리가 현재 시스템의 CPU 아키텍처에서 구동 가능한 명령어 셋을 포함하고 있는지 확인한다. 이와 동시에 바이너리의 `e_type` 필드를 검사하여 해당 파일이 독립적인 실행 파일(`ET_EXEC`)이거나, 위치 독립 실행 파일(PIE) 및 공유 객체를 의미하는 동적 바이너리(`ET_DYN`)인지 확인한다. 만약 파일 유형이 재배치 가능 목적 파일(`ET_REL`) 등이라면 실행을 거부한다. ```mermaid graph TD A(Kernel: do_execve) --> B(search_binary_handler) B --> C{Verify ELF Header</br>memcmp e_ident & EM_AARCH64} C -- Invalid --> D(Return Error / Try binfmt-XXXX) C -- Valid --> E(load_elf_phdrs:<br/>Read Program Headers) E --> F{Identify Segments<br/>PT_LOAD, PT_INTERP, PT_GNU_STACK} ``` #### 프로그램 헤더 파싱 및 세그먼트 식별 기본적인 파일 검증이 완료되면, 커널은 실행 파일의 메모리 레이아웃을 구성하기 위해 ELF 파일의 프로그램 헤더 테이블(Program Header Table) 전체를 분석해야 한다. 커널은 `load_elf_phdrs()` 함수를 호출하여 디스크의 실행 파일로부터 프로그램 헤더를 커널 내의 스크래치 공간*Scratch space*으로 모두 읽어 들인다. 프로그램 헤더 테이블은 파일 내의 각종 섹션들이 가상 메모리 내의 세그먼트로 어떻게 그룹화되어 매핑될지를 규정하는 배열이며, 개별 헤더는 `Elf64_Phdr` (또는 32비트의 경우 `Elf32_Phdr`) 구조체로 표현된다. 커널 코드는 읽어 들인 프로그램 헤더 배열을 순회하며 프로세스 구동에 필수적인 세그먼트들을 식별하고 관련 플래그를 변수에 저장한다. 커널이 특별히 주목하는 세그먼트의 타입(`p_type`)은 크게 세 가지이다. ##### `PT_LOAD` `PT_LOAD`는 디스크립터에 명시된 오프셋의 데이터를 가상 주소 공간에 지정된 메모리 사이즈만큼 매핑해야 함을 지시하는 가장 핵심적인 세그먼트이다. 실행 가능한 기계어 코드(`.text`)와 데이터 영역이 모두 이 규칙을 통해 메모리에 로드된다. ##### `PT_INTERP` `PT_INTERP` 세그먼트는 해당 프로그램이 동적 링킹 라이브러리를 필요로 할 경우, 런타임에 이를 해석하고 연결해 줄 동적 링커(인터프리터)의 절대 경로 문자열을 포함한다. 보통 ARM64 리눅스 환경에서는 `/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1`과 같은 경로가 여기에 저장된다. 루프를 돌며 이 세그먼트를 발견하면, 커널은 해당 인터프리터 파일의 경로를 추출하고 이 인터프리터 자체에 대한 프로그램 헤더를 별도로 로드할 준비를 한다. ##### `PT_GNU_STACK` 새롭게 실행될 프로세스의 스택 메모리 영역에 실행 권한*Executable*을 부여할지 여부를 결정하는 단일 비트 정보를 제공한다. 스택 내 코드 실행은 ROP 공격 등의 기반이 되므로 보안을 위해 최신 시스템에서는 이 헤더를 통해 실행 권한이 제거(NX bit 적용)되는 것이 일반적이다. #### 컨텍스트 스위치를 위한 보안 및 상태 파기 ```mermaid graph TD F(Segment Identification Complete) --> G(flush_old_exec) G --> G1[Zap other threads] G --> G2[Clear signal & timers] G --> G3 G3 --> H(setup_new_exec) H --> H1 H --> H2 ``` 모든 분석이 완료되고 바이너리가 실행 가능하다고 판단되면, 커널은 실행 프로세스의 정체성을 완전히 교체하는 '불가역적인 지점(Point of no return)'을 통과해야 한다. 기존 프로세스(통상 셸이나 부모 프로세스에 의해 `fork`된 상태)가 점유하고 있던 커널 내의 메모리 매핑과 컨텍스트 상태를 완벽히 소멸시키고 백지상태를 만드는 과정이 요구되는데, 이 중대한 역할은 `begin_new_exec()` 함수가 담당한다. ```C /* * Calling this is the point of no return. None of the failures will be * seen by userspace since either the process is already taking a fatal * signal (via de_thread() or coredump), or will have SEGV raised * (after exec_mmap()) by search_binary_handler (see below). */ int begin_new_exec(struct linux_binprm * bprm) { struct task_struct *me = current; int retval; /* Once we are committed compute the creds */ retval = bprm_creds_from_file(bprm); if (retval) return retval; /* * This tracepoint marks the point before flushing the old exec where * the current task is still unchanged, but errors are fatal (point of * no return). The later "sched_process_exec" tracepoint is called after * the current task has successfully switched to the new exec. */ trace_sched_prepare_exec(current, bprm); /* * Ensure all future errors are fatal. */ bprm->point_of_no_return = true; /* Make this the only thread in the thread group */ retval = de_thread(me); if (retval) goto out; /* see the comment in check_unsafe_exec() */ current->fs->in_exec = 0; /* * Cancel any io_uring activity across execve */ io_uring_task_cancel(); /* Ensure the files table is not shared. */ retval = unshare_files(); if (retval) goto out; /* * Must be called _before_ exec_mmap() as bprm->mm is * not visible until then. Doing it here also ensures * we don't race against replace_mm_exe_file(). */ retval = set_mm_exe_file(bprm->mm, bprm->file); if (retval) goto out; /* If the binary is not readable then enforce mm->dumpable=0 */ would_dump(bprm, bprm->file); if (bprm->have_execfd) would_dump(bprm, bprm->executable); /* * Release all of the old mmap stuff */ acct_arg_size(bprm, 0); retval = exec_mmap(bprm->mm); if (retval) goto out; bprm->mm = NULL; retval = exec_task_namespaces(); if (retval) goto out_unlock; #ifdef CONFIG_POSIX_TIMERS spin_lock_irq(&me->sighand->siglock); posix_cpu_timers_exit(me); spin_unlock_irq(&me->sighand->siglock); exit_itimers(me); flush_itimer_signals(); #endif /* * Make the signal table private. */ retval = unshare_sighand(me); if (retval) goto out_unlock; me->flags &= ~(PF_RANDOMIZE | PF_FORKNOEXEC | PF_NOFREEZE | PF_NO_SETAFFINITY); flush_thread(); me->personality &= ~bprm->per_clear; clear_syscall_work_syscall_user_dispatch(me); /* * We have to apply CLOEXEC before we change whether the process is * dumpable (in setup_new_exec) to avoid a race with a process in userspace * trying to access the should-be-closed file descriptors of a process * undergoing exec(2). */ do_close_on_exec(me->files); if (bprm->secureexec) { /* Make sure parent cannot signal privileged process. */ me->pdeath_signal = 0; /* * For secureexec, reset the stack limit to sane default to * avoid bad behavior from the prior rlimits. This has to * happen before arch_pick_mmap_layout(), which examines * RLIMIT_STACK, but after the point of no return to avoid * needing to clean up the change on failure. */ if (bprm->rlim_stack.rlim_cur > _STK_LIM) bprm->rlim_stack.rlim_cur = _STK_LIM; } me->sas_ss_sp = me->sas_ss_size = 0; /* * Figure out dumpability. Note that this checking only of current * is wrong, but userspace depends on it. This should be testing * bprm->secureexec instead. */ if (bprm->interp_flags & BINPRM_FLAGS_ENFORCE_NONDUMP || !(uid_eq(current_euid(), current_uid()) && gid_eq(current_egid(), current_gid()))) set_dumpable(current->mm, suid_dumpable); else set_dumpable(current->mm, SUID_DUMP_USER); perf_event_exec(); /* * If the original filename was empty, alloc_bprm() made up a path * that will probably not be useful to admins running ps or similar. * Let's fix it up to be something reasonable. */ if (bprm->comm_from_dentry) { /* * Hold RCU lock to keep the name from being freed behind our back. * Use acquire semantics to make sure the terminating NUL from * __d_alloc() is seen. * * Note, we're deliberately sloppy here. We don't need to care about * detecting a concurrent rename and just want a terminated name. */ rcu_read_lock(); __set_task_comm(me, smp_load_acquire(&bprm->file->f_path.dentry->d_name.name), true); rcu_read_unlock(); } else { __set_task_comm(me, kbasename(bprm->filename), true); } /* An exec changes our domain. We are no longer part of the thread group */ WRITE_ONCE(me->self_exec_id, me->self_exec_id + 1); flush_signal_handlers(me, 0); retval = set_cred_ucounts(bprm->cred); if (retval < 0) goto out_unlock; /* * install the new credentials for this executable */ security_bprm_committing_creds(bprm); commit_creds(bprm->cred); bprm->cred = NULL; /* * Disable monitoring for regular users * when executing setuid binaries. Must * wait until new credentials are committed * by commit_creds() above */ if (get_dumpable(me->mm) != SUID_DUMP_USER) perf_event_exit_task(me); /* * cred_guard_mutex must be held at least to this point to prevent * ptrace_attach() from altering our determination of the task's * credentials; any time after this it may be unlocked. */ security_bprm_committed_creds(bprm); /* Pass the opened binary to the interpreter. */ if (bprm->have_execfd) { retval = FD_ADD(0, bprm->executable); if (retval < 0) goto out_unlock; bprm->executable = NULL; bprm->execfd = retval; } return 0; out_unlock: up_write(&me->signal->exec_update_lock); if (!bprm->cred) mutex_unlock(&me->signal->cred_guard_mutex); out: return retval; } EXPORT_SYMBOL(begin_new_exec); ``` 이 함수의 세부 동작 메커니즘은 다음과 같다. 1. **스레드 정리 및 단일 스레드화**: 기존 프로그램에서 파생된 다른 스레드들이 커널 스케줄러 내에 남아있다면 모두 강제로 종료시켜 시스템 콜을 수행 중인 메인 스레드 단 하나만이 남도록 정리한다. 이는 새로운 프로그램이 예측할 수 없는 경쟁 조건없이 깨끗한 상태에서 시작될 수 있도록 보장한다. 2. **시그널 및 타이머 초기화**: 기존 프로그램과 엮인 신호 처리기*Signal Handler*의 공유 상태를 해제하여 향후 새로운 바이너리의 필요에 따라 안전하게 재정의될 수 있도록 한다. 또한 이전 프로세스가 설정해 둔 대기 중인 POSIX 타이머를 모조리 소멸시킨다. 3. **가상 메모리 반환**: 가장 무거운 리소스 작업으로서, 기존 프로그램의 `mm_struct` 매핑 영역들을 완전히 해제한다. 이 과정에서 메모리에 올라와 있던 구 프로그램의 텍스트와 데이터가 싹 지워지며, 기존에 보류 중이던 비동기 I/O 연산들도 강제로 취소된다. 프로세스 추적을 위해 설정되었던 `uprobes` 인스턴스 역시 메모리에서 제거된다. 4. **프로세스 심볼릭 링크 갱신**: 사용자 공간에서 프로세스의 실행 경로를 모니터링하는 데 사용되는 `/proc/pid/exe`의 심볼릭 링크가 방금 로드를 확정한 새로운 실행 파일로 업데이트된다. 5. **보안 특성(Personality) 초기화**: 기존 프로세스의 `linux_binprm` 내 `per_clear` 필드에 기록되어 있던 정보를 바탕으로, 보안 메커니즘에 악영향을 줄 수 있는 구 프로그램의 퍼스널리티(Personality) 특성들을 일괄 제거한다. ARM64 아키텍처를 기준으로, 퍼스널리티 초기화 직후 `SET_PERSONALITY()` 매크로가 이어서 호출된다. 이 매크로는 내부적으로 `clear_thread_flag(TIF_32BIT)`를 호출하여 프로세스가 32비트 호환 모드(AArch32)가 아닌 네이티브 64비트(AArch64) 환경으로 동작하도록 스레드 플래그를 재설정한다. 6. 이전 세계의 잔재가 완벽히 소멸된 직후, 커널은 논리적 연속성을 부여하기 위해 `setup_new_exec()` 함수를 호출하여 새 프로그램의 내부 상태를 기초한다. 이 함수에서는 메모리의 크기를 결정한다. ```C void setup_new_exec(struct linux_binprm * bprm) { /* Setup things that can depend upon the personality */ struct task_struct *me = current; arch_pick_mmap_layout(me->mm, &bprm->rlim_stack); arch_setup_new_exec(); /* Set the new mm task size. We have to do that late because it may * depend on TIF_32BIT which is only updated in flush_thread() on * some architectures like powerpc */ me->mm->task_size = TASK_SIZE; up_write(&me->signal->exec_update_lock); mutex_unlock(&me->signal->cred_guard_mutex); } EXPORT_SYMBOL(setup_new_exec); ``` 7. 새로 진입할 환경이 코어 덤프(Core dump)를 생성할 수 있는 권한이나 ptrace를 통한 디버깅을 허용받을 수 있는지를 판단한다. 또한 SUID/SGID에 의한 권한 상승 로직에 따라 `install_exec_creds()`를 호출하여 완전히 새로운 자격 증명을 현재 프로세스 커널 객체에 고정시킨다. 8. 마지막으로 커널은 `__set_task_comm` 인터페이스를 호출하여 포크 직후 아직 업데이트되지 않았던 태스크의 이름*task comm*을 새 바이너리의 이름으로 변경함으로써 커널 관점에서의 프로세스 교체를 물리적으로 확정 짓는다. #### 가상 메모리 공간 재건 및 세그먼트 적재 ```mermaid graph TD H2(Task setup complete) --> I(setup_arg_pages) I --> I1 I1 --> J(elf_map) J --> J1[Map.text,.data,.rodata into memory] J1 --> K(set_brk) K --> K1[Initialize.bss and Heap boundaries] ``` 새로운 컨텍스트가 확보된 직후, `load_elf_binary`는 프로세스의 뼈대가 될 새로운 메모리 레이아웃을 구성하기 시작한다. 메모리 구성의 첫 단계는 프로그램 실행 중 가장 동적으로 활용될 스택 영역의 확보이다. 커널은 `setup_arg_pages()` 함수를 호출하여 스택에 대한 가상 메모리 영역 구조체(`vm_area_struct`)를 확정하고 필요한 권한 및 플래그를 할당한다. ##### 스택 최상단 설정 주소 공간 배치 난수화(ASLR)를 적용하여 스택의 베이스 주소를 랜덤화한다. 커널은 실행 중인 태스크의 퍼스널리티 속성을 점검하여 `ADDR_NO_RANDOMIZE` 플래그가 설정되어 있지 않다면 스택 최상단 포인터(`sp`)를 무작위로 변경하여 보안성을 확보한다. ##### elf_map을 통한 PT_LOAD 세그먼트 매핑 스택이 안정적으로 구성되면, 커널은 파일의 코드가 위치할 `.text` 및 `.data` 영역을 `elf_map()`을 통해 매핑한다. `elf_map` 함수는 실행 파일 디스크립터에 접근하여 로드될 바이어스(`load_bias`) 주소와 각 세그먼트의 가상 주소를 합산한 위치에 페이지 매핑을 지시한다. ##### BSS(초기화되지 않은 변수) 영역 생성 명시적으로 값을 할당받지 않은 전역 변수들(`.bss`)을 위해 커널은 `clear_user()` 함수를 사용해 남는 메모리 영역을 강제로 0으로 초기화한다. 이후 `set_brk()` 함수를 호출하여 힙 공간의 시작 주소*Break point*를 BSS 영역의 뒤로 설정한다. #### 인터프리터(동적 링커) 로딩 및 진입점 변환 ```mermaid graph TD K1(Memory mapped) --> L{PT_INTERP found?} L -- Yes (Dynamic) --> M(load_elf_interp) M --> M1[Map ld-linux-aarch64.so.1 into VMA] M1 --> N(entry = interp_entry) L -- No (Static) --> O(entry = e_entry) N --> P(Proceed to Stack Setup) O --> P ``` 정적 링킹 방식의 바이너리는 제어권이 ELF 헤더에 명시된 원래의 프로그램 진입점(`e_entry`)으로 직접 양도된다. 하지만 동적 링킹 파일의 경우 커널은 사용자 프로세스로 제어권을 넘기기 전에 인터프리터를 로딩해야 한다. 커널은 추출된 링커 경로의 파일을 열어 `load_elf_interp()` 함수를 호출한다. 이 함수는 링커 파일 내부에 존재하는 자체적인 `PT_LOAD` 세그먼트들을 빈 공간으로 베이스 주소를 조정하여 적재한다. ```C /* This is much more generalized than the library routine read function, so we keep this separate. Technically the library read function is only provided so that we can read a.out libraries that have an ELF header */ static unsigned long load_elf_interp(struct elfhdr *interp_elf_ex, struct file *interpreter, unsigned long no_base, struct elf_phdr *interp_elf_phdata, struct arch_elf_state *arch_state) { struct elf_phdr *eppnt; unsigned long load_addr = 0; int load_addr_set = 0; unsigned long error = ~0UL; unsigned long total_size; int i; /* First of all, some simple consistency checks */ if (interp_elf_ex->e_type != ET_EXEC && interp_elf_ex->e_type != ET_DYN) goto out; if (!elf_check_arch(interp_elf_ex) || elf_check_fdpic(interp_elf_ex)) goto out; if (!can_mmap_file(interpreter)) goto out; total_size = total_mapping_size(interp_elf_phdata, interp_elf_ex->e_phnum); if (!total_size) { error = -EINVAL; goto out; } eppnt = interp_elf_phdata; for (i = 0; i < interp_elf_ex->e_phnum; i++, eppnt++) { if (eppnt->p_type == PT_LOAD) { int elf_type = MAP_PRIVATE; int elf_prot = make_prot(eppnt->p_flags, arch_state, true, true); unsigned long vaddr = 0; unsigned long k, map_addr; vaddr = eppnt->p_vaddr; if (interp_elf_ex->e_type == ET_EXEC || load_addr_set) elf_type |= MAP_FIXED; else if (no_base && interp_elf_ex->e_type == ET_DYN) load_addr = -vaddr; map_addr = elf_load(interpreter, load_addr + vaddr, eppnt, elf_prot, elf_type, total_size); total_size = 0; error = map_addr; if (BAD_ADDR(map_addr)) goto out; if (!load_addr_set && interp_elf_ex->e_type == ET_DYN) { load_addr = map_addr - ELF_PAGESTART(vaddr); load_addr_set = 1; } /* * Check to see if the section's size will overflow the * allowed task size. Note that p_filesz must always be * <= p_memsize so it's only necessary to check p_memsz. */ k = load_addr + eppnt->p_vaddr; if (BAD_ADDR(k) || eppnt->p_filesz > eppnt->p_memsz || eppnt->p_memsz > TASK_SIZE || TASK_SIZE - eppnt->p_memsz < k) { error = -ENOMEM; goto out; } } } error = load_addr; out: return error; } ``` 동적 링커의 적재가 완료되면 `load_elf_interp()` 함수는 동적 링커 자신의 진입점 주소를 반환하는데, 커널은 이 값을 `elf_entry`라는 변수에 덮어씌운다. 그 결과, 커널이 시스템 콜을 끝내고 사용자 공간으로 돌아갈 때 진입하는 최초의 명령어는 우리가 컴파일한 응용 프로그램의 시작 위치가 아니라 **동적 링커의 시작 위치**가 된다. #### 프로세스 스택 환경 구축 ```mermaid graph TD P(Ready for Stack Setup) --> Q(create_elf_tables) Q --> Q1[Push strings: args, envs] Q1 --> Q2[Push pointers: argv, envp] Q2 --> Q3 Q3 --> Q4 ``` 모든 바이너리 데이터가 메모리에 안착하면, 새로운 런타임 환경에 필수적인 변수 및 인자들을 스택에 배치해야 한다. 이는 `create_elf_tables()` 함수의 책임이며, C 라이브러리의 시작점(`__libc_start_main`)과 동적 링커가 구동되기 위해 의존하는 데이터를 C ABI 규약에 맞춰 정밀하게 쌓아 올리는 작업이다. ```C static int create_elf_tables(struct linux_binprm *bprm, const struct elfhdr *exec, unsigned long interp_load_addr, unsigned long e_entry, unsigned long phdr_addr) { struct mm_struct *mm = current->mm; unsigned long p = bprm->p; int argc = bprm->argc; int envc = bprm->envc; elf_addr_t __user *sp; elf_addr_t __user *u_platform; elf_addr_t __user *u_base_platform; elf_addr_t __user *u_rand_bytes; const char *k_platform = ELF_PLATFORM; const char *k_base_platform = ELF_BASE_PLATFORM; unsigned char k_rand_bytes[16]; int items; elf_addr_t *elf_info; elf_addr_t flags = 0; int ei_index; const struct cred *cred = current_cred(); struct vm_area_struct *vma; /* * In some cases (e.g. Hyper-Threading), we want to avoid L1 * evictions by the processes running on the same package. One * thing we can do is to shuffle the initial stack for them. */ p = arch_align_stack(p); /* * If this architecture has a platform capability string, copy it * to userspace. In some cases (Sparc), this info is impossible * for userspace to get any other way, in others (i386) it is * merely difficult. */ u_platform = NULL; if (k_platform) { size_t len = strlen(k_platform) + 1; u_platform = (elf_addr_t __user *)STACK_ALLOC(p, len); if (copy_to_user(u_platform, k_platform, len)) return -EFAULT; } /* * If this architecture has a "base" platform capability * string, copy it to userspace. */ u_base_platform = NULL; if (k_base_platform) { size_t len = strlen(k_base_platform) + 1; u_base_platform = (elf_addr_t __user *)STACK_ALLOC(p, len); if (copy_to_user(u_base_platform, k_base_platform, len)) return -EFAULT; } /* * Generate 16 random bytes for userspace PRNG seeding. */ get_random_bytes(k_rand_bytes, sizeof(k_rand_bytes)); u_rand_bytes = (elf_addr_t __user *) STACK_ALLOC(p, sizeof(k_rand_bytes)); if (copy_to_user(u_rand_bytes, k_rand_bytes, sizeof(k_rand_bytes))) return -EFAULT; /* Create the ELF interpreter info */ elf_info = (elf_addr_t *)mm->saved_auxv; /* update AT_VECTOR_SIZE_BASE if the number of NEW_AUX_ENT() changes */ #define NEW_AUX_ENT(id, val) \ do { \ *elf_info++ = id; \ *elf_info++ = val; \ } while (0) #ifdef ARCH_DLINFO /* * ARCH_DLINFO must come first so PPC can do its special alignment of * AUXV. * update AT_VECTOR_SIZE_ARCH if the number of NEW_AUX_ENT() in * ARCH_DLINFO changes */ ARCH_DLINFO; #endif NEW_AUX_ENT(AT_HWCAP, ELF_HWCAP); NEW_AUX_ENT(AT_PAGESZ, ELF_EXEC_PAGESIZE); NEW_AUX_ENT(AT_CLKTCK, CLOCKS_PER_SEC); NEW_AUX_ENT(AT_PHDR, phdr_addr); NEW_AUX_ENT(AT_PHENT, sizeof(struct elf_phdr)); NEW_AUX_ENT(AT_PHNUM, exec->e_phnum); NEW_AUX_ENT(AT_BASE, interp_load_addr); if (bprm->interp_flags & BINPRM_FLAGS_PRESERVE_ARGV0) flags |= AT_FLAGS_PRESERVE_ARGV0; NEW_AUX_ENT(AT_FLAGS, flags); NEW_AUX_ENT(AT_ENTRY, e_entry); NEW_AUX_ENT(AT_UID, from_kuid_munged(cred->user_ns, cred->uid)); NEW_AUX_ENT(AT_EUID, from_kuid_munged(cred->user_ns, cred->euid)); NEW_AUX_ENT(AT_GID, from_kgid_munged(cred->user_ns, cred->gid)); NEW_AUX_ENT(AT_EGID, from_kgid_munged(cred->user_ns, cred->egid)); NEW_AUX_ENT(AT_SECURE, bprm->secureexec); NEW_AUX_ENT(AT_RANDOM, (elf_addr_t)(unsigned long)u_rand_bytes); #ifdef ELF_HWCAP2 NEW_AUX_ENT(AT_HWCAP2, ELF_HWCAP2); #endif #ifdef ELF_HWCAP3 NEW_AUX_ENT(AT_HWCAP3, ELF_HWCAP3); #endif #ifdef ELF_HWCAP4 NEW_AUX_ENT(AT_HWCAP4, ELF_HWCAP4); #endif NEW_AUX_ENT(AT_EXECFN, bprm->exec); if (k_platform) { NEW_AUX_ENT(AT_PLATFORM, (elf_addr_t)(unsigned long)u_platform); } if (k_base_platform) { NEW_AUX_ENT(AT_BASE_PLATFORM, (elf_addr_t)(unsigned long)u_base_platform); } if (bprm->have_execfd) { NEW_AUX_ENT(AT_EXECFD, bprm->execfd); } #ifdef CONFIG_RSEQ NEW_AUX_ENT(AT_RSEQ_FEATURE_SIZE, offsetof(struct rseq, end)); NEW_AUX_ENT(AT_RSEQ_ALIGN, rseq_alloc_align()); #endif #undef NEW_AUX_ENT /* AT_NULL is zero; clear the rest too */ memset(elf_info, 0, (char *)mm->saved_auxv + sizeof(mm->saved_auxv) - (char *)elf_info); /* And advance past the AT_NULL entry. */ elf_info += 2; ei_index = elf_info - (elf_addr_t *)mm->saved_auxv; sp = STACK_ADD(p, ei_index); items = (argc + 1) + (envc + 1) + 1; bprm->p = STACK_ROUND(sp, items); /* Point sp at the lowest address on the stack */ #ifdef CONFIG_STACK_GROWSUP sp = (elf_addr_t __user *)bprm->p - items - ei_index; bprm->exec = (unsigned long)sp; /* XXX: PARISC HACK */ #else sp = (elf_addr_t __user *)bprm->p; #endif /* * Grow the stack manually; some architectures have a limit on how * far ahead a user-space access may be in order to grow the stack. */ if (mmap_write_lock_killable(mm)) return -EINTR; vma = find_extend_vma_locked(mm, bprm->p); mmap_write_unlock(mm); if (!vma) return -EFAULT; /* Now, let's put argc (and argv, envp if appropriate) on the stack */ if (put_user(argc, sp++)) return -EFAULT; /* Populate list of argv pointers back to argv strings. */ p = mm->arg_end = mm->arg_start; while (argc-- > 0) { size_t len; if (put_user((elf_addr_t)p, sp++)) return -EFAULT; len = strnlen_user((void __user *)p, MAX_ARG_STRLEN); if (!len || len > MAX_ARG_STRLEN) return -EINVAL; p += len; } if (put_user(0, sp++)) return -EFAULT; mm->arg_end = p; /* Populate list of envp pointers back to envp strings. */ mm->env_end = mm->env_start = p; while (envc-- > 0) { size_t len; if (put_user((elf_addr_t)p, sp++)) return -EFAULT; len = strnlen_user((void __user *)p, MAX_ARG_STRLEN); if (!len || len > MAX_ARG_STRLEN) return -EINVAL; p += len; } if (put_user(0, sp++)) return -EFAULT; mm->env_end = p; /* Put the elf_info on the stack in the right place. */ if (copy_to_user(sp, mm->saved_auxv, ei_index * sizeof(elf_addr_t))) return -EFAULT; return 0; } ``` `create_elf_tables` 함수는 `linux_binprm` 버퍼에 일시적으로 보관되어 있던 `argc`(명령줄 인수 개수), `envc`(환경 변수 개수) 데이터 및 문자열들을 최종 계산된 스택 위치로 복사한다. 이 스택 레이아웃은 리눅스 아키텍처에 독립적인 공통된 양식을 따르며, 일반적으로 높은 주소에서 낮은 주소로 자라나는 형태를 취한다. 가장 주목해야 할 컴포넌트는 커널이 사용자 공간으로 시스템 속성 정보를 통보하는 매개체인 **보조 벡터*Auxiliary Vector***이다. 특히 ARM64 아키텍처에서는 하드웨어 발전으로 인해 CPU 기능이 급증함에 따라 `AT_HWCAP`, `AT_HWCAP2`에 이어 `AT_HWCAP3`까지 보조 벡터가 확장되었다. 커널은 이 벡터들을 통해 유저랜드(`EL0`) 애플리케이션에게 부동소수점, AES 연산 지원뿐 아니라 최신 보안 기술인 PAC(Pointer Authentication Code) 및 BTI(Branch Target Identification) 등의 하드웨어 지원 여부를 동적으로 전달한다. 이를 바탕으로 런타임에 ROP/JOP 방어 기법과 메모리 보호가 올바르게 활성화될 수 있다. 다음은 `create_elf_tables()` 함수 수행 직후의 프로세스 스택 레이아웃을 높은 주소에서 낮은 주소로 구조화한 표이다. | **스택 메모리 주소** | **레이아웃 컴포넌트** | **저장 내용 및 설명** | | ------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | **High Address** | 문자열 영역 (Strings) | 실행에 전달된 실제 환경 변수 및 명령줄 인수의 문자열(Raw character strings) 데이터가 배치된다. | | | 패딩 영역 (Padding) | 메모리 정렬(Alignment)을 위해 보정된 빈 공간이 삽입된다. | | | **NULL 마커** | 보조 벡터의 끝을 나타내는 0 값 (`AT_NULL`). | | | **보조 벡터**(Auxiliary Vector) | `AT_PAGESZ` (페이지 크기), `AT_PHDR` (프로그램 헤더 위치), `AT_ENTRY` (바이너리 원래 진입점), `AT_HWCAP` (CPU 벤더 확장 명령어셋 정보), `AT_UID`, `AT_RANDOM` (난수화 기반값) 등의 동적 쌍. | | | **NULL 마커** | 환경 변수 포인터 배열의 끝을 표시하는 0 값. | | | **`envp` 포인터 배열** | 상단에 저장된 환경 변수 문자열들을 가리키는 C 스타일 문자열 포인터(`char *`)들의 목록. | | | **NULL 마커** | 명령줄 인수 포인터 배열의 끝을 표시하는 0 값. | | | **`argv` 포인터 배열** | 상단에 저장된 명령줄 인수 문자열들을 가리키는 문자열 포인터들의 목록. (C언어 `main`의 `argv` 인자 구조) | | **Low Address**(SP) | **`argc` (인수 개수)** | 명령줄 인수의 총개수. 이 값이 스택의 가장 하단이자 초기 스택 포인터(SP)가 가리키는 첫 번째 데이터가 된다. | #### 프로세서 레지스터 초기화 및 제어권 이양 ```mermaid graph TD Q4(Stack initialized) --> R(start_thread) R --> R1[regs->regs_0_ = 0] R --> R2[regs->pc = entry\nregs->sp = stack_top] R --> R3 R1 --> S(Return 0 to execve) R2 --> S R3 --> S S --> T(ERET: Exception Return to EL0) T --> U{Entry Target} U -- Dynamic --> V(Execute ld.so -> Setup libc -> _start) U -- Static --> W(Execute Program _start directly) ``` `create_elf_tables`가 모든 준비를 마치면, 커널 모드에서의 `execve` 프로세싱은 사실상 끝을 맺는다. 남은 작업은 새로운 프로세스가 사용자 모드에서 올바르게 실행될 수 있도록 CPU 레지스터를 재설정하고 점프하는 것뿐이다. 이 작업은 `load_elf_binary()`의 가장 마지막 줄에 위치한 아키텍처 종속 함수인 `start_thread()` 호출을 통해 이루어진다. ```C static inline void start_thread_common(struct pt_regs *regs, unsigned long pc, unsigned long pstate) { /* * Ensure all GPRs are zeroed, and initialize PC + PSTATE. * The SP (or compat SP) will be initialized later. */ regs->user_regs = (struct user_pt_regs) { .pc = pc, .pstate = pstate, }; /* * To allow the syscalls:sys_exit_execve tracepoint we need to preserve * syscallno, but do not need orig_x0 or the original GPRs. */ regs->orig_x0 = 0; /* * An exec from a kernel thread won't have an existing PMR value. */ if (system_uses_irq_prio_masking()) regs->pmr = GIC_PRIO_IRQON; /* * The pt_regs::stackframe field must remain valid throughout this * function as a stacktrace can be taken at any time. Any user or * kernel task should have a valid final frame. */ WARN_ON_ONCE(regs->stackframe.record.fp != 0); WARN_ON_ONCE(regs->stackframe.record.lr != 0); WARN_ON_ONCE(regs->stackframe.type != FRAME_META_TYPE_FINAL); } static inline void start_thread(struct pt_regs *regs, unsigned long pc, unsigned long sp) { start_thread_common(regs, pc, PSR_MODE_EL0t); spectre_v4_enable_task_mitigation(current); regs->sp = sp; } ``` 위 함수는 `arch/arm64/include/asm/processor.h`에 정의된 ARM64 아키텍처에서의 `start_thread()` 함수다. 이때 전달되는 인자들의 의미는 다음과 같다. - **`regs`**: 사용자 모드로 되돌아갈 때 복원될 CPU 레지스터의 세트를 담고 있는 구조체이다. 앞서 언급한 커널 스택 최상단의 트랩 프레임(`pt_regs`)의 위치를 가리키며, `start_thread`는 이 구조체 내부의 값들을 직접 조작한다. - **`elf_entry`**: 앞선 단계에서 결정된 프로세스의 진입점 주소이다. 정적 바이너리라면 원래의 `e_entry` 주소이며, 동적 바이너리라면 `ld.so` 링커의 진입점 주소이다. - **`bprm->p`**: `create_elf_tables` 작업 후 데이터가 전부 채워진 최하단의 유저스페이스 스택 포인터 주소(`argc`가 저장된 위치)이다. ARM64 아키텍처의 경우, 이 함수(및 하위 루틴) 내에서 다음과 같은 핵심적인 레지스터 초기화가 수행된다. 1. 먼저 `ELF_PLAT_INIT` 매크로를 호출하여 0번 범용 레지스터(`regs->regs`)를 명시적으로 0으로 초기화한다. 2. 프로그램 카운터(`regs->pc`)에 앞서 결정된 `elf_entry` 주소를 대입한다. 3. 스택 포인터(`regs->sp`)에는 구축이 완료된 유저 스택 주소 `bprm->p`를 대입한다. 4. 프로세서 상태 레지스터(`regs->pstate`)를 조작하여, 현재의 커널 모드(EL1)에서 예외 반환을 거친 뒤 유저 모드인 **EL0t** 모드로 올바르게 진입하도록 권한과 인터럽트 마스크를 설정한다. 레지스터 갱신 작업이 완료되어 `load_elf_binary`가 `0`(성공)을 반환하면, 호출 스택을 따라 `execve` 시스템 콜 핸들러로 복귀한 커널은 최종적으로 `ERET` (Exception Return) 명령을 수행한다. 이 찰나에 프로세서는 권한 수준*Exception Level*을 EL1(커널)에서 EL0(유저랜드)로 내려가며, `regs->pc`에 기록된 새로운 진입점부터 유저 애플리케이션의 첫 번째 명령어가 해독*Fetch*되기 시작한다. > [!Warning] > 그림은 x86 아키텍처를 기준으로 설명되어 있으니 유의해 주세요. ![[loaderMap.png]] ## 다음 단계 커널이 `ERET`를 통해 EL0 유저스페이스로 제어권을 넘겼을 때, 프로그램은 `main` 함수에서 곧바로 시작하지 않는다. 커널이 세팅한 진입점 주소(`e_entry`)는 C 표준 라이브러리가 제공하는 기계어 부트스트랩 코드가 위치한 `_start` 심볼을 가리킨다. ARM64 환경에서 `_start` 루틴은 실행되자마자 ABI 규약에 따라 프레임 포인터(`x29`)와 링크 레지스터(`x30`)를 0으로 지워 호출 스택의 끝*Backtrace terminator*을 명시한다. 이후 커널이 구성해 둔 스택 포인터에서 `argc`와 `argv` 배열을 레지스터(`x0`, `x1` 등)로 복사한다. 스택을 16바이트 경계에 맞게 재정렬한 후, 전역 생성자를 담당하는 `__libc_csu_init` 등의 주소를 레지스터에 준비하여 `__libc_start_main`을 호출한다. 이후 스레딩 인프라 가동, 보안 카나리*Stack Canary* 설정 등의 런타임 세팅이 끝나고 나서야, 비로소 개발자가 작성한 `main(argc, argv, envp)` 함수가 호출되며 사용자 애플리케이션의 비즈니스 로직이 개시된다. 프로세스 로딩 과정을 살펴봤으니, 이제 실행 중인 프로세스가 어떻게 종료되는지를 이해할 차례다. [[프로세스 종료]]를 참고하길 바란다. ## 출처 - Bryant, R.E. and O'Hallaron, D.R. (2016) *Computer Systems: A Programmer's Perspective*. 3rd edn. Boston: Pearson. - Amini, K. (2019) *Extreme C: Taking you to the limit in Concurrency, OOP, and the most advanced capabilities of C*. 1st edn. Birmingham: Packt Publishing. - Kerrisk, M. (2018) *The Linux Programming Interface*. 9th printing. San Francisco: No Starch Press. - Linux kernel source code: `fs/exec.c`, `fs/binfmt_elf.c` --- [^glibc-execve]: glibc에서 `execl()`, `execv()`, `execlp()`, `execvp()` 등은 모두 최종적으로 `execve` 시스템 호출을 수행한다. `fexecve()`는 `execveat` 시스템 호출을 사용한다. [execve(2) - Linux manual page](https://www.man7.org/linux/man-pages/man2/execve.2.html) 참고.