system call 호출 시 어떤 일이 일어날까?
일단 system call에 대하여 설명하기 전에, 우리는 커널(Kernel) - 운영체제에 대하여 알고있어야 합니다.
커널은 본질적으로 소프트웨어(main으로 시작하는 바이너리)입니다.
하지만, 모든 프로그램과는 다르게 커널은 메모리(CPU)에 항상 올라가 있다는 차이점이 있습니다. 커널이 아닌 프로그램들은 메모리에 있어도 상관없고, 없어도 됩니다. 필요할 때 마다 메모리에 올려 사용하면 된다는 의미이죠.
또한, UNIX 운영체제는 다중 사용자 운영체제 입니다. 따라서, 보안성이 정말 중요합니다. 만약, 다른 사용자가 나의 문서를 삭제한다던가, 원하는 대로 내 문서를 훔처보지 못하도록 막는 것 입니다.
그래서 UNIX에서 실질적인 파일 I/O(Input/Oupt)는 커널밖에 하지 못합니다. 즉, 유저가 작성한 프로그램이 필요할 때 마다 커널에게 I/O 권한을 요청하는 것 입니다. 이 과정을 System call이라고 합니다.
특히, CPU(하드웨어)에는 현재 모드가 커널 모드인지, 유저 모드인지 판별해주는 하나의 비트가 있습니다. 이 비트를 mode bit라고 합니다. 모드비트가 커널모드라면 모든 메모리에 접근 할 수 있습니다. 하지만, 유저 모드라면 자기의 메모리만 접근 할 수 있는것입니다.
간단하게 stdin(입력)과 stdout(출력)에 값을 쓰는 프로그램을 하나 작성하여 System call이 어떻게 작동하는지 알아봅시다.
int main(void)
{
char buf[0x100] = {0,};
scanf("%s",&buf); // stdin
printf("Hello, World!\n"); // stdout
}
작성한 프로그램은 위와 같습니다.
Linux 커널 버전 등에 의해 사소한 작동은 달라 질 수 있겠지만, System call의 기본적인 작동 원리에 대하여 알아보는 작업이므로 구체적인 환경은 적지 않겠습니다.
먼저, 해당 프로그램에서 I/O를 사용하는 부분은 두가지 입니다.
scanf
를 통하여stdin
에 값을 씀 -> 후에 이 값을buf
변수에 넘김printf
를 통하여stdout
에 값을 씀 -> 문자열이 출력됨.
이렇게 두가지의 부분을 분석 해 봅시다. 그 전에, main함수의 어셈블리 코드를 확인 해 보았습니다.
pwndbg> disass main
Dump of assembler code for function main:
0x00000000004005f6 <+0>: push rbp
0x00000000004005f7 <+1>: mov rbp,rsp
0x00000000004005fa <+4>: sub rsp,0x110
0x0000000000400601 <+11>: mov rax,QWORD PTR fs:0x28
0x000000000040060a <+20>: mov QWORD PTR [rbp-0x8],rax
0x000000000040060e <+24>: xor eax,eax
0x0000000000400610 <+26>: lea rdx,[rbp-0x110]
0x0000000000400617 <+33>: mov eax,0x0
0x000000000040061c <+38>: mov ecx,0x20
0x0000000000400621 <+43>: mov rdi,rdx
0x0000000000400624 <+46>: rep stos QWORD PTR es:[rdi],rax
0x0000000000400627 <+49>: lea rax,[rbp-0x110]
0x000000000040062e <+56>: mov rsi,rax
0x0000000000400631 <+59>: mov edi,0x4006f4
0x0000000000400636 <+64>: mov eax,0x0
0x000000000040063b <+69>: call 0x4004e0 <__isoc99_scanf@plt>
0x0000000000400640 <+74>: mov edi,0x4006f7
0x0000000000400645 <+79>: call 0x4004b0 <puts@plt>
0x000000000040064a <+84>: mov eax,0x0
0x000000000040064f <+89>: mov rsi,QWORD PTR [rbp-0x8]
0x0000000000400653 <+93>: xor rsi,QWORD PTR fs:0x28
0x000000000040065c <+102>: je 0x400663 <main+109>
0x000000000040065e <+104>: call 0x4004c0 <__stack_chk_fail@plt>
0x0000000000400663 <+109>: leave
0x0000000000400664 <+110>: ret
첫번째로 눈에 띄는 점은 printf
가 아니라 puts
를 사용한다는 점 입니다. 분명 코드에는 printf
를 사용 했는데 말입니다.
그것은 바로 printf("123\n")
꼴의 함수를 컴파일러가 puts
로 인식하여 자동으로 최적화 해 준 것입니다. printf
함수는 스택(메모리)사용량이 큰 함수이기 때문입니다.
pwndbg> b*main+69
Breakpoint 1 at 0x40063b
pwndbg> b*main+79
Breakpoint 2 at 0x400645
각각의 함수에 breakpoint
를 걸고, 분석을 진행 해 보겠습니다.
scanf()
scanf에서는 내부적으로 _IO_vfscanf
함수를 호출합니다.
또한, 몇가지 루틴을 따라가면 _IO_new_file_underflow
함수를 호출합니다.
최종적으로는 __GI__IO_file_read
함수를 호출하게 됩니다. 이 함수가 바로 read
함수 입니다. read
함수는 system call을 사용하는 함수이고, 결국 scanf
는 리눅스 환경에서 이렇게 read
함수로 변경되어 동작한다 라는 것을 알 수 있습니다.
결국, 0번 fd(stdin)에 값을 쓰고, 그 값을 0x602010에 넘겨주는 작업이 시행됩니다. 이 작업은 우리가 작성했던 코드에서의 scanf
의 역할과 완벽히 일치합니다.
또, 여기서 rax
의 값이 0으로 세팅되었는데, 이 값을 syscall number
라고 합니다. 커널 내부에는 syscall table이 배열형태로 존재하는데, 여기서 해당 배열을 rax를 통해서 접근합니다.
각각에 인덱스에는 system call function
들이 매핑되어있고, 여기서 0번 인덱스는 read
함수 인 것입니다.
puts()
이번에는 puts입니다.
puts
함수는 내부적으로 _IO_new_file_xsputn
함수를 먼저 호출합니다.
그 후에는 _IO_new_file_overflow
함수가 호출됩니다.
최종적으로는 _IO_do_write
로 jmp하게 됩니다. 여기서 write
는 system call을 사용하는 함수입니다. read함수와 마찬가지로 syscall을 사용하게 된다는 것을 알 수 있습니다.
결론
scanf
나 puts
같이 stdin
,stdout
등 파일에 값을 쓰는 함수는 내부적으로 커널의 도움을 빌려서 syscall
을 사용합니다.
과정은 아래와 같습니다.
유저 프로그램이 system call을 호출함
mode bit가 커널 모드로 바뀜
함수의 syscall number 확인
syscall table에서 올바른 syscall number인지 검사
함수를 실행시켜 작업함
유저 프로그램의 영역으로 돌아가고, mode bit를 다시 유저 영역으로 바꿈
UNIX/LINUX
운영체제는 멀티 유저 시스템으로 작동하고 보안성이 몹시 중요하기때문에 파일에 값을 읽고 쓰는 행위는 오직 kernel
만이 할 수 있습니다.
따라서 syscall
을 통해 kernel
의 기능을 빌려 사용합니다.