
서비스 · 프로세스 관리 19 / 50 · Part 2. 시그널·자원·세션
실습 환경: Rocky Linux 9.8 · Ubuntu 24.04.5 (systemd로 부팅한 Docker 격리 컨테이너, 테스트 계정analyst)
ps -ef를 실행하면 TTY 열이 pts/0인 프로세스와 ?인 프로세스가 섞여 있다. ?는 제어 터미널이 없는 프로세스, 대부분은 데몬이다. sshd, crond, journald 같은 시스템 데몬이 여기에 속한다. 그런데 공격자가 남긴 백도어도 같은 모습이다.
이번 글에서는 10편에서 잠깐 본 세션(SID)·프로세스 그룹(PGID)·제어 터미널을 정리하고, 전통적인 데몬화(fork → setsid → fork) 를 직접 구현한다. 그리고 시스템 데몬과 사용자가 만든 데몬을 구분하는 기준을 세운다.
세션 (SID) ← 로그인 한 번 = 세션 하나, 제어 터미널 하나
└─ 프로세스 그룹 (PGID) ← 셸의 작업(job) 하나, 파이프라인 하나
└─ 프로세스 (PID)
| 용어 | 의미 | 확인 |
|---|---|---|
| 세션 리더 | 세션을 만든 프로세스 (SID = PID) | STAT의 s |
| 제어 터미널 | 세션에 연결된 터미널 | ps -o tty, tty |
| 포그라운드 그룹 | 터미널 입력·Ctrl+C를 받는 그룹 | ps -o tpgid, STAT의 + |
| 감사 세션 ID | 로그인 시 PAM이 부여하는 번호 | /proc/PID/sessionid |
| logind 세션 | systemd-logind가 관리하는 세션 | loginctl list-sessions |
| 조건 | 이유 | 구현 |
|---|---|---|
| 제어 터미널 없음 | 로그아웃 SIGHUP, 터미널 입력의 영향을 받지 않기 위해 | setsid() |
| 세션 리더가 아님 | 실수로 터미널을 열어도 제어 터미널이 되지 않게 | setsid 후 한 번 더 fork |
| 부모는 init | 부모 셸이 기다리지 않게 | 부모 즉시 exit → 고아화 |
작업 디렉터리 / | 마운트 해제를 막지 않기 위해 | chdir("/") |
| 표준 입출력 분리 | 닫힌 터미널에 쓰다 오류 나지 않게 | fd 0·1·2 → /dev/null |
| (umask 설정) | 파일 권한 예측 가능 | umask(0) 또는 지정 값 |

셸 ─fork─► python(A)
A: fork() → 부모 A 는 exit ← 셸은 즉시 프롬프트 복귀
자식 B: setsid() ← 새 세션·새 그룹의 리더, 터미널 없음
fork() → B 는 exit
손자 C: chdir("/"), fd → /dev/null, 본 작업 실행
C: PPID 1 (B 가 죽어 고아), SID = B 의 PID, 세션 리더 아님
systemd 이전에는 모든 데몬이 이 절차를 스스로 수행했다. 지금은 systemd가 서비스를 시작할 때 이미 새 세션·빈 표준 입출력·cgroup을 준비해 주므로, 서비스 프로그램은 포그라운드로 그냥 실행 하면 된다(Type=simple, 25편).
# 1) 세션·그룹·터미널 확인
tty; echo "셸 PID=$$"
set -m; sleep 100 | cat & # 파이프라인 하나 = 프로세스 그룹 하나
ps -o pid,ppid,pgid,sid,tty,tpgid,stat,cmd -t $(tty | sed 's#/dev/##')
cat /proc/$$/sessionid; echo
loginctl list-sessions --no-pager
# 2) 데몬 만들기: fork → setsid → fork
python3 -c 'import os,sys,time
if os.fork(): sys.exit(0)
os.setsid()
if os.fork(): sys.exit(0)
os.chdir("/"); fd=os.open("/dev/null",os.O_RDWR); [os.dup2(fd,i) for i in (0,1,2)]
time.sleep(120)'
D=$(pgrep -n -u analyst python3); ps -o pid,ppid,pgid,sid,tty,stat,cmd -p $D
ls -l /proc/$D/cwd /proc/$D/fd
# 3) (Ubuntu) 시스템 전체의 터미널 없는 프로세스
ps -eo tty= | sort | uniq -c
ps -eo pid,ppid,sid,tty,user,cmd --sort=sid | awk '$4=="?" && $2==1' | head -8



텍스트 원본(실제 출력):
[analyst@rocky9-lab ~]$ tty; echo "셸 PID=$$"
/dev/pts/0
셸 PID=1701
[analyst@rocky9-lab ~]$ set -m; sleep 100 | cat & sleep 0.2
[analyst@rocky9-lab ~]$ ps -o pid,ppid,pgid,sid,tty,tpgid,stat,cmd -t $(tty | sed 's#/dev/##')
PID PPID PGID SID TT TPGID STAT CMD
1701 1700 1701 1701 pts/0 1732 Ss bash /tmp/run_19_1.sh
1726 1701 1726 1701 pts/0 1732 S sleep 100
1727 1701 1726 1701 pts/0 1732 S cat
1732 1701 1732 1701 pts/0 1732 R+ ps -o pid,ppid,pgid,sid,tty,tpgid,stat,cmd -t pts/0
[analyst@rocky9-lab ~]$ cat /proc/$$/sessionid; echo
68
[analyst@rocky9-lab ~]$ loginctl list-sessions --no-pager
SESSION UID USER SEAT TTY STATE IDLE SINCE
68 1000 analyst pts/0 active no
1 sessions listed.
[analyst@rocky9-lab ~]$ kill %1
[analyst@rocky9-lab ~]$ python3 -c 'import os,sys,time
> if os.fork(): sys.exit(0)
> os.setsid()
> if os.fork(): sys.exit(0)
> os.chdir("/"); fd=os.open("/dev/null",os.O_RDWR); [os.dup2(fd,i) for i in (0,1,2)]
> time.sleep(120)'
[analyst@rocky9-lab ~]$ sleep 0.5; D=$(pgrep -n -u analyst python3); ps -o pid,ppid,pgid,sid,tty,stat,cmd -p $D
PID PPID PGID SID TT STAT CMD
1771 1 1770 1770 ? S python3 -c import os,sys,time if os.fork(): sys.exit(0) os.setsi
[analyst@rocky9-lab ~]$ ls -l /proc/$D/cwd /proc/$D/fd | grep -v total
lrwxrwxrwx 1 analyst analyst 0 Sep 24 11:51 /proc/1771/cwd -> /
/proc/1771/fd:
lrwx------ 1 analyst analyst 64 Sep 24 11:51 0 -> /dev/null
lrwx------ 1 analyst analyst 64 Sep 24 11:51 1 -> /dev/null
lrwx------ 1 analyst analyst 64 Sep 24 11:51 2 -> /dev/null
lrwx------ 1 analyst analyst 64 Sep 24 11:51 3 -> /dev/null
[analyst@rocky9-lab ~]$ kill $D
root@ubuntu-lab:~# ps -eo tty= | sort | uniq -c
12 ?
4 pts/0
root@ubuntu-lab:~# ps -eo pid,ppid,sid,tty,user,cmd --sort=sid | awk '$4=="?" && $2==1' | head -8
25 1 25 ? root /usr/lib/systemd/systemd-journald
67 1 67 ? systemd+ /usr/lib/systemd/systemd-resolved
80 1 80 ? root /usr/sbin/cron -f -P
81 1 81 ? message+ @dbus-daemon --system --address=systemd: --nofork --nopidfile --syst
84 1 84 ? root /usr/lib/systemd/systemd-logind
86 1 86 ? daemon /usr/sbin/atd -f
94 1 94 ? root (agetty)
95 1 95 ? root sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups
| 관찰 | 의미 |
|---|---|
tty → /dev/pts/0 | SSH가 할당한 가상 터미널(pseudo terminal) |
bash PGID 1701 = SID 1701, STAT Ss | 셸이 세션 리더이자 자기 그룹의 리더 |
sleep 1726, cat 1727 모두 PGID 1726 | 파이프라인의 두 프로세스가 하나의 그룹 이다. Ctrl+C를 누르면 둘 다 받는다 |
TPGID 1732 = ps의 PGID, STAT R+ | 지금 포그라운드는 ps다. sleep·cat은 백그라운드라 +가 없다 |
/proc/$$/sessionid = 68, loginctl SESSION 68 | 커널 감사 세션 ID와 logind 세션 번호가 같다. 인증 로그·감사 로그를 연결하는 키다 |
데몬 1771: PPID 1, PGID 1770, SID 1770, TTY ? | 새 세션(1770)에 속하지만 리더(1770)는 이미 종료 했다. 1771은 세션 리더가 아니라 터미널을 다시 얻을 수 없다 |
STAT S (s·+ 없음) | 세션 리더도 포그라운드도 아닌 순수 백그라운드 프로세스 |
cwd -> /, fd 0·1·2 → /dev/null | 데몬 정리 단계가 적용되었다. fd 3은 /dev/null을 연 원본 fd다 |
Ubuntu: ? 12개, pts/0 4개 | 서버 프로세스 대부분은 터미널 없는 데몬이다 |
시스템 데몬들: PPID 1, SID = 자기 PID | systemd가 시작한 서비스는 각자 자기 세션의 리더 로 실행된다 |
시스템 서비스(
SID = PID, PPID 1, cgroup이.service)와 double fork 데몬(SID ≠ PID, PPID 1, cgroup이session-N.scope)은 이 세 값으로 구분된다.
| 주제 | 내용 |
|---|---|
| 백도어의 데몬화 | 공격 도구는 double fork로 터미널·부모와 관계를 끊고 PPID 1로 숨는다. 모습은 시스템 데몬과 비슷하다 |
| 구분 기준 | 사용자 계정 소유 · TTY ? · PPID 1 · SID ≠ PID · cgroup이 session-N.scope 또는 웹 서비스 → 사용자가 수동으로 데몬화한 프로세스 |
| 감사 세션 ID | /proc/PID/sessionid는 데몬화해도 로그인 당시 값이 유지 된다. auditd 로그의 ses= 필드와 연결하면 어느 로그인에서 나왔는지 알 수 있다 |
| 터미널 없는 셸 | TTY ?인 bash -i, sh -i는 대화형 셸인데 터미널이 없다는 뜻 → 리버스 셸 전형 (44편) |
[Detection] 점검: 사용자 계정 소유 · TTY ? · PPID 1 프로세스
ps -eo pid,ppid,sid,user,tty,lstart,args -ww | awk '$2==1 && $5=="?" && $4!="root"'
↓
[분류] cat /proc/PID/cgroup
→ *.service : 시스템이 관리하는 서비스 (정상 가능성)
→ session-N.scope : 로그인 세션에서 수동 데몬화
↓
[추적] cat /proc/PID/sessionid → N
ausearch --session N / journalctl 의 세션 N 로그인 기록
↓
[Response] 업무 목적 확인 → 불명확하면 증거 수집 후 종료, 계정 점검
| 필드 | 정상 서비스 | 수동 데몬화 |
|---|---|---|
| PPID | 1 | 1 |
| SID | = PID | ≠ PID (리더 종료) |
| cgroup | xxx.service | session-N.scope |
| sessionid | 4294967295 (로그인 없음) | 로그인 세션 번호 |
| 실수 | 결과 | 예방 |
|---|---|---|
| systemd 서비스에서 프로그램이 스스로 데몬화 | systemd가 메인 PID를 잃어 "종료됨"으로 판단 | Type=simple로 포그라운드 실행, 또는 Type=forking + PIDFile= |
TTY ?면 모두 시스템 데몬이라고 판단 | 수동 데몬화 백도어를 놓침 | 계정·SID·cgroup 확인 |
| 데몬의 표준 출력을 닫지 않음 | 로그아웃 후 쓰기 오류(EIO)로 종료 | /dev/null 또는 로그 파일로 |
| 데몬이 cwd를 유지 | 해당 파일 시스템 umount 불가 | chdir("/") |
[ ] PID, PGID, SID, TTY, TPGID 를 한 화면에서 읽었다
[ ] 파이프라인이 하나의 프로세스 그룹인 것을 확인했다
[ ] /proc/PID/sessionid 와 loginctl 세션 번호를 대조했다
[ ] fork → setsid → fork 로 데몬을 만들었다
[ ] 데몬의 PPID 1, TTY ?, SID ≠ PID, cwd /, fd /dev/null 을 확인했다
[ ] 시스템 서비스와 수동 데몬을 SID·cgroup 으로 구분할 수 있다
/proc/PID/sessionid는 데몬화 후에도 남아 로그인 기록과 연결하는 단서가 된다.Part 2의 마지막 글 「20. 메모리 부족과 OOM Killer」 에서는 메모리가 부족할 때 커널이 프로세스를 골라 강제 종료하는 OOM Killer를 다룬다. oom_score·oom_score_adj로 누가 먼저 죽는지 확인하고, 메모리 한도를 넘긴 서비스가 OOM Killer에 종료되는 과정을 로그로 추적한다.