Article:StudyAboutDoubleDeleteFrom IdeA thinKING
Double Delete에 관한 연구개요MemPool에 debugging 기능을 추가하면서 double delete를 검출하는 기능을 추가했으나 실제 virtual destructor를 가진 클래스를 상속한 경우에는 두번째 delete 시에 MemPool에서 재정의한 operator delete가 호출되기도 전에 Segmentation Fault가 발생하였다. 이 글은 어째서 이런 현상이 발생하는지에 관해 살펴본 결과이다. 경고어떤 경우에도 같은 포인터를 두번 지워서는 안된다. 이 글은 같은 포인터를 두번 지워도 프로그램에 문제가 없도록 하는 방법을 다루는 것이 아니다. 단지 delete시에 어떤 일이 일어나는지를 알기 위해서만 사용되어야 한다. 글에 나오는 vtable, vptr등은 C++ 표준과 관련이 없고 단지 컴파일러 구현 이슈일뿐이다. 이론적으로는 다른 방법으로 C++의 polymorphism을 구현할 수 있으나 대부분의 컴파일러가 이 방법을 사용한다. 마지막으로 이글은 g++ 컴파일러와 Intel 프로세서의 경우에만 해당한다. g++의 경우에도 버전에 따라 결과가 다를 수 있다. 관찰1먼저 이 글에서 사용한 코드이다. struct B { virtual ~B() {} }; struct D : B { virtual ~D() {} }; int main() { D* d = new D; delete d; delete d; } 먼저 main 함수의 disassemble 결과를 살펴보면 다음과 같다. 0x08048540 <main+64>: call 0x8048410 <operator delete(void*)> 0x08048545 <main+69>: add $0x10,%esp 0x08048548 <main+72>: cmpl $0x0,0xfffffffc(%ebp) 0x0804854c <main+76>: je 0x8048563 <main+99> 0x0804854e <main+78>: sub $0xc,%esp 0x08048551 <main+81>: mov 0xfffffffc(%ebp),%eax 0x08048554 <main+84>: mov (%eax),%eax 0x08048556 <main+86>: add $0x4,%eax 0x08048559 <main+89>: pushl 0xfffffffc(%ebp) 0x0804855c <main+92>: mov (%eax),%eax 0x0804855e <main+94>: call *%eax 0x08048560 <main+96>: add $0x10,%esp 0x08048563 <main+99>: cmpl $0x0,0xfffffffc(%ebp) 0x08048567 <main+103>: je 0x804857e <main+126> 0x08048569 <main+105>: sub $0xc,%esp 0x0804856c <main+108>: mov 0xfffffffc(%ebp),%eax 0x0804856f <main+111>: mov (%eax),%eax 0x08048571 <main+113>: add $0x4,%eax 0x08048574 <main+116>: pushl 0xfffffffc(%ebp) 0x08048577 <main+119>: mov (%eax),%eax 0x08048579 <main+121>: call *%eax 0x0804857b <main+123>: add $0x10,%esp 0x0804857e <main+126>: mov $0x0,%eax 0x08048583 <main+131>: leave 0x08048584 <main+132>: ret
<main+72>부터 <main+96>와 <main+99>부터 <main+123>가 똑같은 코드가 반복되며 이것이 각각 첫번째와 두번째 delete 코드라는 것을 알 수 있다. <main+64>는 무엇을 하는 코드인지 알지 못하고 있다. [1] <main+94>에서 call *%eax가 호출되면 먼저 다음과 같이 D의 소멸자가 호출된다. 0x080485ee <~D+0>: push %ebp 0x080485ef <~D+1>: mov %esp,%ebp 0x080485f1 <~D+3>: sub $0x8,%esp 0x080485f4 <~D+6>: mov 0x8(%ebp),%eax 0x080485f7 <~D+9>: movl $0x8049838,(%eax) 0x080485fd <~D+15>: sub $0xc,%esp 0x08048600 <~D+18>: pushl 0x8(%ebp) 0x08048603 <~D+21>: call 0x8048628 <~B> <~D+9>에서 객체의 vtable을 D 클래스의 vtable로 셋팅한다. (이 경우에는 0x8049838) 하지만 이 첫번째 delete의 경우에는 이미 vtable이 D 클래스의 것이었으므로 같은 값이 덮어써진다. 다음으로 <~D+21>에서 B의 소멸자가 호출된다. 0x08048628 <~B+0>: push %ebp 0x08048629 <~B+1>: mov %esp,%ebp 0x0804862b <~B+3>: sub $0x8,%esp 0x0804862e <~B+6>: mov 0x8(%ebp),%eax 0x08048631 <~B+9>: movl $0x8049858,(%eax) 0x08048637 <~B+15>: mov $0x1,%eax 0x0804863c <~B+20>: and $0x0,%eax 0x0804863f <~B+23>: test %al,%al 0x08048641 <~B+25>: je 0x8048651 <~B+41> 0x08048643 <~B+27>: sub $0xc,%esp 0x08048646 <~B+30>: pushl 0x8(%ebp) 0x08048649 <~B+33>: call 0x8048410 <operator delete(void*)> 0x0804864e <~B+38>: add $0x10,%esp 0x08048651 <~B+41>: leave 0x08048652 <~B+42>: ret <~B+9>에서 객체의 vtable을 B 클래스의 vtable로 바꾼다. 이 루틴이 지나고 나면 실제 객체의 vtable은 계속 B 클래스의 것을 가리키게 된다. (main 함수의 stack에 있는 d 포인터가 가리키는 객체) 0x08048608 <~D+26>: add $0x10,%esp 0x0804860b <~D+29>: mov $0x1,%eax 0x08048610 <~D+34>: and $0x3,%eax 0x08048613 <~D+37>: test %al,%al 0x08048615 <~D+39>: je 0x8048625 <~D+55> 0x08048617 <~D+41>: sub $0xc,%esp 0x0804861a <~D+44>: pushl 0x8(%ebp) 0x0804861d <~D+47>: call 0x8048410 <operator delete(void*)> 0x08048622 <~D+52>: add $0x10,%esp 0x08048625 <~D+55>: leave 0x08048626 <~D+56>: ret ~B에서 리턴을 하여 ~D의 코드로 돌아온 후 <~D+47>에 있는 ::operator delete가 호출되면 실제 객체의 vtable이 0x0으로 셋팅된다.[1] 따라서 두번째 delete가 불리는 경우 0x0에 있는 값을 참조하려고 하다가 Segmentation Fault가 발생한다. 관찰2그럼 왜 MemPool을 사용하여 ::opertor delete를 재정의하였는데 문제가 발생할까? 이를 확인하기 위해 코드를 다음과 같이 수정하였다. #include <stdlib.h> struct B { virtual ~B() {} }; struct D : B { virtual ~D() {} static void* operator new(size_t s) { static char buff[100]; return buff; } static void operator delete(void* p) { } }; 실제 MemPool보다 간단히 구현하였다. 이 경우 operator들을 재정의하지 않은 경우와 같은 순서로 동작하다가 두번째 delete에서는 ~D가 아닌 ~B가 불리게 된다. 왜냐하면 이전엔 이 값이 첫번째 delete시에 ~D에서 불리는 operator delete에서 0x0으로 셋팅되어 Segmentation Fault가 발생했으나 이 경우엔 D::operator delete에서 아무 작업도 하지 않기 때문에 vtable은 그대로 ~B의 값이 들어있다. 이 경우에는 문제 없이 ~B안에서 ::operator delete까지 호출되며 다음과 같은 메시지를 출력한다. free(): invalid pointer 0x8049980! 왜냐하면 ~B에서 호출되는 operator delete는 D에서 오버로딩한 것이 아니라 ::operator delete가 호출되기 때문이다. 하지만 Segmentation Fault는 발생하지 않는다. g++의 heap manager에서는 자신이 관리하고 있는 포인터가 아니면 위와 같은 에러 메시지를 출력하고 바로 리턴하는 것으로 보인다. 관찰3MemPool의 경우에는 항상 leaf 클래스에서 상속받아 사용하여 위와 같이 결과가 나왔으나 만약 B에서 ::opertor delete를 정의하면 어떻게 될까? 이를 확인하기 위해 코드를 다음과 같이 수정하였다. #include <stdlib.h> struct B { virtual ~B() {} static void* operator new(size_t s) { static char buff[100]; return buff; } static void operator delete(void* p) { } }; struct D : B { virtual ~D() {} }; 이 경우 ~D와 ~B안에서 모두 오버로딩한 operator delete가 호출되어 문제없이 정상적으로 종료되었다. 결과따라서 실제 제일 상위 클래스에서 상속받아 사용가능한 MemPool의 경우에는 간단한 클래스의 경우 double delete 검출도 가능할 것으로 보인다. 여기서 간단한 클래스란 내부에 포인터 변수를 가지지 않는 경우를 말한다. 따라서 이 경우에는 클래스 내부에 string이나 vector 같이 내부적으로 포인터 변수를 가지는 클래스 역시 가지지 않아야 한다. 아래 코드는 MemPool을 사용하더라도 문제가 발생하는 클래스의 예이다. struct B { B() { p = new int; } virtual ~B() { delete p; } int* p; std::string s; }; struct D : B { }; 만약 위에서 p와 같은 변수들 역시 MemPool을 사용하는 경우라면 이 글에서 알아본 사항을 재귀적으로 모두 검토해야 한다. Footnotes |

