C기본 문법 어셈블리 변환
개요보안 공부를 하면서 가장 많이 하는 말은 "어떤 공부부터 시작해야 하나요?"라는 질문이며, 이에 대한 답으로 흔히 "C언어부터 공부하세요."라고 한다. 이처럼 C언어는 프로그래밍의 기본을 이해할 수 있게 해주며 이에 대한 이해는 이후 다른 프로그래밍 언어나 리버싱에도 영향을 미치게 된다. 그렇기에 C언어에 대해 다시 학습을 하면서 리버싱까지 겸하여 공부하기 위해 이번 문서를 준비하였다. 이번 문서에서는 C언어에 대한 입문적인 단계를 다루는 것이 아니다. C언어와 같은 프로그래밍 언어들 컴파일되어 사람이 읽을 수 없는 기계어의 형태로 나타나게 되며 이러한 기계어를 사람이 읽을 수 있는 형태로 변화하는 것이 바로 디스 어셈블링이다. 따라서 바로 이러한 디스 어셈블링 된 C언어의 기본 문법을 살펴보고자 한..
2016.03.20
no image
Anti disassembly
Understanding Anti Disassembly안티 디스어셈블리 구현 시 악성코드 제작자는 디스어셈블러를 속여 실제 실행과 다른 명령어들의 목록을 디스어셈블러에 보이게 일련의 과정을 생성한다. 안티 디스어셈블리 기법은 디스어셈블러의 가정과 제약 사항을 이용한다. 예를 들어 디스어셈블러는 한번에 명령어 하나로 프로그램의 각 바이트를 표현하지만 이러한 잘못된 오프셋에서 디스어셈블하게 교묘히 조작하면 유효한 명령을 화면에서 숨길 수 있다. 아래의 코드를 살펴보자. 이 코드는 IDA Pro로 확인했을 경우에 보이는 코드이다. 40100E의 위치에 있는 JZ 401011 명령어가 존재하고 있다. 하지만 디스어셈블러는 401010의 명령어를 보여주며 심지어 잘못된 주소를 호출하고자 하는 것을 확인할 수가 있..
2015.09.01
Assembly Basic Commands
데이터 타입BYTE : 8비트 부호 없는 정수SBYTE : 8비트 부호 있는 정수WORD : 16비트 부호 없는 정수SWORD : 16비트 부호 있는 정수DWORD : 32비트 부호 없는 정수SDWORD : 32비트 부호 있는 정수FWORD : 48비트 정수QWORD : 64비트 정수TBYTE : 80비트 정수 연산자(operand) 타입r8 : 8비트 범용 레지스터r16 : 16비트 범용 레지스터r32 : 32비트 범용 레지스터Reg : 임의의 범용 레지스터Sreg : 16비트 세그먼트 레지스터Imm : 8, 16, 32비트 상수imm8 : 8비트 상수imm16 : 16비트 상수imm32 : 32비트 상수r/m8 : 8비트 범용 레지스터 또는 8비트 메모리r/m16 : 16비트 범용 레지스터 또는 16..
2015.01.23
개요

보안 공부를 하면서 가장 많이 하는 말은 "어떤 공부부터 시작해야 하나요?"라는 질문이며, 이에 대한 답으로 흔히 "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

Anti disassembly

Kail-KM
|2015. 9. 1. 03:26

Understanding Anti Disassembly


안티 디스어셈블리 구현 시 악성코드 제작자는 디스어셈블러를 속여 실제 실행과 다른 명령어들의 목록을 디스어셈블러에 보이게 일련의 과정을 생성한다. 안티 디스어셈블리 기법은 디스어셈블러의 가정과 제약 사항을 이용한다. 예를 들어 디스어셈블러는 한번에 명령어 하나로 프로그램의 각 바이트를 표현하지만 이러한 잘못된 오프셋에서 디스어셈블하게 교묘히 조작하면 유효한 명령을 화면에서 숨길 수 있다. 아래의 코드를 살펴보자.


이 코드는 IDA Pro로 확인했을 경우에 보이는 코드이다. 40100E의 위치에 있는 JZ 401011 명령어가 존재하고 있다. 하지만 디스어셈블러는 401010의 명령어를 보여주며 심지어 잘못된 주소를 호출하고자 하는 것을 확인할 수가 있다. 이제 이룰 수정한 아래의 코드를 보자.

이전의 코드와는 다르게 40100E의 JZ 명령어가 올바른 곳을 가리키고 있는것을 확인할 수가 있다. 이는 401010의 위치에 0xE8 이라는 값이 존재하기에 디스어셈블러에서 잘못 나타나게 된 것이다.여기서 0xE8은 CALL 명령어의 OPCODE로 0xE8까지 같이 인식이 되었기에 잘못된 주소를 호출하고자 한 것이다.


*OllyDBG의 경우 저 부분은 올바르게 인식을 했지만 다른 부분을 올바르게 인식하지 못하였다. 그러므로 하나의 디스어셈블러에만 의존하는 것은 자칫 오류를 범할 수가 있다.


아래는 CALL 명령어 뒤에 "hello"라는 문자열이 존재하는 경우이다. 원래의 경우라면 hello라는 문자열은 호출되지 않으므로 아무 이상없어야 한다. 하지만 아래 그림 에서와 같이 Call 명령어 뒤에 PUSH 명령어가 존재하는 것을 확인할 수가 있다. 이는 "h"에 해당하는 0x68이 PUSH의 OPCODE이기에 push는 하나의 오퍼랜드를 필요로 하기에 뒤에 4바이트가 push의 오퍼랜드로 인식이 되어 맨 뒤의 0x00은 그 뒤의 명령어들과 짝을 이루어 나타나게 된다.

이를 정상적으로 나타내도록 수정하면 아래와 같이 'hello' 문자열이 나타나고 pop eax 와 retn 명령어가 제대로 나타나는 것을 확인할 수가 있다.



Anti-Disassembly Techniques


디스어셈블러가 부정확하게 디스어셈블하게 하는 악성코드의 주요 방법은 디스어셈블러의 선택과 가정을 악용하는 것이다. 이제 이러한 종류에 어떤 것들이 있는지 확인을 해볼 것이다. 


Jump Instructions with Same Target

이러한 방법 중 하나인 동일한 대상으로 점프하는 명령어에 대하여 알아보면 아래의 명령어와 같다. 이는 두 조건 분기 명령어가 연달아 두 개 모두 같은 지점을 가리키는 형태다. 이는 실제로 JMP 명령어와 다를 바가 없지만 디스어셈블러는 이 명령어를 올바르게 인식하지 못한다.

위에서는 call 명령어가 잘못된 주소를 가리키고 있는 것을 확인할 수가 있다. 이에 대하여 우리는 수정을 하여 아래와 같이 나타낼 수가 있다. 실제로 두개의 조건 분기들은 모두 올바르게 가리키고 있는 것을 확인할 수가 있다. 여기서 우리는 IDA를 통하여 잘못된 주소를 가리키는 부분이 붉게 표시되는 것을 확인할 수가 있는데, 이 표식은 현재 분석하고 있는 파일이 안티디스어셈블리 기법이 적용됐을 가능성을 나타내는 단서 중 하나이다.


A Jump Instruction with a Constant Condition

위와 유사하게 찾을 수 있는 또 다른 안티디스어셈블리 기법은 항상 동일한 조건으로 구성한 하나의 조건 분기 명령어를 이용하는 것이다. 다음 코드는 이 기법을 사용한다. 아래의 그림을 보면 xor eax, eax 명령어로 시작되는 코드를 주의해야 한다. 이 명령어는 EAX를 0으로 만들고 부가적으로 ZF를 설정한다. 이 다음 조건 분기 명령어인 JZ는 ZF가 설정되어 있을 때 분기를 한다.

디스어셈블러는 거짓분기를 우선 처리하면서 참일 때의 분기 구문과 충돌하게 되며 거짓 분기를 첫 번째로 처리하기 때문에 해당 분기를 더 신뢰한다. 아래의 그림을 보면 무조건 점프로 인하여 0xE9는 무시되어야 하자만 거짓 분기를 우선적으로 처리하므로 인하여 E9가 그 뒤의 바이트 까지 오퍼랜드로 이용을 한다는 것이다.

이를 정상적으로 수정하면 아래와 같이 표시가 된다. 여기서도 우리는 올바르지 않은 주소로 점프하려하는 것을 확인할 수가 있었고 이를 단서로 안티 디스어셈블리 기법을 의심할 수가 있다. 아래에는 0xE9가 하나의 Byte로 존재하며 그 뒤의 pop eax와 retn이 올바르게 나타나 있다.


Impossible Disassembly

위에서는 디스어셈블러가 부적절하게 디스어셈블한 코드를 살펴보았다. 그러나 단일 바이트를 두 명령어의 일부로 표현하는 디스어셈블러는 현재 존재하지 않지만 프로세서는 단일 바이트를 다중 명령어로 사용되지 못하게 하는 제약이 없다.

아래의 그림을 보면 첫 번째 명령어는 4바이트의 mov 명령어이다. mov의 마지막 2 바이트는 mov 명령어의 일부이면서 나중에 실행할 명령어 자체이기도 하다. 두번째 명령어인 xor 은 레지스터를 0으로 만들고 ZF를 설정한다. 세번째 명령어는 조건 분기로 ZF가 설정돼 있을 경우 분기한다. 하지만 직전 명령어가 항상 ZF를 설정하므로 실제론 무조건 분기라고 할 수 있다. 디스어셈블러는 jz 명령어 다음에 0xE8로 시작하는 5바이트의 call 명령어의 디스어셈블 여부를 결정한다. 하지만 실제로 이 0xE8은 실행되지 않는 가짜 바이트이며 그 다음 부분이 실제로 실행될 부분이다.



Obscuring Flow Control


Return Pointer Abuse

아래의 코드를 살펴보면 call 명령어가 바로 뒤의 add 명령어를 인식한다. 하지만 add의 오퍼랜드는 IDA에서 인식한 것으로 항상 정확하지는 않다. ESP+4+var_4에서 var_4는 상수로 -4를 이야기하는 것이다. 이는 즉, ESP+4+(-4)이므로 ESP를 기리키게 되는데 이 ESP에 +5를 더하는 것이다. 이로 인하여 스택의 ESP에 위치한 RETN 주소(4011C5)에 5를 더하게 되므로 이는 결국 4011CA를 기리키게 되며 이 주소로 다음 명령어에 의해 이동하게 된다.

이는 가짜 RETN을 통하여 그 부부능로 이동하는 것이라 할 수 있다. 이 예제에서는 위의 명령어 3개를 NOP으로 수정하여 정상적으로 패치할 수가 있다.


Misusing Structured Exception Handlers [ SEH ]

SEH 메커니즘은 디스어셈블러로 따라갈 수 없게끔 디버거를 속여 흐름을 제어하는 방법을 제공한다. SEH는 프로그램이 지능적으로 에러 상황을 처리할 수 있는 방식을 제공한다. 우선 유효하지 않은 메모리에 접근하거나 0으로 나누는 겨우와 같이 다양한 이유로 Exception이 발생할 수 있다. 이에 추가적으로 소프트ㅜ에어 예외 처리는 RaiseException 함수를 호출함으로 발생할 수 있다.

SEH chain은 스레드 내에서 예외 처리를 목적으로 고안한 함수 목록이다. 목록 내의 각 함수는 예외를 처리하거나 목록 내의 다음 핸들러에게 넘길 수 있다. 예외가 최정 핸들러까지 넘어간 경우 처리 불가 예외(Unhacndled exception)로 간주한다.

SEH 체인을 찾으려면 운영체제는 FS 세그먼트 레지스터를 확인한다. 이 부분은 TEB로 스레드 환경블록이라 하며 이에 접근하기 위해 사용 되는 것이 FS 이다. TEB에 있는 첫번째 구조는 스레드 정보블록 TIB이다. 이 TIB의 첫번째 항목이 SEH 체인을 가리키는 포인터이다.SEH Chanin은 EXCEPTION_REGISTRATION 레코드라 부르며 8바이트 데이터 구조의 단순 연결 리스트이다.

SEH 체인은 프로그램의 변화 속에서 서브루틴을 호출하거나 예외 핸들러 블록을 삽입하기 때문에 예외 처리 핸들러가 스택과 같이 동작하며 예외가 발생할 때 가장 먼저 ExceptionHandler 함수를 호출한다. 이는 MS의 소프트웨어 DEP ( Data Execution Prevention)라는 메커니즘에 의해 부과된 제약조건이 적용된다.


Conclusion


현대 디스어셈블러와 같은 고급 프로그램은 명령어가 구성하는 프로그램을 결정하는 작업을 훌륭하게 수행하지만, 디스어셈블러는 여전히 가정 사항을 필요로하고, 프로세스에서 선택해야하는 문제가 있다. 디스어셈블러가 선택하고 가정하는 각각에 대응하는 안티디스어셈블리 기술이 있을 수 있다.


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

Anti Virtual Machine  (0) 2015.09.03
Anti Debugging  (0) 2015.09.03
DLL Injection  (0) 2015.08.29
데이터 인코딩  (0) 2015.08.26
위장 악성코드 실행  (1) 2015.08.24

데이터 타입


BYTE : 8비트 부호 없는 정수

SBYTE : 8비트 부호 있는 정수

WORD : 16비트 부호 없는 정수

SWORD : 16비트 부호 있는 정수

DWORD : 32비트 부호 없는 정수

SDWORD : 32비트 부호 있는 정수

FWORD : 48비트 정수

QWORD : 64비트 정수

TBYTE : 80비트 정수

 


연산자(operand) 타입


r8 : 8비트 범용 레지스터

r16 : 16비트 범용 레지스터

r32 : 32비트 범용 레지스터

Reg : 임의의 범용 레지스터

Sreg : 16비트 세그먼트 레지스터

Imm : 8, 16, 32비트 상수

imm8 : 8비트 상수

imm16 : 16비트 상수

imm32 : 32비트 상수

r/m8 : 8비트 범용 레지스터 또는 8비트 메모리

r/m16 : 16비트 범용 레지스터 또는 16비트 메모리

r/m32 : 32비트 범용 레지스터 또는 32비트 메모리

mem : 8, 16, 32 비트 메모리


 

명령어 (Command Destination,Source ) 



- ADD(add) : destination과 source를 더해 destination에 저장한다.

 

- SUB(Subtract) : destination에서 source값을 뺀 뒤 destination에 저장한다.

 

- SBB(Subtract with Borrow) : 하위 자리의 수계산 중에 빌림수를 가져갔다면 그것을

                                           감안해 뺄셈을 한다.

dst = dst - src - CF 식으로 생각하면 된다.

 

- MUL(Unsigned Integer Multiply) : 부호 없는 al, ax, eax의 값을 피연산자와 곱한다.

피연산자가 8비트 이면 al과 곱해서 ax에 저장되고 16비트이면 ax와 곱해서 dx:ax에 저장된다.

피연산자가 32비트 이면 EAX와 곱해서 EDX:EAX에 저장된다.

 

- IMUL(Integer Multiplication) : 부호 있는 al, ax, eax의 값을 피연산자와 곱한다.

결과에 따라 CF, OF가 달라질 수 있다.

피연산자가 8비트 이면 al과 곱해서 ax에 저장되고 16비트이면 ax와 곱해서 dx:ax에 저장된다.

피연산자가 32비트 이면 EAX와 곱해서 EDX:EAX에 저장된다.

결과에서 사용되는 비트 이외에 남은 비트를 부호비트로 채운다.

 

- DIV(Unsigned Integer Divide) : 8, 16, 32비트 부호 없는 값의 나눗셈을 한다.

ax/8bit 값 -> al:ah (몫:나머지)

dx:ax/16bit 값 -> ax:dx

edx:eax/32bit 값 -> eax:edx

결과에 따라 CF, OF, ZF가 세트될 수 있다.

 

- IDIV(Integer Divide) : 8, 16, 32비트 부호 있는 값의 나눗셈을 한다.

ax/8bit 값 -> al:ah (몫:나머지)

dx:ax/16bit 값 -> ax:dx

edx:eax/32bit 값 -> eax:edx

나눌 대상은 나눌 값보다 커야 한다. 부호 없는 경우는 xor연산을 해 0으로 초기화시키면서

확장을 시키고 부호 있는 경우 movsx 동작을 하는 CBW, CWD, CDQ로 부호비트로 채우면서

초기화 시켜서 나눗셈 연산을 수행한다.

 

- INC(Increase) : 피 연산자에 1을 더한다.

연산 결과에 따라 ZF나 OF가 세트 될 수 있다.

- DEC(decrease) : 피 연산자에 1을 뺀다.

연산 결과에 따라 ZF나 OF가 세트 될 수 있다.

 

- LEA(Load Effective Address) : source 의 주소값을 destination에 복사한다.

다시말해 간단히 source 주소값을 알아내서 destination에 복사하는 명령어라고 보면된다.

 

- MOV(Move data) : source 데이터를 destination으로 복사한다.

 

- MOVS(Move String) : source에서 destination으로 복사한다.

                                 문자열을 다루기 때문에 ESI와 EDI를 다룬다.

                                 따라서 ESI 안의 주소가 가리키는 문자열을 EDI 안의 주소가

                                 가리키는 곳으로 복사한다.

MOVS destination, Source

(MOVSB, MOVSW, MOVSD, MOVSQ : BYTE, WORD, DWORD, QWORD)

복사하는 단위마다 명령어가 다르다.

MOVSQ : 64비트에서 사용 가능한 명령어.

 

- MOVZX(Move with zero-Extend) : BYTE나 WORD크기의 피 연산자를 WORD나 DWORD

                                                   크기로 확장하고 남은 비트는 0으로 채운다.

 

- MOVSX(Move with Sign eXtend) : BYTE나 WORD 크기의 피연산자를 WORD나 DWORD

                                                    크기로 확장하고 부호는 그대로 유지한다.

                                                    다시 말해 나머지 공간을 부호비트로 채운다.

* movzx와 movsx의 차이점은 확장시 부호비트에 따라 값이 달라지기 때문에

확장시 확장된 공간을 부호비트로 채우거나 0으로 채우기 위해 두가지로 나뉘어 진다.

movxz - unsign , movsz - sing

 

- rep(repeat string) : ECX가 0이 될 때 까지 문자열 관련 명령을 반복시킨다.

                             문자열 관련 명령어는 MOVS, SCAS, STOS 등이 있다.

 

- repne(repeat until Not Equal) : 보통 SCAS명령어와 함께 쓰인다.

지정된 데이터 타입별로 문자열을 구분하고 한번 구분할 때마다 ECX를 -1 시킨다.

ZF가 1이거나 ECX가 0이 되기 전까지 반복한다.

시작 하는 순간 ECX를 -1 하고 시작한다.

 

- SCAS(Scan String) : 보통 REPNE REPE와 같이 사용된다.

                               Register와 Memory의 데이터 비교한다.

AL 또는 AX, EAX와 ES:(E)DI가 지시한 메모리 내용 비교 후 같은 값이면 ZF가 1로 세트된다.

scasb, scasw, scasd로 사용 한다.

자동으로 DF에 따라 EDI값이 달라진다.

 

- STOS(Store String) : AL, AX, EAX 안의 값을 EDI가 가리키는 곳으로 문자열을 저장시킨다.

Stosb, stosw, stosd로 사용되며 rep명령어와 함께 사용될 수도 있다.

DF에 따라 EDI 값이 + 또는 - 된다.

 

- LOOP : ECX가 0이 될 때 까지 정해진 라벨로 goto 한다.

디스 어셈블리 되면 라벨은 주소가 된다. 다시말해 라벨은 주소다.

또 loop label 을 디스 어셈블리 하면 loopd short 주소 이런 형식으로 나오는데

Loop를 사용하면 CX를 사용한다는 이야기이고 loopd는 ECX값을 사용한다는 이야기이다.

Short은 가까운 라벨을 찾겠다는 의미인데 별 뜻은 없다라고 난 생각한다.

 

- AND : 논리연산자 중 하나로 마스크 비트를 씌우는 동작을 한다.

예를 들면 네트워크에서 서브넷 마스크를 설정하면 네트워크 이름이 나오게 되는게

하나의 예이다.

 

- SAR(Shift Arithmetic Right) : SHR 명령을 사용하면 부호 비트가 변화하기 때문에 값이

                                            일정하게 바뀌지 않는다.

이때 사용 하는 것이 SAR인데 SHR은 무조건 오른쪽으로 비트를 밀어버리는 반면에

SAR은 오른쪽으로 비트를 밀고 기존의 부호 비트를 다시 MSB에 적용시킨다.

예를 들어 10110110 (-74)라는 비트를 SHR하면 01011011(91)이 되는데

만약 SAR하면 11011011(-37)이 된다.

neg : 음수값을 양수로 양수값을 음수로 바꿀때 사용

 

- TEST : 두 오퍼랜드값을 AND연산을 수행한다.

하지만 결과는 저장하지 않고 플래그 레지스터에만 영향을 준다.

대체적으로 TEST 명령 후 jmp 구문이 온다.

TEST는 CMP와 비교할 수 있는데 둘 다 비교 후 결과는 저장하지 않고

플레그 레지스터만 바꾼다.

예를 들어 if문을 사용할 때 비교 대상이 있을경우(if(a<10) 와 비교 대상이 없을경우(if(a))

가 있는데 비교 대상이 있을 경우는 cmp를 쓰고 비교 대상이 없을 경우는

현재 상태를 모르기 때문에 값이 뭔지 알아내기 위해서 TEST 명령어를 이용해 AND연산을

이용해 자신이 어떤 값인지 알아낸다.

보통 참인지 거짓인지를 알아내기 위해 사용한다.

OF와 CF는 항상 0으로 세트되고 TEST 연산 결과값이 0이면 ZF가 1로 세트되고

아니면 0으로 해제된다.

 

- CALL : 함수 호출 시 사용된다.

Jmp와 같이 프로그램의 실행 흐름을 변경 시키지만 jmp명령어와 다른 점은

돌아올 리턴 어드레스를 스택에 저장한다는 것이다.

 

-CMP : 두 오퍼랜드를 비교한다.

Destination 에서 source 를 묵시적으로 값을 빼서 비교한다.

두 피연산자의 값이 같다면 결과는 0이 되고 ZF가 1로 세트된다.

 

- OFFSET : 세그먼트 시작부터 변수가 위치한 거리까지 상대적인 거리를 리턴한다.

예를 들어 lea edi, offset value 하면 세그먼트로부터 value의 위치를 edi에 저장한다.

 

- NOP(No Operation) : 아무일도 하지 않는다.

필요에 따라 유용하게 사용하는데 예를 들면 추가적인 코드를 삽입시키고자 할 때

중간에 바로 삽입이 안되기 때문에 공간을 만들어야 한다.

이럴 때 필요없는 코드를 nop하면 그만큼의 공간을 확보할 수 있다.

 

조건 점프 명령



 JMP는 플래그 래지스터 값들을 이용해 조건이 만족하면 점프를 수행하게 되는 명령어이다.


JA(Jump if (unsigned) above) : CF = 0 and ZF = 0

JAE(Jump if (unsigned) above or equal) : CF = 0

JB(Jump if (unsigned) below) : CF = 1

JBE(Jump if (unsigned) below or equal) : CF = 1 or ZF = 1

JC(Jump if carry flag set) : CF = 1

JCXZ(Jump if CX is 0) : CX = 0

JE(Jump if equal) : ZF = 1

JECXZ(Jump if ECX is 0) : ECX = 0

JG(Jump if (signed) greater) : ZF = 0 and SF = 0

JGE(Jump if (singed) greater of equal) : SF = OF

JL(Jump if (signed) less) : SF != OF

JLE(Jump if (signed) less or equal) : ZF = 1 and OF != OF

JNA(Jump if (unsigned) not above) : CF = 1 or ZF = 1

JNAE(Jump if (unsigned) not above or equal) : CF = 1

JNB(Jump if (unsigned) not below) : CF = 0

JNBE(Jump if (unsigned) not below or equal) : CF = 0 and ZF = 0

JNC(Jump if carry flag not set) : CF = 0

JNE(Jump if not equal) : ZF = 0

JNG(Jump if (signed) not greater : ZF = 1 or SF != OF

JNGE(Jump if (signed) not greater or equal) : SF != OF

JNL(Jump if (signed) not less) : SF = OF

JNLE(Jump if (signed) not less or equal) : ZF = 0 and SF = OF

JNO(Jump if overflow flag not set) : OF = 0

JNP(Jump if parity flag not set) : PF = 0

JNS(Jump if sign flag not set) : SF = 0

JNZ(Jump if not zero) : ZF = 0

JO(Jump if overflow flag set) : OF = 1

JP(Jump if parity flag set) : PF = 1

JPE(Jump if parity is equal) : PF = 1

JPO(Jump if parity is odd) : PF = 0

JS(Jump if sign flag is set) : SF = 1

JZ(Jump is zero) : ZF = 1


 

부호 확장 명령어


부호 있는 나눗셈 연산을 할 시 나눌 대상이 되는 값은 나눌 값보다 커야 하기 때문에 확장을

시켜줘야 하는데 부호 있는 확장을 할 시 사용하는 명령어가 부호 확장 명령어 이다.

- CBW(Convert Byte to Word) : byte크기를 word 크기로 확장시킴

- CWD(Convert Word to Dword) : word크기를 dword 크기로 확장시킴

- CDQ(Convert Dword to Qword) : dword 크기를 qword 크기로 확장시킴

*동작은 movsx와 같고 개념은 초기화 개념으로 본다.

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

RVA to RAW 쉽게 생각해보기  (1) 2015.03.20
범용 CPU 레지스터  (1) 2015.03.04
PE File Format 0x04  (0) 2015.01.13
IDA PRO 단축키  (0) 2015.01.13
PE FILE Format 0x03  (0) 2015.01.10