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

1. 들어가며

"서버에 좀비 프로세스가 수백 개 있는데 kill -9로도 안 지워집니다." 운영 현장에서 자주 듣는 질문이다. 좀비는 이미 죽은 프로세스이기 때문에 죽일 수 없다. 해결책은 좀비가 아니라 그 부모 에 있다.

이번 글에서는 04편에서 잠깐 본 좀비 프로세스를 직접 만들어 정리하고, 반대 상황인 고아 프로세스 가 어떻게 PID 1에 입양되는지 확인한다. 고아화는 공격 도구가 실행 출처를 숨기는 데 쓰는 기법이기도 해서 관제 관점에서도 중요하다.


2. 핵심 개념

2-1. 좀비와 고아 비교

구분좀비 (Zombie, Z)고아 (Orphan)
상황자식이 종료됐는데 부모가 wait()하지 않음부모가 먼저 종료되고 자식은 계속 실행
프로세스 상태이미 종료됨 (코드·메모리 해제)정상 실행 중
남은 것PID와 종료 상태(프로세스 테이블 항목)프로세스 전체
표시Z, <defunct>, 이름이 [ ]로 감싸짐PPID가 1(또는 subreaper)
kill로 제거불가가능
해결부모가 wait하게 하거나 부모를 종료필요 없음 (PID 1이 관리)

2-2. 왜 좀비 단계가 필요한가

자식이 끝나면 부모는 "어떻게 끝났는지(종료 코드, 시그널)"를 알아야 한다. 셸이 $?로 종료 코드를 보여 줄 수 있는 것도 이 덕분이다. 커널은 부모가 wait()로 이 정보를 가져갈 때까지 최소한의 기록만 남겨 두는데, 그 상태가 좀비다. 부모가 정상적으로 wait하면 좀비는 순간적으로만 존재한다.

2-3. subreaper

PID 1 대신 고아를 입양하도록 지정된 프로세스를 subreaper 라고 한다(prctl(PR_SET_CHILD_SUBREAPER)). 사용자별 systemd --user, 컨테이너 런타임, 일부 슈퍼바이저가 이 역할을 한다. 그래서 환경에 따라 고아의 PPID가 1이 아닐 수 있다.


3. 동작 원리

좀비(Zombie)와 고아(Orphan)는 반대 상황이다

[좀비]
부모 ──fork──► 자식 ──exit()──► Z (종료 정보만 남음)
부모가 wait() 하지 않음 → Z 유지
부모 종료 → 좀비가 PID 1 의 자식으로 넘어감 → PID 1 이 wait() → 소멸

[고아]
부모 ──fork──► 자식 (실행 중)
부모 exit() → 커널이 자식의 PPID 를 PID 1 (또는 subreaper) 로 변경
자식 종료 시 PID 1 이 wait() 로 회수 → 좀비가 남지 않음

두 상황의 결말이 모두 PID 1 로 이어진다는 점이 핵심이다. PID 1(systemd)은 입양한 자식을 항상 wait하도록 만들어져 있다.


4. 명령어 실습

# 1) 좀비 3개 만들기: 자식은 즉시 종료, 부모는 wait 없이 잠만 잔다
python3 -c 'import os,time
for i in range(3):
    if os.fork()==0: os._exit(i)
time.sleep(120)' &

# 2) 부모와 좀비 확인
ps -o pid,ppid,stat,cmd --ppid $PARENT -p $PARENT
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'

# 3) 좀비에 kill -9 → 변화 없음
kill -9 $(ps -eo pid=,stat= | awk '$2 ~ /^Z/{print $1}')

# 4) 부모를 종료 → 좀비가 사라짐
kill $PARENT; ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/' | wc -l

# 5) (Ubuntu) 고아 만들기: 부모(bash -c)가 먼저 끝난다
bash -c 'sleep 300 & echo "child PID=$!  parent(bash -c) PID=$$"; sleep 1'
ps -o pid,ppid,stat,cmd -p $C
pstree -ps $C
grep -E '^(PPid|NSpid)' /proc/$C/status

5. 실행 결과

실제 실행 결과 — Rocky Linux 9.8 · analyst@rocky9-lab — 좀비 프로세스 만들고 정리하기

실제 실행 결과 — Ubuntu 24.04.5 · analyst@ubuntu-lab — 고아 프로세스는 누가 입양하나

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

[analyst@rocky9-lab ~]$ python3 -c 'import os,time
> for i in range(3):
>     if os.fork()==0: os._exit(i)
> time.sleep(120)' &
[analyst@rocky9-lab ~]$ sleep 1; PARENT=$!; ps -o pid,ppid,stat,cmd --ppid $PARENT -p $PARENT
    PID    PPID STAT CMD
    356     333 S+   python3 -c import os,time for i in range(3):     if os.fork()==0: os._exit(i) time.sleep(
    358     356 Z+   [python3] <defunct>
    359     356 Z+   [python3] <defunct>
    360     356 Z+   [python3] <defunct>
[analyst@rocky9-lab ~]$ ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'
    358     356 Z+   [python3] <defunct>
    359     356 Z+   [python3] <defunct>
    360     356 Z+   [python3] <defunct>
[analyst@rocky9-lab ~]$ kill -9 $(ps -eo pid=,stat= | awk '$2 ~ /^Z/{print $1}'); sleep 0.5; ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'
    358     356 Z+   [python3] <defunct>
    359     356 Z+   [python3] <defunct>
    360     356 Z+   [python3] <defunct>
[analyst@rocky9-lab ~]$ kill $PARENT; sleep 0.5; ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/' | wc -l
0
analyst@ubuntu-lab:~$ bash -c 'sleep 300 & echo "child PID=$!  parent(bash -c) PID=$$"; sleep 1'
child PID=341  parent(bash -c) PID=340
analyst@ubuntu-lab:~$ C=$(pgrep -nx sleep); ps -o pid,ppid,stat,cmd -p $C
    PID    PPID STAT CMD
    341       1 S+   sleep 300
analyst@ubuntu-lab:~$ pstree -ps $C
systemd(1)───sleep(341)
analyst@ubuntu-lab:~$ grep -E '^(PPid|NSpid)' /proc/$C/status
PPid:	1
NSpid:	341
analyst@ubuntu-lab:~$ kill $C

6. 결과 해석

관찰의미
부모 356 S+, 자식 358·359·360 Z+ [python3] <defunct>자식 3개가 종료했지만 부모가 회수하지 않아 좀비가 되었다
좀비의 PPID가 모두 356좀비의 원인은 PPID가 가리키는 부모 다. 조사 대상은 356이다
kill -9 후에도 좀비 3개 그대로이미 종료된 프로세스는 시그널을 처리할 주체가 없다
부모 종료 후 좀비 수 0좀비가 PID 1로 넘어가 즉시 회수되었다
Ubuntu: child 341, parent 340bash -c가 sleep을 백그라운드로 띄우고 1초 뒤 종료했다
sleep 341의 PPID 1부모가 사라져 PID 1에 입양된 고아 다. 상태는 S+로 정상 실행 중이다
pstree -ps → systemd(1)───sleep(341)원래 부모(bash → sshd)와의 관계가 트리에서 완전히 사라졌다
NSpid: 341PID 네임스페이스가 하나라 번호가 하나만 보인다. 컨테이너라면 여러 값이 나온다

이 실습 환경에서는 SSH 세션의 고아가 PID 1에 입양되었다. 데스크톱 세션처럼 systemd --user가 subreaper로 동작하는 환경에서는 PPID가 사용자 systemd의 PID로 보일 수 있다.


7. 보안 관점

주제내용
출처 은닉공격자는 nohup cmd & 후 로그아웃하거나, 이중 fork(19편)로 일부러 고아가 되어 트리에서 웹 서버·셸과의 연결을 끊는다. 결과만 보면 "systemd가 실행한 프로세스"처럼 보인다
출처 복원고아가 된 뒤에는 PPID로 추적할 수 없다. auditd execve 로그(당시 ppid), 세션 ID(/proc/PID/sessionid), 사용자·시작 시각, cgroup(어느 서비스·세션 소속인지)으로 복원한다
좀비 누적 = DoS좀비는 PID를 점유한다. 버그 있는 서비스가 좀비를 계속 만들면 pid_max나 TasksMax에 도달해 새 프로세스를 만들 수 없게 된다
컨테이너 PID 1컨테이너에서 PID 1이 일반 애플리케이션이면 입양한 고아를 wait하지 않아 좀비가 쌓인다. tini 같은 init을 쓰는 이유다

8. 보안관제 관점

[Detection]  사용자 계정 프로세스인데 PPID=1, TTY=?, 시작 시각이 새벽
     ↓
[확인]       ps -o pid,ppid,sid,user,lstart,tty,args -ww -p <PID>
             cat /proc/<PID>/cgroup            ← 어느 session-N.scope 소속이었나
             cat /proc/<PID>/sessionid          ← 로그인 세션 번호
     ↓
[상관 분석]  journalctl _SYSTEMD_UNIT=sshd.service 에서 같은 세션 로그인 기록
             last / wtmp 에서 해당 시각 로그인 IP
     ↓
[판단]       로그인 → 백그라운드 실행 → 로그아웃 패턴 = 지속 실행 의도
     ↓
[Response]   프로세스 증거 수집 후 종료, 계정 비밀번호·키 점검
신호해석
좀비 다수 + 같은 PPID해당 부모 서비스의 버그. 재시작 대상
좀비의 부모가 모르는 프로세스악성 도구가 자식을 대량 생성 중일 수 있다
사용자 프로세스 PPID 1 + TTY 없음데몬화·고아화. 정상 서비스인지 목록과 대조

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

실수결과예방
좀비에 kill -9 반복효과 없음PPID 확인 → 부모 처리
부모가 중요 서비스인데 바로 kill서비스 중단좀비 수가 적으면 영향이 거의 없다. 점검 시간에 재시작
PPID 1이면 "시스템 프로세스"라고 판단고아화된 악성 프로세스를 놓침사용자·exe·cgroup·세션으로 확인
컨테이너 PID 1에 앱 직접 실행좀비 누적init 프로세스(tini, --init) 사용

10. 실습 체크리스트

[ ] wait 하지 않는 부모로 좀비 3개를 만들었다
[ ] 좀비의 PPID 로 원인 부모를 찾았다
[ ] kill -9 로 좀비가 사라지지 않는 것을 확인했다
[ ] 부모 종료 후 좀비가 회수되는 것을 확인했다
[ ] 부모가 먼저 끝난 고아의 PPID 가 1 이 되는 것을 확인했다
[ ] 고아화가 출처 은닉에 쓰이는 이유를 설명할 수 있다

11. 핵심 정리

  • 좀비 는 종료 후 회수를 기다리는 기록이고, 고아 는 부모를 잃고 실행 중인 프로세스다.
  • 좀비는 kill로 없앨 수 없다. PPID로 부모를 찾아 wait하게 하거나 부모를 종료한다.
  • 고아는 PID 1(또는 subreaper)이 입양하고, 종료 시 회수한다.
  • 고아가 되면 원래 부모와의 연결이 트리에서 사라진다. 공격자는 이를 출처 은닉에 쓴다.
  • 고아의 출처는 cgroup, sessionid, 감사 로그, 로그인 기록으로 복원한다.

12. 다음 편 예고

다음 글 「09. 프로세스 우선순위 (nice·renice)」 에서는 CPU를 여러 프로세스가 나눠 쓸 때 누가 더 많이 받는지를 정하는 nice 값을 다룬다. 같은 CPU 코어에서 nice 0과 nice 19 프로세스를 경쟁시켜 실제 CPU 배분 차이 를 측정하고, 일반 사용자가 우선순위를 올릴 수 없는 이유를 확인한다.


참고 자료


시리즈 이동

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

0개의 댓글