SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor · 已更新 2026-09-01

如何用 ProtectSystem 強化 systemd 服務

了解 ProtectSystem、PrivateTmp、DynamicUser 與 NoNewPrivileges 會阻擋哪些操作、可能造成哪些啟動錯誤,以及如何排查 unit 無法啟動。

systemd sandboxing 的作用

systemd sandboxing 是撰寫 unit file 的後半部分。Type= 決定服務如何啟動,ProtectSystem=PrivateTmp= 等指示詞則決定服務啟動後可以存取哪些內容。這些功能由核心、mount namespace 與 seccomp filter 提供,並由 systemd 在程序取得控制權前套用。應用程式不會察覺這些限制,也不需要修改程式碼。

預設情況下完全沒有 sandboxing。未設定 sandboxing 的 unit 會以 root 身分執行,可以在任何位置寫入、讀取系統上的所有檔案,也能載入 kernel module。如果該 unit 是可從網際網路存取的 Web 應用程式,一個檔案上傳漏洞就可能導致整台伺服器遭到入侵。在下方的 unit 設定下,同一個應用程式會使用唯讀檔案系統、空的 /home、其他程序無法查看的 /tmp,即使找到 setuid binary,也無法取得 root 權限。

上述設定一次只會套用到一個 unit。強化某項服務不會影響其他服務,因此應先處理監聽公開埠的服務。

一次完成 unit file

notes 是小型 Web 服務。它監聽 localhost,將 SQLite 資料庫存放在 /var/lib/notes 下,並置於 nginx 後方。Type=exec 適用於會在前景執行的 binary,而 Type=simple、exec、forking 與 notify 的差異會決定 systemd 如何追蹤啟動程序。下方會進一步說明每個指令,包括它可避免的問題,以及常見的故障原因。

[Unit]
Description=Notes web service
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
ExecStart=/opt/notes/bin/notes --listen 127.0.0.1:8080
DynamicUser=yes
StateDirectory=notes
Environment=NOTES_DB=/var/lib/notes/notes.db

ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectProc=invisible

NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=yes

ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes

RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
LockPersonality=yes

[Install]
WantedBy=multi-user.target

將內容寫入 /etc/systemd/system/notes.service,執行 sudo systemctl daemon-reload,然後執行 sudo systemctl restart notes.service。如果 unit 來自套件,請勿直接編輯 vendor file。sudo systemctl edit notes.service 會在 /etc/systemd/system/notes.service.d/override.conf 開啟 drop-in,其中只存放你的 [Service] 設定,並可在套件升級後保留。systemctl cat notes.service 會列出合併後的結果,且先顯示 vendor file。

服務以哪個身分執行

沒有 User= 時,服務會以 root 身分執行,這裡其他所有指令都只是降低風險的補救措施。解決方式有兩種。

靜態 system user。 使用 sudo useradd --system --no-create-home --shell /usr/sbin/nologin notes 建立帳號,然後在 unit 中設定 User=notes。UID 在服務重新啟動或系統重新開機後都維持不變。如果服務需要擁有自己狀態目錄以外的檔案,或備份工作需要讀取這些檔案,這點很重要。讓每個服務使用專屬的非特權帳號的理由在這裡同樣適用。

DynamicUser=yes systemd 會在服務啟動時從保留範圍配置 UID,並在服務停止時釋放。磁碟上不會建立帳號,因此移除服務時不會留下任何帳號。服務執行期間,getent passwd notes 會透過 systemd 的 NSS(name service switch)模組解析名稱。服務停止後,該名稱就會消失。

DynamicUser=yes 也會替你啟用另外 4 個設定:RemoveIPC=yesPrivateTmp=yesProtectSystem=strictProtectHome=read-only。只需一行就能完成大部分 sandbox 設定,因此範例 unit 仍然很精簡。

會造成的限制。 動態 UID 無法在任意位置擁有檔案,因為這個編號會在不同服務之間重複使用。持久資料必須放在 StateDirectory=CacheDirectory=LogsDirectory= 中;systemd 會在每次啟動時建立這些目錄,並將擁有者變更為目前的 UID。使用 DynamicUser=yes 時,實際目錄是 /var/lib/private/notes,而 /var/lib/notes 是指向該目錄的符號連結。/var/lib/private 的權限模式為 0700,且擁有者是 root,因此以一般使用者身分執行的備份工作,在看似對 root 完全可讀的路徑上會得到 Permission denied。任何需要固定擁有者的內容,例如 SSH key、NFS export 或供第二個服務讀取的檔案,都必須使用靜態使用者。

檔案系統的樣貌:ProtectSystem、ProtectHome、PrivateTmp

ProtectSystem= 接受 3 個值。yes 會以唯讀方式掛載 /usr 與開機目錄。full 會再加入 /etcstrict 會以唯讀方式掛載整個階層,但核心 API 目錄 /dev/proc/sys 除外;這些目錄由其他指令涵蓋。請從 strict 開始,再逐一開放必要路徑,因為先採寬鬆設定、之後再收緊通常不會發生。

開放路徑就是 ReadWritePaths=/srv/notes/uploads。需要的路徑比預期少:在 ProtectSystem=strict 下,StateDirectory=LogsDirectory=CacheDirectory=RuntimeDirectory= 會自動保持可寫,因此範例 unit 完全沒有 ReadWritePaths= 這一行。在 strict 下,/tmp 也會是唯讀,除非 PrivateTmp=yes 為該 unit 提供專用的可寫目錄。

會造成的問題。 對這些路徑以外的任何寫入都會因 Read-only file system 而失敗。會將自身設定寫回 /etc、直接在 /run 建立 PID 檔案,或將外掛解壓縮到 /opt 的應用程式,都會受到影響。請從錯誤訊息取得路徑,只加入該路徑,不要加入其上層目錄。ReadWritePaths= 中列出的路徑若不存在,會導致啟動失敗,而不是顯示警告,因此請在選用路徑前加上連字號:ReadWritePaths=-/srv/notes/uploads

唯讀不等於隱藏。在 ProtectSystem=strict 下,服務仍可讀取 /etc/passwd,也能讀取其他應用程式所擁有、且所有使用者均可讀取的 secret。InaccessiblePaths=/etc/ssh /srv/otherapp 會將子樹完全從該 unit 的視野中移除。對於服務自身的 secret,LoadCredential=dbpass:/etc/notes/dbpass 會將檔案複製到每個 unit 專用的目錄,只有該服務能讀取;應用程式會在 $CREDENTIALS_DIRECTORY 找到它。

ProtectHome=yes 會讓 /home/root/run/user 顯示為空目錄。Web 服務不需要存取家目錄,這也能避免路徑穿越漏洞觸及 /root/.sshread-onlytmpfs 是限制較寬鬆的值。若資料確實位於家目錄,相關功能就會失效;許多安裝在 /home/app 下的手動安裝應用程式都屬於此類。請將資料移至 /var/lib,或設定 ProtectHome=read-only,並接受較小的安全收益。

PrivateTmp=yes 會為服務提供專用的 /tmp/var/tmp,在服務啟動時建立,停止時刪除。這能消除服務之間整類的暫存檔競爭問題;即使服務當機,也不會將 secret 留在每位使用者都能列出的目錄中。

會造成的問題。 任何將 /tmp 視為共用位置的功能都會受影響。若服務設定為透過 /tmp/mysql.sock 連線至 MySQL,現在會回報 Can't connect to local MySQL server through socket '/tmp/mysql.sock',因為資料庫將 socket 建立在主機的 /tmp,而服務查看的是自己的目錄。請將它指向 127.0.0.1,或指向 /run 下的實際 socket 路徑。除錯時也會遇到相同情況:服務寫入 /tmp 的檔案,不會出現在 shell 的 /tmp 中。若要查看其中內容,請進入該服務的 mount namespace。

pid=$(systemctl show --property=MainPID --value notes.service)
sudo nsenter --target "$pid" --mount ls -l /tmp

PrivateDevices=yes 會以少量 pseudo device 取代 /dev,例如 /dev/null/dev/zero/dev/urandom。實體裝置將完全不存在。磁碟節點、/dev/kvm/dev/net/tun、音效卡與 GPU 都會消失,因此需要硬體轉碼的媒體伺服器會無法開啟 /dev/dri/renderD128,並改用軟體處理或直接結束。若服務確實需要某個裝置節點,請對該 unit 關閉 PrivateDevices=,並以 DeviceAllow=/dev/dri/renderD128 rw 指定該節點;這仍比預設開放系統上的所有裝置狹窄得多。

服務可具備的權限:NoNewPrivileges 與 capabilities

NoNewPrivileges=yes 會設定一個 kernel 永不清除的程序旗標。從此之後,該程序及其啟動的所有子程序,都無法透過 setuid binary 或檔案 capability 取得更高權限。這是 unit 中最有價值的單行設定,因為它能讓多數本機提權鏈在第一步就中止。

它會造成的限制。 服務內任何呼叫 sudo 的操作都會失敗,現在會輸出 sudo: effective uid is not 0, is /usr/bin/sudo on a file system with the 'nosuid' option set or an NFS file system without root privileges?。PAM(可插拔驗證模組)的密碼檢查若透過 shell 呼叫 unix_chkpwd,也會以相同方式失敗;需要 newuidmap 的 rootless container 工具亦然。如果服務依賴其中任何一項,應移除該依賴,而不是移除這個指令。

CapabilityBoundingSet= 會限制 unit 中任何程序可持有的 capabilities;指定空值則會移除所有 capabilities。對於已以非 root 使用者執行的服務,這是第二道鎖,而不是第一道鎖,因為 NoNewPrivileges=yes 會阻擋取得 capability 的一般方式。兩者都應保留,因為它們的失敗方式不同,應讓問題受到雙重攔截。

Web 服務通常可能需要的唯一 capability 是 CAP_NET_BIND_SERVICE,用於監聽低於 1024 的連接埠。非 root 程序不只需要獲准使用它,還必須實際授予該 capability,因此兩行都不可省略。

CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

systemd 會在降低權限前套用 ambient capabilities,因此這仍可與 NoNewPrivileges=yes 搭配運作。缺少這些設定時,服務會啟動後因錯誤而結束,錯誤訊息會指出連接埠,例如 listen tcp :443: bind: permission denied。在大多數 VPS 環境中,更好的做法是讓服務繫結 127.0.0.1:8080,再由 nginx 或 Caddy 使用 443。如此可讓 unit 完全不需要該 capability。開頭的 ~ 會反轉清單,因此 CapabilityBoundingSet=~CAP_SYS_ADMIN 會封鎖該項並允許其餘項目。建議使用允許清單形式:拒絕清單不易長期維護,因為新的 capabilities 會持續增加。

核心公開的內容

ProtectKernelTunables=yes 會讓 /proc/sys/sys 及其中可寫入的檔案,對此單位而言變成唯讀。凡是在啟動期間設定 sysctl 的服務都會因此失敗:啟動腳本會輸出 sysctl: setting key "vm.max_map_count": Read-only file system,然後結束。請改將值寫入 /etc/sysctl.d/,這才是正確的位置,也能在重新開機後保留,接著維持啟用此指令。

ProtectKernelModules=yes 會阻止載入模組。執行 modprobe 的單位會收到 modprobe: ERROR: could not insert 'nf_conntrack': Operation not permitted。請改透過 /etc/modules-load.d/ 在開機時載入模組,不要由服務載入。

ProtectKernelLogs=yes 會移除 dmesgProtectControlGroups=yes 會將 /sys/fs/cgroup 設為唯讀,容器執行環境及任何自行管理 cgroups 的程式會立即發現這項變更。ProtectProc=invisible 會在 /proc 隱藏其他使用者的程序,因此遭入侵的服務無法讀取其他 daemon 的命令列,以及有人在命令列中傳入的密碼。會巡覽 /proc 的監控代理程式才需要關閉此項設定。

RestrictNamespaces=yes 會阻止服務建立新的 namespace;容器執行環境需要這項能力,攻擊者也會利用它建立逃逸路徑。LockPersonality=yes 會阻止變更 kernel execution domain,RestrictSUIDSGID=yes 則會阻止服務建立 setuid 檔案。這兩項設定成本低,通常不會影響一般應用程式。

服務可以連線的對象

RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 允許本機 socket,以及 IPv4 和 IPv6,並讓 socket() 對其他所有連線以 EAFNOSUPPORT 失敗。這是 seccomp 篩選器,因此可在 x86-64 和 arm64 上運作,涵蓋目前的所有 VPS。

它會造成的問題。 AF_NETLINK,而且發生頻率遠超出一般預期。glibc 的 getifaddrs() 會使用 netlink socket,Go、Java 和 .NET 執行階段的介面列舉流程也會使用 netlink。因此,只想取得自身 IP 位址的服務,可能會因 OSError: [Errno 97] Address family not supported by protocol 或該語言中的等效錯誤而終止。發生這種情況時,清單會變成 AF_UNIX AF_INET AF_INET6 AF_NETLINK,但仍比預設值嚴格得多。AF_PACKET 是原始封包擷取所需的權限,幾乎不應授予其他任何程式。

IPAddressDeny=any 搭配 IPAddressAllow=localhost 是不同的機制:將 BPF 篩選器附加至該 unit 的 cgroup。它只適用於該 unit,且 nft list ruleset 無法看見,因此適合只應連線到同一主機上資料庫的服務;但對之後負責除錯該主機的人而言,這種設定可能難以理解。在不支援 cgroup BPF 的核心上,systemd 會記錄該 unit 正在設定 IP 防火牆,但本機系統不支援 BPF/cgroup 防火牆,而這些規則會悄悄失效。因此,請改為檢查 journal,不要直接假設規則已生效。

強化的 unit 為何會停止啟動?

上方的每個指令都可能讓原本正常運作的服務失敗,而且失敗現象通常與造成問題的指令完全不同。處理流程始終相同:查看 journal,只放寬一個指令,然後重新測試。

sudo systemctl restart notes.service
systemctl status notes.service
sudo journalctl -u notes.service -n 50 --no-pager

指定 sandbox 的行大致如下:

notes.service: Failed to set up mount namespacing: No such file or directory
notes.service: Failed at step NAMESPACE spawning /opt/notes/bin/notes: No such file or directory
notes.service: Main process exited, code=exited, status=226/NAMESPACE

226/NAMESPACE 表示 systemd 無法建立檔案系統檢視,因此 binary 根本沒有執行。最常見的原因是 ReadWritePaths=BindPaths=InaccessiblePaths= 中指定的路徑不存在。228/SECCOMP 表示套用 SystemCallFilter=SystemCallArchitectures= 時失敗。code=killed, status=31/SYS 則不同:程序已經啟動,接著呼叫了遭 filter 拒絕的 system call,最後由 kernel 終止程序。排查時請同時開啟不會啟動之 unit 的 systemd 結束代碼參考,因為這個數字是區分 namespace 問題與應用程式問題最快的方法。

一次只放寬一個指令,並在 drop-in 中進行,這樣結果才具有判斷價值。執行 sudo systemctl edit notes.service,並在其中加入單獨一行:

[Service]
ProtectSystem=full

重新啟動。如果服務現在能啟動,就知道應該縮小哪個指令的限制,而不是刪除它。將它恢復為 strict,再加入應用程式實際需要之單一路徑的 ReadWritePaths= 行,然後再次重新啟動。服務無法啟動就刪除整個區塊,最後會讓 unit 毫無保護,並留下沒有人記得是誰寫的註解。

若要在不涉及服務的情況下測試 sandbox,請在其中執行 shell:

sudo systemd-run --pty -p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes /bin/bash

在該 shell 中,touch /etc/test 會回傳 Read-only file system,而 ls /home 不會顯示任何內容。這是了解應用程式能看到什麼最快的方法,因為你可以手動執行它的命令,並觀察哪個命令失敗。

有一種失敗模式完全不會產生錯誤。拼寫錯誤的指令只會產生警告,服務仍會在沒有該指令的情況下啟動:

/etc/systemd/system/notes.service:14: Unknown key name 'ProtectSytem' in section 'Service', ignoring.

unit 會執行,但 sandbox 不存在,之後也不會有任何元件再次回報問題。以下兩個命令可以捕捉這種情況。sudo systemd-analyze verify /etc/systemd/system/notes.service 會依需求列印相同的警告,而 systemctl show 會列印執行中服務實際收到的內容:

systemctl show notes.service -p User -p ProtectSystem -p PrivateTmp -p NoNewPrivileges

如果你寫入 strict,但該命令回傳 ProtectSystem=no,表示 unit 載入的內容與你認為的不同。

將 systemd-analyze security 當作檢查清單

sudo systemd-analyze security notes.service 會列出單位可使用的所有沙箱設定、該單位目前對各設定的使用方式,以及每列的判定結果。不加參數執行時,會列出此主機上所有已載入的服務;加入 --offline=true 並指定路徑,則可在安裝前檢查單位檔案。

請將輸出視為待辦清單。逐一檢查工具標記的列,並針對每列回答一個問題:此服務是否需要這項存取權?多數情況下答案是否定的,此時加入該設定即可。有時答案是肯定的。媒體伺服器需要裝置節點。備份代理程式需要讀取 /home。這些列會一直維持標記狀態,而這是正確結果,不代表檢查失敗。

結尾的摘要數字是上方各列結果的彙總。它不知道你的服務用途、服務保存的資料,也不知道應用程式本身是否存在錯誤。即使單位通過工具檢查的每一列,仍可能是伺服器上最薄弱的環節,因為工具衡量的是單位檔案的暴露程度,而不是軟體的暴露程度。自架密碼管理器能具體說明這項落差:Vaultwarden 單位可以通過每一列,但 其管理員 token 與備份檔案 仍然決定密庫是否安全。盲目追求這個數字,會讓人貼上自己不理解的指令,而這些指令正是套件升級後最容易造成服務故障的設定,最後也沒有人能說明當初為何加入該行。

沙箱的邊界

這些指令會控制服務可以存取哪些資源,但不會限制服務可消耗的資源量。因此,即使完全套用沙箱的 unit,仍可能用盡主機上的所有 CPU 核心與記憶體。這是另一組設定,請參閱systemd 服務的 CPU 與記憶體限制指南

這些指令也不能取代強制存取控制。namespace 僅適用於單一 unit,並由撰寫 unit 檔案的人員設定;SELinux 則會在整個系統套用單一政策。兩者應搭配使用,彼此都不能取代對方。

最後,這些設定只涵蓋 systemd 在此 unit 內啟動的處理程序。若服務透過 socket 將工作交給 helper daemon,該 helper 並未套用沙箱。也要將相同區塊寫入該 unit,並使用 systemctl show 檢查結果,不要只相信檔案內容。

FAQ

ProtectSystem=strict 實際上會將哪些內容設為唯讀?

除了 /dev/proc/sys 以外,整個檔案系統階層都會設為唯讀;這 3 個路徑則分別由 PrivateDevices=ProtectKernelTunables=ProtectControlGroups= 處理。這包括 /etc/var/srv/opt/tmp。systemd 會替你恢復寫入權限的例外,是它所管理的目錄:StateDirectory=CacheDirectory=LogsDirectory=RuntimeDirectory=。服務需要寫入的其他任何路徑,都必須明確加入 ReadWritePaths= 項目。請注意,唯讀不代表無法讀取,因此主機其他位置的 secret 檔案仍可供服務開啟,除非你將它列在 InaccessiblePaths= 中。

為什麼我的服務會以 status=226/NAMESPACE 失敗?

systemd 無法建立 mount namespace,因此執行檔根本沒有啟動。前一行 journal 訊息通常會顯示 Failed to set up mount namespacing: No such file or directory。幾乎所有情況都是因為 ReadWritePaths=BindPaths=InaccessiblePaths= 中的路徑不存在於磁碟上。請建立該目錄,或在項目前加上連字號(ReadWritePaths=-/srv/notes/uploads),讓 systemd 在路徑不存在時略過該項目。如果路徑確實存在,請檢查 unit 中是否有拼字錯誤,並使用 systemctl cat notes.service 確認,因為 drop-in 可能加入了你目前看不到的設定行。

使用 PrivateTmp 的服務仍可透過 /tmp 共用檔案嗎?

不行,這正是該設定的目的。服務會取得新的 /tmp/var/tmp,且只在服務執行期間存在,因此其他程序在主機的 /tmp 中建立的 socket 或檔案,對該服務不可見。資料庫 socket 位於 /tmp/mysql.sock 時最常受到影響;解決方式是透過 127.0.0.1 連線,或將 client 指向 /run 下的實際 socket。若要檢查服務自己的暫存檔案,請從 systemctl show --property=MainPID --value 取得其主要 PID,再使用 sudo nsenter --target <pid> --mount 進入該服務的 mount namespace。

沙箱化服務如何在不使用 root 的情況下監聽 443 埠?

只授予一項 capability,不要授予完整的 root 帳號權限。設定 AmbientCapabilities=CAP_NET_BIND_SERVICECapabilityBoundingSet=CAP_NET_BIND_SERVICE,保留 User=DynamicUser=yes,程序就能繫結低號埠,且不具備其他權限。只設定 bounding set 是常見錯誤:此時 capability 只被允許使用,卻從未實際授予服務,服務會因權限錯誤而結束,並指出該埠。若 VPS 上已經執行反向代理,較適當的作法是在服務中繫結 127.0.0.1:8080,讓 nginx 監聽 443,如此 unit 完全不需要任何 capability。

我應該使用 DynamicUser,而不是建立 system user 嗎?

如果服務將所有資料保存在 StateDirectory=CacheDirectory=LogsDirectory= 內,就可以使用 DynamicUser;這涵蓋大多數小型自架 Web 應用程式。服務執行期間才會存在 UID,並且會自動啟用 PrivateTmp=ProtectSystem=strictProtectHome=read-onlyRemoveIPC=。如果 UID 必須保持固定,請使用靜態 system user,例如檔案的擁有者位於這些目錄之外、需要 SSH key、使用 NFS 掛載,或有第二個程序會讀取相同資料。請記住,使用 DynamicUser=yes 時,資料實際位於 /var/lib/private/notes,而且該目錄的模式為 0700、擁有者為 root;這會導致非 root 的備份工作失敗。