주요 내용으로 건너뛰기

64bit Linux Binary 취약점 분석 - (3) ROP

64-bit Linux에서의 ROP(Return-Oriented Programming) 에 관한 글을 작성하려던 중 이미 넘사벽 수준으로 잘 작성된 해외 문서를 발견하였습니다. 원저자(Ben Lynn, blynn@cs.stanford.edu)의 허락을 받아 번역하였습니다. 흔쾌히 허락해주신 스탠포드의 Ben Lynn박사님께 감사드립니다.

원문의 코드는 Ubuntu 12.04에서 수행되었으나 현재는 호환되지 않습니다. 아래의 실습 내용은 Ubuntu 16.04에서 수행한 내용으로 코드를 알맞게 수정하였으며,  저자의 허락을 얻어 기재합니다.

*This article(64-bit Linux Return-Oriented Programming) is translated / published under permission of the original author(Ben Lynn, blynn@cs.stanford.edu). Thank you very much for your patience, Ph.D Ben Lynn.

**The original code was based on Ubuntu 12.04 version, but in this posting, it was changed to compatible code in version 16.04 and it was approved by the author.


세상에 '완벽함'이란 존재하기 힘듭니다. 프로그래밍 분야도 예외는 아닙니다. 우리는 인생의 절반을 코딩하는데 보내고, 나머지 절반은 본인이 작성한 코드의 버그를 다시 수정하는데 쏟습니다. 하지만 그렇게라도 고칠 수 있다면 차라리 다행인 것입니다. 대부분은 그런 엉뚱한 버그가 존재하는지도 모르는 채 제품을 출시하게 되고, 엄청난 대형참사가 발생하고 나서야 비로소 그러한 버그가 있었다는 것을 뒤늦게 인지하게 됩니다.

어떤 경우는 단지 우연에 의해 촉발된 상황에서 하필이면 프로그래머가 간과했던 논리적 실수가 드러나게 되어 연쇄적인 사고를 일으키게 된 것일 수도 있습니다. 하지만 또 한편으로는 의도적으로 벌어지는 악행도 있습니다. 예를 들어 고도의 지능범죄의 경우 회계사 등의 자격이 있는 사람이 치밀하게 계획하여 법률의 허점을 뚫고 탈세행위를 벌이는 일처럼 말입니다. 컴퓨터 분야에서의 해커(공격자) 역시 특정 버그(bug)를 찾아내고 그 지점을 악용하여 타인의 시스템을 임의로 조작할 수 있게 됩니다.

그렇기 때문에 현대의 시스템은 악의적인 사용자들이 버그를 역이용하는 상황으로부터 스스로를 보호할 수 있도록 다양한 보안 기능을 설계하여 도입하고 있습니다. 이러한 safeguards 프로그램들은 대체적으로 비정상적인 악성 행위가 포착되는 순간 스스로를 중단시켜버리거나 중요한 정보를 은닉함으로써 보안을 유지합니다.

실행 영역 보호(Executable space protection) 역시 이러한 보안 기법의 일종이지만, 안타깝게도 거의 효과가 없는 수단인 것으로 드러났습니다. 본 블로그에서는 64-bit Linux 환경에서 Executable space protection을 교묘하게 우회할 수 있는 기법인, 일명 ROP(return-oriented programming)을 다루어 보도록 하겠습니다.

기본 어셈블리 코드

우선 execve system call을 통해 shell을 획득하기 위해 필요한 기본적인 어셈블리 언어를 작성해보겠습니다.

호환성 관점으로 생각해본다면, 기존의 32-bit 리눅스의 system call 함수들이 64-bit 환경에서도 지원되기에 32-bit 시스템에서 사용하던 쉘 코드를 그대로 재사용할 수 있지 않을까 하는 생각을 하기 십상(十常)입니다. 하지만 execve syscall 은 메모리 주소 공간을 참조할 때 반드시 NULL-terminated로 구분된 프로그램명을 찾습니다. 그렇기 때문에 32bit 보다 큰 메모리 주소를 참조할 필요가 있는 경우 이를 삽입하면 호환이 되지 않습니다. 따라서 64-bit 환경에서는 반드시 전용 syscall 함수를 사용해야 합니다.

이미 32-bit에 익숙한 분들이라면 아래의 표를 참고하면 쉽게 이해하실 수 있습니다.

아래와 같은 인라인 어셈블리 코드를 C언어 파일로 작성하여 shell.c 로 저장하도록 합니다.

해당 코드는 메모리의 어느 위치에 적재되던 상관없이, call / pop을 조작하여 궁극적으로 RDI 레지스터의 값을 /bin/sh 문자열의 주소로 설정하게 될 것입니다.

needle0과 needle1 레이블은 추후 검색 가능하도록 하기 위해 설정한 것이며, 상수로 설정된 0xdeadbeef도 마찬가지입니다. (x86의 경우 리틀 엔디언으로 표현되어 4개의 zero byte 뒤에 EF BE AD DE 와 같은 형태로 표현될 것입니다.)

참고로 단순한 설명을 위해, API 호출 절차를 일부 생략하였음을 밝힙니다. 원칙대로라면 execve에 대해 두 번째, 세 번째 파라미터가 추가적으로 필요하며 문자열에 대한 Null-terminated 배열의 포인터가 되어야 합니다. (argv[]와 envp[]). 그러나 이를 무시하여도 시스템에서 관용적으로 처리가 되며, argv와 envp는 NULL이지만 "/bin/sh" 실행에는 별다른 지장이 없습니다.

어쨌든, argv 와 envp 배열을 추가하는 것이 옳기는 합니다.


쉘 코드

삽입하려는 payload를 아래와 같이 구현하였습니다. 이 기계 코드가 잘 작동하는지 확인해봅시다.

64-bit 시스템에서 code segment는 보통 0x4000000에 위치합니다. 즉, 바이너리에서 해당 코드는 0x4da 오프셋에서 시작하여 0x4f7 에서 끝이 납니다. 이 길이는 29 bytes입니다.

이를 8의 배수로 맞추어주기 위해 올림하여 얻을 수 있는 가장 적합한 수는 32입니다. 따라서 아래의 명령어를 수행합니다.

생성된 shellcode의 내용을 확인하면 아래와 같습니다.


단숨에 훑어보는 취약한 C 프로그램

아래와 같이 아주 끔찍하게 취약한 프로그램을 작성해봅시다. victim.c 로 저장하도록 하겠습니다.

실습을 보다 수월하게 진행하기 위해, buffer의 위치를 출력하는 코드를 함께 삽입하였습니다.

시스템의 cdecl 호출 규약(calling convention)으로 인해, 매우 긴 문자열을 입력하게 되면 name 배열에서 buffer overflow가 발생하여 복귀 주소(return address)를 덮어쓸 수 있게 됩니다. 그 점을 고려하여 적절한 위치에 쉘 코드를 삽입해두면, 프로그램이 메인 함수에서 복귀하려고 할 때에 의도치 않게 쉘 코드를 실행하게 됩니다. 

코드 주입에 필요한 3요소

Stack smashing 공격은 갈수록 어려워지고 있습니다. 이 글이 작성된 시점의 Ubuntu 12.04 에서는 적어도 3개의 보호 기법이 적용되어 있습니다. (역자 주 : 현재 실습을 수행하고 있는 16.04에서는 적용된 보호 기법이 12.04보다 더 많아졌겠지만 우선 원문에서 언급된 내용을 위주로 진행함)

1. GCC Stack-Smasing Protector (SSP), 일명 ProPolice

컴파일러가 버퍼 오버플로우의 발생 위협을 줄이기 위해 스택 레이아웃을 재설정하고, 프로그램이 실행되는 도중에 스택의 무결성을 검사함.

2. Executable space protection (NX)

스택에 있는 코드를 바로 실행하려는 경우 세그먼트 오류를 발생시키는 보호 기법. 이 기능은 여러 가지 유사한 이름으로 불리고 있는데, 윈도 운영체제에서는 DEP(Data Execution Prevention)이라고 하고, BSD에서는 Write XOR Execute (W^X)라고 함. 여기에서는 64-bit Linux 시스템에서 본 기능을 CPU의 NX bit(Never eXecute)로 구현한 관례에 따라 NX라고 지칭할 것임.

3. Address Space Layout Randomization (ASLR) 

프로그램이 매번 실행될 때마다 Stack이 배치되는 위치를 계속 변경함으로써, 설령 Return Address를 용케 덮어 씌웠다 하더라도 어디로 점프하도록 주소를 지정해야 할지 알지 못하게 하는 방법.


하지만 본 실습에서는 첫 연습의 수월성을 위해 약간의 편법을 사용하도록 하겠습니다. 

먼저 SSP를 해제합니다.

(역자 주 : 현대의 컴파일러들은 컴파일 시에 취약한 함수들을 사용하지 말 것을 권고하는 warning 메시지가 표출되도록 되어있음. 그러나 우선 실습 진행에는 상관없음.)

다음은 NX를 해제합니다.

마지막으로, 바이너리를 실행할 때 ASLR 도 해제하고 동시에 해당 바이너리를 실행합니다.

실행하면 메모리의 주소가 표출되는데, 이 값은 여러 번 다시 수행할 때에도 변함없이 같은 값이 나와야 합니다. 이 값을 리틀 엔디언으로 표현하여 환경변수에 저장하기 위해 아래의 명령어를 수행하고, 잘 저장되었는지 확인합니다.


쉘 획득!

이제 해당 취약 프로그램을 exploit 하여 쉘을 획득해봅시다.
 
먼저 버퍼의 처음 32 bytes 에는 쉘 코드가 위치합니다. 그리고 이어지는 80개의 0들은 40 bytes 의 00을 뜻합니다. 이는 버퍼의 나머지 32 bytes를 다 채워버리고, 남은 8 bytes 들은 저장된 RBP 레지스터의 위치를 덮어씌울 용도입니다. 그 뒤에 8 bytes의 값으로 복귀 주소를 덮을 때 쉘 코드가 위치한 버퍼의 시작 부분을 가리키도록 설정하면 됩니다.

엔터를 친 후 "ls" 명령어를 입력해보면, 현재 shell이 작동 중임을 확인할 수 있습니다. 다만 prompt 형식이 제대로 표출되지 않는 것처럼 느낄 수 있는데 이것은 표준 입력을 터미널 모드(/dev/tty)가 아닌 cat으로 출력하기 때문입니다.

(역자 주 : 원문이 작성되던 시절에 유행하던 command 기법으로 추정되나 최근에는 python 의 pwntools를 이용하여 아래와 같이 간단히 작성하는 방법이 이해에 빠를듯하여 별도로 추가하였으며, interactive 모드로 진입하면 쉘 프롬프트를 얻을 수 있다)


패치의 중요성(ASLR 우회하기)

여기에서는 흥미를 위해 ASLR을 우회하는 것을 잠시 살펴보겠습니다. 예전에는 /proc/pid/stat을 확인하면 모든 프로세스의 ESP 레지스터를 조회할 수 있었습니다. 그러나 이 방식의 취약성이 드러나게 되어, 오래전에 패치가 발표되어 현재는 대부분 업데이트되었습니다. 현재는 ptrace()할 때에 권한이 부여된 상태에서만 주어진 프로세스를 확인할 수 있습니다. 

그렇다면, 본 실습을 수월하게 진행하기 위해 아직 해당 패치가 적용되지 않은 구형 시스템이 존재한다고 가정해봅시다. ASLR이 왜 필요한지, 그리고 그 임의성(Randomness)과 기밀성의 중요성을 깨닫고, 왜 이 패치가 중요한지를 더욱 알게 될 것입니다. 

Tavis Ormandy와 Julien Tinnes 의 발표자료를 참고로 하여 인용하자면, 다음 명령을 통해 확인할 수 있습니다.

그러면 이제 ASLR을 끈 상태로의 victim 프로그램을 수행하여 봅시다.

그리고 새로운 터미널을 하나 더 띄워서, 아래와 같이 입력합니다

victim 프로그램은 사용자의 입력값을 기다리기 위해 대기하고 있는데, 이때의 stack pointer 가 출력됩니다. 그렇다면 해당 포인터로부터 name 버퍼의 위치까지의 길이를 계산할 수 있습니다.

계산된 값을 토대로 해당 시스템의 ASLR을 우회할 수 있습니다. 이제 다시 ASLR을 활성화한 상태로 victim 프로그램을 실행해봅시다.

다음 명령어를 통해 해당 프로세스의 상대적인 포인터 위치를 계산할 수 있으므로, 이 값을 추가한 오프셋을 확인해봅시다.

간단히 확인하기 위해서는 pipe 명령어 ( | )를 이용하는 것이 가장 좋습니다.

엔터를 입력하다 보면 반대쪽 터미널에서 쉘 프롬프트가 획득된 것을 볼 수 있습니다.


NX 우회하기

NX를 켜는 옵션을 적용하여 victim 프로그램을 재컴파일하십시오. 또는 아래의 명령어를 통해 실행시 NX 기능을 다시 활성화 할 수 있습니다.

이 상태로 지금까지의 공격 기법을 다시 시도해봅시다. 지금까지 공들였던 모든 노력이 수포로 돌아갔다는 것을 깨달을 수 있을 것입니다. 해당 프로그램에 삽입된 쉘 코드를 스택에 적재하고 실행하려는 순간 해당 영역은 실행할 수 없다는 이유로 종료되기 때문입니다.

이때 필요한 것이 Return-Oriented Programming입니다. 고전적인 BOF 취약점은 버퍼에 코드를 적재한 후 이것 자체를 실행하려고 하는 Exploit 방식입니다. 그러나 Return-Oriented Programming에서는 버퍼를 "실행하려는 코드 조각의 주소"를 버퍼에 올려두고, 스택 포인터에 일련의 간접 명령어 포인터들의 조합을 전달합니다.

이 코드 조각(code snippet)은 실행 가능한 메모리에서 추출할 수 있습니다. 대표적으로 libc의 파편들이 주로 이용됩니다. 이를 이용하면 NX 비트의 보호 기법을 무력화시킬 수 있습니다. 보다 자세히 설명드리자면, 

1. 일련의 주소가 시작되는 지점을 Stack Pointer로 지정한 상태에서 시작합니다. RET 명령어로부터 시작됩니다.

2. RET은 원래 subroutine에서 복귀하는 역할을 하는 명령어이지만 그 기능은 잠시 뒷전으로 해두고, 다른 하나의 역할에 집중해봅시다. RET은 SP에 의해 지정된 메모리 위치의 주소로 jump 하고서 SP를 8 만큼 증가시키는 습성이 있습니다. (64-bit 시스템의 경우)

3. 몇 가지 명령어를 실행하다가, RET을 만나게 된다면? 다시 step 2로 되돌아갑니다.

이런 의미에서, 여러 명령어가 나열되어 있고 마지막이 RET으로 끝나는 형태를 gadget이라고 부릅니다. 그리고 이를 이용하는 방식을 ROP(Return-Oriented Programming)이라 합니다.

Gadget 만들기

우리의 목적은 libc system() 함수를 호출하여 "/bin/sh"을 실행하는 것입니다. 이것은 임의의 값을 RDI에 할당하고 system libc 함수로 jump 하도록 하는 일련의 gadget을 수행함으로써 할 수 있습니다.

먼저, libc가 어디 있는지 살펴봅시다.

현재 본 시스템에는 32-bit와 64-bit용 libc가 모두 설치되어 있는 상황입니다. 하지만 여기에서는 64-bit에 대해서만 다루도록 하겠으므로, 목록에 표출된 것 중 두 번째에 위치한 것에 집중합시다.

자, 그렇다면 여기에서 사용할 수 있는 gadget을 찾아야겠지요?

결과를 직접 확인해보시면 알겠지만, 딱히 나쁜 선택은 아니지만 그렇다고 썩 좋은 결과는 아닙니다. 저희가 원하는 아주 적합한 형태의 코드 조각을 찾아야 하는데 여기에서는 지나치게 많은 결과물을 보여주고 있기 때문입니다. 

보다 나은 방법을 생각해봅시다. 사실 지금의 상황에서 정확히 필요한 핵심 내용은, 단지 아래의 명령어만 실행되면 된다는 것입니다.

"/bin/sh"에 대한 포인터가 Stack의 Top 부분에 있다면, 스택 포인터를 변경하기 전에 포인터를 RDI에 할당할 수 있게 됩니다. 이에 해당하는 기계어 코드는 0x5f와 0xc3으로 이루어진 2-bytes 단어일 것이며 이러한 동작이 libc의 어딘가에서 발생할 것으로 예상할 수 있습니다.

안타깝게도 이런 식으로 몇 가지 byte를 키워드로 하여 검색을 수행할 수 있는 적절한 관련 linux 도구를 알지 못합니다. 대부분의 도구들은 텍스트 파일을 기반으로 작동하고 있으며 입력 시에 작동하는 방식을 띄고 있습니다. (Rob Pike의 구조적 정규 표현식을 참고하세요.)

(역자 주 : 최근에는 gdb-peda 및 ropper 등의 도구에서 이 기능을 잘 지원하고 있다)

우선은 다소 복잡해 보이지만 아래의 명령어를 수행해봅시다.


이를 풀어서 설명하자면:

  1. 라이브러리를 덤프하여 각 라인별로 각각의 Hex 코드로 나타낸다.
  2. "c3"을 검색하여 일치하는 것을 발견하면 한 줄을 출력한다. 이때 해당 줄의 번호도 표시한다.
  3. 검색된 결과 중에서 "5f"도 포함한 것을 찾는다.
  4. 줄 번호가 1에서부터 시작하고 있는데, offset은 0부터 시작하므로 얻은 결과에서 1을 빼주어야 일치한다. 그리고 이렇게 얻어진 값을 16진수(hexadecimal)로 표기해야 한다. awk를 통해 처리하는 값을 숫자로 인식하도록(뺄셈 연산을 수행하므로) 하기 위해 5f를 grep 한 결과에서 숫자 뒤에 오는 문자는 모두 버리도록 한다. 

(ropper를 사용하여 pop rdi; ret;을 검색하면 아래와 같이 손쉽게 21102를 찾을 수 있다.)


이제 거의 다 왔습니다! 만약 복귀 주소(return address)를 아래와 같은 일련의 코드로 덮어 씌운다면 어떻게 될까요?

  • Null Padding
  • libc의 주소 + gadget(0x21102)
  • "/bin/sh"의 주소
  • libc의 system() 함수의 주소
  • /bin/sh

그런 다음 RET 명령이 수행된다면, 첫 번째 Gadget 덕분에 프로그램이 /bin/sh의 주소를 RDI에 할당하게 되고, 시스템 함수로 접근하게 됩니다.

얻어낸 결과

터미널에서 다음과 같이 실행합니다.

또 다른 터미널에서 아래와 같이 실행합니다.

즉, libc는 메모리에 로드될 때 0x7ffff7a0d000 의 지점에 적재된 것을 확인했습니다. 이것이 바로 필요한 첫 번째 준비물입니다. 그러므로 Gadget의 주소를 계산하면 0x7ffff7a0d000 + 0x21102 가 됩니다.

다음으로는 메모리 어딘가에 위치할 "/bin/sh"의 주소를 찾아야 합니다. 이전과 마찬가지로 버퍼의 시작 지점에 문자열의 위치를 기준으로 지정하면 됩니다. 우선 배열의 시작지점은 0x7fffffffdcb0 이었습니다. 그렇다면 name 배열의 크기(64) 및 그를 넘어선 RBP 레지스터(8)까지 Null 패딩되었고, 추가로 8의 크기만큼 3번(Gadget addr, /bin/sh addr, system() addr)을 더하게 되어 해당 위치는 0x7fffffffdcb0 + 64 + 8 + 24 이므로 0x7fffffffdd10 이 되겠습니다.

그리고 마지막 준비물은 system library function이 적재된 위치입니다.

그렇습니다! system function 은 0x7ffff7a0d000 + 0x45390 의 위치에 존재하는군요.


이상의 내용을 종합해봅시다.

다시 종합하자면 144개의 0이 있는데, 이는 곧 72 bytes의 "0"으로 표현됩니다. 이는 RBP 레지스터뿐만 아니라 "/bin/sh"까지 채우고 남는 부분을 덮기에 충분한 아주 정확한 길이입니다. 그러므로 그 바로 다음 주소는 스택의 최상위 부분을 덮어 씌우게 되는 것입니다.

엔터를 몇 번 누르면 쉘이 수행되어 있음을 확인할 수 있을 것입니다.

(역자 주 : 마찬가지로, 원문이 작성되던 시절에 유행하던 command 기법으로 추정되나 최근에는 python 의 pwntools를 이용하여 아래와 같이 간단히 작성하는 방법이 이해에 빠를듯하여 별도로 추가하였으며, interactive 모드로 진입하면 쉘 프롬프트를 얻을 수 있다)


결과 요약

본 포스팅을 통해 간단히 실험한 결과로는, ProPolice(GCC Stack-Smasing Protector (SSP))가 가장 나은 방어대책으로 보입니다. 이는 스택의 가장 높은 부분에 배열을 위치함으로써, 오버플로우 공격 시도를 어렵게 합니다. 또한, 배열의 끝에 Canary로 알려진 특수한 값을 삽입합니다. Return 명령이 수행될 때 Canary 의 값을 검사해서 만약 이 내용이 악의적으로 조작된 것으로 판명되면 프로그램의 실행을 강제로 중단합니다. ProPolice 기능을 종료하지 않는 한 더 이상 진행을 할 수가 없게 됩니다.

ASLR도 충분한 엔트로피의 임의성을 통해 기밀성을 확보할 수 있습니다만, 안타깝게도 시스템의 /proc 정보에 관련 정보에 대한 단서가 주어진다는 약점이 있었습니다. 일반적으로 공격자들은 숨겨져 있는 주소를 파악할 수 있도록 하는 프로그램을 개발하여 성공적으로 공격을 수행하곤 합니다.

마지막으로, 가장 무의미한 것으로 보이는 실행 보호(NX) 기법은 허점 투성이인 것으로 드러났습니다. 스택에서 코드를 실행할 수 없게 하더라도, 코드의 다른 부분으로 표적을 변경하는 방식으로 우회하여 실행할 수 있기 때문입니다. 보통의 경우처럼 libc를 이용하여 수행하는 법을 확인해보았지만, 그 외에도 일반적으로 사용 가능한 다양한 코드 조각들이 도처에 널려있습니다. 예를 들어, 전자투표 시스템을 해킹하기 위해 해당 프로그램의 코드 자체를 역이용하여 대규모의 실행 방지 기능을 우회한 사례가 있습니다.

흥미로운 점은, 각 방법에 드는 비용과 효율성은 반비례 관계에 있는 것처럼 보인다는 것입니다.

  • Executable space protection는 특별한 하드웨어(NX비트) 또는 고액의 소프트웨어 에뮬레이션이 필요합니다.
  • ASLR은 적용 시 많은 고려사항이 추가로 필요합니다. 프로그램뿐만 아니라 라이브러리들도 각각 무작위 한 주소로 배치되어야 합니다. 그리고 각각의 정보들은 적절히 기밀성이 유지되어야 합니다.
  • ProPolice는 컴파일러 패치가 필요합니다. 


보여주기식 보안? 에 대한 논평들

Executable space protection 이 이토록 깨지기 쉬운 장치라는 것을 깨달았다면, 이것을 굳이 왜 사용하고 있는지 의문을 가지는 분들이 계실지도 모르겠습니다.

물론 처음에는 누군가가 이것이 충분히 가치가 있다고 생각했기에 이토록 보급이 되어있는 것이겠지요. 그렇다면 이제는 이런 질문을 해볼 수도 있겠군요. "Executable space protection 은 슬슬 퇴역시켜야 하지 않을까요? 있으나 없으나 마찬가지 아닙니까?"

우리는 이미 존재하는 코드 조각을 한 땀 한 땀 조합하여 다소 지저분해 보이기는 해도 무난하게 목표점에 다다랐다는 것을 보았습니다. 단지 약간의 Gadget만 있다면, 그 어떤 작업이든 가능하다는 것을 말입니다. 게다가 이러한 라이브러리 Gadget을 자동으로 찾아주는 도구가 많이 개발되고 있을 뿐만 아니라, 그리고 철두철미할 줄만 알았던 non-executable stack을 농락하고 입력된 값을 일련의 주소 값들로 변환해주는 컴파일러 역시 존재합니다. 이러한 무기들을 장착한 공격자라면 executable space protection 같은 보호 기법은 그 존재 자체가 유명무실해지고 말 것입니다. 

그러므로, 필자는 Executable space protection은 정말 쓸데없는 것이므로 차라리 없는 것이 낫겠다고 주장합니다. 코드 영역과 데이터 영역을 분리하기 위해 고비용을 투자하고는 낮은 효율을 얻는 것이 얼마나 한심한 일입니까? 아래와 같은 Rob Pike의 의견을 봅시다. (Notes on Programming in C, 1989)

컴퓨터에 저장된 프로그램에 대해 튜링(Turing)과 폰 노이만(von Neumann)이 확립한 기본 이론의 정의에 따르면, 코드와 데이터는 사실상 같거나, 그렇지 않더라도 최소한 같아지게 만들 수 있다.

그렇지만 Executable space protection은 자체적으로 코드를 수정하려는 행위를 차단하는데, 이것은 JIT(just-in-time compiling)에 있어 상당히 중요한 기능이기는 하며, 이미 빼도 박도 못할 만큼 고착화된 구시대적인 calling conventions에 새로운 생명을 불어넣은 것임에는 나름의 성과가 있다고는 할 것입니다.

한편, 단순한 Calling convention과 thin pointers 에도 불구하고, 어떻게 C언어에 중첩된 함수를 추가할지에 대해 Thomas Breuel은 논문에서 또 다른 의견을 말하고 있습니다.

그러나 일부 아키텍처 및/또는 운영 체제는 프로그램이 실행되는 동시에 스스로 코드를 생성해내서 실행하는 것을 금지하고 있다. 우리는 이 제한이 근거 없는 독단이라고 간주하며, 이런 식의 하드웨어 또는 소프트웨어 설계는 부적절하다고 생각한다. FORTH, Lisp 또는 Smalltalk와 같은 프로그래밍 언어의 구현에 따르면 런타임에 코드를 빠르게 생성하거나 수정할 수 있는 기능은 상당한 이점을 얻을 수 있다고 생각한다.


결론

Return-oriented Programming에 처음으로 관심을 갖게 해 준 Hovav Shacham에게 감사를 표합니다. 그는 ROP 기법에 대한 포괄적인 발표 논문의 공저자입니다. 또한, ROP를 통해 전자식 투표 시스템에 어떻게 공격을 수행할 수 있었는지에 대한 구체적인 기술이 여기에 소개되어 있습니다.

본 포스팅에서는 잘 구성된 환경에서의 공격 사례에 초점을 맞추어 설명하였습니다. 여기에 나온 방어책들은 한편으로는 다른 종류의 공격에는 다시 쉽사리 무력화되기도 합니다. 예를 들어, ASLR은 Heap Sparying 공격에는 그다지 효과가 없습니다. 

Return-to-libc

사실 ROP는 Return-to-libc 공격의 한 종류인 것인데, Gadget 대신 라이브러리 함수를 호출하는 것이 Re2 libc입니다. 32 비트 Linux에서는 파라미터가 스택에 곧바로 전달되기 때문에 C 언어의 호출 규약만 파악해도 공격에 큰 단서를 찾을 수 있습니다. 스택을 조작하여 파라미터와 라이브러리 함수의 주소를 위치시키기만 하면 공격할 수 있습니다. RET 구문이 실행되기만 하면 이미 성공한 것입니다.

그러나 64비트 시스템으로 변경되면서 64비트에서 작성된 C언어 코드의 호출 규칙 역시 64비트 시스템과 유사하게 작동하게 되었습니다. 6개 이상의 파라미터가 추가되고(오른쪽에서 왼쪽 순서로 스택에 배치됨), 버퍼를 오버플로우 시켜도 제어할 수 있는 내용은 스택뿐이며 결정적인 레지스터들을 조작하기가 쉽지 않기에 Return-to-libc 공격이 32bit에 비해 다소 어려워졌었습니다.

그러나 ROP를 사용한다면, Gadget들을 통해 Register를 조작할 수 있게 되므로 64비트의 새로운 Calling convention 상에서도 공격을 할 수 있게 된 것입니다. 

GDB

멋들어진 고층 건물이 다 완공되고 나면, 건축업자들이 그동안 작업했던 조립식 발판들을 말끔히 치워버리기 마련입니다. 이처럼 저도 이 포스팅을 작성하면서 열심히 활용했던 수많은 GDB 작업들을 생략했을 뿐입니다. 설마 제가 단 한 번의 타이핑만으로 완벽하게 작동하는 exploit 코드를 뚝딱! 만들어냈다고 생각하시는 건 아니겠죠? 천만의 말씀입니다. 절대 그렇지 않습니다. 

솔직히 말하자면, 저는 디버깅 작업을 하기 위해 디버거를 사용해본 적이 거의 없습니다. 저는 프로그램을 어셈블리 레벨에서 분석하고, 출처를 알 수 없는 바이너리 파일을 조사하기 위해 디버거를 활용해왔습니다. 본 포스팅에서도 버퍼 오버플로우 취약점을 악용하기 위해 GDB를 적극 활용했음을 밝힙니다.

Linux Torvals(역주 : 리눅스의 창시자)도 다음과 같은 명언을 남겼습니다.

솔직히 말해서 나는 디버거를 그다지 좋아하지는 않는다. 지금까지도 그랬고, 아마 앞으로도 계속 그럴 예정이다. 나는 대부분의 시간을 gdb와 함께하지만, 나는 이것을 디버깅하기 위해 사용한 것이 아니라 프로그래밍 작업을 전폭적으로 지원하는 디스 어셈블 도구로써 사용하고 있다.

마찬가지로 Brian Kernighan 도 다음과 같이 말했습니다.

가장 효과적인 디버깅 도구는, 신중한 생각 끝에 삽입하기로 결정한 print 함수 구문들의 조합일 뿐이다. 그거면 충분하다.

이미 GDB의 사용법과 관련한 수많은 문서들이 존재하기 때문에 제가 덧대어 추가할 필요가 굳이 있을지는 모르겠습니다만, 그나마 유용하게 사용했던 몇 가지 명령어만 나열한 목록을 아래와 같이 제시합니다.

GDB는 ASLR이 활성화 유무에 따라 쉘이 주소를 선정하는 위치가 약간씩 달라지지만, 그래도 코드를 결정적으로 배치하는데 큰 도움이 됩니다.

요약본

이상의 내용을 쉘 스크립트의 형식으로 정리했습니다. (역주 : 원문이 작성된 시점의 Ubuntu 12.04 64bit에서 잘 작동되지만, ROP버전은 Ubuntu 16.04 64bit에서 작동되지 않습니다. 이와 관련해서 확인해보니 운영체제가 달라지면서 Stack Layout이 일부 변경되었기 때문이며 해당 issue는 https://github.com/finallyjustice/security/tree/master/rop/demo1 에 공개되어 있습니다.)

  • classic.sh : 고전 버퍼 오버 플로우 공격
  • rop.sh : ROP 버전 (Ubuntu 12.04)

(역주 : 저자의 허락을 받아, 파이썬 pwntools 을 이용하여 exploit 코드를 작성한 것을 함께 첨부하며, 우분투 16.04 에서 작동하는 것을 확인하였습니다.)

rop.py : It works on Ubuntu 14.04 and 16.04 on x86_64).


정보보안에 관심이 많은 대학원생, 소프트웨어 엔지니어/서버관리자

CPUU 님의 창작활동을 응원하고 싶으세요?

댓글

SNS 계정으로 간편하게 로그인하고 댓글을 남겨주세요.