다음 C프로그램은 그냥 stdin으로 입력된 값을 stdout으로 출력하는 간단한 echo 프로그램이다.
그러나 이 프로그램에는 버퍼 오버플로우를 일으킬 수 있는 취약점이 존재한다.
코드를 수정하지 않고 해당 파일을 컴파일한 프로그램을 실행해서 do_something() 함수를 호출하는 것이 목표!
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void do_something()
{
setreuid(0, 0);
system("whoami");
printf("Congratulation!\n");
}
int main()
{
int tmp = 0xFFFFFFFF;
char buffer[20] = "AAAABBBBCCCCDDDDEEE";
fgets(buffer, 80, stdin);
fputs(buffer, stdout);
return 0;
}
- 어떤 프로그램을 동작시키면 메모리에 프로그램이 동작하기 위한 위와 같은 가상의 메모리 공간이 생성된다.
- 우리는 이 중에서 스택 영역에 집중할 것!
아래의 간단한 C프로그램 예시를 통해 어셈블리어를 차근차근 이해해보자.
1. 예제로 어셈블리 코드 이해하기
// sample.c
int main() {
int c;
c=function(1, 2);
}
int function(int a, int b){
char buffer[10];
a=a+b;
return a;
}
위 프로그램에서 main 함수는 프로그램이 실행되면 가장 먼저 실행되는 함수이며, function 함수는 두 개의 정수를 받아서 더한 뒤 결과를 반환하는 함수이다.
이 C 프로그램을 컴파일(즉 번역)한 결과가 아래의 어셈블리 코드(sample.a)이고, 이는 컴퓨터가 함수 호출과 반환, 변수 관리 등을 어떻게 처리하는지 보여준다.
# dnf install gcc glibc-devel.i686 # GNU Compiler Collection (C컴파일러) 설치
$ gcc -m32 -S -fno-stack-protector -mpreferred-stack-boundary=2 -z execstack -o sample.a sample.c
어셈블리어 분석에 들어가기 전, 컴퓨터 아키텍쳐에 대해 간단히 살펴봐야한다.
CPU는 연산 장치와 초소형 저장 장치 register로 구성되며, 메모리는 주 기억 장치로서 스택, 힙을 예로 들 수 있다.
레지스터의 종류는 위와 같이 많지만, 이 중에서 우리가 지금 먼저 알아야 할 것은 다음과 같다.
- EAX : 누산기 / 주로 함수의 결과값을 저장하는 등의 산술 연산에 사용
- EBP : 베이스 포인터 / SS 레지스터와 함께 사용되어 스택 내의 변수 값을 읽는 데 사용 (스택 관리)
=> 함수 시작 전의 기준점
=> 스택에 저장된 EBP를 SFP (Saved Frame Pointer)라고 부름
- ESP : 스택 포인터 / SS 레지스터와 함께 사용되어 스택의 가장 끝 주소를 가리킴 (스택 관리)
- EIP : 명령 포인터 / 다음 명령어의 offset(상대 위치 주소)를 저장하며 CS 레지스터와 함쳐져 다음에 수행될 명령의 주소 형성
main 함수
main:
pushl %ebp
movl %esp, %ebp
subl $4, %esp
pushl $2
pushl $1
call function
addl $8, %esp
movl %eax, -4(%ebp)
movl $0, %eax
leave
ret
1. pushl %ebp
: 현재 함수의 이전 상태 (이전 함수의 ebp)를 스택에 저장
ebp는 "Base Pointer"라는 레지스터로, 이는 함수 호출 시 함수의 "시작 위치"를 기억하는 역할을 한다.
따라서 prologue, epilogue 호출 시에만 변경되며 값이 잘 바뀌지 않는 레지스터이다.
반면에 esp는 stack pointer이기 때문에 값이 자주 바뀌는 레지스터임!
그리고 아래의 RET는 return address로서, main 함수가 끝나면 돌아가는 지점이다.
2. movl %esp, %ebp
: esp 값을 ebp로 이동
esp는 항상 스택의 가장 끝 꼭대기값 (하위 메모리 주소)을 가진다.
즉, 과거 ebp 값이 푸쉬된 위치가 새로운 ebp가 되며, 현재 함수의 시작 지점을 표시하게 된다.
3. subl $4, %esp
: 지역변수 int c 가 저장될 공간(4 byte)을 마련
스택 포인터 esp 값을 아래로 4byte만큼 이동한다는 의미이다. (데이터를 저장할 때 위에서 아래로 쌓으므로)
즉, 스택에 4 byte 공간을 확보함
4. pushl $2
pushl $1
call function
: 스택에 정수 2를 저장 (함수 function의 두 번째 인수 b)
: 스택에 정수 1을 저장 (함수 function의 첫 번째 인수 a)
: 함수 function()을 실행
파라미터가 순서대로 정렬되기 때문에 거꾸로 집어넣기 위해 2, 1 순서대로 push한 것이다.
function 함수를 호출하면 CPU가 함수로 이동하여 실행을 시작하고, eax 레지스터에 반환값을 저장한다.
즉, 잠시 다른 함수로 여행을 떠났고 돌아올 때 답을 가져온다고 생각하면 쉬움!
function 함수
function:
pushl %ebp
movl %esp, %ebp
subl $12, %esp
movl 12(%ebp), %eax
addl %eax, 8(%ebp)
movl 8(%ebp), %eax
leave
ret
5. pushl %ebp
: 현재 ebp 레지스터 값을 스택에 저장 (현재 ebp 값 : main() 함수의 SFP)
즉 이전 상태를 저장하는 과정
6. movl %esp, %ebp
: 현재 esp 값을 ebp에 저장 (현재 함수의 시작 지점 기록)
main(), function 모두 공통된 prologue를 가지는 셈이다!
7. subl $12, %esp
: 스택에 12 bytes 만큼의 공간 확보
이는 char buffer[10] 때문!
메모리 alignment를 위해 4 bytes 단위로 스택 크기를 가감
8. movl 12(%ebp), %eax
: ebp + 12 위치에 있는 값을 eax에 가져와 복사하며, 이는 함수의 첫 번째 인수(a=1)이다.
9. addl %eax, 8(%ebp)
: eax에 기록된 값(현재 a)과 ebp+8 지점에 기록된 값(두 번째 인수 b)을 더한다
이 값이 ebp+8 지점에 update 되는 것!! (기존 : 1 -> 업데이트 : 1+2=3)
10. movl 8(%ebp), %eax
: ebp+8 지점에 저장된 값 3을 eax에 저장
return a 처리를 하기 위함이다!
< epilogue 에필로그 >
11. leave
: 스택 재구성, 함수 호출 전의 스택을 다시 갖춘다. (함수 종료 준비 단계)
mov %ebp, %esp => ebp의 위치로 esp 스택의 끝이 이동한 것을 볼 수 있음 (되돌리기)
pop %ebp => 꺼냄과 동시에 esp + 4로 커짐. 그림에서는 한 칸 내려온 모습
12. ret
: 호출한 함수(main)로 돌아가기
13. addl $8, %esp
: esp에 8을 더함 (스택 정리 단계)
함수 호출 전 push 한 데이터 크기 8byte 만큼 스택 포인터를 위로 이동하여 스택 크기를 조절한다.
즉, 함수 호출을 위해 쌓아뒀던 두 개의 인수 (각각 4 byte)를 제거하여 필요 없어진 작업 공간을 청소하는 개념이다.
14. movl %eax, -4(%ebp)
: eax에 저장된 값을 EBP-4를 뺀 주소값에 복사하여 int c에 기록한다.
앞서 10번 단계에서 eax에 3을 저장해 두었었는데,
이 값은 function이 반환한 값 a+b로서 이를 c 변수에 저장하는 작업이라고 보면 된다!
15. movl $0, %eax
: eax에 0을 저장 (모든 게 잘 끝났다는 표시)
이는 main 함수가 0을 반환하도록 설정하는 것으로서, 프로그램 종료 시 반환 값을 설정하는 마무리 단계이다.
16. leave
: 다시 한 번 에필로그 단계!!
mov %ebp, %esp (함수 실행 이전 상태로 돌아가기)
pop %ebp
17. ret
: 호출한 곳으로 돌아가기 (main 함수의 실행 끝)
<정리>
- 지역 변수는 ebp 보다 작은 주소에 존재한다.
- 함수 인자는 ebp 보다 큰 주소에 존재한다.
ebp : SFP (saved frame pointer)
ebp+4 : RET (돌아갈 다음 주소인 return address가 저장됨)
ebp+8 : 첫 번째 함수 인자
- 함수 반환값은 eax에 저장된다.
2. Buffer Overflow
버퍼 오버플로우의 핵심은 '덮어쓰기' 이다!
정상적인 경우에는 사용되지 않아야 주소 공간, 즉 원래는 덮어쓸 수 없는 부분에 해커가 임의의 코드를 덮어쓰는 것.
처음에 언급했던 목표 프로그램 practice.c를 다시 가져와보자.
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void do_something()
{
setreuid(0, 0);
system("whoami");
printf("Congratulation!\n");
}
int main()
{
int tmp = 0xFFFFFFFF;
char buffer[20] = "AAAABBBBCCCCDDDDEEE";
fgets(buffer, 80, stdin);
fputs(buffer, stdout);
return 0;
}
1) 코드 분석
- main() 함수
- 총 24 bytes 크기의 지역 변수 존재
- fgets로 stdin을 읽음 > 최대 80 bytes를 읽음
- fputs로 읽은 데이터를 stdout으로 반환 ("echo")
- do_something() 함수
- owner=root AND set-uid bit가 걸린채 실행되었을 때 UID = EUID = 으로 전환
- "whoami" 커맨드 실행
- 현재 소스코드 상으로는 절대 실행되지 않음!
2) 취약점 분석
- 부족한 버퍼의 크기로 인해 프로그램이 비정상 종료될 가능성이 있음 : 버퍼 오버플로우!
3) 익스플로잇
정확히 어떻게 죽는지 알아보기 위해 gdb 디버거로 실습해보자.
디버거 실습
$ gdb -tui -q practice
-tui -q 옵션을 붙여주면 위와 같이 예쁜 창이 나오게 된다.
어셈블리어 창 보이기
(gdb) layout asm
레지스터 창 보이기
(gdb) layout regs
main 함수 디스어셈블
(gdb) disas main
메인 함수에 bp 설정
(gdb) b main
프로그램 실행
(gdb) run
메모리를 확인하는 디버깅 명령
(gdb) x/12xw $esp
- x : 메모리를 읽으라는 명령
- /12 : 12개의 항목을 출력하라는 의미
- x : 각 항목을 16진수로 출력하라는 뜻
- w : 각 항목의 크기를 워드(4byte)로 지정
- $esp : 현재 스택 포인터(esp)의 값을 기준으로 메모리 출력
=> 즉 현재 스택 포인터가 가리키는 주소부터 12개의 4byte를 읽어와 16진수로 출력하겠다는 의미!
하나의 어셈블리 명령어 실행 (next instruction 약자)
(gdb) nexti
위 두 명령을 하나의 함수 fn으로 매크로를 만들어서 다음과 같이 한 번에 수행하였음!
이제 여기서 fgets() 함수 실행 이후 스택을 보기 위해 *main+68 지점에 breakpoint를 설정한다.
(gdb) b *main+68
(gdb) cont
입력 값이 들어가있는 것을 볼 수 있다.
해당 프로그램을 끝까지 실행하게 되면 EIP가 가리키는 공간의 주소는 사용 불가능한 공간으로서, 명령을 읽을 수 없다.
따라서 practice 프로세스가 gdb에게 아래와 같이 SIGSEGV 시그널을 내며 강제 종료된다!
이 eip가 do_something() 함수의 시작 지점을 가리키도록 하는 것이 현재의 목표이다.
(eip로 do_something()의 시작 주소 부여하기)
함수의 시작주소는 print 혹은 x/i 명령으로 다음과 같이 얻을 수 있다.
시작주소는 0x80491cd 인 것을 알 수 있다!
이를 기반으로 다음과 같이 페이로드를 짜볼 수 있다.
지역 변수를 채울 24byte의 임의의 데이터(int tmp 와 char buffer[20]) + SFP에 저장되어 있던 4byte 의 임의의 데이터
= 28 byte 값 뒤에 do_something()의 주소 0x80491cd 를 덮어쓰면 됨
앞의 28byte는 아무 쓰레기값이나 넣어주면 되는데, 여기서는 개념 구분을 위해 A*20 + B*4 + C*4 를 입력해주자.
그리고 RET를 위한 해당 주소값은, x86 CPU의 little endian 특성에 따라 byte 단위로 역순으로 기재하여 입력해준다.
=> \xcd\x91\x04\x08
'System Hacking' 카테고리의 다른 글
[System] 버퍼 오버플로우 실습 예제 문제 풀이 (0) | 2024.11.25 |
---|