혜랑's STORY

[System Hacking STAGE 4] Exploit Tech: Return Address Overwrite 본문

무지성 공부방/Dreamhack SystemHacking

[System Hacking STAGE 4] Exploit Tech: Return Address Overwrite

hyerang0125 2022. 1. 26. 16:19
스택 버퍼 오버플로우 취약점 발생 코드
// 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 함수의 반환 주소를 덮을 수 있다.

트리거(trigger)

발견한 취약점을 확인하는 행위를 취약점을 발현시킨다는 의미에서 트리거라고도 표현한다.

예제를 컴파일 하고, 실행해보자.

"A"를 다섯 개 입력하니 프로그램이 정상적으로 종료 되었다. 이번에는 취약점을 트리거하기 위해 "A"를 많이 입력해 보았다.

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

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

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

$ ulinit -c unlinited

위 커맨드로 그 제한을 해제하고, 다시 오류를 발생시키면 코어파일을 얻을 수 있다.

코어 파일 분석

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

뭔 재귀함수 제한 어쩌구 오류때문에 다른 방법으로 함

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

익스플로잇

스택 프레임 구조 파악

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

먼저 scanf 함수의 위치를 파악하기 위해 disassemble 명령어를 사용하고, scanf 함수 근처 주소를 nearpc 명령어를 사용하여 지정된 주소 근처를 disassemble 해보았다.

 "%s"의 위치가 0x4007c4라는 것을 확인했다. 이를 의사코드로 표현하면 다음과 같다.

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

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

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

get_shell() 주소 확인

위 예제는 셸을 실행해주는 get_shell() 함수가 있으므로, 이 함수의 주소로 main 함수의 반환 주소를 덮어서 셸을 획득할 수 있다. 주소를 찾기 위해 gdb를 사용한다.

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

페이로드 구성

익스플로잇에 사용할 페이로드(payload)를 구성해야 한다. 시스템 해킹에서 페이로드는 공격을 위해 프로그램에 전달하는 데이터를 의미한다.

페이로드

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

엔디언 적용

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

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

0x12345678 예시

익스플로잇을 작성할 떄는 대상 시스템의 엔디언을 고려해야 한다. 인텔 x86-64아키텍처는 리틀 엔디언을 사용하기 때문에 get_shell()의 주소인 0x4006aa은 "\xaa\x06\x40\x00\x00\x00\x00\x00"로 전달돼야 한다.

익스플로잇

파이썬으로 출력한 페이로드를 rao의 입력으로 전달한다.

성공적으로 셸을 획득했다.

취약점 패치