Ubuntu 24.04에서 systemd 서비스 샌드박싱하기 — systemd-analyze security와 drop-in

유닛 파일에 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

참고

답글 남기기