프로그램에서 함수 A가 함수 B를 호출하면, 실행 흐름이 함수 B로 이동한다.

함수 B의 실행이 완료되면, 프로그램은 다시 함수 A로 돌아와 기존의 실행을 이어간다.

 

  • 호출자(Calller)의 상태 관리: 함수 A가 함수 B를 호출할 때, 함수 A는 반환된 후에도 원활한 실행을 위해 자신의 상태(스택 프레임)와 반환 주소(Return Address)를 저장해 둬야 한다.
  • 인자 전달: 호출자(Calller)는 피호출자(Callee)가 필요로 하는 인자를 전달해야 힌다. 이 인자는 함수 호출 시 스택이나 레지스터를 통해 전달된다.
  • 반환 값 처리: 피호출자(Callee)의 실행이 종료될 때, 피호출자는 결과 값을 호출자에게 반환한다. 호출자는 이 반환 값을 받아 후속 처리를 이어간다.

 

함수 호출 규약은 위 과정에 대한 약속이다.

이러한 규약은 일반적으로 컴파일러가 처리한다.

 

  • 컴파일러의 역할: 프로그래머가 고수준 언어로 코드를 작성하면, 컴파일러가 CPU 아키텍처에 적합한 호출 규약을 자동으로 선택하여 코드를 컴파일한다. 따라서 대부분의 프로그래머는 함수 호출 규약을 알 필요 없이 코드를 작성할 수 있다.

그러나 컴파일러의 도움 없이 어셈블리 코드를 직접 작성하거나, 어셈블리로 작성된 코드를 분석하려면 함수 호출 규약을 이해하는 것이 중요다. 이는 시스템 해킹과 같은 분야에서 필수적인 기술이다.

 

 



목차

  • 함수 호출 규약의 종류
  • SYSV 호출 규약

 

 

함수 호출 규약의 종류

컴파일러는 CPU 아키텍처에 적합한 함수 호출 규약을 선택한다.

  • x86(32비트) 아키텍처: 레지스터 수가 적기 때문에, 인자를 스택을 통해 전달하는 호출 규약을 사용
  • x86-64 아키텍처: 레지스터가 많아 적은 수의 인자는 레지스터로 전달하고, 인자가 많을 때만 스택을 사용

컴파일러는 같은 CPU 아키텍처에서도 컴파일러에 따라 적용되는 호출 규약이 다를 수 있다.

 

  • 윈도우의 MSVC: x86-64 아키텍처에서 MS x64 호출 규약을 사용
  • 리눅스의 gcc: x86-64 아키텍처에서 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 일반 함수

 

 

 

SYSV 호출 규약

리눅스에서 file 명령어를 사용하여 바이너리 정보를 확인한 모습

 

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

 SYSV ABI는 ELF 포맷, 링킹 방법, 함수 호출 규약 등의 내용을 담고 있다.

 

SYSV의 함수 호출 규약은 아래 특징을 갖는다.

 

  • 인자 전달: 처음 6개의 인자는 레지스터 RDI, RSI, RDX, RCX, R8, R9에 순서대로 저장하여 전달한다. 추가 인자는 스택을 통해 전달한다.
  • 스택 정리: 호출자(Caller)가 인자 전달에 사용된 스택을 정리한다.
  • 반환 값: 함수의 반환 값은 RAX 레지스터에 저장하여 전달한다.

 

다음 코드를 컴파일하고 동적 분석해보며 SYSV 호출 규약에 대해 더 알아보자.

 

#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.c 파일을 생성하고 컴파일한 뒤, gdb로 sysv를 로드한다.

caller()에 브레이크 포인트를 걸고 실행한다.

 

1. 인자 전달

첫 번째 인자부터 여섯 번째 인자는 레지스터에, 7번째 인자는 스택으로 전달하는 것을 확인할 수 있다.

 

callee() 호출 부분을 브레이크 포인트로 설정하고 실행한다.

 

인자들이 레지스터  rdi, rsi, rdx, rcx, r8, r9,  rsp(스택의 최상단을 가리키는 포인터)에 설정되어 있는 것을 확인할 수 있다.

 

 

2. 반환 주소 저장 

si 명령어로 한 단계 더 실행시키면 함수 호출이 실행되며, 스택에 반환 주소가 저장된다.

0x5555555551bccallee에서 반환됐을 때, 원래의 실행 흐름으로 돌아갈 수 있는 주소다.

 

 

3. 스택 프레임 저장

callee()의 도입부를 살펴보면, 가장 먼저 push rbp를 통해 호출자 (caller())의 rbp를 저장한다.

rbp(스택의 가장 낮은 주소를 가리키는 포인터)는 SFP라고도 부른다.

callee()에서 반환될 때, SFP를 꺼내어 caller()의 스택 프레임으로 돌아갈 수 있다.

 

si로 push rbp를 실행하면, rbp값이 저장된 것을 확인할 수 있다.

 

 

4. 스택 프레임 할당

mow rbp, rsp를 실행해서 rbprsp가 같은 주소를 가리키게 한다.

만약 이 다음에 rsp의 값을 빼게 되면, rbpsrp의 사이 공간을 새로운 스택 프레임으로 할당하는 것이다.

하지만 callee() 는 지역 변수를 사용하지 않으므로, 새로운 스택 프레임을 만들지 않는다.

 

 

5. 반환 값 전달

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

 

반환 직전에 rax를 확인하면 7개 인자의 합인 123456789123456816을 확인할 수 있다.

 

 

6. 반환

저장해뒀던 스택 프레임과 반환 주소를 꺼내면서 반환한다.

여기서는 callee()가 스택 프레임을 만들지 않았기 때문에, pop rbp로 스택 프레임을 꺼낼 수 있지만, 일반적으로 leave로 꺼낸다.

 

 

 

 

 

 

참고 - 드림핵 ( System Hacking) https://dreamhack.io/lecture/roadmaps/all/system-hacking

 

 

목록 | 로드맵 | Dreamhack

Memory Corruption: Stack Buffer Overflow 9.7★ (234) Free

dreamhack.io

 

 

셸(Shell)은 운영체제에 명령을 내리기 위한 사용자 인터페이스로, 운영체제의 핵심 기능을 담당하는 커널(Kernel)과 대비된다. 셸을 획득하면 시스템을 제어할 수 있어, 해킹에서 셸 획득은 성공을 의미한다.

 

execve 셸코드는 임의의 프로그램을 실행할 수 있는 셸코드로, 이를 통해 서버의 셸을 획득할 수 있다. 보통 셸코드라고 하면 이 execve 셸코드를 가리킨다.

 

리눅스에서 기본 셸로는 sh, bash가 많이 사용되며, 사용자가 zsh, tsh 등의 다른 셸도 설치할 수 있다.

 

 

 

어셈블리 코드

execve 셸코드의 syscall은 다음과 같다. 

syscall  rax arg0 (rdi) arg1 (rsi) arg2 (rdx)
execve 0x3b const char *filename const char *const *argv const char *const *envp

 

여기서 argv는 실행파일에 넘겨줄 인자이고, envp는 환경변수다.

리눅스에서 기본 실행 프로그램들은 /bin 디렉토리에 저장되어 있으며, 여기서 실행해야 할 sh도 해당 디렉토리에 저자오디어 있다.

따라서  execve("/bin/sh", null, null) 을 실행하는 것을 목표로 쉘 코드를 작성한다.

mov rax, 0x68732f6e69622f
push rax
mov rdi, rsp  ; rdi = "/bin/sh\x00"
xor rsi, rsi  ; rsi = NULL
xor rdx, rdx  ; rdx = NULL
mov rax, 0x3b ; rax = sys_execve
syscall       ; execve("/bin/sh", null, null)

 

한줄씩 살펴보면

mov rax, 0x68732f6e69622f
push rax

rax 레지스터에 0x68732f6e69622f 값을 이동(저장)한다. ASCII 문자열 /bin/sh에 해당하는 값이다.

rax 레지스터에 저장된 값을 스택에 push한다. 스택에 문자열 /bin/sh이 저장된다.

 

mov rdi, rsp

rdi 레지스터에 현재 스택 포인터(rsp) 값을 저장한다. 이로써 rdi는 "/bin/sh" 문자열을 가리키게 된다.

이는 execve 시스템 호출에서 실행할 프로그램의 경로를 나타낸다.

 

xor rsi, rsi
xor rdx, rdx

xor 연산을 통해 rsirdx가 0으로 초기화되며, 0은 포인터로 사용되면 NULL을 나타낸다.

 

mov rax, 0x3b
syscall

 

rax 레지스터에 0x3b를 저장한다.이는 execve 시스템호출의 번호이다.

syscall 명령어로 rax에 저장된 시스템 호출 번호(0x3b, 즉 execve)와, 앞서 설정된 rdi(파일 경로), rsi(인자), rdx(환경 변수) 값을 사용하여 /bin/sh 셸을 실행하게 된다.

 

 

 

컴파일 및 실행

위 어셈블리 코드를 컴파일하기 위해, 스켈레톤 코드안에 쉘코드를 채운 파일을 만든다.

__asm__(
    ".global run_sh\n"
    "run_sh:\n"

    "mov rax, 0x68732f6e69622f\n"
    "push rax\n"
    "mov rdi, rsp  # rdi = '/bin/sh'\n"
    "xor rsi, rsi  # rsi = NULL\n"
    "xor rdx, rdx  # rdx = NULL\n"
    "mov rax, 0x3b # rax = sys_execve\n"
    "syscall       # execve('/bin/sh', null, null)\n"

    "xor rdi, rdi   # rdi = 0\n"
    "mov rax, 0x3c	# rax = sys_exit\n"
    "syscall        # exit(0)");

void run_sh();

int main() { run_sh(); }

 

 

위 파일을 컴파일하고 실행한다. sh가 성공적으로 실행된 것을 확인할 수 있다.

 

 

 

 

디버깅

run_sh에 브레이크 포인트를 걸고 실행한 결과다.

 

 

다음은 execve 시스템 호출(run_sh+27)에 브레이크 포인트를 걸고 실행했다.

경로, 인자, 환경변수가 설정된 것을 확인할 수 있다.

 

 

ni(next instruction)명령어를 사용해서 다음 코드인 syscall을 실행하면 sh이 실행된다.

 

 

 

참고 - 드림핵( System Hacking )

+ Recent posts