brunch

You can make anything
by writing

C.S.Lewis

by Younggi Seo Jun 15. 2018

New 2: CTF for RCE testing

바이너리 문제를 풀기 위한 프로그래밍 기법

CTF에 출제되는 바이너리 문제의 대부분은 바이너리를 분석하는 것만으로는 FLAG*를 획득할 수 없다. 깃발을 손에 넣기 위한 조건을 만족시켜야 하기 때문에 분석 결과를 바탕으로 별도의 프로그램을 만들어야 하는 경우도 꽤 있다. 먼저 바이너리 문제에서 '반드시 이걸 써야 한다'라는 프로그래밍 언어는 없다. 하지만 필자는 파이썬을 사용하는 것을 추천한다(우스이 토시노리, 2016).


파이썬의 만든 귀도 반 론섬(Guido van Rossum)은 애초에 언어 자체를 가장 간단하면서도 사용이 편리한 도구로 만들려고 했었기 때문에 파이썬은 자동화 스크립트를 빠른 시간 내에 완성하는데 최적의 언어이다. 또한 지원되는 라이브러리 또한 많이 개발**되고 있기 때문에 보안에서 가장 유용한 언어이다.


우선 반드시 준비해두면 편한 소스 코드는 소켓 통신을 위한 스크립트이다. 서버 측에서 어떤 서비스가 작동하고 있고, 그 서비스의 바이너리가 전달되고, 바이너리 분석 결과에 따라 서버와 통신하는 문제가 많다. 따라서 소켓 통신을 하는 것은 필수 기술이다. 또한 익스플로잇 분야에서도 원격 서버를 공격하는 유형의 문제가 많기 때문에 필수 기술이다. 이러한 소켓 통신은 대부분 TCP가 사용되기 때문에 일단 다음과 같은 기법을 기본으로 기억해두고 필요에 따라 변경하면 좋을 것이다(우스이 토시노리, 2016).

import socket
host = '127.0.0.1'  # 서버의 IP 주소
port = 31337  # 포트 번호
s = socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
s.sendall('Some data you want to send')
s.recv(1024)


CTF에서 종종 C 언어의 표준 함수가 필요한 경우가 있다. 서버에서 생성한 난수를 예측하고 싶은 경우라면 C 언어의 표준 라이브러리인 srand와 rand를 사용해야 한다. 물론 seed가 시간이 된다면 자신의 환경에서도 동일하게 시간을 seed로 만들어서 srand와 rand 기능과 같이 구현(즉, 같은 버전의 표준 라이브러리 libc)해서 동일한 난수를 발생시켜 난수를 예측할 수 있다. 바이너리 동작을 파이썬 스크립트로 재현할 때 효과적이다(우스이 토시노리, 2016).

import ctypes
libc = ctypes.cdll.LoadLibrary('/lib/x86_64-linux-gnu/libc.so.6')
libc.srand(libc.time(0))
print libc.rand()


그 밖에 16진수 취급도 해야 한다. struct 모듈의 pack, unpack을 사용해 16 진수 값과 다른 변수 값을 상호 변환할 수 있다. pack은 인수로 취한 변수를 바이너리로, unpack은 인수로 바이너리를 변수로 변환한다. 이 경우 형식에 '<I'를 지정하면 리틀 엔디언 정수형이 된다('<'이 리틀 엔디언을 나타내고, 'I'가 정수를 나타낸다).

import struct
num = 65535
binary = struct.pack('<I', num)
num = struct.unpack('<I', bin)



그리고 미리 준비해놓을 수 있는 셸 코드를 살펴보자. 셸 코드(Shell code)는 공격할 때 사용하기 위해 만든 기계어로 작성된 프로그램의 일부를 말하며 주로 셸을 실행하기 위해 만들어진 경우가 많기 때문에 '셸 코드'라고 한다. 셸 코드는 기본적으로 외부 라이브러리를 사용하지 않고 단독으로 작동하게끔 만들어져 있다. 즉 리눅스에 기본적으로 탑재되어 있는 C 언어는 자체 라이브러리를 사용하므로 황무지에서 개간할 필요가 없었으나, 셸 코드는 스스로 개척해야 한다. 셸 코드를 만드는 문제부터 버퍼 오버플로 공격을 통한 셸 코드 실행 등 다양하게 응용할 수 있다.


셸 코드는 기계어로 만들어지지만 아무래도 사람이 기계어를 만드는 것은 어렵기 때문에 보통은 어셈블리어로 만들고 그것을 기계어로 변환하는 방법으로 만든다. 평소에 바이너리를 많이 분석하고 어셈블리 코드를 많이 읽어봤다면 직접 만들 수 있을 것이다(우스이 토시노리, 2016).


어셈블리어를 만들 때 사용하는 도구는 GNU Assembler(GAS)와 Netwide Assembler(NASM)의 두 가지 종류가 가장 널리 사용된다. 이번 세션에서는 표준 인텔 기법을 사용하는 NASM을 사용해 설명을 이어나가겠다. 셸 코드는 기본적으로 외부 라이브러리를 사용하지 않고 어셈블리어를 쓰기 때문에 평소에 쉽게 사용할 수 있는 puts, printf, fgets 같은 표준 라이브러리(libc) 함수를 호출할 수는 없다. 그 대신 어셈블리어에서 시스템 호출을 사용해 운영체제 기능을 직접 호출할 수 있다.



시스템 호출(인터럽트 루틴)


기계어에서 CPU에 인터럽트를 걸어 운영체제 기능을 호출할 수 있다. 시스템 호출의 종류는 매우 많다. 입출력에 사용되는 read, write를 비롯해 다른 프로세스를 시작하는 execve 파일을 여는 데 사용하는 open 등도 시스템 호출에서 제공하는 기능이다.


이러한 기능은 레지스터에 값을 할당한 뒤 시스템 호출 명령을 실행해서 불러올 수 있다. 예를 들어, x86 환경에서 표준 입력으로 0x804b000이라는 주소에 0x100 바이트만큼의 문자열을 읽어오게 하고 싶다면 다음과 같은 어셈블리 코드를 만들어 기계어로 변환한다.


mov eax, 3
mov ebx, 0
mov ecx, 0x804b000
mov edx, 0x100
int ox80

ebx, ecx, edx는 read에서 사용하는 3개의 인수이며, 마지막 'int 0x80'이 CPU에 대한 시스템 호출 인터럽트 처리다. eax가 3으로 돼있는데 이것은 많은 종류의 시스템 호출을 구분하기 위한 시스템 호출 번호(아래 표)에 해당한다(우스이 토시노리, 2016).


종류                x86            x64            인수 1            인수 2            인수 3
read                3                0                  fd                  buf              count    
write               4                1                  fd                  buf              count
execve            11              59                filename     argv             envp
dup2               63              33                old               new
mprotect        125            10                start            len               prot


x86과 x64에서도 시스템 호출 방법에 차이가 있기 때문에 시스템 호출 인수와 지원하는 명령을 정리하면 아래 표와 같다.

아키텍처            명령            번호            인수 1            인수 2            인수 3            인수 4
x86                    int 0x80          eax                ebx                  ecx                  edx                   esi
x64                    syscall            rax                 rdi                   rsi                    rdx                   r10


앞으로 어셈블리어를 다루거나 디버깅할 때 가장 중요한 것은,


어셈블리어를 읽고 머릿속에서 알고리즘***을 떠올리는 연습이다.
1) 스택이 push/pop 명령으로 어떻게 변화되는지,
2) 레지스터의 값은 어떻게 됐는지,
3) 논리 연산과 지시 변환으로 인한 계산 결과가 어떻게 되는지 알아야 한다.


일단, 이전의 섹션에서 아래와 같은 strcpy함수를 사용하는 프로그램의 어셈블리어 코드를 gdb를 통해 확인해서 어떤 흐름으로 위의 세 가지 사항이 이루어지는 확인할 수 있다.


1) main( ) 함수의 어셈블리어 코드

아키텍처가 64bit이기 때문에 아래 표를 다시 확인해가면서 어셈블리어 코드를 순서대로 살펴보자. 그전에 어셈블리어를 인텔 AT 표기법으로 변경하기 위한 명령을 쳐서 operand(피연산자)의 저장 방식이 뒤에서 앞으로 넣는 순서(후치 수식)로 읽어야 한다는 것을 염두에 두자.

 

아키텍처            명령            번호            인수 1            인수 2            인수 3            인수 4
x64                    syscall            rax                rdi                    rsi                     rdx                   r10


첫 번째 레지스터에서 콜론 오른편의 OPcode(연산자)는 push이며 operand(피연산자)로 %rbp가 쓰였다.

그리고 두 번째 레지스터에서는 mov 연산자가 쓰였고 목적어에 해당하는 피연산자들로 rbp와 rsp가 쓰였다. 64bit에서는 피연산자 접두어가 'e'가 아니라 'r'이다. e로 바꾸면 esp(Extended Stack Pointer), ebp(Extended Base Pointer)라는 것을 알 수 있다. 이 두 줄의 어셈블리어 명령은 기본적으로 메모리 스택이 처음 push 하기 위한 명령어 세트로 프롤로그(prologue)라고 부른다. 즉 처음에는 스택 포인터(SP)를 베이스 포인터(BP, 스택의 첫 시작점을 지칭)로 이동시켜서 메모리값을 스택 메모리로부터 레지스터로 로딩한다. 왜냐하면 스택 포인터(스택의 길이를 지정) 자체는 계산 값을 담을 수 있는 레지스터의 종류가 아니기 때문이다.  


이후 아래의 4 bytes만큼 스택 포인터가 이동되는 명령어로 'sub    rsp,0x10'을 확인할 수 있다. 이것은 rsp 값(char *argv[] 할당 값)에서 4 bytes 만큼을 뺀다. 즉 스택에 0x10라는 메모리 값만큼의 용량을 할당한다는 뜻으로 아래 소스 코드 main() 함수 내부의 function(argv[1]); 명령 라인에 해당한다. function 함수를 호출하는 부분으로 rsp 레지스터에 0x10 번지만큼 빼서 호출에 필요한 메모리 공간을 확보한 것이다. 이제 함수 내에서 rsp 값이 아무리 변해도 호출을 위해 확보한 스택 영역은 변하지 않는다.


이후 다시 8 bytes 만큼 스택 포인터 이동이 이루어지는 명령어로 'mov    DWORD PTR [rbp-0x4], edi'가 나온다. 왠 64bit에서 rdi가 아닌 edi가 튀어나왔는지는 나의 아키텍처(Intel(R) Core(TM) i5-4300U CPU @ 1.90 GHz 2.50 GHz)가 완전한 x64 지원형이 아닐 수도 있다는 것과 혹은 현재 리눅스 LTS 18.04 버전이 x86 OS의 형태를 완전히 탈피하지 못한 하이브리드 OS일 수도 있다는 거다.


edi(Extended Destination Index)는 데이터를 전송되는 목적지 주소를 저장하는 지시자로 edi라는 레지스터에 있는 주소 값을 4 bytes 크기의 [rbp-0x4] 주소에 넣으라는 뜻이다. 앞의 OPcode 값으로는 'DWORD PTR'이 있는데 Double Word Pointer의 준말이다. 즉 여기서 Word가 2 bytes에 해당하므로 더블 워드는 4 bytes를 의미하며 4 byte만큼의 포인터 이동이 이루어진다.

 

여기까지 일단 필자가 기억나는 대로 어셈블리어 명령어의 용도를 환기해봤고, 실제 메모리 스택이 어떻게 바뀌는지 그리고 레지스터 값은 어떻게 바뀌고 논리 연산과 지시 연산으로 계산 결과가 어떻게 되는지는 참조 서적을 보고 상상하는 연습을 해야 할 것 같다. 아니며 이전 섹션에서 University of Washington 대학의 동영상 시리즈 강좌(Procedure Video1~6, https://www.youtube.com/channel/UCmf3tLU4WzOnriEQXa638Bw)도 참조할만하다. 


아래 소스 코드 내의 다른 함수의 어셈블리어 명령어도 같은 논리의 순서로 이루어진다는 것을 알 수 있다. 왜냐하면 소스 코드의 라인이 많아야 두 줄이기 때문에 프로그램의 흐름은 단순하게 구성되기 때문이다.


2) function() 함수의 어셈블리어 코드


3) overflowed() 함수의 어셈블리어 코드






* FLAG는 2진수로 두 가지 상태를 표현할 수 있는 0, 1의 bit를 뜻한다. CTF 대회에서는 목적으로 하는 대상(Flag, 깃발)을 의미하는 것으로 깃발을 쟁취하면 방어 혹은 공격 성공의 실마리를 쟁취할 수 있다.


**귀도 반 론섬과의 출판사 인터뷰 내용(http://www.hanbit.co.kr/network/category/category_view.html?cms_code=CMS3338272127)


*** 알고리즘(algorithm)이란 비슷한 문제를 해결하기 위한 순서를 말한다.



참조

1) 우스이 토시노리 외 7인. 양현 역. (2016). CTF 정보보안 콘테스트 챌린저 북: Capture The Flag 문제 풀이로 배우는 해킹 테크닉, 서울: 위키북스.   

매거진의 이전글 New 1: Windows RCE testing
브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari