함수 호출 규약이란?
함수의 호출 및 반환에 대한 약속을 말한다.
함수를 호출할 땐 피호출자가 요 구하는 인자를 전달해야 하며 실행이 끝난 후에는 반환값을 받아야 한다.
또한 반환된 이후를 위해 호출자의 상태 정보(Stack frame)와 반환 주소(Return Address)를 저장해야 한다.
이에 대한 규약을 함수 호출 규약이라고 한다.
함수 호출 규약의 종류
컴파일러는 CPU 아키텍처에 적합한 호출 규약을 선택한다.
x86(32bit) 아키텍처의 경우 레지스터로 피호출자에게 인자를 전달하기에는 레지스터의 수가 적기 때문에 스택으로 인자를 전달하는 규약을 사용한다. 반면 x86-64 아키텍처의 경우 레지스터의 수가 많으므로 인자 전달은 레지스터를 통해 이루어지며 인자의 수가 많을 경우에만 스택을 추가적으로 사용한다.
→ 드림핵에서는 cdecl과 SYSTEM V(SYSV) 호출 규약에 대해서만 살펴본다.
x86 함수 호출 규약
함수 호출 규약 | 사용 컴파일러 | 인자 전달 방식 | 스택 정리 | 적용 |
stdcall | MSVC | Stack | Callee | WINAPI |
cdecl | GCC, MSVC | Stack | Caller | 일반 함수 |
fastcall | MSVC | ECX, EDX | Callee | 최적화된 함수 |
thiscall | MSVC | ECX(인스턴스), Stack(인자) | Callee | 클래스의 함수 |
x86-64 함수 호출 규약
함수 호출 규약 | 사용 컴파일러 | 인자 전달 방식 | 스택 정리 | 적용 |
MS ABI | MSVC | RCX, RDX, R8, R9 | Caller | 일반 함수, Windows Syscall |
System ABI | GCC | RDI, RSI, RDX, RCX, R8, R9, XMM0-7 | Caller | 일반 함수 |
x86 호출 규약: cdecl
x86 아키텍처는 레지스터의 수가 적으므로 스택을 통해 인자를 전달한다.
인자를 전달할 때는 마지막 인자부터 첫 번재 인자까지 거꾸로 스택에 push한다.
인자 전달에 사용한 스택을 호출자가 정리하는 특징이 있다.
예제
// Name: cdecl.c
// Compile: gcc -fno-asynchronous-unwind-tables -nostdlib -masm=intel \
// -fomit-frame-pointer -S cdecl.c -w -m32 -fno-pic -O0
void __attribute__((cdecl)) callee(int a1, int a2){ // cdecl로 호출
}
void caller(){
callee(1, 2);
}
💡 컴파일의 정확한 의미
컴파일(Compilation)의 정확한 의미는 어떤 언어로 작성된 소스 코드(Source Code)를, 다른 언어의 목적 코드(Object Code)로 번역하는 것이다. 소스 코드를 어셈블리어로, 또는 소스 코드를 기계어로 번역하는 행위 모두 컴파일의 범주에 포함된다.
x86-64 호출 규약: SYSV
리눅스는 SYSV Application Binary Interface(ABI)를 기반으로 만들어졌다.
SYSV ABI는 ELF 포맷, 링킹 방법, 함수 호출 규약 등의 내용을 담고 있다.
SYSV에서 정의한 함수 호출 규약은 다음과 같은 특징이 있다.
- 6개의 인자를 RDI, RSI, RDX, RCX, R8, R9에 순서대로 저장하여 전달한다. 더 많은 인자를 사용해야 할 땐 스택을 추가로 이용한다.
- Caller에서 인자 전달에 사용된 스택을 정리한다.
- 함수의 반환 값은 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
ull 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 호출 규약을 정리했다.
컴파일 옵션: gcc -fno-asynchronous-unwind-tables -masm=intel -fno-omit-frame-pointer -o sysv sysv.c -fno-pic -O0
인자 전달
caller함수에 중단점을 걸고 실행한 후 DISASM을 보면 caller+6부터 caller+33까지 6개의 인자들이 레지스터에 들어가고,
caller+4에서는 마지막 인자가 스택에 들어가는 것을 볼 수 있다.
callee가 호출되기 전까지 실행하고 REGISTER를 살펴보면 아래와 같다.
(b *caller+43)
RDI, RSI, RDX, RCX, R8, R9에 1번째 인자부터 6번째 인자까지 설정된 것을 확인할 수 있다.
다음은 스택을 살펴보자.
마지막 7번째 인자가 스택에 들어간 것을 확인했다.
반환 주소 저장
si 명령어로 한 단계 더 실행시켜 call을 실행하고 스택을 확인해보면 새로운 값이 push된 것을 알 수 있다.
0x0000555555554682이 바로 반환 주소이고, 해당 위치를 확인해보면 callee호출 다음 명령어의 주소인 것을 알 수 있다.
callee에서 반환됐을 때 이 주소값을 꺼내서 원래 실행 흐름으로 돌아갈 수 있다.
스택 프레임 저장
x/5i $rip 명령어로 callee 함수의 도입부(Prologue)를 보면 호출자의 rbp를 저장하고 있다.
rbp는 스택 프레임의 가장 낮은 주소를 가리키는 포인터로 Stack Frame Pointer(SFP)라고도 한다.
callee에서 반환될 때 SFP를 꺼내어 caller의 스택 프레임으로 돌아갈 수 있다.
si 명령어로 한 단계 실행하고 x/4gx $rsi 명령어로 스택을 보면 rbp값인 0x00007fffffffdfe0가 저장된 것을 확인할 수 있다.
스택 프레임 할당
si 명령어를 실행한 후 rbp와 rsp 레지스터 값을 확인하면 둘이 같은 것을 알 수 있다.
이는 스택 프레임 할당을 위한 것으로 mov rbp, rsp 명령어를 실행해서 rbp와 rsp가 같은 주소를 가리키게 하고,
rsp의 값을 빼게 되면 rbp와 rsp사이의 공간 만큼 스택 프레임을 할당하게 된다.
(다만 callee는 지역변수를 사용하지 않으므로 스택 프레임을 생성하지 않는다)
💡 callee에서 ret이라는 지역변수를 선언했는데?
callee에서 ret를 선언하기는 했지만, 반환값을 저장하는 용도 외에는 사용되지 않고 있다. gcc는 이런 변수에 대해 스택을 할당하지 않고, rax를 직접 사용한다.
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;
}
현재까지 진행 상황
반환값 전달
b *callee+87 명령어로 새로운 중단점 걸고 마저 실행하면 callee함수의 종결부(Epilogue)에서 멈추게 된다.
함수의 종결부(Epilogue)에 도달하게 되면 반환값을 rax 레지스터로 옮긴다.
따라서 반환 직전에 rax 레지스터 값을 출력하면 반환값을 미리 볼 수 있다.
7개의 인자의 합 = 123456789123456816
반환
si로 다음 단계를 실행하고 rip를 확인하면 caller에서 마지막으로 실행되었던 부분으로 돌아온 것을 확인할 수 있다.
$rbp와 $rip를 확인하면 스택에 저장해뒀던 SFP와 ret의 값으로 설정된 것을 알 수 있다.
반환은 저장해뒀던 스택 프레임과 반환 주소를 꺼내면서 이루어진다.
즉 앞서 저장했던 SFP와 ret을 꺼내어 각각 rbp와 rip에 설정해주면서 일어난다.
이번 예제에서는 callee가 스택 프레임을 만들지 않아 pop rbp로 스택 프레임을 꺼낼 수 있지만,
보통은 leave로 스택 프레임을 꺼낸다.
스택 프레임을 꺼낸 뒤에는 ret으로 caller(호출자)로 복귀한다.
Quiz
1. push 0x3
→ 32비트 아키텍쳐로 컴파일 했기 때문에 인자는 레지스터가 아니라 스택에 저장된다. 또한 인자는 거꾸로 전달되기 때문에 0x3이 스택에 push된다.
2. push 0x2
3. push 0x1
→ 1번 문제와 동일한 이유 각 값들이 스택에 push된다.
4. add esp, 0xc
→ sum함수 반환이 끝나고 인자가 필요 없어졌으므로 인자 3개의 크기만큼 esp를 이동해야 한다. 각 인자는 int형으로 4바이트씩 차지하고 있으니 총 12바이트만큼 이동해야 하므로 0xc를 더해주면 된다.
5. mov edx, 0x3
→ 64비트 아키텍쳐에서는 인자를 레지스터에 전달한다. 1번째 인자부터 6번째 인자는 순서대로 RDI, RSI, RDX, RCX, R8, R9 레지스터에 전달된다. 인자는 거꾸로 전달되므로 3번째 인자가 전달되어야 하는 레지스터를 찾으면 된다.
6. mov esi, 0x2
7. mov edi, 0x1
→ 5번 문제와 동일한 이유로 각 인자값이 전달되는 레지스터를 찾으면 된다.
'Study > System Hacking' 카테고리의 다른 글
[dreamhack] Exploit Tech: Return Address Overwrite (0) | 2022.05.06 |
---|---|
[dreamhack] Memory Corruption: Stack Buffer Overflow (0) | 2022.05.05 |
[dreamhack] shell_basic (1) | 2022.04.04 |
[dreamhack] Exploit Tech: Shellcode (0) | 2022.04.02 |
[dreamhack] Tool: pwntools (0) | 2022.03.27 |