시스템 해킹

Buffer Overflow

kchabin 2022. 8. 12. 10:48

버퍼(Buffer) : 시스템이 연산 작업을 하는데 있어 필요한 데이터를 일시적으로 저장하는 메모리 상의 저장 공간
문자열 처리 = 문자열 버퍼, 수열 = 숫자형 데이터 배열

대부분의 프로그램 = 버퍼를 스택에 생성함.
스택은 함수 내에서 선언한 지역 변수가 저장되게 되고 함수가 끝나고 나면 반환된다.
- mallo()과 같은 반영구적인 데이터 저장 공간과는 다름.

동작 원리
버퍼 오버플로우 - 미리 준비된 버퍼에 버퍼의 크기보다 큰 데이터를 쓸 때 발생.


위 그림처럼 40바이트의 스택이 준비되어 있을 때 이보다 큰 데이터를 쓰면 버퍼가 넘치고 프로그램 에러가 발생하게 된다.
- 41~44바이트 : 이전 함수의 base pointer 수정
- 45~48바이트 : retun address가 저장된 공간 침범
- 48 바이트 이상 : return address뿐만 아니라 그 이전에 스택에 저장되어 있던 데이터 마저도 바뀌게 될 것.

return address가 있는 위치 : 시스템에게 첫 명령어를 간접적으로 내릴 수 있는 부분.
- 현재 함수의 base pointer 바로 위에 있고, 그 위치는 변하지 않는다.
- 공격자가 base pointer를 직접적으로 변경하지 않는다면 정확히 해당 위치에 있는 값이 EIP에 들어가게 돼있다.

buffer overflow 공격 : 공격자가 메모리 상의 임의의 위치에 원하는 코드를 저장시켜 놓고 return address가 저장되어 있는 지점에 그 코드의 주소를 집어 넣음으로 해서 EIP에 공격자의 코드가 있는 곳의 주소가 들어가게 해서 공격한다.

공격자는 버퍼가 넘칠 때, 즉 버퍼에 데이터를 쓸 때 원하는 코드를 넣을 수 있다. + 정확한 return address가 저장되는 곳을 찾아 return address도 정확하게 조작해줘야 한다.

buffer1[15], buffer2[10] 40 바이트의 버퍼가 할당돼있다. 이 버퍼에 데이터를 쓰려한다고 가정.

strcpy(buffer2, receive_from_client);

이 코드는 client로부터 수신한 데이터를 buffer2와 buffer1에 복사한다.
strcpy는 몇바이트나 저장할지 지정해주지 않아 receive_from_client에 들어있는 데이터에서 NULL(\0)을 만날 때까지 복사한다.
스택 구조에서 45~48바이트 위치에 있는 return address도 조작해줘야 하고 공격 코드도 넣어줘야한다.


클라이언트인 공격자가 전송하는 데이터가 receive에 위와 같이 구성되어 전송한다고 가정한다.
strcpy가 호출되어 데이터가 buffer2에 복사가 될 것을 예상하면 아래와 같이 매칭된다.




들어가 있는 데이터들을 보면 <그림 16>의 데이터 순서와 차이가 있는 걸 확인할 수 있다.

Byte order
데이터 저장 순서가 바뀐 이유 = 바이트 정렬 방식

1. big endian : 바이트 순서가 낮은 메모리 주소 -> 높은 메모리 주소
2. little endian : 높은 메모리 주소 -> 낮은 메모리 주소

ex ) 74E3FF59
빅엔디언 = 74E3FF59
- IBM 370, RISC 기반 컴퓨터, 모토로라 마이크로 프로세서
리틀엔디언 = 59FF3E47 -> 순서를 뒤집어 놓는 이유는 수를 더하거나 뺄 때 낮은 메모리 주소 영역의 변화는 수의 크기 변화에서 더 적기 때문이다.
- IBM 호환 시스템, 알파 칩 시스템

74E3FF59 + 1 = 5AFFE374(메모리 상 변화)
낮은 수의 변화는 낮은 메모리 영역에 영향을 받고 높은 수의 변화는 높은 메모리 영역에 자리를 잡게 한다. = Little endian 논리

높은 메모리에 있는 바이트가 변하면 수의 크기는 크게 변한다는 말.
하지만 한 바이트 내에서 bit의 순서는 big endian 방식으로 정렬된다.
+ 네트워크 바이트오더는 빅 엔디언

little endian 시스템에 return address 값을 넣을 때는 바이트 순서를 뒤집어서 넣어주어야 한다.

<그림 17> return address가 변경이 되었고 실제 명령이 들어 있는 코드는 그 위에 있다.
함수 실행이 끝나고 ret instruction을 만나면 return address가 있는 위치의 값을 EIP에 넣을 것이고 EIP가 가리키는 곳의 명령을 수행하려 할 것이다.
이때 이 주소에 명령어가 들어 있지 않다면 프로그램 오류가 발생한다.

공격자는 EIP에 return address 위에 있는 쉘 코드의 시작 주소를 넣고 싶어한다.

공격 코드는 execve("/bin/sh",...). -> 쉘을 띄우는 것.
쉘 코드의 시작 지점 = 0xbffffa60 -> 함수가 리턴될 때 EIP에 리턴 주소 들어가고 0xbffffa60에 있는 명령 수행 -> execve("/bin/sh", ...)를 수행하게 된다.

발생할 수 있는 문제점
return address 위의 버퍼 공간이 쉘 코드를 넣을 만큼 충분하지 않다면 다른 공간을 찾아봐야한다.
위의 예에서는 90909090으로 채워진 아래 공간이 40바이트이고 추가로 main 함수의 base pointer가 저장되어 있는 4바이트까지 44바이트 공간이 존재한다.
이 공간을 활용하려면 return address가 EIP에 들어간 다음에 40바이트의 스택 공간의 명령을 수행할 수 있도록 해줘야 한다.
그러나 직접 rerurn address에다 저 공간의 주소를 적어주기엔 해당 명령어가 있는 주소를 정확히 알아내는 것은 매우 어렵다.

-> 간접적으로 명령 수행 지점을 변경.
return address 이후의 버퍼 공간이 부족할 경우 return address 이전의 버퍼 공간을 활용하는 방법.

ESP 값을 이용하여 명령 수행 지점을 지정해 준다.

쉘 코드가 더 넓은 공간에 있고 return address는 똑같다.
함수가 실행을 마치고 return 할 때 return address가 스택에서 POP되어 EIP에 들어가고 나면 stack pointer 는 1 word 위로 이동하고 ESP는 return address가 있던 자리 위를 가리키게 된다.
EIP는 0xbffffa60을 가리키고 그 곳에 있는 명령을 수행할 것이다.

쉘 코드가 있던 자리에는 다른 코드가 들어갔는데, ESP가 가리키는 지점을 쉘 코드가 있는 위치를 가리키도록 48바이트를 빼주고 jmp %esp instruction을 수행하여 EIP에 ESP가 가리키는 지점의 주소를 넣도록 한다.
이 과정의 명령들을 쉘 코드로 변환했을 때 단 8바이트만 있으면 충분하다. ESP 레지스터는 사용자가 직접 수정할 수 있기 때문이다.

이 공간도 부족하다면 return address 부분만 제외한 위아래 모든 공간을 활용하도록 코딩해야하고그것도 안되면 또다시 다른 공간을 찾는 작업을 해야한다.

쉘 코드 만들기

쉘 코드 : 쉘(Shell)을 실행시키는 코드.
쉘 : 명령 해석기. 일종의 유저 인터페이스, 사용자의 키보드 입력을 받아서 실행파일을 실행시키거나 커널에 어떠한 명령을 내릴 수 있는 대화통로.
- 바이너리 형태의 기계어 코드(혹은 opecode)

쉘 코드를 만들어야 하는 이유 : 실행중인 프로세스에게 어떤 동작을 하도록 코드를 넣어 그 실행 흐름을 조작할 것이기 때문에 실행 가능한 명령어를 만들어야 함.

쉘 실행 프로그램
쉘 상에서 쉘 실행 = '/bin/sh' 명령 내림.
쉘 실행 프로그램이 이 명령을 내리는 것과 똑같은 일을 하도록 해주면 된다.



execve() : 바이너리 형태의 실행 파일이나 스크립트 파일을 실행시키는 함수.

첫 번째 인자 : 파일 이름
두 번째 인자 : 함께 넘겨줄 인자들의 포인터 -> C main() 함수에 argv 인자.
argv[0] = 해당 프로그램의 실행 파일 이름
argv[1] = 실행 시 주어진 첫 번째 인자
...
execve()의 두 번째 인자는 argv[0]부터 들어가는 값을 가리키는 포인터가 되어야 함.
세 번째 인자 : 환경 변수 포인터

Dynamic Link Library & Static Link Library

DLL
printf()함수처럼 많은 응용 프로그램드리 공통적으로 사용하는 명령어가 있다.
운영체제에는 많이 사용되는 함수들의 기계어 코드를 자신이 가지고 있고 다른 프로그램들이 이 기능을 빌려 쓰게 해준다.
따라서 응용프로그래머는 이 기능을 직접 구현할 필요 없이 호출만 해주면 도디고 컴파일러도 직접 컴파일 하지않고 호출하는 기계어 코드만 생성해주면 된다.

라이브러리 형태로 존재한다.
리눅스
- libc
- 실제 파일로는 .so, .a 확장자 가진 형태
윈도우
- DLL 파일

Static Link Library
운영체제의 버전과 libc의 버전에 따라 호출 형태나 링크 형태가 달라질 수 있기 때문에 영향을 받지 않기 위해 기계어 코드를 실행 파일이 직접 갖고 있게 할 수 있는 방법.

DLL 보다 실행파일의크기가 커진다.



NULL의 제거
char c = "\x90" -> 문자열이 아닌 16진수 90으로 인식하여 1바이트 데이터로 저장함.
기계어 코드로 만들어진 쉘 코드를 char형 문자열로 전달할 것.
push 0x0 -> 기계어 코드로 6a 00
char a[] ="\x6a\x00"
문자열에서는 0의 값을 만나면 그것을 문자열의 끝으로 인식하게 된다.
₩x00인 기계어 코드가 생기지 않게 만들어줘야 한다.
mov, $0xb, %eax 코드 또한 00을 만들어내므로 이도 고쳐줘야 한다.

xor %eax, %eax //같은 수를 XOR하면 0이 된다. 즉 NULL이다.
push %eaxx //NULL을 PUSH
push $0x68732f2f // /bin/sh나 /bin//sh나 둘 다 shell을 띄운다.
push $0x6e69622f // /bin 문자열. 위와 합쳐서 /bin//sh가 된다.
mov %esp, %ebx // 현재 스택 포인터는 /bin//sh를 넣은 지점이다.
push %eax // NULL을 PUSH
push %ebx // /bin//sh 의 포인터를 PUSH
mov %esp, %ecx // esp 레지스터는 /bin//sh 포인터의 포인터다
mov %eax, %edx // edx 레지스터에 NULL을 넣어 줌
mov $0xb, %al // 시스템 콜 벡터를 12번으로 지정, al에 넣는다.
int $0x80 // 시스템 콜을 호출하라는 intterupt 발생

또 다른 방법
쉘 코드를 저장할 변수를 int형으로 만들어 준다.
- 리틀 엔디언 순서 정렬
- int형이므로 4바이트 단위로 만들어줘야 한다.

- objump를 이용하여 얻은 기계어 코드를 little endian 방식으로 재정렬 해줘야 해서 귀찮다.
- 대부분의 버퍼 오버플로우 공격 방법이 문자열형 데이터 처리의 실수를 이용하는 것이므로 char 형으로 생성하는 것이 더 편하다.


setreuid(0,0)와 exit(0)가 추가된 쉘 코드
공격자는 쉘 획득 이후 root 권한을 얻으려고 한다.
-> setuid 비트가 set 되어 있는 프로그램을 오버플로우시켜 쉘 코드를 실행시키고 루트의 쉘을 얻어낼 방법이 필요하다.

setreuid() 함수 : 프로그램 소유자의 권한을 얻어올 수 있게 된다. 쉘코드에 setreuid()가 하는 기계어 코드를 추가해 줘야 한다.
exit(0) 함수 : 공격자가 오버플로우 공격을 수행하고 난 뒤 프로그램의 정상적인 종료를 위해서이다.


Buffer Overflow 공격

고전적인 방법
쉘 코드가 있는 곳의 address를 추측.
쉘이 떨어질 때까지 계속 공격을 시도해야만 한다.
실행되는 확률을 좀 더 높이기 위해서 또한 buffer를 채우기 위해서 NOP를 사용하는데 보통 NOP는 0x90 값을 많이 쓴다.

NOP
No operation의 약자. -> 아무런 실행을 하지 않는다.
기계어 코드가 다른 코드와 섞이지 않게 하는 것.

예) 0xab로 끝나는 하나의 함수. 다음에 나오는 함수가 0xcdef로 시작한다고 할 때, 이 프로그램은 0xab를 수행하고 끝내기를 바란다. 하지만 뒤에 나오는 0xcd를 만나 0xabcd라는 instruction과 0xef라는 전혀 다른 의미의 두 개의 instruction으로 오해가 될 수 있다.
CPU는 instruction의 값을 보고 instruction set에 해당값이 있다면 하나의 instruction 단위를 거기서 잘라 인지하게 된다.
-> instruction이 섞이지 않게 하기 위해 instruction을 끊기 위한 목적으로 사용된다.

CPU는 NOP를 만나면 아무런 수행을 하지 않고 유효한 instruction을 만날 때까지 다음 instruction을 찾기 위해 한 바이트씩 이동한다.

버퍼오버플로우 공격에서는 쉘 코드가 있는 곳까지 아무런 수행을 하지 않고 흘러 들어가게 만드는 목적으로 사용된다.
NOP를 만나며 유효한 명령이 있는 쉘 코드의 시작점이 나올 때까지 한 바이트씩 EIP를 이동시키게 된다.

쉘 코드 앞을 NOP로 채우고 return address를 NOP로 채워져 있는 영역 어딘가의 주소로 바꾸면 operation의 흐름은 NOP를 타고 쉘 코드가 있는 곳까지 흘러 들어갈 수 있게 된다.

쉘 코드가 위치한 정확한 주소를 넣어주기 힘들기 때문에 return address에는 NOP로 채워져 있는 0xbffffa30~0xbffffa4c 사이의 값을 넣어주면 EIP는 return address가 가리키는 지점으로 가지만 NOP가 있기 때문에 한 바이트씩 증가하여 0xbffffa4c에까지 자동으로 이동하게 된다.

노가다라 힘들어서 요즘엔 거의 사용되지 않는다.

환경변수를 이용하는 방법
*nic 계열 쉘 : 환경변수 포인터로 참조.
환경변수가 메모리 어딘가에 항상 저장되어 있다.
- 응용프로그램에서 참조하여 사용하 수 있기 때문에 putenv(), getenv()같은 API 함수들도 많이 사용된다.

공격자는 환경변수를 하나 만들고 이 환경 변수에다 쉘 코드를 넣고 취약한 프로그램에 환경변수의 address를 return address에 넣어줌으로써 쉘 코드를 실행하게 할 수 있다.
-> 오버플로우되는 버퍼 크기가 쉘 코드가 들어갈 만큼 넉넉치 못할 경우에 매우 유용하게 사용된다.

환경 변수에 쉘 코드를 넣는 방법, 환경변수가 위치한 address
eggshell.c

EGG안에는 많은 NOP들이 들어있어서 구해진 stack pointer가 쉘 코드의 정확한 시작점을 가리키지 않더라도 instruction pointer가 흘러서 쉘 코드 시작점까지 도달할 수 있게 된다.


Return into libc 기법
스택 영역의 코드를 실행하지 못하게 하는 non-executable stack 보호기법이나 일부 IDS(instruction detectin system)에서 네트워크를 통해 쉘 코드가 유입되는 것을 차단하는 보호기법을 뚫기 위한 방법

- non-executable stack
스택 영역에 있는 코드를 실행하지 못하게 함.
CPU 레벨, 운영체제 영역에서의 보호 방법
EIP 레지스터에 stack segment 영역의 주소가 들어가게 되면 에러 출력 및 실행 종료 / 실행 종료


오버플로우 공격 기반.
버퍼를 오버플로우시켜 return address를 조작하여 실행의 흐름을 libc 영역으로 돌려 원하는 libc 함수를 수행하게 하는 것.
buffer 위에 있는 return address 영역에 실행시키고자 하는 libc 함수의 주소를 넣어준다.

libc 함수 위치 : 공유메모리 영역(Dynamic Link), segment 내부(static link)

함수가 리턴되면서 지정된 libc 함수가 실행된다.
대부분의 함수들이 호출될 때 인자들을 필요로 하게 되는데 그 인자는 buffer나 이전 함수의 base pointer, argument가 있는 영역 어디든 될 수 있다.



'시스템 해킹' 카테고리의 다른 글

3주차 질문  (0) 2022.08.13
basic_exploitation_000 문제 풀이  (0) 2022.08.13
pwnable.kr collision  (0) 2022.08.12
set UID  (0) 2022.08.10
질문 - 2주차  (0) 2022.08.05