본문 바로가기
CS & Reversing

[book] 리버스 엔지니어링 기드라 실전 가이드: Chapter 1

by m_.9m 2023. 5. 4.

실습 환경 설정


  1. 실습 파일 다운로드(https://www.hanbit.co.kr/support/supplement_survey.html?pcode=B7974847632): GZF(기드라 데이터베이스), PE, ELF, APK 포함
  2. 기드라 다운로드: https://github.com/NationalSecurityAgency/ghidra/releases
  3. *기드라 실행을 위해선 Java 17+ 이상 버전이 필요: https://www.oracle.com/java/technologies/downloads/#jdk20-windows
  4. 가상머신에 VM을 올려 기드라 분석 환경 설정 (참고 블로그: https://maktony.tistory.com/11)
  5. 기드라에 연습 스크립트 경로 추가 과정

 

1. Chapter 1: 리버싱 엔지니어링 입문


1.1 리버스 엔지니어링

리버스 엔지니어링은 하드웨어나 소프트웨어를 분석하여 그 구성이나 기능을 밝혀내는 기술을 말한다. 책에서 다루는 리버스 엔지니어링은 소프트웨어를 대상으로 리버싱은 주로 소스코드가 없는 환경에서 타켓 프로그램을 분석할 때 사용한다.

1.1.1 소스코드와 실행파일

주로 C언어와 같은 고수준 언어가 사용된 소스코드를 컴파일하여 생성된 실행 파일을 대상으로 멀웨어 분석 등을 진행한. 실행 파일은 실행 환경 아키텍처마다 다른 헥스 코드(hex code)가 사용되기 때문에 분석을 위해서는 이를 인간의 언어로 변환해야 하는데, 헥스 코드 상태에서 어셈블리 언어 등의 저수준 언어로 변환하는 것을 디스어셈블, C 언어 등의 고수준 언어로 변환하는 것을 디컴파일이라고 한다.

원래 컴파일 시는 함수명이나 변수명 등의 많은 정보가 상실되지만 최적화도 동시에 진행되기 떄문에 프로그램이 사용하는 DLL, 윈도우 API 문자열, 프로그램 내 개발자가 하드코딩한 문자열은 컴파일헤도 제거되지 않는다.

1.2 프로그램 실행

1.2.1 실행 파일과 하드웨어

  • CPU: 제어부, 연산부(ALU), 레지스터로 구성된다. 제어부는 메인 메모리에서 실행하는 명령을 취득하고 연산부는 제어부에서 받은 명령을 실행해 결과를 레지스터나 메인 메모리에 저장한다. 레지스터는 CPU 내에 존재하는 데이터 저장 영역이다.
  • 메인 메모리: 프로그램을 실행하기 위한 메모리 공간. 실행 파일이 시작하면 프로세스로 메모리에서 읽힌다. 메모리 공간은 프로세스별로 분리되어 있고 독립적으로 사용한다.
  • 실행 파일: 실행되는 프로그램의 본체로 보통 디스크에 있다.

 

1.2.2 레지스터

레지스터의 기억 용량은 메모리에 비해 작지만 고속으로 접근할 수 있다. 주요 레지스터에는 범용 레지스터, 상태 레지스터, 명령 포인터, 세그먼트 레지스터가 있다.

  • 범용 레지스터: 연산이나 포인터의 저장 등 범용적으로 사용될 수 있다. 8개의 레지스터가 존재한다.

이러한 래지스터는 32비트이지만 AX, BX, CX, DX, BP, SP, SI, DI의 이름으로 하위 16비트만을 사용할 수도 있다. 또한 EAX, EBX, ECX, EDX 각 레지스터의 하위 2바이트는 저마다 상위 바이트/하위바이트를 참조할 수 있다.

 

  • 상태 레지스터: x86 프로세서에서는 EFLAGS 레지스터라고 불린다. 32비트 레지스터에서 각 비트가 상태를 나타내는 플래그가 되고 이 플래그를 사용해 연산 결과를 나타낸다.

  • 명령 포인터: 다음으로 실행될 명령용 오프셋을 저장한다. x86 프로세서에서는 EIP 레지스터에 해당된다.
  • 세그먼트 레지스터: 세그먼트 레지스터는 메모리 내 특정 세그먼트를 나타내는 16비트 레지스터이다. x86에 존재하는 6개의 레지스터는 아래와 같다.

1.2.2 메모리 영역

모든 실행 파일은 메인 메모리(RAM)에서 읽히고 CPU에서 실행된다. 메모리 공간은 프로세스 별로 분리, 독립되어있고 메모리 상의 데이터 위치를 주소라고 하고 보통 16진수로 표기된다.

  • 코드 영역: 실행할 코드가 저장되는 메모리 영역
  • 데이터 영역: 코드 실행 시 읽고 쓰는 데이터, 글로벌 변수가 저장되는 메모리 영역
  • 힙 영역: 프로그램 실행 시 동적으로 확보되는 메모리 영역
  • 스택 영역: 프로그램 실행 시 함수의 인수, 로컬 변수, 리턴 주소 등을 저장하기 위해 사용되는 영역

1.2.4 스택

PUSH, POP으로 데이터를 다루는 LIFO 구조를 가지는 영역이다. 프로그램 실행 중 스택 상태는 계속 변화하며 스택의 톱 주소는 ESP(Extended Stack Pointer) 레지스터, 베이스 주소는 EBP(Extended Base Pointer)에 저장된다. 스택의 이용 방법은 함수 호출 규약에 따라 달라지고 스택 프레임 확보 처리를 프롤로그, 해제를 위한 처리를 에필로그라고 한다.

  1. 함수 B(호출 함수)의 인수를 PUSH아 MOV 명령으로 스택에 저장한다. 인수는 역순으로 스택에 저장된다. (인수가 두 개면 두 번째 인수부터 저장)
  2. 함수 B를 호출했을 때 돌아오는 주소(리턴 주소)를 스택에 저장한다.
  3. 함수 B의 프롤로그에서 함수 A의 EBP 값이 스택에 저장된다. 그 후 ESP를 변경하여 로컬 변수용 영역을 확보한다.
  4. 함수 B의 처리를 실행한다.
  5. 함수 B의 에필로그로 ESP를 변경하고 로컬 변수용 영역을 해제한다. 3에서 저장한 함수 A의 EBP 값을 복원하여 함수 A의 스택을 복원한다.
  6. RET 명령으로 스택에 저장되어 있는 리턴 주소를 EIP에 저장하여 호출원(함수 A)로 제어를 옮긴다.

https://hyk0425.tistory.com/8

1.3 호출 규약

호출 규약이란 함수 호출 시 인수 전달 방법과 반환 값의 방법을 정의한 것이다. 호출 규약에 따라 레지스터와 스택을 이용하는 방법이 다르기 때문에 리버싱을 하기 위해 호출 규약을 파악해야 한다. 호출 규약은 아키텍처나 프로그램의 포맷, 컴파일러나 링커에 따라 변한다.

  • cdecl: x86(32비트)의 C/C++에서 가장 일반적인 호출 규약. cdecl에서 함수의 인수는 역순으로 스택에 쌓이고 반환 값은 EAX 레지스터에 저장되며 함수 호출원이 스택을 POP한다.
  • stdcall: 윈도우 API에서 사용되는 호출 규약이다. cdecl과 같이 역순으로 쌓이고 EAX 레지스터에 저장되지만 함수가 종료될 때 그 함수 자체가 스택을 PUSH 한다는 점이 다르다. 인수를 위해 확보한 스택 영역은 RET 명령 오퍼랜드에 의해 POP 되는 크기가 지정된다.
  • fastcall: 레지스터를 사용해 인수를 넘긴다. 사용하는 레지스터는 컴파일러에 의존한다. 마이크로소프트의 fastcall에서는 처음 2개의 인수를 ECX 레지스터와 EDX 레지스터에 저장하고, 인수가 세 개 이상일 경우 cdecl과 마찬가지로 나머지는 역순으로 저장한다. 반환 값은 EAX에, 종료 시 stdcal과 같이 그 함수 자체가 스택을 Free 한다.
  • thiscall: C++ 클래스의 멤버 함수로 이용되는 호출 규약이다. 윈도우에서의 동작은 cdecl과 같고 다른 특징은 this 포인터를 ECX 레지스터에 저장한다.

1.4 C언어와 어셈블리 언어

어셈블리 명령은 종류가 많기 때문에 인텔의 공식 메뉴얼이나 인터넷을 서칭을 활용.

1.4.1 함수 호출

인수: 함수를 호출할 때의 C 언어 소스 코드와 디스어셈블 결과를 설명한다.

FUN_00401020 함수 호출 약관이 cdecl이므로 함수를 CALL로 호출하기 전 PUSH 역순으로 인수를 전달한다.

  • 프롤로그와 에필로그에필로그에서는 반대로 함수를 개시했을 때의 EBP 값을 복원한다. 0x00401012 주소의 ADD ESP, 0x8의 명령으로 ESP를 복원하고 0x00401017의 POP EBP에 의해 저장했던 EBP 값을 복원한다.
  • FUN_00401000 함수(Winmain)을 예로 들면 프롤로그에서는 함수를 개시했을 때 EBP 값 대피와 갱신을 수행한다. 앞선 예제에서는 주소 0x00401000의 PUSH EBP와 0x00401001의 MOV EBP, ESP로 나뉘어 동작했다. 프롤로그의 핵스 코드(55 8b ec)를 기억해두면 디스어셈블러에서 함수 인식이 안될 때 수동으로 함수 변경이 가능하다.

1.4.2 분기

If문: 먼저 [예제 1-4]는 제 1인수에 입력된 문자를 수치로 변환하고 그 값에 의해 출력되는 문자가 변화하는 심플한 프로그램이다. 입력 값이 0인 경우 if 문 블록이 실행되며 입력 값이 1인 경우 else if문 블록이 실행된다.

int main(int argc, char *argv[]){

	int arg = atoi(argv[1]);

	if (arg ==0){
					puts("if");
	}
	else if (arg ==1)
				puts("else if");
	else{
				puts("else");
	}
	return 0;
}

첫 번째 if 문은 어셈블리 언어에서는 [예제 1~5]의 주소 0x0040101e에서 주소 0x00401031의 처리에 해당된다. MOV dword ptr[EBP + local_8], EAX에서 atoi 함수를 사용해 수치로 변환한 입력을 local_8에 저장하고 그 값을 CMP dword ptr[EBP + local_8], 0x0로 비교한다.

 

비교 후 주소 0x00401022의 JNZ LAB_00401033이 실행되고 값이 일치하면 그대로 처리를 하고 다르면 LAB_00401033으로 제어가 넘어간다. 처리가 계속 될 경우 puts 함수를 실행하고 주소 LAB_00401031의 JMP 명령으로 함수 에필로그인 LAB_00401055로 제어가 넘어간다. LAB_00401033에서는 if else 문이 실행된다. 마찬가지로 입력 값을 0x1과 비교하려 작은 경우에는 JL의 명령어에 의해 LAB_00401048로 제어가 넘어간다.

처리가 계속될 경우 if문과 마찬가지로 puts 함수를 실행하여 JMP 명령으로 함수 에필로그인 LAB_00401055로 제어가 넘어간다. LAB_00401048에서는 else 문이 실행된다. puts 함수를 실행하여 함수 에필로그로 이어진다.

기드라에서 그래프뷰라는 기능을 제공하면 한 눈에 로직을 파악할 수 있다.


 

Swith문: Switch 문의 소스코드는 제 1인수에 입력된 문자를 수치로 변환하고 그 값에 따라 출력 문자가 변환하는 if와 거의 같다. 입력 값이 1이면 0을 2이면 case 2를 기타 값인 경우 default 값을 실행시킨다.

int main(int argc, char *argv[]){ //전달받은 개수, 전달받은 문자의 실행 주소(인수)
	int arg = atoi(argv[1]); //첫번째 인수를 int로 변환
	//argv[0]에는 실행 경로 저장, argv[1]의 값을 정수로 변환

	switch (arg){
	
			case 1: 
					puts("arg = 1");
					break;
			case 2:
					puts("arg = 2");
					break;
			default:
					puts("others");
					break;
	}
		return 0;
}

 

위의 코드에서는 Switch문을 통해 주소 0x00401026에서 0x00401032까지 비교의 CMP와 결과에 대응하는 JMP의 명령이 연속 실행됩니다. case 별로 비교에서 일치하면 JZ 명령으로 각각 처리로 넘어갑니다. 어떤 case도 해당되지 않으면 JMP 명령으로 default 처리로 제어가 넘어간다.

 

0040101d: EBP가 가르키는 local_c(12)만큼 떨어진 곳에 EAX를 저장

00401020: EAX 주소에 EBP + local_c주소에 저장된 데이터를 복사

00401023: EBP가 가르키는 local_8만큼 떨어진 곳에 EAX를 저장

00401026: ECX주소에 EBP + local_c주소에 저장된 데이터를 복사

00401029: ECX-1

Swith문에서는 case문의 효율화를 위해 점프 테이블을 사용한다. 주소 0x0040101d에서 0x00401026의 처리로 ECX에 수치를 변환한 인수의 값이 저장된다. 주소 0x00401029의 SUB 명령에서는 ECX-1을 실행하여 입력 값보다 1이 작은 수치가 반환된다. 그 후 0x0040102f의 CMP 명령으로 입력 값이 ‘분기 최대수-1’인 3과 비교된다.

3보다 크면 JA명령에 의해 defalut문으로 제어가 넘어간다.

즉 1~4일 때는 처리가 계속된다. 0x00401038의 JMP 명령에서는 (입력 값-1)x4를 계산해 점프 테이블로부터 점프할 곳의 주소를 취득한다. 예를 들어 입력값이 2이면 EDX는 1이 대입되고 EDX0x4는 0x4가 된다. 이 값을 점프테이블의 선두 주소 0x00401090에 더한 주소가 0x00401094이며 0x00401094에 저장되어 있는 주소 0x0040104e로 제어가 넘어간다.

 


for문:

int main(int argc, char *argv[]){
	int i;
	for(i=0; i<8; i++){
		printf("%d",i);
	)
	return 0;
)

주소 0x00401004의 MOV dword ptr [EBP + local_8], 0x0에서 카운터로서 사용하는 local_8을 0으로 초기화한다. 그리고 주소 0x0040100b의 JMP 명령으로 LAB_0041016의 루프 처리에 들어간다. LAB_0041016에서는 카운터를 0x8과 비교하여 값이 크거나 동일한 경우에 함수 에필로그의 LAB_004102f로 제어가 넘어가고 그렇지 않을 경우 printf 함수를 호출하여 카운터의 수치를 표시하게 된다. 그 후 주소 LAB_004102d의 JMP 명령으로 LAB_004100d로 제어가 넘어가 LAB_004100d에서는 카운터를 인크리먼트하여 루프처리를 계속한다.

 


While문:

int main(int argc, char *argv[]){
	int i = 0;
	while(i<8){
		print("%d", i);
		i++
	}
	return 0;
}

for문보다 루프 조건이 단순하다. local_8을 카운터로 이용하고 주소 0x0040100 b의 CMP dword ptr[EBP + local_8], 0x8에서 8과 비교한다. 주소 0x004010f의 JGE 명령에서 비교 결과에 따라 크거나 동일한 경우에만 함수 에필로그인 LAB_0040102d로 제어가 넘어간다.

1.5 PE 포멧

PE(Portable Executable) 포맷 실행 파일은 윈도우에서 이용되는 형식이다. 멀웨어 대부분은 윈도우를 대상으로 해 이해도가 높으면 멀웨어 분석에 유리하다.

1.5.1 헤더

PE 포맷 헤더의 각 요소에서 중요한 항목이다.

  • DOS 헤더: IMAGE_DOS_HEADER 구조체로 정의된다. IMAGE_DOS_HEADER 구조체는 선두의 e_magic가 PE 포맷 실행 파일의 매직 넘버인 MZ(0x4D, 0x5D)를 갖고 IMAGE_DOS_HEADER 구조체의 마지막 멤버 오프셋 0x3c의 e_Ifanew는 헤더의 오프셋을 나타낸다.
  • DOS Stub: MS-DOS용 코드로 “This program cannot be run in DOS mode” 라는 문자열을 포함하고 있다. 본래는 DOS 모드로 실행하면 발생하는 에러 코드이지만 리버스 엔지니어링에서는 매직 넘버와 함께 PE 포멧의 실행파일인지 확인하는 하나의 요소로 사용한다.
  • PE 헤더: IMAGE_NT_HEADER 구조체로 정의되며 PE(0x50 0x45, 0x00, 0x00)이라는 값의 서명과 IMAGE_FILE_HEADER 구조체의 FileHeader, IMAGE_OPTIONAL_HEADER32 구조체의 OPTIONALHEADER로 구성되어 있다.Optional Header중 리버스 엔지니어링에 중요한 멤버로는 엔트리 포인트의 주소(RVA)를 나타내는 Adress Of Entry Point나 프로그램이 메모리에 로드되는 주소를 나타내는 ImageBase. IMAGE_DATA_DIRECTORY 구조체 배열인 Data Directory가 있다. RVA는 Relative Virtual Address 의 약자로 실제 주소는 RVA에 ImageBase 값을 더해서 계산합니다. ImageBase의 값은 기본적으로 EXE면 0x400000이 되고 DLL이면 0x10000000이 됩니다. DataDirectory에는 내보내기 테이블 엔트리나 임포트 테이블 엔토리, IAT(Import Address Table) 엔트리가 있다. 프로그램이 메모리에 로딩되면 IAT에는 임포트할 API의 주소가 입력된다.
  • File Header 중 리버스 엔지니어링에 중요한 멤버로는 섹션 수를 나타내는 Number Of Sections나 컴파일한 일시를 나타내는 Number Of Section이 있다.
  • 섹션 테이블(섹션 헤더)
  • IMAGE_SECTION_HEADER 구조체로 정의된다. 섹션 테이블에는 각 섹션의 이름과 주소 정보가 저장되어 있다.

1.5.2 섹션

대표적인 섹션은 다음과 같다.

1.5.3 라이브러리

파일 조작이나 메모리 조작, 통신 등 대부분의 프로그램의 동작에는 운영체제가 제공하는 라이브러리를 사용한다. PE 파일의 경우 윈도우 API가 라이브러리로 제공된다. 라이브러리 링크 방법에는 정적 링크와 동적 링크가 있는데 정적 링크는 라이브러리를 결합해 하나의 실행 파일을 생성하고 동적 링크는 Lynamic Link LIbrary(DLL)라는 형식으로 다른 프로그램에 라이브러리 함수를 제공한다.

  • 임포트: 대부분의 프로그램은 윈도우 API가 필요하다. PE 포맷에서는 DLL이 내보내는 함수를 임포트 할 수 있다. 보통 윈도우 API를 사용하려면 윈도우가 제공하는 DLL이 내보내는 함수를 임포트해 호출하고, PE 헤더의 임포트 테이블에는 실행 파일이 임포트하는 함수의 정보가 저장된다. 따라서 분석 대상 프로그램의 Import 함수를 확인하면 기능을 추측할 수 있다.
  • 익스포트: PE 포켓에서는 다른 프로그램에 사용 가능한 함수를 내보낼 수 있다. DLL은 EXE에 함수를 제공하기 위한 형식이기 때문에 DLL은 Export 함수가 많다.

1.6 x64 아키텍처

멀웨어는 x86 아키텍처가 많지만 최근 운영체제는 x64이기에 해당 지식이 필요하다.

1.6.1 x64로의 변화

  • 모든 주소, 포인터가 64 비트로: 윈도우 32비트 프로세스의 사용자 주소가 2GB의 0.ㅌ00000000~0x7FFFFFFF에서 128TB의 0x000’00000000~0x7FFF’FFFFF가 되었다.
  • 레지스터가 64비트가 되며 수도 증가
  • 대응 및 명령 추가
  • 함수 호출 방법의 변화

1.6.2 x64 레지스터

r8~r15 8개의 레지스터가 추가되었다.

 

1.6.3 x64 호출 규약

  • 윈도우: x64 호출 규약에서는 제 1인수~4인수를 각각 RCX, RDX, R8, R9에 저장한다. 인수가 부동 소수점인 경우 SSE2 레지스터인 XMM0~XMM3으로 넘어간다. 인수가 4개 이상이면 스택을 사용해(인수용 홈 영역 사용) 전달한다. 반환 값이 정수이거나 포인터면 RAX에, 부동 소수점의 경우XMM0에 저장된다. 또 RBP 레지스터는 베이스 포인터로 사용되지 않으며 변수 등 스택 참조는 RSP 레지스터를 사용한다.
  • 유닉스 계열 운영체제: 리눅스나 FreeBSD 등의 호출 규약은 System V AMD64 ABI 호출 규약으로 알려져 있고 x64와 다르다. 제 1인수에서 6인수까지를 RDI, RSI, RDX, RCX, R8, R9에 저장하며 시스템 호출에는 RCX 대신 R10 레지스터가 사용된다. 사용인수가 6개 이상인 경우 스택을 통해 전달되고 인수가 부동 소수점이면 SSE2 레지스터인 XMM0~XMM7로 넘어간다. 반환 값이 64비트 사이즈까지의 정수나 포인터면 RAX 레지스터에 저장되고 128비트면 상위 64비트만 RDX에 나머지는 RAX에 저장된다. 부동 소수점의 반환값 역시 64비트만 XMM0에 나머지는 XMM0과 XMM1에 분할에 저장된다.
  • 윈도우와 달리 레드존이라는 영역이 있다. 레드존은 스택 포인터 아래 배치되는 128비트의 최적화를 위한 영역이다. 이 영역 덕분에 128비트까지의 일시 데이터를 스택 포인터 변경 없이 스택에 배치할 수 있다. System V AMD64 ABI 호출 규약 함수의 프롤로그 처리 후 스택에는 다음 요소가 포함된다.
    • 리턴 주소
    • 함수가 사용하는 로컬 변수
    • 스택을 경유하며 전달된 인수
    • 레드존