FR-084.T1 掃描報告:防竄改機制「套件本體」(jedi-integrity)

  • 卡片CM-1645(母卡 CM-1644
  • 掃描範圍jedi-integrity 套件的 jedi_integrity/harness/,共 21 檔約 3,250 行
  • 版本:commit 8aa6f060(jedi monorepo,branch feature/FR-075
  • 日期:2026-09-10

1. 🔴 一句話結論

這套防竄改機制擋得住「隨手改個檔案」,但擋不住「已經拿到主機管理權限、而且知道它怎麼運作」的人——目前找到四條繞過路徑,其中兩條只要一個指令就能讓被鎖住的機器重新開起來。

要強調的是:這四條都不是程式寫錯,每一條在程式碼裡都有一段註解說明「為什麼這樣設計是合理的」。問題在於那些理由是在「攻擊者只是個手滑的維運人員」的假設下成立的,而這套機制真正要防的是「刻意想破解產品保護的人」。


2. 這一棒在檢查什麼(白話)

我們的落地版產品(裝在客戶自己機房裡的那種)有一套「防竄改機制」,運作方式像這樣:

  1. 出貨前,原廠把產品裡每一個檔案算出一組指紋(就像每個檔案的身分證號碼),列成一份清單,再用原廠的私章簽名——這份簽了名的清單叫 manifest
  2. 產品每次開機時,逐檔重算指紋、跟清單比對。只要有一個對不上,就拒絕啟動並把機器鎖定
  3. 服務跑起來之後,大約每 4 小時再抽查一次。
  4. 被鎖定的機器,只有原廠簽發的一次性解鎖檔能解開。

這一棒要回答的是:這套保護能不能被繞過、被關掉,或者反過來被拿來搗亂(例如讓正常客戶的機器莫名被鎖死、或鎖了之後解不開)。

只找問題、不修問題——所有修法都只寫建議,這一棒不動任何程式碼。

一個重要前提

這套機制的威脅模型(假想敵)是「攻擊者已經能改動這台機器上的檔案」——因為他要竄改產品,本來就得先有這個能力。所以底下每一條發現,「需要主機管理權限」都不能當成減輕理由。真正要問的是:有了主機權限之後,繞過這套保護還要不要額外成本? 如果答案是「不用,再多打一個指令就好」,那這道保護對他而言等於不存在。


3. 掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 嚴重度
T1-1 只要刪掉一個標記檔,被鎖住的機器就能重新開起來 鎖定形同虛設;文件宣稱的「兩道鎖」實際只有一道 主機管理權限(不需額外成本,就是一行 rm startup_gate.py:206 HIGH
T1-2 開機檢查不涵蓋負責「驗章」的那個函式庫,換掉它就能讓所有檢查永遠通過 整套保護完全失效,且產品自己不會察覺 主機管理權限+知道要換哪個檔 config.py:49build_release.sh:365 HIGH
T1-3 一個環境變數就能把「要檢查哪個目錄」指到別處 檢查一個空目錄=零個檔案要比對=必定通過 能改動服務的啟動環境設定 resource_path.py:31-39 HIGH
T1-4 把「用過的解鎖檔紀錄」弄壞,同一張解鎖檔就能再用一次 一次性憑證變成可重複使用 主機管理權限+手上有一張用過的解鎖檔 unlock.py:83-100 MEDIUM
T1-5 鎖定畫面對外開放,任何人都能拿到機器指紋與事件編號 洩漏申請解鎖所需的兩項資訊;另可被塞爆連線 連得到那個網路位址 config.py:93lockdown.py:182 LOW
T1-6 竄改事件回報會把伺服器 log 最後 50 行往外送 log 尾段可能夾帶密碼或金鑰,隨事件送到原廠伺服器 需先觸發竄改事件 forensic.py:108-129 LOW

沒有發現的項目:⑦終止流程對 PID 的信任、⑧解鎖檔效期與時鐘、⑨埋點退化、⑩開發用啟動殼——四項都查了,結論寫在第 5 節。


4. 每條發現的詳述

T1-1(HIGH)只要刪掉一個標記檔,被鎖住的機器就能重新開起來

這是什麼問題(白話) 機器被判定「遭竄改」之後,這件事會記在兩個地方:一個是硬碟上的標記檔,一個是資料庫裡的一筆紀錄。產品文件與程式碼註解都說:「這兩個地方任何一個還在,就拒絕開機」——意思是攻擊者要把兩邊都洗掉才解得開,成本加倍。

但實際的程式碼只看硬碟那個檔,從來不看資料庫。 負責查資料庫的那支程式(exists())寫好了,卻全套件與宿主專案都沒有任何一行呼叫它

出事會怎樣 機器被鎖之後,有主機權限的人只要刪掉一個檔(/opt/guidant/pki/.integrity-tamper),重開服務就正常起來了。原本設計的「兩道鎖」實際上只有一道,而那一道就是一個普通檔案。文件承諾的成本加倍不存在。

要先有什麼才打得到

  • 主機管理權限(能進到機器裡刪檔)——但這正是威脅模型的起點,攻擊者為了竄改產品本來就得先有這個能力
  • 不需要任何額外成本:不用破解、不用逆向、不用猜密碼,就是一行 rm
  • 附帶條件:他還得把被改壞的檔案還原(否則開機時第二道檢查會再次鎖定)。但如果他的目的就是移除鎖定紀錄、湮滅痕跡,這一步反而正合他意

在哪裡

  • jedi_integrity/startup_gate.py:206marker = tamper_marker.read_marker(config),這是開機流程唯一一次讀取竄改狀態,只讀檔案
  • jedi_integrity/infra/tamper_event_repo.py:96exists() 方法,零呼叫者(已 grep 套件與 compliance-manager-be 全樹確認;宿主只用到 insert_if_absentcore/app_factory.py:347)與 mark_unlockedcore/scheduler.py:311))
  • 文件宣稱處:jedi_integrity/__init__.py:11tamper_marker.py:5-7README.md:32
  • 程式碼自己也承認了:app/service/tamper_sync_service.py:13-15 寫「補同步是把『FS 被洗掉』這個洞的窗口縮到最小」——它知道有這個洞,只說窗口縮小了

怎麼修startup_gate.py:206 那一段,讀完檔案標記後再查一次資料庫:呼叫 TamperEventRepo.exists(),任一為真就走拒啟流程。

技術障礙是「開機閘門跑在資料庫連線建立之前」——這正是當初只做檔案這一半的原因。三個可行方向:

  1. 開機時額外開一條短連線專門查這張表,查完就關(這張表刻意沒有多租戶隔離,不需要使用者身分脈絡,技術上可行)
  2. 若堅持不在開機期碰資料庫,就把文件改成與程式一致,別再宣稱「任一存在即拒啟」——現在的落差會讓維護者誤以為有兩道鎖而不去補
  3. 折衷:服務起來後的補同步發現「資料庫有紀錄但檔案標記不見了」時,立刻重新寫回檔案標記並終止服務(目前的補同步只單向把檔案寫進資料庫,不會反向補回)

首腦核對註記:工具的三位檢查員對這條投 1 比 2(一票認為是漏洞、兩票認為提出的攻擊手法被周邊程式碼駁回),未達成立門檻,所以工具的正式報告沒有列它。我開檔逐行核對後判定成立,並提高到 HIGH:兩位反對者的理由是「刪掉標記後,被改壞的檔案仍會在第二道檢查被抓到」——這對「想繼續使用被破解產品」的攻擊者成立,但對「想湮滅鎖定痕跡」的攻擊者不成立,而後者正是這套機制要留下的法律證據。文件與程式碼不符則是三票一致確認的事實。


T1-2(HIGH)開機檢查不涵蓋負責「驗章」的函式庫,換掉它就能讓所有檢查永遠通過

這是什麼問題(白話) 產品出貨時,所有檔案被分成三層:

  • core(我們自己寫的程式,編譯成機器碼)
  • resources(範本、翻譯檔這類資料)
  • thirdparty(別人寫的公開套件,原樣附帶)

開機時只檢查前兩層,第三層交給「4 小時一次的抽查」隨機抽 200 個檔。

問題是:負責驗證原廠簽名的那個函式庫(cryptography)落在第三層。也就是說,開機時完全不會檢查「驗章的工具本身有沒有被掉包」。

我循 build 腳本確認了這一點:cryptography 不在「不編譯清單」裡,但腳本會自動把清單裡套件的相依也一併排除,而 pdfminer.six 依賴 cryptographybuild_release.sh:365 白紙黑字寫著這條相依鏈)。被排除的套件是原樣複製進產物、歸入 thirdparty 層。

出事會怎樣 攻擊者把 cryptography 裡負責驗簽名的那段換掉,讓它無論收到什麼都回答「簽名正確」,那麼:

  • 他可以自己偽造一份 manifest(把被改過的檔案指紋寫成新的正確值),產品照單全收
  • 開機檢查完全失效,而且產品自己毫無察覺,log 還會印「產物完整性驗證通過」
  • 唯一還可能抓到的是 4 小時一次的抽查,但抽查也是用同一個被掉包的驗章工具,所以它也一起失效

換句話說:這一步一旦成功,整套防竄改機制就結束了,不是繞過一次,是永久關閉。

要先有什麼才打得到

  • 主機管理權限
  • 知道要換哪個檔(需要一點逆向工夫,但 cryptography 是公開套件、檔案結構完全公開,難度不高)
  • 不需要原廠私鑰、不需要破解密碼學

在哪裡

  • jedi_integrity/config.py:49DEFAULT_BOOT_LAYERS = ("core", "resources"),開機只驗這兩層
  • jedi_integrity/config.py:38DEFAULT_SPOT_CHECK_THIRDPARTY_SAMPLE = 200,抽查每輪只隨機抽 200 個
  • jedi_integrity/runtime_check.py:181-191_sample_thirdparty(),隨機抽樣的實作
  • scripts/build/build_release.sh:365(BE repo)— 註解明列 pdfminer.six → charset_normalizer, cryptography
  • scripts/build/build_release.sh:370-406resolve_excluded_closure(),遞迴求相依封閉集合的實作
  • scripts/build/build_release.sh:895-906 — 把封閉集合寫進 thirdparty 清單
  • 驗章的呼叫鏈:common/integrity/adapters.py:33-38jedi_license_runtime.common.engine.verify_payloadcryptography 的 Ed25519

怎麼修 把「驗章相依的函式庫」從 thirdparty 提升到開機必驗的範圍。三個做法,建議第一個:

  1. gen_integrity_manifest.py 增設一個 security-critical 分層,把 cryptography(及其 .so)歸進去,並加入 DEFAULT_BOOT_LAYERS。好處是不必把整個 thirdparty 都納入開機檢查(那會拖慢開機)。
  2. 或在 build_release.shcryptography 從排除清單的封閉集合中強制拉回編譯,讓它變成 core 層的機器碼。要先確認它的原生 .so 能被 Nuitka 正確處理。
  3. 最低限度:在 runtime_check.py:_select_hotpath_targets() 的優先清單(config.py:41-45hotpath_priority_prefixes)加入 cryptography/,讓業務埋點每次都順帶驗它。這只是縮短空窗,不是根治。

額外提醒:即使不修,也該把 thirdparty 的抽查改成「保證涵蓋安全關鍵檔+其餘隨機」,而不是純隨機。目前若 thirdparty 有數萬檔,每輪抽 200 個,抽中特定某檔的機率極低,平均要等非常久——而抽查本身已經被掉包的工具驗證,實際上抽到也沒用。

首腦核對註記:工具完全沒碰這條(工具未報,runner 人工查證)。分層判定是跨 repo 的事實(套件在 jedi monorepo、build 腳本在 BE repo),單一 repo 的掃描讀不到。我循 resolve_excluded_closure() 的實作與 :365 的註解確認相依鏈成立。未實測——沒有在任何環境實際掉包 .so 驗證,這是讀程式碼與 build 腳本推論的結果。建議下一棒在本機造一份假產物實測確認。


T1-3(HIGH)一個環境變數就能把「要檢查哪個目錄」指到別處

這是什麼問題(白話) 產品用一個叫 GUIDANT_RESOURCE_ROOT 的環境變數決定「產品檔案放在哪個目錄」。防竄改檢查就是去那個目錄逐檔比對。

這個變數沒有任何保護:誰能改服務的啟動設定,誰就能把它指到任何地方。

出事會怎樣 把它指到一個空目錄,那裡沒有簽名清單檔,開機檢查會直接判定「清單不存在=視同竄改」而拒啟——這一條是擋住的。

但指到一個攻擊者自己準備好的目錄(放一份他自己簽的 manifest 與對應檔案)就不一樣了。這條路要成功,仍然得先過 T1-2 那關(換掉驗章工具讓假簽名通過)。兩條合起來用,就是一條完整的繞過路徑:換掉驗章工具 → 自備一份假產物目錄 → 用環境變數指過去 → 開機一路綠燈。

單獨看它的價值在於:它讓 T1-2 從「要偽造整份 manifest」降級成「隨便準備一個目錄就好」,省掉攻擊者不少工。

要先有什麼才打得到

  • 能改動服務的啟動環境設定(改 docker-compose.yml.env、或 systemd 設定檔)——與主機管理權限實質等價
  • 搭配 T1-2 才能發揮完整效果

在哪裡

  • common/util/resource_path.py:23_ENV_OVERRIDE = "GUIDANT_RESOURCE_ROOT"
  • common/util/resource_path.py:31-39resource_root()環境變數的優先序高於打包判定if override: return ...if is_packaged() 之前)
  • docker/production/Dockerfile:245 — image 內釘死 GUIDANT_RESOURCE_ROOT=/app,但 docker run --env-file 或 compose 的 environment: 都會覆寫掉它
  • Dockerfile:196-198 的註解說明釘死的理由是「讓路徑從哪來在部署現場一眼可見」——是為了排錯方便,不是為了安全

怎麼修resource_path.py:31resource_root() 裡,打包模式下忽略這個環境變數

def resource_root() -> Path:
    if is_packaged():
        return Path(sys.executable).resolve().parent   # 打包模式:只信 binary 位置
    override = os.getenv(_ENV_OVERRIDE)                # 覆寫只在源碼模式生效
    if override:
        return Path(override).resolve()
    return Path(__file__).resolve().parents[2]

這與套件既有的設計原則一致:force_enforcement 那個旗標就是「只能開不能關」(config.py:78,理由在 manifest.py:80-81)。GUIDANT_RESOURCE_ROOT 目前是「能把執法對象整個換掉」,比 force_enforcement 危險得多,卻沒有同等的保護。

注意:改之前要確認沒有任何正式部署情境真的依賴「打包模式下用環境變數換資源根目錄」。Dockerfile 的註解說打包模式下設不設結果相同,若屬實則此修改零影響。

首腦核對註記:工具未報(工具未報,runner 人工查證)——resource_path.py 在 BE repo,不在本次掃描範圍內。卡片「已知背景」已點名這個變數要追,我開檔確認了優先序問題。未實測


T1-4(MEDIUM)把「用過的解鎖檔紀錄」弄壞,同一張解鎖檔就能再用一次

這是什麼問題(白話) 機器被鎖定後,原廠會簽發一張一次性解鎖檔。為了確保它真的只能用一次,產品會把用過的那張的流水號(程式裡叫 nonce,就是一組一次性隨機碼)記在一個檔案裡(used-nonces.json)。下次有人拿解鎖檔來,先查這本帳,用過的就拒絕。

問題是:這本帳讀不懂的時候,程式選擇當作「這本帳是空的」——也就是「沒有任何解鎖檔用過」。程式註解自己說明了理由:「取寬鬆側……若取嚴格側,一次檔案損毀就會讓機器再也解不開,只能重灌。」

這個顧慮是合理的,但代價是:攻擊者只要把這個檔案改成亂碼,用過的解鎖檔就復活了。

出事會怎樣 拿過一張解鎖檔的客戶(或任何拿得到那張檔的人),可以無限次重複使用它——只要每次用之前先把 nonce 帳弄壞。

不過這條有三道額外關卡擋著,這也是它是 MEDIUM 而不是 HIGH 的原因:

  1. 解鎖檔綁機器指紋——換一台機器就不能用
  2. 解鎖檔綁事件編號——換一次鎖定事件就不能用
  3. 解鎖檔有有效期限

所以實際能重放的情境很窄:同一台機器、同一次鎖定事件、而且還在效期內。這種情況下重複解鎖的實際收益不大(那次鎖定本來就該被這張檔解開)。

真正的問題是它把一次性憑證的保證從「密碼學等級」降到「一個可寫檔案」等級——防重放本來就是這道機制存在的唯一理由,而它可以被一行指令關掉。

要先有什麼才打得到

  • 主機管理權限(能寫 /opt/guidant/pki/used-nonces.json
  • 手上有一張用過的、對應同一台機器與同一次事件、且還沒過期的解鎖檔

在哪裡

  • jedi_integrity/unlock.py:83-100_read_used_nonces()except (ValueError, TypeError): return [] 就是「讀不懂就當空的」
  • jedi_integrity/unlock.py:60_MAX_USED_NONCES = 1000
  • jedi_integrity/unlock.py:113-114 — 帳滿時 nonces[-_MAX_USED_NONCES:] 丟掉最舊的
  • 對照組:tamper_marker.py:102-126read_marker() 對同樣的「讀不懂」情況取嚴格側(回 corrupted 一律視同鎖定中)。同一個套件,兩個檔案,相反的失敗策略

怎麼修 把「讀不懂」與「檔案不存在」分開處理,而不是都回空清單:

  • 檔案不存在 → 回空清單(正常,第一次解鎖)
  • 檔案存在但解析失敗不放行本次解鎖,並印出明確訊息說明是「防重放紀錄毀損」而非「token 有問題」,指引客戶聯絡原廠

這樣就與 tamper_marker.read_marker() 的策略一致了。原本擔心的「一次損毀就再也解不開」有現成解法:原廠可以簽發帶特殊旗標的救援解鎖檔來覆蓋這個狀態,或指引客戶刪除該檔(刪除是「檔案不存在」,走寬鬆路徑)——讓解法是原廠可控的動作,而不是預設放行

至於 _MAX_USED_NONCES = 1000 滿了丟最舊的:實務上一台機器解鎖次數以個位數計,這個上限遠超實務需求,且被丟掉的最舊 token 早已過期,這一項不構成獨立風險,不必修。

首腦核對註記:工具未報(工具未報,runner 人工查證)。未實測——沒有實際造一張解鎖檔測試重放,這是讀程式碼推論。判 MEDIUM 而非 HIGH 的理由如上:三道關卡把可利用的情境壓得很窄,但防重放機制本身確實可被停用。


T1-5(LOW)鎖定畫面對外開放,任何人都能拿到機器指紋與事件編號

這是什麼問題(白話) 機器被鎖定後,產品會起一個小網頁伺服器,顯示「系統已鎖定」,並附上兩項資訊:機器指紋事件編號。這是刻意設計的——客戶要拿這兩個值去跟原廠申請解鎖。

但這個小伺服器:

  • 綁定在 0.0.0.0(意思是接受來自任何網路位址的連線,不只本機)
  • 回應帶 Access-Control-Allow-Origin: *(意思是任何網站的網頁都能讀取它的內容
  • 任何路徑、任何方法都回同一份資訊,不需要登入

出事會怎樣 兩件事:

  1. 資訊外洩:機器指紋與事件編號是申請解鎖時要提供的兩項資料。它們本身不是密碼(原廠簽發時還會驗證申請者身分),但把它們公開等於把申請解鎖的材料先送給對方。若原廠的簽發流程只憑這兩個值就發,那問題就嚴重得多——這一點要請簽發端(License Center)確認,不在本棒範圍。

  2. 可被塞爆:這個殼用的是 Python 內建的 ThreadingHTTPServer每來一個連線就開一條執行緒,沒有上限。有人持續開連線就能把記憶體吃光。不過機器已經被鎖定了,服務本來就不可用,所以實際損害有限。

實際暴露程度沒有想像中大:正式部署的 docker-compose.yml 裡,BE 服務用的是 expose: 8000 而不是 ports:,意思是只在容器內部網路可見,不對主機外開放docker-compose.yml:279-283,註解明確寫「對外不開 port」)。整組系統唯一對外的是前端 nginx(:318-325)。所以要打到這個殼,得先進到容器網路內。這是它是 LOW 的主要理由。

要先有什麼才打得到

  • 連得到容器內部網路(同一台主機上的其他容器、或已進入內網的攻擊者)
  • 若部署時有人加了 ports: ["8000:8000"] 排錯後忘記拿掉(compose 註解正好提醒過這件事),暴露面就直接擴大到主機網路

在哪裡

  • jedi_integrity/config.py:93lockdown_bind_host = "0.0.0.0"
  • jedi_integrity/lockdown.py:182_send_cors_headers()Access-Control-Allow-Origin: *
  • jedi_integrity/lockdown.py:51 — 用 ThreadingHTTPServer(每連線一執行緒、無上限)
  • jedi_integrity/lockdown.py:66-69_lockdown_data(),吐出的三個欄位
  • 部署現況:docker/production/docker-compose.yml:279-283expose 而非 ports

怎麼修 這條的修法要謹慎,因為0.0.0.0 與 CORS 星號都是有真實理由的lockdown.py:32-38 說明:前端與後端不同源時,若 preflight 被擋,瀏覽器只會顯示網路錯誤而不是鎖定頁,那條「前端認碼跳鎖定頁」的契約就斷了)。建議:

  1. 維持現狀但明列於部署文件:告訴部署方「鎖定殼會對容器網路公開機器指紋與事件編號」,並強調 ports: 不可留。這是成本最低、風險降幅最大的一項
  2. 若要收斂:把 HTML 頁(給人看的,帶完整資訊)與 JSON(給前端認碼用的,可以只回 error_code 不帶指紋)分開——前端跳鎖定頁只需要 error_code,指紋可以要求從主機 log 或 console 取得。
  3. ThreadingHTTPServer 換成有連線數上限的做法,或在殼前面加簡單的連線計數。

首腦核對註記:工具未報(工具未報,runner 人工查證)。我另外查了 compose 的實際暴露設定才把嚴重度定在 LOW——若只讀套件程式碼會誤判成 MEDIUM 以上。部署設定是這條的關鍵變數:任何把 8000 對外映射的部署,這條就要重新評估。


T1-6(LOW)竄改事件回報會把伺服器 log 最後 50 行往外送

這是什麼問題(白話) 偵測到竄改時,產品會把事件回報給原廠的 License Center。回報內容除了「哪些檔案被改了」,還包含伺服器 log 的最後 50 行——用意是幫忙判斷「服務最後正常運作到什麼時候」,藉此推算竄改發生的時間窗。

問題是:沒有人檢查那 50 行裡有什麼。應用程式的 log 常常會夾帶意料之外的東西——錯誤堆疊裡的連線字串、debug 訊息裡的 token、SQL 全量 log 裡的查詢內容。

另外,回報時會在 HTTP header 帶上 License Center 的 API token,而宿主端這個位址的預設值是明文 httphttp://127.0.0.1:5062)。

出事會怎樣

  • log 尾段若剛好含有密碼或金鑰,會隨事件送到原廠伺服器並存進資料庫。這不是外洩給攻擊者(收件方是原廠),但屬於「敏感資料流到非預期的地方」,且會長期存放
  • 若部署時 LICENSE_ACTIVATION_SERVER_URL 真的設成明文 http://,那 token 與 log 內容會在網路上裸奔

為什麼是 LOW 不是更高:預設值 127.0.0.1 是本機位址,明文傳輸不出這台機器;要出事得部署方把它設成外部的 http 位址。而 log 外洩的收件方是原廠自己,不是攻擊者。

工具對這條的判斷:三位檢查員一致否決了另一個相關的疑慮——「API token 會不會被轉址騙去攻擊者的伺服器」。理由是回報位址由部署設定決定,攻擊者插不了手。我同意這個判斷,所以那條不列入。這裡列的是不同的問題:送出去的內容本身沒有過濾

要先有什麼才打得到

  • 需要先觸發一次竄改事件(回報才會發生)
  • 對「明文傳輸」那半:需要部署方把回報位址設成外部 http 位址

在哪裡

  • jedi_integrity/forensic.py:108-129_log_tail(),取 log 尾端 50 行,無任何內容過濾
  • jedi_integrity/forensic.py:43-44_LOG_TAIL_BYTES = 64 * 1024_LOG_TAIL_LINES = 50
  • jedi_integrity/lc_report.py:69-79 — 送出的 request,header 帶 X-API-Token
  • common/integrity/adapters.py:55_DEFAULT_LC_URL = "http://127.0.0.1:5062"(明文 http,本機位址)
  • 附帶:common/integrity/adapters.py:212-330collect_host_trigger_context() 還會塞登入帳號、來源 IP、活躍 session 清單進去。這些是刻意蒐集的證據,屬設計意圖,但同樣會隨事件外送

怎麼修

  1. forensic.py:_log_tail() 回傳前加一層遮罩:用正規表示式把常見的敏感樣式打碼(password=token=Authorization:secret、長度像金鑰的十六進位字串)。這是低成本、高效益的一步。
  2. lc_report.py:report_tamper_best_effort() 開頭檢查 base_url若不是 https:// 且不是本機位址,就拒送並印警告。目前任何位址都照送。
  3. 部署文件明列:回報位址若指向外部,必須是 https。

首腦核對註記:工具未報這條(工具未報,runner 人工查證)。工具的研究員提出的是相鄰但不同的問題(轉址竊取 token),三票否決,我核對後同意否決。遵守卡片紀律,本報告未印出任何金鑰值,也未讀取任何實際 log 內容——上述判斷全部來自程式碼結構。


5. 卡片「重點看什麼」十項逐項回覆

卡片列了十個要追的點。逐項交代,沒有留白

# 卡片的疑問 結論
能不能讓「打包判定」回 False 來關掉整套保護?force_enforcement 只能開不能關的主張成立嗎? 主張成立,但有另一個形狀的洞。 enforcement_enabled()manifest.py:83-85)確實是打包模式恆回 True,旗標只能加嚴。is_packaged()resource_path.py:26-28)看的是 Nuitka 編譯期注入的 __compiled__ 全域變數,執行期改不動。但卡片預見的「另一種形狀」成立了——GUIDANT_RESOURCE_ROOT 能換掉執法對象,見 T1-3
刪掉 FS 標記檔,服務是不是就正常起來?DB 那一半真的沒人呼叫嗎? 兩者都成立,這是本棒最重要的發現,見 T1-1。 exists() 零呼叫者已跨兩個 repo grep 確認。
used-nonces.json 改成亂碼,用過的解鎖檔能不能再用? 能,但可利用的情境很窄,見 T1-4。 三道關卡(指紋/事件編號/效期)把它壓在 MEDIUM。_MAX_USED_NONCES 滿了丟最舊的不構成獨立風險(被丟的早已過期)。
cryptography 是不是在 thirdparty 層?換掉它的 .so 能不能讓驗章永遠通過? 是,且能,見 T1-2。 已循 build 腳本查證(不是猜):resolve_excluded_closure() 遞迴求相依封閉集合,:365 明列 pdfminer.six → cryptography未實測掉包
鎖定殼公開機器指紋與事件編號有沒有問題?能不能塞爆? 有,但實際暴露面比預期小,見 T1-5(LOW)。 正式部署用 expose 不用 ports,只在容器網路可見。塞爆確實可行(無上限執行緒)但機器已鎖定,損害有限。
回報用明文 http 嗎?token 從哪來?log 尾段會不會夾帶密碼? 預設是明文 http 但指向本機127.0.0.1:5062),不出這台機器;log 尾段確實沒有任何過濾,見 T1-6(LOW)。工具提出的「轉址竊取 token」三票否決,我核對後同意。
register_gunicorn_master 無保護,能不能登記到錯的 pid?有沒有「訊號送不到、自己也沒退出」的組合? 查了,沒有可利用的問題。 登記函式(termination.py:54-67)確實是模組全域無保護,但要呼叫它就得先能在該進程內執行程式碼——已經到這一步的攻擊者不需要繞這條路。宿主的接線正確(main.py:223-225server.pid,在 worker 內執行)。「送不到又沒退出」的組合不存在terminate_process():107-115)把所有送訊號的例外都吞掉,但最後一行 os._exit(1) 在迴圈外、無條件執行,自己一定會死。
改本機時鐘能不能讓過期的解鎖檔復活?expires_at 缺欄位或畸形值走哪條? 理論上能改時鐘,但這條不構成獨立問題。 unlock.py:179 確實用本機 datetime.now() 比對,改時鐘可讓過期檔復活——但能改系統時鐘的人已有 root,直接刪標記檔(T1-1)成本更低,這條在 T1-1 面前沒有額外價值。欄位處理是穩健的:缺欄位或無法解析 → raise UnlockRejected:174-176,走拒絕);"9999-12-31" 是合法 ISO 日期、會被當成未過期,但仍要過指紋、事件編號、nonce 三關,單靠它無法解鎖。naive 時間當 UTC(:177-178)與寫入端(tamper_marker.to_utc_iso())一致,無矛盾。
「先刪 manifest 再走一次匯出流程」會不會讓埋點永久失效? 會,但空窗有上限,判定為「已知取捨」不另列發現。 _init_hotpath_cache()runtime_check.py:220-241)manifest 讀不到時回 [] 並 cache,之後不再重讀hotpath_verify 只在 cache 為 None 時初始化),所以該進程的埋點確實永久變成空轉。但:①埋點本來就定位為「抽查的補強不是主防線」;②4 小時一次的抽查會以完整流程補上判定,且抽查每輪重新讀取+驗章 manifest、不 cache:94 註解明說),所以刪 manifest 會在下一輪抽查被抓到(load_verified_manifest 失敗即判竄改)。空窗上限是一個抽查週期。真正讓這條失效的是 T1-2(驗章工具被掉包後抽查也一起失效),所以修 T1-2 才是關鍵。
開發用啟動殼 dev_app.py:寫死的金鑰、tmp 目錄權限、--keep;CM-1572 的無界解壓真的補齊了嗎? CM-1572 確認已修_bounded_unpack()harness/dev_app.py:95-105)雙重上限(32 MiB base64 字串、16 MiB 解壓後)並檢查 unconsumed_tail:117-134 的驗章走的正是它。無寫死金鑰Harness.__init__:265)每次執行現場產生 Ed25519 金鑰對,不入版控。tmp 目錄tempfile.mkdtemp():255),權限為 0700(僅擁有者可讀),--keep 只是不刪除、不改權限。這支是開發工具、不進出貨產物,本身不構成產品風險。 唯一可提的是它自帶一份與正式產品不同的驗章實作(:107-141),兩份程式碼可能分岔——但那是維護性問題不是安全問題。

6. 這份結果可信到什麼程度

分兩層講,因為這兩件事的可信度差很多。

第一層:「這些問題真的存在嗎?」——高

六條發現全部由我逐檔開啟核對過,每一條都能指到具體的檔名與行號,而且大多有程式碼註解自己佐證(T1-1 的補同步註解自陳「窗口縮到最小」、T1-4 的註解自陳「取寬鬆側」)。跨 repo 的事實(T1-2 的分層、T1-3 的環境變數、T1-5 的部署設定)也都是開檔查證,不是推測。

但沒有任何一條做過實際攻擊驗證。 依卡片紀律,不動任何環境的 /opt/guidant/pki、不觸發真實鎖定,所有繞過路徑都是「讀程式碼+推論」的結果。T1-2 特別建議下一棒在本機造假產物實測——它是六條裡影響最大、也最依賴推論的一條。

第二層:「只有這些嗎?」——低,這一輪遠不能算掃透

三個具體理由:

  1. 工具這一輪幾乎沒有產出。 兩個研究員只提出 2 條候選,三位檢查員投票後兩條都沒過關,工具的正式報告是零發現。本報告六條裡有五條是我人工查出來的,工具只碰到 T1-1 的一部分。這是連續第四個 arc 出現同樣情形。

  2. low 是快篩不是徹查。 沒有清點階段、沒有威脅建模、沒有廣掃,就是一輪讀+一輪投票。

  3. 研究員沒交「哪些檔沒讀完」的自述,所以無法從工具端說明 21 個檔各自被讀到什麼程度。

還沒被涵蓋的面向(建議列入後續規劃):

  • 簽發端(License Center):解鎖檔怎麼簽、憑什麼決定要簽給誰。若簽發只憑機器指紋+事件編號,T1-5 的資訊外洩就要重新評估嚴重度。
  • 驗章實作本身jedi_license_runtime.common.engine):本棒只驗證了「呼叫鏈通到哪裡」,沒有審查 Ed25519 驗章的實作細節。三環境公鑰全編在同一份產物內(總表 §3.1 第 6 項已記)。
  • build 端的 manifest 產生流程:T1-2 揭露了分層判準會直接決定保護範圍,這條產線值得單獨檢視。
  • tests/:不在本次範圍(9 個檔),沒有檢查測試是否真的涵蓋這些繞過路徑。

7. 執行概況

項目 數值
掃描範圍 jedi_integrity/harness/,21 個受版控檔
版本 commit 8aa6f0609c4434c0fa5206d5e99e31cad249646e
工具 Claude Code claude-security plugin v0.11.0
effort / focus low / attack-surface
研究員 派 2 個,回 2 個(零失敗)
候選發現 2 條(去重後仍 2 條)
檢查員投票 三個檢查員對 2 條發現各投一票,6 票全數投出,沒有漏投
投票結果 候選 1:1 票成立 / 2 票否決(未達 3 票中要 2 票的門檻);候選 2:0 票成立 / 3 票否決
工具正式發現 0 條
驗證章(stamp) verification.status: verified(完整跑完,未中斷、未撞額度)
驗證輪次 1 輪,無候選遺失、無未驗項目
執行時間 約 19 分鐘
工具產物 jedi-integrity/CLAUDE-SECURITY-20260910-113825/(含自身 .gitignore,不入版控)
本報告發現 6 條(工具貢獻 1 條的一部分,其餘 5 條為 runner 人工查證)

8. 建議的下一步

按投資報酬率排序:

  1. 修 T1-2(把驗章函式庫納入開機檢查)——它是唯一一條「一旦得手,整套機制永久失效」的路徑,且會連帶讓 T1-3、⑨的空窗問題失去價值。
  2. 修 T1-3(打包模式忽略 GUIDANT_RESOURCE_ROOT)——改動極小(一個 if 的順序),風險極低。
  3. 決定 T1-1 怎麼處理:補上資料庫檢查,或把文件改成與程式一致。現狀最糟——文件宣稱有兩道鎖,維護者因此不會去補。
  4. 修 T1-4(區分「檔案不存在」與「內容毀損」)與 T1-6 的 log 遮罩——都是小改動。
  5. T1-5 先寫進部署文件即可,程式改動可延後。
  6. 實測驗證 T1-2:在本機造一份假產物、掉包驗章函式庫,確認繞過真的成立。

本報告依 security-scan-lead skill 第十節白話規則撰寫。所有發現均為讀程式碼與 build 腳本推論的結果,未在任何環境實際執行攻擊,未觸碰任何環境的 /opt/guidant/pki,未印出任何金鑰值。