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 배열의 규칙:
- argv[0]은 항상 프로그램 이름 (관례)
- argv[1]부터는 실제 인자들
- 마지막은 반드시 NULL로 끝
- 이 구조는 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)
시스템 콜 흐름
- 준비: 레지스터에 인자값 설정
- 호출: syscall 명령어로 커널 진입
- 실행: 커널이 현재 프로세스를 /bin/sh로 교체
- 결과: 성공하면 리턴 없음, 실패하면 -1 리턴
왜 어셈블리가 더 복잡할까?
C 코드:
- 컴파일러가 자동으로 시스템 콜 번호, 레지스터 할당 처리
- 라이브러리 함수 호출
어셈블리:
- 시스템 콜 번호(59) 직접 지정
- 레지스터 수동 설정
- 메모리 주소 직접 계산
실행 결과 비교
둘 다 같은 결과:
$ ./program
sh-5.1$ # 쉘이 실행됨
차이점은 구현 방식뿐, 최종 결과는 동일.
학습 포인트
- C는 추상화: 복잡한 부분을 컴파일러가 처리
- 어셈블리는 직접 제어: 모든 세부사항을 수동으로 관리
- 시스템 콜은 동일: 결국 같은 커널 함수 호출
- 성능 차이: 거의 없음 (컴파일 후 비슷한 코드 생성)
마무리
같은 기능을 C와 어셈블리로 구현해보면서 고급 언어의 편리함과 저급 언어의 정확한 제어를 모두 경험할 수 있음.
시스템 프로그래밍에서는 두 방식 모두 중요하며, 상황에 따라 적절한 선택을 하는 것이 필요함. 보안 연구나 임베디드 시스템에서는 어셈블리 수준의 이해가 필수적이고, 일반적인 애플리케이션 개발에서는 C 언어의 추상화가 더 효율적일수도 있음.
'system' 카테고리의 다른 글
[시스템 프로그래밍] myshell.c 코드 분석 - 1 (1) | 2025.09.11 |
---|---|
[시스템 프로그래밍] Tiny Shell 프로젝트: 잡 컨트롤, 시그널, 레이스 컨디션 다루기 (0) | 2025.09.09 |
[시스템 프로그래밍] execve() 시스템 콜로 이해하는 프로세스 교체 원리 (0) | 2025.09.08 |
[시스템 프로그래밍] fork() 시스템 콜로 이해하는 프로세스 복제 원리 (0) | 2025.09.08 |
[시스템 보안] 버퍼 오버플로우 (0) | 2025.09.08 |