한 물리 서버에서 여러 백엔드 서비스를 운영할 때, 프로그램에는 보통 상태 디렉터리에 대한 쓰기 권한만 있으면 됩니다. 하지만 디렉터리 소유권을 지나치게 넓게 부여하면 배포된 파일까지 수정할 수 있게 됩니다. 서비스 전용 계정을 만드는 것은 첫 번째 경계를 세우는 데 그칩니다. 서비스가 보는 파일 시스템을 읽기 전용으로 설정하고 필요한 경로에만 쓰기를 허용해야 잘못된 쓰기의 영향을 줄일 수 있습니다. 검증도 “서비스가 정상적으로 시작됐다”에서 끝내지 말고, 쓰기가 금지된 위치에 실제로 쓸 수 없는지까지 확인해야 합니다.
1. 쓰기 경로 목록부터 정리한 뒤 샌드박스 설정하기
이 글에서는 Ubuntu Server 24.04 LTS를 기준으로 systemd로 시스템 수준의 서비스를 관리합니다. 작업에는 관리자 권한과 Python 3가 필요합니다. 모든 경로는 독립된 실험용 디렉터리이며 기존 서비스 유닛은 수정하지 않습니다. AmdMetal 물리 서버의 로그인 정보는 결제 후 콘솔에서 확인할 수 있으며, Linux에는 SSH로 접속합니다.
먼저 프로그램 코드, 영구 상태 데이터, 런타임 파일, 임시 파일을 구분합니다. 코드는 배포 계정이 갱신하고 서비스 계정은 읽기만 할 수 있어야 합니다. 상태 디렉터리에는 재시작 후에도 유지해야 하는 데이터를 저장합니다. 소켓이나 프로세스 ID 파일 같은 런타임 파일과 임시 파일의 경로도 따로 목록에 정리해야 합니다. 로그를 표준 출력으로 내보낸다면 로깅 시스템에 수집을 맡길 수 있으므로, 로그 디렉터리 전체에 쓰기를 허용할 필요가 없습니다.
핵심 설정은 ProtectSystem=strict입니다. 이 설정은 서비스의 마운트 네임스페이스에서 파일 시스템 계층을 읽기 전용으로 만듭니다. 다만 일부 API 파일 시스템 등은 예외입니다. 여기에 ReadWritePaths로 지정한 디렉터리의 쓰기 권한을 유지합니다. 이 설정은 호스트 전체의 마운트 상태를 바꾸지 않으며, 완전한 프로세스 격리 수단도 아닙니다.
읽기 전용 제한이 기밀성을 보장하는 것은 아닙니다. 읽을 수 있던 파일이 자동으로 숨겨지거나 네트워크 접근이 자동으로 제한되지는 않습니다. 쓰기 허용 목록 역시 디렉터리 권한을 대신할 수 없습니다.
2. 거부 원인을 구분할 수 있는 권한 기준선 마련하기
실험에서는 디렉터리 두 개를 준비합니다. 하나는 쓰기가 허용되어야 하고, 다른 하나는 일반 파일 권한으로는 쓰기가 가능하지만 샌드박스에서 차단되어야 합니다. 이렇게 해야 “원래 권한이 없어서 실패한 것”을 샌드박스가 작동한 것으로 오인하지 않을 수 있습니다. 아래 계정명과 경로를 기존 서비스에서 사용하고 있지 않은지 먼저 확인하세요. 이들은 실험용이며 서버의 기본 로그인 계정이 아닙니다.
sudo useradd --system --no-create-home \
--shell /usr/sbin/nologin sandbox-check
sudo install -d -o root -g root -m 0755 \
/srv/sandbox-check
sudo install -d -o sandbox-check -g sandbox-check -m 0750 \
/srv/sandbox-check/blocked \
/var/lib/sandbox-check
sudo runuser -u sandbox-check -- /usr/bin/python3 -c \
'import tempfile, os; f, p = tempfile.mkstemp(dir="/srv/sandbox-check/blocked"); os.close(f); os.unlink(p)'
마지막 명령은 정상 종료되어야 합니다. 이 명령은 차단 대상 디렉터리 안에 임의 이름의 임시 파일을 만든 다음 즉시 삭제합니다. 실패한다면 먼저 경로상 디렉터리의 탐색 권한, ACL, 기반 파일 시스템의 마운트 속성을 확인해야 합니다. 이 문제를 해결하지 않은 채 이후의 실패를 샌드박스 때문이라고 판단해서는 안 됩니다.
검사 로직을 서비스 계정이 바꾸지 못하도록 배포하는 스크립트 자체는 관리자 소유로 둡니다. 실험 디렉터리의 소유자를 쓰기가 가능한 계정으로 정한 것은 제한이 어디서 비롯되는지 입증하기 위한 의도적인 설정입니다. 실제 애플리케이션의 코드 디렉터리는 서비스 계정이 쓸 수 없도록 유지해야 하며, 이 실험용 예외를 그대로 적용해서는 안 됩니다.
3. 쓰기 허용 목록과 독립 검증 스크립트 구성하기
다음 스크립트를 /srv/sandbox-check/probe.py로 저장하고 소유자를 관리자로, 권한을 0644로 설정합니다. 허용 경로에서는 쓰기에 성공해야 하고, 금지 경로에서는 읽기 전용 파일 시스템 오류가 반환되어야 합니다. 그 밖의 오류는 모두 검증 실패로 처리합니다.
import errno
import os
import tempfile
def create_and_remove(directory):
fd, path = tempfile.mkstemp(prefix="probe-", dir=directory)
try:
with os.fdopen(fd, "w") as stream:
stream.write("sandbox-check\n")
stream.flush()
os.fsync(stream.fileno())
finally:
os.unlink(path)
create_and_remove("/var/lib/sandbox-check")
try:
create_and_remove("/srv/sandbox-check/blocked")
except OSError as exc:
if exc.errno != errno.EROFS:
raise
else:
raise RuntimeError("Write boundary was not enforced")
print("Allowed write passed; blocked write returned EROFS")
기존 데이터를 덮어쓰지 않도록 파일 이름은 무작위로 생성합니다. fsync는 쓰기 호출 과정을 확인하기 위한 것으로, 성능이나 전원 장애 시 데이터 영속성을 검증했다는 뜻은 아닙니다. 검증 스크립트의 출력에는 검사 결과만 담기며, 인증 정보나 업무 데이터는 포함되지 않습니다.
/etc/systemd/system/sandbox-check.service를 생성합니다.
[Unit]
Description=Filesystem sandbox verification
[Service]
Type=oneshot
User=sandbox-check
Group=sandbox-check
ExecStart=/usr/bin/python3 /srv/sandbox-check/probe.py
ProtectSystem=strict
ReadWritePaths=/var/lib/sandbox-check
PrivateTmp=true
NoNewPrivileges=true
PrivateTmp=true는 서비스에 격리된 임시 디렉터리 뷰를 제공합니다. 따라서 “영구 디렉터리 하나에만 쓰기를 허용한다”는 말을 “프로세스 전체에서 쓸 수 있는 위치가 하나뿐이다”라는 뜻으로 해석해서는 안 됩니다. NoNewPrivileges=true는 프로그램 실행을 통해 새로운 권한을 얻는 것을 제한하지만, 이미 부여된 파일 접근 권한을 회수하지는 않습니다.
4. 쓰기 성공과 거부를 함께 검증하기
먼저 유닛을 검사한 다음 정의를 다시 읽어 실행합니다.
sudo systemd-analyze verify /etc/systemd/system/sandbox-check.service
sudo systemctl daemon-reload
sudo systemctl start sandbox-check.service
sudo systemctl show sandbox-check.service \
-p Result -p ExecMainStatus -p ProtectSystem -p ReadWritePaths
sudo journalctl -u sandbox-check.service -b --no-pager -n 30
예상 결과는 Result=success와 ExecMainStatus=0이며, 로그에는 검증 스크립트의 통과 메시지가 표시되어야 합니다. 한 번 실행하고 종료하는 서비스이므로 실행이 끝난 뒤 비실행 상태로 표시되는 것은 이상이 아닙니다. 여기서는 계속 실행하거나 활성 상태를 유지하도록 설정하지 않았습니다.
설정값을 확인하는 것만으로는 쓰기 경계가 실제로 작동한다고 입증할 수 없습니다. 유효한 증거에는 다음 세 가지가 모두 포함되어야 합니다. 샌드박스 밖에서 같은 계정으로 쓰기에 성공하고, 샌드박스 안에서 허용 디렉터리에 쓰기에 성공하며, 금지 디렉터리에 쓰기를 시도하면 EROFS가 반환되어야 합니다. EACCES가 반환됐다면 일반 파일 권한이나 다른 보안 정책이 접근을 거부했다는 사실만 확인할 수 있으므로, 이번 검증을 통과한 것으로 볼 수 없습니다.
실제 서비스에서는 업무 경로도 테스트하기
검증 스크립트가 통과하면 실제 서비스용 보안 강화 설정을 별도로 작성합니다. 업로드, 캐시 생성, 상태 저장, 임시 파일 정리를 포함하는 업무 테스트를 수행하세요. 애플리케이션이 “임시 파일 생성 후 이름 변경” 방식으로 데이터를 갱신한다면 대상 디렉터리 자체에도 쓰기 권한이 필요합니다. 파일 하나에만 예외를 주는 것으로는 부족한 경우가 많습니다.
심볼릭 링크가 실제로 가리키는 위치도 확인해야 합니다. 설정상으로는 상태 디렉터리 안에 있는 링크라도 최종적으로는 다른 읽기 전용 경로를 가리킬 수 있습니다. 상위 디렉터리 전체의 권한을 넓혀 이 문제를 덮어서는 안 됩니다.
5. 문제 해결, 원상 복구, 검증 기록
서비스 시작 전에 실패한다면 먼저 로그를 읽고 마운트 네임스페이스를 만들 수 없는지, 경로가 존재하지 않는지, 설정에 오타가 있는지 확인합니다. 시작 후에 실패한다면 파일 작업이 일어나는 위치를 기준으로 살펴봅니다. 프로그램이 코드 디렉터리에 몰래 캐시를 쓰는지, 공유 임시 파일에 의존하는지, 추가 런타임 디렉터리가 필요한지 확인하세요.
실제 서비스에 런타임 디렉터리가 필요하다면 RuntimeDirectory로 systemd가 해당 디렉터리의 수명 주기를 관리하게 한 뒤 실제 접근을 테스트할 수 있습니다. /var나 /srv 전체를 허용 목록에 넣지 마세요. 확인된 쓰기 요구 사항에 따라 필요한 경로만 개별적으로 허용해야 합니다.
설정을 변경한 뒤 daemon-reload를 실행해도 유닛 정의만 다시 읽을 뿐, 이미 실행 중인 프로세스에 새 샌드박스가 적용되지는 않습니다. 실제 서비스는 통제된 재시작으로 새 프로세스 환경을 만든 다음, 쓰기 성공 테스트와 거부 테스트를 다시 수행해야 합니다.
실험을 철회할 때는 유닛을 중지하고 이번에 만든 유닛 파일을 삭제한 다음 정의를 다시 읽습니다. 실험용 데이터 디렉터리와 계정은 사용하는 프로세스가 없음을 확인한 뒤 별도로 정리하세요. 업무 디렉터리를 재귀적으로 삭제하는 범용 명령은 실행하지 않습니다. 실제 서비스는 변경 전에 보관한 설정으로 복원하고, 재시작 후 검증합니다.
마지막으로 유닛의 실제 적용 설정, 서비스 계정, 쓰기 허용 목록, 검증 스크립트의 종료 상태, 실제 업무 테스트 결과를 기록합니다. 검증의 목적은 보안 점수를 높이는 데 있지 않습니다. 필요한 쓰기는 모두 성공하고, 금지하려던 쓰기는 모두 설명 가능하고 재현 가능한 증거와 함께 거부되는 것이 목표입니다.
자주 묻는 질문
ReadWritePaths를 설정하면 권한이 없는 디렉터리에도 쓸 수 있나요?
아닙니다. 읽기 전용 샌드박스 안에서 쓰기 가능한 경로를 남길 뿐 소유자, 모드 비트, ACL을 바꾸지 않습니다. 서비스 계정에 일반 파일 권한도 있어야 합니다.
ProtectSystem=strict로 비밀 파일 읽기도 차단되나요?
아닙니다. 주로 쓰기를 제한하며 읽을 수 있는 파일을 자동으로 숨기거나 네트워크 접근을 막지 않습니다. 민감한 파일 권한과 경로 숨김, 네트워크 경계는 별도로 설정해야 합니다.
AMD EPYC 7543P 전용 물리 서버
월 $458.7부터