Exploit/윈도우 버그헌팅 및 익스플로잇

1장. 개요 - 어셈블리어 기본

RNRF 2020. 12. 23. 04:25

2. 어셈블리어 기본

"PUSH EBP, ADD EAX, MOV ECX, EDX..."

어셈블리어는 여전히 컴퓨터에게 친근한 언어이지 사람이 이해하기 편한 언어는 아니다. 사실 수 년째 이 분야를 공부해도 어셈블리어는 그닥 좋아할 수 없다. 간혹 몇 MB 이상의 바이너리를 분석하다 보면 짜증이난다.

하지만 바이너리를 분석하고 취약점을 찾기 위해서는 반드시 어셈블리어를 이해하고 읽을 줄 알아야만 한다. 오픈소스 프로그램이 아닌 이상 소스 코드를 볼 수는 없기 때문이다. 이 점이 바로 시스템 해킹에 발을 들여놓는데 큰 장벽이 되기도 한다. 처음부터 모든 어셈블리어를 무작정 외우려고 하거나 어셈블리어로 프로그래밍을 하지는 않아도 된다. 매뉴얼을 보면 알겠지만 어셈블리어에는 생각 이상으로 많은 명령어가 있는 편이며, 실제로 그 명령어들이 모두 쓰이지도 않는다.

기본적인 어셈블리어를 제외하고는 외우려고 노력하지 않아도 된다. 모르는 명령어가 나오면 매뉴얼을 찾아보면 그만이기 때문이다. 목적은 분석이지 암기가 아님을 다시 한 번 상기한다. 처음에는 ADD, MOV, PUSH, POP 등과 같이 쉬운 명령어 위주로 눈에 익히고, 나머지는 필요할 때 검색해서 사용하다 보면, 시간이 지나고 경험이 쌓일수록 프로그램에 많이 사용되는 명령어는 자연스레 익숙해진다.

2.1 어셈블리어 기초

"55 8B EC 81 EC D8 00 00 00 53 56 57 ..."

이 문자들만 보고 무슨 뜻인지 이해할 수 있을까? 아마 대부분은 16진수라는 것 외에는 알 수 없을 것이다.

PUSH	EBP
MOV	EBP,ESP
SUB	ESP,0D8
PUSH	EBX
PUSH	ESI
PUSH	EDI

위의 어셈블리어의 의미를 자세히 모르더라도 대략 무슨 뜻인지 짐작이 갈 것이다. 사실 어셈블리어는 기계어를 사람이 쉽게 이해할 수 있도록 표현을 바꿔놓은 것일뿐이며, 각 명령어는 기계어와 완벽히 매칭된다.

55			PUSH	EBP
8B EC			MOV	EBP,ESP
81 EC D8 00 00 00	SUB	ESP,0D8
53			PUSH	EBX
56			PUSH	ESI
57			PUSH	EDI

기계어를 어셈블리어로 해석하는 것을 "디스어셈블링"이라 한다. 다행스럽게도 디버거와 디스어셈블러를 통해 우리는 기계어가 아닌 어셈블리어를 보고 분석할 수 있다. 16진수를 보며 분석하지 않아도 된다는 것이 조금은 위안이다.

어셈블리어는 기본적으로 한번에 하나의 명령어만 수행한다. sum(4,5) 함수 한 줄을 실행하기 위해서 어셈블리어는 수십 줄을 실행해야 한다. 기본적인 어셈블리어 명령을 살펴보면...

1) PUSH - 스택에 값을 저장한다. PUSH 후에는 스택이 4바이트 커지기 떄문에 ESP 레지스터는 4바이트 감소한다. 스택은 높은 주소에서 낮은 주소로 할당되기 때문이다.

PUSH

2) POP - PUSH와는 반대로 스택의 끝에 저장된 값을 가져온다. POP 후에는 스택이 4바이트 줄어들기 때문에 ESP 레지스터가 4바이트 증가한다.

POP

3) MOV - 지정한 값을 지정한 곳에 넣어주는 역할을 수행한다. 바이트에 따라 다양한 파생 명령어들이 있으나, 일단은 값을 넣어준다라고만 이해해도 충분하다.

MOV

4) LEA - MOV와 거의 동일한 역할을 수행한다. 다만 MOV는 주소에 저장된 값을 저장하고, LEA는 주소를 그대로 저장한다.

LEA

5) INC, DEC - INC는 값을 1 증가시키고, DEC는 값을 1 감소시킨다.

INC & DEC

6) ADD - 두 오퍼랜드의 덧셈 연산을 수행한다.

ADD

7) SUB - ADD와는 반대로 두 오퍼랜드의 뺄셈 연산을 수행한다.

SUB

8) CALL - 함수를 호출하는 명령어이며, 스택에 리턴 주소를 PUSH한 뒤 바로 뒤의 주소 값으로 점프한다.

CALL

9) RET - 함수 내부에서 다시 원래 코드로 돌아오는 명령어이며, CALL 명령어 수행 시 스택에 저장했던 복귀 주소로 돌아오게 된다. POP EIP를 수행한다고 생각하면 쉽다.

RET

10) NOP - 아무런 동작도 하지 않는 코드이다. 기계어로는 "0x90"이며 공격 코드를 작성할 때 자주 쓰인다. 단순히 주소를 채우거나, 주소 값을 알지 못하는 상황에서 공격 성공률을 높이기 위해 사용되기도 한다.

11) XOR, OR, AND, SHR, SHL - 각각에 해당하는 비트 연산을 수행한다. 비트 연산은 비트 단위의 연산을 의미한다.

명령어 설명
XOR 각 비트를 비교하여 값이 같으면 0, 다르면 1로 계산
Ex.) 10 XOR 13 = 7
          1010 // 10진수 = 10
          1101 // 10진수 = 13
XOR = 0111 // 10진수 = 7
OR 두 비트 중 하나라도 1이면 1, 아니면 0으로 계산
Ex.) 10 OR 13 = 15
          1010 // 10진수 = 10
          1101 // 10진수 = 13
  OR = 1111 // 10진수 = 15
AND 두 비트 모두 1이면 1, 나머지 경우는 0으로 계산
Ex.) 10 AND 13 = 8
          1010 // 10진수 = 10
          1101 // 10진수 = 13
AND = 1000 // 10진수 = 8
SHR 각 비트를 오른쪽(=Right)으로 쉬프트 연산한다. 벗어난 비트는 CF 플래그 레지스터에 저장된다.
Ex.)
            1010 // 10진수 = 10
SHR 1 = 0101 // 10진수 = 5
SHR 2 = 0010 // 10진수 = 2
: 넘어가는 부분이 없으면 0으로 바뀐다.
SHL 각 비트를 왼쪽(=Left)으로 쉬프트 연산한다. 벗어난 비트는 CF 플래그 레지스터에 저장된다.
Ex.)
           1010 // 10진수 = 10
SHL 1 = 0100 // 10진수 = 4
SHL 2 = 1000 // 10진수 = 8
: 넘어가는 부분이 없으면 0으로 바뀐다.

12) CMP, JMP, JE, JNE ... - 비교와 점프를 수행한다. 일반적으로 CMP 명령어 실행 결과에 따라 플래그 레지스터의 특정값이 셋팅되고, 그에 따라 조건에 맞추어 분기한다. IF, FOR 문 등의 제어 구문을 구현할 때 많이 사용된다. 조건에 따른 점프 명령어는 매우 다양하며 대표적인 조건 점프 명령어는 아래와 같다.

명령어 설명
JMP 무조건 점프
JE 비교 결과가 같으면 점프 (Equal)
JGE 비교 결과가 크거나 같으면 점프 (Great + Equal)
JLE 비교 결과가 작거나 같으면 점프 (Less + Equal)
JNE 비교 결과 같지 않으면 점프 (Not Equal)
JZ 0이면 점프 (제로 플래그가 1이면 점프 = 제로플래그가 값의 반대를 생각)

연속적인 어셈블리어 코드를 살펴본다.

00E62D32	mov		dword ptr [ebo-8],eax
00E62D35	mov		eax,dword ptr [ebp-14h]
00E62D38	push		eax
00E62D39	mov		ecx,dword ptr [ebp-8]
00E62D3C	push		ecx
00E62D3D	call		00E611C7
00E62D42	add		esp,8
00E62D45	mov		esi,esp

위에서 언급된 몇 가지 명령어만으로 이루어져 있음을 알 수 있다. 이 정도만으로도 어셈블리어의 기본 내용을 충분히 이해할 수 있을 것이다.

2.2 어셈블리어 실습

이제 코드를 분석하는 연습을 직접해본다. 분석할 프로그램은 간단한 덧셈을 수행하는 프로그램이다. 실습을 위해 분기문 코드를 추가하고 소스 코드를 조금 수정한다.

간단한 덧셈을 수행하는 샘플 프로그램 - Ctrl+F5 : 컴파일 및 실행

실습 어셈블리어 분석
Immunity Debugger를 이용하여 어셈블리어를 분석한다.
구분 내용
테스트 환경 Windows 7 서비스팩1 / 32bit
테스트 대상 sample_calc.exe
테스트 도구 Visual C++ Express
Immunity Debugger

sample_calc.sln 프로젝트 파일

sln 파일을 더블클릭하면 Visual Studio에서 sample_calc 프로젝트가 로드되고, sample_calc.cpp 소스 코드를 볼 수 있다.

Visual C++ 2010 Express의 화면 구성

Ctrl+F5를 누르면 컴파일 및 실행을 할 수 있으며, 컴파일된 파일은 프로젝트 폴더 하위의 Debug 폴더에 저장된다.

컴파일된 sample_calc.exe 프로그램

Immunity Debugger로 컴파일한 sample_calc.exe 파일을 열어본다. Immunity Debugger의 기본적인 메뉴 구성은 아래와 같다.

Immunity Debugger 메뉴

메뉴와 단축키는 외울 필요는 없다. 몇 번 사용하다 보면 많이 쓰는 기능이나 꼭 필요한 단축키는 자동으로 외워지므로 일단은 디버거에 익숙해지는데 중점을 둔다. 지금 당장 필요한 단축키는 F7과 F8 그리고 브레이크 포인트를 설정하는 F2, 이 세 개가 전부이다.

F7 : Step Into
F8 : Step Over
둘다 코드를 한 줄 씩 실행시켜주는 기능을 한다. 하지만 F7은 함수 내부 코드로 들어가고, F8은 함수 내부 코드로 들어가지 않고 함수 실행 다음 줄로 바로 넘어간다.

F7과 F8은 때에 따라 적절히 선택해서 진행하면 된다. 분석하고 싶은 함수를 만나면 F7, 불필요한 함수로 보인다면 F8로 넘어가면 된다. 예를 들어, 기본 라이브러리 함수인 strcpy() 함수는 이미 문자열 복사를 해주는 함수임을 알고 있으니 굳이 매번 함수 내부를 분석하는 것은 시간 낭비일 것이다. 이와 같이 이미 용도를 알고 있는 잘 알려진 API라면 F8을 이용하여 다음 줄로 넘어가는 것이 현명한 방법이다.

다시 본격적으로 코드를 분석해본다. 그런데 파일을 열어서 보면 아래와 같이 위에서 작성했던 코드가 아닌 엉뚱한 주소에서 멈춘것을 알 수 있다.

디버거 로드 직후

컴파일러 옵션에 따라 조금씩 다를 수는 있으나, 일반적으로 메인 함수가 시작되기 전 프로그램 내부적으로 간단한 초기화 루틴을 거치기 때문이다. 당황하지 말고 아래쪽으로 스크롤을 조금만 내려보면 작성했던 루틴을 볼 수 있다.

프로그램 main 함수

PUSH EBP(그림에서는 "00861290"이지만 PC마다 다를 수 있다.) 라인 위에서 F2 키를 눌러서 브레이크 포인트를 설정한 뒤 F9를 눌러서 프로그램을 실행해본다. 브레이크 포인트가 설정된 주소에서 실행이 멈춘 것을 볼 수 있다.

이제 중요 부분들을 F8로 한 줄씩 실행해보면서 레지스터와 스택의 변화를 살펴본다.

메인 함수도 역시 하나의 함수이므로 시작 부분에는 스택 프레임을 만들어주는 프롤로그 코드가 존재한다. 이 두줄이 실행되고 나면 새로운 스택 프레임이 생성된다.

main 함수의 프롤로그 코드

그 다음의 SUB 코드는 ESP를 4C만큼 감소시키는데, 바로 스택 메모리를 할당하는 코드이다. 스택 메모리 할당 시 높은 주소 방향에서 낮은 주소 방향으로 포인터에 저장된 주소를 감소시킨다. 그러므로 스택에 메모리를 할당하는 것은 ESP에 저장된 SP 주소를 감소시키는 것과 같다.

스택 할당 코드

조금 아래쪽의 MOV를 살펴보면, 9와 4는 소스 코드 상에서 "int x=9;"와 "int y=4;"를 나타낸다. 어셈블리어로 스택의 지역 변수에 접근할 때는 이와 같이 EBP를 기준으로 접근한다.

MOV 명령어를 이용한 스택의 지역 변수 값 저장

EBP(0x0034FC84)를 기준으로 -4에는 9가, -8에는 4가 들어가있음을 확인할 수 있다.

EBP를 이용한 스택 변수 접근

다음 코드에서는 위에서 저장했던 9와 4를 스택에 PUSH로 넣고 특정 주소를 CALL하는 것을 확인할 수 있다.

함수 호출 명령어 (=CALL)

어디선가 보았던 코드아닌가? 함수의 호출 과정을 설명할 때 보았던 코드이다. 이 부분은 예상대로 소스 코드의 "sum(4,9);" 부분이다.

cdecl 호출 규약에 따라 인자 값을 두번 PUSH하며 생긴 스택의 정리를 위해 함수 호출 후, "ADD ESP,8" 명령어를 수행한 것을 확인할 수 있다.

CALL 명령어 라인 위에서 F7을 눌러 함수 내부로 들어가보면 아래와 같이 sum 함수의 구현 부분을 확인할 수 있다.

sum 함수의 어셈블리어 코드

먼저, 새로운 스택 프레임을 생성한 뒤 EAX를 이용해 덧셈 연산을 수행한다. 그리고 EAX에 함수의 결과 값을 저장한 뒤 RETN 명령어로 기존의 코드로 복귀한다. 일반적으로 함수 내부의 결과 값을 EAX 레지스터에 저장한다는 사실을 기억해야한다.

호출 직후 EAX에 sum 함수의 결과 값인 "0D(=10진수 13)"가 들어가있는 것을 확인할 수 있다.

EAX를 이용한 함수 결과 값 저장

다음은 비교문인데, 함수의 결과 값인 "0D"와 "0A(10진수 10)를 비교하여 결과 값이 작거나 같다면(=JLE) 점프한다.

JLE 비교문

현재 결과 값인 "0D(=10진수 13)"가 더 크기 때문에 점프문은 수행되지 않고 바로 아래줄로 넘어간다. 이에 대한 소스 코드는 아래와 같다.

if(result>10){ // CMP 명령어로 값을 비교한 후
	...
}else{ // 이곳으로 이동
	...
}

이제 조건문과 분기문이 CMP, 조건 점프문을 통해 구현된다는 것을 알게되었다. 조건문은 분석시 매우 많이 등장하므로 유심히 보도록 해야한다. 특히 어떤 조건에서 어떤 분기문이 실행되는지를 파악하는 일은 프로그램 분석의 핵심 중 하나이다.

다음은 연산 결과 값과 포맷 스트링 인자를 역순으로 PUSH한 뒤 printf()를 호출하는 부분이다. 함수를 호출하는 부분은 sum과 동일하다.

printf 함수 호출

마지막으로 함수의 종료 부분이다. 스택프레임을 제거하고 기존의 스택으로 복구하기 위한 에필로그 코드가 보인다. 이후 RETN 명령어를 통해 메인 함수를 호출한 코드로 복귀하고 작성했던 코드는 끝난다.

함수 종료 코드

지금까지 간단한 프로그램을 어셈블리어로 분석해 보았다. 조금은 어렵게 느껴질 수도 있을것이다. 하지만 그리 길지 않은 코드이므로 차분히 한 줄씩 살펴보면 쉽게 이해할 수 있었다. 사실 바이너리 내부는 수많은 함수의 호출과 복귀, 조건문에 따른 분기가 반복될 뿐이다. 지금까지 분석한 내용만으로도 어느 정도 분석은 가능할 것이다. 어려운 명령어와 잘 쓰이지 않는 명령어는 경험을 통해 하나씩 익혀나가면 된다.