January 6, 2009, Tuesday, 5

Article:StudyAboutDoubleDelete

From IdeA thinKING

Jump to: navigation, search

Contents

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+81>에서 실제 eax 레지스터에 d의 포인터 값이 들어간다. (main함수의 stack상의 값) 그리고 그 다음줄에서 다시 eax 레지스터에 D클래스의 vtable 포인터를 할당한다. g++은 해당 객체의 가장 윗쪽에 vtable 포인터를 가지고 있다.

<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에서는 자신이 관리하고 있는 포인터가 아니면 위와 같은 에러 메시지를 출력하고 바로 리턴하는 것으로 보인다.

관찰3

MemPool의 경우에는 항상 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