-
LOB에 도전하기 전에...시스템/Lord of the BOF 2017. 5. 31. 06:07
스택 버퍼오버플로우를 이해하기 전 반드시 읽었으면 하는 책이 있다. 그것은 바로 달고나님의 '해커 지망자들이 알아야 할 Buffer Overflow Attack의 기초' 라는 문서다. 정말 도움이 되니 버퍼 오버플로우의 개념이 확실히 잡히지 않는다면 몇번이고 보면서 다시금 그림을 그려가며 복습하기를 적극 권장한다.
알기전에 프로그램이 어떻게 실행되는가를 알아야한다(운영체제가 실행시키는 하나의 프로세스를 일컫는다)
.
1. 프로그램은 어떻게 실행되는가?
프로세스는 운영체제가 실행시킬 때 다음 그림과 같은 세그먼트 단위로 나누어 RAM에 올린다.
각 프로세스별로 할당된 세그먼트는 내부적으로 stack segment, data segment, code segment로 크게 나누어진다.
stack segment는 현재 실행중인 프로그램의 스택정보가 모두 들어간다. 프로그램 내에 쓰이는 버퍼는 여기에 속한다.
data segment는 프로그램 실행시 쓰이는 데이터가 들어간다. 전역변수와 같은 데이터들을 의미한다.
code segment는 명령어(instruction)가 들어있다. 이는 컴파일러가 만들어낸 기계어 코드로 분기, 점프, 시스템 호출, 등을 수행하는데 이용된다. segment는 논리주소값으로, 실제 메모리상의 주소가 아니기 때문에 이를 segment selector를 통하여 실제 메모리상의 주소를 찾아가게 된다. (페이징)
2. 레지스터는 무엇이고 어떤게 있는가?
프로세스는 명령어를 실행하고 데이터를 가지고오고 RW과정을 수행한다. 이를 위해서 특별한 저장공간인 레지스터를 사용한다. 레지스터는 범용 레지스터, 세그먼트 레지스터, 플래그 레지스터를 먼저 알아보도록 한다. 범용 레지스터는 앞으로 계속해서 알아야할 것들이며, 세그먼트 레지스터는 '데이터가 어디에 저장되는구나' 하고 이해하면 될 것이고, 플래그 레지스터는 그때그때 찾아가면서 배우면 될 것이다.
3. 프로그램의 실행은 어떻게 되며, 이는 세그먼트와 레지스터와 어떤 관계가 있는가?
거의 모든 프로그램은 프롤로그, 에필로그를 거치며 스택 영역을 할당하고 스택 포인터를 사용한다. 이는 앞서 언급한 범용 레지스터를 적극 이용한다. 프롤로그, 에필로그를 설명하는 것으로 버퍼오버플로우가 무엇인지 기술하도록 하겠다.
프롤로그란:
1) 이전에 실행되던 함수의 베이스포인터(EBP)를 스택에 저장시킨다
2) EBP에서 1 word만큼 아래의 주소를 가리키게 된다(4바이트 아래).
그래서 리턴주소가 ebp+4에 있고, ebp+8부터 다른 함수의 parameters 주소값이거나 직접적인 값으로 쓰이게 되는 것이다. (이는 쉘코드를 분석하며 push하고 args.들을 넣을 때 자세히 알 수 있다 스택에는 값이 역순으로 들어감에 유의해야한다)
(요컨대 function(1, 2, 3)이라는 함수는:
push $0x3
push $0x2
push $0x1 순으로 넣게된다. 이는 스택에서 꺼낼 때 값을 거꾸로 꺼내기 때문이다.)
에필로그란:
함수 프롤로그를 되돌린다.
1) ESP를 EBP값과 동일하게 만든다.
2) EBP의 스택을 POP하고 EBP를 도로 꺼내어 저장한다.
3) ESP는 1 word만큼 위의 주소를 가리킨다(4바이트 위).
4) return address가 POP되었기 때문에 그 주소를 EIP에 저장하여 이전에 실행하던 함수의 마지막 부분을 실행하도록 옮긴다.
정상적인 과정이라면 설계대로 프로그램의 분기가 오고갈 것이다. 하지만 return address를 덮어버리면 프로그램의 실행이 의도치 않게 흘러갈 것이다. 이것이 버퍼 오버플로우이며, 이를 적절히 이용하는 것이 버퍼 오버플로우 공격이라 할 수 있다. 즉, 버퍼를 넘치게해서 프로그램의 기존 분기가 아니라 해커가 원하는 분기로 이동시키는 것을 의미한다.
4. 버퍼 오버플로우는 어떻게 하는가?
버퍼 오버플로우는 스택의 버퍼 값을 넘치도록 받는 프로그램의 분기를 이용하여 return address를 바꾸어버리는 것이라고 앞서 언급한 바 있다. 그렇다면 분기를 바꾸기 위해 어떻게 해야할까? 먼저 다음과 같은 과정을 거친다.
1) 내가 원하는 코드를 메모리의 허용된 공간에 기록
2) 그 영역으로 분기를 바꿈 ( 원래라면 Code Segment가 실행되어야 할 곳 )
3) 원하는 코드가 실행이 된다.
하지만 데이터를 넣을 땐 byte order를 고려해야한다. Intel 계열의 CPU는 little endian이므로 낮은 메모리부터 값을 채워넣음에 유의해야한다. (추후 ARM계열 RISC 버퍼 오버플로우를 할때는 다음에 기술하도록 하겠다.)
1) 내가 원하는 코드를 메모리의 허용된 공간에 기록
공격코드는 보통 쉘을 띄우는 것을 사용한다. 쉘을 띄우는 공격코드를 쉘코드(shellcode)라고 하며 쉘을 실행시키도록 프로그램의 분기를 바꾸면 쉘이 실행될 것이다.
쉘코드를 작성하기 위해선 SLL형식으로 라이브러리를 모두 포함한 컴파일을 수행하여 내부 함수가 어떻게 실행되는지를 분석하고 쉘코드를 짠다. 통상의 프로그램은 DLL 방식으로 외부 라이브러리를 사용하며 리눅스 상에서는 *.so, *.a 형식으로, 윈도우즈 환경에서는 *.dll 방식으로 쓰인다.
2) 그 영역으로 분기를 바꿈 ( 원래라면 Code Segment가 실행되어야 할 곳 )
(1) NOP
쉘코드를 모두 짰다면 쉘코드가 있는 곳의 주소값을 찾아야한다. 버퍼를 채우려고 NOP를 사용하는데, 이 주소를 찾기도 힘들고 정확하지 않고, 보통 쉘코드를 넣기에는 스택의 공간이 모자라기 때문에 다양한 방법으로 쉘코드를 실행시키는 영역을 찾아야한다. 때문에 다른값을 이용한다.
(2) Eggshell
환경변수는 *nix 계열의 쉘에서 포인터로 참조된다. 따라서 환경변수는 메모리 어딘가에 저장되어있음을 의미하고 이는 환경변수를 조작가능하면 쉘코드를 올릴 수 있게되고, 그렇다는 것은 쉘코드가 실행된 주소를 찾는 프로그램만 있다면 얼마든지 실행가능하단 의미가 된다. (관련하여 에그쉘을 찾아보라)
(3) Return-into-Libc, aka. RTL
이 기법은 스택에 코드를 실행하지 못하게 하는 보호기법을 우회하기 위한 방법이다. 이 기법 역시 오버플로우에 기반하나, return 주소값을 libc 영역으로 돌린다(이때 주로 쓰이는 함수는 system, 그보다 더 low-level한 execl, execve가 쓰인다). 이를 구할 때, 사용하려는 libc 함수의 argument 구조를 알아보자.
libc 함수가 실행될 때는 다른 함수가 실행되는 것과 동일하게 return address를 PUSH하고 해당 함수의 시작점으로 이동한다. 즉, return 지점이 사용하고자 하는 libc 함수의 시작지점이 된다.
예를들어, system() 함수를 사용한다고 했을 때, system함수의 프롤로그 후 mov 0x8(%ebp), %eax로 argument 처리를 수행하는데, 이는 ebp+8의 위치이고 이 주소는 기존 main()에서 변조된 return 주소값이 됨을 의미한다. 앞서 말한 return 지점이 사용하고자 하는 libc 함수의 시작지점이 된다는 말은 이 뜻을 의미한다. 해당 과정을 그림으로 도식화하면 다음과 같다.
분기를 바꾸는데는 성공했다. 그렇다면 이제 system 함수를 사용한다면 실행할 문자열만 있으면 되는데, 이는 간단히 환경변수를 응용하여 사용하면 될 것이다.
(4) beist's excel method
이는 달고나님의 문서에서 발췌한 내용이다. FC3이었나 FC4에 응용되는 내용으로, 메모한 정보를 여기에 올린다.
RTL 기법으로 루트를 따려면 execl을 사용하면 된다.
execl?
int execl(const char *path, const char *arg, …);
첫 인자: 경로(full path)
두번째: 실행파일 이름으로 구성된 문자열의 주소
세번째부터: 실행파일 실행시 주어질 args.
끝값에: NULL
다음 공격방법을 제안:
L
H
dummy
&argv[2]-8
&execl()+3
"./shell"
공격원리?
execl()+3은: 함수 프롤로그 작업을 하지 않음. 즉, execl+3이 있는 지점의 주소를 return address로 지정.
&argv[2]-8은: 여기는 이전 함수의 base pointer가 들어가는 지점. 즉, vul의 main()함수가 return 하면 여기에 넣어둔 &argv[2]-8이 ebp에 들어가게되고 execl+3부터 실행된다. 따라서, execl() 함수는 이 값(&argv[2]-8)을 base pointer로 삼고 실행한다.
그 후 execl()을 따라가면 execve() 실행 전에 pushl 0x8(%ebp) 가 있다. 이는 ebp+8의 값을 스택에 넣는다는 말인데, 넣고 execve()를 호출한단 말이다. 실행시킬 명령어를 넣는다는 의미와 같다. execl()은 base pointer+8에 실행할 명령이 들어있는데, ebp+8은 argv[2]의 주소를 가리키고, 거기엔 쉘 프로그램 실행 명령어를 넣어둬서 쉘을 겟한다.
(5) ROP (Return Oriented Programming)
코드를 실행할 영역을 못찾았을 때 ROP를 사용한다.
plt: 프로그램 내 함수의 '첫' 호출 시 함수의 주소값을 읽어오는 주소값을 의미한다.
got: 프로그램 내 함수가 실제로 호출되는 주소값을 의미한다.
got를 덮어쓰면? 프로그램의 분기가 수정된다. 그 후 Chaining RTL Calls을 수행한다.
그 후 got가 덮어써진 함수를 '첫'호출을 하면, 자연스레 plt를 호출할 것이고 그러면 변조된 got를 수행할 것이다. 따라서 그때 공격할 쉘코드 혹은 적절한 값(페이로드라고 부른다)을 넣어서 함수를 실행시키면 쉘을 겟할 것이다.
이정도가 LOB 6.3, LOB FC3, LOB FC4를 모두 푸는데 필요한 내용이다. 이 내용을 모두 익혔다면 적어도 스택 오버플로우만큼은 본인이 어느정도 '기본적인' 감을 익혔다고 보면 될 것이다. 그 후 다양한 CTF나 pwnable.kr, pwnable.tw같은 기라성같은 포너블 사이트들이 있으니 하나씩 풀다보면 될 것이다. 물론 겁나 힘들겠지만.....
ps. 기초적인 가이드라인은 여기까지입니다. 잘못된 점이 있거나 오탈자가 있다면 댓글로 지적해주시면 감사하겠습니다.
ps2. 다음은 해당 내용을 이용하여 문제풀이를 하는 글을 차례로 올릴 예정입니다.