해커 지망자들이 알아야 할 Buffer Overflow Attack의 기초
4. 프로그램 구동 시 Segment에서는 어떤 일이? (p.12)
simple.c
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를 살펴보자.
앞에 붙어 있는 주소가 logical address이다.
이 주소를 보면 function()함수가 아래에 자리 잡고 main()함수는 위에 자리잡고 있음을 알 수 있다.
따라서 메모리 주소를 바탕으로 생성될 이 프로그램의 segment 모양은 다음 그림과 같이 될 것임을 유추할 수 있다.
위와 같이 segment가 구성되었다. segment의 크기는 프로그램마다 달라 최상위 메모리의 주소는 그림과 같이 구성되지 않을 수 있다. (임의의 값이다.) 이 segment의 logical address는 0x08000000부터 시작하지만 실제 프로그램이 컴파일과 링크되는 과정에서 다른 라이브러리들을 필요로 하게 되기 때문에 코딩한 코드가 시작되는 지점은 시작점과 일치하지 않을 것이다.stack segment 역시 0xBFFFFFFF까지 할당 되지만 필요한 환경 변수나 실행 옵션으로 주어진 변수 등등에 의해서 가용한 영역은 그보다 더 아래에 자리잡고 있다. simple.c는 전역변수를 지정하지 않았기 때문에 data segment에는 링크된 라이브러리의 전역변수 값만 들어 있을 것이다.
프로그램이 시작되면 EIP 레지스터(CPU가 수행할 명령이 있는 레지스터)는 main()함수가 시작되는 코드를 가리키고 있을 것이다. main()함수의 시작점은 0x8048fc이다.
ESP가 정확히 어느 지점을 가리키는지 알아보기 위해 레지스터 값을 알아본 것
ESP가 정확히 어느 지점을 가리키는지 알아보기 위해 레지스터 값을 알아본 것


Step 1
그림과 같이 EIP는 main()함수의 시작점을 가리키고 있다. ESP는 스텍의 맨 꼭대기를 가리키고 있다. 그 이유는 프로그램이 수행되면서 수많은 PUSH와 POP 명령을 할 것이기 때문에 이 지점에다 PUSH를 해라, 이 지점에 있는 데이터를 POP해가라 라는 의미이다. PUSH 명령이 ESP가 가리키는 지점에다 데이터를 넣을 것인지 아니면 그 아래 지점에다 데이터를 넣을 것인지는 system architecture에 따라 다르다. 마찬가지로 POP 명령이 ESP가 가리키는 지점의 데이터를 가져갈 것인지 아니면 그 위의 데이터를 가져갈 것인지 역시 다르게 동작한다. 별 상관은 없다.
base pointer
이전에 수행하던 함수의 데이터를 보존하기 위해 ebp를 저장하는 것
함수 프롤로그 과정
함수가 시작될 때 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 111111111 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에 있는 명령을 수행하라는 것. function 함수가 자리잡은 곳이다.
call 명령은 함수를 호출할 때 사용되는 명령으로 함수 실행이 끝난 다음 다시 이후 명령을 계속 수행할 수 있도록 이후 명령이 있는 주소를 스텍에 넣은 다음 EIP에 함수의 시작지점의 주소를 넣는다. "add $0x10, %esp" 명령이 있는 주소다.
따라서 함수 수행이 끝나고 나면 이제 어디에 있는 명령을 수행해야 하는가 하는 것을 스텍에서 POP하여 알 수 있게 되는 것이다. 이것이 바로 buffer overflow에서 가장 중요한 return address이다.
이제 EIP에는 function함수가 있는 0x80482f4 주소값이 들어가게 된다.
Step 4
이제 EIP는 function()함수가 시작되는 지점을 가리키고 있고 스텍에는 main()함수에서 넣었던 값들이 차곡차곡 쌓여있다.
push %ebp
mov %esp, %ebp
function()함수에서도 마찬가지로 함수 프롤로그가 수행된다. main()함수에서 사용하던 base pointer가 저장되고 stack pointer를 function()함수의 base pointer로 삼는다.
Step 5
function() 함수의 프롤로그가 끝나고 만난 명령은 다음과 같다.
sup $0x28, %esp
이것은 스텍을 40바이트 확장한다. 40바이트가 된 이유는 simple.c의 function()함수에서 지역 변수로 buffer1[15]와 buffer2[10]을 선언 했기 때문인데, buffer1[15]는 총 15바이트가 필요하지만 스텍은 word (4byte)단위로 자라기 때문에 16바이트를 할당하고 buffer2[10]을 위해서는 12바이트를 할당한다. 따라서 확장 되어야할 스텍의 크기는 28바이트이다. 하지만 gcc버전에 따라 또 달라진다.
gcc 2.96 미만의 버전에서는 위와 같이 word 단위로 할당되어 28바이트 확장이 되고 그 이후의 버전에서 스텍은 16배수로 할당된다. 단 8바이트 이하의 버퍼는 1 word 단위로 할당되지만 9바이트 이상의 버퍼는 4 word 단위로 할당이 된다. 또한 8 바이트 dummy값이 들어간다.
buffer1[15]를 위한 16 바이트 + buffer2[10]을 위한 16바이트 + 8바이트 dummy = 40바이트
→ 쓸데없는 8바이트의 공간이 소모되고 있다.
function함수의 인자는 function()함수의 base pointer와 return address 위에 존재하게 된다. 이것은 main함수가 호출 될 때 주어지는 인자 argc, argv가 위치한 곳과 같은 배치를 갖고 있다.
Step 6
이렇게 만들어진 버퍼에는 이제 우리가 필요한 데이터를 쓸 수 있게 된다.
mov $0x41, [$esp -4]
mov $0x42, [$esp-8]
같은 형식으로 ESP를 기준으로 스텍의 특정 지점에 데이터를 복사해 넣는 방식으로 동작한다. simple.c에는 데이터를 넣는 과정이 없으므로 스텍이 만들어진 과정까지만 확인하자.
스텍은 위와 같은 형태를 갖게 된다.
Step 7
이제 leave instruction을 수행했다. leave instruction은 함수 프롤로그 작업을 되돌리는 일을 한다.
함수 프롤로그
push %ebp
mov %esp, %ebp
되돌리는 작업
mov %ebp, %esp
pop %ebp
leave instruction 하나가 위의 두 가지 일을 한꺼번에 한다. stack pointer를 이전의 base pointer로 잡아서 function()함수에서 확장했던 스텍 공간을 없애버리고 PUSH해서 저장해 두었던 이전 함수, main()함수의 base pointer를 복원 시킨다. POP을 했으므로 stack pointer는 1 word 위로 올라갈 것이다. stack pointer는 return address가 있는 지점을 가리키고 있을 것이다. ret instruction은 이전 함수로 return하라는 의미이다. EIP 레지스터에 return address를 POP하여 집어 넣는 역할을 한다. 표현하자면 pop %eip라고 할 수 있지만 EIP 레지스터는 직접적으로 수정할 수 없어 명령이 먹히지는 않는다.
Step 8
ret를 수행하고 나면 return address는 POP되어 EIP에 저장되고 stack pointer는 1 word 위로 올라간다.
add $0x10, %esp는 스텍을 16바이트 줄인다. 따라서 stack pointer는 0x804830c에 있는 명령을 수행하기 이전의 위치로 돌아가게 된다.
leave
ret
를 수행하게 되면 각 레지스터들의 값은 main()함수 프롤로그 작업을 되돌리고 main()함수 이전으로 돌아가게 된다. 이것은 아마 init_process()함수로 되돌아가게 될 것이다. 이 함수는 운영체제가 호출하는 함수다.
5. Buffer overflow의 이해
buffer란?
시스템이 연산 작업을 하는데 있어 필요한 데이터를 일시적으로 메모리 상에 저장하는 공간
문자열을 처리할 것이라면 문자열 버퍼, 수열이라면 숫자형 데이터 배열이다. 대부분의 프로그램에서는 버퍼를 스텍에 생성한다. 스텍은 함수 내에서 선언한 지역 변수가 저장되게 되고 함수가 끝나고 나면 반환된다. 이것은 malloc()과 같이 free()를 해 주지 않는 이상 계속 보존되는 반영구적인 데이터 저장 공간과는 다르다.
buffer overflow는 어떤 원리로 동작하는가?
buffer overflow는 미리 준비된 버퍼에 버퍼의 크기보다 큰 데이터를 쓸 때 발생한다. <그림14>의 스텍은 40바이트의 스텍이 준비되어 있는데, 그것보다 큰 데이터를 쓰면 버퍼가 넘치게 되고 프로그램은 에러를 발생시키게 된다. 41~44바이트의 데이터를 쓰면 이전 함수의 base pointer를 수정하게 되고, 45~48바이트를 쓰면 return address가 저장되어 있는 공간을 침범하게 될 것이고, 48바이트 이상을 쓰면 return address뿐만 아니라 그 이전에 스텍에 저장되어 있던 데이터 마저도 바뀌게 될 것이다.
여기서 시스템에게 첫 명령어를 간접적으로 내릴 수 있는 부분은 return address가 있는 위치이다. return address는 현재 함수의 base pointer 바로 위에 있으므로 그 위치는 변하지 않는다. 공격자가 base pointer를 직접적으로 변경하지 않는다면 정확히 해당 위치에 있는 값이 EIP에 들어가게 되어 있다. 따라서 buffer overflow 공격은 공격자가 메모리상의 임의의 위치에다 원하는 코드를 저장시켜 놓고 return address가 저장되어 있는 지점에 그 코드를 집어 넣음으로 해서 EIP에 공격자의 코드가 있는 곳의 주소가 들어가게 해 공격을 하는 방법이다.
공격자는 버퍼가 넘칠 때(버퍼에 데이터를 쓸 때) 원하는 코드를 넣을 수가 있다. 물론 정확한 return address가 저장되는 곳을 찾아 return address도 정확하게 조작해 줘야 한다.
<그림 14>와 simple.c를 다시 상기시켜보면, function()함수 내에서 정의한 buffer1[15]와 buffer2[10]의 버퍼가 있고 여기에는 40바이트의 버퍼가 할당되어 있다. 이 버퍼에 데이터를 쓰려한다고 생각해보자.
코드 예시) strcpy(buffer2, receive_from_client);
이 코드는 client로부터 수신한 데이터를 buffer2와 buffer1에 복사한다. 저장할 바이트를 지정해주는 strncpy()함수와 달리 strcpy함수는 길이 체크를 해 주지 않기 때문에 receive_from_client 안에 들어있는 데이터에서 NULL(\0)를 만날 때까지 복사를 한다. <그림14>와 같은 스텍 구조에서 45~48바이트 위치에 있는 return address도 조작해줘야 하고 공격 코드도 넣어줘야 한다. <그림 15>와 같은 구성의 공격 코드를 생각해보자.(값은 의미 없는 임의의 값이다)
클라이언트인 공격자가 전송하는 데이터는 receive_from_client에 저장되어 버퍼에 복사될 것이다. 그 데이터가 <그림 15>와 같이 구성하여 전송한다고 가정하자. 그리고 strcpy가 호출되어 receive_from_client가 buffer2에 복사가 될 것을 예상하면 다음과 같이 매칭될 것이다.
strcpy가 호출되고 나면 스텍안의 데이터는 <그림 17>과 같이 된다.
<그림 17>은 receive_from_client의 데이터를 버퍼에 복사한 후의 모습이다. 들어가 있는 데이터를 보면 <그림 16>에서 만들어낸 데이터와 순서에 있어 약간의 차이가 있다.
Byte order
데이터가 저장되는 순서가 바뀐 이유는 바이트 정렬 방식이다. 현존하는 시스템들은 두 가지의 바이트 순서를 가진다.
big endian 방식
바이트 순서가 낮은 메모리 주소에서 높은 메모리 주소로 된다.
- IBM 370, RISC 기반의 컴퓨터, 모토로라의 마이크로 프로세서
little endian 방식
높은 메모리 주소에서 낮은 메모리 주소로 되어 있다.
- IBM 호환 시스템, 알파 칩의 시스템
예) 74E3FF59(16진수)
big endian → 74E3FF59
little endian → 59FFE374
little endian이 저장 순서를 뒤집어 놓는 이유는 수를 더하거나 빼는 셈을 할 때 낮은 메모리 주소 영역의 변화는 수의 크기 변화에서 더 적기 대문이다. 74E3FF59에 1을 더한다고 하면 74E3FF5A가 될 것이고 메모리상에서의 변화는 %aFFE374가 된다. 낮은 수의 변화는 낮은 메모리 영역에 영향을 받고 높은 수의 변화는 높은 메모리 영역에 자리를 잡게 하겠다고 하는 것이 이 방식의 논리이다. 높은 메모리에 있는 바이트가 변하면 수의 크기는 크게 변한다. 하지만 한 바이트 내에서 bit의 순서는 big endian 방식으로 정렬된다.
*네트웍 byte order는 big endian 방식을 사용한다.
공격 코드의 바이트를 정렬할 때에는 이러한 byte order의 문제점을 고려해야 한다. 그러므로 little endian 시스템에 return address 값을 넣을 때는 바이트 순서를 뒤집어서 넣어주어야 한다.
buffer overflow를 이용한 공격 방법
<그림 17>에서 보는 바와 같이 return address가 변경이 되었고 실제 명령이 들어 있는 코드는 그 위에 있다. 이 시점까지는 아무런 에러를 발생하지 않는다. 하지만 함수 실행이 끝나고 ret insruction을 만나면 return address가 있는 위치의 값을 EIP에 넣을 것이고 이제 EIP가 가리키는 곳의 명령을 수행하려 할 것이다. 이 때 이 주소에 명령어가 들어 있지 않다면 프로그램은 오류를 발생시키게 된다. 또한 공격자는 자신이 만든 공격 코드를 실행하기를 원하므로 EIP에 return address 위에 있는 쉘 코드의 시작 주소를 넣고 싶어 한다.
이 주소를 알아내는 방법
쉘 코드가 들어있는 지점의 정확한 주소를 찾았다고 생각하자. 주소는 0xbffffa60이다.(<step 8> 참고) <그림17>을 다시 그려 쉘 코드와 return address를 묘사해 보면 아래와 같다.
위에서 보여주는 공격 코드는 execve("/bin/sh", ...)이다. 쉘을 띄우는 것이다. 실제 쉘 코드가 그림처럼 들어가 있는 것은 아니고 저 위치에 저런 의미의 코드가 들어있다는 개념을 표현한 것이다.
쉘 코드의 시작 지점은 스텍상의 0xbffffa60이다. 따라서 함수가 리턴될 때 return address는 EIP에 들어가게 될 것이고 EIP는 0xbffffa60에 있는 명령을 수행할 것이므로 execve("/bin/sh", ...)를 수행하게 된다.
만날 수 있는 문제점 한 가지
<그림 18>에서의 공격 코드는 총 24byte 공간 안에 들어가 있다. 공격 코드가 24byte로 만들어 지지 못한 경우에, 즉 return address 위의 버퍼 공간이 쉘 코드를 넣을 만큼 충분하지 않다면 다른 공간을 찾아봐야 한다. 위의 예에서는 90909090...이 들어가 있는 function()함수가 사용한 스텍 공간이 그 공간이다. 이 공간은 40byte이고 추가로 main()함수의 base pointer가 저장되어 있는 4byte까지 44byte가 낭비되고 있다. 이 공간을 활용하자. return address가 EIP에 들어간 다음에 40byte의 스텍 공간의 명령을 수행할 수 있도록 해주어야 한다. return address에 직접 40byte 공간의 주소를 적어주면 좋지만 해당 명령어가 있는 주소를 정확히 알아내는 것은 매우 어렵다. 간접적으로 그 곳으로 명령 수행 지점을 변경해 주는 방법을 사용해야 한다.
ESP 값을 이용해 명령 수행 지점을 지정해 주는 방법
return address 이후의 버퍼 공간이 부족할 경우 return address 이전의 버퍼 공간을 활용하는 방법
위에서는 쉘 코드가 return address 아래에 있다. 40byte가 남아 있던 공간 return address는 똑같다. 함수가 실행을 마치고 return할 때 return address가 스텍에서 POP되어 EIP에 들어가고 나면 stack pointer는 1 word 위로 이동한다. 따라서 ESP는 return address가 있던 자리 위를 가리키게 된다. EIP는 0xbffffa60을 가리키고 있을 테니 그 곳에 있는 명령을 수행할 것이다. <그림18>에서 쉘 코드가 있던 그 자리에는 다음과 같은 코드가 들어갔다. ESP가 가리키는 지점을 쉘 코드가 있는 위치를 가리키도록 48byte를 빼주고 jmp %esp instruction을 수행해 EIP에 ESP가 가리키는 지점의 주소를 넣도록 한다. 이 과정의 명령들을 쉘코드로 변환했을 때 8byte면 충분하다. ESP 레지스터는 사용자가 직접 수정할 수 있는 레지스터라 가능하다.
만약 return address 이전의 버퍼 공간도 부족하면 return address 부분만을 제외한 위아래 모든 공간을 활용하도록 코딩할 수 있고 이것도 안되면 또 다른 공간을 찾는 작업을 해야 한다.
쉘 코드 만들기
쉘) 명령 해석기, 일종의 유저 인터페이스
사용자의 키보드 입력을 받아서 실행파일을 실행시키거나 커널에 명령을 내릴 수 있는 대화통로
쉘 코드) 쉘을 실행시키는 코드
바이너리 형태의 기계어 코드(혹은 opcode)
쉘 코드를 만들어야 하는 이유
실행중인 프로세스에게 어떤 동작을 하도록 코드를 넣어 그 실행 흐름을 조작할 것이기 때문에 실행 가능한 상태의 명령어를 만들어야 한다. 기계어 코드에 능통하다면 직접 기계어 코드를 작성해도 좋다. C를 이용해 간단한 프로그램을 작성한 다음 컴파일러가 변환시켜준 어셈블리 코드를 최적화 시켜 쉘 코드를 생성해보자.
먼저 쉘을 실행시키는 프로그램을 작성한다. 그 다음 어셈블리 코드를 얻어내고 불필요한 부분을 빼고 또 라이브러리에 종속적이지 않도록 일부 수정을 해준 다음에 바이너리 형태의 데이터를 만들어낼 것이다.
쉘 실행 프로그램
쉘 상에서 쉘을 실행시키려면 '/bin/sh ' 라는 명령을 내리면 된다. 쉘 실행 프로그램은 이 명령을 내리는 것과 똑같은 일을 하도록 해주면 된다.
sh.c
#include<unistd.h>
void main(){
char *shell[2];
shell[0] = "/bin/sh";
shell[1] = NULL;
execve(shell[0], shell, NULL);
}
쉘을 실행시키기 위해 execve()라는 함수를 사용했다. 이 함수는 바이너리 형태의 실행 파일이나 스크립트 파일을 실행시키는 함수이다. 세 개의 인자들이 모두 const char * 형 인자들을 요구하고 있고 첫번째 인자는 파일 이름, 두 번째 인자는 함께 넘겨줄 인자들의 포인터, 세 번째 인자는 환경 변수 포인터이다. 이 조건을 만족시켜 주기 위해 char *shell[2]를 만들었고 각 인자들을 채워주었다. 두 번째 인자인 인자들의 포인터는 C 프로그램의 main()함수에 argv라는 인자를 떠올리면 된다. argv[0]은 해당 프로그램의 실행 파일 이름을 나타내고 argc[1]은 실행 시 주어진 첫 번째 인자 ... 이런 식으로 나간다. 두 번째 인자는 argv[0]부터 들어가는 값을 가리키는 포인터가 되어야 한다.
이 프로그램이 컴파일 되어 생성될 바이너리 코드를 얻어야 한다. 이 함수 때문에 프로그램은 컴파일 되면서 Linux libc와 링크되게 된다. 실제 코드가 libc에 들어 있기 때문이다. execve()함수가 어떤 일을 하는지 알아보기 위해 static library 옵션을 주어 컴파일해야 한다.
Dynamic Link Library & Static Link Library
응용프로그램의 실행에 있어서 실제 프로그램의 동작에는 매우 많은 명령들이 사용된다. 그리고 많은 응용프로그램들이 공통적으로 사용하는 명령어들이 있다. C언어에서 printf()함수는 어떤 문자열을 출력하는 함수인데, 'ps', 'cat'이라는 프로그램도 printf()함수를 사용할 것이다. 같은 기능을 하는 기계어 코드가 서로 다른 실행파일에 모두 포함되어 있는 것은 저장 공간의 낭비다. 그래서 운영체제에는 많이 사용되는 함수들의 기계어 코드를 자신이 가지고 있고 다른 프로그램들이 이 기능을 빌려 쓰게 해준다. 프로그래머는 이 기능을 직접 구현할 필요 없이 호출만 해주면 되고, 컴파일러도 직접 컴파일 할 필요 없이 호출하는 기계어 코드만 생성해 주면 된다. 이러한 기능들은 라이브러리라고 하는 형태로 존재하고 있으며 리눅스에서는 libc라는 라이브러리에 들어있고 실제 파일로는 .so 혹은 .a라는 확장자를 가진 형태로 존재한다. 윈도우즈에서는 DLL파일로 존재한다.
Static Link Library
하지만 운영체제의 버전과 libc의 버전에 따라 호출 형태나 링크 형태가 달라질 수 있기 때문에 영향을 받지 않기 위해 printf()기계어 코드를 실행파일이 직접 가지고 있게 할 수 있다. 다만 Dynamic Link Library 방식보다 실행파일의 크기가 당연히 커진다. 윈도우즈용 응용프로그램에서 실행 파일을 실행 했는데 DLL파일을 찾을 수 없다는 에러메시지는 Dynamic Link Library 형태의 프로그램인데 필요한 기계어 코드가 있는 라이브러리를 찾지 못했다는 뜻이다. 또는 DLL 파일을 필요로 하지 않고 실행파일 하나만 있는 프로그램의 경우는 Static Link Library 형태의 프로그램이다.
sh.c 프로그램에서 호출하는 execve()함수의 내부까지 들여다 보기 위해 Static Link Library 형태로 컴파일 한 후 기계어 코드를 살펴보자.
sh.c 를 static link library로 컴파일 해 sh라는 실행파일을 만들고 objdump를 이용해 기계어 코드를 출력하게 했다. 필요한 부분만 보기 위해 grep을 했고 execve()부분은 32라인이면 다 보이기 때문에 -A 32옵션을 주었다. 덤프된 코드는 세 개의 column으로 출력되는데 맨 왼쪽은 address를 나타내고 가운데는 기계어 코드, 맨 오른쪽은 기계어 코드에 대응하는 어셈블리 코드를 나타낸다. 기계어 코드는 어셈블리 코드와 1:1 대응이 된다. execve()함수 내에서 보면 함수 프롤로그를 하고 함수 호출 이전에 스텍에 샇인 인자값들을 검사하고 이상이 없으면 인터럽트를 발생시켜 시스템 콜을 한다. 시스템 콜은 운영체제와 약속된 행동을 해 달라고 요청하는 것이다.
굵게 표시된 부분만 보자.
execve()함수는 인터럽트를 발생시키기 이전에 범용 레지스터에 각 인자들을 집어넣어줘야 한다.
mov 0x8(%ebp),%ebx
mov 0xc(%ebp),%ecx
mov 0x10(%ebp),%edx
이것은 ebp 레지스터가 가리키는 곳의 +8 byte 지점의 값을 ebx 레지스터에 넣고, +12 byte 지점의 값을 ecx 레지스터에 넣고, +16 byte 지점의 값을 edx 레지스터에 넣으라는 뜻이다. 위 그림을 보면 dbp는 함수 프롤로그에 의해서 execve()가 호출되고 이전 함수의 base pointer를 PUSH하고 난 다음의 esp가 가리키던 곳을 가리키고 있다. 따라서 ebp +0 byte 지점은 이전 함수의 ebp(base pointer)가 들어가 있을 것이다. 그리고 ebp+4 byte 지점은 return address가 들어가 있을 것이고, ebp+8, ebp+12, ebp+16 지점은 execve()함수가 호출되기 이전 함수에서 execve()함수의 인자들이 역순으로 PUSH되어 들어갔을 것이다. step 3,4,5 과정 참고
그 다음 eax 레지스터에 11을 넣고 int $0x80을 했다. (system call 과정) int $0x80은 운영체제에 할당된 인터럽트 영역으로 system call을 하라는 뜻이다. int $0x80을 호출하기 이전에 eax 레지스터에 시스템 콜 벡터를 지정해 줘야 하는데 execve()에 해당하는 값이 11(0xb)이다. 11번 시스템 콜을 호출하기 위해 각 범용 레지스터에 값들을 채우고 시스템 콜을 위한 인터럽트를 발생시킨 것
32bit Intel Architecture에서의 인터럽트 및 Exception
인터럽트 0x80은 'Maskable Interrupts'로써 External interrupt 영역에 있다.
execve()를 호출하기 이전에 main()에서는 어떤 처리를 했는지 알아보자.
main()함수에서는 execve()를 호출하기 위해서 세 번의 push를 한다. 이는 execve()의 인자로 넘겨주는 값이라는 걸 짐작할 수 있다. 제일 처음 '/bin/sh'라는 문자열이 들어있는 곳의 주소(0x8089728)를 ebp 레지스터가 가리키는 곳의 -8 byte 지점(0xfffffff8)에 넣는다. 그리고 ebp -4 byte 지점(0xfffffffc)에는 0을 넣는다. 이것은 sh.c에서 다음과 같은 역할을 한다.
shell[0] = "/bin/sh";
shell[1] = NULL;
그리고 이 값들을 PUSH하기 시작한다.
push $0x0
NULL을 PUSH하고
lea 0xfffffff8(%ebp)
push %eax
ebp+8의 주소를 eax 레지스터에 넣은 다음에 eax 레지스터를 push한다. 포인터를 PUSH한 것
pushl 0xfffffff8(%ebp)
call 804c75c <__execve>
ebp+8의 값을 PUSH하고 execve()를 호출한다.
shell 변수는 char * 형의 배열 이름이다. 따라서 shell 자체는 char *들이 위치한 곳을 가리키고 있을 것이다. 그림의 ebp-4와 ebp-8이 바로 포인터가 모여 있는 곳이다. shell[0]은 '/bin/sh'라는 문자열이 있는 곳의 주소를 가지고 있다. '/bin/sh'는 정의된 값이므로 data segment에 위치할 것이다. 그곳 어딘가의 주소가 0x8089728이라고 objdump를 해 알 수 있었다. main()함수에서 각 값들을 PUSH하여 스텍에는 'bin/sh'가 있는 주소, shell의 주소, 그리고 0이 들어가 있다.
쉘을 띄우기 위한 과정
1. 스텍에 execve()를 실행하기 위한 인자들을 제대로 배치하고
2. NULL과 인자값의 포인터를 스텍에 넣어 두고
3. 범용 레지스터에 이 값들의 위치를 지정해 준 다음에
4. interrupt 0x80을 호출해 system call 12를 호출하게 한다.
위의 코드에서는 '/bin/sh'가 data segment에 저장되어 있기 때문에 그 주소를 이용할 수 있었지만 buffer overflow 공격 시점에서는 '/bin/sh'가 어느 지점에 저장되어 있다는 것을 기대하기 어렵고 있다고 하더라도 저장되어 있는 메모리 공간의 주소를 찾기도 어려워 직접 넣어줘야 할 것이다. 이 역할을 하는 코드를 작성해보자.
push $0x0 // NULL을 넣어준다
push '/sh\0' // /sh\0문자열의 끝을 의미하는 \0
push '/bin' // /bun 문자열. 위와 합쳐서 /bin/sh\0가 된다.
mov %esp,%ebx // 현재 스텍 포인터는 /bin/sh\0를 넣은 지점이다.
push $0x0 // NULL을 PUSH
push %ebx // /bin/sh\0의 포인터를 PUSH
mov $esp,%ecx // esp 레지스터는 /bin/sh\0의 포인터의 포인터다.
mov $0x0,%edx // edx 레지스터에 NULL을 넣어 줌
mov $0xb,%eax // system call vector를 12번으로 지정. eax에 넣는다
int $0x80 // system call을 호출하라는 interrupt 발생
push '/sh\0'와 push '/bin'은 실제 어셈블리 코드가 아니라 개념적으로 적은 것이다. 실제는 코드는 아래와 같다.
push $0x0068732f
push $0x6e69622f
(little endian 순서)
이 코드는 C 프로그램 내에 인라인 어셈블로 코딩할 것이고 main()함수 안에 들어갈 것이기 때문에 함수 프롤로그가 필요 없다. 컴파일러가 알아서 만들어준다.
'/bin/sh'를 16진수 형태로 바꾸고 main()함수 안에 넣어서 작성한 sh01.c의 코드
NULL의 제거
이 기계어 쉘 코드를 얻은 다음에 이것을 char형 문자열 형태로 전달할 것이다. C언어에서는 char형 변수에 바이너리 값을 넣는 방법을 제공한다. char c="\x90"과 같은 형태로 값을 넣어주면 컴파일러는 문자열로 보는 게 아니라 16진수 90으로 인식해 1byte 데이터로 저장한다. char형 배열(문자열)에서는 0의 값을 만나면 문자열의 끝으로 인삭한다. 0x00와 같은 기계어 코드가 생기지 않게 만들어줘야 한다. mov $0xb,%eax 코드 또한 00를 만들어 내기 때문에 고쳐줘야 한다.
다시 작성한 어셈블리 코드
xor %eax,%eax // 같은 수를 XOR하면 0 -> NULL
push %eax // 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 // system call vector를 12번으로 지정. al에 넣는다.
int $0x80 //system call을 호출하라는 interrupt 발생
덤프한 모습을 보면 우리가 필요로 하는 코드 xor %eax,%eax (8048304)이후부터 int $0x80 (804831b) 사이의 기계어 코드에는 00이 없다. 따라서 NULL로 인식될 염려가 없다. 이것을 문자열화 시켜야 한다. char형 배열에 16진수 형태의 바이너리 데이터를 전달하려면 \x90형식으로 바꿔줘야 한다.
함수 프롤로그가 수행되고 나서 굵게 표시된 부분의 코드를 수행한다.
ebp-4byte 지점의 address를 eax 레지스터에 넣고 그 address에 8을 더한다. 이것은 sh03.c에서 ret=(int *)&ret +2;과정이다. ret라는 포인터 변수의 address를 찾아서 8바이트 상위의 주소로 만든다. int *형에 1을 더하면 실제로는 4가 더해지는 것이다. ebp+4 지점에는 return address가 들어 있다. 이 주소 값을 ebp -4byte 지점에 넣어준다. 거꾸로도 해준다. 그리고 eax 레지스터 값이 가리키는 지점에 $0x804936c을 넣어준다. char sc[] 데이터가 있는 지점이다. 따라서 main()함수가 종료되고 EIP는 return address가 가리키는 지점에 있는 명령을 가리키게 될 것이다. 그것을 쉘 코드가 들어있는 위치를 가리키게 했으므로 시스템은 쉘 코드를 수행하게 된다.
또 다른 방법
쉘 코드를 저장할 변수를 int형으로 만들어준다. 유의할 점은 little endian 순서로 정렬해야 하며 int형이므로 4byte 단위로 만들어줘야 한다.
int형 배열이든 char형 배열이든 상관없다. 그러나 int형 배열을 사용할 때는 objdump를 이용하여 얻은 기계어 코드를 little endian 방식으로 재정렬 해줘야 하고, 대부분의 buffer overflow 공격 방법이 문자열형 데이터 처리의 실수를 이용하는 것이므로 char 형으로 생성하는 게 더 편하다.
setreuid(0,0)와 exit(0)가 추가된 쉘 코드
root권한을 얻을 수 있는 방법은 setuid 비트가 set되어 있는 프로그램을 이용할 수 있다. 그 프로그램을 오버플로우시켜 쉘 코드를 실행시키고 루트의 쉘을 얻어내야 한다.
이 프로그램에 의해 만들어진 sh03에 setuid비트를 붙여서 실행해도 아무런 역할을 하지 못한다. root 소유의 프로그램의 권한을 그대로 상속받지 못했기 때문이다. 쉘 코드에 소유자의 권한을 얻어내는 기능이 필요해 쉘 코드부터 수정해야 한다.
setreuid()함수를 이용해 프로그램 소유자의 권한을 얻어올 수 있게 된다. 따라서 쉘 코드에 setreuid()가 하는 기계어 코드를 추가해줘야 한다. 기계어 코드를 찾는 방법은 위에서 살펴본 execve()에서 기계어 코드를 찾는 방법과 동일하게 수행할 수 있다. static으로 컴파일해 setreuid()함수를 찾아 인터럽트를 호출하는 부분을 찾으면 된다.
찾은 기계어 코드와 어셈블리 코드
"\x31\xc0" // xorl %eax,%eax
"\x31\xdb" // xorl %ebx,%ebx
"\xb0\x46" // movb $0x46,%al
"\xcd\x80" // int $0x80
이것을 만든 쉘 코드 앞에 붙여주면 된다.
좀 더 완벽한 쉘 코드를 만들기 위해 exit(0)가 필요할 수 있다. 공격자가 overflow 공격을 수행하고 난 뒤 프로그램의 정상적인 종료를 위해서이다. 그렇지 못하면 에러 메시지가 발생할 수도 있고 이 메시지가 로그 파일 혹은 관리자에게 그대로 전달될 수도 있다. exit(0)에 대한 기계어 코드는 아래와 같다.
"x31\xc0\xb0\x01\xcd\x80"
이를 이용해 만든 sh03.c의 수정 코드
이렇게 프로그램 소유자의 권한으로 쉘을 실행시키는 쉘 코드를 생성할 수가 있다. 이제 취약한 프로그램에다 이 쉘 코드를 집어 넣어 실행시킬 수 있는 방법을 다음 포스팅에서 알아볼 것이다.
'시스템 해킹' 카테고리의 다른 글
쉘코드 (0) | 2021.02.22 |
---|---|
시스템해킹 공부 사이트 (0) | 2021.02.14 |
[시스템] Buffer overflow 공격 54~84p - 달고나 (0) | 2021.02.04 |
8086 Memory Architecture, 8086 CPU 레지스터 구조 (by. 달고나) (0) | 2021.01.17 |