✔️ Calling Convention
위에 나온 단어는 함수 호출 규약을 총칭한다.
함수를 호출할 때는 반환된 이후를 호출자(Caller)의 상태(Stack frame)
및 반환 주소(Return Address)를 저장해야 한다.
또한, 호출자는 피호출자(Callee)가 요구하는 인자를 전달해줘야 하며,
피호출자의 실행이 종료될 때는 반환 값을 전달받아야 한다.
이러한 함수 호출 규약을 적용하는 것은 일반적으로 컴파일러의 몫이고,
프로그래머가 고수준 언어로 코드를 작성하면 컴파일러가
호출 규약에 맞게 코드를 컴파일 하는 것이다.
이번 과정에서는 해당 함수 호출 규약의 종류와 cdecl, SYSV 호출 규약에 대해 공부한다.
함수 호출 규약에는 x86에서 cdecl, stdcall, fatscall, thiscall이 있으며,
x86-64 에서는 System V AMD64 ABI의 Calling Convention, MS ABI의 Calling Convention이 존재한다.
CPU의 아키텍처가 같아도 컴파일러가 다르면 호출 규약이 다르기 때문에
C언어를 컴파일할 때, 윈도우에서는 MSVC를 사용하고 리눅스에서는 gcc를 많이 사용한다.
x86 아키텍처는 레지스터의 수가 적어서 스택을 통해 인자를 전달하는데,
인자를 전달하기 위해 사용한 스택을 호출자가 정리하는 특징이 있다.
또한, 스택을 통해 인자를 전달할 때 마지막 인자부터 첫 번째 인자까지 거꾸로 스택에 push 한다.
컴파일로 생성된 파일을 살펴보면,
callee라는 피호출자는 스택을 정리하지 않고 리턴하는 것을 볼 수 있고
caller라는 호출자는 2와 1의 값을 스택에 저장하여 callee의 인자로 전달하고
스택에 2번의 푸시가 이루어져 8byte만큼 스택을 정리하기 위해서
add esp, 8 이라는 연산을 수행하는 것을 확인할 수 있다.
위에 나온것과 다르게 리눅스에서는 SYSV의 ABI를 기반으로 만들어졌기 때문에
SYSV에 해당하는 함수 호출 규약을 사용하는데, 특징은 다음과 같다.
① 6개의 인자를 RDI, RSI, RDX, RCX, R8, R9 순서대로 저장하여 전달한다.
더 많은 인자를 사용할 때는 스택을 추가로 이용한다.
② Caller에서 인자 전달에 사용된 스택을 정리한다.
③ 함수의 반환 값은 RAX로 전달한다.
해당 컴파일 한 파일을 다시 gcc로 실행파일로 만들고
caller 함수에 중단점을 설정하여 run 시켜보자.
현재는 caller의 시작지점에 와있는데 callee 부분을 호출하는
caller+48 지점으로 가기위해 b *caller+43 을 입력하고 c로 continue하여 이동해보자.
보면 다음과 같이 해당 call 호출 부분으로 오면 소스 코드에서 callee() 순서대로 집어넣은 값이
rdi, rsi, rdx, rcx, r8, r9, [rsp]에 설정되어 있는 것을 확인할 수 있다.
si로 다음 스텝으로 넘어가게 되면 callee에 호출한 값들을 확인할 수 있다.
여기에서 call이 실행되고 스택을 확인해보면 0x0000555555554682가 Return Address로 들어와있는데
해당 값의 주소는 callee가 반환될 때 해당 주소를 꺼내어 원래 실행 flow로 돌아가게 된다.
해당 주소를 확인해보면 바로 이전에 0x5555555545fa인 callee를 call 호출하는 것을 확인할 수 있다.
함수의 도입부를 Prologue, 프롤로그라고 하는데 다음 명령어를 통해 확인할 수 있다.
해당 함수의 프롤로그에서는 push rbp를 통해 반환될 주소를 미리 저장하는 것을 볼 수 있다.
rbp는 스택프레임의 가장 낮은 base pointer를 가리키기 때문에 해당 포인터를
Stack Frame Pointer(SFP)라고도 부른다.
si 명령어를 통해 push rbp를 진행하고나면 현재 rbp 값인 0x00007fffffffe2b0 값을 확인할 수 있다.
그리고 최근 스택의 4개 값을 확인해보면 2b0으로 끝나는 주소값이 들어간 것을 확인할 수 있다.
현재 실행되는 부분에서 rbp와 rsp 값이 같아지는 것을 볼 수 있고
해당 연산을 수행하고 나면,
두 포인터의 값이 같아진 것을 확인할 수 있다.
덧셈 연산을 모두 마친후에는 함수의 종결부인 Epilogue에 도달하게 되는데,
반환값을 rax에 옮기고 반환 바로 직전에 7개 인자의 합을 확인할 수 있다.
이런식으로 callee+87에 도착하여 rax값을 확인해보면
123456789123456816 값을 확인할 수 있다.
반환은 ret을 통해 스택 프레임과 반환 주소를 꺼내면서 이루어진다.
pop rbp로 스택 프레임을 꺼낼 수 있지만,
일반적으로 leave로 스택 프레임을 꺼낸다고 한다.
스택 프레임을 꺼낸 뒤에는 ret로 호출자로 복귀한다.
🔰 퀴즈
1) push 0x3
2) push 0x2
3) push 0x1
4) add esp, 0xc
5) mov edx, 0x3
6) mov esi, 0x2
7) mov edi, 0x1
✔️ Stack Buffer Overflow
스택 버퍼 오버플로우는 아직도 많은 소프트웨어에서 발견될 만큼 유명한 취약점이다.
스택 버퍼 오버플로우는 스택의 버퍼에서 발생하는 오버플로우이다.
여기서 사용되는 버퍼(Buffer)는 컴퓨터 과학에서 '데이터가 목적지로 이동되기 전에 보관되는
임시 저장소'라는 의미로 사용된다. 예를 들어 키보드에서 데이터가 입력되는 속도보다
데이터를 처리하는 속도가 느릴 때 수신 측과 송신 측 사이에 버퍼라는 임시 저장소를 두고
데이터를 간접적으로 전달하여 서로 완충 작용을 통해 정상적으로 통신이 되고 데이터가
전달되는 과정이 이루어지는데, 이러한 과정에서 스택에 있는 지역 변수를 스택 버퍼,
힙에 할당된 메모리 영역을 힙 버퍼라고 부른다.
이러한 버퍼의 공간이 넘치는 것을 버퍼 오버플로우라고 한다.
예를 들어 int로 선언한 지역 변수는 4바이트의 크기를 갖고,
10개의 원소를 갖는 char 배열은 10바이트의 크기를 갖는데,
10바이트 크기의 버퍼에 20바이트 크기의 데이터가 들어가면 오버플로우가 발생한다.
일반적으로 버퍼는 메모리상에서 연속 할당으로 구조되어 있어서
오버플로우가 발생하면 뒤에 있는 버퍼의 값들이 조작될 위험이 존재한다.
해당 코드를 살펴보면, check_auth 함수 인자로 argv[1] 문자열을 가져와서
검토하여 auth가 1로 비교 연산이면 Hello Admin을 출력하고 아니면 접근 거부!가 뜨는데,
여기서 strncpy 함수를 통해 temp 버퍼에 입력받은 패스워드를 복사하는데,
temp의 크기가 16 이상을 넘어가게 되면, 스택 버퍼 오버플로우가 발생할 수 있다.
auth는 temp 버퍼 뒤에 존재하기 때문에 temp 버퍼에 오버플로우를 발생시키면
auth의 값을 0이 아닌 임의의 값으로 바꿀 수 있고, 이 경우에 실제 인증과는 상관없이
main함수의 check_auth를 검사하는 조건문은 항상 참이 된다.
temp 버퍼의 크기만큼을 채웠을 때는 접근 거부가 되지만
해당 버퍼의 크기를 1만큼이라도 넘어가서 버퍼 오버플로우가 발생하게 되면
인증은 항상 참이되어 인증을 우회할 수 있게 되고 Hello Admin!을 출력하게 된다.
C언어에서 정상적인 문자열은 널바이트로 종결되고,
표준 문자열 출력 함수들은 널바이트를 문자열의 끝으로 인식한다.
만약에 어떠한 버퍼에 오버플로우가 발생하여 다른 버퍼와의 사이에 있는
널바이트를 모두 제거하면 해당 버퍼를 출력시켜 다른 버퍼의 데이터를 읽을 수 있다.
획득한 데이터는 여러 보호기법을 우회하는데 사용될 수 있고,
해당 데이터 자체가 중요한 정보일 수도 있다.
이러한 과정을 실습하기 위한 데이터 유출을 공부해보자.
이러한 코드가 존재한다고 할 때, name이라는 버퍼에 12바이트 크기만큼의 입력을 받는다.
secret 버퍼 사이에 barrier라는 4바이트의 널 배열이 존재하는데,
오버플로우를 이용하여 널 바이트를 모두 다른 값으로 변경하여 secret 값을 읽어보자.
현재는 name에 8바이트 크기만큼의 값이 들어가서 올바르게 출력한다.
8+4바이트 크기만큼 보다 작은 13바이트 크기까지 입력했을 때는 해당 합친 버퍼를 넘기지 않아서
버퍼 오버플로우가 발생하지 않는 것을 확인할 수 있다. (그래서 barrier?)
하지만 해당 크기를 모두 채워 널 바이트가 다른 값으로 변경되고 버퍼 오버플로우가 발생하면
secret 버퍼의 값을 출력하게 되어 해당 데이터가 중요한 값이거나 변경이 가능한 위험이 생긴다.
이번에는 실행 흐름 조작에 관한 실습을 진행해본다.
함수 호출 규약에서 함수를 호출할 때 반환 주소를 스택에 쌓고,
함수에서 반환될 때 이를 꺼내어 원래의 실행 흐름으로 돌아가는데
해당 return address를 조작하게 되면 어떻게 될까라는 생각에서 출발하게 된다.
실제로 함수의 반환 주소를 조작하여 프로세스의 실행 flow를 바꿀 수 있다.
해당 코드를 살펴보면 gets라는 함수를 통해 buf를 입력받고 있는데,
gets 함수는 해당 buf[] 타입의 문자열에 stdin으로 들어온 문자열을 입력받는다.
또한, gets 함수는 표준입력으로 들어온 문자열을 개행한 앞부분까지 잘라서
char 타입 문자열로 저장하고 문자열 맨 끝에 \0을 넣어서 문자열을 완성한다.
실제로 엔터키를 누르기 전까지 공백을 포함한 모든 문자열을 입력받는다.
해당 ret 주소가 0x41의 형태가 아니기 때문에 출력되지 않지만,
return address 자리에 0x41 값을 8번 집어넣게 되면,
Success!를 출력하는 것을 확인할 수 있다.
✔️ Return Address Overwrite
이번 실습을 위한 예제 코드로 scanf에 문자열을 입력받아 buf에 저장하게 된다.
특히, %s 포맷스트링을 사용하면 입력의 길이를 제한하지 않고 입력받기 때문에
오버플로우가 발생할 수 있다.
이 외에도 C 또는 C++의 표준 함수 중에서 버퍼를 다루면서
길이를 입력하지 않는 strcpy, strcat, sprintf 등이 존재한다.
이러한 위험을 방지하기 위해 버퍼의 크기를 같이 입력하는
strncpy, strncat, snprintf, fgets, memcpy 등을 사용하는 것이 좋다.
특히, 표준 함수는 널바이트를 만날 때 까지 연산을 진행하는데,
예를 들어 char *strcpy(char *dest, const char *src)은 src 배열의 첫 번째 인덱스부터
널 바이트가 저장된 인덱스까지 참조하여 dest에 값을 복사한다.
해당 src에 널 바이트가 없다면 계속해서 인덱스를 증가시키면서 널바이트를 찾기때문에
인덱스 값이 배열의 크기보다 커지는 Index Out-Of-Bound 현상,
즉 Out-Of-Bound(OOB) 취약점이 발생한다.
실제로 해당 예제 프로그램에서 입력 버퍼의 크기 이상의 값을 주면,
Segmentation fault라는 오류와 함께 프로그램의 비정상 종료를 알린다.
해당 core dumped 된 파일 core를 gdb 로 확인해보자.
디스어셈블된 코드와 스택을 확인할 수 있다.
rsp 스택 최상단에는 입력 값의 일부인 AAAAAAAA 를 발견할 수 있다.
실행가능한 메모리의 주소를 벗어났기 때문에 세그먼테이션 폴트가 발생했고,
이 값에 적절한 입력 값을 할당하게 되면 원하는 코드가 실행되도록 만들 수 있을 것이다.
진입점을 확인하고 gdb로 디버깅을 시작한 뒤 start 명령어를 작성한다.
함수를 진행하다 보면 구조를 확인할 수 있고, 메인 함수에서 printf와 scanf 받는
주소를 찾을 수 있다. 해당 부분이 scanf에 인자를 전달하는 부분이다.
0x400719 자리에 0xab 만큼을 더한 주소번지에 인자를 전달하는 %s를 확인할 수 있고,
해당 부분이 scanf("%s", (rbp-0x30)); 이라는 코드로 해석할 수 있다.
즉, 오버플로우를 발생시킬 버퍼는 rbp-0x30에 위치하고 그 다음 rbp+0x8 만큼에 반환 주소가 저장된다.
입력할 버퍼와 반환 주소 사이에 0x38 만큼의 거리가 있으므로 쓰레기 값으로 해당 공간을 채우고
실행하고자 하는 코드의 주소를 입력하면 조작할 수 있게 된다.
해당 프로그램에서 get_shell() 함수의 주소를 찾게되면 0x4006aa 라는 것을 확인할 수 있다.
해당 주소를 이용하여 페이로드를 구성하게 되면,
buf에는 0x30만큼 A로, SFP에는 0x8만큼 B로 나머지 return address에는 get_shell()을
구성하게 된다. 페이로드는 엔디언을 적용해서 전달해야하는데,
리틀엔디언은 일반적인 순서의 역순으로, 빅엔디언은 순차적으로 작성된다.
이걸 컴퓨터 과학에서는 리틀 엔디언은 데이터의 Most Significant Byte(MSB)가 가장 높은 주소에
저장되고 빅 엔디언에서는 데이터의 MSB가 가장 낮은 주소에 저장되게 된다.
해당 커리큘럼은 리틀 엔디언을 사용하는 인텔의 x86-64 아키텍처를 대상으로 하므로
get_shell() 주소인 0x4006aa는 \xaa\x06\x40\x00\x00\x00\x00\x00 으로 전달해야 한다.
살짝 느낌이 다르긴 하지만 나머지 값을 덮어 씌우면서
/bin/sh에 접근한 다음에 id가 정상적으로 출력되는 것을 확인할 수 있었다...
🔰 부록 퀴즈
1) sacnf("%39s");
위와 같은 방법으로 nc로 접속하여 flag를 획득할 수 있다.
✔️ basic_exploitation_000
문제의 설명은 다음과 같다.
이 문제는 서버에서 작동하고 있는 서비스(basic_exploitation_000)의 바이너리와 소스 코드가 주어집니다.
프로그램의 취약점을 찾고 익스플로잇해 셸을 획득한 후, “flag” 파일을 읽으세요.
“flag” 파일의 내용을 워게임 사이트에 인증하면 점수를 획득할 수 있습니다.
플래그의 형식은 DH{…} 입니다.
해당 문제 파일의 실행 파일 진입점을 확인해보면, 0x8048480 인것을 확인할 수 있다.
gdb로 분석하기 전에 main 주소를 찾아보자.
0x80485d9가 main 함수의 주소임을 확인했다.
해당 지점에 중단점을 걸고 들어와보았다.
해당 지점에서 initialize와 print를 호출하고 있었다.
그리고 ni로 넘어가면서 확인하면 scanf도 확인할 수 있다.
해당 프로그램의 생김새는 다음과 같이 생겼다.
위에서 gdb로 확인한 것과 같이 main 함수에서 initialize 함수와
printf, scanf를 통해 buf에 입력되는 값을 받는 것을 확인할 수 있다.
우선, buf의 크기는 80만큼의 크기를 할당하고 있고
scanf에서는 141 길이 만큼의 문자열을 buf에 입력받는다.
바로 여기 해당 자리에 %141s 만큼을 받는 것을 gdb에서도 확인할 수 있다.
해당 지점의 시작 주소는 0x8048460부터 시작하게 되는데,
ebp에 현재 0xffffd3d8을 가리키고 있고, eax에 들어가는 값이 buf의 시작 주소값이 된다.
그래서 두개를 연산하게 되면,
0x80 만큼의 크기가 나온다. hex 0x80은 dec으로 128을 가리킨다.
Stack Frame Pointer 4byte를 추가하여 132바이트가 되는데,
여기서 scanf 우회 셸 코드 예시를 사용하여 익스플로잇을 해보려고 한다.
(참고: https://hackhijack64.tistory.com/38)
\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
사용할 셸 코드는 26바이트, 132-26은 106이 되고 해당 크기만큼 쓰레기 값을 넣어준다.
해당 exploit 코드에서는 원격으로 우선 해당 문제 접속 정보와 연결한다.
recvuntil은 buf = () 값에서 ( 해당 뒤부터 명령어를 실행하게 된다.
recv는 int값 만큼 받아와서 10바이트를 16진수로 변환하게 된다.
payload는 위에서 scanf 우회 셸코드에 106바이트 크기만큼의 문자를 집어넣어주고,
buf 주소 값을 p32비트 리틀엔디안 방식으로 넣어주면 다음과 같이 성공한다.
(참고로 25바이트 셸 코드를 사용하지 못하는 데에는 scanf에서 0b를 읽지 못한다)
(p32, p64 패킹에 대한 내용은 다음을 참고했다. 참고: https://kangsecu.tistory.com/145)
다음과 같이 쉘을 획득하여 flag 파일이 있음을 확인하고,
cat flag로 플래그를 획득한다.
✔️ basic_exploitation_001
문제의 설명은 다음과 같다.
이 문제는 서버에서 작동하고 있는 서비스(basic_exploitation_001)의 바이너리와 소스 코드가 주어집니다.
프로그램의 취약점을 찾고 익스플로잇해 “flag” 파일을 읽으세요.
“flag” 파일의 내용을 워게임 사이트에 인증하면 점수를 획득할 수 있습니다.
플래그의 형식은 DH{…} 입니다.
문제의 코드를 살펴보면, read_flag라는 함수를 실행할 수 있는데
해당 내용을 위의 강의에서 살펴본 gets 함수로 buf를 받는 것을 확인할 수 있다.
일단 해당 flag를 출력하는 함수의 주소는 0x80485b9임을 확인할 수 있다.
진입점은 0x8048460이다. 본격적으로 gdb를 실행해보자.
진행하기전에 main 함수 주소가 0x80485cc 임을 확인하고 가자.
main 함수의 시작점에 중단점을 걸고 run 하게 되면 0x80485db가 gets에 buf를 받는 부분임을
확인할 수 있다. 해당 지점으로 한번 가보자.
해당 함수에 들어와서 확인해보면 ebp가 0xffffd3d8인것을 확인할 수 있고,
해당 지점의 시작점에 esp가 0xffffd358이 들어가는 것을 확인해볼 수 있다.
바로 직전 문제처럼 80만큼의 크기를 확보하는 것이다.
그런데 직전 문제와는 다르게 아까 강의에서 처럼 버퍼의 크기만큼을 채운
뒤의 부분 버퍼가 ret를 차지하고 있기 때문에 132바이트 크기만큼을 쓰레기값으로 채운다.
그리고 해당 지점에 OOB 취약점을 이용해서 ret하는 return address를
read_flag인 0x80485b9 주소를 넣어주면 되지 않을까라는 생각이 들었다.
위와 비슷한 익스플로잇 코드이지만, 이번에는 해당 함수 주소를 작성하고
p32비트 리틀엔디안 방식으로 패킹하여 sendline에 실어서 액티브해주었다.
정상적으로 공격에 성공하여 플래그를 획득할 수 있었다.
앞선 강의 공부를 진행하고 basic 001을 풀고나니까
002에서 찾아야 할 주소가 어디이고, 어떠한 함수를 찾아야하며
해당 함수는 얼마만큼의 버퍼를 메모리에 가지고 있는지
조금 더 수월하게 진행할 수 있던 것 같다.
화이팅 💪
'CTF > 시스템해킹' 카테고리의 다른 글
[Dreamhack] Bypass NX & ASLR (0) | 2022.10.25 |
---|---|
[Dreamhack] Stack Canary (0) | 2022.10.23 |
[Dreamhack] Shellcode + shell_basic (0) | 2022.10.22 |
[Dreamhack] Tool Installation (0) | 2022.10.21 |
[Dreamhack] Background - CS (0) | 2022.10.20 |