동계 디지털 국제 계절수업 프로그램을 통해 조지아 공대의 Information Security Lab (CS6265) 수업을 들어볼 수 있었다. 겨울방학 동한 공부한 컴퓨터 보안에 대해 정리해보고자 한다. 당연히 이 글을 읽는 누군가는 이 수업을 들을 수도 있기 때문에, 스포일러는 최대한 자제할 것이다. 무엇을 배웠는지 위주로 정리한다.

General rules

점수 배분은 Lab score 100%로 이루어졌다. Lab은 전적으로 CTF(Capture-the-flag) 문제들로 구성되어 있다. 어떻게든 특정 프로그램이 /proc/flag 파일을 읽어서 내용을 출력하도록 한 뒤, 그 flag 값을 사이트에 입력하면 점수가 주어진다(사실 보고서도 써야 인정되지만)

편법을 방지하기 위해서 여러 가지 보호가 적용되어 있는 듯 하다:

  • 모든 target binary들은 setuid bit이 설정되어 있다. 즉 랩 guest 계정으로 실행해도 특별한 권한이 부여되며 /proc/flag가 읽힐 때 커널이 UID를 검사하는 듯 하다. 그냥 guset user로 읽기를 시도하면 >>> NOT A VALID FLAG <<< 가 중간에 삽입된다.
  • setuid bit 때문에, LD_PRELOAD로 바이너리를 후킹할 수 없다. 2021년도에 이런 걸 막는 건 당연한 조치겠지만 이거 시도해 보겠다고 30분을 날려서 억울해서 기록해 둔다.
  • tracer(strace, ltrace, gdb)가 붙어 있으면 역시 플래그가 무효화된다. 커널에서 확인하는 듯 하다.

Lab 1: Introduction

이 Lab에서는 간단한 GDB의 사용법을 배워볼 수 있었다.

  • breakpoint 명령어로 어떤 코드가 실행될 때 프로그램을 일시정지할 수 있다. 중단점을 만나면 GDB가 프로그램 실행을 중지하고 디버깅 창을 띄운다.
  • 비슷하게, watchpoint 명령어로 어떤 메모리 주소가 변경될 때 프로그램을 일시정지할 수 있다.
  • {n}/{fmt}x {addr} 명령어로 해당 주소의 메모리를 볼 수 있다. (중괄호는 무시하고)
    • n: 출력할 개수
    • fmt: 서식(g: 64비트, w: 32비트? (word라고 표기되어 있다), x: 16진수, d: 10진수, i: 디스어셈블된 명령어)
    • addr: 시작 주소
  • disassemble 명령어로 디스어셈블된 결과를 확인할 수 있다.
  • step 명령어로 특정 횟수만큼 프로그램을 진행시킬 수 있다(명령어를 실행시킨다).
  • next 명령어는 step와 비슷하지만, 중간에 함수 실행이 있으면 리턴할 때까지 한 번 진행한 걸로만 계산한다.
  • finish 명령어는 지금 실행 중인 함수를 나갈 때까지 프로그램을 실행시킨다. (ex: step으로 잘못 들어왔을 때 유용)
  • run 명령어는 프로그램을 (재)실행시킨다. 뒤에 argument를 붙이거나 bash 문법과 비슷하게 input pipelining도 가능하다.

Lab 2: Shellcode

이 Lab에서는 쉘코드를 작성하는 법을 배운다. GCC 어셈블러 as를 사용해도 되고, 아니면 (앞으로 쭉 쓰게 될) 파이썬의 pwntools 모듈을 사용해도 편리하다. pwntools에서 이미 유용한 쉘코드들을 내장해 두었다. (사실 이건 Lab 3에서 다룬다)

from pwn import *

context.update(arch='i386', os='linux') # 타깃에 맞게 미리 설정

shellcode = shellcraft.sh()

받은 코드를 그대로 실행하면 랩이 너무 쉬워지니까, 문제마다 몇 가지 변형이 있었다:

  • 문자열을 다루는 함수들은 대부분 NULL byte까지만 작업하기 때문에 쉘코드에서 NULL byte를 모두 없애야 함
  • execve syscall을 막아서 /bin/sh를 그대로 실행하지 못하게 함 (쉘을 안 띄우고 수동으로 flag를 읽어야 한다)
  • 32비트와 64비트에서 모두 동작하는 쉘코드를 요구 (32비트와 64비트 리눅스의 ABI가 다르기 때문에 실행 시점에서 구분을 해야 하는데, 놀랍게도 32비트와 64비트에서 동작이 다른 명령어가 있었다)
  • ASCII 문자열로만 된 입력(쉘코드) 요구(사용 가능한 명령어가 극히 제한된다. 결국 못 풀었음…)

Lab 3: Buffer overflow

버퍼 오버플로우는 말 그대로 쓸 수 있는 공간(이 랩에서는 스택 내의 할당된 공간)을 넘어 그 뒤까지 덮어써버리는 보안 허점을 말한다. 여기서 제공되는 프로그램들은 말 그대로 ‘순진하기’ 때문에, 길이 검사가 생략되어 있다거나 잘못된 방식으로 진행된다. 이를 통해 허용된 범위를 넘어 다른 변수나 activation record를 조작할 수 있으며(함수의 return address, 저장된 frame pointer 등) 이는 control hijacking으로 이어진다.

랩 문제들의 주된 풀이 패턴은 어딘가에 쉘코드를 넣어 놓고 BOF를 사용하여 거기로 점프하는 것이다. 쉘코드를 넣을 만한 곳은

  • Overflow되는 메모리 공간 그 자체
  • 프로그램 실행 시 전달되는 argument
  • 프로그램 실행 시 전달되는 environment variable

등이 있겠다.

Return address를 조작하기 어려워도 저장된 frame pointer를 조작할 수 있다면 ‘다음 return address’를 가리키는 값이 변조될 수 있기 때문에 control hijacking이 가능하다.

이러한 허점을 대응하기 위해 다음과 같은 장치들이 도입되었다. 모두 이어지는 랩에서 다루게 된다.

  • Stack Canary, Stack shield와 같이 스택 메모리의 변조를 탐지하거나 방해하는 방법 (Lab 4)
  • NX Bit와 같이 허용된 메모리 내의 내용만 실행 가능하도록 설정하는 방법 (Lab 5)

Lab 4: Stack protection

Return address 변조를 방지하기 위해, 각 스택 프레임마다 지역 변수activation record 사이에 특수한 값을 집어넣고 return 직전에 이 값이 변조되었는지 확인할 수 있다. 대부분의 BOF는 strcpy()와 같이 순차적으로 메모리를 덮어쓰는 함수를 잘못 사용해서 발생하기 때문에 return address가 변조되었다면 그 앞의 값들도 바뀌었을 것이기 때문이다. 이 특수한 값을 광부들이 가스 중독을 막기 위해 데려갔던 카나리아에 비유해 stack canary라 부른다. 카나리를 결정하는 방법에는 여러 가지가 있다.

  • 문자열 관련 함수들이 만나면 중지하는 값들인 NULL, CR(0x0d), LF(0x0a), EOF(0xff)로 이루어진 상수값(terminator)
    • 만약 문자열 관련 함수를 사용하는 게 아니라면 그냥 이 값을 그대로 똑같이 덮어씌워 우회할 수 있다.
  • 프로그램 시작 시 랜덤하게 결정되는 값(GCC에 내장된 SSP—Stack Smashing Protector—가 이 방식을 사용하는 듯 하다)
    • 프로그램을 실행할 때마다 바뀌므로 예측하기 어렵다. 그러나 BOF 전에 스택의 내용이 유출되거나, 서버 프로그램과 같이 계속 실행되면서 fork를 하는 프로그램이라면 fork된 프로세스는 부모와 같은 카나리를 계속 갖는다는 점을 이용해 브루트포싱을 시도할 수 있다.

또한, 프로그램에 따라 임의 위치에 임의 데이터를 쓰는 것이 가능하게 하는 취약점이 존재할 수도 있는데, 이러한 취약점이 존재하면 Stack Canary 영역을 피해 return address 등만 조작할 수 있기 때문에 우회가 가능하다.

StackShield(Shadow stack)는 이와 다르게, 함수에 진입할 때마다 return address를 독립된 스택에 복사해 두었다가 종료 시점에 다시 복사해 온다. 이를 통해 return address가 조작되었더라도 원상태로 복구가 가능하다. Stack canary보다는 강력해 보이지만 더 비용이 높다고 할 수 있다.

Lab 5: DEP(Data execution prevention) and return-to-libc

NX bit, W^X(Write xor Execute)라고도 불린다. 이전까지는 쉘코드를 메모리 어딘가에 올려놓고 그곳으로 점프해 실행할 수 있었지만, 이러한 공격 방법을 차단하기 위해 프로그램이 쓸 수 있는 공간은 명령어 실행을 하지 못하도록 막아두는 기법이 적용되었다. 즉 우리는 return address를 조작하되 우리가 제공한 명령어로는 점프할 수 없다. 대신, (실행이 허용된) 존재하는 코드들을 향해 점프하는 방법이 유효하다.

예를 들어 32비트 리눅스에서, 프로그램 내의 어떤 함수가 call system@plt 명령어를 가지고 있다면 여기로 점프하여 system 함수를 실행할 수 있을 것이다. 변조된 return address 뒤의 값에 system()에 전달될 명령어 문자열을 담아두면 이것이 argument로 전달되고, 이 자체는 Execution이 허용되지 않아도 가능하므로 DEP이 소용 없어지게 된다.

조금 더 확장하여 타깃이 libc를 로드해 뒀고 그 주소를 알아올 수 있다면(ex: libc 버전을 정확히 알고 어떤 libc 함수의 GOT를 읽어올 수 있음), 우리는 간단한 오프셋 계산만으로 모든 libc 함수를 실행할 수 있다.

참고로 이 랩부터 ASLR이 적용되어 스택 주소나 libc 주소를 예측하기 어려워진다. (32비트 리눅스의 경우에는 랜덤화 가능한 비트가 적어서 브루트포싱이 가능하긴 하다)

추가적으로, 랩 title에는 나타나있지 않지만 format string bug도 배운다. 요즘은 잘 알려져서 대책이 다 세워진 것 같지만 사용이 가능하다면 임의 주소에 임의 내용 작성이 가능한 아주 강력한 공격 벡터가 된다.

Lab 6: ROP(Return-oriented programming)

return-to-libc의 확장이라고 할 수 있다. <어떤 명령어>; <다른 명령어>; ret(혹은 jmp); 들로 구성된 명령어 조각들(이들 각가을 gadget이라 한다)을 조합하여, 우리가 원하는 코드를 직접 구성할 수 있다. Gadget들은 타깃 바이너리에서 찾을 수도 있고, libc address를 알고 있다면 libc 전체에서 가져올 수도 있다(이 경우에는 사실상 가젯이 무한하므로 자유도가 매우 높아진다)

  • 가장 간단한 ROP 구성 전략은 argument를 스택에 적재하고 원하는 함수로 jump하는 것이다. 가젯이 존제한다면 직접 syscall을 수행할 수도 있을 것이다.
  • 32비트 리눅스 ABI에서는 argument들이 스택에 push되기 때문에 어떤 함수의 호출이 끝난 후 다음 return address 자리에 argument가 존재하게 된다. 이를 모두 제거해야 하므로 함수의 호출이 끝난 다음에 argument의 수만큼 pop을 수행하는 가젯을 찾아야 한다.
  • 반면, 64비트 리눅스 ABI에서는 argument들을 레지스터에 적재해야 하기 때문에 pop rdi; ret 과 같은 가젯들을 찾아야 한다.

필요한 가젯들은 ropper와 같은 툴을 사용하면 자동으로 찾을 수 있고, 아니면 pwntoolROP 클래스를 사용하여 자동으로 구성할 수도 있다.

Lab 7: Remote attacks

사실상 Lab 6까지의 내용에서 추가된 것은 거의 없지만, 모든 공격을 원격으로 수행해야 한다. 즉 GDB를 타깃에 직접 붙이거나 할 수 없고 nc(netcat) 등으로 통신하여 payload를 보내는 것만 가능하다. (일부 문제는 바이너리를 제공해서 분석하거나 로컬에서 돌려볼 수는 있었다)

여기서 그 유명한 Shellshock 취약점을 사용하여 CGI 웹 서버를 털어보는 체험(?)을 할 수 있었다.

Lab 8: Miscellaneous bugs

앞에서 다루지 않았던 자잘한 버그들을 다룬다.

  • Integer overflow: 오버플로를 고려하지 않은 코드들의 체크를 우회하는 법을 시연한다. 간단한 예로 x*x >= 0이 항상 참인지 생각해보자.
  • Race condition: 동기화(synchronization)가 고려되지 않은 두 코드의 충돌을 의미한다.
    • TOCTOU(Time-of-check to Time-of-use): 파일의 유효성을 검사하는 시점과 실제로 사용하는 시점이 다를 때 유효성 검사를 우회할 수 있다. 예를 들어 어떤 함수가 command.txt를 읽어서 이를 그대로 실행한다고 하자. 최소한의 보호를 위해서 이 파일이 특정 해시값을 가지지 않으면 실행을 거부한다고 하면, 검사 직전에는 유효한 해시값을 가지는 어떤 내용으로 채워뒀다가 검사를 통과한 직후 원하는 내용으로 바꿔치기할 수 있을 것이다. 주어진 시간이 아주 짧겠지만 브루트포싱과 같은 방법을 이용하면 유의미한 확률로 성공이 가능하다. 이를 막으려면 검사와 사용이 원자적으로(atomically, 한 번에) 이루어져야 하겠지만 일반적으로 이러한 종류의 취약점을 봉쇄하는 것은 어렵다고 한다.
  • Bad casting
  • Sandbox escaping

Lab 9: Heap overflow

앞선 랩들은 스택 위주로 공격이 이뤄졌지만, 이 랩에서는 힙을 공격한다. 메모리 할당의 효율성을 위해 malloc 라이브러리들이 (이중 혹은 단일) 링크드 리스트를 사용하기 때문에 이를 악용하여 임의 주소에 임의 쓰기와 같은 취약점을 만들어낼 수 있다고 한다. 자세한 것은 운영체제 수업을 아직 안 들어서 정확히 모르므로 생략.

총평

이걸 왜 쓰냐면... 국제계절수업이라 수강편람에 포함되지 않기 때문에 수강평을 쓸 수 있는 곳이 없다!!

절대 만만한 수업은 아니었지만(한 주에 10~12개씩 CTF 문제가 나온다… 거기다 나는 선수과목인 시스템프로그래밍과 운영체제를 안 들은 상태라 이것도 같이 공부해야 했다), 배워가는 게 정말 많았고(컴퓨터 보안 9주 과정 캠프를 다녀온 기분이다), 특히 유명한 취약점들(Heartbleed, Shellshock, sudo format string bug 등)의 원리를 알고 시연된 타깃들을 직접 exploit 하는 과정에서 많은 흥미를 유발할 수 있었다고 생각한다. 다음 계절에도 이 과목이 열리는지는 잘 모르겠지만 선수 과목을 들은 상태라면 한번 수강해 보는 것을 추천한다.