이번에 정리할 것은 달고나(Dalgona@wowhacker.org)님이 만드신 BOF 관련 문서입니다.

1pg ~ 27pg까지 정리하는 것이 목표입니다.

1. 목적

-Buffer Overflow 공격에 대한 원리와 관련 지식들을 설명한 문서

-Buffer Overflow 공격이 어떻게 이루어지는지, 어떤 원리로 가능한지에 대해 설명

-컴퓨터 시스템의 기본 구조에 대해 설명

→ 여러 가지 산재되어 있는 지식들을 잘 정리해서 모아보는 것이 목적

2. 8086 Memory Architecture

8086 basic memory structure

위 그림은 8086 시스템의 기본적인 메모리 구조이다. 시스템이 초기화 되기 시작하면 시스템은 커널을 메모리에 적재시키고 가용 메모리 영역을 확인하게 한다.

시스템(운영에 필요한 명령어 집합을 커널에서 찾음) → 따라서 Low address에 있어야 함.

32bit 시스템: CPU가 한꺼번에 처리할 수 있는 데이터가 32bit 단위

따라서 메모리 영역에 주소를 할당할 수 있는 범위가 0~2의 32승 -1이다.

64bit 시스템: CPU가 한꺼번에 처리할 수 있는 데이터가 64bit 단위

따라서 메모리 영역에 주소를 할당할 수 있는 범위가 0~2의 64승-1이다.

프로그램이 실행되기 위한 메모리 구조

Segmented Memory Model

운영체제가 하나의 프로세스를 실행시키면 segment라는 단위로 묶어서 가용 메모리 영역에 저장시킨다.

위의 그림은 여러개의 segment를 병렬적으로 작업을 수행하는 멀티-테스킹이 가능하다. 따라서 가용햔 메모리 영역에는 여러개의 segment들이 저장될 수 있으며 실행 시점에 실제 메모리의 어느 위치에 저장될 지가 결정된다.

하나의 segment: code segment + data segment + stack segment

code segment: 시스템이 알아들을 수 있는 명령어(instruction)들이 들어 있다. 명령어들은 명령을 수행하면서 많은 분기 과정과 점프, 시스템 호출등을 수행하게 되는데 분기와 점프의 경우 메모리 상의 특정 위치에 있는 명령을 지정해줘야 한다. 하지만 segment는 정확한 주소를 지정할 수 없기 때문에 실제 메모리 상의 주소와 매핑되어 있는 logical address를 사용한다. 따라서 실제 메모리 주소(physical address)는 offset + logical address이다.

data segment: 프로그램이 실행시에 사용되는 데이터(전역 변수)가 들어간다. 프로그램 내에서 선언한 전역 변수가 자리잡게 된다. 이는 또 4개의 data segment로 나뉜다.

stack segment: 현재 수행되고 있는 handler, task, program이 저장하는 데이터 영역이다. 우리가 사용하는 버퍼를가 바로 여기에 자리잡게 된다. 지역 변수들이 자리잡는 공간이다. 처음 생성될 때 필요한 크기만큼 만들어지고, 명령에 의해 데이터를 저장하는 과정을 거친다(stack pointer). FILO.

3. 8086 CPU 레지스터 구조

레지스터(register): 명령어와 데이터를 적절하게 집어내고 읽고, 저장하기 위한 공간. CPU 내부에 존재하는 메모리를 사용한다.

범용 레지스터: 논리연산, 수리연산에 사용되는 피연산자, 주소를 계산하는데 사용되는 피연산자, 메모리 포인터가 저장된다.

세그먼트 레지스터: code segment, data segment, stack segment를 가리키는 주소가 저장된다.

플래그 레지스터: 프로그램의 현재 상태나 조건 등을 검사하는데 사용되는 플래그들이 저장된다.

인스트럭션 포인터: 다음 수행해야 하는 명령이 있는 메모리 상의 주소가 저장된다.

일반적 시스템의 프로그램 레지스터 구성

범용 레지스터: 프로그래머가 임의로 조작할 수 있게 허용되어 있는 레지스터이다. 일종의 4개의 32bit 변수.

범용 레지스터

AH: AX 레지스터의 상위 부분

AL: AX 레지스터의 하위 부분

EAX: 피연산자와 연산 결과의 저장소

EBX: DS segment안의 데이터를 가리킴

ECX: 문자열 처리나 루프를 위한 카운터

EDX: I/O 포인터

ESI: DS 레지스터가 가리키는 data segment 내의 어느 데이터를 가리키고 있는 포인터, 문자열 처리에서 source를 가리킴

EDI: ES 레지스터가 가리키고 있는 data segment 내의 어느 데이터를 가리키고 있는 포인터. 문자열 처리에서 destination을 가리킴

ESP: SS 레지스터가 가리키는 stack segment 의 맨 꼭대기를 가리키는 포인터

EBP: SS 레지스터가 가리키는 스택상의 한 데이터를 가리키는 포인터

세그먼트 레지스터: 프로세스의 특정 세그먼트를 가리키는 포인터 역할을 함.

세그먼트 레지스터

CS 레지스터: code segment를 가리킴

DS, ES, FS, GS 레지스터: data segment를 가리킴

SS 레지스터: stack segment를 가리킴

이를 통해 우리는 원하는 segment 안의 특정 데이터, 명령어를 정확하게 끄집어낼 수 있게 된다.

플래그 레지스터: 상태 플래그, 컨트롤 플래그, 시스템 플래그들의 집합.

플래그 레지스터의 구성

Status flags

CF : carry flag. 연산을 수행하면서 carry 혹은 borrow가 발생하면 1이 된다. Carry와 borrow는 덧셈 연산시 bit bound를 넘어가거나 뺄셈을 하는데 빌려오는 경우를 말한다.

PF : Parity flag. 연산 결과 최하위 바이트의 값이 1이 짝수 일 경우에 1이 된다. 패리티 체크를 하는데 사용된다.

AF : Adjust flag. 연산 결과 carry나 borrow가 3bit 이상 발생할 경우 1이 된다.

ZF : Zero flag. 결과가 zero임을 가리킨다. If문 같은 조건문이 만족될 경우 set된다.

SF : Sign flag. 이것은 연산 결과 최상위 비트의 값과 같다. Signed 변수의 경우 양수이면 0, 음수이면 1이 된다.

OF : Overflow flag. 정수형 결과값이 너무 큰 양수이거나 너무 작은 음수여서 피연산자의 데이터 타입에 모두 들어가지 않을 경우 1이 된다.

DF : Direction flag. 문자열 처리에 있어서 1일 경우 문자열 처리 instruction이 자동으로 감소(문자열 처리가 high address에서 low address로 이루어진다), 0일 경우 자동으로 증가 한다.

System flags

IF : Interrupt enable flag. 프로세서에게 mask한 interrupt에 응답할 수 있게 하려면 1을 준다.

TF : Trap flag. 디버깅을 할 때 single-step을 가능하게 하려면 1을 준다.

IOPL : I/O privilege level field. 현재 수행 중인 프로세스 혹은 task의 권한 레벨을 가리킨다.

현재 수행 중인 프로세스의 권한을 가리키는 CPL이 I/O address 영역에 접근하기 위해서는 I/O privilege level보다 작거나 같아야 한다.

NT : Nested task flag. Interrupt의 chain을 제어한다. 1이 되면 이전 실행 task와 현재 task가 연결되어 있음을 나타낸다.

RF : Resume flag. Exception debug 하기 위해 프로세서의 응답을 제어한다.

VM : Virtual-8086 mode flag. Virtual-8086 모드를 사용하려면 1을 준다.

AC : Alignment check flag. 이 비트와 CR0 레지스터의 AM 비트가 set되어 있으면 메모리 레퍼런스의 alignment checking이 가능하다.

VIF : Virtual interrupt flag. IF flag의 가상 이미지이다. VIP flag와 결합시켜 사용한다.

VIP : Virtual interrupt pending flag. 인터럽트가 pending(경쟁 상태) 되었음을 가리킨다.

ID : Identification flag. CPUID instruction을 지원하는 CPU인지를 나타낸다.

Instruction Pointer 레지스터: 다음 실행할 명령어가 있는 현재 code segment의 offset 값을 가진다.

4. 프로그램 구동 시 Segment에서는 어떤 일이?

프로그램이 실행되어 프로세스가 메모리에 적재되고 메모리와 레지스터가 어떻게 동작하는지 알아보기 위해 간단한 프로그램을 예를 들었다.

void function(int a, int b, int c){
char buffer1[15];
char buffer2[10];
}
void main(){
function(1, 2, 3);
}

이 프로그램을 아래와 같이 컴파일해주자

$gcc –S –o simple.asm simple.c

이렇게 하면 어셈블리 코드가 만들어지는데 이는 컴파일러의 버전에 따라 다르게 생성된다.

실행한 모습...

가지고 있던 우분투를 통해 해보니 이렇게 나온다.

그 다음 컴파일을 하고 gdb를 이용해 어셈블리 코드와 메모리에 적재될 logical address 를 찾아보려고 했지만...

명령어가 제대로 실행이 되지 않아 문서로 대체하겠다.

[dalgona@redhat8 bof]$ gcc -o simple simple.c
simple.c: In function `main':
simple.c:6: warning: return type of `main' is not `int'
[dalgona@redhat8 bof]$ gdb simple
GNU gdb Red Hat Linux (5.2.1-4)
Copyright 2002 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB. Type "show warranty" for details.
This GDB was configured as "i386-redhat-linux"...
(gdb) disas main
Dump of assembler code for function main:
0x80482fc <main>: push %ebp
0x80482fd <main+1>: mov %esp,%ebp
0x80482ff <main+3>: sub $0x8,%esp
0x8048302 <main+6>: and $0xfffffff0,%esp
0x8048305 <main+9>: mov $0x0,%eax
0x804830a <main+14>: sub %eax,%esp
0x804830c <main+16>: sub $0x4,%esp
0x804830f <main+19>: push $0x3
0x8048311 <main+21>: push $0x2
0x8048313 <main+23>: push $0x1
0x8048315 <main+25>: call 0x80482f4 <function>
0x804831a <main+30>: add $0x10,%esp
0x804831d <main+33>: leave
0x804831e <main+34>: ret
0x804831f <main+35>: nop
End of assembler dump.
(gdb) disas function
Dump of assembler code for function function:
0x80482f4 <function>: push %ebp
0x80482f5 <function+1>: mov %esp,%ebp
0x80482f7 <function+3>: sub $0x28,%esp
0x80482fa <function+6>: leave
0x80482fb <function+7>: ret
End of assembler dump.
(gdb)

[

이렇게 나온다고 한다. 앞에 붙어있는 주소는 logical address라고 한다. 주소를 보면 main함수 아래에 function 함수가 자리잡고 있는 것을 알 수 있다.

simple.c 프로그램이 실행 될 때의 segment 모습

구성된 segment는 위 그림과 같다. 크기는 필자가 임의의 값을 정한 것이다. logical address 는 0x08000000부터 시작하지만 실제 프로그램이 컴파일과 링크되는 과정에서 다른 라이브러리들을 필요로 하게 된다. 따라서 실제 시작점과 코드의 시작점은 일치하지 않을 것이다. 뿐만 아니라 stack segment도 0xBFFFFFF까지 할당되지만 환경 변수나 실행 옵션으로 주어진 변수 등에 의해 좀 더 아래에 자리잡고 있다. 이제 프로그램이 시작되면 EIP 레지스터는 main() 함수가 시작되는 코드를 가리킬 것이다. main()함수의 시작점은 0x80482fc가 된다.

step 1

EIP는 main() 함수의 시작점을 가리킨다.

ESP는 스택의 꼭대기를 가리킨다.(프로그램이 수행되면서 수많은 push, pop 명령을 할 것이기 때문이다.)

ebp를 저장하는 이유는 이전에 수행했던 함수의 데이터를 보존하기 위해서이다.(base pointer)

함수 프롤로그 과정: 함수가 시작될 때 stack pointer와 base pointer를 새로 지정하는 것이다.

step 2

push %ebp

이 코드를 수행하여 이전 함수의 base pointer를 저장하면 stack pointer는 4바이트 아래인 0xbffffa78을 가리키게 될 것이다.

mov %esp, %ebp

이 코드를 수행하여 ESP 값을 EBP에 복사하였다. 이렇게 함으로써 함수의 base pointer와 stack pointer가 같은 지점을 가리키게 된다.

sub $0x8, %esp

ESP에서 8을 빼는 명령이다. 따라서 ESP는 8바이트 아래 지점을 가리키게 되고 스텍에 8바이트의 공간이 생기게 된다. 이것을 스텍이 8바이트 확장되었다고 말한다. 이 명령이 수행되고 나면 ESP에는 0xbffffa70 이 들어가게 된다.

and $0xfffffff0, %esp

ESP와 11111111 11111111 11111111 11110000 과 AND 연산을 한다. 이것은 ESP의 주소값의 맨 뒤 4bit를 0으로 만들기 위함이다. 별 의미 없는 명령이다.

mov $0x0, %eax

EAX 레지스터에 0을 넣는다.

sub %eax, %esp

ESP에 들어 있는 값에서 EAX에 들어 있는 값만큼 뺀다. 이것은 역시 stack pointer를 EAX만큼 확장시키려 하는 것이지만 0이 들어 있으므로 의미 없는 명령이다.

sub $0x4, %esp

스텍을 4바이트 확장하였다. 따라서 ESP에 들어있는 값은 0xbffffa6c 가 된다.

step 3

ESP는 12바이트 이동했다.

다음은

push $0x03

push $0x02

push $0x01

를 수행한다. 이는 function(1, 2, 3)을 수행하기 위해 인자값 1, 2, 3을 차례로 넣어준다. 스택에서 나올때에는 거꾸로 나우므로 3, 2, 1 순으로 들어가있다.

call 0x80482f4

이 명령은 0x80482f4에 있는 명령을 수행하라는 것이다. 보는 것과 같이 0x80482f4에는 function 함수가 자리잡은 곳이다.

call

이 명령은 함수를 호출할 때 사용되는 명령으로 함수 실행이 끝난 다음 다시 이 후 명령을 계속 수행할 수 있도록 이 후 명령이 있는 주소를 스텍에 넣은 다음 EIP에 함수의 시작 지점의 주소를 넣는다.

“add $0x10, %esp” 명령이 있는 주소이다.

따라서 함수 수행이 끝나고 나면 이제 어디에 있는 명령을 수행해야 하는가 하는 것을 스텍에서 POP하여 알 수 있게 되는 것이다. 이것이 바로 buffer overflow에서 가장 중요한 return address(ret) 이다.

step 4

이제 EIP는 function() 함수의 시작점을 가리키고 있으며 스택에는 main() 함수에서 넣었던 값이 쌓여있다.

push %ebp

mov %esp, %ebp

function()함수에서도 마찬가지로 함수 프롤로그가 수행된다. main()함수에서 사용하던 base pointer가 저장되고 stack pointer를 function()함수의 base pointer로 삼는다.

step 5

sub $0x28, %esp

스텍을 40바이트 확장한다. buffer1[15]를 위해서 16바이트가 할당되고 buffer2[10]을 위해서 16바이트가 할당된다. 그리고 추가로 8바이트의 dummy가 들어가 총 40바이트의 스텍이 확장된 것이다.

step 6

function() 중 스택의 모습

step 7

leave instruction

함수 프롤로그 작업을 되돌리는 일을 한다.

step 8

ret

이 명령을 수행하면 return address는 POP되어 EIP에 저장되고 stack pointer는 1word 위로 올라간다.

add $0x10, %esp

스텍을 16바이트 줄인다. 따라서 stack pointer는 0x804830c 에 있는 명령을 수행하기 이전의 위치로 돌아가게 된다.

leave

ret

를 수행하게 되면 각 레지스터들의 값은 main()함수 프롤로그 작업을 되돌리고 main()함수 이전으로 돌아가게 된다. 이것은 아마 init_process()함수로 되돌아가게 될 것이다. 이 함수는 운영체제가 호출하는 함수로 프로그래머가 알아야 할 필요는 없다.

여기까지! 27pg까지의 정리입니다.

내용을 보고 이해하고 작성하는데 좀 많이 걸렸네요...마지막이 제일 어려웠어요 ㅠ

'Hacking-기초 > Pwnable' 카테고리의 다른 글

Linux Exploitation & Mitigation Part 1 (1)  (1) 2020.04.13
Memory Corruption - C (2) FSB 문제해설  (1) 2020.04.11
Memory Corruption - C (2)  (1) 2020.04.11
Memory Corruption - C (1)  (0) 2020.04.11
시스템 해킹 기초  (0) 2020.04.11

+ Recent posts