Fascination
article thumbnail

Background: Calling Convention


# 서론

1. 함수 호출 규약

  • 함수 호출 규약: 함수의 호출 및 반환에 대한 약속
  • 한 함수에서 다른 함수를 호출할 때, 프로그램 실행 흐름은 다른 함수로 이동함
  • 호출한 함수가 반환하면, 다시 원래의 함수로 돌아와서 기존의 실행 흐름을 이어나감
  • 함수를 호출할 때는 반환된 이후를 위해 호출자(Caller)의 상태(Stack Frame) 및 반환 주소(Return Address)를 지정해야 함
  • 호출자는 피호출자(Callee)가 요구하는 인자를 전달해줘야 하며, 피호출자의 실행이 종료될 때는 반환 값을 전달받아야 함

 

2. 함수 호출 규약 종류

  • 컴파일러는 지원하는 호출 규약 중, CPU 아키텍처에 적합한 것을 선택함
  • x86(32bit) 아키텍처는 레지스터를 통해 피호출자의 인자를 전달하기에는 레지스터의 수가 적으므로, 스택으로 인자를 전달하는 규약을 사용
  • x86-64 아키텍처에서는 레지스터가 많으므로 적은 수의 인자는 레지스터만 사용해서 인자를 전달하고, 인자가 너무 많을 때만 스택을 사용
  • CPU의 아키텍처가 같아도, 컴파일러가 다르면 적용하는 호출 규약이 다를 수 있음
  • C언어의 경우 컴파일 할 때 윈도우에서는 MSVC를, 리눅스에서는 gcc를 많이 사용함
  • x86-64 아키텍처
    • MSVC: MS x64 호출 규약 적용
    • gcc: SYSTEM V 호출 규약 적용

 

 

# x86호출 규약: cdecl

  • x86 아키텍처는 레지스터의 수가 적으므로, 스택을 통해 인자를 전달함
  • 인자를 전달하기 위해 사용한 스택을 호출자가 정리하는 특징이 있음
  • 스택을 통해 인자를 전달할 때는, 마지막 인자부터 첫 번째 인자가 거꾸로 스택에 push 됨
  • Figure 1: 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);
}
  • cdecl.s
; 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

💡 컴파일의 정확한 의미

  • 컴파일(Compile)의 정확한 의미는 어떤 언어로 작성된 소스 코드(Source code)를, 다른 언어의 목적 코드(Object code)로 번역하는 것
  • 소스 코드를 어셈블리어로, 또는 소스 코드를 기계어로 번역하는 행위 모두 컴파일의 범주에 포함됨
  • C언어를 실행 가능한 바이너리로 만드는 과정을 보통 전처리, 컴파일, 어셈블, 링크의 4단계로 구분하는데, 이를 합해서 '컴파일'이라고 부를 수 있는 것도 위와 같은 이유임

 

 

# x86-64 호출 규약: SYSV

1. SYSV

  • 리눅스는 SYSTEM V(SYSV) Application Binary Interface(ABI)를 기반으로 만들어졌음
  • SYSV ABI는 ELF 포맷, 링킹 방법, 함수 호출 규약 등의 내용을 담고 있음
  • file 명령어를 이용하여 바이너리의 정보를 살펴보면, 아래와 같이 SYSV 문자열이 포함된 것을 확인할 수 있음

  • SYSV에서 정의한 함수 호출 규약은 다음의 특징을 가짐
    1. 6개의 인자는 RDI, RSI, RDX, RCX, R8, R9에 순서대로 저장하여 전달하고 더 많은 인자를 사용해야 할 때는 스택을 추가로 사용함
    2. Caller에서 인자 전달에 사용된 스택을 정리
    3. 함수의 반환 값은 RAX로 전달함
  • Figure 2. sysv.c
// 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(); }

 

2. SYSV 상세 분석

  • SYSV를 gdb로 분석해볼 것
gcc -fno-asynchronous-unwind-tables -masm=intel -fno-omit-frame-pointer -o sysv sysv.c -fno-pic -O0

 

2.1 인자 전달 🚚

  • gdb로 sysv를 로드한 후 중단점을 설정하여 caller 함수까지 실행
  • context의 DISASM을 보면, caller+6부터 caller+33까지 6개의 인자를 각각의 레지스터에 설정하고 있으며, caller+4에서는 7번째 인자인 7을 스택으로 전달하고 있음 callee 함수를 호출하기 전까지 실행하고, 레지스터와 스택을 확인함

  • callee 함수를 호출하기 전까지 실행하고, 레지스터와 스택을 확인함
    • 소스코드에서 callee(123456789123456789, 2, 3, 4, 5, 6, 7)로 함수를 호출했음
    • 인자들이 순서대로 rdi, rsi, rcx, r8, r9 그리고 [rsp]에 설정되어 있음을 확인할 수 있음

 

2.2 반환 주소 저장 🏠

  • si 명령어로 한 단계를 더 실행시킴

  • call이 실행되고 스택을 확인해보면 0x555555554682가 반환 주소로 저장되어 있음
  • gdb로 확인해보면 0x555555554682callee 호출 다음 명령어의 주소임
  • callee에서 반환됐을 때, 이 주소를 꺼내어 원래의 실행 흐름으로 돌아갈 수 있음

 

2.3 스택 프레임 저장

  • x/5i $rip 명령어로 callee 함수의 도입부(Prologue)를 살펴보면, 가장 먼저 push rbp를 통해 호출자의 rbp를 저장하고 있음

  • rbp가 스택 프레임의 가장 낮은 주소를 가리키는 포인터이므로, 이를 Stack Frame Pointer(SFP)라고도 부름
  • callee에서 반환될 때, SFP를 꺼내어 caller의 스택 프레임으로 돌아갈 수 있음
  • si로 push rbp를 실행하고(callee+1에 breakpoint가 걸림), 스택을 확인해보면 rbp 값인 0x00007fffffffdf80 가 저장된 것을 확인할 수 있음

 

2.4 스택 프레임 할당

  • mov rbp, rsp 명령어를 통해 rbp와 rsp가 같은 주소를 가리키게 함

  • 바로 다음에 rsp의 값을 빼게 되면, rbp와 rsp 사이 공간을 새로운 스택 프레임으로 할당하는 것이지만, callee 함수는 지역 변수를 사용하지 않으므로, 새로운 스택 프레임은 만들지 않음
  • si로 실행하고, 레지스터를 보면 이 둘이 같은 주소를 가리키는 것을 확인할 수 있음

💡 Callee함수에서 ret라는 지역 변수를 선언하지 않았나요?

  • 코드를 보면, 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;
}

 

2.5 반환 값 전달

  • 덧셈 연산을 모두 마치고, 함수의 종결부(Epilogue)에 도달하면, 반환 값을 rax에 옮김

  • 반환 직전에 rax를 출력하면 전달한 7개 인자의 합인 123456789123456816을 확인할 수 있음

 

2.6 반환

  • 반환은 저장해뒀던 스택 프레임과 반환 주소를 꺼내면서 이루어짐
  • 여기서는 callee 함수가 스택 프레임을 만들지 않았기 때문에, pop rbp로 스택 프레임을 꺼낼 수 있지만, 일반적으로 leave로 스택 프레임을 꺼냄
  • 스택 프레임을 꺼낸 뒤에는, ret을 통해 호출자로 복귀함

  • 앞에서 저장했던 sfp로 rbp가, 반환 주소로 rip가 설정된 것을 확인할 수 있음

 

 

# 결론

  • x86 함수 호출 규약

  • x86-64 함수 호출 규약

 

 

# Quiz

1번)

 

2번) 

 

3번)

 

4번)

인자를 위해 사용했던 0x4 * 3개의 스택 공간 정리 필요

 

5번)

 

6번)

 

7번)

profile

Fascination

@euna-319

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!