Hacking/Pwnable

함수 호출 규약 (Calling Convention)

GunP4ng 2024. 3. 27. 23:43

함수 호출 규약


1. 함수 호출 규약

1. 함수 호출 규약이란?

함수 호출 규약은 함수의 호출 및 반환에 대한 약속이다.

함수를 호출할 때 파라미터를 어떤 식으로 전달하는지에 대한 규칙을 정의한다.

 

caller (호출자) : 함수를 호출한 곳

callee (피호출자) : 호출 당하는 함수

 

함수 호출 규약을 적용하는 것은 일반적으로 컴파일러의 몫이다.

프로그래머가 코드에 명시하지 않는다면, 컴파일러는 CPU 의 아키텍처에 적합한 함수 호출 규약을 적용한다.

 

2. 함수 호출 규약 종류

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

x86 (32bit) 아키텍처는 레지스터의 수가 적으므로, 스택으로 인자를 전달하는 규약을 사용한다.

반대로 x86-64 (64bit) 아키텍처는 레지스터가 많으므로, 레지스터만 사용해서 인자를 전달하고, 인자가 너무 많을 때만 스택을 사용한다.

 

CPU의 아키텍처가 같아도 컴파일러가 다르면 적용하는 호출 규약이 다를 수 있다.

C언어를 컴파일 할 때 윈도우에서는 MSVS 를 사용하고, 리눅스는 gcc 컴파일러를 사용한다.

x86-64 아키텍처에서 MSVC 는 MS x64 (4-register-fastcall) 호출 규약을 적용하지만 gcc 는 SYSTEM V 호출 규약을 적용한다.

 

아래는 대표적인 호출 규약들이다.

 

x86

  • cdecl
  • stdcall
  • fastcall
  • thiscall

x86-64

  • SYSTEM V (AMD64 ABI)
  • 4 register fastcall (MS ABI)

 

 

2. x86 호출 규약 (cdecl, stdcall, fastcall)

1. cdecl

C와 C++(가변인자)의 기본 호출 규약이다.

x86 아키텍처는 레지스터의 수가 적기 때문에 스택을 통해 인자를 전달한다.

 

cdecl 은 인자를 전달하기 위해 사용한 스택을 호출자(caller)가 정리한다.

스택을 통해 인자를 전달할 때는 오른쪽에서 왼쪽 순서로 스택에 push 한다.

 

함수 인자의 개수를 미리 알고 호출하기 때문에 가변 인자(C++)를 호출할 수 있다.

 

// Name: calling_convention.c
// Compile: gcc -m32 -fno-stack-protector -mpreferred-stack-boundary=2 -z execstack -no-pie -o calling_cdecl calling_convention.c

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

int main() {
   callee(1, 2);
   return 0;
}

위의 코드를 컴파일 한 후 gdb 를 이용해 어셈블리어를 확인해보자.

 

  • main 함수 (caller)

매개변수를 push 로 저장

호출자에서 인자를 push 로 스택에 저장한다.

인자를 스택에 저장할 때는 마지막 인자부터 첫 번째 인자까지 거꾸로 push 한다.

 

호출자(caller)에서 스택 정리

호출자에서 esp 위치를 더해서 스택을 정리한다.

스택은 높은 주소에서 낮은주소로 자라기 때문에 값을 더하면 메모리 위치는 줄어든다.

 

2. stdcall

cdecl 과 마찬가지로 레지스터의 수가 적기 때문에 스택을 통해 인자를 전달한다.

전반적으로 cdecl 과 비슷하지만 인자를 전달하기 위해 사용한 스택을 피호출자(callee)가 정리한다.

스택에 인자를 저장할 때는 오른쪽에서 왼쪽 순으로 저장한다.

 

피호출자(callee)가 스택을 정리하기 때문에 인자를 얼마나 정리해야 할 지 모르기 때문에 가변인자를 사용할 수 없다.

Win32 API 에서 주로 사용된다.

 

cdecl 방식보다 코드 양이 적고, 함수의 독립성이 좋다.

cdecl 방식은 여러곳에서 호출되면 호출자(caller) 가 스택을 정리하기 때문에 스택 정리 코드가 여러개 생기게 된다.

하지만 stdcall 방식은 피호출자(callee)가 스택을 정리하기 때문에 스택 정리 코드가 함수 내 1번만 존재한다.

함수가 종료된 뒤에 호출자(caller)에서 신경 쓸 필요가 없기 때문에 함수의 독립성이 좋다.

 

// Name: calling_convention.c
// Compile: gcc -m32 -fno-stack-protector -mpreferred-stack-boundary=2 -z execstack -no-pie -o calling_stdcall calling_convention.c

void __attribute__((stdcall)) callee(int a1, int a2){ // stdcall로 호출
}

int main() {
   callee(1, 2);
   return 0;
}

위의 코드를 컴파일 한 후 gdb 를 이용하여 어셈블리어를 확인해보자.

 

  • main 함수 (caller)

매개변수를 push 로 저장

호출자에서 인자를 push 로 저장한다.

하지만 피호출자(callee)에서 스택을 정리하기 때문에 main 함수에는 스택을 정리하는 코드가 없다.

 

  • cellee 함수 (피호출자)

피호출자(callee)에서 스택 정리

피호출자(callee)에서 빠져나갈 때 ret (인자의 크기) 로 스택을 정리한다.

 

3. fastcall

인텔 CPU에서만 사용이 가능하다.

첫 번째 인자는 ecx , 두 번째 인자는 edx 에 저장한다.

세 번째 인자부터는 오른쪽에서 왼쪽 순서로 스택에 저장한다.

피호출자(callee)가 스택을 정리한다.

가변인자는 사용이 불가능하다.

stdcall 과 동일하지만 인자가 2개 이하 시 레지스터를 이용하기 때문에 속도가 빠르다.

 

// Name: calling_convention.c
// Compile: gcc -m32 -fno-stack-protector -mpreferred-stack-boundary=2 -z execstack -no-pie -o calling_fastcall calling_convention.c

void __attribute__((fastcall)) callee(int a1, int a2, int a3, int a4){ // fastcall로 호출
}

int main() {
   callee(1, 2, 3, 4);
   return 0;
}

위의 코드를 컴파일 한 후 gdb 로 어셈블리어를 확인해보자.

 

  • main 함수 (caller)

ecx, edx 를 이용해 인자를 전달

첫 번째 인자는 ecx, 두 번째 인자는 edx 를 이용해서 전달한다.

세 번째 인자부터는 스택에 push 한다.

 

  • callee 함수 (피호출자)

피호출자(callee)에서 스택 정리

피호출자(callee)에서 빠져나갈 때 ret 로 스택을 정리한다.

 

4. thiscall

C++ 의 기본 호출 규약이다

가변인자를 사용하지 않는 함수는 thiscall 을 사용하고 가변인자를 사용하면 cdecl 방식으로 변경된다.

인자는 스택을 사용해서 오른쪽에서 왼쪽 순서로 전달한다.

피호출자(callee) 가 스택을 정리한다.

 

MSVC 컴파일러를 사용하고 클래스의 함수에 적용된다.

ecx 레지스터에 클래스의 this 포인터를 전달한다.

 

직접적으로 호출 규약을 사용할 수 없다.

멤버 함수는 thiscall 을 사용하지만 직접 지정해서 다른 호출 규약을 사용할 수 있다.

다른 호출 규약 사용 시 첫 번째 인자로 this 포인터가 전달한다.

 

 

3. x86-64 호출 규약 (SYSV, MS x64)

1.  SYSV (SYSTEM V)

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

따라서 gcc 에서는 SYSV  호출 규약을 사용한다.

 

SYSV 호출 규약은 6개의 인자를 rdi, rsi, rdx, rcx, r8, r9 순으로 저장하여 전달한다.

더 많은 인자를 사용할 때는 스택을 이용한다.

호출자(caller)에서 스택을 정리한다.

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

 

// Name: x64_calling_convention.c
// Compile: gcc -m32 -fno-stack-protector -mpreferred-stack-boundary=2 -z execstack -no-pie -o x64_fastcall_SYSV x64_calling_convention.c

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

위의 코드를 컴파일 한 후 gdb 로 어셈블리어를 확인해보자.

 

  • caller 함수 (호출자)

rdi, esi, edx, ecx, r8, r9 순으로 인자 저장

rdi, esi, edx, ecx, r8, r9 순으로 레지스터를 이용하여 인자를 저장한다.

인자가 6개 이상이기 때문에 7번째 인자부터는 스택에 push 한다.

 

호출자(caller)에서 스택 정리

호출자(caller) 에서 스택을 정리한다.

64비트 시스템에서는 스택이 8바이트 단위로 정렬되기 때문에 rsp 에 0x8을 더한다.

이와 관련해서는 나중에 다시 설명하겠다.

 

  • callee 함수 (피호출자)

반환값을 RAX에 옮김

callee 함수에서 덧셈 연산을 모두 마치고, 함수의 에필로그에 도달하면 반환값을 rax에 옮긴다.

gdb 를 이용해 rax 에 저장된 반환값을 확인해보자.

pwndbg> b *callee +91
Breakpoint 1 at 0x401161
pwndbg> r

... 

───────────────────────────────────[ DISASM / x86-64 / set emulate on ]───────────────────────
 ► 0x401161       <callee+91>                ret                       <0x401196; caller+52>
    ↓
   0x401196       <caller+52>                add    rsp, 8
   0x40119a       <caller+56>                nop    
   0x40119b       <caller+57>                leave  
   0x40119c       <caller+58>                ret    
    ↓
   0x4011af       <main+18>                  mov    eax, 0
   0x4011b4       <main+23>                  pop    rbp
   0x4011b5       <main+24>                  ret    
    ↓
   0x7ffff7dee083 <__libc_start_main+243>    mov    edi, eax
   0x7ffff7dee085 <__libc_start_main+245>    call   exit                <exit>
 
   0x7ffff7dee08a <__libc_start_main+250>    mov    rax, qword ptr [rsp + 8]

...

pwndbg> print $rax
$1 = 123456789123456816

callee 함수의 반환 직전에 브레이크를 걸고 rax 를 출력하면 7개 인자의 합인 123456789123456816 을 확인할 수 있다.

 

2. MS x64 (4-register-fastcall)

MSVC 컴파일러를 사용하는 윈도우에서는 MS x64 호출 규약을 사용한다.

4개의 레지스터를 사용해서 인자를 전달하기 때문에 4-register-fastcall 이라고도 한다.

 

함수 호출시 rcx, rdx, r8, r9 레지스터를 사용해서 순서대로 인자를 전달한다.

인자가 5개 이상인 경우 스택에 push 해서 전달한다.

인자는 오른쪽에서 왼쪽 순서로 전달한다.

호출자(caller)에서 스택을 정리한다.

 

 


Reference

함수 호출 규약

https://sanghoon23.tistory.com/28#google_vignette

https://ccurity.tistory.com/15

https://learn.dreamhack.io/54#9

 

64비트 시스템에서 스택을 8바이트로 정리하는 이유

https://velog.io/@itsantiago/C%EC%96%B8%EC%96%B4-32-vs-64%EB%B9%84%ED%8A%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%97%90%EC%84%9C%EC%9D%98-%EC%BB%B4%ED%8C%8C%EC%9D%BC-%EC%B0%A8%EC%9D%B4