2018년 5월 11일 금요일

[펌] 전역 생성자, 전역 소멸자에 대해

=======================================================================================
Title: gcc-4.1.1/gcc/crtstuff.c 코드 분석을 통한 전역 생성자, 소멸자 분석
Author : 유동훈 (Xpl017Elz) in INetCop
E-mail : szoahc@hotmail.com
Home: http://x82.inetcop.org
=======================================================================================


- 전역 생성자, 전역 소멸자란 무엇인가?

C의 전역 생성자, 전역 소멸자는 C++에서 제공하는 클래스 생성자, 소멸자와 유사한 개념이다.
main 함수 이전과 이후로 전역 개체를 핸들링하기 위해 gcc에서는 두 가지 함수 속성 타입을
제공한다. 이는 main 함수 이전에 개체를 초기화하는 전역 생성자와, main 함수 종료 후
개체를 소멸하는 전역 소멸자로 나뉜다.

--
전역 생성자 함수 속성 타입: __attribute__ ((constructor));
전역 소멸자 함수 속성 타입: __attribute__ ((destructor));
--

전역 생성자와 전역 소멸자가 언제 어떻게 호출되는지 알아보도록 하겠다.



- 함수 호출 순서 분석

함수 호출 순서를 분석을 통해 우리는 전역 생성자와 전역 소멸자가 언제 호출되는지 정확히
알 수 있다. 또한, 어떤 함수가 이들을 핸들링하는지도 정확히 분석할 수 있다. gcc-4.1.1,
glibc-2.5 버전을 사용하는 Fedora core 6 시스템에서 분석한 결과이다.

--
_start 함수 (사용자 프로그램)
|
+- __libc_start_main() 호출 (libc.so)
   |
   |<__libc_start_main+127>:       call   *%edi
   +- __libc_csu_init() 호출 (사용자 프로그램)
   |  |
   |  +- _init() 호출 (사용자 프로그램)
   |     |
   |     +- __do_global_ctors_aux() 호출 (사용자 프로그램) - 전역 생성자
   |
   |
   |<__libc_start_main+217>:       call   *0x8(%ebp)
   +- main() 함수 호출 (사용자 프로그램)
   |  |
   |  +- libc.so 라이브러리 함수 재배치 수행 (※ 재배치 문서 내용 참고할 것)
   |
   |
   |<__libc_start_main+223>:     call   0x81a850 <exit>
   +- exit() 함수 호출 (libc.so)
      |
      |
      |<exit+231>:    call   *%eax
      +- _dl_fini() 함수 호출 (ld-linux.so)
         |
         |
         |<_dl_fini+480>:        call   *%eax
         +- _fini() 함수 호출 (사용자 프로그램)
            |
            +- __do_global_dtors_aux() 호출 (사용자 프로그램) - 전역 소멸자
--

참고로, 수행에 중요한 부분만 간추린 것이며, 전역 생성자와 소멸자를 핸들링하는 함수를
찾을 수 있게 되었다. 전역 생성자는 __do_global_ctors_aux() 함수에 의해서 다뤄지고,
전역 소멸자는 __do_global_dtors_aux() 함수에 의해서 다루어진다. init() 호출과
exit()->fini() 호출에 의해 수행된다는 점을 기억하자.



- 전역 생성자, 소멸자를 사용하는 프로그램 예제와 디버깅 결과

그럼, 실제로 전역 생성자와 소멸자를 다루는 간단한 프로그램을 소개하도록 하겠다.

--
[root@localhost tmp]# cat test.c
#include <stdio.h>

void __attribute__((constructor)) ctors_1()
{
        printf("ctors_1();\n");
}

void __attribute__((constructor)) ctors_2()
{
        printf("ctors_2();\n");
}

int main()
{
        printf("main();\n");
}

void __attribute__((destructor)) dtors_1()
{
        printf("dtors_1();\n");
}

void __attribute__((destructor)) dtors_2()
{
        printf("dtors_2();\n");
}

[root@localhost tmp]# gcc -o test test.c
[root@localhost tmp]# ./test
ctors_2();
ctors_1();
main();
dtors_1();
dtors_2();
[root@localhost tmp]#
--

결과를 살펴보면, constructor 속성 함수는 main() 함수 이전에 호출되었고, destructor 속성
함수는 main() 함수 이후에 호출된 것을 볼 수 있다. 여기서 재미있는 점은 전역 생성자
함수의 경우, 맨 나중에 선언된 전역 생성자 함수가 제일 처음 호출된다는 점이다. 어떤
내용을 참고하여 각 호출이 이루어지는지 다음 디버깅 결과를 살펴보면 한 눈에 확인할 수
있다.

--
[root@localhost tmp]# objdump -h test | grep -e ctors -e dtors
 15 .ctors        00000010  080494e8  080494e8  000004e8  2**2
 16 .dtors        00000010  080494f8  080494f8  000004f8  2**2
[root@localhost tmp]# gdb -q test
(no debugging symbols found)
Using host libthread_db library "/lib/libthread_db.so.1".
(gdb) x &__CTOR_LIST__
0x80494e8 <__CTOR_LIST__>:      0xffffffff
(gdb)
0x80494ec <__CTOR_LIST__+4>:    0x0804837c ; ctors_1() 함수
(gdb)
0x80494f0 <__CTOR_LIST__+8>:    0x08048394 ; ctors_2() 함수
(gdb)
0x80494f4 <__CTOR_END__>:       0x00000000
(gdb) x/i 0x0804837c
0x804837c <ctors_1>:    push   %ebp
(gdb) x/i 0x08048394
0x8048394 <ctors_2>:    push   %ebp
(gdb) x/x &__DTOR_LIST__
0x80494f8 <__DTOR_LIST__>:      0xffffffff
(gdb)
0x80494fc <__DTOR_LIST__+4>:    0x080483da ; dtors_1() 함수
(gdb)
0x8049500 <__DTOR_LIST__+8>:    0x080483f2 ; dtors_2() 함수
(gdb)
0x8049504 <__DTOR_END__>:       0x00000000
(gdb) x/i 0x080483da
0x80483da <dtors_1>:    push   %ebp
(gdb) x/i 0x080483f2
0x80483f2 <dtors_2>:    push   %ebp
(gdb)
--

디버깅 결과를 통해 우리가 선언한 함수들이 __CTOR_LIST__ 섹션과 __DTOR_LIST__ 섹션에
포인터로 적재되어 있는 것을 볼 수 있다. 그럼, 왜 이런 구조를 갖는지
gcc-4.1.1/gcc/crtstuff.c 코드 분석을 통해 답을 찾아보도록 하겠다.



- gcc-4.1.1/gcc/crtstuff.c 코드 분석

우리는 crtstuff.c 코드 전체를 분석할 필요가 없다. 이들 중 __CTOR_LIST__ 섹션과
__DTOR_LIST__ 섹션을 참조하여 수행되는 실속있는 코드들만 분석해보면 쉽게 답을 얻을 수
있다. 먼저, 전역 생성자를 핸들링하는 __do_global_ctors_aux() 함수이다.

-- __do_global_ctors_aux() 함수 --
...
   145  /*  Declare a pointer to void function type.  */
   146  typedef void (*func_ptr) (void);
...
   513  static void __attribute__((used))
   514  __do_global_ctors_aux (void)
   515  {
   516    func_ptr *p;
   517    for (p = __CTOR_END__ - 1; *p != (func_ptr) -1; p--)
   518      (*p) ();
   519  }
--

참고로, func_ptr은 void 형을 뜻한다. 잘 살펴보면, __CTOR_END__-1(워드 단위) 위치를
기준으로 p의 값이 -1(0xffffffff)이 나올 때까지 검사하며, p(__CTOR_LIST__ 섹션)에 위치한
함수들을 하나씩 호출하는 것을 볼 수 있다. 결국, __CTOR_LIST__ 섹션 시작 부의 0xffffffff 
값을 만나면 전역 생성자 함수 호출을 멈추게 된다. 이러한 이유로 constructor에 예약된
전역 생성자 함수 중 맨 나중에 선언된 함수가 제일 처음으로 수행된 것이다.

다음은 해당 코드를 disassemble 한 결과이다.

-- __do_global_ctors_aux() 함수 --
<__do_global_ctors_aux+0>:   push   %ebp
<__do_global_ctors_aux+1>:   mov    %esp,%ebp
<__do_global_ctors_aux+3>:   push   %ebx
<__do_global_ctors_aux+4>:   push   %edx
<__do_global_ctors_aux+5>:   mov    0x80494f0,%eax // (1) __CTOR_LIST__ 내용(값) 복사
<__do_global_ctors_aux+10>:  cmp    $0xffffffff,%eax // (2) -1인지 검사
<__do_global_ctors_aux+13>:  je     0x8048485 <__do_global_ctors_aux+33>
<__do_global_ctors_aux+15>:  mov    $0x80494f0,%ebx // (3) 주소 위치 복사
<__do_global_ctors_aux+20>:  call   *%eax // (4) 호출
<__do_global_ctors_aux+22>:  mov    0xfffffffc(%ebx),%eax // (5) -4 위치 내용(값) 복사
<__do_global_ctors_aux+25>:  sub    $0x4,%ebx // (6) -4 만큼 감소
<__do_global_ctors_aux+28>:  cmp    $0xffffffff,%eax // (7) -1인지 검사
<__do_global_ctors_aux+31>:  jne    0x8048478 <__do_global_ctors_aux+20>
<__do_global_ctors_aux+33>:  pop    %eax
<__do_global_ctors_aux+34>:  pop    %ebx
<__do_global_ctors_aux+35>:  leave
<__do_global_ctors_aux+36>:  ret
<__do_global_ctors_aux+37>:  nop
<__do_global_ctors_aux+38>:  nop
<__do_global_ctors_aux+39>:  nop
--

0x80494f0 주소는 __CTOR_END__ -1(워드 단위) 위치이며, 이 주소가 가진 값을 %eax
레지스터에 넣고 call *%eax 코드로 해당 위치에 있는 함수를 호출한다. %ebx 레지스터는
__CTOR_END__-1 위치부터 4byte씩 자신을 감소시키며, 자신이 가지고 있는 주소 값을 %eax
레지스터에 복사하는 역할을 한다. 결과적으로, %eax 레지스터에 -1(0xffffffff) 값이 나올
때까지 함수를 반복 호출하게 된다.

그럼, 이번에는 전역 소멸자를 핸들링하는 __do_global_dtors_aux() 함수를 살펴보자.

-- __do_global_dtors_aux() 함수 --
...
   145  /*  Declare a pointer to void function type.  */
   146  typedef void (*func_ptr) (void);
...
   260  static void __attribute__((used))
   261  __do_global_dtors_aux (void)
   262  {
   263  #ifndef FINI_ARRAY_SECTION_ASM_OP
   264    static func_ptr *p = __DTOR_LIST__ + 1;
   265    func_ptr f;
   266  #endif /* !defined(FINI_ARRAY_SECTION_ASM_OP)  */
   267    static _Bool completed;
   268
   269    if (__builtin_expect (completed, 0)) // 0인지 검사
   270      return;
...
   281    while ((f = *p))
   282      {
   283        p++;
   284        f (); // 해당 위치 값이 null이 아닐 경우 함수 호출
   285      }
...
   300    completed = 1; // 함수 내용이 두 번 이상 호출되지 않도록
   301  }
--

__DTOR_LIST__ +1(워드 단위) 위치부터 함수 호출을 시작하며, completed 섹션의 값이 0인지
검사 후, 함수 호출을 시작한다. 1일 경우, 이미 __DTOR_LIST__ 섹션에 있는 함수들이 수행된
것으로 간주하고 함수 수행을 중단한다. 함수 호출은, p 섹션 위치를 4byte씩 증가시켜
반복적으로 호출하며, null일 경우 함수 호출을 중단하게 된다. 앞서, constructor와는 달리,
destructor는 예약된 함수를 순서대로 호출하는 것을 볼 수 있다.

다음은 해당 코드를 disassemble 한 결과이다.

-- __do_global_dtors_aux() 함수 --
<__do_global_dtors_aux+0>:   push   %ebp
<__do_global_dtors_aux+1>:   mov    %esp,%ebp
<__do_global_dtors_aux+3>:   sub    $0x8,%esp
<__do_global_dtors_aux+6>:   cmpb   $0x0,0x80495fc // (1) completed 섹션 값이 0인지 검사
<__do_global_dtors_aux+13>:  je     0x804833e <__do_global_dtors_aux+30>
<__do_global_dtors_aux+15>:  jmp    0x8048350 <__do_global_dtors_aux+48>
<__do_global_dtors_aux+17>:  lea    0x0(%esi),%esi
<__do_global_dtors_aux+20>:  add    $0x4,%eax // (5) %eax 4byte 증가
<__do_global_dtors_aux+23>:  mov    %eax,0x80495f8 // (6) p 섹션에 증가한 주소를 복사
<__do_global_dtors_aux+28>:  call   *%edx // (7) 함수 호출
<__do_global_dtors_aux+30>:  mov    0x80495f8,%eax // (2) p 섹션 값을 %eax에 복사
<__do_global_dtors_aux+35>:  mov    (%eax),%edx // (3) %eax가 가진 값을 %edx에 복사
<__do_global_dtors_aux+37>:  test   %edx,%edx // (4) 해당 위치에 값이 있는지 검사
<__do_global_dtors_aux+39>:  jne    0x8048334 <__do_global_dtors_aux+20>
<__do_global_dtors_aux+41>:  movb   $0x1,0x80495fc
<__do_global_dtors_aux+48>:  leave
<__do_global_dtors_aux+49>:  ret
--

0x080495fc는 completed 섹션이며, 이 값이 0인지 검사하여 수행을 여부를 결정 짓는다.
0x080495f8는 p 섹션이며, __DTOR_END__+1(워드 단위) 위치 주소 값을 가지고 있다. 이 주소를
%eax에 넣고, 해당 위치 값을 %edx에 넣어 null인지 검사한다. 호출할 예약 함수가 있는 경우,
4byte만큼 증가시킨 %eax 레지스터를 p 섹션에 복사하므로써, 다음 위치에 있는 함수가
반복적으로 호출되도록 구성한다.



- 정리

지금까지 전역 생성자 constructor와 전역 소멸자 destructor에 대해 알아보았다. 전역 생성자와
소멸자, 재배치 부분은 사용자 프로그램 내에서 매우 중요한 역할을 하고 있다. 부디 이 글을
통해 한층 더 재미있고 즐거운 exploit 작성에 도움이 되었길 빈다.


- 레퍼런스

최호진 - main 함수 이전에 일어나는 일에 대한 간단한 추적
Guido Bakker - Overwriting ELF .dtors section to modify program execution


댓글 없음:

댓글 쓰기