
서비스 · 프로세스 관리 02 / 50 · Part 1. 프로세스 기초
실습 환경: Rocky Linux 9.8 · Ubuntu 24.04.5 (systemd로 부팅한 Docker 격리 컨테이너, 테스트 계정analyst)
01편에서 프로세스는 "실행 중인 프로그램의 인스턴스"라고 정리했다. 그렇다면 셸에서 ls를 입력했을 때 새 프로세스는 정확히 어떻게 만들어질까? Linux는 이 일을 두 단계로 나눈다. 먼저 fork()로 자신을 복제하고, 그 복제본이 execve()로 다른 프로그램이 된다.
이번 글에서는 이 두 시스템 콜을 strace로 직접 추적 하고, bash의 exec 빌트인이 PID를 바꾸지 않는다는 점을 확인한다. 이 원리를 알아야 auditd·EDR 로그에서 부모-자식 관계와 실행 이력을 올바르게 읽을 수 있다.
「리눅스 시스템 기초 5편(System Call)」과 「35편(Parent / Child Process)」에서 개념을 소개했다. 이번 글은 실제 시스템 콜 흐름을 추적하는 데 집중한다.
| 시스템 콜 | 하는 일 | 반환값 / 특징 |
|---|---|---|
fork() | 호출한 프로세스를 복제해 자식을 만든다 | 부모에게는 자식 PID, 자식에게는 0을 반환 |
clone() / clone3() | fork의 일반화. 무엇을 공유할지 플래그로 지정 | glibc의 fork()는 내부적으로 clone()을 호출 |
vfork() | 메모리 복사 없이 부모를 멈추고 자식이 바로 exec | 오래된 최적화 방식 |
execve() | 현재 프로세스의 프로그램을 교체 | 성공하면 돌아오지 않는다. PID 유지 |
exit() | 프로세스 종료, 종료 코드 남김 | 부모에게 SIGCHLD 전달 |
wait4() / waitpid() | 부모가 자식의 종료 상태를 회수 | 회수 전까지 자식은 좀비(Z) |
fork와 exec를 분리하면 exec 직전에 자식 프로세스의 환경을 바꿀 수 있다. 셸의 리다이렉션과 파이프가 이렇게 동작한다.
fork() → [자식] fd 1을 파일로 교체 (dup2) → execve("ls")
→ ls는 자기 출력이 파일로 간다는 사실을 모른 채 실행된다
ls > out.txt에서 ls가 파일을 여는 것이 아니라, 셸이 fork한 자식에서 미리 fd를 바꿔 둔 뒤 exec 하는 것이다.
fork는 부모의 메모리를 통째로 복사하지 않는다. 부모와 자식이 같은 물리 페이지를 읽기 전용으로 공유 하다가, 어느 한쪽이 쓰기를 시도할 때만 그 페이지를 복사한다(COW). 곧바로 exec할 자식에게 메모리를 복사하는 낭비를 피하는 구조다.

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가 외부 프로그램이면 자식의 작업 디렉터리만 바뀌고 부모 셸은 그대로이기 때문이다.
# 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 제한), 운영 서버에서 장시간 붙여 두면 대상 프로세스가 크게 느려진다. 실습 이외의 사용은 짧게, 승인 후에 한다.



텍스트 원본(실제 출력):
[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
| 관찰 | 의미 |
|---|---|
250 execve("/usr/bin/bash", ...) | strace가 만든 프로세스가 먼저 bash로 exec된다 |
250 clone(... SIGCHLD ...) = 251 | bash가 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=292 | exec는 새 프로세스를 만들지 않고 셸 자체를 python으로 바꿨다 |
exec 없이: bash PID=293, python PID=293 | 예상과 달리 이것도 PID가 같다. bash는 -c 문자열의 마지막 명령 을 fork 없이 exec하는 최적화를 한다 |
| Python: parent 245 / child 246 | os.fork()가 부모에게는 자식 PID(246), 자식에게는 0을 돌려줘 분기가 갈렸다. 자식의 PPID는 245 |
child exit status=0 | 부모가 waitpid로 회수한 종료 코드. 회수하지 않았다면 자식은 좀비로 남는다 |
3)의 결과는 흥미로운 함정이다.
bash -c 'A; B'에서 B가 마지막 명령이면 bash는 fork를 생략하고 바로 exec한다. 따라서 "PID가 같다 = exec 빌트인을 썼다"로 단정하면 안 된다. 이런 차이를 확인하려면 strace처럼 실제 시스템 콜을 봐야 한다.
| 주제 | 내용 |
|---|---|
| 프로세스 체인 | 공격자의 웹셸이 명령을 실행하면 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편) |
[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 감사 로그에 남지 않는다. 셸 히스토리 등 다른 소스가 필요하다 |
| 실수 | 결과 | 예방 |
|---|---|---|
strace에서 -f 생략 | 자식의 execve가 안 보여 "아무것도 실행 안 됐다"고 오판 | 자식까지 보려면 반드시 -f |
| PID가 같으면 같은 프로그램이라고 가정 | exec로 교체된 프로세스를 놓친다 | /proc/PID/exe와 execve 로그로 확인 |
| 운영 프로세스에 strace 장시간 연결 | 서비스 지연·장애 | 짧게, -e trace=로 범위를 좁혀서 |
부모가 wait하지 않는 프로그램 작성 | 좀비 누적 (08편) | waitpid 또는 SIGCHLD 처리 |
| 디렉터리 이동을 서브셸·자식 스크립트에서 실행 | 부모 셸의 디렉터리가 안 바뀐다 | 빌트인과 fork의 관계 이해, source 사용 |
[ ] strace -f 로 clone 과 execve 를 추적했다
[ ] clone 의 반환값이 자식 PID 라는 것을 확인했다
[ ] execve 후에도 PID 가 유지되는 것을 확인했다
[ ] echo 같은 빌트인은 fork 를 만들지 않는 것을 확인했다
[ ] exec 빌트인과 bash -c 마지막 명령 최적화를 구분했다
[ ] os.fork() 반환값으로 부모/자식 분기를 확인했다
fork()는 커널의 clone()으로 구현되며, fork는 Copy-on-Write로 메모리를 효율적으로 공유한다.wait4()로 자식의 종료 상태를 회수한다. 회수하지 않으면 좀비가 된다.-c의 마지막 명령을 fork 없이 exec하기도 한다.다음 글 「03. PID·PPID와 프로세스 트리」 에서는 fork가 만든 부모-자식 관계를 PID 1(systemd)까지 거슬러 올라가며 확인한다. pstree, ps --forest, /proc/PID/status의 PPid로 SSH 로그인 세션의 실제 조상 체인을 그려 본다.