서비스 · 프로세스 관리 02 / 50 · Part 1. 프로세스 기초
실습 환경: Rocky Linux 9.8 · Ubuntu 24.04.5 (systemd로 부팅한 Docker 격리 컨테이너, 테스트 계정 analyst)

1. 들어가며

01편에서 프로세스는 "실행 중인 프로그램의 인스턴스"라고 정리했다. 그렇다면 셸에서 ls를 입력했을 때 새 프로세스는 정확히 어떻게 만들어질까? Linux는 이 일을 두 단계로 나눈다. 먼저 fork()로 자신을 복제하고, 그 복제본이 execve()로 다른 프로그램이 된다.

이번 글에서는 이 두 시스템 콜을 strace로 직접 추적 하고, bash의 exec 빌트인이 PID를 바꾸지 않는다는 점을 확인한다. 이 원리를 알아야 auditd·EDR 로그에서 부모-자식 관계와 실행 이력을 올바르게 읽을 수 있다.

「리눅스 시스템 기초 5편(System Call)」과 「35편(Parent / Child Process)」에서 개념을 소개했다. 이번 글은 실제 시스템 콜 흐름을 추적하는 데 집중한다.


2. 핵심 개념

2-1. 프로세스 생성 관련 시스템 콜

시스템 콜하는 일반환값 / 특징
fork()호출한 프로세스를 복제해 자식을 만든다부모에게는 자식 PID, 자식에게는 0을 반환
clone() / clone3()fork의 일반화. 무엇을 공유할지 플래그로 지정glibc의 fork()는 내부적으로 clone()을 호출
vfork()메모리 복사 없이 부모를 멈추고 자식이 바로 exec오래된 최적화 방식
execve()현재 프로세스의 프로그램을 교체성공하면 돌아오지 않는다. PID 유지
exit()프로세스 종료, 종료 코드 남김부모에게 SIGCHLD 전달
wait4() / waitpid()부모가 자식의 종료 상태를 회수회수 전까지 자식은 좀비(Z)

2-2. 왜 두 단계로 나누었나

fork와 exec를 분리하면 exec 직전에 자식 프로세스의 환경을 바꿀 수 있다. 셸의 리다이렉션과 파이프가 이렇게 동작한다.

fork() → [자식] fd 1을 파일로 교체 (dup2) → execve("ls")
         → ls는 자기 출력이 파일로 간다는 사실을 모른 채 실행된다

ls > out.txt에서 ls가 파일을 여는 것이 아니라, 셸이 fork한 자식에서 미리 fd를 바꿔 둔 뒤 exec 하는 것이다.

2-3. Copy-on-Write

fork는 부모의 메모리를 통째로 복사하지 않는다. 부모와 자식이 같은 물리 페이지를 읽기 전용으로 공유 하다가, 어느 한쪽이 쓰기를 시도할 때만 그 페이지를 복사한다(COW). 곧바로 exec할 자식에게 메모리를 복사하는 낭비를 피하는 구조다.


3. 동작 원리

셸이 명령어를 실행하는 과정: fork → execve → exit → wait

bash (PID 250)
  │ clone(SIGCHLD)                     ← fork: 새 PID 251 생성
  ├──────────────► 자식 (PID 251, bash 코드)
  │                  │ execve("/usr/bin/ls")   ← 프로그램 교체, PID 251 유지
  │                  │ ls 실행 → 출력 "/tmp"
  │                  │ exit(0)
  │ wait4(-1) ◄──── SIGCHLD + 종료 상태 0
  ▼
bash가 다음 명령(echo done) 실행 — echo는 빌트인이라 fork 없음

마지막 echo done에서 clone이 기록되지 않는 점도 눈여겨보자. echo, cd, export 같은 셸 빌트인은 fork 없이 셸 프로세스 안에서 처리 된다. cd가 외부 프로그램이면 자식의 작업 디렉터리만 바뀌고 부모 셸은 그대로이기 때문이다.


4. 명령어 실습

# 1) strace로 fork(clone)와 execve 추적
#    -f : 자식 프로세스까지 추적, -e trace= : 볼 시스템 콜만 선택, -o : 파일로 저장
strace -f -e trace=clone,clone3,execve,wait4 -o /tmp/tr.txt bash -c 'ls -d /tmp; echo done'
cat /tmp/tr.txt

# 2) exec 빌트인: 셸을 다른 프로그램으로 교체 (PID 유지)
bash -c 'echo "bash   PID=$$"; exec python3 -c "import os; print(\"python PID=\", os.getpid(), \"PPID=\", os.getppid())"'

# 3) 비교: exec 없이 실행
bash -c 'echo "bash   PID=$$"; python3 -c "import os; print(\"python PID=\", os.getpid(), \"PPID=\", os.getppid())"'

# 4) (Ubuntu) Python os.fork()로 fork → exec → wait 직접 구현
python3 -c 'import os
pid=os.fork()
if pid==0:
    print("child : ..."); os.execvp("ls", ["ls","-d","/etc"])
else:
    print("parent: ..."); r=os.waitpid(pid,0); print("parent: child exit status=...")'

strace는 다른 사용자의 프로세스에 붙을 수 없고(ptrace 제한), 운영 서버에서 장시간 붙여 두면 대상 프로세스가 크게 느려진다. 실습 이외의 사용은 짧게, 승인 후에 한다.


5. 실행 결과

실제 실행 결과 — Rocky Linux 9.8 · analyst@rocky9-lab — strace로 본 fork(clone)와 execve

실제 실행 결과 — Rocky Linux 9.8 · analyst@rocky9-lab — exec는 PID를 바꾸지 않는다

실제 실행 결과 — Ubuntu 24.04.5 · analyst@ubuntu-lab — fork 직후 부모와 자식 (Python os.fork)

텍스트 원본(실제 출력):

[analyst@rocky9-lab ~]$ strace -f -e trace=clone,clone3,execve,wait4 -o /tmp/tr.txt bash -c 'ls -d /tmp; echo done' ; cat /tmp/tr.txt
/tmp
done
250   execve("/usr/bin/bash", ["bash", "-c", "ls -d /tmp; echo done"], 0x7ffd75c19bd8 /* 29 vars */) = 0
250   clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f91056c1a10) = 251
250   wait4(-1,  <unfinished ...>
251   execve("/usr/bin/ls", ["ls", "-d", "/tmp"], 0x55cd47839100 /* 29 vars */) = 0
251   +++ exited with 0 +++
250   <... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 251
250   --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=251, si_uid=1000, si_status=0, si_utime=0, si_stime=0} ---
250   wait4(-1, 0x7fff61f5e490, WNOHANG, NULL) = -1 ECHILD (No child processes)
250   +++ exited with 0 +++
[analyst@rocky9-lab ~]$ bash -c 'echo "bash   PID=$$"; exec python3 -c "import os; print(\"python PID=\", os.getpid(), \"PPID=\", os.getppid())"'
bash   PID=292
python PID= 292 PPID= 269
[analyst@rocky9-lab ~]$ bash -c 'echo "bash   PID=$$"; python3 -c "import os; print(\"python PID=\", os.getpid(), \"PPID=\", os.getppid())"'
bash   PID=293
python PID= 293 PPID= 269
analyst@ubuntu-lab:~$ python3 -c 'import os,time
> pid=os.fork()
> if pid==0:
>     print("child : pid=%d ppid=%d" % (os.getpid(), os.getppid())); os.execvp("ls", ["ls","-d","/etc"])
> else:
>     print("parent: pid=%d child=%d" % (os.getpid(), pid)); r=os.waitpid(pid,0); print("parent: child exit status=%d" % os.waitstatus_to_exitcode(r[1]))'
parent: pid=245 child=246
child : pid=246 ppid=245
/etc
parent: child exit status=0

6. 결과 해석

관찰의미
250 execve("/usr/bin/bash", ...)strace가 만든 프로세스가 먼저 bash로 exec된다
250 clone(... SIGCHLD ...) = 251bash가 fork(glibc → clone)로 자식 251 을 만들었다. SIGCHLD 플래그는 "자식이 끝나면 나에게 알려 달라"는 뜻
251 execve("/usr/bin/ls", ["ls","-d","/tmp"])자식 251이 같은 PID로 ls 프로그램이 되었다
250 wait4(-1, <unfinished ...> → resumed ... = 251부모는 자식이 끝날 때까지 기다렸다가 종료 상태(0)를 회수했다
echo done에 clone 없음echo는 빌트인이라 새 프로세스를 만들지 않는다
exec 사용: bash PID=292, python PID=292exec는 새 프로세스를 만들지 않고 셸 자체를 python으로 바꿨다
exec 없이: bash PID=293, python PID=293예상과 달리 이것도 PID가 같다. bash는 -c 문자열의 마지막 명령 을 fork 없이 exec하는 최적화를 한다
Python: parent 245 / child 246os.fork()가 부모에게는 자식 PID(246), 자식에게는 0을 돌려줘 분기가 갈렸다. 자식의 PPID는 245
child exit status=0부모가 waitpid로 회수한 종료 코드. 회수하지 않았다면 자식은 좀비로 남는다

3)의 결과는 흥미로운 함정이다. bash -c 'A; B'에서 B가 마지막 명령이면 bash는 fork를 생략하고 바로 exec한다. 따라서 "PID가 같다 = exec 빌트인을 썼다"로 단정하면 안 된다. 이런 차이를 확인하려면 strace처럼 실제 시스템 콜을 봐야 한다.


7. 보안 관점

주제내용
프로세스 체인공격자의 웹셸이 명령을 실행하면 httpd/nginx → sh → 명령어 형태의 fork·exec 체인이 생긴다. 부모가 웹 서버인 셸 은 대표적인 이상 징후다
exec로 흔적 줄이기exec로 셸을 다른 프로그램으로 바꾸면 중간 셸 프로세스가 트리에서 사라진다. 리버스 셸에서 exec를 쓰는 이유 중 하나다 (44편)
fork bomb함수가 자기 자신을 백그라운드로 무한히 fork하면 PID와 메모리가 고갈된다. ulimit -u, systemd TasksMax=, cgroup pids.max로 막는다 (17·18편)
감사 근거auditd의 execve 규칙은 exec 시점을 기록하고, ppid·pid 필드로 fork 관계를 복원한다 (48편)

8. 보안관제 관점

[Detection]  EDR 경보: 부모 프로세스 nginx(www-data) → 자식 /bin/sh -c "curl ... | sh"
     ↓
[확인]       ps -o pid,ppid,user,lstart,cmd -p <PID>,<PPID>
             pstree -ps <PID> 로 조상 체인 확인
     ↓
[판단]       웹 서버는 정상 동작 중 셸을 fork·exec할 이유가 거의 없다 → 웹셸/RCE 의심
     ↓
[Evidence]   execve 감사 로그(명령 전체 인자), 웹 접근 로그의 같은 시각 요청
     ↓
[Response]   해당 요청 IP 차단, 웹 애플리케이션 취약점 확인, 생성 파일 수집
관점내용
탐지 규칙의 기본 단위"어떤 부모가 어떤 자식을 exec했는가"(parent-child pair). 예: sshd → bash는 정상, java → bash는 조사 대상
PID 재사용짧게 사는 자식은 PID가 금방 재사용된다. 로그 상관 분석 시 PID + 시각을 함께 쓴다
빌트인은 로그가 없다cd, echo, export는 execve를 만들지 않아 execve 감사 로그에 남지 않는다. 셸 히스토리 등 다른 소스가 필요하다

9. 실무에서 자주 발생하는 실수

실수결과예방
strace에서 -f 생략자식의 execve가 안 보여 "아무것도 실행 안 됐다"고 오판자식까지 보려면 반드시 -f
PID가 같으면 같은 프로그램이라고 가정exec로 교체된 프로세스를 놓친다/proc/PID/exe와 execve 로그로 확인
운영 프로세스에 strace 장시간 연결서비스 지연·장애짧게, -e trace=로 범위를 좁혀서
부모가 wait하지 않는 프로그램 작성좀비 누적 (08편)waitpid 또는 SIGCHLD 처리
디렉터리 이동을 서브셸·자식 스크립트에서 실행부모 셸의 디렉터리가 안 바뀐다빌트인과 fork의 관계 이해, source 사용

10. 실습 체크리스트

[ ] strace -f 로 clone 과 execve 를 추적했다
[ ] clone 의 반환값이 자식 PID 라는 것을 확인했다
[ ] execve 후에도 PID 가 유지되는 것을 확인했다
[ ] echo 같은 빌트인은 fork 를 만들지 않는 것을 확인했다
[ ] exec 빌트인과 bash -c 마지막 명령 최적화를 구분했다
[ ] os.fork() 반환값으로 부모/자식 분기를 확인했다

11. 핵심 정리

  • 새 프로그램 실행 = fork(복제, 새 PID) + execve(교체, PID 유지) 의 두 단계다.
  • glibc의 fork()는 커널의 clone()으로 구현되며, fork는 Copy-on-Write로 메모리를 효율적으로 공유한다.
  • 부모는 wait4()로 자식의 종료 상태를 회수한다. 회수하지 않으면 좀비가 된다.
  • 셸 빌트인은 fork하지 않고, bash는 -c의 마지막 명령을 fork 없이 exec하기도 한다.
  • 보안관제에서 "부모 → 자식" 실행 관계는 웹셸·RCE 탐지의 기본 단위다.

12. 다음 편 예고

다음 글 「03. PID·PPID와 프로세스 트리」 에서는 fork가 만든 부모-자식 관계를 PID 1(systemd)까지 거슬러 올라가며 확인한다. pstree, ps --forest, /proc/PID/status의 PPid로 SSH 로그인 세션의 실제 조상 체인을 그려 본다.


참고 자료


시리즈 이동

profile
코드에 숨겨진 위협을 읽고 AI로 보안의 미래를 설계합니다. 프론트엔드 개발 경험을 자산 삼아 더 견고하고 지능적인 보안 운영 시스템을 구축해 나가는 과정을 기록합니다

0개의 댓글