본문 바로가기

3. Pwnable (포너블)

[2024.10.05]PWN PWN 해 3주차 활동

3주차는 드림핵의 System Hacking 로드맵에서 Shellcode, Stack Buffer Overflow 강의를 공부했다.

https://dreamhack.io/lecture/roadmaps/2

 

System Hacking

시스템 해킹을 공부하기 위한 로드맵입니다.

dreamhack.io


익스플로잇: 해킹 분야에서 상대 시스템을 공격하는 것

 

셸코드: 익스플로잇과 관련된 9가지 공격 기법 중 하나로, 익스플로잇을 위해 제작된 어셈블리 코드 조각. 일반적으로 을 획득하기 위한 목적으로 사용한다. 셸코드는 어셈블리어로 구성되므로 공격을 수행할 대상 아키텍처와 운영체제, 셸코드의 목적에 따라 다르게 작성된다. 만약, 해커가 rip를 자신이 작성한 셸코드로 옮길 수 있으면 해커는 원하는 어셈블리 코드가 실행되게 할 수 있다.

 

orw 셸코드: 파일을 열고, 읽은 뒤 화면에 출력해주는 셸코드

 

 

1. int fd = open(/tmp/flag, O_RDONLY, NULL)

syscall rax arg0 (rdi) arg1 (rsi) arg2 (rdx)
open 0x02 const char *filename int flags umode_t mode

-      첫 번째로 해야 할 일은 “/tmp/flag”라는 문자열을 메모리에 위치시키는 것이다. 스택에는 8 바이트 단위로만 값을 push할 수 있으므로 0x67를 우선 push한 후, 0x616c662f706d742fpush한다. 그리고 rdi가 이를 가리키도록 rsprdi로 옮긴다.

-      O_RDONLY 0이므로, rsi 0으로 설정.

-      rax open syscall 값인 2로 설정

 

2. read(fd, buf, 0x30)

syscall rax arg0 (rdi) arg1 (rsi) arg2 (rdx)
read 0x00 unsigned int fd char *buf size_t count

-      syscall의 반환 값은 rax로 저장, rax rdi에 대입한다.

-      rsi: 파일에서 읽은 데이터를 저장할 주소.

-      rdx: 파일로부터 읽어낼 데이터의 길이인 0x30으로 설정.

-      read 시스템콜을 호출하기 위해서 rax 0으로 설정.

 

3. write(1, buf, 0x30)

syscall rax arg0 (rdi) arg1 (rsi) arg2 (rdx)
write 0x01 unsigned int fd const char *buf size_t count

-      출력은 stdout으로 할 것이므로, rdi 0x1로 설정한다.

-      rsirdxread에서 사용한 값을 그대로 사용한다.

write 시스템콜을 호출하기 위해서 rax 1로 설정.

 

-      C언어 형식의 의사코드를 셸코드로 변환한다.

 

-      파일 서술자(fd): 유닉스 계열의 운영체제에서 파일에 접근하는 소프트웨어에 제공하는 가상의 접근 제어자. 프로세스마다 고유의 서술자 테이블을 갖고 있으며, 그 안에 여러 파일 서술자를 저장한다. 서술자 각각은 번호로 구별되는데, 일반적으로 0번은 일반 입력(STDIN), 1번은 일반 출력(STDOUT), 2번은 일반 오류(STDERR)에 할당되어 있으며, 이들은 프로세스를 터미널과 연결해준다. 프로세스 생성 이후, 함수를 통해 어떤 파일과 프로세스를 연결하려고 하면, 기본으로 할당된 2번 이후의 번호를 새로운 fd에 차례로 할당해준다.

 

-      ELF: 리눅스의 실행 가능한 파일의 형식. 크게 헤더와 코드, 기타 데이터로 구성되어 있는데, 헤더에는 실행에 필요한 여러 정보가 적혀 있고, 코드에는 CPU가 이해할 수 있는 기계어 코드가 적혀있다.

 

-      스켈레톤 코드: 핵심 내용이 비어있는, 기본 구조만 갖춘 코드

 

 

-      초기화되지 않은 메모리 영역 사용: 다양한 함수들이 공유하는 메모리 자원인 스택이 있다. 각 함수가 자신들의 스택 프레임을 할당해 사용하고, 종료할 때 해제한다. 그런데 스택에서 해제라는 것은 사용한 영역을 0으로 초기화하는 것이 아니라, 단순히 rsp rbp를 호출한 함수의 것으로 이동시키는 것을 의미한다. , 어떤 함수를 해제한 후, 다른 함수가 스택 프레임을 그 위에 할당하면, 이전 스택 프레임의 데이터는 여전히 새로 할당한 스택 프레임에 존재하게 된다. 이것을 쓰레기 값이라고 표현할 수도 있다. 그렇기 때문에 안전한 프로그램을 작성하려면 스택이나 힙을 사용할 때 항상 적절한 초기화 과정을 거쳐야 한다.

 

 

-      알 수 없는 값이 함께 출력되는 경우, read 시스템 콜을 실행한 직후로 돌아가 원인을 분석해볼 수 있다. (아래의 예시 참고) 쓰레기 값은 어셈블리 코드의 주소나 어떤 메모리의 주소일 수도 있다. 이렇게 쓰레기 값에서 중요한 값을 유출해 내는 작업을 메모리 릭이라고 부른다.

Ex) 파일을 읽어 스택에 저장하고, 해당 스택의 영역을 조회해본다. 48바이트 중 앞의 41바이트만 내가 저장한 파일의 데이터고, 마지막 7바이트는 null 바이트로 존재한다. 알 수 없는 값이 출력되는 경우에는 null 바이트여야 하는 뒤의 7바이트에 쓰레기 값이 들어 있을 것이다. Write 시스템콜을 수행할 때, 쓰레기 값이 플래그와 함께 출력되는 것이다.

 

 

: 운영체제에 명령을 내리기 위해 사용되는 사용자의 인터페이스로, 운영체제의 핵심 기능을 하는 프로그램인 커널과 대비된다. 셸 획득을 통해 시스템을 제어할 수 있게 되므로, 통상적으로 셸 획득을 시스템 해킹의 성공으로 여긴다.

 

execve 셸코드: 임의의 프로그램을 실행하는 셸코드. 이것을 이용해 서버의 셸을 획득할 수 있으며, ‘셸코드라고 하면 이것을 의미하는 경우가 많다.

syscall rax arg0 (rdi) arg1 (rsi) arg2 (rdx)
execve 0x3b const char *filename const char *const *argv const char *const *envp

-      argv: 실행파일에 넘겨줄 인자

-      envp: 환경변수

 

execve("/bin/sh", null, null)을 실행

 

 

objdump 를 이용한 shellcode 추출

-      작성한 shellcode byte code(opcode)의 형태로 추출하는 방법으로, 리눅스 명령어를 입력하면 오브젝트 파일인 shellcode.o를 얻을 수 있다.

 

-      objcopy 명령어를 이용하여 shellcode.bin 파일을 얻고, xxd 명령어로 shellcode.bin 파일의 내용의 바이트 값들을 16진수 형태로 확인한다.

-      1 $ objcopy --dump-section .text=shellcode.bin shellcode.o

-      2 $ xxd shellcode.bin

-      xxd 출력 결과에서 바이트 값들을 추출하면 바이트 코드 형태의 셸코드를 만들 수 있다.

 

함수 호출 규약: 함수의 호출 및 반환에 대한 약속. 함수를 호출할 때는 반환된 이후를 위해 호출자의 상태 및 반환 주소를 저장해야 한다. 또한, 호출자는 피호출자가 요구하는 인자를 전달해줘야 하며, 피호출자의 실행이 종료될 때는 반환 값을 전달받아야 한다. 호출 규약은 프로그래머가 코드에 명시하지 않는다면, 컴파일러는 지원하는 호출 규약 중 CPU의 아키텍처에 적합한 것을 선택한다.

     Ex) x86 아키텍처(32bit)는 레지스터를 통해 피호출자의 인자를 전달하기에는 레지스터의 수가 적으므로, 스택으로 인자를 전달하는 규약을 사용한다. 인자를 전달하기 위해 사용한 스택을 호출자가 정리한다. 스택을 통해 인자를 전달할 때는, 마지막 인자부터 첫 번째 인자까지 거꾸로 스택에 push한다. 반면, x86-64 아키텍처는 레지스터가 많으므로 적은 수의 인자는 레지스터만 사용해서 인자를 전달하고, 인자가 너무 많을 때만 스택을 사용한다.

 

SYSV: 리눅스는 SYSTEM V(SYSV) Application Binary Interface(ABI)를 기반으로 만들어졌다. SYSV ABI ELF 포맷, 링킹 방법, 함수 호출 규약 등의 내용을 담고 있다.

-      SYSV에서 정의한 함수 호출 규약:

1.    6개의 인자를 RDI, RSI, RDX, RCX, R8, R9에 순서대로 저장하여 전달한다. 더 많은 인자를 사용해야 할 때는 스택을 추가로 이용한다.

2.    Caller(호출자)에서 인자 전달에 사용된 스택을 정리한다.

3.    함수의 반환 값은 RAX로 전달한다.

 

-      SYSV 상세 분석:

 

1.    인자 전달: gdbsysv를 로드한 후 중단점을 설정하여 caller함수까지 실행한다. caller+10부터 caller+37까지 6개의 인자를 각각의 레지스터에 설정한다. 소스 코드에서 callee(123456789123456789, 2, 3, 4, 5, 6, 7)로 함수를 호출했는데, 인자들이 순서대로 rdi, rsi, rdx, rcx, r8, r9 그리고 [rsp]에 설정되어 있는 것을 확인.

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

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

4.    스택 프레임 할당: mov rbp, rsp rbprsp가 같은 주소를 가리키게 한다.

5.    반환값 전달: 덧셈 연산을 모두 마치고, 함수의 종결부(Epilogue)에 도달하면, 반환값을 rax에 옮긴다.

6.    반환: 저장해뒀던 스택 프레임과 반환 주소를 꺼내면서 이루어진다. 일반적으로 leave로 스택 프레임을 꺼냄(스택 프레임을 만들지 않을 시 pop rbp 가능), 꺼낸 뒤에는, ret로 호출자로 복귀한다

 

x86호출 규약:

-      함수호출규약/사용 컴파일러/인자 전달 방식/스택 정리

1.    stdcall/MSVC/Stack/Callee

2.    cdecl/GCC, MSVC/Stack/Caller

3.    fastcall/MSVC/ECX, EDX/Callee

4.    thiscall/MSVC/ECX(인스턴스), Stack(인자)/Callee

5.     

x86-64 호출 규약:

-      함수호출규약/사용 컴파일러/인자 전달 방식/스택 정리

1.    MS ABI/MSVC/RCX, RDX, R8, R9/Caller

2.    System ABI/GCC/RDI, RSI, RDX, RCX, R8, R9, XMM0-7/Caller

 

 

스택 버퍼 오버플로우: 유명하고 역사가 오래된 취약점으로, 세계 최초의 웜인 모리스 웜도 스택 버퍼 오버플로우 공격을 통해 전파되었다.

-      스택 오버플로우와 스택 버퍼 오버플로우의 차이점:

스택 영역은 실행 중에 크기가 동적으로 확장될 수 있다. 하지만 한정된 크기의 메모리 안에서 스택이 무한히 확장될 수는 없고, 스택 오버플로우는 스택 영역이 너무 많이 확장돼서 발생하는 버그를 의미한다.

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

 

버퍼: 데이터가 목적지로 이동되기 전에 보관되는 임시 저장소의 역할을 한다. 데이터의 처리속도가 다른 수신 장치와 송신 장치 사이에서, 그 둘 사이에 오가는 데이터를 임시로 저장해두며 일종의 완충 작용을 해준다. 이렇게 하면 버퍼가 가득 찰 때까지는 유실되는 데이터 없이 통신할 수 있다.

-      버퍼링: 송신 측의 전송 속도가 느려서 수신 측의 버퍼가 채워질 때까지 대기하는 것을 의미한다.

 

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

 

버퍼 오버플로우 공격 예시:

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

2.    데이터 유출: C 언어의 정상적인 문자열은 null 바이트로 종결되며, 표준 문자열 출력 함수들은 null 바이트를 문자열의 끝으로 인식한다. 만약 어떤 버퍼에 오버플로우를 발생시켜서, 다른 버퍼와의 사이에 있는 null 바이트를 모두 제거하면, 해당 버퍼를 출력시켜 다른 버퍼의 데이터도 같이 출력시키는 것이 가능하다.

3.    실행 흐름 조작: 함수를 호출할 때 반환 주소를 스택에 쌓고, 함수에서 반환될 때 이것을 꺼내어 원래의 실행 흐름으로 돌아간다. 여기서 스택 버퍼 오버플로우로 함수의 반환 주소를 조작하면, 프로세스의 실행 흐름을 바꿀 수 있다.

 

 

-      취약점 분석: %s는 문자열을 입력받을 때 사용하는 것으로, 입력의 길이를 제한하지 않기 때문에 실수 또는 악의적으로 버퍼의 크기보다 큰 데이터를 입력하면 오버플로우가 발생할 수 있다.

 

-      스택 버퍼에 오버플로우를 발생시켜 반환주소를 덮으려면, 우선 해당 버퍼가 스택 프레임의 어디에 위치하는지 조사해야 한다. , 스택 프레임 구조를 파악해야 한다.

 

-      페이로드: 시스템 해킹에서 공격을 위해 프로그램에 전달하는 데이터

-      엔디언: 메모리에서 데이터가 정렬되는 방식. 주로 리틀 엔디언, 빅 엔디언이 사용된다. 리틀 엔디언에서는 데이터의 Most Significant Byte (MSB, 가장 왼쪽의 바이트)가 가장 높은 주소에 저장되고, 빅 엔디언에서는 데이터의 MSB가 가장 낮은 주소에 저장된다.

 

취약점 패치를 위해 자주 사용되는 문자열 입력 함수:

1.    gets(buf): 입력받는 길이에 제한이 없고, 버퍼의 null 종결을 보장하지 않는다.

2.    Scanf(“%s”, buf): 입력받는 길이에 제한이 없으며, 버퍼의 null 종결을 보장하지 않는다.

3.    scanf(“%[width]s”, buf): width만큼 입력받는데, width size(buf)-1 보다 크면 오버플로우가 발생할 수 있다. 또한, 버퍼의 null 종결을 보장하지 않는다.

4.    Fgets(buf, len, stream): len 만큼만 입력받는데, len size(buf)보다 크면 오버플로우가 발생할 수 있다. 또한, 버퍼의 null 종결을 보장한다. 하지만 데이터 유실에 주의해야 한다.