x64 calling convention

solmin·2023년 7월 24일

시스템해킹

목록 보기
2/2
post-thumbnail

함수 호출 규약은 함수의 호출, 인자 전달, 반환등
실행 전반에 대한 작동 방식을 정의합니다.

함수의 호출 방식은 아키텍처와 컴파일러에 따라서 제각각의 방식이 있지만
일반적으로 x86 아키텍처에서는 cdecl 방식을, x64 아키텍처에서는 fastcall 방식을 사용합니다.

이 글에서는 바이너리의 함수 호출 방식을 확인해보고, 디어셈블러를 활용하여
함수가 호출되는 과정을 분석해보겠습니다.

#include <stdio.h>

int add(int x, int y)
{
        return x+y;
}

int main()
{
        printf("%d\n", add(5,7));
        return 0;
}

테스트를 위해서 위 코드를 gcc로 컴파일링하여 바이너리 파일로 만들어주었습니다.

$ readelf -h binary

위 명령어를 통해 바이너리에 적용된 함수 호출 기법을 확인해줍니다.
컴파일시 별다른 옵션을 지정해주지 않았기 때문에
최적화를 위해 SYSTEM V ABI라는 함수 호출 규약이 적용되었습니다.
SYSTEM V ABI는 유닉스 기반 시스템에서 사용되는 함수 호출 규약으로
rdi, rsi, rdx, R8, R9, R10의 여섯개의 레지스터를 이용해 인자를 전달하고, 이후의 값들은
스택에 push하여 저장하는 방식으로 전달합니다.

$ gdb binary

이후 gdb라는 디버깅 툴을 활용하여 바이너리 파일을 분석해보았습니다.
(플러그인 추가 설치로 기본 gdb 프로그램과 인터페이스에 차이가 있을 수 있습니다.)

gdb에 바이너리를 올리고 main 함수를 디스어셈블 해보았습니다.
먼저 가장 윗 줄의 endbr64는 인텔 프로세서 관련 보안 기술로서
이 글의 주제와 관련 없는 내용이기 때문에 제외하고 분석하도록 하겠습니다.
main 함수가 실행되면서 가장 먼저 rbp 레지스터의 값을 스택에 push 해주는 것을 알 수 있습니다.
rbp 레지스터는 베이스 포인터(Base Pointer) 레지스터로, 함수 내의 지역 변수에 접근하거나
함수의 실행을 마친 후 스택 프레임을 정리하는 과정에서 사용되는 레지스터입니다.명령어를 실행하기 이전 스택의 모습입니다. 이 상태에서 push rbp 명령어를 실행해주면
이렇게 사진처럼 스택의 최상단에 rbp 레지스터의 값이 저장된 것을 볼 수 있습니다.
그리고 또한 rsp 레지스터의 값이 0x7fffffffdde8에서 1바이트 낮은(스택 구조상 더 높은)
주소인 0x7fffffffdde0을 가리키도록 변경된것 또한 확인할 수 있습니다.

rbp 레지스터의 값이 1이었던 이유

일반적으로 main 함수를 실행하기 전까지 다른 여러가지 함수를 실행하는 단계를 거치지만
컴파일러에 따라서 이러한 값이 main 함수 시작 직전에 초기화 되는 경우도 있습니다.
이러한 과정에 대해서는 바이너리가 실행중 직접 디버깅 과정을 거쳐서 알아볼 수 있습니다.

두번째 명령어 mov rbp, rsp까지 실행해주고 각각의 레지스터들의 값을 확인해보면
둘 다 같은 주소를 가리키게 된 것을 확인할 수 있습니다.
이전까지 main 함수의 프롤로그에 대해 알아보았고,
add 함수를 호출하는 부분에 대해서 다뤄보도록 하겠습니다.

명령어를 보면 add 함수를 호출하기 전 edi, esi 레지스터에 값을 저장해주는 걸 알 수 있습니다.

아키텍처첫 번째 인자두 번째 인자세 번째 인자네 번째 인자다섯 번째 인자여섯 번째 인자
x64rdirsirdxR10R8R9
x32ebxecxedxesiediebp

위 표는 SYSTEM V ABI 함수 호출 규약에서 각각의 인자에 대응되는 레지스터들을 나타냅니다.
표를 보았을때 첫 번째 인자인 5는 rdi, 7은 rsi 레지스터를 이용하여 전달되어야함을 알 수 있습니다.
그런데 왜 사진에서는 edi와 esi 레지스터를 이용하여 인자를 전달하는걸까요?

왜 인자 전달에 32비트 레지스터들을 사용했을까

그 이유는 컴파일링중 최적화 과정에 있습니다.
소스코드를 컴파일할때 따로 옵션을 지정해주지 않는다면
컴파일러는 자동으로 최적화 과정을 거치게됩니다.
따라서 64비트 레지스터를 사용할 필요가 없는 작은 값의 전달에 32비트 레지스터를 사용하도록
자동으로 최적화된것입니다.
https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
위 페이지에서 gcc 컴파일러의 최적화 옵션에 대해서 자세히 알아볼 수 있습니다.

이후 두 레지스터의 값들을 확인해보면 정상적으로 값들이 저장된것을 볼 수 있습니다.
값들을 저장한 이후 call add 명령어로 add 함수로 실행 흐름이 이동되었습니다.
첫 번째 endbr64와 mov rbp,rsp 명령어 까지의 과정은 main함수에서와 동일하고
이미 설명했기 때문에, 이후의 명령어들의 동작에 대해서만 설명하겠습니다.
첫 번째 명령어를 보면 rbp 레지스터에서 0x4, 0x8만큼 작은 주소에
edi 레지스터와 esi 레지스터의 값을 저장해줍니다.

이후 rbp 레지스터의 하위 32비트에는 7이, 상위 32비트 5가 저장된 걸 볼 수 있습니다.
(2진수 표기법으로 출력해본 데이터)

연산을 위해서 리턴값을 저장할 레지스터인 eax에 첫 번째 피연산자인 5를 저장해주고
산술 연산에 사용되는 범용 레지스터인 edx에 두 번째 피연산자인 7을 저장해줍니다.

eax 레지스터(5)에 edx 레지스터(7)을 더해줌으로서 연산을 수행하고
이전에 스택에 push 하였던 rbp 값을 다시 pop 명령어를 통해 rbp에 저장합니다.
이후 ret 명령어를 통해 이전의 실행 흐름으로 돌아가줍니다.
eax 레지스터(함수 호출 이후 반환값을 저장)에 12가 저장되었고
ret 주소를 확인해보면 이전에 add 함수를 호출한 명령어의 바로 다음 명령어로 이동하여
실행을 이어나가는것을 볼 수 있습니다.
main 함수로 돌아온 이후 printf 함수를 통해서 연산의 결과를 출력해주어야하기 때문에
printf 함수의 두 번째 인자를 전달할 레지스터인 esi 레지스터에
add 함수에서의 반환값이 저장되어 있는 eax 레지스터의 값을 저장해줍니다.
그리고 rax 레지스터에 코드영역에 있는 포맷스트링 인자가 저장된 주소를
rip 레지스터와의 상대 주소 연산으로 구하여 저장해줍니다.
printf 함수의 첫 번째 인자를 전달하기 위해서 rdi함수를 쓰고 있는데
64비트 레지스터인 rip 레지스터와의 연산으로 주소를 계산하기 때문에
8바이트 주소 값을 저장하기 위해서 rdi 레지스터를 사용해주었습니다.
rax 레지스터에 반환값을 저장하지 않게 하기 위해 0을 저장해주고 printf함수를 call 해주면
연산 결과 출력 까지의 과정이 끝나게됩니다.
return 0; 을 실행하면서 종료하기 위해서 eax 레지스터에 0을 저장해주고
main 함수 프롤로그에서 push했던 rbp의 값을 pop을 통해 다시 rbp에 저장하면서
프로그램 종료 루틴으로 ret합니다.

마치면서

여기까지 x64 아키텍처에서 함수를 호출하는 과정 전반에 대해서 자세히 알아보았습니다.
글을 쓰는 과정에서 제대로 알지 몰랐던 부분들에 대해서 다시 공부해보게되어서
좋은 기회가 되었던 것 같습니다.
다음에 비슷한 주제로 글을 쓰게 된다면 더욱 세밀하게 조사하여 작성해보고 싶습니다.

0개의 댓글