유닛 파일에 User=를 넣었다고 서비스가 격리되지는 않는다. 그 서비스가 탈취되면 파일 시스템 대부분을 읽을 수 있고, 지정하지 않은 시스템 콜도 그대로 호출할 수 있다. systemd 서비스 유닛 파일 작성 실전 예제에서는 기본 보안 옵션을 소개했다. 그러나 어떤 옵션이 필요한지는 측정해야 알 수 있다.
이 글에서는 systemd-analyze security로 노출 점수를 측정한다. 그리고 원본 유닛을 고치지 않는 drop-in으로 점수를 9.6에서 0.9까지 낮춘다. 출력은 Ubuntu 24.04(systemd 255)에서 직접 실행한 결과다.
현재 서비스의 노출 점수
$ systemd-analyze security --no-pager | sort -k2 -n | head -6
UNIT EXPOSURE PREDICATE HAPPY
polkit.service 1.6 OK :-)
systemd-timesyncd.service 2.1 OK :-)
systemd-resolved.service 2.2 OK :-)
upower.service 2.4 OK :-)
systemd-networkd.service 2.6 OK :-)
$ systemd-analyze security --no-pager | grep -E '^(ssh|cron|rsyslog)'
cron.service 9.6 UNSAFE :-{
rsyslog.service 6.3 MEDIUM :-|
ssh.service 9.6 UNSAFE :-{
점수는 0(안전)~10(노출)이며, 취약점 수가 아니라 켜진 격리 옵션을 평가한다. ssh처럼 root 권한이 필수인 서비스는 점수가 높아도 정상이다.
측정용 서비스
시작할 때 자기 권한을 시험하는 서비스를 만든다.
#!/usr/bin/env python3
# 권한 범위를 확인하는 데모 서비스: 시작할 때 접근을 시도하고 HTTP를 연다
import http.server, os, socketserver
def probe(name, fn):
try:
fn(); print(f"{name}: OK", flush=True)
except OSError as e:
print(f"{name}: {type(e).__name__} {e.strerror}", flush=True)
probe("write /etc", lambda: open("/etc/sdemo-test", "w").close())
probe("read /root", lambda: os.listdir("/root"))
probe("read /home", lambda: os.listdir("/home"))
probe("write /tmp", lambda: open("/tmp/sdemo-test", "w").close())
probe("write state", lambda: open(os.environ.get("STATE_DIRECTORY", "/var/lib/sdemo") + "/x", "w").close())
print(f"uid={os.getuid()} tmp={sorted(os.listdir('/tmp'))[:3]}", flush=True)
socketserver.TCPServer.allow_reuse_address = True
http.server.test(http.server.SimpleHTTPRequestHandler, port=8090, bind="127.0.0.1")[Unit]
Description=systemd hardening demo
[Service]
ExecStart=/usr/bin/python3 -u /opt/sdemo/app.py
WorkingDirectory=/opt/sdemo
[Install]
WantedBy=multi-user.target$ sudo journalctl -u sdemo -n 7 -o cat
write /etc: OK
read /root: OK
read /home: OK
write /tmp: OK
write state: OK
uid=0 tmp=['.ICE-unix', '.X11-unix', '.XIM-unix']
Serving HTTP on 127.0.0.1 port 8090 (http://127.0.0.1:8090/) ...
$ systemd-analyze security sdemo --no-pager | head -5 | cut -c1-110
NAME DESCRIPTION
✗ RootDirectory=/RootImage= Service runs within the host's root directory
SupplementaryGroups= Service runs as root, option does not matter
RemoveIPC= Service runs as root, option does not apply
✗ User=/DynamicUser= Service runs as root user
$ systemd-analyze security sdemo --no-pager | tail -1
→ Overall exposure level for sdemo.service: 9.6 UNSAFE :-{
기본 유닛은 root로 돌며 /etc에 쓰고 다른 서비스의 /tmp도 볼 수 있다.
파일 시스템 격리
원본 유닛은 그대로 두고 drop-in 파일만 추가한다.
[Service]
DynamicUser=yes
StateDirectory=sdemo
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes$ sudo systemctl daemon-reload && sudo systemctl restart sdemo
$ sudo journalctl -u sdemo -n 7 -o cat
write /etc: OSError Read-only file system
read /root: PermissionError Permission denied
read /home: PermissionError Permission denied
write /tmp: OK
write state: OK
uid=62578 tmp=['sdemo-test']
Serving HTTP on 127.0.0.1 port 8090 (http://127.0.0.1:8090/) ...
$ systemd-analyze security sdemo --no-pager | tail -1
→ Overall exposure level for sdemo.service: 8.1 EXPOSED :-(
옵션 5개로 쓰기 경로가 상태 디렉터리와 전용 /tmp로 줄었다. 그래도 점수는 8.1이다. capability, 커널 인터페이스, 시스템 콜 항목이 아직 열려 있다.
커널 인터페이스와 시스템 콜 제한
[Service]
DynamicUser=yes
StateDirectory=sdemo
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
PrivateUsers=yes
NoNewPrivileges=yes
CapabilityBoundingSet=
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
UMask=0077$ sudo systemctl daemon-reload && sudo systemctl restart sdemo
$ systemctl is-active sdemo
active
$ curl -s -o /dev/null -w 'http=%{http_code}\n' http://127.0.0.1:8090/
http=200
$ systemd-analyze security sdemo --no-pager | tail -1
→ Overall exposure level for sdemo.service: 1.1 OK :-)
$ systemd-analyze security sdemo --no-pager | grep '^✗' | cut -c1-100
✗ RootDirectory=/RootImage= Service runs within the host's root
✗ RestrictAddressFamilies=~AF_UNIX Service may allocate local sockets
✗ RestrictAddressFamilies=~AF_(INET|INET6) Service may allocate Internet socket
✗ PrivateNetwork= Service has access to the host's net
✗ DeviceAllow= Service has a device ACL with some s
✗ IPAddressDeny= Service does not define an IP addres
서비스는 그대로 HTTP 200을 응답한다. 남은 항목은 대부분 네트워크 관련이다. 네트워크 서비스라도 접근 범위는 더 좁힐 수 있다.
[Service]
RestrictAddressFamilies=AF_INET
IPAddressDeny=any
IPAddressAllow=localhost
DeviceAllow=$ curl -s -o /dev/null -w 'http=%{http_code}\n' http://127.0.0.1:8090/
http=200
$ systemd-analyze security sdemo --no-pager | tail -1
→ Overall exposure level for sdemo.service: 0.9 SAFE :-}
| 단계 | 추가한 내용 | 점수 |
|---|---|---|
| 기본 유닛 | 없음 | 9.6 UNSAFE |
| 파일 시스템 격리 | 옵션 5개 | 8.1 EXPOSED |
| 커널·시스템 콜 제한 | capability와 시스템 콜 범위 축소 | 1.1 OK |
| 네트워크 제한 | 주소 패밀리와 허용 IP 제한 | 0.9 SAFE |
옵션별 효과를 단독으로 확인하기
systemd-run은 유닛 파일 없이 옵션 하나의 효과를 바로 시험한다.
$ sudo systemd-run --wait --pipe -q -p ProtectSystem=strict sh -c 'touch /etc/x; echo rc=$?'
touch: cannot touch '/etc/x': Read-only file system
rc=1
$ sudo systemd-run --wait --pipe -q -p PrivateDevices=yes sh -c 'ls /dev | tr "\n" " "; echo'
char core fd full hugepages log mqueue null ptmx pts random shm stderr stdin stdout tty urandom zero
$ sudo systemd-run --wait --pipe -q -p NoNewPrivileges=yes -p User=nobody sh -c 'sudo -n true; echo rc=$?'
sudo: The "no new privileges" flag is set, which prevents sudo from running as root.
sudo: If sudo is running in a container, you may need to adjust the container configuration to disable the flag.
rc=1
$ sudo systemd-run --wait --pipe -q python3 -c 'import mmap;m=mmap.mmap(-1,4096,prot=mmap.PROT_READ|mmap.PROT_WRITE|mmap.PROT_EXEC);print("ok")'
ok
$ sudo systemd-run --wait --pipe -q -p MemoryDenyWriteExecute=yes python3 -c 'import mmap;m=mmap.mmap(-1,4096,prot=mmap.PROT_READ|mmap.PROT_WRITE|mmap.PROT_EXEC);print("ok")' 2>&1 | tail -1
PermissionError: [Errno 13] Permission denied
SystemCallFilter는 기본 동작이 프로세스 종료다. 원인을 찾기 쉽도록 SystemCallErrorNumber로 오류 반환을 지정할 수 있다.
$ sudo systemd-run --wait --pipe -q -p SystemCallFilter='~@mount' sh -c 'mount -t tmpfs t /mnt; echo rc=$?'
Bad system call (core dumped)
rc=159
$ sudo systemd-run --wait --pipe -q -p SystemCallErrorNumber=EPERM -p SystemCallFilter='~@mount' sh -c 'mount -t tmpfs t /mnt; echo rc=$?'
mount: /mnt: permission denied.
dmesg(1) may have more information after failed mount system call.
rc=32
$ systemd-analyze syscall-filter @mount --no-pager | head -6
@mount
# Mounting and unmounting of file systems
chroot
fsconfig
fsmount
fsopen
쓰기 경로 허용하기
ProtectSystem=strict는 로그처럼 서비스가 실제로 쓰는 경로도 막는다. 그 경로만 ReadWritePaths로 연다.
$ sudo systemd-run --wait --pipe -q -p ProtectSystem=strict sh -c 'echo started >> /var/log/sdemo/app.log; echo rc=$?'
/usr/bin/sh: 1: cannot create /var/log/sdemo/app.log: Read-only file system
rc=2
$ sudo systemd-run --wait --pipe -q -p ProtectSystem=strict -p ReadWritePaths=/var/log/sdemo sh -c 'echo started >> /var/log/sdemo/app.log; echo rc=$?'
rc=0
drop-in 규칙
drop-in은 파일 이름 순서로 합쳐진다. 값이 하나인 항목은 뒤 파일이 이긴다.
$ cat /etc/systemd/system/sdemo.service.d/50-a.conf
[Service]
Environment=COLOR=red
$ cat /etc/systemd/system/sdemo.service.d/60-b.conf
[Service]
Environment=COLOR=blue
$ systemctl show sdemo -p Environment --no-pager
Environment=COLOR=blue
ExecStart는 누적되는 항목이다. 빈 값으로 먼저 비우지 않으면 유닛이 로드되지 않는다.
[Service]
ExecStart=/usr/bin/python3 -u -X dev /opt/sdemo/app.py$ sudo systemctl daemon-reload && sudo systemctl restart sdemo
Failed to restart sdemo.service: Unit sdemo.service has a bad unit file setting.
See system logs and 'systemctl status sdemo.service' for details.
$ sudo journalctl -u sdemo -n 1 -o cat
sdemo.service: Service has more than one ExecStart= setting, which is only allowed for Type=oneshot services. Refusing.
[Service]
ExecStart=
ExecStart=/usr/bin/python3 -u -X dev /opt/sdemo/app.py$ sudo systemctl daemon-reload && sudo systemctl restart sdemo
$ systemctl is-active sdemo
active
$ systemctl status sdemo --no-pager | sed -n 2,4p
Loaded: loaded (/etc/systemd/system/sdemo.service; static)
Drop-In: /etc/systemd/system/sdemo.service.d
└─10-hardening.conf, 30-exec.conf
$ systemd-delta --type=extended --no-pager | grep sdemo
[EXTENDED] /etc/systemd/system/sdemo.service → /etc/systemd/system/sdemo.service.d/10-hardening.conf
[EXTENDED] /etc/systemd/system/sdemo.service → /etc/systemd/system/sdemo.service.d/30-exec.conf
$ sudo systemctl revert sdemo
Removed "/etc/systemd/system/sdemo.service.d/10-hardening.conf".
Removed "/etc/systemd/system/sdemo.service.d/30-exec.conf".
Removed "/etc/systemd/system/sdemo.service.d".
패키지 유닛도 같은 방법으로 덮어쓴다. systemctl edit가 이 위치에 파일을 만들므로, 패키지 업데이트에도 설정이 유지된다.
자원 제한
탈취 후의 자원 소모도 함께 막을 수 있다. 무한 루프를 5초 동안 CPU 하나에 고정해 측정했다.
$ sudo systemd-run --wait -p RuntimeMaxSec=5 taskset -c 0 sh -c 'while :; do :; done' 2>&1 | grep 'CPU time'
CPU time consumed: 4.998s
$ sudo systemd-run --wait -p CPUQuota=50% -p RuntimeMaxSec=5 taskset -c 0 sh -c 'while :; do :; done' 2>&1 | grep 'CPU time'
CPU time consumed: 883ms
$ sudo systemd-run --wait -p CPUQuota=20% -p RuntimeMaxSec=5 taskset -c 0 sh -c 'while :; do :; done' 2>&1 | grep 'CPU time'
CPU time consumed: 424ms
$ sudo systemd-run --wait -p MemoryMax=64M -p MemorySwapMax=0 python3 -c "x=bytearray(200*1024*1024);print(len(x))"
Running as unit: run-u298.service; invocation ID: 23919131b5a443dd9599bf4d7399111c
Finished with result: oom-kill
Main processes terminated with: code=killed/status=KILL
Service runtime: 41ms
CPUQuota는 상한이다. 이 VM에서는 측정값이 상한보다 낮게 나왔으므로 정확한 배분 수단으로 쓰지 않는다. 메모리는 상한을 넘자 곧바로 oom-kill로 끝났다.
주의사항
| 지시 | 이유 |
|---|---|
| 점수를 목표로 삼지 말 것 | 점수는 옵션 평가값이다. ssh의 9.6이 취약점을 뜻하지 않는다 |
JIT 런타임에 MemoryDenyWriteExecute=yes를 무조건 넣지 말 것 | 쓰기·실행을 동시에 요구하는 mmap이 실패한다. Node.js·Java 서비스는 먼저 시험한다 |
SystemCallFilter 도입 시 SystemCallErrorNumber=EPERM으로 먼저 시험할 것 | 기본 동작은 프로세스 강제 종료(rc=159)라 원인을 찾기 어렵다 |
ProtectSystem=strict를 쓰면 쓰기 경로를 모두 나열할 것 | 로그·소켓·캐시 경로 쓰기가 Read-only file system으로 실패한다 |
ExecStart= 재정의 시 빈 값을 먼저 둘 것 | 빠뜨리면 유닛이 bad-setting으로 로드되지 않는다 |
| 옵션을 한 번에 다 넣지 말 것 | 실패한 옵션을 찾기 어렵다. 묶음별로 넣고 서비스 동작을 확인한다 |
| 다른 릴리스에서는 점수를 다시 측정할 것 | 이 글의 수치는 systemd 255 기준이다. 26.04는 259, 26.10은 261이다 |
마무리
| 목적 | 명령 |
|---|---|
| 전체 서비스 점수 확인 | systemd-analyze security |
| 미적용 옵션 확인 | systemd-analyze security <유닛> | grep '^✗' |
| 옵션 단독 시험 | systemd-run --wait --pipe -p <옵션> <명령> |
| 시스템 콜 그룹 내용 확인 | systemd-analyze syscall-filter @그룹 |
| 적용된 drop-in 확인 | systemctl cat, systemd-delta --type=extended |
| drop-in 전체 제거 | systemctl revert |