함수 호출 규약은 함수를 호출 했을 때와 함수를 실행하고 반환할 때의 약속이다.
Main함수에서 sum함수를 호출 할 때, sum으로 인자값을 전달해주는데 어떤 방법이 사용되고, sum함수의 호출이 끝났을 때 sum함수에서 return한 값을 어떻게 main 함수로 가져오는지 등을 정해놓은 것이 함수 호출 규약이다.
이런 함수 호출 규약은 컴파일러가 알아서 처리해주기 때문에 프로그래머는 신경쓰지 않아도 되지만, 어셈블리 코드를 작성하거나 읽게 된다면 함수 호출 규약을 알아야 할 수 있다.
컴파일러는 CPU의 아키텍처에 따라 함수 호출 규약을 선택 한다.
x86 아키텍처는 레지스터의 수가 적기 때문에 스택으로 인자를 전달하는 규약을 사용하는데,
x86-64 아키텍쳐에서는 레지스터의 수가 많고 크기 때문에 적은 수의 인자는 레지스터만을 사용하고 인자가 많을 때 스택을 사용한다.
(x64는 R8 ~ R15 레지스터도 존재) 하나의 레지스터는 아래 그림처럼 크기에 따라 적절히 쪼개 사용 가능
RAX(64 bits) - EAX(32 bits, Extended AX) - AX(16 bits) - AL(8 bits) - AH(8 bits)
CPU의 아키텍쳐가 같아도 컴파일러에 따라서 적용되는 호출 규약이 다를 수도 있다.
OS별로 컴파일러를 윈도우에서는 MSVC를 주로 사용하고, 리눅스에서는 GCC를 주로 사용하는데,
x86-64 아키텍처에서 MSVC는 MS x64규약을, GCC는 SYSTEM V 규약을 사용한다.
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아키텍처에서 사용하는 cdecl 규약은 레지스터의 수가 적으므로 스택을 통해 인자를 전달한다.
아래 C언어를 어셈블리로 컴파일하여 보게 되면,
// 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);
}
; Name: cdecl.s
.file "cdecl.c"
.intel_syntax noprefix
.text
.globl callee
.type callee, @function
callee:
nop
ret ; 스택을 정리하지 않고 리턴합니다.
.size callee, .-callee
.globl caller
.type caller, @function
caller:
push 2 ; 2를 스택에 저장하여 callee의 인자로 전달합니다.
push 1 ; 1를 스택에 저장하여 callee의 인자로 전달합니다.
call callee
add esp, 8 ; 스택을 정리합니다. (push를 2번하였기 때문에 8byte만큼 esp가 증가되어 있습니다.)
nop
ret
.size caller, .-caller
.ident "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0"
.section .note.GNU-stack,"",@progbits
callee에서는 스택을 정리하지 않고 리턴하고
caller에서는 callee를 호출 하기 전, 스택에 인자값을 넣어주고 함수를 호출한다.
당연하게도 int 변수 2개를 push했으니 esp는 8만큼 증가했을 것이고, add esp 8을 통해 esp가 callee 호출 전의 스택을
가르키게 한다. (스택 정리)
SYSV
리눅스에서 사용하는 SYSV 규약은 인자를 전달할 때,
1. 6개의 인자를 RDI, RSI, RDX, RCX, R8, R9에 순서대로 저장하여 전달한다.
2. 더 많은 인자를 사용해야 할 때는 스택을 추가로 이용한다.
3. Caller에서 인자 전달에 사용된 스택을 정리한다.
4. 함수의 반환 값은 RAX로 전달한다.
아래 C언어를 컴파일하여 gdb로 디버깅 해보면,
// 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(); }
1. 순서대로 인자값으로 사용 되고 마지막 인자를 push 할 때는 push하는 모습을 볼 수 있다.
그리고 함수 호출이 실행되는 흐름을 보게 되면,
현재 callee함수를 호출하고 난 뒤에 실행할 구문의 주소는 0x80011b4이다. 잘 기억하자.
2. callee함수의 안으로 들어오고 난 뒤의 스택 모습이다. callee함수가 호출되고 난 뒤에 실행되야할 구문의 주소가 스택에 들어있는 모습이다.
이것을 바탕으로 callee에서 값이 반환됐을 때, 이 주소를 꺼내어 원래의 실행 흐름으로 돌아갈 수 있다.
3. callee의 맨 처음 구문은 rbp를 스택에 넣고 있는데 이것은 caller함수의 rbp이다.이 작업을 하는 이유는 callee는 rbp라고 생각 했을 때, callee+1 같이 rbp를 바탕으로 구문을 실행하거나 값을 접근하기 때문에함수 호출이 끝났을 때 caller의 구문으로 다시 돌아가 실행해야하기 때문에 스택에 rbp를 저장하는 것이다.
4. 그 다음 callee함수의 스택 프레임을 할당 하는데,
mov rbp, rsp로 rbp와 rsp가 같은 주소를 가리키게 하면서 바로 다음에 rsp의 값을 빼게 되면,
rbp와 rsp의 사이 공간을 새로운 스택 프레임으로 할당하는 것이지만, callee 함수는 지역 변수를 사용하지 않으므로, 새로운 스택 프레임을 만들지 않는다.
5. 마지막으로 함수의 반환 값을 전달 하는데,
덧셈 연산을 다 한뒤에 연산한 값을 rbp에 옮긴다.
반환 직전 rax를 출력해보면,
연산의 결과값이 들어있는 것을 볼 수 있다.
callee함수의 호출도 끝나며 caller함수도 호출이 끝나가는데,
callee는 caller의 스택 베이스 포인터를 다시 rbp에 넣으면서 리턴하고 caller에서는 add rsp 8로 스택을 초기화 한다.
callee는 caller로 caller는 main 으로 돌아가며 프로그램이 종료된다.
'Pwnable > 이론' 카테고리의 다른 글
Return Address Overwrite (0) | 2022.06.27 |
---|---|
Stack Buffer Overflow (0) | 2022.06.22 |
Shell Code (0) | 2022.06.20 |
최근댓글