본문으로 이동
  • 현재 모든 문서는 작성 중입니다. 보시는 데 불편함이 있더라도 양해 부탁드립니다.

운영체제 기본/Linux의 ELF 로딩/execve와 실행 이미지 교체

~ldmsys
관리자 (토론 | 기여)님의 2026년 10월 31일 (토) 08:44 판 (execve 공통 경로, binfmt 디스패치와 실행 이미지 교체 경계 설명)
(차이) ← 이전 판 | 최신판 (차이) | 다음 판 → (차이)

ELF 로딩 목차 · 다음: ELF 세그먼트와 주소 공간

이 절은 Linux v6.12의 x86-64 네이티브 실행 경로를 기준으로 한다. 실행 파일의 이름을 받은 커널이 파일을 열고, 실행 형식을 판별하고, 기존 실행 이미지를 새 이미지로 바꾸기로 결정하는 과정을 다룬다. ELF 세그먼트의 실제 매핑과 초기 사용자 스택 구성은 ELF 세그먼트와 주소 공간에서 이어서 설명한다.

새 프로세스를 만드는 것이 아니라 실행할 프로그램을 바꾼다

execve()는 호출한 프로세스의 실행 이미지를 교체하는 시스템 콜(System Call)이다. 프로세스를 새로 만드는 fork()나 clone()과 역할이 다르다. 셸이 자식 프로세스를 만든 뒤 자식에서 execve()를 호출하는 경우에도, 프로세스 생성은 앞선 단계에서 이미 끝났다. execve()가 하는 일은 그 자식이 실행할 코드, 데이터, 스택과 실행 시작 상태를 새 프로그램에 맞게 바꾸는 것이다.[1]

여기서 실행 이미지란 실행 파일의 바이트만을 뜻하지 않는다. 사용자 가상 주소 공간, 해당 공간에 배치할 프로그램과 스택, 최초로 사용자 모드에 진입할 때의 레지스터 상태까지 함께 생각해야 한다. 반면 프로세스 식별과 외부 관계가 모두 새로 생기는 것은 아니다. 프로세스의 PID, 즉 사용자에게 프로세스 ID로 보이는 스레드 그룹 ID는 유지된다. 부모 프로세스와의 관계도 이어진다. FD_CLOEXEC가 설정되지 않은 열린 파일 디스크립터도 보통 유지되므로, 셸이 미리 구성한 표준 입출력과 파이프를 새 프로그램이 사용할 수 있다.[1]

멀티스레드 프로세스에서는 이 설명에 주의가 필요하다. 실행을 요청한 스레드 이외의 스레드는 제거된다. 요청한 스레드가 기존 그룹 리더가 아니었다면, 커널은 그 스레드가 기존 리더의 PID를 이어받도록 식별자를 정리한다. 따라서 “프로세스 PID는 유지된다”와 “호출한 스레드의 TID도 그대로다”는 같은 말이 아니다. 이 작업은 de_thread()에서 수행한다.[2] 프로세스와 스레드의 식별 관계는 프로세스와 스레드를 참고한다.

두 진입점이 하나의 실행 준비 경로로 합쳐진다

execve()는 경로명과 인수 배열, 환경 배열을 받는다. execveat()는 여기에 기준 파일 디스크립터와 플래그를 더한다. 상대 경로를 특정 디렉터리 파일 디스크립터를 기준으로 해석할 수 있고, AT_EMPTY_PATH와 빈 경로를 사용하면 지정한 파일 디스크립터가 가리키는 실행 파일을 대상으로 삼을 수 있다. 둘은 실행 이미지 교체 규칙이 다른 시스템 콜이 아니라, 실행 대상을 지정하는 방식이 다른 진입점이다.[3]

두 시스템 콜은 do_execveat_common()에서 합류한다.[2]

execve                           execveat
  do_execve                        do_execveat
      \                            /
             do_execveat_common
               alloc_bprm
                 do_open_execat
                   do_filp_open
                 bprm_mm_init
               count / bprm_stack_limits / copy_strings
               bprm_execve
                 exec_binprm
                   search_binary_handler
                     prepare_binprm
                     security_bprm_check
                     fmt->load_binary
                       load_elf_binary  [ELF handler]

getname() 또는 getname_uflags()는 사용자 주소 공간의 경로명을 커널이 사용할 이름 객체로 가져온다. do_execveat_common()은 실행 요청의 공통 준비를 담당한다. 이 단계에서는 아직 기존 사용자 프로그램의 주소 공간을 파괴하지 않는다. 파일 열기 실패, 잘못된 인수 포인터, 지나치게 큰 인수 목록처럼 미리 확인할 수 있는 문제를 먼저 발견해야 하기 때문이다.

경로 문자열이 실행 대상 파일 객체가 되는 과정

alloc_bprm()은 do_open_execat()으로 실행 대상을 연 다음 struct linux_binprm을 할당한다. do_open_execat()은 실행 의도를 나타내는 __FMODE_EXEC, 접근 검사에 사용할 MAY_EXEC 등을 지정하여 do_filp_open()을 호출한다. 이때 실제 경로 탐색과 파일 열기는 VFS 경로를 따른다. 일반 파일인지 확인하는 검사는 이 버전에서 이미 하위 열기 경로의 may_open()으로 옮겨져 있으며, do_open_execat()은 path_noexec()도 검사한다.[2]

따라서 파일명 끝이 .elf인지 확인해서 실행하는 것이 아니다. 먼저 파일시스템의 경로 해석과 실행 허용 여부를 거쳐 struct file을 얻고, 이후 그 파일의 내용을 읽어 실행 형식을 판별한다. 경로명은 이름이고, 열린 파일 객체는 이번 로딩 작업이 참조하는 대상이다. 이 구분은 경로와 inode, 열린 파일 객체의 관계를 설명하는 VFS와 파일 객체와 연결된다.

또한 실행 가능 비트 하나만으로 모든 보안 판단이 끝나는 것도 아니다. 실행 파일을 여는 단계의 접근 검사 외에, 이후 LSM 검사와 자격 증명 준비 및 적용이 이어진다. 기존 프로세스가 추적되고 있는지, no_new_privs가 설정되어 있는지, 파일에 set-user-ID 등의 속성이 있는지에 따라 새 실행 이미지에 적용할 권한이 달라질 수 있다. bprm_execve()는 prepare_bprm_creds()로 새 자격 증명을 준비하고, 실행의 안전 조건과 보안 훅을 거쳐 로더를 호출한다. 권한이 최종 적용되는 단계까지 관련 동기화가 유지된다.[2] 권한 변경과 격리의 기본 원리는 보호를 참고한다.

linux_binprm은 교체 준비 상태를 보관한다

linux_binprm은 새 프로세스 자체가 아니라, 진행 중인 실행 요청의 준비 상태를 담는 객체다. 열린 실행 파일, 파일명과 인터프리터 정보, 인수와 환경의 개수, 새 주소 공간, 인수 문자열을 쌓는 위치, 형식 판별용 버퍼와 되돌아갈 수 없는 단계 진입 여부 등이 이 객체를 통해 연결된다.[2][4]

bprm_mm_init()은 새 mm_struct를 준비한다. MMU를 사용하는 x86-64에서는 인수 문자열을 보관할 임시 스택 VMA도 준비한다. 이때 새 주소 공간은 아직 current->mm을 대체하지 않았다. 기존 프로그램의 주소 공간을 유지한 채 새 이미지에 필요한 입력을 먼저 확보하는 것이다. 인수와 환경 문자열을 복사하는 작업은 나중에 완성될 사용자 스택의 준비 단계이며, 이 시점에 최종 argc, 포인터 배열, 보조 벡터까지 모두 완성되는 것은 아니다.[2]

do_execveat_common()은 argv와 envp의 원소 수를 세고, 스택과 인수 크기 제한을 확인한 뒤 실행 파일명, 환경 문자열, 인수 문자열을 복사한다. 사용자가 넘긴 포인터가 올바르지 않으면 이 과정에서 실패할 수 있다. 이러한 복사는 기존 주소 공간을 버린 뒤에도 새 프로그램의 시작 입력을 사용할 수 있게 만드는 데 필요하다. 임의로 큰 인수 목록을 허용하지 않는 것은 커널과 새 스택이 무제한 자원을 소비하지 않게 하기 위한 제한이다.

binfmt 디스패치는 파일 내용과 핸들러의 응답으로 결정된다

exec_binprm()은 search_binary_handler()를 호출한다. 후자는 먼저 prepare_binprm()으로 bprm->buf를 0으로 채우고 실행 파일의 처음부터 읽는다. v6.12의 BINPRM_BUF_SIZE는 256바이트다. 이 작은 버퍼는 파일 전체를 적재하는 공간이 아니라, 각 핸들러가 자신의 형식인지 판별할 수 있게 하는 공통 입력이다.[2][5]

그 다음 security_bprm_check()를 통과하면 등록된 linux_binfmt 목록을 순회하면서 각 핸들러의 load_binary(bprm)를 호출한다. 이 구조에서 형식 선택의 핵심은 다음과 같다.[2]

  • 핸들러가 -ENOEXEC를 반환하고 아직 교체 확정 단계에 들어가지 않았다면 다음 핸들러를 시도한다. 즉 이 값은 탐색 문맥에서 “이 형식으로 실행할 수 없다”는 신호다.
  • 다른 결과를 반환하면 무조건 다음 핸들러로 넘기지 않는다. 파일 형식이 맞더라도 접근 실패나 자원 부족 등이 발생했다면 그 실패를 실행 요청의 결과로 다룬다.
  • bprm->point_of_no_return이 설정되었다면 결과가 무엇이든 다른 핸들러로 재시도하지 않는다. 이미 기존 실행 상태를 해체하는 단계에 들어갔기 때문이다.

핸들러 호출 동안 목록 잠금을 계속 잡고 있는 것은 아니다. 커널은 해당 모듈의 참조를 확보하고 목록 잠금을 풀어 핸들러를 호출한 뒤, 다시 잠금과 참조를 정리한다. 실제 파일 읽기와 메모리 준비처럼 오래 걸리거나 잠들 수 있는 로딩 작업을 전체 형식 목록의 잠금 아래에서 수행하지 않으면서 핸들러의 수명을 보장하려는 구조다. 모듈 지원 구성에서는 인식 실패 후 바이너리 형식 모듈을 요청하고 제한적으로 다시 탐색하는 경로도 있다.

ELF 핸들러의 load_elf_binary()는 ELF 매직을 확인하고, ET_EXEC 또는 ET_DYN인지, 해당 아키텍처에 맞는지 등을 확인한다. 처음 256바이트만으로 로딩이 끝나는 것은 아니다. 핸들러가 ELF임을 받아들인 뒤에는 프로그램 헤더 등을 따로 읽어 메모리 배치에 필요한 검증과 준비를 계속한다.[6]

스크립트 인터프리터와 ELF 인터프리터는 다른 경로다

#!로 시작하는 파일은 binfmt_script의 load_script()가 처리한다. 이 핸들러는 스크립트 본문을 기계어처럼 메모리에 적재하지 않는다. 첫 줄에서 인터프리터 경로와 선택적 인수를 읽고, 새 인수 목록에 인터프리터 이름과 스크립트 경로를 넣은 뒤, 인터프리터 파일을 열어 bprm->interpreter에 지정한다.[7]

exec_binprm()은 이 결과를 보고 bprm->file을 인터프리터 파일로 교체한 다음 형식 탐색을 반복한다. 따라서 스크립트의 인터프리터가 ELF 실행 파일이면 다음 탐색에서 ELF 핸들러가 이를 처리한다. 스크립트가 또 다른 스크립트를 가리키는 등의 연쇄가 끝없이 이어지지 않도록 반복 깊이를 제한하며, v6.12 코드에서는 반복 시작 시 depth > 5를 검사하여 -ELOOP로 종료한다. [2]

반면 ELF의 PT_INTERP는 이미 선택된 ELF 핸들러 내부에서 해석하는 프로그램 헤더 항목이다. ELF 로더는 여기에 적힌 동적 링커 파일을 열고 검증한 뒤, 주 실행 파일과 함께 그 ELF 인터프리터를 매핑한다. 이 과정은 스크립트처럼 bprm->interpreter를 사용하여 전체 binfmt 탐색을 다시 시작하는 경로가 아니다. 동적 링커가 필요한 경우 최초 사용자 실행 주소는 보통 주 실행 파일의 진입점이 아니라 동적 링커의 진입점이 된다.[6]

script file
  -> binfmt_script
     -> rewrite arguments and select interpreter file
        -> search_binary_handler again
           -> binfmt_elf, if the interpreter is ELF

ELF file with PT_INTERP
  -> binfmt_elf
     -> map main ELF and interpreter ELF
        -> initial user entry in the interpreter

되돌아갈 수 없는 지점은 주소 공간 교체보다 먼저 온다

ELF 로더는 기존 이미지를 버리기 전에 실행 파일과 인터프리터의 헤더 등을 검사한다. 그러나 새 프로그램을 실행할 수 있다는 모든 조건을 기존 상태를 유지한 채 완전히 보장할 수는 없다. 기존 스레드 정리, 주소 공간의 전환, 새 매핑 구성 등은 상태를 실제로 바꾸는 작업이며, 그 이후에도 메모리 부족 같은 실패가 발생할 수 있다.[6][2]

v6.12의 경계는 load_elf_binary()가 호출하는 begin_new_exec() 안에 명시되어 있다. 이 함수는 파일에 따른 자격 증명 계산을 거친 뒤 bprm->point_of_no_return = true를 설정한다. 그 다음에 de_thread()로 다른 스레드를 종료시키고, 파일 테이블을 분리하고, exec_mmap()으로 새 주소 공간을 설치한다. 즉 되돌아갈 수 없다는 표시를 설정하는 시점과 current->mm을 교체하는 시점은 같지 않다. 다른 스레드를 없애기 시작한 이후에는 주소 공간만 그대로라고 해서 이전 프로그램을 정상 상태로 복구할 수 없기 때문이다.[2]

prepare and validate
  old image still usable on ordinary failure
             |
             v
begin_new_exec
  compute credentials
  point_of_no_return = true
  de_thread
  unshare_files
  exec_mmap(new_mm)
  reset execution state / close CLOEXEC descriptors
  commit credentials
             |
             v
ELF mappings and initial stack
  set initial user registers
             |
             v
return path to the new user image

exec_mmap()은 새 mm_struct를 현재 태스크의 주소 공간으로 연결하고 기존 주소 공간에 대한 참조를 정리한다. 기존 주소 공간이 다른 참조 없이 마지막으로 사용되었다면 해제되지만, 이를 “기존 사용자 페이지를 같은 물리 위치에서 새 파일 바이트로 덮어쓴다”라고 이해하면 안 된다. 핵심은 가상 주소 공간과 그 매핑 관계를 교체한다는 데 있다.

실패 의미도 이 경계를 기준으로 나뉜다. 확정 전의 일반적인 오류는 호출한 사용자 프로그램으로 돌아가 오류를 보고할 수 있다. 확정 뒤에는 이전 사용자 코드로 돌아가서는 안 된다. bprm_execve()의 오류 경로는 point_of_no_return이 설정되었고 이미 치명적 시그널이 대기 중인 것이 아니라면 SIGSEGV로 프로세스를 종료시키도록 한다. 따라서 “exec가 실패하면 언제나 원래 프로그램에서 오류 처리를 계속한다”는 설명도 엄밀하지 않다.[2]

이 구조는 실행 교체를 완전한 롤백 가능한 거래로 만들지 않는 대신, 가능한 검증과 입력 복사는 앞쪽에 두고 되돌릴 수 없는 구간은 명확히 표시한다. 형식별 로더는 교체를 확정하기 전에 자신의 형식인지 판단해야 하며, 확정 후 실패를 다른 형식의 핸들러가 만회하도록 넘겨서는 안 된다.

성공한 exec는 어디로 돌아가는가

사용자 API 관점에서 execve()는 성공하면 반환하지 않는다. 그러나 이것이 커널의 C 함수 호출이 전부 중간에서 사라진다는 뜻은 아니다. 로더가 성공하면 내부 함수들은 정리 경로를 거쳐 반환할 수 있다. 달라진 것은 사용자 모드로 나갈 때 사용할 주소 공간과 레지스터다.

ELF 로더의 마지막 부분은 START_THREAD를 통해 시작 상태를 설정한다. x86-64의 start_thread()와 start_thread_common()은 저장된 사용자 레지스터에 새 명령 포인터와 스택 포인터, 사용자 코드 및 스택 세그먼트 등을 설정한다. 따라서 이후 사용자 모드로 나갈 때는 원래 프로그램의 execve() 호출 다음 명령이 아니라 새 이미지의 시작 주소에서 실행한다. 이 함수 이름의 “thread”는 여기서 스레드를 하나 더 생성한다는 뜻이 아니다. 초기 스택과 복귀 레지스터의 상세한 구성은 초기 스택과 사용자 모드 진입에서 이어서 설명한다.[6][8]

첫 사용자 프로그램도 같은 로더를 사용한다

어떤 파일을 첫 사용자 프로그램으로 선택하는지, 루트 파일시스템이 언제 준비되는지는 루트 파일시스템과 init 선택에서 다룬다. 여기서는 선택이 끝난 뒤 공통 로더에 합류하는 경계만 살펴본다.

부팅 중 첫 사용자 프로그램을 실행하는 경로는 사용자 공간에서 시스템 콜 명령으로 들어오지 않는다. v6.12의 rest_init()은 user_mode_thread(kernel_init, ...)로 초기 태스크를 만들고, kernel_init() 쪽의 run_init_process()가 kernel_execve()를 호출한다.[9]

kernel_execve()는 커널 주소 공간에 있는 파일명과 인수 및 환경 문자열을 받는다. 따라서 사용자 포인터를 읽는 do_execveat_common()을 그대로 호출하지 않는다. getname_kernel(), alloc_bprm(), 커널 문자열용 개수 계산과 복사 함수를 사용한 뒤 bprm_execve()에서 일반 exec 경로와 합쳐진다. 이후 형식 탐색과 ELF 로딩, 새 이미지 구성은 공통이다. 또한 이 버전은 PF_KTHREAD가 설정된 일반 커널 스레드에서의 호출을 거부한다. “커널에서 호출한다”는 말만으로 모든 커널 스레드가 이 함수를 사용할 수 있다고 확대하면 안 된다.[2]

userspace execve                 kernel_init
  user-pointer argument copy      run_init_process
  do_execveat_common                kernel_execve
                                    kernel-string argument copy
            \                      /
                   bprm_execve
                     exec_binprm
                       binfmt handler
                         ELF loader

일반 exec와 PID 1의 첫 실행이 사용자 모드로 넘어가는 경로는 초기 스택과 사용자 모드 진입에서 이어진다.

참고 문헌

  1. ↑ 1.0 1.1 Linux man-pages, execve(2). 사용자 공간 인터페이스와 보존되거나 초기화되는 프로세스 속성.
  2. ↑ 2.00 2.01 2.02 2.03 2.04 2.05 2.06 2.07 2.08 2.09 2.10 2.11 2.12 Linux v6.12, fs/exec.c. do_execveat_common(), alloc_bprm(), bprm_execve(), exec_binprm(), search_binary_handler(), begin_new_exec(), de_thread(), exec_mmap().
  3. ↑ Linux man-pages, execveat(2). 경로 해석, AT_EMPTY_PATH, 스크립트와 close-on-exec의 제약.
  4. ↑ Linux v6.12, include/linux/binfmts.h. struct linux_binprm과 struct linux_binfmt.
  5. ↑ Linux v6.12, include/uapi/linux/binfmts.h. BINPRM_BUF_SIZE 및 인수 문자열 관련 상수.
  6. ↑ 6.0 6.1 6.2 6.3 Linux v6.12, fs/binfmt_elf.c. load_elf_binary(), load_elf_interp(), ELF 핸들러 등록.
  7. ↑ Linux v6.12, fs/binfmt_script.c. load_script()와 스크립트 핸들러 등록.
  8. ↑ Linux v6.12, arch/x86/kernel/process_64.c. start_thread()와 start_thread_common().
  9. ↑ Linux v6.12, init/main.c. rest_init(), kernel_init(), run_init_process().