
서비스 · 프로세스 관리 30 / 50 · Part 3. systemd와 서비스
실습 환경: Rocky Linux 9.8 · Ubuntu 24.04.5 (systemd로 부팅한 Docker 격리 컨테이너, 테스트 계정analyst)
웹 서버에 원격 코드 실행 취약점이 터졌다. 공격자가 얻는 권한은 그 서비스가 실행 중이던 계정의 권한 이다. 서비스가 root로 돌고 있었다면 한 번의 취약점으로 서버 전체가 넘어가고, 전용 계정으로 돌고 있었다면 공격자는 그 계정의 작은 권한 안에 갇힌다.
Part 3의 마지막 글에서는 서비스 실행 계정을 정하는 방법을 정리한다. root 서비스와 DynamicUser= 서비스의 Capability(세분화된 root 권한) 를 비교하고, 일반 계정 서비스가 80번 포트를 열 수 있도록 필요한 권한 하나만 부여하는 방법을 실습한다.
「파일 · 권한 · 사용자 관리 40편(root 계정과 시스템 계정)」, 「45편(최소 권한 원칙)」에서 계정 관점의 최소 권한을 다뤘다. 이번 글은 서비스 실행 관점이다.
| 방식 | 설정 | 특징 |
|---|---|---|
| root | User= 생략 | 모든 권한. 꼭 필요한 경우만 |
| 기존 전용 계정 | User=nginx | 패키지가 만든 시스템 계정 (UID < 1000, nologin) |
| DynamicUser | DynamicUser=yes | 서비스 실행 중에만 존재하는 임시 UID. 파일 소유권이 남지 않는다 |
| nobody | User=nobody | 여러 서비스가 공유하면 서로 영향 → 권장하지 않음 |
root의 권한은 약 40개의 Capability 로 나뉜다. 필요한 것만 주면 root가 아니어도 특정 특권 작업을 할 수 있다.
| Capability | 허용하는 작업 |
|---|---|
CAP_NET_BIND_SERVICE | 1024 미만 포트 바인딩 |
CAP_NET_RAW | raw 소켓 (ping, 패킷 캡처) |
CAP_NET_ADMIN | 네트워크 설정 변경 |
CAP_SYS_ADMIN | 마운트 등 광범위한 관리 권한 (사실상 root) |
CAP_DAC_OVERRIDE | 파일 권한 무시하고 읽기·쓰기 |
CAP_SYS_PTRACE | 다른 프로세스 추적·메모리 접근 |
CAP_SETUID | UID 변경 |
| 설정 | 의미 |
|---|---|
AmbientCapabilities= | root가 아닌 서비스 프로세스에 부여할 Capability |
CapabilityBoundingSet= | 이 서비스 트리가 가질 수 있는 상한. root 서비스도 여기에 없는 권한은 못 쓴다 |
NoNewPrivileges=yes | SUID 실행 등으로 권한이 늘어나지 못하게 (38편) |
/proc/PID/status의 CapEff(유효 권한) 비트마스크로 확인한다. 11편의 SigCgt처럼 비트 N이 Capability 번호 N에 대응한다(CAP_NET_BIND_SERVICE = 10 → 0x400).

systemd (root, 모든 Capability)
→ fork → 서비스 자식
→ CapabilityBoundingSet 으로 상한 축소 (CAP_NET_BIND_SERVICE 만 남김)
→ setuid(nobody) (보통은 여기서 모든 권한 상실)
→ AmbientCapabilities 로 CAP_NET_BIND_SERVICE 유지
→ execve(python3) → CapEff = 0x400
→ bind(0.0.0.0:80) 성공 / 그 밖의 특권 작업은 모두 실패
DynamicUser=yes는 여기서 더 나아가, 서비스가 시작될 때 사용하지 않는 UID를 할당 하고 끝나면 반납한다. /etc/passwd에 계정이 추가되지 않고, 서비스가 남긴 파일이 다른 서비스와 섞이지 않는다.
# 1) 현재 서비스들의 실행 계정과 로그인 셸
ps -eo user,pid,cmd --sort=user | awk 'NR==1 || $3 !~ /^\[/' | head -14
getent passwd root analyst dbus nobody | cut -d: -f1,3,6,7
grep -E 'nologin|/bin/false' /etc/passwd | wc -l
grep -vE 'nologin|/bin/false' /etc/passwd | cut -d: -f1,7
# 2) root 서비스 vs DynamicUser 서비스의 UID·Capability
systemd-run --unit=lab-root -p Type=simple /bin/sleep 300
systemd-run --unit=lab-dyn -p DynamicUser=yes /bin/sleep 300
for u in lab-root lab-dyn; do P=$(systemctl show $u -p MainPID --value)
echo "$u PID=$P $(ps -o user= -p $P) $(grep -E '^(Uid|CapEff)' /proc/$P/status | tr '\t\n' ' ')"; done
id $(ps -o user= -p $(systemctl show lab-dyn -p MainPID --value))
# 3) 일반 계정으로 80번 포트: 권한 없이 → 실패 / Capability 하나 부여 → 성공
sysctl -w net.ipv4.ip_unprivileged_port_start=1024
systemd-run --unit=lab-web80 -p User=nobody /usr/bin/python3 -m http.server 80
systemctl is-active lab-web80; journalctl -u lab-web80 -o cat | grep -m1 -i permission
systemd-run --unit=lab-web80b -p User=nobody -p AmbientCapabilities=CAP_NET_BIND_SERVICE \
-p CapabilityBoundingSet=CAP_NET_BIND_SERVICE /usr/bin/python3 -m http.server 80
ss -tlnp | grep ':80 '; grep -E '^Cap(Eff|Bnd)' /proc/<PID>/status
Docker는 컨테이너의
ip_unprivileged_port_start를 0으로 설정해 모든 계정이 낮은 포트를 열 수 있게 한다. 실제 서버와 같은 조건을 만들기 위해 실습 전에 1024로 되돌렸다.



텍스트 원본(실제 출력):
[root@rocky9-lab ~]# ps -eo user,pid,cmd --sort=user | awk 'NR==1 || $3 !~ /^\[/' | grep -v -e 'ps -eo' -e awk | head -14
USER PID CMD
analyst 3289 /usr/bin/python3 /opt/labapp/app.py
dbus 41 /usr/bin/dbus-broker-launch --scope system --audit
dbus 46 dbus-broker --log 4 --controller 9 --machine-id eefc7636505d4b099e14a89497222b58 --max-bytes
root 1 /usr/sbin/init
root 21 /usr/lib/systemd/systemd-journald
root 31 sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups
root 32 /usr/lib/systemd/systemd-logind
root 38 /usr/sbin/crond -n
root 39 /sbin/agetty -o -p -- \u --noclear - linux
root 2396 /usr/sbin/anacron -s
root 2637 /usr/sbin/atd -f
root 3592 /usr/lib/systemd/systemd --user
root 3594 (sd-pam)
[root@rocky9-lab ~]# getent passwd root analyst dbus nobody | cut -d: -f1,3,6,7
root:0:/root:/bin/bash
analyst:1000:/home/analyst:/bin/bash
dbus:81:/:/usr/sbin/nologin
nobody:65534:/:/sbin/nologin
[root@rocky9-lab ~]# grep -E 'nologin|/bin/false' /etc/passwd | wc -l; grep -vE 'nologin|/bin/false' /etc/passwd | cut -d: -f1,7
13
root:/bin/bash
sync:/bin/sync
shutdown:/sbin/shutdown
halt:/sbin/halt
analyst:/bin/bash
[root@rocky9-lab ~]# systemd-run --unit=lab-root -p Type=simple /bin/sleep 300
Running as unit: lab-root.service
[root@rocky9-lab ~]# systemd-run --unit=lab-dyn -p DynamicUser=yes /bin/sleep 300
Running as unit: lab-dyn.service
[root@rocky9-lab ~]# sleep 1; for u in lab-root lab-dyn; do P=$(systemctl show $u -p MainPID --value); echo "$u PID=$P $(ps -o user= -p $P) $(grep -E '^(Uid|CapEff)' /proc/$P/status | tr '\t\n' ' ')"; done
lab-root PID=3808 root Uid: 0 0 0 0 CapEff: 000001fffeffffff
lab-dyn PID=3810 lab-dyn Uid: 63399 63399 63399 63399 CapEff: 0000000000000000
[root@rocky9-lab ~]# id $(ps -o user= -p $(systemctl show lab-dyn -p MainPID --value))
uid=63399(lab-dyn) gid=63399(lab-dyn) groups=63399(lab-dyn)
[root@rocky9-lab ~]# sysctl -w net.ipv4.ip_unprivileged_port_start=1024
net.ipv4.ip_unprivileged_port_start = 1024
[root@rocky9-lab ~]# systemd-run --unit=lab-web80 -p User=nobody /usr/bin/python3 -m http.server 80; sleep 1.5; systemctl is-active lab-web80; journalctl -u lab-web80 -o cat --no-pager | grep -m1 -i 'permission'
Running as unit: lab-web80.service
failed
PermissionError: [Errno 13] Permission denied
[root@rocky9-lab ~]# systemd-run --unit=lab-web80b -p User=nobody -p AmbientCapabilities=CAP_NET_BIND_SERVICE -p CapabilityBoundingSet=CAP_NET_BIND_SERVICE /usr/bin/python3 -m http.server 80; sleep 1.5
Running as unit: lab-web80b.service
[root@rocky9-lab ~]# ss -tlnp | grep ':80 '; P=$(systemctl show lab-web80b -p MainPID --value); ps -o user=,cmd= -p $P; grep -E '^Cap(Eff|Bnd)' /proc/$P/status
LISTEN 0 5 0.0.0.0:80 0.0.0.0:* users:(("python3",pid=3868,fd=3))
nobody /usr/bin/python3 -m http.server 80
CapEff: 0000000000000400
CapBnd: 0000000000000400
| 관찰 | 의미 |
|---|---|
analyst 3289 python3 /opt/labapp/app.py | 25편에서 User=analyst로 등록한 서비스는 root가 아니다 |
| 대부분의 데몬(journald, sshd, crond, logind)은 root | 시스템 관리 기능이라 root가 필요한 서비스들이다. 이들은 대신 Capability·샌드박스로 좁힌다 |
dbus UID 81, /usr/sbin/nologin | 패키지가 만든 시스템 계정. 로그인할 수 없다 |
| nologin·false 계정 13개, 로그인 가능 계정은 root·analyst 등 | sync/shutdown/halt는 특수 목적 셸이다. 로그인 가능한 계정 목록이 짧을수록 좋다 |
lab-root Uid 0, CapEff: 000001fffeffffff | 사실상 모든 Capability를 가진 root 프로세스 |
lab-dyn lab-dyn, Uid 63399, CapEff: 0 | 실행 시 할당된 동적 UID, 권한 없음 |
id lab-dyn → uid=63399(lab-dyn) | /etc/passwd에 없지만 systemd가 NSS로 이름을 제공한다. 서비스가 멈추면 사라진다 |
lab-web80 → failed, PermissionError: [Errno 13] | 일반 계정은 80번 포트를 열 수 없다 |
lab-web80b → 0.0.0.0:80 LISTEN, user nobody | Capability 하나만 받아 포트를 열었다 |
CapEff: 0x400, CapBnd: 0x400 | 유효 권한과 상한 모두 비트 10(CAP_NET_BIND_SERVICE) 하나뿐 이다 |
| 주제 | 내용 |
|---|---|
| 피해 범위 | 웹 서비스가 root면 RCE = root 탈취, CapEff 0x400 계정이면 공격자는 포트 바인딩 외에 특권이 없다 |
| 위험한 Capability | CAP_SYS_ADMIN, CAP_SYS_PTRACE, CAP_DAC_OVERRIDE, CAP_SETUID를 가진 비root 프로세스는 사실상 root로 올라갈 수 있다. 점검 시 우선 확인 |
| 파일 Capability | 실행 파일에도 Capability를 붙일 수 있다(setcap, getcap -r /). SUID처럼 권한 상승 경로가 되므로 점검 대상이다 (파일·권한 시리즈 47편과 함께) |
| 서비스 계정 로그인 | 서비스 계정에 로그인 셸이 있으면 비밀번호·키 설정만으로 대화형 접근 이 가능해진다. nologin 유지 |
| DynamicUser | 공격자가 서비스 계정 권한으로 만든 파일이 서비스 종료 후 주인 없는 UID 로 남는다. find / -nouser로 흔적을 찾을 수 있다 |
[점검 1] root 로 실행 중인 서비스 목록
ps -eo user=,comm= | awk '$1=="root"' | sort -u
[점검 2] root 가 아닌데 Capability 를 가진 프로세스
for s in /proc/[0-9]*/status; do
awk '/^Name/{n=$2} /^Uid/{u=$2} /^CapEff/{c=$2} END{if(u!=0 && c!="0000000000000000") print n, u, c}' $s
done 2>/dev/null
[점검 3] 로그인 가능한 계정 · 서비스 계정 셸
awk -F: '$7 !~ /(nologin|false)$/ {print $1, $3, $7}' /etc/passwd
[점검 4] 파일 Capability
getcap -r / 2>/dev/null
↓
[판단] 업무상 필요 없는 root 서비스 · 불필요한 Capability · 셸이 있는 서비스 계정 → 개선 권고
Capability 비트마스크는 capsh --decode=0000000000000400으로 이름을 확인할 수 있다(libcap 패키지).
| 실수 | 결과 | 예방 |
|---|---|---|
| 80/443을 쓰려고 서비스를 root로 실행 | 불필요한 전체 권한 | AmbientCapabilities=CAP_NET_BIND_SERVICE 또는 리버스 프록시 |
여러 서비스가 nobody 공유 | 한 서비스 탈취가 다른 서비스 파일에 영향 | 서비스별 계정 또는 DynamicUser |
| DynamicUser 서비스에 고정 경로 쓰기 | 권한 오류 | StateDirectory=, CacheDirectory= 사용 |
| Capability를 root 서비스에서도 제한 안 함 | 모든 권한 유지 | CapabilityBoundingSet=으로 상한 설정 |
| 컨테이너 환경 결과를 서버에 그대로 적용 | 낮은 포트 허용 등 조건 차이 | sysctl 차이 확인 |
[ ] 서비스별 실행 계정을 ps 로 확인했다
[ ] 시스템 계정의 nologin 셸과 로그인 가능 계정을 구분했다
[ ] root 서비스와 DynamicUser 서비스의 Uid·CapEff 를 비교했다
[ ] 일반 계정 서비스가 80번 포트에서 Permission denied 로 실패하는 것을 확인했다
[ ] AmbientCapabilities 로 CAP_NET_BIND_SERVICE 하나만 부여해 성공했다
[ ] CapEff 비트마스크 0x400 이 Capability 10 번임을 해석했다
User=를 생략하면 root다.AmbientCapabilities=로 줄 수 있다.CapabilityBoundingSet=은 상한, DynamicUser=yes는 실행 중에만 존재하는 임시 계정이다.Part 4 「로그·스케줄링·운영」을 시작하는 「31. journalctl로 서비스 로그 분석」 에서는 지금까지 여러 번 쓴 journalctl을 본격적으로 다룬다. unit·시간·우선순위·필드로 로그를 거르는 방법과, SSH 인증 실패·sudo 사용 같은 보안 이벤트 를 journal에서 뽑아내는 실습을 한다.