티스토리 뷰

오버라이딩(Overriding)

가상함수를 이해하기 위해선 오버라이딩(Overriding) 에 대해서 알아야 한다.

SourceCode(1)

class Parent{
    void show(){
        printf("this is parent\n");
    }
}

class Child : public Parent{
    void show(){
        printf("this is child\n");
    }
}

class ChildChild : public Child{
    void show(){
        printf("this is childchild\n");
    }
}

int main(){
    Parent * p = new Parent;
    Child * c = new Child;
    ChildChild * cc = newChildChild;

    p->show();
    c->show();
    cc->show();
}

Result

this is parent
this is child
this is childchild

결과를 보면 상속받은 부모 객체의 함수가 실행되는게 아닌, 자식 클래스에서 정의된 함수가 호출되고 있다. 이렇게 상속받은 함수를 자식 클래스에서 새로 정의하는 것을 오버라이딩(Overriding)이라고 하며, 리턴타입과 함수 인자 구성이 똑같아야 한다.

SourceCode(2)

p = c;
p->show();
p = cc;
p->show();

Result

this is parent
this is parent

Parent 타입으로 선언된 p 포인터 변수에 Child 객체의 주소를 넣고, 함수를 호출하면 Child 객체의 함수가 호출되는 것이 아니라 Parent 클래스에 정의되어 있는 show 함수가 호출된다. 단순히 함수를 오버라이딩할 경우, Parent 타입의 p 포인터 변수에 Child 객체 주소를 넣더라도 컴파일러는 p 변수가 Parent 타입이기 때문에 p 변수에는 Parent 의 show() 함수가 바인딩 된 상태로 빌드가 끝나버린다는 것이다.

빌드된 바이너리를 실행하면 바인딩 된 상태로 실행되기 때문에 Parent 타입의 변수에 다른 객체를 넣더라도, Parent 의 함수가 호출되는 것이다.

함수뿐만 아니라 멤버 변수 역시 바인딩 대상이기 때문에 멤버 함수와 같이 동작된다.

일반 멤버 함수의 오버라이딩은 부모의 함수를 은닉(Hiding) 하는 특징이 있다.

SourceCode(3)

class Parent{
    void show(){
        AAA
    }

    void show(int a){
        CCC
    }
}

class Child : public Parent{
    void show(){
        BBB
    }
}

int main(){
    Child * c = new Child;
    c->show();
    c->show(10); // Error!
}

이런 코드가 있을 때, Child 는 Parent 를 상속받았지만 부모인 Parent의 show() 함수는 호출되지 않고, 숨어버린다. 뿐만 아니라 부모 클래스에서 오버로딩된 함수까지 자식 객체에서 호출할 수 없게된다. 때문에 이렇게 오버라이딩 하는 경우를 하이딩(Hiding) 이라고 한다고 한다. 말 그대로 부모 클래스의 멤버를 통으로 숨겨버리기 때문이다.

가상함수(Virtual function)

앞의 SourceCode(1)과 (2) 의 결과를 보면, 분명 부모 클래스의 포인터 변수에 다른 객체의 주소를 넣었지만 함수 호출시 정적 바인딩에 의해 부모의 함수가 호출되는 것을 확인할 수 있었다. 이럴 경우 객체 지향 프로그래밍의 특징중 하나인 다형성(Polymorphism)이 무시되고, 무엇보다 일단 그냥 봐도 코더가 원하는 방식으로 동작하지 않는 다는 문제가 있다. C++ 에서는 virtual 이라는 키워드를 함수 선언 시 붙임으로써 가상 함수(Virtual function) 라는 것을 만들어 이 문제를 해결할 수 있다.

다형성(Polymorphism)은 하나의 객체가 여러 형태의 자료형을 가질 수 있는 것을 말한다. 즉 특정 클래스로 만들어진 객체지만 다른 클래스 자료형으로도 객체가 사용가능하도록 하는 것이다. 반대로 하나의 타입으로 여러 형태의 객체를 사용할 수 있도록 하는 것 역시 다형성에 해당한다. 다형성은 상속관계와 오버라이딩 등에 의해 구현될 수 있다.

class Animal { }

class Dog : public Animal { wol, wol }

class Cat : public Animal { meow, meow }

class Bird: public Animal { jack, jack}

Animal * d = new Dog; Animal * c = new Cat; Animal * b = new Bird;

d.sound(); // wol wol

c.sound(); // meow, meow

b.sound(); // jack, jack

이런식으로 Animal 타입으로 만들어진 포인터변수지만 각자 다른 객체를 받아 들이고 있으며, 같은 함수를 호출했을 때 다른 출력이 나오게 된다.

SourceCode

class Parent{
    virtual void show(){
        AAA
    }
}

class Child : public Parent{
    virtual void show(){
        BBB
    }
}

class Childchild : public Child{
    virtual void show(){
        CCC
    }
}

int main(){
    Parent * p = new Parent;
    Child * c = new Child;
    Childchild * cc = new Childchild;

    p->show();
    p = c;
    p->show();
    p = cc;
    p->show();
}

Result

AAA
BBB
CCC

같은 문장 p->show() 이지만 출력된 결과는 다 다르다. p 변수에 저장된 객체에 따라서 출력 결과가 다르게 나타난다. 이처럼 같은 문장이지만 다른 동작을 하게 되는 것 역시 다형성의 특징이다.

부모 클래스 타입의 포인터 변수는 자식 객체를 (객체의 주소) 담을 수 있지만, 자식 클래스 타입의 포인터 변수는 부모 객체를 담을 수 없다.

가상함수테이블(Virtual function table)

가상함수를 사용하면 같은 타입으로 선언된 객체 포인터 변수라도 가리키고 있는 객체에 따라 오버라이딩 된 함수를 호출하는 것을 위에서 확인했다. 이렇게 동작할 수 있는 이유는 객체가 생성되면 객체 고유의 가상함수테이블(Virtual function table)이 만들어지고, 이 테이블을 참조하여 함수를 호출하기 때문이다. 편의를 위해 가상함수테이블은 "vtable" 이라고 명칭한다.

SourceCode

class Parent{
    virtual void func1(){
        AAA
    }
    virtual void func2(){
        BBB
    }
    virtual void func3(){
        CCC
    }
    void func4(){
        DDD
    }
}

class Child : public Parent{
    virtual void func1(){
        childA
    }
    virtual void func3(){
        childC
    }
}

int main(){
    Parent * p = new Parent;
    Parent * c = new Child;
    Child * cc = new Child;

    p->func1(); // Parent의 func1 함수 호출
    c->func1(); // Child의 Overriding 된 func1 호출
    c->func2(); // Parent의 func2 함수 호출
    c->func4(); // Parent의 func4 함수 호출, 가상테이블엔 없음 
    cc->func3(); // Child의 func3 함수 호출
}

Result

AAA
childA
BBB
DDDD
childC

[image-1]

객체를 생성하면 [image-1]과 같이 객체마다 vtable이 생성된다. 각 vtable 의 위치는 배열의 이름이 배열의 0번 인덱스 지점을 가리키는 것처럼 객체의 시작지점에 있다. 즉 각 포인터 변수(p,c,cc)들은 객체를 가리킴과 동시에 vtable의 시작지점을 가리키고 있다.

vtable을 보면 같은 함수더라도 Child 에서 오버라이딩 된 함수(func1,func3)는 주소가 다르고, 오버라이딩 되지 않은 함수(func2)는 Parent 객체의 vtable 주소와 같다 .

자식 객체가 생성되면 부모 객체의 vtable을 복사하고, 오버라이딩 된 함수만 주소가 새로 업데이트 된다고 한다. 그리고 만약 자식 클래스에 부모에 없는 새로운 가상함수를 추가할 경우, 객체의 vtable 마지막 부분에 추가된다.

vtable에는 virtual 로 선언된 가상 함수만 저장된다. SouceCode를 보면 Parent에 func4만 일반 멤버 함수로 선언되어 있다. [image-1]에서 Parent 객체의 vtable을 보면 func4는 없는 것을 확인할 수 있다.

가상함수의 호출

SourceCode

scanf("%d", &num);
if (num >= 1 && num <= 10) {
    p = c;
}
p->func3();

가상함수가 호출되는 방식을 살펴보기 위해, 위와 같이 코드를 작성하고 컴파일 했다. (컴파일시 코드 최적화를 최대한 끄는게 분석하기 편하다)
[image-2]

디버거로 컴파일된 바이너리를 열어보았다. [image-2]가 p->func3(); 에 해당하는 어셈블리어다. 우선 rax 레지스터에 포인터 변수 p 에 있는 값을 가져와서 넣고, rax에 저장된 주소값을 역참조해서 해당 주소에 있는 값을 다시 rax 레지스터에 저장한다. 호출 할 때는 이 rax 레지스터에 저장된 주소에서 0x10 만큼 떨어진 곳의 주소를 call 한다.
[image-3]

객체가 만들어지면 [image-3]과 같이 객체의 첫 4byte(x64에선 8byte)에는 vtable의 주소값이 저장되어 있다. 이 주소는 vtable을 가리키고 있고, vtable에는 함수의 주소가 차례대로 저장되어 있다. 즉 vtable 주소값에는 func1 함수의 주소가, vtable 주소 + 8(x86에선 +4)에는 func2의 주소가 저장되어 있는 식이다. vtable의 주소를 기준으로 offset 계산을 해서 함수 호출을 한다.

mov rax, [rsp + 68h + var_40] ; 포인터 변수에 저장되어 있는 객체의 주소를 rax 레지스터에 저장
-> rax = p
mov rax, [rax] ; 객체의 첫 8byte를 읽어, vtable 주소를 rax 레지스터에 저장 
-> rax = *p
call [rax + 0x10] ; vtable로부터 0x10(16byte)만큼 떨어진 곳에 있는 주소를 call
-> call func3

여기서 Heap overflow나 OOB Write 를 통해 객체의 vtable 주소를 변조시키면, 본래의 함수가 아닌 다른 함수를 호출시킬 수 있게되고 이는 프로그램의이 의도치 않은 동작을 하게한다. 공격자가 프로그램에 쉘코드를 삽입하고, vtable 주소를 이용해 쉘코드를 호출하거나, system 함수등을 호출하는 방식으로 공격을 할 수 있기 때문에 주의해야 한다.

댓글
댓글쓰기 폼