tmxklab
Linux Binary Execution Flow(main함수 호출 및 종료 과정) 본문
main()가 호출되고 종료되는 과정에 대한 지식이 필요하여 이번에 정리하게 되었다.
우리가 흔히 .c파일을 작성하여 컴파일 과정을 마치면 결과물로 linux실행파일인 elf파일이 생성된다.
이제 elf파일 실행할 때 내부적으로 어떻게 실행되는지 단계별로 살펴보자
목표 :
- elf파일(바이너리 파일)이 실행되어 종료되는 과정을 살펴본다
- 특히 main함수가 호출되고 종료되는 과정에 중점을 둔다.
- 너무 깊게 들어가지는 않을꺼다....
1. elf파일 실행
(이쪽 부분은 커널 영역이다 보니 자세하게 다루지 않음... 나중에 리눅스 커널쪽을 배우고 커널 디버깅을 할 수 있을 때 따로 정리하도록 하겠다. )
1) 쉘에서 ./a.out와 같이 elf파일을 실행시키면 새로운 프로세스를 fork
2) 새로운 프로세스가 execve()를 호출하면서 유저 모드에서 커널 모드로 전환
- 이 때, 파일 경로, 명령 인자, 환경 변수 등 프로그램 동작 시 필요한 정보를 넘겨 준다.
- ex) execve("./a.out", *argv[], *envp[])
3) system call handler에 의해 sys_execve()호출
ex) system call henadler
4) do_execve()호출
- 데이터 구조체(linux_binprm)에 필요한 정보(argv, envp, uid, gid..)를 설정
- search_binary_handler() 호출
+) struct linux_binprm
- 리눅스가 동시에 여러 개의 실행파일 포맷을 지원하기 위한 구조체
- 해당 구조체에는 실행파일 포맷 로더에 관한 함수 포인터 포함
do_execve flow :
5) search_binary_handler()
- 특정 파일에 적합한 바이너리 포맷을 찾기 위해 do_execve()끝에 사용
- 바이너리 포맷에 맞는 처리기 호출
- elf 파일인 경우 load_elf_binary() 호출
6) load_elf_binary()
-
elf 파일 실행 준비를 위해 여러가지 작업을 수행
→ elf 파일의 architecture 및 type 확인
→ elf 파일을 메모리에 매핑
→ 등등...(자세한 것은 찾아보길 바란다..)
-
load_elf_binay()를 실행한 후 start_thread()를 호출
-
start_thread()가 프로세스 실행을 시작
-
_start 루틴이 커널에서 실행의 제어를 넘겨 받게 됨
참고 자료)
- https://junsoolee.gitbook.io/linux-insides-ko/summary/syscall/linux-syscall-4
- https://m.blog.naver.com/PostView.nhn?blogId=ekthatkxkd&logNo=220823251720&proxyReferer=https:%2F%2Fwww.google.com%2F
- https://m.blog.naver.com/sdonghwi/90017862057
2. _start()
_start루틴 시작 전에 위와 같이 커널에서 몇 가지 작업을 수행하고 파일을 로드한 후 커널 모드에서 유저 모드로 넘어와 _start루틴이 시작된다.
_start루틴
따로 설정을 하지 않아도 main함수가 호출되기 전에 가장 먼저 실행되는 함수로 csu 또는 crt(C run time)루틴이라고 부른다. _start루틴은 커널로부터 받은 argc, argv인자를 저장하고 스택을 초기화한 후 glibc내에 정의된 __libc_start_main()를 호출한다.
glibc에서 제공하는 crt1.o는 elf파일의 entry point인 _start함수를 구현하고 elf형식의 .init, .fini섹션을 지원한다. 참고로 gcc 4.7 이상 버전부터 .ctors(constructor), .dtors(destructor)대신에 .init, .fini를 사용하고 이들은 main함수 호출 직전 및 종료 후에 실행되는 생성자, 소멸자이다. (.init / .fini섹션에 관련된 작업은 __libc_csu_init, fini함수에서 처리)
+) .init / .fini섹션
.init섹션(main()보다 먼저 실행)
- 오브젝트 파일 로드할 때 실행되는 코드(프로세스 초기화 코드)
- _init() 함수가 저장되어 있다.
.fini섹션(main()가 반환된 후 실행)
- 프로세스 종료 전에 실행되는 코드(프로세스 종료 코드)
- _fini() 함수가 저장되어 있다. → 특별한 일을 하지 않음
디버깅은 glibc 2.27 환경에서 진행하였고 glibc 버젼에 따라 약간의 차이가 존재할 수 있음
- $elfheader명령을 통해 각 섹션들의 주소를 확인할 수 있다.
- .init섹션 : 0x4008f8 → _init()주소 / .fini섹션 : 0x4012e4 → _fini()주소
(참고로 예전에 FTZ문제(level 11)에서 FSB를 사용하여 쉘 코드 주소를 뭔지도 모르고 .dtors에 넣었는데 이제 처음 알았음.. ㄷㄷ)
1) elf header를 통해 _start 확인
- Entry point address(0x400a60) : _start() address
- 해당 바이너리 파일을 시작하면 가장 먼저 Entry Point가 가리키는 _start함수를 호출한다.
2) _start함수 확인
- _libc_start_main()을 호출하기 전에 필요한 인자들을 레지스터에 셋업한다.
- _libc_start_main_ptr : _libc_start_main@got
- _libc_start_main@got에 __libc_start_main()의 실제 주소가 들어있다.
3. __libc_start_main()
_start()에서 필요한 인자들을 셋업해주고 __libc_start_main()를 호출하였다.
_libc_start_main()은 glibc에 존재하는 함수로 바이너리 실행과정에 필요한 여러 요소들을 초기화하고 실제로 main()를 호출해주는 역할을 한다. 추가로 _start에서 언급했던 .init / .fini섹션에 관련된 작업을 하는 __libc_csu_init, fini 를 __libc_start_main()에서 호출한다.
1) call __GI__cxa_atexit()
__libc_csu_init()을 호출하기전에 call하는 함수로 파라미터를 _dl_fini()를 받는다.(뒤에서 _dl_fini()나옴)
공유 라이브러리가 언로드될 때 호출할 함수를 등록하는 함수라는 정도만 알고 넘어가자
2) call __libc_csu_init()
먼저, main()을 호출하기 전에 __libc_csu_init()을 호출하여 초기화 과정을 거친다.
4. __libc_csu_init()
(이전에 가젯 제한으로 인해 RTC(Return to csu)기법을 이용한 문제를 풀면서 한 번 봤었던 함수이다. )
1) __libc_csu_init() 소스 코드
/* These functions are passed to __libc_start_main by the startup code.
These get statically linked into each program. For dynamically linked
programs, this module will come from libc_nonshared.a and differs from
the libc.a module in that it doesn't call the preinit array. */
void
__libc_csu_init (int argc, char **argv, char **envp)
{
/* For dynamically linked executables the preinit array is executed by
the dynamic linker (before initializing any shared object). */
#ifndef LIBC_NONSHARED
/* For static executables, preinit happens right before init. */
{
const size_t size = __preinit_array_end - __preinit_array_start;
size_t i;
for (i = 0; i < size; i++)
(*__preinit_array_start [i]) (argc, argv, envp);
}
#endif
#ifndef NO_INITFINI
_init ();
#endif
const size_t size = __init_array_end - __init_array_start;
for (size_t i = 0; i < size; i++)
(*__init_array_start [i]) (argc, argv, envp);
}
출처 : https://code.woboq.org/userspace/glibc/csu/elf-init.c.html#__libc_csu_init
__libc_csu_init()가 수행하는 주요 작업은 _init()를 호출하는 일이다. 추가적으로 __preinit_array 와 __init_array에 설정된 함수 포인터를 읽어서 함수를 호출하는 것을 확인할 수 있다. 그리고 _init()는 call_gmon_start()를 호출하고 call_gmon_start()는 _gmon_start_()가 존재할 경우 이거를 호출한다고만 알고 넘어가자
2) challenge파일 확인
challenge파일에서는 .init_array섹션 사이즈를 계산해서 .init_array에 저장된 함수 포인터들을 for문을 통해 순차적으로 호출하는 것을 확인할 수 있다. (RTC기법을 사용할 때 위 로직이 사용됨)
추가로 바이너리가 종료되는 시점에도 이와 비슷한 방식으로 .fini_array가 참조된다.(여기서 init_proc()는 _init()와 동일)
+) .init_array / .fini_array 섹션
.init_array 섹션
- 오브젝트 파일 로드할 때 실행되는 함수의 포인터 배열
.fini_array 섹션
- 프로세스 종료 전에 실행되는 함수의 포인터 배열
현재 challenge파일에서 각 .init_array와 .fini_array에 저장된 함수들을 확인할 수 있다.
_init()를 호출하고 .init_array에 저장된 함수들을 순차적으로 호출하는 것을 확인할 수 있다. 여기서는 .init_array배열 처음에 들어있는 frame_dummy() 하나만 호출하고 리턴된다.
__libc_csu_init()루틴이 종료되면 다시 __libc_start_main()으로 돌아오고 드디어 main()를 호출한다.
5. .fini_array섹션
__libc_csu_init()이 main()실행 전에 호출되는 함수로 프로그램 실행 전 초기화를 위해 .init섹션에 저장된 _init()를 호출하고 .init_array배열에 들어있는 함수들을 순차적으로 호출하는 것이라고 하였다.
반대로 __libc_csu_fini()는 main()가 리턴되고 프로그램을 정상적으로 종료시키기 위해서 .fini섹션과 .fini_array에 대한 처리를 하는 역할을 한다.
그런데 실제로 메인함수가 종료되면 __GI_exit()를 통해 .fini_array섹션을 참조한다.
main함수가 리턴되고 __libc_start_main()으로 돌아와 __GI_exit()를 호출한다.
이 때, 파라미터로 0x0을 받는 것을 알 수 있는데 우리가 흔히 .c파일을 작성할 때 main() 마지막에 return 0; 으로 작성하는데 여기서 return값 0이 exit()의 파라미터로 들어가게 되는 것이다.
_GI_exit()루틴 내부에서는 __run_exit_handlers()를 호출한다.
__run_exit_handlers()루틴 내부에서는 rdx에 저장된 _dl_fini()를 호출하게 된다.
__run_exit_handlers()는 exit_function구조체 멤버 변수인 flavor값에 따라 함수를 호출한다고 하는데 기본적으로 glibc내부에 존재하는 _dl_fini()를 호출한다.
_dl_fini()내부 루틴이 쭉쭉 실행되다가 r15(0x601e00)에 들어있는 함수를 호출한다.
r15는 .fini_array섹션을 가리키며 .fini_array배열에 들어있는 __do_global_dtors_aux()를 호출하는 것을 확인할 수 있다.
.fini_array배열에 있는 함수들을 호출하고 다시 _dl_fini()로 돌아와 _fini()를 호출하는 것을 볼 수 있다.
그런데 아까 위에서 말했듯이 딱히 _fini()에서 하는 일이 없다.
_dl_fini()가 리턴되고 다시 __run_exit_handlers()루틴으로 돌아오면
__GI__exit()를 호출한다.(처음에 실행한 __GI_exit()랑 다름, 가운데에 '_'가 2개임)
__GI__exit()에서는 rax값을 0xe7, rdi를 0x0으로 세팅하고
syscall하면서 이제 완전히 프로세스가 종료된다.
6. 정리
1) 쉘을 통해서 elf파일을 실행하면 user단에서 kernel에게 요청을 하게 되고 kernel은 elf파일을 실행시키기 위해 몇 가지 복잡한 작업을 거치게 된다.
2) kernel에서 작업을 마치면 파일을 메모리에 로드하고 elf파일의 entry point가 가리키는 _start()부터 호출하여 시작하게 된다.
3) _start루틴(crt(C run time))에서는 커널로부터 받은 argc, argv인자를 저장하고 스택을 초기화한 후 glibc내에 정의된 __libc_start_main()를 호출한다.
4) __libc_start_main()에서는 .init / .fini섹션 작업과 관련된 함수들을 호출하고 메인함수를 호출한다.(.init → main() → exit())
지금까지 쉘을 통해 elf파일을 실행시키고 main함수가 호출되기 전부터 종료되기까지 과정을 살펴봤는데 진짜 너무 복잡한 것 같다.ㅋㅋㅋㅋ 나중에 기회되면 리눅스 커널이랑 커널 디버깅도 공부해보고 싶다.
(혹시 틀린 내용있으면 말씀해주세여.. )