system

[시스템 프로그래밍] execve() 시스템 콜 C vs 어셈블리 비교 분석

숨usm 2025. 9. 8. 19:58

execve() 시스템 콜을 C 언어와 어셈블리 두 가지 방식으로 구현해보고, 각각의 차이점과 동작 원리를 자세히 분석한 내용 정리.

같은 기능을 하는 코드가 어떻게 다르게 구현되는지, 그리고 시스템 콜이 실제로 어떻게 작동하는지 확인.


💡 참고
이 코드는 학습용 예제입니다.
실제로 실행하면 셸이 바로 뜨므로, 반드시 승인된 실습 환경(로컬 VM, CTF 문제 등)에서만 실행하세요.

C 언어 구현

#include <unistd.h>
int main() {
    char *const argv[] = { "/bin/sh", NULL };  // 인자 리스트
    char *const envp[] = { NULL };             // 환경변수 없음
    execve("/bin/sh", argv, envp);
    _exit(1);  // execve 실패 시만 도달
}

어셈블리 구현

// assembly(x86-64, Linux)
        .data
binsh:  .ascii "/bin/sh\0"     ; 실행할 경로 문자열
        .balign 8
argv:   .quad binsh            ; argv[0] = "/bin/sh"
        .quad 0                ; argv[1] = NULL
        .text
        .global _start
_start:
        movq $59, %rax         ; execve syscall 번호 (59)
        lea binsh(%rip), %rdi  ; rdi = "/bin/sh" 주소
        lea argv(%rip), %rsi   ; rsi = argv 배열 주소
        xorq %rdx, %rdx        ; rdx = 0 (envp = NULL)
        syscall                ; execve("/bin/sh", argv, NULL)
        ; 실패할 경우 exit(1)
        movq $60, %rax
        movq $1, %rdi
        syscall

C 코드 분석

1. 변수 선언 부분

char *const argv[] = { "/bin/sh", NULL };
char *const envp[] = { NULL };

argv 배열:

  • argv[0] = "/bin/sh" → 실행할 프로그램 이름
  • argv[1] = NULL → 배열 끝 표시
  • char *const → 문자열 포인터 배열 (상수)

터미널에서 명령어를 입력할 때와 비교해보면:

$ ls -la /home

이건 내부적으로는:

char *argv[] = {"ls", "-la", "/home", NULL};
execve("/bin/ls", argv, envp);

argv 배열의 규칙:

  1. argv[0]은 항상 프로그램 이름 (관례)
  2. argv[1]부터는 실제 인자들
  3. 마지막은 반드시 NULL로 끝
  4. 이 구조는 C 언어의 main(int argc, char *argv[])와 동일

정리

  • argc는 배열 크기를 의미 (여기서는 2)
  • argv는 문자열 포인터들의 배열
  • NULL로 끝나는 이유: 배열 크기를 따로 전달하지 않기 때문

2. execve() 호출

execve("/bin/sh", argv, envp);
  • 첫 번째 인자: 프로그램 경로
  • 두 번째 인자: 인자 배열 주소
  • 세 번째 인자: 환경변수 배열 주소

3. 실패 처리

_exit(1);  // execve가 실패했을 때만 실행됨

어셈블리 코드 분석

1. 데이터 섹션 (.data)

binsh:  .ascii "/bin/sh\0"     ; 문자열 저장
  • 메모리에 "/bin/sh" 문자열을 저장
  • \0는 널 문자 (문자열 끝 표시)
.balign 8                      ; 8바이트 정렬
argv:   .quad binsh            ; 8바이트 주소값 저장
        .quad 0                ; NULL (8바이트 0)
  • argv[0] = binsh의 주소
  • argv[1] = 0 (NULL)

2. 코드 섹션 (.text)

movq $59, %rax                 ; 시스템 콜 번호 59 (execve)

3. 인자 설정

lea binsh(%rip), %rdi          ; rdi = "/bin/sh" 주소
lea argv(%rip), %rsi           ; rsi = argv 배열 주소  
xorq %rdx, %rdx                ; rdx = 0 (envp = NULL)

레지스터 역할:

  • %rdi → 첫 번째 인자 (프로그램 경로)
  • %rsi → 두 번째 인자 (argv 배열)
  • %rdx → 세 번째 인자 (envp)

4. 시스템 콜 실행

syscall                        ; execve() 호출

핵심 개념 정리

메모리 구조

메모리 주소    내용
0x1000:      "/bin/sh\0"      ← binsh가 가리키는 곳
0x2000:      0x1000          ← argv[0] (binsh 주소)
0x2008:      0x0000          ← argv[1] (NULL)

시스템 콜 흐름

  1. 준비: 레지스터에 인자값 설정
  2. 호출: syscall 명령어로 커널 진입
  3. 실행: 커널이 현재 프로세스를 /bin/sh로 교체
  4. 결과: 성공하면 리턴 없음, 실패하면 -1 리턴

왜 어셈블리가 더 복잡할까?

C 코드:

  • 컴파일러가 자동으로 시스템 콜 번호, 레지스터 할당 처리
  • 라이브러리 함수 호출

어셈블리:

  • 시스템 콜 번호(59) 직접 지정
  • 레지스터 수동 설정
  • 메모리 주소 직접 계산

실행 결과 비교

둘 다 같은 결과:

$ ./program
sh-5.1$   # 쉘이 실행됨

차이점은 구현 방식뿐, 최종 결과는 동일.


학습 포인트

  1. C는 추상화: 복잡한 부분을 컴파일러가 처리
  2. 어셈블리는 직접 제어: 모든 세부사항을 수동으로 관리
  3. 시스템 콜은 동일: 결국 같은 커널 함수 호출
  4. 성능 차이: 거의 없음 (컴파일 후 비슷한 코드 생성)

마무리

같은 기능을 C와 어셈블리로 구현해보면서 고급 언어의 편리함과 저급 언어의 정확한 제어를 모두 경험할 수 있음.

시스템 프로그래밍에서는 두 방식 모두 중요하며, 상황에 따라 적절한 선택을 하는 것이 필요함. 보안 연구나 임베디드 시스템에서는 어셈블리 수준의 이해가 필수적이고, 일반적인 애플리케이션 개발에서는 C 언어의 추상화가 더 효율적일수도 있음.