Odysec

設定檔正確,不代表實際生效

WHITE PAPER2026-07-31Odysec

主機加固最危險的狀態不是「沒有做」,而是「以為做了」。設定檔逐行檢視都正確、稽核清單全部打勾,實際行為卻不是那樣——而且沒有任何錯誤訊息告訴您兩者不一致。本文整理六種我們在組態審查規則開發與自有主機維運中實際遇到的「看起來有生效」盲點。它們的共同解法只有一個:驗證生效狀態,而不是檢視設定檔。每一節都附上對應的驗證指令。

一、sshd_config 寫的,會被 drop-in 悄悄蓋掉

OpenSSH 的設定原則是先取得者優先(first obtained wins):同一個參數出現多次時,先被讀到的那個生效,後面的靜默忽略。而現代發行版的 /etc/ssh/sshd_config 開頭就有一行:

Include /etc/ssh/sshd_config.d/*.conf

Include 在檔案開頭,代表 drop-in 目錄裡的設定全部先於主檔被讀取——drop-in 寫了什麼,主檔同名的設定就整個失效。典型案例是雲端主機映像自帶的 50-cloud-init.conf,內容常是 PasswordAuthentication yes:您在主檔明明寫了 PasswordAuthentication no,逐行檢查完全正確,密碼登入卻依然開著。

驗證:不要讀設定檔,問 sshd 本人:

sudo sshd -T | grep -i -E 'passwordauthentication|permitrootlogin'

sshd -T 輸出的是解析完所有 Include 之後的最終生效值。加固 SSH 的正確做法是把自己的設定放進編號排序在最前的 drop-in(例如 00-hardening.conf 之類,依發行版慣例),而不是改主檔。

二、sysctl:all 不一定是 all,設了也不一定還在

這一節有兩個獨立的陷阱,常常疊在一起發生。

陷阱一:conf.all 的語意因參數而異

net.ipv4.conf.all.* 這組參數的「all」不是統一語意。以兩個常見的加固項為例:

兩個名字對稱的參數、行為完全不同,憑直覺推論必錯其一。

陷阱二:開機競態——設定被之後啟動的服務改回去

systemd-sysctl 在開機早期套用設定,但網路管理服務、Docker 等元件在之後啟動,其中一些會重設特定的 sysctl 鍵(我們實測過 log_martiansconf.all 在開機後段被網路/容器階段重置回 0,而同一份檔案裡的 send_redirects 卻不受影響——哪些鍵會被動到,取決於各服務的實作,無法從設定檔推知)。此外,開機時還不存在的介面(容器橋接、VPN)不會吃到逐介面條目。

驗證:開機完成、所有服務就緒後,讀 /proc/sys 的即時值:

for f in /proc/sys/net/ipv4/conf/*/send_redirects; do
  echo "$(basename "$(dirname "$f")") = $(cat "$f")"
done

若有鍵持續被晚啟動的服務重置,解法是加一個排在該服務之後的 oneshot unit(After=network-online.target docker.service)重跑 sysctl --system——並替這個 unit 掛上失敗告警,否則它壞掉時您回到原點而不自知。

三、nginx 的 add_header:子區塊一開口,繼承全歸零

add_header 的繼承規則是全有或全無:location 區塊只要出現任何一條自己的 add_header(哪怕只是給靜態資源加 Cache-Control),上層 http/server 設定的全部標頭在該區塊一條都不送。安全標頭在首頁測起來 4/4 齊全,在那個 location 底下是 0/4——而兩處回應都是 200,沒有任何異狀。

還有一個常被漏掉的修飾詞:不加 alwaysadd_header 只在 2xx/3xx 回應送出,404、500 頁面上安全標頭會整組消失。

驗證:對每一類路徑(首頁、靜態資源、API、一個真實的 404)各打一次,數標頭:

curl -sI https://your-site/some/static.css | grep -ci -E \
  'strict-transport|x-content-type|x-frame|content-security'

修正:把安全標頭抽成一份 snippet 檔,server 層與每一個含 add_header 的 location 都 include 它,並讓所有標頭一律帶 always。此後的紀律是:任何 location 新加 add_header 時,必須同時 include snippet——這條規則值得寫進部署檢查清單。

四、映像標籤釘死次版本=自願退出安全更新

compose 檔把映像釘在 redis:6.0-alpine 這種次版本標籤時,定期的 docker compose pull 永遠只會拉到 6.0 系列的最後一版——該版本線 EOL 之後,每週的「自動更新」照常執行、照常成功、照常什麼都沒更新。這是典型的靜默失效:更新機制的每個環節都回報成功。

判準要分軟體類型,一律浮動也是錯的:

五、掛載 Docker socket 的容器,等於拿到主機 root

/var/run/docker.sock 掛進容器(監控面板、自動更新工具最常見)在風險上不是「多一點權限」,而是主機 root 等價:能對 Docker API 說話,就能起一個掛載主機根目錄的特權容器,一步走出去。做這個決定時應當作「這個容器被攻破=主機被攻破」來評估,而不是當作一般的容器權限設定。

需要保留這類工具時的務實做法:確認該容器不對外暴露任何埠、其 Web 介面收在認證之後,並理解這是一個有意識接受的風險而非已緩解的風險。同一等級的還有 privileged: true 與把 Docker API 直接發布在 TCP 埠上——後者等於把主機 root 放上網路。

六、nosuid 不等於 noexec

/tmp/dev/shm 常見的掛載選項是 nosuid,nodev——很多檢查清單看到這兩個就打勾了。但 nosuid 只擋「以檔案擁有者權限執行」,不擋執行本身:攻擊者落地的程式照常能從 /tmp 跑起來。要擋掉「可寫目錄放置可執行檔」這條最常見的落地路徑,需要的是 noexec

# 驗證:看實際掛載選項,而不是 fstab
findmnt -no OPTIONS /tmp /dev/shm

注意副作用:少數安裝流程假設 /tmp 可執行,加上 noexec 後要實際跑一次系統更新確認不受影響。另外,systemd 提供的 tmpfs 可能根本沒有 fstab 條目——「fstab 沒寫」不代表「沒有掛載」,這又是一個設定檔與生效狀態分離的例子。

通則:把「驗證生效」變成習慣

層面不要看要看
SSHsshd_config 內容sshd -T 的最終值
sysctl/etc/sysctl.d/ 的檔案開機完成後的 /proc/sys
HTTP 標頭nginx.conf對每類路徑(含 404)實際 curl
防火牆UFW 規則清單從外部主機實測連線
映像更新更新排程的執行紀錄映像的實際建置日期
掛載選項fstabfindmnt 的即時輸出
為什麼這件事值得一篇白皮書。這六項的共同點是:故障狀態下沒有任何元件回報錯誤,每一層各自「正常運作」。偵測工具也有一模一樣的問題——「掃描完成、未發現異常」與「掃描器本身壞了」在輸出上完全相同。我們自己的檢測引擎因此把「查不到」與「確認沒有」嚴格分開呈報:未採集不等於沒問題。

本文的方法與限制

本文各節的靜態可判讀部分(sshd 的 Include 覆蓋、nginx add_header 繼承、compose 的版本釘死與 socket 掛載、sysctl 基準值)已實作在我們的免費組態審查規則中,貼上設定檔即可比對;伺服器不保存您上傳的內容。誠實揭露其限制:如本文通篇所述,靜態審查只能看設定檔,而本文的主旨正是設定檔不等於生效狀態——所以審查結果中涉及生效狀態的項目,一律附上驗證指令由您在主機上確認,而不是替您斷言。在主機上實際採集生效狀態的版本,是自助主機健檢