C++ 구조체(구조체나 클래스나 C++에서는 거의 같으니 하나만 하겠다)에서 가상 함수를 사용하는 경우가 있다. 대표적으로 C++의 다형성이 구현되는 부분이라고 할 수 있겠는데, 자식 클래스에서 부모 클래스를 상속받아 가상함수를 오버라이딩해서 사용하곤 한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;
 
#pragma pack(push, 1)
struct A{
    int i;
    char ch[0x10];
    virtual void vfoo(){}
    virtual void vbar(){}
    virtual void vbaz(){}
};
#pragma pack(pop)
 
int main()
{
    cout << sizeof(A) << endl;
    return 0;
}
cs



 위와 같은 코드를 생각해 보자.

64비트 환경 기준으로, 위 코드를 빌드하고 실행했을 때, 어떤 결과가 나올까? 

pragma pack은 컴파일러가 자동으로 붙여주는 더미를 없애주는 역할을 한다.


나는 처음에는 가상함수 포인터 세개로 8*3 = 24바이트, int형 4바이트, char 배열 16바이트로 총 44바이트가 나올 거라고 예상했다. 하지만 사실은 28바이트밖에 되지 않는다. 그 이유를 살펴보면, 우선 int 형 4바이트와 char 배열 16바이트는 맞지만 가상함수는 아무리 많이 선언되어도 가상함수 테이블이라는 곳을 가리키는 포인터 하나밖에 할당되지 않아 8바이트만 차지하기 때문에 4+16+8 = 28이 되는 것이다.


그럼 가상함수 테이블이란 무엇일까?

 우리가 가상함수를 사용할 때, 함수를 호출하기 위해서 로우레벨로, 어셈블리 레벨까지 내려가 보자.

보통 함수를 호출하기 위해서 call 명령을 사용한다. 일반적인 경우, call 명령을 사용하기 위해서는 스택에 A 구조체를 할당했다고 가정했을 때, A의 주소를 레지스터에 담고, 해당 주소에서 함수 포인터까지의 옵셋을 구해서 다시 레지스터에 담고, 그대로 call을 하면 된다.


하지만 가상함수를 사용할 경우, 중간에 가상함수 테이블이라는 곳을 한번 더 거친다.


위의 A 객체를 힙에 할당한다고 가정하면, 다음과 같은 힙 상태가 만들어진다.


[ ... ] [ Virtual Function (8 bytes) ] [ i (4 bytes) ] [ ch (16 bytes) ] [ ... ]


 이렇게 가상함수 테이블 포인터는 항상 할당되는곳의 첫 번째에 위치하게 된다.

가상함수 내부에 들어있는 함수를 호출하는 코드를 확인해 보자.

두 번째로 선언한 vbar을 호출해 보면, 어셈블리로 다음과 같이 나타난다.


mov    -0x18(%rbp),%rax

mov    (%rax),%rax

add    $0x8,%rax

mov    (%rax),%rax

mov    -0x18(%rbp),%rdx

mov    %rdx,%rdi

callq  *%rax


힙 영역에 할당된 주소가 들어 있는 %rbp-0x18에서 값을 꺼내어 %rax에 넣는다.

이로써 %rax에는 힙 영역에 할당된 A의 주소가 들어간다.

다음으로 바로 (%rax)를 통해 %rax 내부의 값을 꺼내어 다시 %rax에 집어넣는다.

여기서 %rax에는 가상함수 테이블(vTable) 주소가 들어갔다.

이제 %rax에 +8을 해주고 나서 다시 %rax 내부의 값을 꺼내서 %rax에 집어넣었다.

이제 %rax 내에는 *(vTable+0x8)의 값이 들어갔다.

그리고 call 명령을 통해 %rax 내부의 값을 집어넣고 함수를 실행한다.


 이를 보면 알 수 있겠지만, 가상함수 테이블이라고 하는 것은 선언된 가상함수들을 순서대로 집어넣어두는 하나의 배열이라고 볼 수 있다. 가상함수를 사용할 때는 이 가상함수 테이블을 이용하여 참조한다.


이제 가상함수의 다형성이 드러나는 부분을 살펴보자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
using namespace std;
 
struct A {
    virtual void vfoo() {
        cout << "A::vfoo()" << endl;
    }
    virtual void vbar() {
        cout << "A::vbar()" << endl;
    }
    virtual void vbaz() {
        cout << "A::vbaz()" << endl;
    }
 
    ~A(){
       cout << "A::Destructor()" << endl; 
    }
};
 
struct Z : A {
    virtual void vbaz(){
        cout << "Z::vbaz()" << endl;
    }
    
    ~Z(){
         cout << "Z::Destructor()" << endl; 
    }
};
 
int main()
{
    Z* z = new Z;
    A* a = z;
    a->vbaz();
    delete z;
    return 0;
}
 
cs


좀 코드가 길긴 하지만, 별로 어려운 코드는 아니다.

여기서 예를 들어 A의 가상함수 테이블의 주소가 0xD000이라고 치자.

또, Z의 가상함수 테이블의 주소가 0xD100이라고 치자.


A의 가상함수 내부를 대충 그려보면

0xD000 :[ &A::vfoo() ] [ &A::vbar() ] [ &A::vbaz() ]

이런 식으로 되어 있을 것이다.


Z는 A를 상속받아서 vbaz 함수를 오버라이딩했다.

이럴 경우 Z는 가상함수 테이블이 따로 할당된다. 이를 대충 그려보면

0xD100 : [ &A::vfoo() ] [ &A::vbar() ] [ &Z::vbaz() ]

이렇게 되어 있다.


보는 바와 같이, 오버라이딩하지 않은 가상함수는 테이블에 부모의 가상함수 주소가 그대로 할당되며

오버라이딩된 가상함수 주소만 바뀌고 오버라이딩한 함수로 대체된다.


위에서 보이듯이 자식인 Z의 객체로 z를 생성하고, 이를 부모인 A의 포인터에 집어넣을 수 있다.

여기서 a->vbaz() 를 실행하면 어떤 함수가 실행되는 것일까?

부모의 함수인 A::vbaz() 인가, 자식의 함수인 Z::vbaz() 인가?


지금까지의 과정을 생각해보면 간단히 답이 나올 것이다.

자식 객체는 부모 객체와 따로 가상함수 테이블의 주소를 갖게 되고, 이는 다른 포인터로 주소를 옮겨 담는다고 바뀌거나 하지 않으니

a->vbaz() 를 실행해도 Z로 생성하여 할당한 객체인 이상 Z::vbaz() 가 실행된다.


이것이 객체지향 프로그래밍의 특징 중 하나인 다형성이다.

그리고 이런 가상함수 테이블은 인스턴스마다 새로 할당하면 메모리의 낭비이므로 같은 가상함수 테이블 주소를 모든 인스턴스가 똑같이 참조한다.

위와 같이 오버라이딩이 일어날 때에만 새로 가상함수 테이블을 할당하여 새로 만들어내는 것이다.


여기서 유의할 점이 하나 있다.

마지막에 객체를 해제할 때, delete 키워드를 이용하여 객체를 할당 해제시키는데,

a->vbaz나 z->vbaz나 같았다는 점과 똑같이 생각하여 같은 주소를 가리키고 있는 a를 z 대신,

즉 delete z 대신 delete a를 사용해 부모 객체로 선언된 포인터를 해제하면 절대 안 된다.


그 이유는 바로 클래스의 소멸자 때문이다.

위에서 A와 Z에는 각각 소멸자가 존재하고, delete z를 이용하여 객체를 할당 해제했을 경우 자식과 부모의 소멸자가 둘 다 실행된다.

즉, ~Z()와 ~A() 가 모두 실행되는 것이다.

하지만 delete a를 통해 객체를 할당 해제했을 경우에는 부모의 소멸자만 진행된다. ~Z는 실행되지 않고 ~A만 실행되는 것이다.

만약 ~Z에(Z객체의 내부에 포인터가 있다고 가정했을 때) free나 delete를 통해 할당된 메모리를 해제하는 부분이 있었다면

이곳에서 심각한 메모리 누수가 발생하게 된다.

이를 이해하고 부모 클래스의 포인터에 자식 클래스를 담을 경우에는 조심해서 사용해야 한다.

(실제로 delete a, delete z를 두번 하여 버그가 발생하는 일도 빈번하다고 한다.)




이런 가상함수 테이블은 모두 힙에 할당되므로 해커 입장에서 봤을 때

가상함수 테이블 내부 주소를 변조하여 원하는 함수를 실행하도록 하는 것도 가능하다.


약간 GOT Overwrite와 비슷한 개념이지만, 나중에 따로 공부하고 시도해 보는 것도 괜찮을 것 같다.

'Programming' 카테고리의 다른 글

[VHDL] D Flip-Flop  (0) 2016.02.11
파이썬 웹 이미지 크롤러 (GUI)  (0) 2016.02.03
[C++] 가상함수(Virtual Function)  (0) 2016.01.20
Assembly Programming - atoi  (0) 2015.10.11
Assembly Programming - isAlpha, isNumber  (0) 2015.10.11
Assembly Programming - gets  (0) 2015.10.10
블로그 이미지

__미니__

E-mail : skyclad0x7b7@gmail.com 나와 계약해서 슈퍼 하-카가 되어 주지 않을래?

댓글을 달아 주세요