본문 바로가기
리버싱(Reversing)/'악성코드 분석 시작하기' 정복

[악성코드 분석]#4 어셈블리어와 디스어셈블리 - part(3/3)

by 아기미믹 2023. 8. 10.

책 '악성코드 분석 시작하기' 표지

이 글의 모든 내용은 책 '악성코드 분석 시작하기'를 스스로 공부하며 정리한 내용임을 밝힙니다.

 

함수

1. 함수의 구성

프로그래밍 언어를 배워본 사람이라면 누구나 알겠지만, 함수는 다음과 같이 구성되어 있다.

● 함수명: 함수의 이름이자 함수 코드의 시작 주소를 나타낸다.

● 매개변수: 함수가 호출될 때, 전달될 수 있는 값을 의미한다. 일반적으로 함수 입장에서는 매개변수, 실제 함수가 호출되어 전달되는 값은 인수라고 한다.

● 지역변수: 함수 내부에서 새로 정의되는 변수이다.

 

함수명을 제외하고는 매개변수, 지역변수는 모두 스택(Stack)이란 공간에 저장된다.

 

2. 스택(Stack)

스택은 후입선출 구조로 구성되어 있는 저장공간이다.

현재 스택의 주소를 가리키는 레지스터가 esp.

스택에 저장되어 있는 함수의 지역변수, 매개변수 값을 참조하기 위해 사용되는 레지스터가 ebp이다.

 

part 2에서 공부했듯이 스택은 push와 pop을 통해 값을 저장하고 뺀다.

스택은 상위 주소에서 하위 주소로 커지기때문에 push할 때는 esp-4되고, pop할 때는 esp+4된다.

(이는 32bit에서 동작하는 것을 기준으로 한다. 64bit에서는 각각 -8, +8이 될 것이다.)

 

3. 함수의 동작

다음 C 코드를 보며, 어셈블리어로 변환됐을 때의 코드를 분석해보자.

int test(int a, int b) {
    int x, y;
    x = a;
    y = b;
    return 0;
}

int main() {
    test(2, 3);
    return 0;
}

먼저 main() 함수 내부가 어셈블리 명령어로 변환됐을 때이다.

push 3
push 2
call test
add esp, 8
xor eax, eax

1) test(2, 3)이 호출되기 전에 함수의 매개변수를 전달해주기 위해 인수값인 2와 3이 역순(오른쪽에서 왼쪽으로)으로 스택에 저장된다.

2) test 함수를 호출한다. 이때, test 함수가 종료되고 나서 다음 코드(add esp, 8)가 수행되어야 하므로 다음 코드의 주소를 스택에 저장한다. 이를 복귀 주소라고 한다.

3) test 함수가 종료되고 나서 test 함수에서 사용된 매개변수(스택에 저장되어있던)를 정리한다.

4) 0값을 return하기 위해 eax 레지스터에 대해 xor 연산을 수행한다.

 

test() 함수를 살펴보자.

push ebp 
mov ebp, esp
sup esp, 8
mov eax, [ebp+8]
mov [ebp-4], eax
mov ecx, [ebp+0Ch]
mov [ebp-8], ecx
xor eax, eax
mov esp, ebp
pop ebp
ret

1) 함수 프롤로그, 함수 에필로그

push ebp

mov ebp, esp

→ 함수가 호출되기 전의 함수(현재 함수를 호출한 함수)의 ebp를 저장한다. 함수가 종료되고 나서 기존의 함수로 돌아가야하기 때문이다.이를 함수 프롤로그라고 한다. 

 

mov esp, ebp

pop ebp

→ 마찬가지로 함수가 종료될 때, 저장해두었던 기존 함수의 ebp값을 가리키기 위하여 수행하는 부분이다. 이를 함수 에필로그라고 한다.

 

2) ret

ret 코드를 만나게되면 스택에서 복귀 주소를 꺼내 eip로 저장한다. (pop eip)

test 함수 호출 종료 후 다음 코드를 수행하기 위한 주소이다.

 

3) test 함수 내부 코드

ebp+8과 ebp+0Ch는 각각 매개변수 a와 b를 의미한다. 스택은 상위 주소에서 하위 주소로 커지고, test 함수 호출 당시 인수값을 오른쪽에서 왼쪽으로 저장했기 때문이다.

ebp-4와 ebp-8은 지역변수 x와 y를 의미한다.

그리고 어셈블리 코드를 분석해보면

x = a; y = b;를 수행함을 알 수 있다.

 

배열과 문자열

1. 배열

배열을 어셈블리어로 표현한 가장 간단한 방식은 다음과 같다.

배열의 이름이 nums라고할 때, nums의 각 인덱스를 접근하는 수식은

[nums+인덱스*<각 요소의 크기(바이트)>]

 

int 배열 nums 예시는 다음과 같다. (int는 4byte로 각 배열 요소의 크기가 4byte이므로)

nums[0] = [nums+0*4]
nums[1] = [nums+1*4]
nums[2] = [nums+2*4]
...
nums[10] = [nums+10*2]

2. 배열의 디스어셈블리

배열은 디스어셈블리하기 꽤 어려운 문법이다. 어셈블리 코드에서 각 메모리 공간에 대해 일정 공간을 두고 연속적으로 표현했을 때, 그것이 배열로 선언된 것인지 아니면 단순히 서로 다른 변수를 여러개 선언한 것인지 알 수 없기때문이다.

다음 어셈블리어를 살펴보자.

mov dword ptr [ebp-14h], 1
mov dword ptr [ebp-10h], 2
mov dword ptr [ebp-0Ch], 3
mov dword ptr [ebp-4], 0

위에서부터 차례대로 ebp-14h, ebp-10h, ebp-0Ch 까지는 4byte씩 연속적으로 줄지만 ebp-4는 갑자기 8byte가 줄어든다.

이는 배열의 인덱스 0,1,2,4만 초기화하고 3은 초기화하지 않은 것인지

서로 다른 변수를 선언한 것인지 알 수 없다.

결국 전체 어셈블리 코드를 보고 각 메모리의 쓰임새와 활용을 잘 분석해야 하는 것이다.

 

3. 문자열

문자열은 문자의 배열로 각 요소가 메모리에서 1byte를 차지한다. (ASCII 문자는 1바이트)

문자열을 정의하면 항상 문자열의 끝에 널 문자(널 종결자/문자열 종결자)가 온다. 널 문자 값은 0x0.

 

4. 문자열 명령어

문자열 명령어는 명령어 접두사로 b, w, d가 붙는다. 각각 byte, word, double word의 줄임으로 연산 단위의 크기(1, 2, 4)를 나타낸다. 문자열 명령어에는 eax, esi, edi 레지스터가 사용된다.

- eax는 값을 저장하는데 쓰인다.

- esi는 출발지(원본) 문자열을 저장하는 데 쓰인다.

- edi는 목적지 문자열을 저장하는 데 쓰인다.

문자열 연산이 끝나면 df(direction flag)의 값에 따라 esi, edi의 값이 증감된다.

cld 명령어: df=0 → esi, edi 값이 증가

std 명령어: df=1 → esi, edi 값이 감소

 

1) movs(b/w/d): 메모리→메모리

movs는 문자열 복사를 일으키는 명령어이다.

lea esi, [src] ; "Good",0x0
lea edi, [dst]
movsb

[src] 메모리 공간에 "Good\0" 이라는 문자열이 있다고 가정해보자.

movsb 명령어를 수행하는 순간 [src]의 첫 번째 문자 "G"가 edi가 가리키는 [dst] 메모리 공간 첫 번째에 복사된다.

명령어 수행결과는 다음과 같다.

[src] = "Good\0"

[dst] = "G"

 

2) rep: 반복 명령어

movs 명령어는 1, 2, 4 바이트만 복사할 수 있지만, 멀티 바이트 내용을 복사하려면 rep 명령어와 함께 사용해야 한다.

rep 명령어는 ecx 레지스터에 지정한 횟수만큼 문자열 명령어를 반복한다.

lea esi, [src] ; "Good",0x0
lea edi, [dst]
mov ecx, 5
rep movsb

1)의 예시와 같이 [src]와 [dst]가 존재한다면 ecx의 값(5회)만큼 1byte씩 복사하라는 의미이다.

참고로 movsb가 수행될 때마다 esi와 edi의 값은 1씩 증가한다. (esi와 edi를 버퍼로 생각하면 된다.)

명령어 수행결과는 다음과 같다.

[src] = "Good\0"

[dst] = "Good\0"

결과적으로 rep movsb는 C언어의 memcpy()함수와 동일한 기능을 수행한다.

 

다음은 여러 가지 형태의 반복 명령어이다.

명령어 조건
rep ecx=0일 때까지 반복
repe, repz ecx=0 또는 ZF=0일 때까지 반복.
즉, e(equal, 일치)하거나 z(zero falg=1)이면 반복.
repne, repnz ecx=0 또는 ZF=1일 때까지 반복.
즉, ne(not equal, 불일치)하거나 nz(not zero flag, zf=0)이면 반복

 

3) stos(b/w/d): 레지스터→메모리

stos는 레지스터 값을 edi에서 지정한 메모리로 옮길 때 사용되는 문자열 명령어이다.

mov eax, 0
mov edi, [dst]
mov ecx, 5
rep stosd

stosd이므로 값 0을 4byte씩 5번 저장한다는 뜻이다.

명령어 수행결과는 다음과 같다.

[dst] = 0x00000000000000000000

결과적으로 rep stosd는 C언어의 memset()함수와 동일한 기능을 수행한다.

 

4) lods(b/w/d): 메모리→레지스터

lodsd는 esi에서 지정한 메모리에 존재하는 값을 eax레지스터로 옮긴다.

lodsb는 esi→al

lodsw는 esi→ax

 

5) scasb: 메모리 스캐닝

scasb는 특정 바이트를 찾는 명령어이다.

찾고자 하는 바이트는 al에 저장되고, edi에서 지정한 메모리에서 탐색한다.

scasb는일반적으로 repne명령어와 함께 쓰인다.

첫 바이트부터 같은 문자열을 찾을 때까지(ZF=1) 탐색해야 하기 때문이다.

 

6) cmpsb: 메모리에서 값 비교

cmpsb는 esi와 edi를 비교하며 같은 데이터를 포함하는지 확인하는 명령어이다.

cmpsb는 일반적으로 repe명령어와 함께 쓰인다.

첫 바이트부터 다른 문자열을 찾을 때까지(ZF=0) 탐색해야 하기 때문이다.

같은 문자열이라면 끝까지 탐색할 것이고, 다른 문자열이면 ZF=0일 때, 반복을 종료할 것이다.

 

 

구조체

1. 구조체

배열은 같은 타입의 변수를 여러개 담을 수 있는 객체라면, 구조체는 다른 타입의 변수를 담을 수 있는 객체이다.

다음 구조체 예시를 살펴보자.

struct simpleStruct {
    int a;
    short int b;
    char c;
};

simpleStruct 구조체는 서로 다른 타입의 변수를 포함하고 있다. 각 데이터의 크기가 4byte, 2byte, 1byte 이므로 이 구조체의 크기는 7byte가 될 것이다.

 

2. 구조체 어셈블리

struct simpleStruct {
    int a;
    short int b;
    char c;
};

void update(struct simpleStruct *test_stru_ptr) {
    test_stru_ptr->a = 6;
    test_stru_ptr->b = 7;
    test_stru_ptr->a = 'A';
}

int main() {
    struct simpleStruct test_stru;
    update(&test_stru);
    return 0;
}

위 C코드의 update() 함수를 어셈블리어로 변환된 것을 확인해보자.

push ebp
mov ebp, esp
mov eax, [ebp+8]
mov dword ptr [eax], 6
mov ecx, 7
mov [eax+4], cx
mov byte ptr [eax+6], 41h
mov esp, ebp
pop ebp
ret

함수 프롤로그와 함수 에필로그를 제외하고 코드를 분석하자.

1) mov eax, [ebp+8]

우선, update() 함수의 인수값으로 들어온 구조체의 포인터를 eax 레지스터로 옮긴다. 이때, ebp+8은 함수의 첫 번째 매개변수의 주소임을 명심하자.

 

2) mov dword ptr [eax], 6

구조체의 첫 번째 변수(a)의 타입이 int이므로 4byte만큼 공간을 할당하여 6값을 저장한다.

 

3) mov ecx, 7

mov [eax+4], cx

앞에서 4byte만큼 값을 저장했으므로, 구조체의 변수 b는 eax+4이다. shor int(2byte)만큼의 7값을 저장한다. (cx의 크기는 2byte이다.)

 

$) mov byte ptr [eax+6], 41h

앞에서 2byte만큼 값을 저장했으므로, 구조체의 변수 c는 eax+6이다. 문자 'A'의 ascii 값은 65이므로 41h를 byte(1byte) 크기로 저장한다.

 

x64 아키텍처

x64 아키텍처는 기본적으로 32bit 프로세서와 비슷하게 동작하지만 데이터 저장 공간에서의 차이가 있다.

자세한 차이점은 다음과 같다.

 

1. 범용 레지스터의 명칭과 크기

x86(32bit) x64(64bit)
eax rax
ebx rbx
ecx rcx
edx rdx
esi rsi
edi rdi
ebp rbp
esp rsp

당연하게도 64bit에서의 레지스터의 크기는 8byte이다.

또한 8개의 새로운 레지스터가 추가되는데 이름은 r8, r9, r10, r11, r12, r13, r14, r15이다.

이러한 모든 레지스터는 64비트(rax), 32비트(eax), 16비트(ax), 8비트(al)로 접근할 수 있다.

r8-r15 레지스터의 경우 b/w/d 로 데이터 크기를 조정하여 접근할 수 있다.

(rb8은 r8 레지스터를 1byte만큼 접근, rw10은 r10 레지스터를 2byte만큼 접근 등)

 

2. 함수의 매개변수 저장 공간

x86 아키텍처는 함수 매개변수를 스택에 저장하는 반면, x64 아키텍처는 처음 4개의 매개변수는 rcx, rdx, r8, r9에 저장된다. 예를 들어 다음과 같은 C 코드가 있다고 가정해보자.

printf("%d %d %d %d %d", 1, 2, 3, 4, 5);

매개변수는 역순으로 저장되므로 다음과 같은 어셈블리 코드로 저장된다.

sub rsp, 38h
mov dword ptr [rsp+28h], 5
mov dword ptr [rsp+20h], 4
mov r9d, 3
mov r8d, 2
mov edx, 1
lea ecx, Format ;

한동안 ISMS 마무리 운동으로 공부를 하지 못했었다...

어셈블리어를 오랜만에 보니 까먹은 것도 많았다. 주말에 시간을 한 번 내서 처음부터 복습해야 할 것 같다.

그래도 가장 고비라고 생각 되었던 어셈블리 포스팅을 마치고나니 굉장히 뿌듯하다.

다음 포스팅부터는 흥미로울 것으로 예상되는 IDA 다루기이다!