본문 바로가기

Reverse Engineering/Reversing 이론 설명

[NASM] 구조체와 C++

1. 구조체
    C에서 사용되는 구조체는 연관된 데이터를 하나의 변수에 보관하는 것이라 말할 수 있다. 이는 몇 가지 장점이 있다.

● 구조체에 정의된 데이터가 연관되어 있음을 보임으로써 코드를 명료하게 할 수 있다.
● 이를 통해 함수로의 데이터 전달을 단순하게 할 수 있다. 여러 개의 변수를 독립적으로 전달하는 대신에 이를 이용해 하나의 단위만 전달하면 된다.
● 이는 코드의 지역성(locality)을 향상시킨다.

    어셈블리의 관점에서 볼 때 구조체는 원소들의 크기가 제각각인 배열로 볼 수 있다. 실제 배열의 원소들의 크기는 언제나 같은 형이자, 같은 크기여야 한다. 이를 통해 실제 배열에서는 배열의 시작 주소와 원소의 크기, 원소의 번째 수 만 알면 원소의 주소를 계산할 수 있게 된다.
    그러나 구조체의 원소들은 동일한 크기를 가질 필요가 없다. (그리고 대부분 동일한 크기가 아니다.)이 때문에 구조체의 각각의 원소들은 뚜렷이 지정되어 있어야 하고, 수치적인 위치 대신에 태그(tag) (또는 이름)을 가진다.
    어셈블리에서 구조체의 원소에 접근하는 것은 배열의 원소의 접근하는 것과 비슷하다. 원소에 접근하기 위해서는 먼저 구조체의 시작 주소를 알아야 하고, 구조체의 시작부분으로 부터의 각 원소의 상대 오프셋(relative offset)을 알아야 한다. 그러나, 배열의 경우 이 오프셋이 원소의 번째 수 만 알고도 계산될 수 있었지만, 구조체의 원소들은 컴파일러에 의해 오프셋이 지정된다.

 struct S {
     short int  x;     // 2바이트 정수
     int          y;     // 4바이트 정수
     double    z;     // 8바이트 정수
};

 원소 오프셋 
 x  0
 y  2
 z  6

    위의 표는 S형의 변수가 컴퓨터 메모리 상에 어떻게 나타나는지 보여준다. ANSI C 표준에 따르면 메모리 상에 배열된 구조체의 원소들의 순서는 struct 정의에서 나타난 순서와 동일해야 한다고 한다. 또한 첫 번째 원소는 구조체의 최상단에 위치해야 한다고 하였다. (i.e 오프셋이 0) 이는 또한 stddef.h 헤더 파일offseteof()라는 매우 유용한 매크로를 정의하였는데, 이 매크로는 구조체의 특정한 원소의 오프셋 값을 계산한 후 리턴한다. 이 매크로는 두 개의 인자를 가지는데 첫 번째는 구조체의 이름이고, 두 번째는 오프셋 값을 계산할 원소의 이름이다. 따라서 위의 표에서 offset(S, y)의 결과는 2가 된다.

- 메모리 배열하기
    gcc 컴파일러 사용시, offsetof 매크로를 이요하여 y의 오프셋을 구한다면 2가 아닌 4를 리턴하게 된다. 왜냐하면 gcc(그리고 대다수의 컴파일러들이)는 변수들을 기본적으로 더블워드 경계에 놓기 때문이다. 32비트 보호 모드에서 CPU는 메모리 상의 데이터가 더블워드 경계에 놓여 있을  때 더 빨리 읽어들일 수 있다. 위의 구조체의 경우 gcc 컴파일러는 x와 y 변수 사이에 사용되지 않는 2바이트를 끼워 넣어서 y를 더블워드 경계에 놓이게 한다. 이는 offset를 사용하여 오프셋을 계산하는 것이 정의된 구조체의 형태 만을 보고 값을 계산하는 것 보다 훨씬 좋은 것인지를 알 수 있다.
   
    gcc 컴파일러는 데이터의 정렬 방법을 구체화 하기 위해 유연하고도 복잡한 방법을 지원한다. 이 컴파일러에선 특별한 문법을 사용함으로써 어떠한 형이라도 데이터를 정렬하는 방법을 지정할 수 있게 하였다. 예를 들어 아래의 행을 보면,

typedef short int unaligned_int __attribute__(algned(1))); 

    이는 unaligned_int라는 새로운 형을 정의하는데, 이는 바이트 경계에 맞추어져 있다. aligned 인자 1은 2의 멱수들로 바뀔 수 있으며 이는 다른 정렬 조건을 나타낸다.(2는 워드 정렬, 4는 더블워드 정렬 등등) 만일 구조체의 y원소가 unaligned_int 형이라면 gcc는 y를 오프셋 2에 놓을 것이다. 그러나, z는 오프셋 8에 놓이게 되는데 왜냐하면 double형들은 기본값이 더블워드 정렬이기 때문이다. 물론 z를 오프셋 6에 놓이게할 수 있도록 변경할 수 있다.

    gcc 컴파일러는 또한 구조체를 압축(pack)하는 것을 지원한다. 이는 컴파일러로 하여금 구조체 생성시 최소한의 공간만을 사용하게 한다. 위의 구조체 S의 경우 최소한의 바이트를 사용하여, 이 경우 14바이트를 사용한다.

    마이크로소프트와 볼랜드 사의 컴파일러는 #pragma 지시어를 통해 위와 동일한 작업을 할 수 있다.

 #pragma pack(1)

    이 지시어는 컴파일러가 바이트 경계에 맞추어 구조체의 원소들을 압축하게 한다. 이 지시어는 다른 지시어의 의해 변경되기 전 까지 계속 효력을 발휘한다. 이 때문에 이와 같은 지시어가 많이 사용되는 헤더 파일에서는 문제가 발생하기도 한다. 만일 이 헤더 파일이 다른 구조체를 포함한 헤더파일보다 먼저 포함(include) 되었다면 이 구조체는 기본값과 다른 방식으로 메모리 상에 배열 될 수 있다. 이와 같은 오류는 찾기가 매우 힘들다. 프로그램의 각기 다른 모듈들이 구조체의 원소들을 각기 다른 장소에 배열하게 된다.

    이와 같은 문제를 피하는 방법이 있다. 마이크로소프트와 볼랜드 사는 현재의 정렬 방법을 저장한 후, 나중에 복원할 수 있게 한다.

#pragma pack(push)
#pragma pack(1)

struct S {
    short int x; // 2바이트 정수
    int         y; // 4바이트 정수
    double   z; // 8바이트 정수
};

#pragma pack(pop)

2. 어셈블리에서 구조체 사용하기
     어셈블리에서 구조체를 사용하는 것은 배열을 사용하는 것과 비슷하다. 간단한 예로 구조체 S의 원소 y를 0으로 만드는 루틴은 다음과 같다.

%define             y_offset    4

_zero_y:
      enter     0, 0
      mov      eax, [ebp + 8]
      mov      dword [eax + y_offset], 0
      leave
      ret

    C는 함수에게 구조체를 값으로 전달하는 것을 허용한다. 그러나 이는 좋지 않은 생각이다. 값으로 전달을 할 경우, 구조체의 모든 데이터들이 스택에 복사된 후 루틴에게 전달될 것이다. 단순히 구조체를 가리키는 포인터를 전달하는 것이 더 효과적이라 할 수 있다.

    C는 또 구조체 타입이 함수의 리턴값으로도 사용할 수 있게 한다. 하지만 명백히도, 구조체는 EAX 레지스터에 저장되어 리턴될 수 없다. 이 상황을 여러 컴파일러들이 각기 다른 방법으로 해결한다. 가장 많이 쓰이는 방법은 컴파일러가 내부적으로 함수를 구조체를 가리키는 포인터를 인자로 갖게 변경하는 것이다. 이 포인터는 루틴이 호출된 곳 밖에서 정의된 구조체에 반환값을 대입하는데 사용된다.

3. 어셈블리와 C++

오버로딩과 네임 맹글링(Name Mangling) : C++은 같은 이름으로 서로 다른 함수를 정의하는 것을 허용한다. 2개 이상의 함수가 동일한 이름을 사용한다면, 이 함수들을 오버로드(overloaded)되었다고 한다. 만일 이 두 함수들이 C에서 같은 이름으로 정의되었다면 C링커는 오류를 출력할 것이다. 왜냐하면 링킹시 오브젝트 파일에서 동일한 심볼에서 두 개의 정의를 찾을 것이기 때문이다.

    C++은 C와 동일한 링킹 과정을 거치지만, 함수에 이름 붙여진 라벨을 네임 맹글링(Name Magling)이라는 과정을 통해 오류를 피해갈 수 있다. 사실 C도 네임 맹글링을 사용한다. C에서 함수에 대한 라벨을 정의하기 위해서 함수의 이름에 _를 붙이는 것이 그것이다. 하지만 두 함수의 이름을 동일한 라벨로 변경해 오류를 출력한다.

    C++는 네임 맹글링 시 조금 더 복잡한 방법을 사용하여 이 두 함수에 대한 서로 다른 라벨을 만들어 준다. 불행히도 C++에서 라벨을 어떻게 만드는지에 대한 기준이 없기 때문에 각기 다른 컴파일러들이 자신들만의 방법으로 네임 맹글링을 수행한다. 이는 각기 다른 컴파일러가 서로 다른 컴파일러에 대한 C++코드의 링크를 불가능하게 하기도 한다.

    함수의 리턴 형식은 함수의 특징에 포함되지 않으며, 맹글링 된 이름에 나타나지도 않는다. 기본적으로 C++함수들은 오버로드 되지 않아도 이름이 맹글링된다. C++컴파일러가 함수 호출을 분석(parsing)할 때, 함수에 전달되는 인자들의 형을 살펴 보아서 일치하는 함수가 존재하는지 살펴본다. 만일 일치하는 함수를 찾으면 컴파일러는 네임 맹글링 규칙을 사용하여 올바른 함수에 대한 CALL 명령을 수행한다.

    만일 C++코드와 함께 사용될 수 있는 함수를 어셈블리에서 작성한다고 한다면, C++ 코드를 컴파일하는데 사용되는 컴파일러를 보고 네임 맹글링 규칙을 적용시켜야 한다.

    C++에선 extern 키워드를 사용하여 특정한 함수와 전역변수에 C 맹글링 규칙을 적용할 수 있다. C++ 용어로 이렇게 선언된 함수와 변수는 C 연계(C Linkage)를 사용한다고 말한다. 예를 들어, printf를 C 연계 시키려면 아래와 같이 원형을 쓰면 된다.

extern "C" int prinitf (const char*, ...); 

    이 명령은 컴파일러로 하여금 이 함수에 대해 C++ 네임 맹글링을 적용하지 않으며, 그 대신에 C 규칙에 따른 맹글링을 하게 한다. 그러나, 위와 같이 정의된 printf는 오버로드 되지 않는다. 편의를 위해, C++는 여러개의 함수들과 전역 변수를 한꺼번에 연계시킬 수 있게 해준다. 이는 중괄호를 사용하면 된다.