개요

보안 공부를 하면서 가장 많이 하는 말은 "어떤 공부부터 시작해야 하나요?"라는 질문이며, 이에 대한 답으로 흔히 "C언어부터 공부하세요."라고 한다. 이처럼 C언어는 프로그래밍의 기본을 이해할 수 있게 해주며 이에 대한 이해는 이후 다른 프로그래밍 언어나 리버싱에도 영향을 미치게 된다. 그렇기에 C언어에 대해 다시 학습을 하면서 리버싱까지 겸하여 공부하기 위해 이번 문서를 준비하였다.


이번 문서에서는 C언어에 대한 입문적인 단계를 다루는 것이 아니다. C언어와 같은 프로그래밍 언어들 컴파일되어 사람이 읽을 수 없는 기계어의 형태로 나타나게 되며 이러한 기계어를 사람이 읽을 수 있는 형태로 변화하는 것이 바로 디스 어셈블링이다. 따라서 바로 이러한 디스 어셈블링 된 C언어의 기본 문법을 살펴보고자 한다. 이러한 이해는 이후 악성코드를 분석하거나 리버싱을 할 때, 해당 명령어가 왜 존재하는지 이해하는데 도움을 줄 것이다.


Return 호출

C언어에 있어서 가장 자주 사용되는 예제는 바로 Hello World를 출력하는 코드일 것이다. 하지만 printf와 같이 출력 함수에 대해서는 이후에 다룰 것이며, 여기서는 return 0;에 대해서만 알아보자. 아래의 코드는 아무런 기능이 없는 메인 함수로 호출된 후 바로 0을 반환한다. 이에 대하여 아래의 코드와 그림을 보자.

#include <stdio.h>
int main()
{
     return 0;
}

*main+8에 보면 mov eax, 0x0라는 명령어를 볼 수 있다. 해당 명령어가 바로 return 0;을 나타내는 부분으로 eax에 값 0을 넣는 것이다. 여기서 프로세스의 구조에 대해 잘 모르는 사람은 저것이 왜 필요한지 모를 수가 있다. 이에 대해 같이 설명을 하자면 하나의 프로세스는 메인 함수로만 구성되어 있는 것이 아니라, 프로그램이 동작하기 위한 다른 함수 및 명령어들과 같이 이루어져 있다. 아래의 그림을 보자.

그림은 하나의 프로세스를 나타내는 것으로 "Main() == Process"이 아니라 "Main() in Process"와 같은 형태이다. 따라서 메인 함수에서 0을 반환하는 것은 일반적으로 우리가 제작한 부분이 아닌 곳에 반환에 되는 것이다. 만약 메인 함수 외에 다른 함수를 만들어 return 0을 할 경우 이는 메인 함수로 반환되는 것과 같다. 메인 함수도 프로세스의 일부에 불과하기 때문에 반환 값이 존재할 수 있는 것이다.


만약 위 코드에서 return 0을 없앨 경우 어셈블리에서는 *main+3 한 줄만 사라지고 나머지는 똑같다. 보통 반환 값은 EAX에 넣는 경우가 일반적이며, 바로 위 코드에서 그러한 역할을 하고 있는 것을 알 수가 있다. 만약 0이 아닌 값을 반환할 경우, 가령 return 1, mov eax, 0x1이라는 어셈블리의 형태로 나타나게 된다.


int 선언

C언어에서는 변수를 사용하기 전에 먼저 선언을 해놓아야 한다. 이러한 변수가 선언되어 값이 주어질 때, 어셈블리에서는 어떻게 나타날까? 이에 대하여 알아보자. 우선 비교를 위하여 두 개의 코드를 비교할 것이다. 우선 아래의 코드와 그림을 보자.


int형 변수 a를 선언하였고 a에 1이라는 값을 넣어주었다. 그렇다면 이는 어떠한 형태의 어셈블리어로 나타날까? *main+3의 sub esp, 0x10으로 스택에 0x10만큼의 공간을 할당한 뒤, mov 명령어를 통해 스택의 한 공간[ebp-0x4]에 1의 값을 넣어주고 있다. 바로 이렇게 우리가 선언한 변수는 스택의 한 "공간"으로 자리 잡게 되는 것이다. 

#inclue <stdio.h>
int main()
{
    int a=1;
    return 0;
}

그렇다면 여러 개의 int형 변수를 선언해주면 어떻게 될까? 이번에는 int형 변수를 5개 선언하였으며 각 변수에 값을 넣었다. *main+6부터 할당된 공간 중 하나씩 각 변수의 값이 주어져 들어가게 된다. [ebp-0x14]는 int a를 나타내며 [ebp-0x10]은 int b를 나타내며 이렇게 총 5개의 공간에 값이 채워진다. 

#include <stdio.h>
int main()
{
    int a=1;
    int b=2;
    int c=3;
    int d=4;
    int e=5;
    return 0;
}

하지만 한 가지 더 보아야 할 요소가 있다. 바로 첫 번째와 두 번째 코드의 *main+3 부분을 보면 sub 명령어를 통해 스택에 공간을 할당한다. 첫 번째 예제에서는 분명 0x10만큼 할당했지만 두 번째 예제에서는 0x20만큼의 공간을 할당하였다.


이는 자료형의 크기에 대해 먼저 알아야 한다. 하나의 int형 변수는 4바이트의 크기를 갖기 때문에, 첫 예제에서는 4바이트의 변수가 하나 존재하였기 때문에 0x10만큼의 공간만 할당했어도 충분하였다. 이러한 공간은 int형 변수가 4개(16 바이트)까지 선언되어도 모두 담을 수가 있다. 하지만 두 번째 예제에서는 int형 변수가 5개 선언되었기 때문에 최소 20바이트가 필요하다. 그렇기에 0x10만큼을 더 할당하므로 32(0x20)만큼의 공간을 할당한 것이다. 만약 변수의 수가 늘어나면 또다시 스택에 할당되는 크기는 증가할 것이다.


printf 함수

Hello World를 출력할 때 가장 많이 사용하는 함수가 바로 printf로, 이는 아마 C언어를 배우는 사람이 가장 처음 배우는 함수일 것이다. 이러한 printf가 어떻게 사용되는지 확인해보자. 우선 가장 기본적인 형태로 간단한 문자열을 출력하는 코드를 보자. printf를 제외하고 다른 내용은 아무것도 존재하지 않는다. 디스 어셈블링 된 코드를 보면 call 명령어와 함께 printf를 호출한다는 것을 확인할 수 있다.


하지만 여기서 중요한 것은 바로 call 명령어의 바로 위에 위치한 mov 명령어이다. ESP는 현재 스택의 최상단(제일 낮은 값)을 가리키고 있는데, 바로 이 부분에 0x80484d0을 넣어주는는데 바로 이 주소에는 printf 함수에 사용될 문자열인 "Hello"가 존재하고 있다. 이와 같이 MOV를 통해 스택에 바로 값을 넣을 수가 있으며, 이와는 다르게 push 명령어를 통해 해당 값을 스택에 넣을 수도 있다.

#include <stdio.h>
int main()
{
    printf("Hello");
}

위의 경우 바로 문자열을 넣어주었다. 그렇다면 이번에는 변수를 하나 선언하여 값을 저장한 다음 이를 출력해보자. 아래의 코드와 같이 int형 변수 a를 선언한 뒤 10이라는 값을 넣었다. 그 후 printf를 통해 "%d\n", 그리고 a를 인자로 주었는데 이에 대해 변환한 코드를 보면 역시 call 명령어를 통해 printf를 호출하고 있다.


하지만 위와는 다르게 int a에 10(0xa)이라는 값을 주었기에 *main+9에 mov 명령어를 통해 주어진 스택의 공간에 0xa라는 값을 넣는 것을 확인할 수 있다. 그다음 해당 값을 eax에 저장한 다음 이를 스택에 넣는 것을 확인할 수 있다. 그다음 스택의 최상단 ESP에 0x80484e0의 값을 넣는다. 이는 아래에서 확인한 바와 같이 "%d\n"라는 문자열을 나타내고 있다.

#includ <stdio.h>
int main()
{
    int a=10;
    printf("%d\n",a);
}

어떠한 함수를 호출하는 데 있어 인자가 스택에 역순으로 놓이게 된다. 스택의 특성상 최상단(가장 낮은 값=ESP)에 있는 값부터 빼내기 때문에 스택에 "% d\n"이 a보다 상단에(낮은 주소)에 위치해있어야 한다. 


* 참고 : *main+3의 and 명령어는 스택의 주소를 16 단위에 맞추기 위해 사용되며 이로 인해 스택에 할당되는 공간이 넓어지는 효과가 있다. 하지만 이번 학습에서는 중요하지 않은 내용이기에 자세히 다루진 않는다.



scanf 함수

Scanf 함수의 경우 사용자가 입력한 내용의 문자열을 입력받아 지정된 변수에 해당 내용을 저장한다. 여기서 한 가지 알아야 할 것은, prinf 함수에서는 "%d", a 의 형태로 인자를 주었지만, scanf 함수에서는 a의 앞에 &을 붙여야 한다. 이는 변수 a의 주소를 넘겨주는 것으로 이렇게 주소를 넘겨주는 이유는 다음과 같다. 함수가 다른 함수를 호출할 때 인자를 넣어주는데, 이러한 인자는 보통 값의 "복사"를 통해서 이루어진다. 그렇기에 A함수에서 B함수로 어떠한 인자를 넣어준 다음, B에서 해당 값을 변경하더라도 A에는 미치는 영향이 없다. 따라서, scanf함수에서는 &a와 같이 변수 a의 주소를 넘겨주어야 그곳에 올바르게 값을 저장할 수가 있다.

#include <stdio.h>
int main()
{
    int a;
    scanf("%d", &a);
    return 0;
}

*main+9~13에서 lea 명령어를 통해 변수 a에 할당된 주소를 스택에 넣어주는 것을 확인할 수가 있다. 그리고 *main+17에서 "%d"를 인자로 넣어주므로 scanf("%d",&a);가 완성이 된다.  단, 여기서 만약 int형이나 char형이 아닌 배열이나 포인터가 올 경우 그 자체가 포인터를 지칭하고 있으므로 &를 넣어줄 필요가 없다.


두 번째 예제는 세 개의 연속된 인자를 넣어주었다. 위 예제와 마찬가지로 lea 명령어를 통해 스택에서 변수를 위한 공간을 각 각 할당받으며, 할당과 동시에 해당 주소를 스택에 넣어주는 것을 확인할 수 있다. 여기서 자세히 보아야 할 것은 printf에서는 바로 스택에 그 값을 넣어주었지만, scanf에서는 주소를 먼저 할당한 뒤, 그 주소를 스택에 넣었다는 것이다.

#include <stdio.h>
int main()
{
    int a,b,c;
    scanf("%d %d %d", &a, &b, &c);
    return 0;
}

While & For 

이번에는 C언어에서 반복문에 주로 사용되는 두 가지 문법 While과 For에 대하여 알아보자. 우선 두 가지 문법에 있어서 어떠한 것이 편한지는 상황에 따라서 다르다. 필자 개인적으로는 while 문의 경우 while(1)과 같이 제작할 때 편하게 사용할 수가 있으며, for문의 경우 어떠한 조건이 따라올 경우 사용하기 편하다. 하지만 이에 대해선 제작자에 의해 차이가 있으므로 자신의 맞게 사용하면 된다.


우선 While 문에 대하여 알아보자. a라는 int형 변수를 선언한 다음, while 문을 통해 a가 0부터 9까지 출력되도록 하였다. 코드 자체는 쉬우므로 추가적인 설명을 하지 않고 바로 어셈블리어를 확인하자. 우선 스택 프레임을 구성하고, 메인 함수를 위한 스택을 0x20만큼 할당한다. 그 후 [esp+0x1c]에 변수 a의 값 0을 넣어준 뒤 바로 main+44로 점프하는 것을 확인할 수 있다. main+44와 main+49에서는 변수 a의 값이 존재하고 있는 [esp+0x1c]의 값을 0x9와 비교한 다음, 만약 9와 같거나 이보다 작은 경우 main+19 지점으로 점프한다.


이렇게 점프한 다음 해당 a의 값을 EAX에 넣은 뒤, 이를 [esp+0x4]에 printf의 인자로 넣어준다. 그 후 printf의 0x80484f0에 존재하는 "%d"를 [esp]에 넣어주고 printf를 호출한다. printf를 통해 값이 출력되고 [esp+0x1c] 변수 a에 1을 더하는 것을 확인할 수 있다. 이렇게 1을 더해진 a는 다시 cmp를  통해 9보다 작거나 같은지 확인하는 작업을 반복한다. a 값이 하나씩 증가하여 a가 9가 된 경우 printf를 통해 9를 출력한 다음, 1이 더해져 10이 되고 cmp 명령어와 jle 명령어를 통해 main+51로 넘어가는 것을 확인할 수 있다.

#include <stdio.h>
int main()
{
    int a=0;
    while(a<10)
    {
        printf("%d",a);
        a++;
    }
    return 0;
}

for 문의 경우 while문과 비슷하게 사용된다는 것은 위에서 설명하였다. 이 역시 문법적으로는 비슷하므로 설명하지 않고 어셈블리어를 확인해보자. for문을 통해 역시 a가 0부터 9까지 출력되도록 하였다. GDB를 통해 열어서 확인한 결과 신기할 정도로 위의 while문과 동일하게 나타난다. 


어셈블리의 면에서는 똑같으므로 결국 for문과 while문의 차이는 C언어를 통해 코딩을 하는 사람의 입장을 편하게 하기 위함이며, 어셈블리어나 기계어의 경우 이를 똑같이 인식한다는 것을 알 수 있다.

#include <stdio.h>
int main()
{
    int a;
    for(a=0;a<10;a++)
    {
        printf("%d",a);
    }
    return 0;
}


If & Switch

프로그래밍을 하면서 다양한 조건을 사용해야하는 경우가 있다. 이러한 경우에 사용할 수 있는 것이 바로 if와 switch로, 지정한 조건에 부합될 경우 이에 대하애 지정된 행동을 수행하도록 한다. 그렇다면 if와 switch에는 어떠한 차이가 있을까? if의 경우 else와 함께 사용하여 다양한 조건을 걸 수 있으며, switch의 경우 case와 default를 통해 조건을 지정할 수 있다.


if의 경우를 먼저 살펴보자. scanf 함수를 통해 숫자를 입력받고 각 숫자에 따라 어떠한 곳으로 지정된 행동을 수행하게 된다. a가 2 이하라면 각 숫자를 출력하고, 그 외의 경우 "a > 2"를 출력하게 되어있다. 

#include <stdio.h>
int main()
{
    int a;
    scanf("%d",&a);
    if(a==0)
        printf("a : 0");
    else if(a==1)
        printf("a : 1");
    else if(a==2)
        printf("a : 2");
    else
        printf("a  > 2");
    return 0;
}

어셈블리에서는  어떻게 나타날까? 우선 main+9를 보면  [esp+0x1c]에 변수 a의 주소를 인자로 가져간 다음 call _scanf_를 확인할 수 있다. 이렇게 사용자로부터 입력된 값을 main+29에서 eax에 넣는다. eax에 존재하는 a의 값은 바로 test eax, eax 명령어를 통해 0인지 아닌지 확인하게 된다. test eax, eax는 eax의 값이 0일 경우 점프 플래그를 설정하게 된다. 값이 0일 경우 0x8048583에 있는 문자열 "a : 0"을 인자로 주고 printf 함수를 호출한 다음 종료한다.


하지만 만약 main+33에서 0이 아닌 값이 존재할 경우 main+51로 점프하게 된다. 다시 main+51에서 [esp+0x1c]의 값을 가져와 1과 비교한다. 만약 1이 아닐 경우 main+74로 넘어가게 되고, 해당 부분에선 다시 2와 비교한다. 만약 2 또한 아닐 경우 main+97로 넘어가 "a > 2"를 인자로 주어 출력한다.

Switch문의 경우 if 문과 유사한 형태를 갖는다. 이전과 마찬가지로 사용자에 따라 switch를 사용할 수도 있고 if 문을 사용할 수도 있다. switch 문의 경우 case를 통해 값을 지정할 수 있으며, if문의 else는 default를 통해 나타낸다. 아래의 코드를 보면 위와 마찬가지로 scanf를 통해 값을 입력 받고 어떠한 조건에 해당하는지 확인한 후 그에 맞는 문자열을 출력한다.

#include <stdio.h>
int main()
{
    int a;
    scanf("%d", &a);
    switch(a)
    {
        case 0:
            printf("a : 0");
            break;
        case 1:
            printf("a : 1");
            break;
        case 2:
            printf("a : 2");
            break;
        default:
            printf("a > 2");
    }
}


scanf 함수까지는 이전과 동일하므로 생략하겠다. main+29부터 a의 값을 eax에 넣은 후 cmp 명령어를 통해 1과 비교한다. 만약 1이 맞다면 바로 main+61로 점프를 하게되고, 아닐 경우 해당 값을 바로 2와 비교한다. 그리고 test 명령어를 통해 0인지 비교하며, 만약 0이 아닌 경우 main+89로 점프하게 되고 0일 경우 main+47을 인자로 printf 함수를 호출한다.

두 코드의 차이점에 대하여 알아보자. if-else 문의 경우 하나의 비교 명령어를 지나면 다시 변수 a의 값을 가져온 후 다시 비교를 하는 형태로 진행되었다. 이에 반해 switch문의 경우 main+29에서 eax 레지스터에 단 한번 넣은 상태로 지정된 값들과 비교하는 형태로 진행된다. 


Array & Pointer

C언어서 배열과 포인터는 밀접한 관련이 있다. 그렇기에 같은 문자열을 하나는 배열의 형태로, 다른 하나는 포인터의 형태로 선언한 다음 이를 출력하는 내용의 코드를 분석해보자. char형 배열 arr을 선언하여 "Hello World!\n"라는 문자열을 넣어주었다. 그 후 printf 함수를 통해 arr을 출력하는 코드이다. 어셈블리를 확인하기 이전에 코드가 복잡해 보일 수 있는데, 버퍼오버플로우 등을 확인하기 위한 코드이므로 현재는 이에 대하여 자세히 알 필요는 없다. 따라서 우리가 확인해야할 부분은 main+21부터 main+64까지이다.


main+21과 +29 +37 + 45를 보면 [esp+0x??]에 어떠한 값들을 넣는 것을 확인할 수 있다. 이 값들은 바로 "Hello World!\n"에 대한 문자열로 [esp+0x1e]부터 [esp+0x2a]까지 넣는 것임을 알 수 있다. 그리고 main+52에서 문자열이 시작되는 주소 [esp+0x1e]의 주소를 eax에 넘기고 이를 printf 함수(또는 puts)의 인자로 넣는다.

#include <stdio.h>
int main()
{
    char arr[] = "Hello World!\n";
    printf("%s\n",arr);
    return 0;
}

포인터를 통해 선언한 경우 main+9를 확인하면 [esp+0x1c]에 0x80484e0를 넣어준다. 이렇게 넣어진 값은 "Hello World!\n"를 포함하고 있는 주소이며, 해당 주소는 printf(또는 puts) 함수의 인자로 넘어가 결과를 출력하게 된다.

#include <stdio.h>
int main()
{
    char *p = "Hello World!\n";
    printf("%s\n",p);
    return 0;
}

'Reversing > Theory' 카테고리의 다른 글

윈도우 메모리구조와 메모리분석 기초  (3) 2016.03.29
CPU 레지스터  (0) 2016.03.26
Visual Studio 메인함수 찾기  (1) 2016.03.16
ClamAV & PEiD to Yara Rules  (1) 2016.03.11
Yara 규칙 제작 & Python  (1) 2016.03.07