.fini_array overwrite 기법을 공부하다 main 함수가 호출되기 전, 호출되고 종료되기까지의 과정을 정리해봐야겠다는 생각이 들어 작성하게 됐다.
-Main 함수 호출 전-
바이너리가 처음 실행될 시에는 ida로 바이너리를 열었을 때 흔히 보게 되는 _start 함수가 호출된다. 이 start 함수는 바이너리 실행 과정에서 필요한 여러 요소들을 초기화하기 위해 __libc_start_main 함수를 호출하게 된다.(__libc_start_main 함수는 libc에 존재하는 함수기 때문에 ida에서는 코드를 볼 수 없다.),
void __usercall __noreturn start(__int64 a1@<rax>, __int64 a2@<rdx>)
{
unsigned int v2; // esi
unsigned int v3; // [rsp-8h] [rbp-8h]
__int64 _0; // [rsp+0h] [rbp+0h]
v2 = v3;
*&v3 = a1;
_libc_start_main(main, v2, &_0, _libc_csu_init, _libc_csu_fini, a2, &v3);
}
디버깅을 해보거나 ida로 디컴파일한 위 _start 함수의 코드를 보면 __libc_start_main을 호출할 때 인자로 __libc_csu_init 함수 포인터가 들어가는 걸 볼 수 있는데, 이 __libc_start_main 안에서는 인자로 들어간 __libc_csu_init을 호출함으로써 실질적으로는 __libc_csu_init에서 초기화 작업이 진행된다. (이 __libc_csu_init은 return to csu 공격에 사용된다.)
void __libc_csu_init (int argc, char **argv, char **envp)
{
#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);
}
위는 dreamhack에 나와있는 __libc_csu_init의 코드다. 직접 디버깅을 해봤을 때는 #ifndef LIBC_NONSHARED 부분은 실행되지 않았다.
보면 init_array의 크기를 계산하고 크기만큼 for문을 통해 반복해 init_array에 저장된 함수 포인터들을 호출해준다.
__libc_csu_init를 호출해 필요한 요소들을 초기화한 뒤, main 함수를 호출하여 우리가 작성한 코드가 실행이 된다.
-Main 함수 호출 후 (종료 과정)-
main 함수가 종료된 후에는 __GI_exit라는 함수를 호출하게 되는데, 이 __GI_exit 함수에서는 __run_exit_handlers를 호출한다. 이 함수의 내부에서 exit_function이라는 구조체의 flavor 값에 따라 함수를 호출한다 하는데, 기본 상태에서는 로더 라이브러리 내부에 존재하는 _dl_fini 함수를 호출한 뒤 __GI__exit를 호출해 종료된다.
이 _dl_fini 함수에서는 .fini_array에 저장된 함수를 호출해주는 동작을 하는데, 이를 통해 fini array를 overwrite 함으로써 원하는 주소의 코드를 실행시키는 .fini_array overwrite 공격으로 연계될 수 있다.
-실습 (gdb를 통해 main 함수 호출 전 과정 확인)-
실습 환경은 wsl2 ubuntu 18.04다.
// gcc -no-pie -o temp temp.c
#include <stdio.h>
int main()
{
printf("Hello World!\n");
}
위는 실습에 사용할 바이너리의 소스코드다. 실행하면 Hello World!를 출력하는 간단한 동작을 한다.
바이너리의 헤더 정보를 출력해주는 readelf -h 명령어를 통해 Entry point 주소를 구할 수 있는데, 이 주소가 바이너리를 실행했을 때 맨 처음 호출되는 _start 함수의 주소다.
ida로 바이너리를 열어봤을 때 0x400400이 _start 함수의 주소가 맞는 것을 알 수 있다.
구한 주소를 바탕으로 _start 함수를 디버깅하다 보면 위 사진처럼 call QWORD PTR [rip+0x200bc6] 명령어가 있는 것을 볼 수 있는데, 이 rip+0x200bc6 주소는 동적으로 구해진 __libc_start_main의 주소가 들어있는 곳이다. 이때 레지스터를 확인해보면 __libc_csu_init의 함수 포인터가 인자로 들어가 있는 것을 확인할 수 있다.
__libc_start_main 함수로 들어와 디버깅을 진행하다 보면 위 사진처럼 call rbp 명령어가 있는 부분을 볼 수 있는데, 이때 rbp 레지스터의 값을 확인해보면 __libc_csu_init의 주소인 0x400500이 있는 것을 볼 수 있다. 이 부분이 __libc_csu_init을 호출하는 부분인 것이다.
__libc_csu_init으로 들어와 디버깅을 진행하다보면 위에서 본 __libc_csu_init 소스코드에서 init 함수를 호출하는 부분을 볼 수 있다.
디버깅을 좀 더 진행하면 위 사진과 같은 call 명령어를 만날 수 있다. 이 call 명령어는 r12+rbx*8을 연산한 주소에 저장돼 있는 주소의 함수를 호출해주는데, r12+rbx*8을 확인해보면 호출할 함수 포인터가 저장된 위치(r12+rbx*8)는 0x600e10인 것을 알 수 있다.
이 0x600e10을 ida에서 확인해보면 .init_array인 것을 알 수 있다. 저 call r12+rbx*8은 반복하면서 .init_array에 저장된 함수 포인터를 호출해주는 역할을 하는 것이다.
.init_array에 저장된 함수 포인터를 확인해보면 frame_dummy 함수를 가리키는 것을 알 수 있다.
_libc_csu_init 함수를 벗어나 __libc_start_main 함수에서 디버깅을 진행하다 보면 call rax 명령어와 call <__GI_exit> 명령어를 볼 수 있다. 여기서 rax 레지스터를 확인해보면 call rax 명령어는 main 함수를 호출해주는 역할을 한다는 것을 알 수 있고, call <__GI_exit>는 위에 정리했듯이 main 함수가 끝난 후에 호출하는 함수인 것을 알 수 있다.
-실습 (gdb를 통해 main 함수 호출 후 과정 확인)-
마찬가지로 실습환경은 wsl2 ubuntu 18.04다.
위 실습 마지막 부분에 본 __GI_exit 함수로 들어와서 디버깅을 진행한다면 __run_exit_handlers 함수를 호출하는 부분을 볼 수 있다.
__run_exit_handlers 함수로 들어와서 다시 디버깅을 진행한다면 call rdx 명령어를 볼 수 있는데, 이때 rdx 레지스터의 값을 확인해보면 _dl_fini의 주소가 들어있는 것을 볼 수 있다. 이 call rdx 명령어는 _dl_fini 함수를 호출해주는 역할을 한다.
_dl_fini 함수로 들어와 한참을 디버깅하다 보면 call QWORD PTR [r15] 명령어를 볼 수 있다.(이 명령어와, 주소는 libc 버전에 따라 다른 것 같다.)
r15 레지스터를 확인해보면 0x600e18이 들어있고, 이 0x600e18 주소에는 __do_global_dtors_aux라는 함수의 주소 0x4004b0가 저장되어 있다.
마찬가지로 ida를 통해 0x600e18을 확인해보면 .fini_array 섹션인 것을 볼 수 있고, _fini_array에 __do_global_dtors_aux의 주소가 들어있어, 이 함수를 실행시켜주는 것을 알 수 있다.
디버깅을 계속 진행하다 보면 fini 함수를 호출하는 부분도 발견할 수 있다. 참고한 사이트에서는 언급을 안 한 것을 보아 중요한 기능을 하는 함수는 아닌 것 같다.
호기심에 fini 함수 내부로 들어와 보니 딱 3줄만 존재하는 것을 알 수 있다. sub rsp, 0x8 명령어로 rsp의 값을 0x8 만큼 빼주지만, 그다음 줄에 add rsp, 0x8을 함으로써 다시 돌려놓는 것을 알 수 있다. 왜 있는 거지 ㅋㅋ
_dl_fini 함수의 디버깅을 모두 진행하고 다시 __run_exit_handlers로 돌아와 디버깅을 마저 한다면 __GI__exit 함수를 호출하는 것을 볼 수 있다.(위에서 호출한 __GI_exit 함수랑은 다르다. exit 앞에 _(언더바)가 하나 더 있다.)
이 함수를 실행하고 나서는 프로그램이 종료된다.
-마무리-
이 글은 복습 차 예전에 .init_array, .fini_array를 공부할 때 배운 내용을 복습 겸 작성한 것인데, 확실히 눈으로만 익힌 것과 글로 정리할 때의 해도가 달라진다는 것을 느꼈다. 나름대로 제대로 익혔다고 생각했지만 이 글을 작성하면서 어설프게 알고 있던 부분을 보완할 수 있었고, 무엇보다 직접 디버깅을 해가며 디버깅 결과에 대한 설명을 작성해보니 단순 외우기만 했던 것들이 뭔가 이해가 되는 느낌(?)이었다.
혹시라도 이 글을 보는 사람이 있다면, 보는 것으로만 끝내기보다는 직접 디버깅을 해서 확인해보는 것을 추천한다.(글로 정리하면 더 좋다.) 이 개념은 .fini_array overwrite 공격을 공부할 때 유용할 것이다.
위는 스택 오버플로우의 질문 글에서 찾은 바이너리 실행, 종료의 과정을 나타낸 그래프 사진이다. 이 사진이 지금까지 배우고 실습한 내용을 가장 잘 정리해준다 생각해서 가져왔다. (출처 : 스택 오버플로우)
참고한 사이트
'Old (2021.01 ~ 2021.12) > Pwnable' 카테고리의 다른 글
Return Address Overwrite (0) | 2021.07.11 |
---|---|
Buffer Overflow (0) | 2021.07.04 |
[Linux-x86] shellcode 제작 (0) | 2021.06.26 |
우리 집에 GDB 있는데 메모리 보고 갈래? (3) (0) | 2021.03.13 |
우리 집에 GDB 있는데 메모리 보고 갈래? (2) (0) | 2021.03.13 |